Skip to main content
Version: Latest

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):

  1. Requires WhatsAppCloudApiOptions.AppSecret to be configured — if empty, validation cannot proceed (governed by SignatureValidationEnabled; see §11 Security).
  2. The X-Hub-Signature-256 header must start with "sha256=" (case-insensitive); the remainder is hex-decoded.
  3. 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).
  4. 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:

SecretPurposeUsed by
VerifyTokenA one-time shared value only used during the GET verification handshakeGET webhook
AppSecretThe HMAC key used on every POST webhook bodyPOST 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.