Automation

Webhooks and automation

Receive signed delivery events and automation events in your application.

Webhook events

Webhook endpoints belong to API clients. Events include message lifecycle events, sender session events, and automation-triggered events.

Webhook secrets are returned only when an endpoint is created or the secret is rotated. Store the secret server-side and verify signatures in your webhook receiver.

Create an endpoint

Open Dashboard > API Keys, choose the API client, and add a webhook endpoint. Enter a public HTTPS URL, a description, and the event types your application should receive.

When the endpoint is created or the secret is rotated, the dashboard shows `signing_secret` once. Copy it into your receiving service secret storage before leaving the page.

Delivery headers

Webhook deliveries are JSON `POST` requests. The signature is HMAC SHA-256 using the webhook signing secret and the exact string `${timestamp}.${rawBody}`.

Reject old timestamps and invalid signatures before processing the event body.

verify-webhook.ts
ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyHookMessageWebhook(rawBody: string, timestamp: string, signature: string, secret: string) {
  const digest = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  const expected = `v1=${digest}`;
  const receivedBuffer = Buffer.from(signature);
  const expectedBuffer = Buffer.from(expected);

  return receivedBuffer.length === expectedBuffer.length && timingSafeEqual(receivedBuffer, expectedBuffer);
}

Delivery retries and statuses

Webhook delivery jobs use exponential retry backoff starting at 10 seconds. The maximum attempt count is controlled by `WEBHOOK_MAX_RETRY_ATTEMPTS` and each HTTP request is aborted after `WEBHOOK_TIMEOUT_MS`.

Manual retry is available for failed or skipped deliveries. Retrying creates a new queued delivery record so history stays auditable.

Express raw body receiver

Signature verification must use the exact raw request body. If JSON middleware parses and reserializes the body first, the HMAC can fail.

express-webhook.ts
ts
import express from "express";

const app = express();

app.post("/webhooks/hookmessage", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8");
  const signature = req.header("x-wa-signature") ?? "";
  const timestamp = req.header("x-wa-timestamp") ?? "";
  const deliveryId = req.header("x-wa-delivery-id") ?? "";

  if (!verifyHookMessageWebhook(rawBody, timestamp, signature, process.env.HOOKMESSAGE_WEBHOOK_SECRET!)) {
    return res.status(401).send("invalid signature");
  }

  const event = JSON.parse(rawBody);
  // Store deliveryId before processing so duplicate retries are idempotent.
  return res.status(204).send();
});

Next.js raw body receiver

In a Next.js route handler, call `request.text()` before parsing JSON. Verify the signature against that text, then parse it.

app/api/hookmessage/route.ts
ts
export async function POST(request: Request) {
  const rawBody = await request.text();
  const signature = request.headers.get("x-wa-signature") ?? "";
  const timestamp = request.headers.get("x-wa-timestamp") ?? "";
  const deliveryId = request.headers.get("x-wa-delivery-id") ?? "";

  const valid = verifyHookMessageWebhook(
    rawBody,
    timestamp,
    signature,
    process.env.HOOKMESSAGE_WEBHOOK_SECRET!,
  );
  if (!valid) return new Response("invalid signature", { status: 401 });

  const event = JSON.parse(rawBody);
  // Store deliveryId and event, then process asynchronously.
  return new Response(null, { status: 204 });
}

Laravel raw body receiver

Use `$request->getContent()` for the raw body and verify `x-wa-signature` before trusting the JSON payload.

Laravel controller
php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/webhooks/hookmessage', function (Request $request) {
    $rawBody = $request->getContent();
    $timestamp = $request->header('x-wa-timestamp', '');
    $signature = $request->header('x-wa-signature', '');
    $deliveryId = $request->header('x-wa-delivery-id', '');

    if (! verify_hookmessage_webhook($rawBody, $timestamp, $signature, config('services.hookmessage.webhook_secret'))) {
        return response('invalid signature', 401);
    }

    $event = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
    // Store $deliveryId before processing to ignore duplicate retries.
    return response()->noContent();
});

Local development

For local webhook testing, expose your local receiver with a tunnel such as ngrok, Cloudflare Tunnel, or a similar HTTPS forwarding tool.

Use the public HTTPS tunnel URL in the dashboard webhook endpoint. If signatures fail locally, log the raw body length, timestamp, and delivery ID, but never log the signing secret.

Message event payload

Use the event type header to route handling, then store the payload with the delivery ID for idempotency. A retried webhook can send the same event again.

Message lifecycle payloads include the workspace, sender, message, status, receiver, and timestamps needed to update your local delivery state.

message.sent payload
json
{
  "event": "message.sent",
  "message_id": "2b7a0bd5-1a0d-4e6c-85b1-a7f44f92dfb0",
  "workspace_id": "f2aa1d49-9f4b-4f37-9918-d8d8f13fb530",
  "sender_id": "9f4b5d4c-0000-4000-9000-123456789abc",
  "receiver_number": "212612345678",
  "status": "sent",
  "source_system": "checkout",
  "source_reference": "order_1042",
  "sent_at": "2026-06-29T16:12:04.000Z"
}

Need help?

Use Book Integration Help if you want setup help for OTP or automated customer messaging.

Next steps