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.
The wire contract
Section titled “The wire contract”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.
The payload
Section titled “The payload”FeedUpdatedPayload:
interface FeedUpdatedPayload { event: "feed.updated"; siteId: string; buildId: string;}Verify on receipt
Section titled “Verify on receipt”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.
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).
Sign a payload
Section titled “Sign a payload”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.
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,});What to reach for next
Section titled “What to reach for next”- 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.