wats.sh
Reference

Webhook Adapter

The @wats/http runtime-neutral WebhookAdapter and its Bun, Node, and Fetch wrappers.

live-validated

WebhookAdapter

The WebhookAdapter is @wats/http's runtime-neutral HTTP adapter layer. Each request passes through four stages:

  1. Verify X-Hub-Signature-256 via the signature primitives.
  2. Normalize the body via normalizeWebhookEnvelope.
  3. Dispatch the resulting TypedUpdate values through a facade-shaped dispatch() method.
  4. Return a response with the correct status code.

The adapter ships in three shapes, all backed by a single runtime-neutral core:

WrapperRuntimeSubpath export
createWebhookAdapterany (the core)@wats/http/webhookAdapter
createFetchWebhookHandlerWorkers / Deno / Bun@wats/http/adapters/fetch
createBunWebhookServerBun@wats/http/adapters/bun
createNodeWebhookHandlerNode@wats/http/adapters/node

Every adapter wrapper is a thin marshalling layer. The verification, normalization, dispatch, and status-code decisions all live in the core — so behavior is identical across runtimes.

Quick start

import {
  createWebhookAdapter,
  createFetchWebhookHandler
} from "@wats/http";
import { createWhatsApp } from "@wats/core";

const wa = createWhatsApp({
  accessToken: process.env.WATS_ACCESS_TOKEN!,
  phoneNumberId: process.env.WATS_PHONE_NUMBER_ID!,
});

// Echo inbound text messages back to the sender.
wa.onMessage(async (ctx) => {
  const message = ctx.update.message;
  if (message.type !== "text") return;
  await wa.sendText({ to: message.from, text: message.text.body });
});

const adapter = createWebhookAdapter({
  verifyToken: process.env.WATS_VERIFY_TOKEN!,
  appSecret: process.env.WATS_APP_SECRET!,
  whatsapp: wa,
  maxBodyBytes: 1_048_576,
  logger: (event) => console.log(event.type)
});

// Edge runtime (Cloudflare Workers / Deno / Bun fetch):
const fetchHandler = createFetchWebhookHandler(adapter);
export default { fetch: fetchHandler };

Runtime-neutral core: createWebhookAdapter

export function createWebhookAdapter(
  config: WebhookAdapterConfig
): WebhookAdapter;

export interface WebhookAdapter {
  handle(request: WebhookRequest): Promise<WebhookResponse>;
}

WebhookAdapterConfig

FieldTypeRequiredNotes
verifyTokenstringyes1..512 chars, not whitespace-only, no CR / LF / NUL bytes
appSecretstringyesnon-empty, not whitespace-only, no CR / LF / NUL bytes
whatsappobject with dispatch(update) methodyesa WhatsApp facade or any structural match
cryptoProviderCryptoProvider (from @wats/crypto)noauto-selected when omitted
maxBodyBytespositive integernodefault 1_048_576 (1 MiB); enforced at READ time by Node + Fetch wrappers
logger(event) => voidnoper-stage observability hook

All config validation happens at construction time. Invalid input throws WebhookAdapterConfigError with a taxonomized .code:

CodeCause
invalid_configconfig is null or not an object
invalid_verify_tokenmissing / non-string / empty / whitespace-only / CR LF NUL / too long
invalid_app_secretmissing / non-string / empty / whitespace-only / CR LF NUL
invalid_whatsappmissing dispatch() method
invalid_crypto_providermissing hmacSha256 / timingSafeEqual
invalid_max_body_bytesnon-positive, non-integer, non-finite
invalid_loggernot a function

WebhookAdapterConfigError is a plain Error subclass — it is NOT a TypeError. Narrow via instanceof WebhookAdapterConfigError and dispatch on .code.

WebhookRequest / WebhookResponse

export interface WebhookRequest {
  readonly method: string;                        // "GET" | "POST" | ...
  readonly url: string;                           // absolute URL
  readonly headers: Headers;                      // WHATWG Headers
  readonly body: ArrayBuffer | ArrayBufferView | null; // raw bytes (POST only)
}

export interface WebhookResponse {
  readonly status: number;
  readonly headers: Record<string, string>;
  readonly body: string | Uint8Array;
}

The body field accepts any ArrayBufferView (Uint8Array, DataView, typed-array subclasses) or a raw ArrayBuffer. A runtime guard in handleDispatch enforces this at request time — a caller slipping a string / number / plain-object / Blob through JS type erasure is rejected with 400 invalid_request_body rather than silently coerced to empty bytes (which would leak to 401 signature_mismatch).

