Skip to content
Splashify Pro
Docs

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 real app_webhooks row with its own id and secret — reveal or rotate the secret on the same Settings page, or via the endpoints below using the webhook_id GET /app/developer/settings returns.
  • POST /app/webhooks — register any number of webhooks, each with its own URL and event list, and get back a secret immediately 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

json
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"
}
json
{
  "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:

text
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:

json
{
  "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.

json
{
  "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:

json
{
  "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:

json
{
  "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.

json
{
  "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:

json
{
  "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:

json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{
  "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:

  1. incoming_message — the message itself, exactly as any other text message.
  2. contact_opted_out — the compliance signal, carrying the keyword matched.

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.