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/cryptoMinimal 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.tsBun.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.