The core operates exclusively on these WHATWG-shaped values, so it has zero runtime dependency on node:http, Bun's Server, or any specific framework. Adapters translate.

Status-code taxonomy

StatusMeaning
200valid GET verify (body = challenge); valid POST dispatched
400malformed JSON body; malformed signature header; malformed verify query; body type outside the declared ArrayBuffer / ArrayBufferView / null union (invalid_request_body)
401missing signature; invalid signature; wrong verify token
405method other than GET or POST (sets canonical Allow: GET, POST)
413POST body exceeds maxBodyBytes (enforced at READ time by Node + Fetch wrappers; see "Body size enforcement")
500unexpected adapter-internal failure (should be rare; logged)

Dispatch-error semantics

A handler failure inside facade.dispatch() is isolated — the adapter STILL returns 200. Once the signature is verified and the envelope normalized, the event is received from an HTTP perspective. A non-200 would make Meta retry the delivery, flooding you with duplicate events over a bug that isn't a transport failure.

Handler errors are surfaced via logger({ type: "error", stage: "dispatch", error }) and via the DispatchReport.errors array the facade's own observer can consume (see the router reference).

Logger hook

export type WebhookAdapterEvent =
  | { type: "request_received"; method: string; url: string }
  | { type: "signature_verified"; success: boolean }
  | { type: "body_normalized"; updates: number; skipped: number }
  | { type: "dispatched"; updates: number }
  | { type: "response_sent"; status: number }
  | { type: "error"; stage: string; error: unknown };

Events fire in lifecycle order. A throwing logger is swallowed — a bad observability hook cannot crash the webhook response path.

Adapter wrappers

createFetchWebhookHandler(adapter)

import { createFetchWebhookHandler } from "@wats/http";
const handler = createFetchWebhookHandler(adapter);
// (request: Request) => Promise<Response>

Pure WinterCG: takes a WHATWG Request, returns a Response. The module contains zero static node:* imports — structural tests enforce that invariant (see "Edge-runtime safety").

Use this directly under:

  • Cloudflare Workers (export { fetch })
  • Deno (Deno.serve(handler))
  • Bun's fetch handler shape
  • Any edge runtime implementing WinterCG

createBunWebhookServer(adapter, options?)

import { createBunWebhookServer } from "@wats/http";
const server = createBunWebhookServer(adapter, {
  port: 8787,
  hostname: "0.0.0.0"
});
// server.port, server.hostname, server.stop(true)

Thin wrapper over Bun.serve. Throws if the Bun global is unavailable (e.g. plain Node).

createNodeWebhookHandler(adapter)

import { createServer } from "node:http";
import { createNodeWebhookHandler } from "@wats/http";

const handler = createNodeWebhookHandler(adapter);
const server = createServer((req, res) => {
  handler(req, res).catch((err) => { res.statusCode = 500; res.end(); });
});
server.listen(3000);

Returns a (req, res) => Promise<void> compatible with Node's http.createServer listener shape. Node-specific code is local to this file and uses structural types; it does not static-import node:http (per the workspace policy).

Edge-runtime safety

The fetch adapter is the edge-runtime entry point. It contains no node:* static imports anywhere in its static import graph. Two structural tests pin the guarantee:

  1. packages/testing/tests/workspace-policy.test.ts scans every .ts file under packages/http/src/ and rejects any static import ... from "node:*" or export ... from "node:*".
  2. packages/testing/edge/webhook-adapter.test.ts greps packages/http/src/adapters/fetchAdapter.ts for any "node: / 'node: substring, then runs a full Request → Response round-trip without node:http.

Both assertions run as part of bun test on every commit.

Non-goals

The adapter deliberately does NOT:

  • Rate-limit requests. Runtime / infrastructure concern (Cloudflare rules, Nginx, etc.).
  • Terminate TLS / HTTPS. Runtime concern.
  • Deprecate verifyWebhookChallenge / validateWebhookSignature. The legacy primitives remain available and are reused verbatim by the adapter.
  • Provide retry/backoff for failed downstream dispatches. Handler errors are surfaced via the logger; retry policy lives elsewhere.
  • Integrate media endpoints.
  • Enforce concurrent-request limits. Runtime responsibility.
  • Transform the raw body bytes before signature verification. Body passes VERBATIM into the HMAC verifier.

Body size enforcement

The adapter core enforces maxBodyBytes via bodyByteLength(body) > maxBodyBytes inside handleDispatch. That check runs only AFTER the full body is resident — sufficient for a well-behaved sender, but a DoS window for wrappers that buffer unbounded bytes before calling the core.

