Webhooks
Verifying the subscription challenge, validating payload signatures, responding fast, and handling retries, duplicates and out-of-order delivery.
Subscription verification
Before Meta delivers events, it verifies ownership of the endpoint with a GET request carrying a challenge. Echo the challenge back only when the verify token matches.
export async function GET(request: Request) {
const params = new URL(request.url).searchParams
const mode = params.get('hub.mode')
const token = params.get('hub.verify_token')
const challenge = params.get('hub.challenge')
const expected = process.env.META_WEBHOOK_VERIFY_TOKEN
if (mode !== 'subscribe' || !token || !expected) {
return new Response('Forbidden', { status: 403 })
}
if (!timingSafeEqual(token, expected)) {
return new Response('Forbidden', { status: 403 })
}
// Echo the challenge verbatim as plain text.
return new Response(challenge ?? '', { status: 200 })
}Payload signature validation
Every event delivery carries an HMAC signature header computed over the exact raw request body using the app secret. An unsigned or incorrectly signed payload must be rejected before it is parsed as trusted input.
import crypto from 'node:crypto'
export async function POST(request: Request) {
// Read the RAW body. Parsing first and re-serialising will break the HMAC.
const raw = await request.text()
const header = request.headers.get('x-hub-signature-256')
if (!header?.startsWith('sha256=')) {
return new Response('Unauthorized', { status: 401 })
}
const expected = crypto
.createHmac('sha256', process.env.META_APP_SECRET!)
.update(raw, 'utf8')
.digest('hex')
const received = header.slice('sha256='.length)
const a = Buffer.from(received, 'hex')
const b = Buffer.from(expected, 'hex')
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return new Response('Unauthorized', { status: 401 })
}
const event = JSON.parse(raw)
await enqueue(event)
// Acknowledge immediately; process asynchronously.
return new Response('OK', { status: 200 })
}- Compute the HMAC over the raw bytes, before any JSON parsing or body transformation.
- Compare digests with a constant-time comparison, never with string equality.
- Reject any request with a missing, malformed or mismatched signature.
- Confirm the current signature header name and algorithm in the official Meta developer documentation before production deployment.
Responding quickly
Meta expects an acknowledgement within a short timeout. Do the minimum synchronously: verify the signature, persist the payload, return 200. All business logic happens afterwards.
- Verify the signature.
- Write the raw event to durable storage or a queue.
- Return 200 immediately.
- Process the event in a background worker with its own retry policy.
Retries, duplicates and ordering
Delivery is at-least-once. The same event can arrive more than once, and related events can arrive out of order. Handlers must be idempotent.
- Deduplicate on a stable identifier
- Record the provider event identifier with a unique constraint. If the insert conflicts, acknowledge and stop; do not process twice.
- Make writes idempotent
- Prefer upserts keyed by provider identifier over blind inserts, so a replay cannot create a duplicate conversation or contact.
- Guard against stale updates
- Compare event timestamps before overwriting a record. Ignore an event that is older than the state you already hold.
- Never rely on arrival order
- A message edit can arrive before the message it edits. Reconcile using identifiers and timestamps rather than sequence of receipt.
const inserted = await db
.insertInto('webhook_events')
.values({ providerEventId: event.id, payload: event })
.onConflict((c) => c.column('providerEventId').doNothing())
.executeTakeFirst()
if (inserted.numInsertedRows === 0n) {
// Already seen. Acknowledge without reprocessing.
return
}
await applyEvent(event)Failure handling
- Return 401 for a signature failure and 403 for a verification failure; do not retry these yourself.
- Retry transient processing failures with exponential backoff and jitter in the worker, not in the request handler.
- Move events that exhaust their retries to a dead-letter store with the full payload for inspection.
- Alert on a rising dead-letter rate, since it usually indicates a schema change rather than an isolated fault.
- Log a correlation identifier per event so a single delivery can be traced end to end.