wats.sh
Guides

Deploying a WATS webhook on Cloudflare Workers

Deploy a WATS webhook on Cloudflare Workers, Deno, and other WinterCG fetch runtimes using createFetchWebhookHandler.

shape-only on Workers — the edge invariant is structurally tested; no full Miniflare integration test yet · reviewed 2026-06-12

This page covers Cloudflare Workers with createFetchWebhookHandler. The same pattern works on Deno (Deno.serve), Vercel Edge Functions, and any other runtime that implements the WinterCG fetch handler contract.

Why the fetch adapter works on Workers

@wats/http's createFetchWebhookHandler produces a pure (request: Request) => Promise<Response> function. The file packages/http/src/adapters/fetchAdapter.ts contains zero static node:* imports — an invariant enforced by two structural tests (packages/testing/tests/workspace-policy.test.ts + packages/testing/edge/webhook-adapter.test.ts) so regressions can't sneak in.

Install

npm install @wats/http @wats/core @wats/graph @wats/crypto

Workers require "nodejs_compat" only for node:buffer-style Node shims. WATS does not need any of them.

wrangler.toml

name = "wats-webhook"
main = "src/index.ts"
compatibility_date = "2024-10-01"

[vars]
VERIFY_TOKEN = "..."

[[secrets]]
binding = "APP_SECRET"

[[secrets]]
binding = "META_ACCESS_TOKEN"

Worker entry

// src/index.ts
import {
  createFetchWebhookHandler,
  createWebhookAdapter
} from "@wats/http";
import { GraphClient, createFetchTransport } from "@wats/graph";
import { WhatsApp, message } from "@wats/core";

export interface Env {
  VERIFY_TOKEN: string;
  APP_SECRET: string;
  META_ACCESS_TOKEN: string;
  WA_PHONE_NUMBER_ID: string;
}

let handler: ((req: Request) => Promise<Response>) | undefined;

function buildHandler(env: Env) {
  const graphClient = new GraphClient({
    accessToken: env.META_ACCESS_TOKEN,
    apiVersion: "v25.0",
    transport: createFetchTransport()
  });

  const wa = new WhatsApp({
    graphClient,
    phoneNumberId: env.WA_PHONE_NUMBER_ID
  });

  wa.on(message, async (update) => {
    // handler body — fire-and-forget from the Worker's perspective.
    console.log("received:", update.message.from);
  });

  const adapter = createWebhookAdapter({
    verifyToken: env.VERIFY_TOKEN,
    appSecret: env.APP_SECRET,
    whatsapp: wa
  });

  return createFetchWebhookHandler(adapter);
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (handler === undefined) handler = buildHandler(env);
    return handler(request);
  }
};

Deno

import { createFetchWebhookHandler, createWebhookAdapter } from "@wats/http";
// ... build adapter as above ...
const handler = createFetchWebhookHandler(adapter);
Deno.serve(handler);

Request body size on Workers

Cloudflare Workers cap request bodies at runtime-plan-specific limits (100 MiB on paid plans as of 2024). WATS's default maxBodyBytes is 1 MiB — well within any realistic WhatsApp webhook payload. Raise it via createWebhookAdapter({ ..., maxBodyBytes }) if your deployment genuinely needs larger envelopes.

mTLS and the HMAC boundary

The Worker handler still verifies Meta webhook POST bodies at the app layer with HMAC-SHA256 from X-Hub-Signature-256. That verification happens in @wats/http after the request reaches the Worker and remains required.

Optional Meta webhook mTLS is infrastructure-level client-certificate validation before the Worker handler runs. Any Cloudflare/edge ingress mTLS configuration you choose must trust Meta's owned root meta-outbound-api-ca-2025-12.pem through the platform controls that support client certificates. WATS does not vendor the CA, embed PEM contents, expose a Worker-side certificate validator, or configure your infrastructure; obtain and rotate the CA from Meta's authoritative channel.

Observability on Workers

Use tailed logs or a platform logging binding — logger fires synchronously and the Worker's execution-context model is compatible with a non-async logger callback.

Verify it works

curl -i "https://wats-webhook.<your-subdomain>.workers.dev/?hub.mode=subscribe&hub.verify_token=<token>&hub.challenge=ping"

A correct deployment returns 200 with body ping; a wrong token returns 401. Only register the URL with Meta once this passes.

See also

On this page