The Node and Fetch wrappers therefore short-circuit at READ time:

  • createNodeWebhookHandler consults adapter.maxBodyBytes, tracks a running total during chunk accumulation, and on overflow calls req.destroy(new Error("payload_too_large")) to abort the socket. The wrapper synthesises a 413 response without ever routing the oversized bytes through the adapter core. Peak memory stays bounded regardless of attacker-controlled chunk sizing.
  • createFetchWebhookHandler applies two belt-and-suspenders guards:
    1. If a Content-Length header is present and exceeds adapter.maxBodyBytes, the wrapper returns 413 WITHOUT calling request.arrayBuffer() or reading request.body.
    2. If the body is a streaming ReadableStream (no Content-Length), the wrapper reads via getReader(), tracks the total byte count, and cancels the reader with payload_too_large the instant the running total exceeds the cap.

The core's post-read 413 check remains as a third safety net for adapter authors that do not (or cannot) thread the cap into their wrapper.

The WebhookAdapter interface exposes the applied cap as adapter.maxBodyBytes (readonly) so downstream wrappers can consult it without re-reading config.

const adapter = createWebhookAdapter({ ..., maxBodyBytes: 512_000 });
adapter.maxBodyBytes; // → 512000

Signature header format

The adapter accepts X-Hub-Signature-256 in the strict Meta format:

  • Exactly one sha256= prefix.
  • Exactly 64 hex characters lowercase ([a-f0-9]{64}).
  • No whitespace anywhere in the value.
  • No upper-case hex; no alternative algorithms; no comma-separated sibling signatures; no sha1= fallback.

Any deviation returns 400 invalid_signature_format (malformed) or 401 missing_signature (header absent) — the adapter never attempts a lenient re-parse. The regex used internally is /^sha256=[a-f0-9]{64}$/.

Callers constructing the header themselves (proxies, test harnesses) MUST match the format exactly. This intentionally narrow surface keeps the attacker cost of signature-confusion attacks high and the validation logic short and auditable.

Service-layer ingestion

When @wats/service is constructed with an injected PersistenceStore, the service wraps the adapter with a bounded, authenticated ingestion layer that adds guarantees the adapter core alone does not provide:

  1. Per-update scoped dedup. Each normalized TypedUpdate gets a stable dedup key scoped by the receiving profile (phoneNumberId + wabaId) and the update's own identity (messageId for messages; statusId + status

    • timestamp for statuses). Fallback families (account, call, group, system, unknown) hash the bounded canonical JSON of the STABLE fields (receive-clock fields receivedAt and rawChange excluded) with the entire scope tuple — not a raw phone/waba prefix — so retries dedup. The key is an async SHA-256 of a JSON tuple via the existing crypto provider, never a non-cryptographic hash. Dedup is recorded AFTER the adapter authenticates the raw bytes, so an invalid request cannot poison dedup. If the dedup store fails, the service returns 503 persistence_unavailable without exposing the backend error. Updates already recorded earlier in that request finish processing; the failed update and remaining batch are left for retry. Retrying the envelope suppresses completed updates.
  2. Finite depth gate. After signature verification, the adapter rejects 400 payload_depth_exceeded when the parsed envelope's nesting depth exceeds MAX_NESTED_DEPTH (128) — for ALL families, including a valid message with a deeply-nested extra property. The gate runs BEFORE dedup and dispatch; an over-limit payload is never silently dispatched.

  3. Event-time projection BEFORE dispatch. Inbound message projection uses the valid Meta timestamp from the message's own timestamp field (not receipt time). A future timestamp within the 1h clock-skew tolerance is CLAMPED to receipt time (never extending the conversation window); beyond the tolerance, and any malformed (non-numeric, empty, non-positive) timestamp, cause the projection to be SKIPPED — it never fabricates a fresh window. Status callbacks project via appendMessageStatus, which atomically appends the event AND updates the stored message row's status — exactly one row per message after delivered/read/failed. Projection is idempotent and runs for both first-delivery and deduped updates, so a transient projection failure is repaired by a retry without re-dispatch.

  4. Readiness gate. /readyz checks store.health() and rejects (503 persistence_unavailable) any store whose schema version is not CURRENT_SCHEMA_VERSION (not just version 0), plus unhealthy/throw, while /healthz stays 200 (process-alive vs dependencies-ready).

The ACK-on-handler-failure contract is preserved: a handler throw is caught and the webhook still returns 200. The service does NOT add raw-content storage — the handler receives the normalized TypedUpdate, not the raw body bytes. /api routes return 401 on missing token; telemetry returns 404.

See also

On this page