Skip to content

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.)

cURL
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.

EventDefaultFires when
message.receivedyesa customer sent you a message
message.statusyesa message you sent was sent / delivered / read / failed
call.permission.requested, call.permission.updated, call.started, call.completed, call.missedopt-incalling activity — see below and Voice Calls
flow.completedopt-ina flow run reached a terminal state
flow.step_completedopt-ina contact answered a flow question
ticket.created, ticket.updated, ticket.deletedopt-ina ticket on a conversation changed

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": "..." }
}
}
FieldDescription
phone_number_idWhich of your WhatsApp numbers received it. Pass it as from when you reply with POST /messages.
display_phone_numberThat number, as WhatsApp displays it.
message.idWhatsApp’s message id (wamid…).
message.typee.g. text, image, document, audio, video, interactive, order.
message.textThe text or caption, if any.
message.media_url, media_mime_type, media_filenamePresent 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.

EventFires whendata
call.permission.requestedYou sent a call-permission requestwa_id, status
call.permission.updatedThe permission changed — the contact accepted or declined (then approved is true/false), or a stale permission was reconciledwa_id, status, and approved on a contact’s answer
call.startedA customer’s call to you started ringingwa_call_id, wa_id, direction (inbound), conversation_id
call.completedAn answered call endedwa_call_id, wa_id, direction, status, duration_sec, billable_sec
call.missedA call ended without being answered — unanswered, declined or failedwa_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.

Every request includes:

  • X-Whatbot-Event: message.received
  • X-Whatbot-Signature: sha256=<hex> where <hex> is HMAC-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.

Node / Express
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
});
Python / Flask
import hmac, hashlib
from 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 "", 200
  • Respond 2xx (or 3xx) within 8 seconds to acknowledge.
  • A 5xx, timeout or network error is retried immediately, up to 3 attempts in all. A 4xx is treated as a rejection and not retried — including a 401 from 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.received on data.message.id, message.status on wa_message_id + status, and flow events on execution_id.
List
curl https://api.whatbot.in/public/v1/webhooks -H "X-API-Key: wa_live_xxx"
200
{
"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"
}
]
}
Delete
curl -X DELETE https://api.whatbot.in/public/v1/webhooks/{id} -H "X-API-Key: wa_live_xxx"
200
{ "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.