wats.sh
Guides

Deploying a WATS webhook on Node

Deploy a WATS webhook server under Node.js using createNodeWebhookHandler and node:http.

active · reviewed 2026-06-12

This page covers Node.js with createNodeWebhookHandler and the standard node:http server. For Bun, see deploy on Bun. For Cloudflare Workers and Deno, see deploy on Cloudflare Workers.

Install

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

Minimal server

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

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:", update.message.from, update.message.type);
});

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

const handler = createNodeWebhookHandler(adapter);

const server = createServer((req, res) => {
  handler(req, res).catch((err) => {
    // The adapter already maps internal failures to 500; this catch
    // is only hit if the adapter itself rejects, which should never
    // happen under normal flow.
    console.error("webhook handler error:", err);
    try {
      res.statusCode = 500;
      res.end();
    } catch {
      /* best effort */
    }
  });
});

server.listen(Number(process.env.PORT ?? 3000), "0.0.0.0", () => {
  console.log("WATS webhook listening on port", server.address());
});

Bodies and streams

createNodeWebhookHandler reads the request body into memory as a Uint8Array before handing it to the adapter core. The default maxBodyBytes (1 MiB) applies — requests with Content-Length exceeding the cap are rejected with 413.

Behind a reverse proxy

When running behind Nginx, Caddy, or a cloud load balancer, make sure the proxy forwards the raw request body unchanged. Any transform applied before the body reaches WATS invalidates the HMAC signature and produces 401s.

mTLS and the HMAC boundary

createNodeWebhookHandler verifies Meta webhook POST bodies at the app layer with HMAC-SHA256 from X-Hub-Signature-256. Keep that verification enabled even when your Node deployment also sits behind TLS or mTLS infrastructure.

Optional Meta webhook mTLS is an infrastructure-level client-certificate control. If you choose that pattern, configure your TLS terminator — reverse proxy, load balancer, CDN, platform ingress, or your own HTTPS server — to trust Meta's owned root meta-outbound-api-ca-2025-12.pem during Meta's CA transition. WATS does not vendor the CA file, embed PEM contents, or configure your infrastructure; obtain and rotate the CA from Meta's authoritative channel.

Graceful shutdown

for (const signal of ["SIGINT", "SIGTERM"]) {
  process.on(signal, () => {
    server.close(() => process.exit(0));
  });
}

Verify it works

curl -i "http://127.0.0.1:3000/?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 register the URL with Meta once this passes.

See also

On this page