wats.sh
Reference

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.

  1. Receive the raw HTTP request body without modification.
  2. Verify Meta's GET challenge with verifyWebhookChallenge(...), or verify POST signatures with validateWebhookSignature(...).
  3. Parse JSON only after signature verification succeeds.
  4. Normalize the parsed body with normalizeWebhookEnvelope(...) from @wats/core.
  5. Dispatch each TypedUpdate through WhatsApp.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 | undefined
  • challenge: string | null | undefined
  • verifyToken: string | null | undefined
  • expectedVerifyToken: string
  • crypto?: CryptoProvider

Returns:

  • success: { ok: true, challenge: string }
  • failure: { ok: false, error: { code, status, message } }

Failure codes:

  • invalid_expected_verify_token
  • invalid_mode
  • invalid_verify_token
  • missing_challenge
  • crypto_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: string
  • rawBody: string | Uint8Array | ArrayBuffer | ArrayBufferView
  • signatureHeader: string | null | undefined
  • crypto?: CryptoProvider

Returns:

  • success: { ok: true }
  • failure: { ok: false, error: { code, message } }

Failure codes:

  • invalid_app_secret
  • invalid_raw_body
  • missing_signature
  • invalid_signature_format
  • signature_mismatch
  • crypto_provider_unavailable

Accepted body types:

Input typeAcceptedNotes
stringyesUTF-8 encoded.
Uint8ArrayyesPreserves bytes.
ArrayBufferyesWrapped as Uint8Array.
ArrayBufferViewyesPreserves byteOffset / byteLength.
Node/Bun BufferyesTreated as ArrayBufferView.
SharedArrayBuffer-backed viewnoRejected to avoid concurrent mutation during HMAC.
detached buffer/viewnoRejected with typed invalid_raw_body.
null, undefined, object, number, boolean, array, symbol, functionnoRejected 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 played for voice playback receipts;
  • status.conversation remains optional, and in v24+ status conversation is absent by default outside special conversation windows;
  • inbound media webhooks may include media.url, surfaced as public media.url on image/video/audio/document/sticker references;
  • unsupported messages preserve details such as unsupported.type (including removed request_welcome / welcome-message shapes), title, and description while preserving raw;
  • Coexistence/account events PARTNER_REMOVED, account_offboarded, and account_reconnected are promoted to account updates, with disconnectionInfo mapped from disconnection_info when 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.

On this page