Webhook payloads — Meta inbound shapes we accept
The webhook handler (src/webhook/normalise.ts) walks Meta’s nested envelope and produces a flat array of typed events. This file documents the shapes we recognise.
Envelope
{
"object": "whatsapp_business_account",
"entry": [
{
"id": "<WABA-id-or-arbitrary-string>",
"changes": [
{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {
"phone_number_id": "<numeric WABA phone id>",
"display_phone_number": "+447700900000"
},
"contacts": [ /* zero or more */ ],
"messages": [ /* zero or more */ ],
"statuses": [ /* zero or more */ ]
}
}
]
}
]
}Fields we don’t read are preserved verbatim in messages.raw after sensitive-key sanitisation.
Message types
text
{ "id": "wamid.…", "from": "447700900456", "timestamp": "1730000000", "type": "text", "text": { "body": "hello" } }Persisted as body = "hello".
image, document, audio, video, sticker
{
"id": "wamid.…", "from": "…", "type": "image",
"image": { "id": "media-id", "mime_type": "image/jpeg", "sha256": "...", "caption": "optional" }
}Persisted as body = caption (when present); media itself is downloaded in Phase 6.
reaction
{ "id": "wamid.…", "from": "…", "type": "reaction", "reaction": { "message_id": "wamid.TARGET", "emoji": "🔥" } }Persisted as body = emoji.
interactive
{
"id": "wamid.…", "from": "…", "type": "interactive",
"interactive": { "type": "button_reply", "button_reply": { "id": "btn-1", "title": "Yes" } }
}Persisted as body = "Yes". List replies follow the same shape with list_reply.
context
Any message may carry context: { id: "wamid.PARENT", from: "..." } indicating a reply. We record messages.reply_to_wamid = wamid.PARENT.
Status updates
{
"id": "wamid.OUTBOUND",
"recipient_id": "447700900456",
"status": "delivered", // 'sent' | 'delivered' | 'read' | 'failed'
"timestamp": "1730000020",
"errors": [ { "code": 131000, "title": "..." } ] // only on 'failed'
}We UPDATE messages SET status, error_code WHERE wa_message_id = .... If no matching row exists, the update is a silent no-op.
Event id derivation
A stable event_id is derived for inngest_idempotency:
| Event | event_id |
|---|---|
| message | <entry.id>:<change-index>:<wamid> |
| status | <entry.id>:<change-index>:status:<wamid>:<status> |
Two different status transitions for the same wamid (e.g. sent then delivered) produce two distinct event ids, so neither is dropped by the dedupe layer.
Sanitisation
Before persisting the raw envelope, every field whose key matches /(token|secret|signature|password)/i is replaced with '<redacted>' (sanitiseForStorage). This guards against a future Meta payload addition that includes a sensitive field we forget to scrub.