wats.sh
Reference

Webhook Adapter

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

active

WebhookAdapter

The WebhookAdapter is @wats/http's runtime-neutral HTTP adapter layer. It takes incoming HTTP requests, verifies their X-Hub-Signature-256 via the signature primitives, normalizes their body via normalizeWebhookEnvelope, dispatches the resulting TypedUpdate values through a facade-shaped dispatch() method, and returns 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.ACCESS_TOKEN!,
  phoneNumberId: "12345"
});

// 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.VERIFY_TOKEN!,
  appSecret: process.env.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.

See also

On this page