Receiving & verifying webhooks

Verify Social Fetch webhook signatures in Next.js, Hono, and Express — including the raw-body gotcha that breaks verification.

Every monitor event is a signed POST. Verify the signature before trusting the body. Anyone can guess your URL; only Social Fetch (or someone with your signing secret) can produce a valid signature.

The request

text
  • socialfetch-event-id — stable per event; same across retries and manual redeliveries. Use this to dedupe (see Delivery, retries & idempotency).
  • socialfetch-delivery-id — unique per attempt. Useful for support, not for dedup.
  • socialfetch-signaturet={unix seconds},v1={hex hmac}. A second v1= appears only during secret rotation. Verification succeeds if either signature matches.

Body (JSON event envelope):

json

data.items uses the same item schema as the underlying endpoint. If you already parse GET /v1/twitter/profiles/{handle}/tweets, you already know this shape.

The raw-body gotcha

Signature is over the exact raw bytes

The signature is computed over the literal request body bytes, not a re-serialized object. If your framework parses the JSON before your handler runs, and you verify against JSON.stringify(parsedBody), verification fails — key order and whitespace don't round-trip. Read the raw body first, verify, then JSON.parse (or let constructEvent do both).

Verify with @socialfetch/sdk/webhooks

Dependency-free verification on Web Crypto. Runs on Node 18+, edge runtimes, Deno, and Bun.

$npm install @socialfetch/sdk

Two functions:

  • verifySignature({ payload, signatureHeader, secret, toleranceSeconds? })Promise<{ ok: true } | { ok: false; reason }>. Never throws for a bad signature.
  • constructEvent({ payload, signatureHeader, secret, toleranceSeconds? }) → verifies, then parses and returns the typed event envelope. Throws SocialFetchWebhookVerificationError on a bad signature.

toleranceSeconds defaults to 300 (5 minutes). Signatures older or newer than that are rejected as stale_timestamp (replay protection). Keep the server clock NTP-synced.

reason is "malformed_header", "stale_timestamp", or "signature_mismatch".

Framework handlers

request.text() gives you the raw body before Next.js does anything else with it — that's the one rule that matters here.

If express.json() runs globally

If app.use(express.json()) runs ahead of your routes, register the webhook route (with express.raw()) before that middleware, or scope express.json() so it skips the webhook path. Once express.json() has consumed the stream, the original bytes are gone.

Prefer a boolean over a throw?

Use verifySignature when you'd rather branch than catch:

typescript

Secret rotation

POST /v1/webhook-endpoints/{id}/rotate-secret is zero-downtime. The new secret becomes primary immediately; the old secret stays as a second v1= entry in socialfetch-signature for 24 hours. Both verifySignature and constructEvent accept if any v1= matches.

Where to go next

On this page