Skip to content
Skip to Content
WaaruDocs

SDK webhook receiver

Store verified events before acknowledging them.

Reviewed

Run a receiver with the Node.js SDK

Use Node.js 22.14+. The receiver needs the signing secret and the internal instance.id returned by waaru.instance.get(). The displayed wa-… label, phone number, Meta phone ID and WhatsApp account ID are different identifiers. An API key is needed for API calls, not for verifying callbacks.

This example uses your application's PostgreSQL database as a durable inbox. The unique event ID prevents duplicate admission. A worker in your application processes unhandled rows; the HTTP receiver does not send replies or perform customer actions.

npm install @waaru/sdk@1.0.0-beta.5 pg

Create this table in your own database, not Waaru's database:

CREATE TABLE waaru_webhook_inbox (
  event_id text PRIMARY KEY,
  instance_id text NOT NULL,
  event_type text NOT NULL,
  payload jsonb NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz
);
CREATE INDEX waaru_webhook_inbox_pending
  ON waaru_webhook_inbox (received_at)
  WHERE processed_at IS NULL;

Create a git-ignored .env. Use your database provider's secure connection settings. Leave the webhook secret blank only while completing the unsigned activation check; signed events fail closed until you save the secret.

DATABASE_URL=your_own_postgresql_connection
WAARU_INSTANCE_ID=internal_instance_id
WAARU_WEBHOOK_SECRET=

Save this as receiver.mjs:

import { createServer } from "node:http";
import { Pool } from "pg";
import { verifyWebhook } from "@waaru/sdk";

const { DATABASE_URL, WAARU_INSTANCE_ID, WAARU_WEBHOOK_SECRET } = process.env;
if (!DATABASE_URL || !WAARU_INSTANCE_ID)
  throw new Error("Configure your database and number ID.");
const pool = new Pool({
  connectionString: DATABASE_URL,
  connectionTimeoutMillis: 2000,
  statement_timeout: 5000,
  max: 5,
});
pool.on("error", () => console.error("Webhook database connection failed."));
const types = new Set([
  "message.received",
  "message.sent",
  "message.delivered",
  "message.read",
  "message.failed",
  "webhook.test",
]);

const server = createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== "/webhooks/waaru") {
    res.writeHead(404).end();
    return;
  }
  try {
    const chunks = [];
    let size = 0;
    for await (const chunk of req) {
      size += chunk.length;
      if (size > 256 * 1024) {
        res.writeHead(413).end();
        return;
      }
      chunks.push(chunk);
    }
    const rawBody = Buffer.concat(chunks);
    const signature = req.headers["x-waaru-signature"];

    // Only this exact unsigned challenge is accepted without a signature.
    if (signature === undefined) {
      const check = JSON.parse(rawBody.toString("utf8"));
      if (
        check &&
        Object.keys(check).sort().join(",") === "apiVersion,challenge,type" &&
        check.type === "webhook.verification" &&
        check.apiVersion === "v1" &&
        typeof check.challenge === "string" &&
        /^[A-Za-z0-9_-]{1,256}$/.test(check.challenge)
      ) {
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ challenge: check.challenge }));
        return;
      }
      res.writeHead(400).end();
      return;
    }
    if (!WAARU_WEBHOOK_SECRET) {
      res.writeHead(503).end();
      return;
    }
    if (
      !verifyWebhook({
        rawBody,
        signature,
        secret: WAARU_WEBHOOK_SECRET,
        timestamp: req.headers["x-waaru-timestamp"],
      })
    ) {
      res.writeHead(401).end();
      return;
    }
    const event = JSON.parse(rawBody.toString("utf8"));
    if (
      !event ||
      typeof event.id !== "string" ||
      !event.id ||
      event.id.length > 191 ||
      event.id !== req.headers["x-waaru-event-id"] ||
      event.apiVersion !== "v1" ||
      !types.has(event.type) ||
      event.instance?.id !== WAARU_INSTANCE_ID ||
      !event.data ||
      typeof event.data !== "object" ||
      Array.isArray(event.data)
    ) {
      res.writeHead(400).end();
      return;
    }
    try {
      // This table is the queue. Await durable admission before acknowledging.
      await pool.query(
        `INSERT INTO waaru_webhook_inbox (event_id, instance_id, event_type, payload)
         VALUES ($1, $2, $3, $4::jsonb) ON CONFLICT (event_id) DO NOTHING`,
        [event.id, event.instance.id, event.type, JSON.stringify(event)],
      );
      res.writeHead(204).end();
    } catch {
      res.writeHead(503).end(); // Waaru can retry; do not acknowledge lost work.
    }
  } catch {
    if (!res.writableEnded) res.writeHead(400).end();
  }
});
server.headersTimeout = 10000;
server.requestTimeout = 10000;
server.listen(8787, "127.0.0.1");

Start the receiver, then open a tunnel in a separate terminal. Ngrok must already be installed and authenticated:

node --env-file=.env receiver.mjs
ngrok http http://127.0.0.1:8787 --inspect=false

Use the tunnel's HTTPS origin followed by /webhooks/waaru as your callback URL. Keep that URL stable while testing. After Create webhook succeeds, save the one-time secret in .env and restart only the receiver. Keep the tunnel running so its URL does not change. Choose Send test and confirm a webhook.test row appears in your inbox table.

For deployment, put this receiver behind your HTTPS reverse proxy and use your durable application database or transactional queue. Process processed_at IS NULL rows in a separate worker, claim them transactionally, and mark completion only after successful work. Use the event ID for idempotency in downstream actions too. Define retention and restrict access because stored payloads contain customer data. A process-local Set or a console log is not a durable inbox.

Next: Test and troubleshoot delivery or review the callback contract.

On this page