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.
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.
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.
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.
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.
{
"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
Quickstart
A practical path from empty workspace to a working server-side integration.
Authentication and access
HookMessage separates customer API keys, dashboard sessions, and internal platform access.
Workspaces
A workspace is the tenant boundary for every customer resource.
Senders and sessions
A sender is the WhatsApp number used to deliver messages for a workspace.