Webhooks
Why webhooks
Section titled “Why webhooks”Instead of repeatedly calling the read API to check for new messages, register a
callback URL once. Whatbot then sends a signed HTTP POST to that URL every
time something happens — a customer replies, or one of your messages is
delivered/read. Your server just needs to expose an endpoint that accepts POST.
It’s the same idea as Stripe, GitHub, or Meta webhooks. (Your live inbox UI uses a WebSocket; external integrations use webhooks — the server-to-server equivalent.)
Register a webhook
Section titled “Register a webhook”curl -X POST https://api.whatbot.in/public/v1/webhooks \ -H "X-API-Key: wa_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/whatbot/webhook", "events": ["message.received", "message.status"] }'Response — the signing secret is returned once. Store it; you need it to
verify every incoming event.
{ "id": "b6e92e7e-...", "url": "https://your-app.com/whatbot/webhook", "events": ["message.received", "message.status"], "secret": "0DghFF2Hn55hFM8yIQA9BdK_-HA7wZupj-YnlXVBkDQ"}url must be a public http(s) URL — internal, loopback and cloud-metadata
addresses are refused with 422, as is any unknown event name. A workspace admin
can also manage webhooks in the app under Settings → Webhooks.
events is optional — it defaults to the two message.* events. There are
twelve events in all; the other ten
— the five call.* events (see Calls), the two flow.*
events and the three ticket.* events below — are opt-in: list them
explicitly. You can register multiple URLs.
| Event | Default | Fires when |
|---|---|---|
message.received | yes | a customer sent you a message |
message.status | yes | a message you sent was sent / delivered / read / failed |
call.permission.requested, call.permission.updated, call.started, call.completed, call.missed | opt-in | calling activity — see below and Voice Calls |
flow.completed | opt-in | a flow run reached a terminal state |
flow.step_completed | opt-in | a contact answered a flow question |
ticket.created, ticket.updated, ticket.deleted | opt-in | a ticket on a conversation changed |
Events
Section titled “Events”Every delivery is a POST with this envelope:
{ "event": "message.received", "data": { ... }, "timestamp": "2026-07-10T05:11:30.482913+00:00"}timestamp is when we sent the delivery, in ISO 8601 with microseconds and a
+00:00 offset (Python’s isoformat()), not the Z form — parse it with a
real ISO parser rather than a fixed format string.
Phone numbers in webhook payloads are always the full number. The workspace privacy setting that masks the read endpoints does not change them today.
message.received — a customer sent you a message
Section titled “message.received — a customer sent you a message”{ "event": "message.received", "data": { "conversation_id": "c9e6f0f7-...", "phone": "919876543210", "phone_number_id": "1066...", "display_phone_number": "917810083015", "message": { "id": "wamid...", "type": "text", "text": "Hi, is this in stock?", "timestamp": "..." } }}| Field | Description |
|---|---|
phone_number_id | Which of your WhatsApp numbers received it. Pass it as from when you reply with POST /messages. |
display_phone_number | That number, as WhatsApp displays it. |
message.id | WhatsApp’s message id (wamid…). |
message.type | e.g. text, image, document, audio, video, interactive, order. |
message.text | The text or caption, if any. |
message.media_url, media_mime_type, media_filename | Present for media only: a signed download link valid for about an hour (audio: about a day). See Inbound media. |
Messages you send from the WhatsApp Business app on a coexistence number are not customer messages and do not fire this event.
message.status — delivery receipt for a message you sent
Section titled “message.status — delivery receipt for a message you sent”{ "event": "message.status", "data": { "message_id": "b1f0...", "wa_message_id": "wamid.HBg...", "status": "delivered", "phone_number_id": "1066...", "recipient": "919876543210", "recipient_user_id": null }}message_id is Whatbot’s id — the same message_id that
POST /messages returned — and wa_message_id is
WhatsApp’s. status is one of sent, delivered, read, failed; an event fires
only when the status moves forward, so a late delivered after read is not sent.
recipient is the customer’s phone number when WhatsApp reports one;
recipient_user_id is filled instead when WhatsApp identifies the customer by a
user id rather than a number. Both keys are always present.
call.* — calling events (opt-in)
Section titled “call.* — calling events (opt-in)”| Event | Fires when | data |
|---|---|---|
call.permission.requested | You sent a call-permission request | wa_id, status |
call.permission.updated | The permission changed — the contact accepted or declined (then approved is true/false), or a stale permission was reconciled | wa_id, status, and approved on a contact’s answer |
call.started | A customer’s call to you started ringing | wa_call_id, wa_id, direction (inbound), conversation_id |
call.completed | An answered call ended | wa_call_id, wa_id, direction, status, duration_sec, billable_sec |
call.missed | A call ended without being answered — unanswered, declined or failed | wa_call_id, wa_id, direction, status (missed, rejected or failed), and duration_sec / billable_sec when WhatsApp reported the end |
call.started is sent for inbound calls only; an outbound call you place with
POST /calls does not send it. See Voice Calls.
flow.completed — a flow run finished (opt-in)
Section titled “flow.completed — a flow run finished (opt-in)”Fires when a run started by POST /flows/{ref}/trigger (or by any other
trigger) reaches a terminal state, so you get the collected answers pushed to you
instead of polling GET /flows/executions/{id}. Delivery is at least once:
one event per run in normal operation, but a retried delivery or a rare internal
race can repeat it — treat execution_id as your idempotency key. data carries the fields of that
endpoint plus conversation_id (handy for GET /conversations/{id}/messages).
One difference: phone here is always the full number — webhooks are
server-to-server, like message.received — whereas the polling endpoint honours
your workspace’s public-API masking setting.
{ "event": "flow.completed", "data": { "execution_id": "7d1e...", "flow_id": "a41b...", "conversation_id": "c9e6f0f7-...", "phone": "919876543210", "status": "completed", "variables": { "name": "Ravi", "budget": "50L", "_responses": [ { "question": "...", "answer": "..." } ] }, "started_at": "2026-09-15T10:00:00+00:00", "completed_at": "2026-09-15T10:03:12+00:00" }}status is completed or failed. failed covers every other way a run ends:
it errored, hit its step limit, was found stuck before its first step, was
cancelled because someone closed the conversation in the inbox, or was abandoned
by the flow’s idle timeout (the contact stopped replying). Each of those sends
the event, so you can wait on flow.completed instead of polling. On failed,
variables._error carries the reason when one was recorded. A flow with no idle
timeout configured waits for a reply indefinitely — set one in the flow builder
if your integration must not.
A run that reaches a Hand off to human node is not finished — the conversation
is now with an agent — so it does not send flow.completed until the agent
closes the conversation (which cancels the run, status: "failed"). If you need
the answers collected up to the handoff, read them from the flow.step_completed
events or poll GET /flows/executions/{id}.
flow.step_completed — a contact answered a question (opt-in)
Section titled “flow.step_completed — a contact answered a question (opt-in)”Fires each time a reply to an Ask question / Ask buttons node moves the flow
forward. A reply that fails the node’s input check (a non-number for a number
question) is re-asked and does not fire; nor does a button reply that matches
nothing when the node has no fallback branch. Use it to react mid-flow — for
example, to read back an answer during a live call — without waiting for
flow.completed.
The event is sent once the turn that consumed the reply has been saved — after the next message(s) go out and any Call API nodes before the next question have run — so it can trail the reply by a few seconds, and it never reports an answer the run failed to keep.
Each delivery is its own request, so ordering across event types is not
guaranteed: on the final turn, the flow.step_completed for the last answer
and the flow.completed for the run are sent back to back and may arrive in
either order. Treat flow.completed’s variables as the authoritative final
state, and don’t stop accepting flow.step_completed for an execution the
moment you see it complete.
{ "event": "flow.step_completed", "data": { "execution_id": "7d1e...", "flow_id": "a41b...", "conversation_id": "c9e6f0f7-...", "phone": "919876543210", "node_id": "q1", "node_type": "ask_question", "variable": "budget", "answer": "50L", "button_id": null, "variables": { "name": "Ravi", "budget": "50L", "q1": "50L", "_responses": [ { "question": "...", "answer": "50L" } ] } }}variable is the node’s Save as name (null for button nodes). answer is the
contact’s reply as typed or tapped. variables also carries the raw reply keyed
by node id (q1) and the engine’s _responses log; keys starting with _ are
internal. For an Ask buttons node, button_id is the id
of the button that matched ("buy"), or null when the contact typed something
that matched no button and the flow took the node’s fallback branch — check it
rather than re-matching button titles yourself. variables is the full state
collected so far.
ticket.created / ticket.updated / ticket.deleted — a ticket changed (opt-in)
Section titled “ticket.created / ticket.updated / ticket.deleted — a ticket changed (opt-in)”Tickets are the work items your team attaches to a conversation — from the
inbox, from an emoji reaction on a message, or from an automation rule. There is
no public tickets endpoint today; these events let you mirror ticket activity
into your own systems, not create it. One event fires per write, whoever made
it. data.ticket is the stored row after the write. ticket.deleted carries
the row as it was before deletion, without organization_id (the other two
include it) — key your store on id, which is unique across workspaces. actor
names who made the change: actor.user_id is null when no signed-in team
member made it, and name is then "Automation" for a rule or
"WhatsApp app" for a reaction sent from the WhatsApp Business app.
{ "event": "ticket.updated", "data": { "ticket": { "id": "3f8c...", "organization_id": "0b2a...", "conversation_id": "c9e6f0f7-...", "source_wa_message_id": "wamid.HBg...", "title": "Send Tower B price sheet", "description": null, "status": "in_progress", "priority": "high", "due_at": "2026-09-23T06:30:00+00:00", "assigned_agent_id": "5d1e...", "assigned_agent_name": "Priya", "labels": ["2bhk"], "custom_props": {}, "resolved_at": null, "created_via": "reaction", "created_by": "9a7f...", "created_by_name": "Rahul", "created_at": "2026-09-22T09:12:04+00:00", "updated_at": "2026-09-22T09:40:51+00:00" }, "conversation_id": "c9e6f0f7-...", "actor": { "user_id": "5d1e...", "name": "Priya" } }, "timestamp": "2026-09-22T09:40:51.204118+00:00"}status is open, in_progress or closed; priority is low, medium,
high or urgent; created_via is manual, reaction, api, rule or ai.
ticket.updated does not say which fields changed — diff against the last row
you stored, keyed on id. Tasks — the lighter to-dos a team keeps on a
conversation, separate from tickets — send no events.
Verify the signature
Section titled “Verify the signature”Every request includes:
X-Whatbot-Event: message.receivedX-Whatbot-Signature: sha256=<hex>where<hex>isHMAC-SHA256(secret, raw_body)
Compute the HMAC over the raw request body and compare — reject anything that doesn’t match, so nobody can forge events.
import crypto from "crypto";
app.post("/whatbot/webhook", express.raw({ type: "*/*" }), (req, res) => { const sig = req.header("X-Whatbot-Signature") || ""; const expected = "sha256=" + crypto .createHmac("sha256", process.env.WHATBOT_WEBHOOK_SECRET) .update(req.body) // raw bytes, not parsed JSON .digest("hex");
// timingSafeEqual throws on unequal lengths, so check the length first if (sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { return res.sendStatus(401); }
const { event, data } = JSON.parse(req.body); // handle event... res.sendStatus(200); // 2xx = acknowledged});import hmac, hashlibfrom flask import request, abort
@app.post("/whatbot/webhook")def webhook(): raw = request.get_data() expected = "sha256=" + hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(request.headers.get("X-Whatbot-Signature", ""), expected): abort(401) payload = request.get_json() # handle payload["event"] / payload["data"] return "", 200Delivery & retries
Section titled “Delivery & retries”- Respond
2xx(or3xx) within 8 seconds to acknowledge. - A
5xx, timeout or network error is retried immediately, up to 3 attempts in all. A4xxis treated as a rejection and not retried — including a401from a failed signature check. After the last attempt the event is dropped. - There is no delivery log or replay. If your endpoint is down, the events in that
window are lost — reconcile with the read endpoints
(
GET /conversations,GET /flows/executions/{id}) after an outage. - Deliveries are best-effort and never block message processing on Whatbot’s
side, so make your handler fast (queue the work, then return
200). - A retried event may arrive more than once — make your handler idempotent:
dedupe
message.receivedondata.message.id,message.statusonwa_message_id+status, and flow events onexecution_id.
Manage webhooks
Section titled “Manage webhooks”curl https://api.whatbot.in/public/v1/webhooks -H "X-API-Key: wa_live_xxx"{ "webhooks": [ { "id": "b6e92e7e-...", "url": "https://your-app.com/whatbot/webhook", "events": ["message.received", "message.status"], "active": true, "created_at": "2026-07-10T05:00:12+00:00" } ]}curl -X DELETE https://api.whatbot.in/public/v1/webhooks/{id} -H "X-API-Key: wa_live_xxx"{ "deleted": true, "id": "b6e92e7e-..." }An id that isn’t one of your webhooks returns 404. There is no update endpoint —
to change a webhook’s URL or events, register a new one and delete the old.
The secret is never returned by the list endpoint — only at creation time.