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.
- message.queued
- message.sent
- message.failed
- sender.connected
- sender.disconnected
- sender.logged_out
- automation.triggered
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.
- Dashboard steps: open Dashboard > API Keys > choose client > Webhooks > Add endpoint.
- Webhook fields: enter URL, description, and event types, then save.
- Test delivery: use the dashboard test-send action, then inspect delivery logs for status and errors.
- Use HTTPS endpoints reachable from the public internet.
- Return any 2xx status when your receiver stores or processes the event successfully.
- Use the delivery log in the dashboard to inspect failed attempts and retry supported deliveries.
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.
- content-type: application/json
- user-agent: whatsapp-sender-service-webhooks/1.0
- x-wa-event: event type, for example message.sent
- x-wa-delivery-id: unique delivery attempt ID
- x-wa-timestamp: unix timestamp in seconds
- x-wa-signature: v1=<hex hmac digest>
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.
- queued: delivery has been created and is waiting for the webhook worker.
- retrying: a previous attempt failed and the queue will try again.
- sent: the endpoint returned a 2xx response.
- failed: the endpoint never returned 2xx within max attempts.
- skipped: the endpoint was disabled before delivery.
- Timeout fix: return 2xx quickly, store the event, then process slow work asynchronously.
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.
- ngrok example: forward `http://localhost:3000` and use `https://...ngrok-free.app/webhooks/hookmessage`.
- Duplicate handling: store `x-wa-delivery-id` with a unique constraint and ignore already-seen deliveries.
- Invalid signature fix: verify against the exact raw body and the current signing secret.
- No delivery received: confirm the tunnel is running, the endpoint is active, and selected event types match the emitted event.
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.
Continue building
Next steps
Start here
A plain-language map of Free Sandbox, paid sender modes, credentials, and the first successful request.
Free Sandbox quickstart
The shortest beginner path from a verified account to a queued Free Sandbox OTP.
Glossary
Understand workspaces, keys, senders, receivers, queue states, webhooks, and idempotency.
Authentication and access
HookMessage separates customer API keys, dashboard sessions, and internal platform access.