Receive webhooks
Receive WhatsApp events and verify that each callback came from Waaru.
Reviewed
Webhooks deliver inbound messages and outbound status changes to your server. Configure the callback for the exact WhatsApp number in Settings > Developer > Webhooks. Your receiver must verify the signature and record each event before acknowledging it.
Use the webhook management API to read settings, save a receiver, rotate a secret, send a test or change delivery state from your server.
Configure the receiver
- Choose the connected WhatsApp number.
- Under Send inbound and status events, enter a Callback URL. Use a public HTTPS hostname on port 443. Redirects and private network targets are blocked.
- Select the required Events and choose Create webhook.
- Copy the signing secret and store it on your receiver. It is separate from the API key and is shown once when created or rotated.
- Select Send test. Confirm that your receiver verifies the signature and accepts the
webhook.testevent. - Choose callback coverage explicitly. Keep Logic Flow for observation and set
observeAllOrigins: trueto observe supported native origins. Select Your server only when your server should own autonomous replies. See the setup sequence.
Use Save webhook for updates. Re-read the revision after concurrent changes and inspect delivery health.
Answer the activation check
Before creating an endpoint, changing its URL, saving a paused or disabled endpoint, or explicitly resuming it, Waaru sends an unsigned HTTPS POST:
{
"type": "webhook.verification",
"apiVersion": "v1",
"challenge": "RANDOM_CHALLENGE"
}Return a 2xx response with exactly this JSON object. Echo the supplied challenge unchanged and include no other fields:
{ "challenge": "RANDOM_CHALLENGE" }Treat the challenge only as activation verification, never as a customer event. It proves current reachability, not signature handling.
A failed check returns 502 webhook_verification_failed and preserves configuration. Unchanged active endpoints continue without rechecking. After saving, use Send test to verify a signed delivery.
Recognize the event envelope
Selected message event types are message.received, message.sent, message.delivered, message.read, and message.failed. A test uses webhook.test with data: { "test": true } instead of a message.
Example message event with illustrative identifiers:
{
"id": "event_example",
"type": "message.received",
"apiVersion": "v1",
"createdAt": "2026-09-06T09:00:00.000Z",
"instance": {
"id": "number_example",
"label": "Support",
"displayPhoneNumber": "+919876543210"
},
"data": {
"message": {
"id": "message_example",
"conversationId": "conversation_example",
"contactId": "contact_example",
"direction": "inbound",
"status": "received",
"type": "text",
"text": { "body": "Can you help with my order?" },
"occurredAt": "2026-09-06T09:00:00.000Z",
"attribution": {
"source": "meta_webhook",
"actorKind": "contact",
"actorId": "contact_example",
"campaignId": null,
"relatedMessageId": null
}
}
}
}Content depends on type. Attachments can be pending; read current messages/media. Text and read events are not guaranteed.
Verify the raw request
Waaru sends these headers:
| Header | Meaning |
|---|---|
X-Waaru-Event-Id | Stable event ID for duplicate detection. |
X-Waaru-Timestamp | Delivery signing time as Unix seconds. |
X-Waaru-Signature | One or more comma-separated v1= HMAC-SHA256 signatures. |
The signed input is the timestamp string, a period, and the exact raw request body. Use the signing secret as the HMAC key. Do not parse and reserialize JSON before verification: even whitespace changes the signed input.
This Node.js helper verifies raw bytes and accepts a five-minute delivery timestamp tolerance as a receiver policy. Keep your server clock synchronized. The event's createdAt can be older during a retry; verify the delivery header timestamp instead.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWaaruWebhook({
rawBody,
timestamp,
signature,
secret,
nowSeconds = Math.floor(Date.now() / 1000),
}) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== "string" || !secret)
return false;
if (!Number.isFinite(nowSeconds)) return false;
if (typeof timestamp !== "string" || !/^\d{1,12}$/.test(timestamp))
return false;
if (typeof signature !== "string" || signature.length > 512) return false;
const signedAt = Number(timestamp);
if (!Number.isSafeInteger(signedAt)) return false;
if (Math.abs(nowSeconds - signedAt) > 300) return false;
const digest = createHmac("sha256", secret)
.update(timestamp)
.update(".")
.update(rawBody)
.digest("hex");
const expected = Buffer.from(`v1=${digest}`);
return signature
.split(",")
.slice(0, 4)
.some((candidate) => {
const actual = Buffer.from(candidate.trim());
return (
actual.length === expected.length && timingSafeEqual(actual, expected)
);
});
}Retain raw bytes in your framework. Reject invalid signatures, then parse JSON and validate its shape, expected number and id against X-Waaru-Event-Id.
Store the event ID and durably queue your work before returning a 2xx. If that event ID is already recorded, acknowledge it without running its business action again. Signature verification alone does not prevent processing the same signed event twice.
Acknowledge and recover delivery
Keep the callback response quick; the delivery timeout is 10 seconds. Process slower business work after durable acceptance. Return a success status only when your receiver has safely accepted the event.
| Receiver result | Delivery behavior |
|---|---|
Any 2xx | Delivery is acknowledged. |
408, 429, 5xx, or retryable transport failure | Retried with bounded backoff and jitter within the 24-hour delivery window. |
404 or 410 | The webhook pauses. Fix the endpoint and explicitly resume it. |
| Other non-success response | That delivery becomes terminal. Redirects are not followed. |
Repeated failures can show a degraded state. Exhausting the delivery window ends that delivery and pauses further work. Review the failure, repair the receiver, and use Resume webhook when appropriate. Resuming does not make an expired delivery fresh or provide an arbitrary historical replay API. Reconcile current conversations and message status when recovering from a gap.
Design for repeated deliveries and status reconciliation. Use event IDs for deduplication and message IDs for message state. Callback failure never hides the conversation from Inbox or starts Logic Flow automatically.
Rotate or disable the callback
Rotate secret shows a new signing secret. Update your receiver promptly. The previous secret remains valid for 24 hours, and deliveries during the overlap can contain signatures for both secrets. Accept a matching signature rather than comparing the entire comma-separated header with one value.
Disable webhook stops callback delivery. Switching to Use Logic Flow stops autonomous API sends; scoped reads and explicitly opted-in all-origin observations can continue. Callback failure, disablement or health never changes the responder.
Reconcile callback and current state
data.message.status describes the transition represented by that event. A later message read can already show a newer state. Keep event processing idempotent and do not regress a delivered or read record merely because an older callback arrives later. failureCode is included on message.failed events only.
Use message reads to reconcile the current record when needed.
Next: Handle API errors and retries or inspect conversations.
Observe without changing the responder
Set observeAllOrigins: true explicitly on callback saves to receive supported fresh observations while retaining Logic Flow. Omission writes false. This grants no reply authority and never replays history. See origin coverage, attribution and media deadlines.