8. Webhooks
Full endpoint reference: §6.1 WhatsAppWebhookController.
8.1 GET Verification
Meta calls GET /api/v1/notifications/webhook?hub.mode=subscribe&hub.verify_token=...&hub.challenge=... once
whenever a webhook URL is configured or re-verified in the Meta App Dashboard. The controller compares
hub.verify_token against the configured VerifyToken (see §4 Configuration) and
echoes hub.challenge back verbatim on a match, or returns 403 otherwise.
8.2 POST Webhook Receive
8.3 HMAC Signature Validation
WhatsAppSignatureValidator.Validate(byte[] body, string signatureHeader):
- Requires
WhatsAppCloudApiOptions.AppSecretto be configured — if empty, validation cannot proceed (governed bySignatureValidationEnabled; see §11 Security). - The
X-Hub-Signature-256header must start with"sha256="(case-insensitive); the remainder is hex-decoded. - Computes
HMACSHA256(AppSecret)over the raw request body bytes (not the parsed/re-serialized JSON — byte-for-byte, since re-serialization can silently change field order or whitespace and break the signature). - Compares the computed and provided digests with
CryptographicOperations.FixedTimeEquals— a constant-time comparison that prevents timing side-channel attacks against the signature check.
8.4 AppSecret and VerifyToken
Two distinct secrets, easy to confuse:
| Secret | Purpose | Used by |
|---|---|---|
VerifyToken | A one-time shared value only used during the GET verification handshake | GET webhook |
AppSecret | The HMAC key used on every POST webhook body | POST webhook (via WhatsAppSignatureValidator) |
Neither value is ever logged; LogRawPayload (default false) must remain off in production since raw
webhook bodies can contain customer message content.
8.5 Payload Structure
Meta's webhook shape (entry[].changes[].value), the structure WhatsAppWebhookParser parses:
{
"object": "whatsapp_business_account",
"entry": [{
"id": "{businessAccountId}",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": { "display_phone_number": "...", "phone_number_id": "..." },
"contacts": [{ "profile": { "name": "..." }, "wa_id": "..." }],
"messages": [ { "from": "...", "id": "...", "timestamp": "...", "type": "text", "text": { "body": "..." } } ],
"statuses": [ { "id": "...", "recipient_id": "...", "status": "delivered", "timestamp": "..." } ]
}
}]
}]
}
contacts is always a sibling of messages/statuses, never nested inside an individual message — see
§8.7 below.
8.6 Message Status
statuses[] entries parse into WhatsAppWebhookStatusDto (MessageId, RecipientId, Status,
TimestampUnix, ErrorCode, ErrorMessage). Status values follow Meta's own vocabulary
(sent/delivered/read/failed), mapped to the ERP's WhatsAppMessageStatus enum by
WhatsAppMetaReceiptMapper. See §5.9 Delivery Status Update.
8.7 Incoming Message and the ProfileName Parsing Fix
messages[] entries parse into WhatsAppWebhookMessageDto (MessageId, From, ProfileName, Type,
TextBody, MediaId, MediaMimeType, MediaSha256, PhoneNumberId, DisplayPhoneNumber,
RawPayloadJson, TimestampUnix).
The fix (Phase 1 Runtime Consolidation): prior versions of the parser looked for a contacts property
inside each individual message object — a property Meta never places there. contacts is always a sibling
array under value, and must be matched to a message by wa_id == message.from. Because the old code's
condition was always false, ProfileName was always null in production regardless of what Meta sent.
The corrected parser builds a wa_id → profile name lookup once per webhook change event
(ExtractProfileNames(value)), then resolves each message's ProfileName from that lookup by its from
field — correctly handling multiple contacts/messages arriving in a single webhook batch, and safely
returning null (never throwing) when no contacts array exists or no matching wa_id is found. This fix
shipped in library version 1.0.1 — see §12 Testing for the regression tests added.
8.8 Failure Handling
The controller always returns 200 OK for a structurally valid, signature-valid request, regardless of
whether individual message/status items fail during processing — each item is processed in its own
try/catch. This deliberately avoids Meta's automatic retry-on-non-2xx behavior triggering duplicate delivery
storms for a single bad item in an otherwise-valid batch. Only a signature-validation failure returns
403.
