Skip to Content

Webhook — Meta inbound delivery

The webhook is the single untrusted ingress into the system. It runs before any authentication, every byte comes from the public internet, and a misstep here is a footgun for the whole platform. The design is dictated by Meta’s delivery semantics: aggressive retries, no rate guarantees, no IP allowlist.

Routes

  • GET /webhook/meta — Meta’s verify-token handshake.
  • POST /webhook/meta — actual delivery (messages, status updates).

Mounted in src/transport/http.ts. The POST route uses express.raw({ type: 'application/json', limit: '5mb' }) only on that routeexpress.json is never mounted globally because the raw body is needed for signature verification.

Signature verification

Per architecture.md §6:

  1. The request body is read into a Buffer.
  2. HMAC-SHA256(rawBody, WA_APP_SECRET) is computed.
  3. The result is compared in constant time (crypto.timingSafeEqual) against X-Hub-Signature-256.
  4. On mismatch, respond HTTP 404 — not 401 or 403. Leaking endpoint existence to scanners is worse than the mild lie.

The verifier (src/webhook/verify-signature.ts) is at 100% line + branch coverage. Every malformed-header path (missing, wrong prefix, wrong length, non-hex) returns false without throwing, so the caller flow stays straightforward.

Idempotency

Meta retries aggressively on 5xx. The dedupe contract is the same one Inngest uses (Phase 3 onwards):

  1. Derive a stable event_id:
    • messages[]: <entry.id>:<change-index>:<wamid>
    • statuses[]: <entry.id>:<change-index>:status:<wamid>:<status>
  2. INSERT INTO inngest_idempotency (event_id, outcome) VALUES (..., 'pending') ON CONFLICT (event_id) DO NOTHING RETURNING event_id.
  3. If rowCount === 0, this is a duplicate — short-circuit, audit webhook_duplicate (Phase 4+).
  4. After processing, UPDATE inngest_idempotency SET outcome = 'processed' | 'failed' WHERE event_id = ....

The same key is reused by Inngest’s idempotencyKey from Phase 3, so retries through either layer collapse to a single processed event.

Normalisation

src/webhook/normalise.ts walks the envelope and produces a typed NormalisedEvent[]. Each event is one of:

  • InboundMessageNormalised — a user message
  • StatusUpdateNormalised — delivery / read / failed transition for one of our outbound messages

Supported message types: text, image, document, audio, video, sticker, reaction, interactive. Unknown types are persisted as messageType: 'unknown' with the original payload preserved in messages.payload so we never silently drop traffic.

Sanitisation of messages.raw

Before persisting the raw envelope to messages.raw, every field whose key matches /(token|secret|signature|password)/i is replaced with the literal string '<redacted>' via sanitiseForStorage. This is belt-and-braces — Meta does not currently send such fields, but the sanitiser fires automatically against a future addition.

Persistence

On a valid + new event:

  • Inbound messages: client_id = NULL (number-owned), direction = 'inbound', status = 'received'. Contact is upserted by (phone_number_id, wa_id) so the same human across multiple inbound messages stays one row with monotonic last_seen_at.
  • Status updates: UPDATE messages SET status = ..., error_code = ... WHERE wa_message_id = .... If no matching row exists (e.g. status for a wamid we never persisted), the update silently no-ops.

Both write paths are wrapped in try { … } catch (err) { logger.warn(...) } so a single bad event in a batch doesn’t fail the whole envelope and trigger an unnecessary Meta retry.

Phase-3 plan

The webhook handler will move from synchronous writes to dispatching an Inngest event:

  1. Receive + verify signature (synchronous).
  2. Emit wa/webhook.received with idempotencyKey = <derived event_id>.
  3. Return 200 in milliseconds.

The process-message Inngest function (Phase 3) does the normalise + persist + notify work durably across restarts. Until Phase 3 lands, the work runs in the request handler.

What this file does not do

  • IP allowlisting (Meta doesn’t publish stable source IPs; signature is cryptographically sufficient).
  • Authentication beyond the signature (the request is the authentication).
  • Rate limiting (Meta won’t deliver faster than ~50/s per number in practice; revisit if abuse appears).