Skip to content

Webhook helpers

When your site’s feed rebuilds, Ghost Writr POSTs a feed.updated body to your registered URL. Each delivery carries three headers — Webhook-Id, Webhook-Timestamp, and Webhook-Signature — and the signature is an HMAC over the id, timestamp, and raw body together, so it’s replay-safe. Verify it, then bust your cache and re-fetch. The same helpers sign and verify, so the signature matches by construction. See instant updates.

Every delivery carries these three headers:

  • Webhook-Id: <delivery id> — also the idempotency key; dedupe retries on it.
  • Webhook-Timestamp: <unix seconds>
  • Webhook-Signature: v1=<hex> — where <hex> is the HMAC-SHA256, keyed by your secret, of `${id}.${timestamp}.${rawBody}`.

Signing the id and timestamp alongside the body gives replay protection: verifyWebhook rejects a delivery whose timestamp is outside the ±300s tolerance window, and the stable Webhook-Id lets you dedupe a retried delivery. rawBody is the exact raw request-body bytes.

FeedUpdatedPayload:

interface FeedUpdatedPayload {
event: "feed.updated";
siteId: string;
buildId: string;
}

verifyWebhook(secret, rawBody, headers) returns a Promise<boolean>. Pass it the whole request headers object — it reads Webhook-Id, Webhook-Timestamp, and Webhook-Signature itself, rejects a timestamp outside the ±300s tolerance window (replay defense), and compares the v1 HMAC in constant time (so a wrong signature can’t be discovered byte-by-byte via timing). Any missing, stale, or mismatched input returns false.

verify a webhook (any fetch runtime)
import { verifyWebhook } from "@ghostwritr/feed";
export async function POST(request: Request) {
const raw = await request.text(); // raw body, BEFORE JSON.parse
// verifyWebhook reads Webhook-Id / Webhook-Timestamp / Webhook-Signature from the headers itself
const ok = await verifyWebhook(process.env.GHOSTWRITR_FEED_SECRET!, raw, request.headers);
if (!ok) return new Response("invalid signature", { status: 401 });
const payload = JSON.parse(raw); // FeedUpdatedPayload
// ...revalidate this site, then re-fetch the feed.
return new Response("ok");
}

verifyWebhook takes the headers object (a Fetch Headers or a plain record), not a single signature string — it pulls all three headers out for you. Tune the replay window with opts.toleranceSeconds (default 300).

signWebhook(secret, { id, timestamp, rawBody }) returns a Promise<string> — the Webhook-Signature value (v1=<64-char hex>) for a given delivery id, timestamp, and raw body. Ghost Writr signs your real webhooks; you’ll mostly use this to build a fixture in tests. Import the header-name constants rather than hardcoding the strings.

sign for a test
import {
signWebhook,
WEBHOOK_ID_HEADER,
WEBHOOK_TIMESTAMP_HEADER,
WEBHOOK_SIGNATURE_HEADER,
} from "@ghostwritr/feed";
const id = crypto.randomUUID();
const timestamp = Math.floor(Date.now() / 1000);
const body = JSON.stringify({ event: "feed.updated", siteId, buildId });
const signature = await signWebhook(secret, { id, timestamp, rawBody: body }); // "v1=<64-char hex>"
const res = await app.request("/webhooks/ghostwritr", {
method: "POST",
headers: {
[WEBHOOK_ID_HEADER]: id,
[WEBHOOK_TIMESTAMP_HEADER]: String(timestamp),
[WEBHOOK_SIGNATURE_HEADER]: signature,
},
body,
});
  • The concept — how instant updates fit the immutable-snapshot feed. See Instant updates.
  • Re-fetch after verifying — the fetchers that read the new build. See Fetchers.
  • Full surface — every export, typed. See API reference.