Webhooks
Webhooks push real-time events to your server as they happen — a customer
replies, a message is delivered or read, someone taps a button, or a
conversation is assigned. They cover both WhatsApp and RCS; every event
carries a channel field so a single endpoint can serve both.
Configuring your webhook
Two ways to create a webhook, and both produce the same kind of object:
an id, a secret that signs every delivery, and an entry in
GET /app/webhooks. Neither is more "real" than the other — use whichever
UI is convenient, or both at once if you want two independent subscriptions.
- Settings → Developer → Configure Webhook (or
PUT /app/developer/webhook) — a single-URL editor. The first time you save it (or the first time an event is delivered to it, whichever happens first), it becomes a realapp_webhooksrow with its own id and secret — reveal or rotate the secret on the same Settings page, or via the endpoints below using thewebhook_idGET /app/developer/settingsreturns. POST /app/webhooks— register any number of webhooks, each with its own URL and event list, and get back asecretimmediately in the response.
Both are listed together by GET /app/webhooks, and both support
GET/PATCH/DELETE /app/webhooks/{id}, POST /app/webhooks/{id}/test, and
POST /app/webhooks/{id}/rotate-secret — there is nothing the console
webhook can do that an API-created one can't, or vice versa.
Registering via the API
POST /api/v1/app/webhooks
{
"url": "https://your-server.example.com/webhooks/splashify",
"events": ["incoming_message", "team_member_assigned", "team_member_unassigned"],
"description": "Production bot"
}{
"success": true,
"webhook": {
"id": "7c2f1e4a-...",
"url": "https://your-server.example.com/webhooks/splashify",
"events": ["incoming_message", "team_member_assigned", "team_member_unassigned"],
"secret": "6f1c...e0a9",
"active": true,
"created_at": "2026-08-17T10:00:00Z",
"updated_at": "2026-08-17T10:00:00Z"
}
}secret is shown once, at creation — store it. POST /app/webhooks/:id/rotate-secret
issues a new one if you need to rotate. Manage subscriptions with
GET/PATCH/DELETE /app/webhooks/:id, send yourself a sample event with
POST /app/webhooks/:id/test, and discover valid event names with
GET /app/webhooks/events.
Verifying signatures
Every delivery — regardless of whether the webhook was created via
POST /app/webhooks or Settings → Developer → Configure Webhook — carries:
Verify by recomputing the HMAC over the exact bytes of the request body (before any JSON parsing) and comparing in constant time:
const crypto = require("crypto");
function verify(rawBody, signatureHeader, secret) {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}If you configured a webhook before 18 Aug 2026 through Settings → Developer
→ Configure Webhook, it received unsigned deliveries until then — the
console editor and the POST /app/webhooks API were backed by two separate
systems, and only the API one was signed. They are unified now: reload
Settings → Developer (or wait for the next delivery) and the console
webhook has a real id and secret too, with the same headers above. No
action is required and the URL/events you already configured are
unaffected — the underlying object simply gained a stable id and secret it
didn't have before.
Request format
Every event is delivered as an HTTP POST with this envelope:
{
"event": "incoming_message",
"timestamp": "2026-05-18T10:00:00Z",
"data": { }
}Respond with any 2xx status within 10 seconds. Non-2xx responses and
timeouts are logged on our side; the event is not retried.
The channel field is "whatsapp" or "rcs". For RCS, status events also
include rcs_message_id (the provider id) alongside message_id (your
Splashify Pro id) — the RCS send APIs return both, so you can correlate on
either.
incoming_message
A customer sent a message to your business.
{
"event": "incoming_message",
"timestamp": "2026-05-18T10:00:00Z",
"data": {
"message_id": "wamid.HBgM...",
"from": "919876543210",
"to": "918100711946",
"phone_number_id": "123456789012345",
"conversation_id": "ee64970d-0539-4eb0-859d-2147859a604d",
"type": "text",
"text": "Hi, is my order shipped?",
"channel": "whatsapp",
"timestamp": "2026-05-18T10:00:00Z",
"contacts": [
{ "profile": { "name": "Aditya" }, "wa_id": "919876543210" }
]
}
}Routing by receiving number
to is the business number the message arrived on, and phone_number_id
is the provider's stable id for it. Use them when one webhook URL serves an
account with more than one connected number — without them you cannot tell
which of your numbers a customer messaged.
Prefer phone_number_id as the routing key: to is a display string that can
be reformatted, while the id does not change. On RCS there is no business phone
number, so to is omitted and phone_number_id carries the agent id
instead. Instagram sets neither.
RCS variant — note channel and the message_id is your Splashify Pro id:
{
"event": "incoming_message",
"timestamp": "2026-05-18T10:00:00Z",
"data": {
"message_id": "794943b0-520f-11f1-910c-8e532107a70b",
"from": "919876543210",
"conversation_id": "ee64970d-0539-4eb0-859d-2147859a604d",
"type": "image",
"text": "photo.jpg",
"media_url": "https://.../photo.jpg",
"channel": "rcs",
"timestamp": "2026-05-18T10:00:00Z"
}
}text carries the answer, whatever its type
text is the human-readable body of the message, and it is the same string
GET /app/messages/conversations/{id} returns as content for that message.
What fills it depends on the type:
It is omitted only when the message genuinely has no text: a bare location pin,
an uncaptioned photo, or a shared contact card (read shared_contacts).
Reading a button tap
A tap arrives as an incoming_message with type: "button_reply" (or
list_reply), carrying both the visible label and the id you defined:
{
"event": "incoming_message",
"timestamp": "2026-05-18T10:00:00Z",
"data": {
"message_id": "wamid.HBgM...",
"from": "919876543210",
"conversation_id": "ee64970d-0539-4eb0-859d-2147859a604d",
"type": "button_reply",
"text": "File it",
"button_id": "file_it",
"context_message_id": "wamid.HBgMOTE5OTcx...",
"channel": "whatsapp",
"timestamp": "2026-05-18T10:00:00Z"
}
}Branch on button_id, not on text. The label is a display string — it
changes the moment someone rewords the button or ships a translated template,
and your handler breaks silently. The id does not change. context_message_id
is the message whose buttons were tapped, so you can tell which prompt was
answered when several are outstanding.
The same tap also fires a button_clicked event with the
same button_id. Subscribe to whichever suits you; subscribing to both means
handling the tap twice.
message_sent · message_delivered · message_read · message_failed
Delivery lifecycle of a message you sent.
{
"event": "message_delivered",
"timestamp": "2026-05-18T10:00:05Z",
"data": {
"message_id": "d58aeff7-a81c-47a7-9b67-f381e13f6c4e",
"to": "919876543210",
"status": "delivered",
"channel": "whatsapp"
}
}RCS variant — includes rcs_message_id:
{
"event": "message_read",
"timestamp": "2026-05-18T10:01:00Z",
"data": {
"message_id": "d58aeff7-a81c-47a7-9b67-f381e13f6c4e",
"rcs_message_id": "a1b2c3d4-...",
"to": "919876543210",
"status": "read",
"channel": "rcs"
}
}message_failed additionally carries an error_message:
{
"event": "message_failed",
"timestamp": "2026-05-18T10:00:05Z",
"data": {
"message_id": "d58aeff7-...",
"to": "919876543210",
"status": "failed",
"channel": "rcs",
"error_message": "Handset does not support RCS"
}
}button_clicked
A customer tapped a template/interactive button (WhatsApp) or a suggestion
(RCS). Fired alongside incoming_message.
{
"event": "button_clicked",
"timestamp": "2026-05-18T10:00:00Z",
"data": {
"message_id": "wamid.HBgM...",
"conversation_id": "ee64970d-0539-4eb0-859d-2147859a604d",
"from": "919876543210",
"button_id": "confirm_order",
"button_text": "Confirm Order",
"channel": "whatsapp",
"timestamp": "2026-05-18T10:00:00Z"
}
}team_member_assigned
A conversation was assigned to a team member — including when a chatbot hands the conversation over to a human agent.
{
"event": "team_member_assigned",
"timestamp": "2026-05-18T10:02:00Z",
"data": {
"conversation_id": "ee64970d-0539-4eb0-859d-2147859a604d",
"contact": "919876543210",
"assigned_to": "0b8074fd-510d-11f1-8b74-aa43648fa5e4",
"assigned_name": "Jane Doe",
"team_id": "1b5c8e00-a6f1-11f1-8000-000000000001",
"team_name": "Sales"
}
}team_id and team_name are there when the conversation is with a team, and left out when it is not. They are on team_member_unassigned too.
conversation_queued
A conversation is waiting in a team's queue, with nobody on it yet: it was sent to the team, or the person on it released it back to the queue. Anyone on the team can pick it in the inbox.
{
"event": "conversation_queued",
"timestamp": "2026-05-18T10:05:00Z",
"data": {
"conversation_id": "ee64970d-0539-4eb0-859d-2147859a604d",
"contact": "919876543210",
"team_id": "1b5c8e00-a6f1-11f1-8000-000000000001",
"team_name": "Sales",
"queued_by": "3c2f91aa-4410-11f1-9a02-1e7712cc31de",
"note": "Asked about a bulk order"
}
}queued_by is who sent it there, and note the note they left, when there is one.
A team that takes turns or gives chats to the least busy person hands a new chat to someone, so conversation_queued is followed by team_member_assigned as soon as someone can take it. See Teams.
conversation_reassigned
A team member did not reply in the team's If nobody replies in time, so the conversation moved on: to the next person on the team, or back to the team queue when the team picks by hand.
{
"event": "conversation_reassigned",
"timestamp": "2026-05-18T10:20:00Z",
"data": {
"conversation_id": "ee64970d-0539-4eb0-859d-2147859a604d",
"contact": "919876543210",
"team_id": "1b5c8e00-a6f1-11f1-8000-000000000001",
"team_name": "Sales",
"from_member_id": "3c2f91aa-4410-11f1-9a02-1e7712cc31de",
"from_member_name": "Rahul Verma",
"to_member_id": "7d01aa10-4410-11f1-9a02-1e7712cc31de",
"to_member_name": "Priya Sharma",
"reason": "no_reply",
"waited_minutes": 10
}
}to_member_id and to_member_name are left out when the conversation went back to the team queue (you also get conversation_queued). waited_minutes is the team's setting. When it goes to a person, you also get team_member_assigned.
team_member_unassigned
A team member released a conversation — it is no longer owned by a human.
This is the counterpart to team_member_assigned, and it is the signal that an
automation may safely resume replying. Without it the only option is a blind
timer, which either talks over a colleague or leaves the customer waiting.
{
"event": "team_member_unassigned",
"timestamp": "2026-05-18T10:40:00Z",
"data": {
"conversation_id": "ee64970d-0539-4eb0-859d-2147859a604d",
"contact": "919876543210",
"assigned_to": "0b8074fd-510d-11f1-8b74-aa43648fa5e4",
"assigned_name": "Jane Doe",
"unassigned_by": "3c2f91aa-4410-11f1-9a02-1e7712cc31de"
}
}assigned_to / assigned_name describe the member who was released — the
same meaning the fields carry on team_member_assigned. unassigned_by is who
released them, which is not always the same person: an admin or an automation
can release someone else's chat.
It fires only when somebody actually held the conversation. Unassigning an already-unassigned conversation is a no-op and emits nothing, so "the human is done" stays distinguishable from "nothing happened".
wallet_low · wallet_empty
Your wallet balance crossed a threshold. wallet_low fires when it falls below
your configured low-balance threshold (the auto-recharge threshold, or ₹100 for
accounts in India if you have not set one); wallet_empty fires when it reaches zero and sending is
blocked.
{
"event": "wallet_low",
"timestamp": "2026-05-18T11:00:00Z",
"data": {
"balance": 84.5,
"threshold": 100,
"currency": "INR",
"previous_balance": 104.5,
"timestamp": "2026-05-18T11:00:00Z"
}
}Both are edge-triggered: they fire on the debit that crosses the
threshold, not on every debit below it. A broadcast that runs your balance down
sends one wallet_low, not one per message. wallet_empty supersedes
wallet_low for the same debit — you get the more severe event, not both.
Discovering events
GET /api/v1/app/webhooks/events returns the authoritative catalogue, each
entry with a name, a description and the channels it applies to. Subscribing to
an event name that is not in that list is rejected with a 400 naming the
valid set — a typo can no longer be stored and then silently never fire.
Event reference
Opt-out keywords do not intercept incoming_message
When a customer sends one of your configured opt-out keywords (STOP and
friends), you receive both events:
incoming_message— the message itself, exactly as any other text message.contact_opted_out— the compliance signal, carrying thekeywordmatched.
The keyword engine runs after the message has been delivered to your webhook
and never swallows it. So an opt-out record built from incoming_message alone
is complete, and one built from contact_opted_out is complete — you will not
silently miss a STOP either way, and you should expect the duplicate rather
than treat it as a bug.
The match is exact and case-insensitive on the whole trimmed message body:
stop matches, STOP matches, please stop does not.