Webhook primitives reference
The low-level verifyWebhookChallenge and validateWebhookSignature primitives for wiring a custom HTTP boundary.
active · reviewed 2026-05-19
Simulate an inbound envelope end to end in the playground.
These are the low-level webhook primitives. Most applications should start with createWebhookAdapter; use these when you need to wire your own HTTP boundary.
Recommended lifecycle
- Receive the raw HTTP request body without modification.
- Verify Meta's GET challenge with
verifyWebhookChallenge(...), or verify POST signatures withvalidateWebhookSignature(...). - Parse JSON only after signature verification succeeds.
- Normalize the parsed body with
normalizeWebhookEnvelope(...)from@wats/core. - Dispatch each
TypedUpdatethroughWhatsApp.dispatch(...)or a compatible router/facade.
createWebhookAdapter performs these steps for Bun, Node, Fetch/Workers, and Deno-style runtimes.
mTLS CA transition boundary
Meta's webhook delivery may sit behind an optional infrastructure-level mTLS client certificate check as Meta transitions outbound client certificate validation to the Meta-owned root meta-outbound-api-ca-2025-12.pem. That check is separate from WATS application behavior.
WATS always treats X-Hub-Signature-256 as the app-level HMAC verification boundary: validateWebhookSignature(...) computes HMAC-SHA256 over the exact raw body bytes using your app secret after the request reaches WATS. Infrastructure-level mTLS client certificate validation, if you use it, belongs at the TLS terminator, load balancer, reverse proxy, CDN/edge product, or platform ingress before the WATS handler is invoked.
WATS does not vendor meta-outbound-api-ca-2025-12.pem, does not embed certificate PEM material, and does not configure your infrastructure. Operators who enable mTLS must obtain the CA from Meta's authoritative distribution channel, keep it updated, and configure their own ingress. App-level HMAC (X-Hub-Signature-256) remains required even with mTLS enabled.
verifyWebhookChallenge(input)
import { verifyWebhookChallenge } from "@wats/http";
const result = await verifyWebhookChallenge({
mode: url.searchParams.get("hub.mode"),
challenge: url.searchParams.get("hub.challenge"),
verifyToken: url.searchParams.get("hub.verify_token"),
expectedVerifyToken: process.env.WATS_VERIFY_TOKEN!
});Input:
mode: string | null | undefinedchallenge: string | null | undefinedverifyToken: string | null | undefinedexpectedVerifyToken: stringcrypto?: CryptoProvider
Returns:
- success:
{ ok: true, challenge: string } - failure:
{ ok: false, error: { code, status, message } }
Failure codes:
invalid_expected_verify_tokeninvalid_modeinvalid_verify_tokenmissing_challengecrypto_provider_unavailable
Verify-token comparison uses the CryptoProvider timing-safe comparison after a length gate.
validateWebhookSignature(input)
import { validateWebhookSignature } from "@wats/http";
const result = await validateWebhookSignature({
appSecret: process.env.WATS_APP_SECRET!,
rawBody,
signatureHeader: request.headers.get("x-hub-signature-256")
});Input:
appSecret: stringrawBody: string | Uint8Array | ArrayBuffer | ArrayBufferViewsignatureHeader: string | null | undefinedcrypto?: CryptoProvider
Returns:
- success:
{ ok: true } - failure:
{ ok: false, error: { code, message } }
Failure codes:
invalid_app_secretinvalid_raw_bodymissing_signatureinvalid_signature_formatsignature_mismatchcrypto_provider_unavailable
Accepted body types:
| Input type | Accepted | Notes |
|---|---|---|
string | yes | UTF-8 encoded. |
Uint8Array | yes | Preserves bytes. |
ArrayBuffer | yes | Wrapped as Uint8Array. |
ArrayBufferView | yes | Preserves byteOffset / byteLength. |
Node/Bun Buffer | yes | Treated as ArrayBufferView. |
SharedArrayBuffer-backed view | no | Rejected to avoid concurrent mutation during HMAC. |
| detached buffer/view | no | Rejected with typed invalid_raw_body. |
null, undefined, object, number, boolean, array, symbol, function | no | Rejected with typed invalid_raw_body. |
The signature header must match sha256=<64 lowercase hex chars>. HMAC-SHA256 is computed over the exact raw body bytes.
normalizeWebhookEnvelope(rawEnvelope, options?)
After signature verification and JSON parsing, normalize the parsed envelope:
import { normalizeWebhookEnvelope } from "@wats/core";
const normalized = normalizeWebhookEnvelope(parsedBody, {
maxEventsPerEnvelope: 1000
});
for (const update of normalized.updates) {
await wa.dispatch(update);
}normalizeWebhookEnvelope emits TypedUpdate values and records skipped malformed nested entries. Envelope-level shape errors throw WebhookNormalizationError; nested malformed entries/changes are captured in skipped[] when possible.
See the Webhook Normalizer for the complete normalization contract.
Calling updates
normalizeWebhookEnvelope(...) promotes field: "calls" changes into typed calling updates when the payload shape is stable and safe:
value.calls[].event === "connect"→kind: "callConnect"value.calls[].event === "terminate"→kind: "callTerminate"value.statuses[].status ∈ { "RINGING", "ACCEPTED", "REJECTED" }→kind: "callStatus"
Every emitted calling update carries updateId, wabaId, phoneNumberId, receivedAt, rawChange, and a call or callStatus payload with the guarded wire fields. Call-button and deep-link payloads surface as camelCase ctaPayload and deeplinkPayload when Meta sends cta_payload / deeplink_payload. Malformed nested calling objects, missing/unsafe ids, unsafe metadata.phone_number_id, unsupported call events, unsupported call statuses, and accessor-backed nested fields are recorded in skipped[] with reason: "malformed_field"; they do not throw host errors. Live webhook fixtures remain credential-gated.
v24/v25 webhook deltas
The credential-free webhook typing/normalizer contract tracks recent WhatsApp Cloud API changes:
- message statuses include
playedfor voice playback receipts; status.conversationremains optional, and in v24+ status conversation is absent by default outside special conversation windows;- inbound media webhooks may include
media.url, surfaced as publicmedia.urlon image/video/audio/document/sticker references; - unsupported messages preserve details such as
unsupported.type(including removedrequest_welcome/ welcome-message shapes),title, anddescriptionwhile preservingraw; - Coexistence/account events
PARTNER_REMOVED,account_offboarded, andaccount_reconnectedare promoted to account updates, withdisconnectionInfomapped fromdisconnection_infowhen present.
Live Meta webhook fixtures remain credential-gated; repository tests use synthetic envelopes only.
Webhook media ID retention
Webhook media IDs are downloadable for 7 days after 2025-10-09 (the WhatsApp changelog dated 2025-09-24 records the reduction from 30 days). If a handler needs media beyond that window, download it through the WATS media helpers and persist it in your own durable storage. WATS normalizes inbound media IDs and optional media.url values, but it does not automatically persist webhook media.
When to use the adapter instead
Prefer createWebhookAdapter unless you need custom HTTP behavior the adapter cannot express. The adapter already provides:
- GET challenge handling
- POST signature verification
- JSON parse failure mapping
- typed normalization
- dispatch through a facade-shaped object
- body-size cap enforcement
- Bun, Node, and Fetch wrappers
- status-code taxonomy
See the Webhook Adapter and the deploy guides.