wats.sh
Guides

Deploying a WATS webhook on Bun

Deploy a WATS webhook server under the Bun runtime using createBunWebhookServer.

active · reviewed 2026-06-12

This page covers the Bun runtime with createBunWebhookServer. For Node, see deploy on Node. For Cloudflare Workers, Deno, and other edge runtimes, see deploy on Cloudflare Workers.

Install

bun add @wats/http @wats/core @wats/graph @wats/crypto

Minimal server

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

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

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

wa.on(message, async (update) => {
  console.log("received message:", update.message.from, update.message.type);
});

const adapter = createWebhookAdapter({
  verifyToken: process.env.VERIFY_TOKEN!,
  appSecret: process.env.APP_SECRET!,
  whatsapp: wa,
  logger: (event) => console.log("wats-http", event.type, event)
});

const server = createBunWebhookServer(adapter, {
  port: Number(process.env.PORT ?? 8787),
  hostname: "0.0.0.0"
});

console.log(`WATS webhook listening on http://${server.hostname}:${server.port}`);

Run it:

bun run server.ts

Bun.serve already speaks WinterCG Request → Response, so the Bun adapter is a thin wrapper over createFetchWebhookHandler. Every request goes through the same runtime-neutral core as the fetch and Node adapters, so the status-code taxonomy is identical across runtimes.

mTLS and the HMAC boundary

createBunWebhookServer verifies Meta webhook POST bodies at the app layer with HMAC-SHA256 from X-Hub-Signature-256. Keep that verification enabled; it is independent of TLS termination.

If your production ingress opts into Meta outbound webhook mTLS, configure client-certificate validation at the TLS terminator in front of Bun — reverse proxy, load balancer, CDN, or platform. Meta's transition names the Meta-owned root meta-outbound-api-ca-2025-12.pem; WATS does not vendor that CA, embed PEM contents, or configure your infrastructure. Obtain and rotate the CA through Meta's authoritative channel, then pass only already-accepted HTTP requests to the Bun handler.

Graceful shutdown

process.on("SIGINT", () => {
  server.stop(true);
  process.exit(0);
});

server.stop(true) terminates active connections. server.stop(false) stops accepting new connections but lets in-flight requests finish.

Observability

The logger hook fires per lifecycle stage (request_received, signature_verified, body_normalized, dispatched, response_sent, error). Pipe it into any observability sink:

logger: (event) => {
  metrics.increment(`wats.webhook.${event.type}`);
  if (event.type === "error") {
    errorTracker.capture(event.error, { stage: event.stage });
  }
}

Verify it works

Hit the GET verify endpoint the way Meta will:

curl -i "http://127.0.0.1:8787/?hub.mode=subscribe&hub.verify_token=$VERIFY_TOKEN&hub.challenge=ping"

A correct setup returns 200 with body ping. A wrong token returns 401. Only point Meta at the URL once this passes.

See also

On this page