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 route — express.json is never mounted globally because the raw body is needed for signature verification.
Signature verification
Per architecture.md §6:
- The request body is read into a
Buffer. HMAC-SHA256(rawBody, WA_APP_SECRET)is computed.- The result is compared in constant time (
crypto.timingSafeEqual) againstX-Hub-Signature-256. - 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):
- Derive a stable
event_id:messages[]:<entry.id>:<change-index>:<wamid>statuses[]:<entry.id>:<change-index>:status:<wamid>:<status>
INSERT INTO inngest_idempotency (event_id, outcome) VALUES (..., 'pending') ON CONFLICT (event_id) DO NOTHING RETURNING event_id.- If
rowCount === 0, this is a duplicate — short-circuit, auditwebhook_duplicate(Phase 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 messageStatusUpdateNormalised— 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 monotoniclast_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:
- Receive + verify signature (synchronous).
- Emit
wa/webhook.receivedwithidempotencyKey = <derived event_id>. - 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).