Webhooks
The SplashifyPro RCS Panel POSTs a signed JSON envelope to your URL for every message delivery, click, reply, broadcast lifecycle event, bot state change, KYC review outcome, and template review outcome. Configure them at Settings → Webhooks or via the API.

Access
How events flow
You don't need to know or care how we receive raw events from the underlying RCS gateway. That's SplashifyPro's problem. What matters to you is the OUTBOUND webhook contract documented below: for every send/delivery/read/click/reply your bot handles, we POST a signed JSON envelope to your URL.
Every event across every provider is normalised into the same
event_type enum + the same envelope shape, so your webhook
receiver never needs a per-provider branch.
Envelope (LOCKED)
Every delivery, real events and the webhook.ping test, carries the
same envelope shape. Locking the shape means you can trust it for
the life of the API without a version negotiation:
{
"event_type": "message.delivered",
"delivery_id": "wd_9a2b7c-…",
"account_id": "3c9f2b8e-…",
"bot_id": "48b21f9a-…",
"bot_name": "My Store Bot",
"occurred_at": "2026-07-17T18:07:52Z",
"data": { /* event-specific, see per-event samples below */ }
}occurred_at is always UTC, RFC 3339. delivery_id is stable across
retries, your consumer MUST dedupe on delivery_id.
bot_id is a stable UUID we mint on your side of the platform. Every
event that concerns a specific bot carries it, including the account-
scoped events, when the row can be pinned to a bot. Account-wide
events (opt-out / opt-in) omit bot_id entirely.
Headers
Every request carries:
Content-Type: application/json
User-Agent: SplashifyPro-Webhooks/1.0
X-SplashifyPro-Event: <event_type>
X-SplashifyPro-Delivery: <delivery_id>
X-SplashifyPro-Signature: sha256=<hmac_hex>X-SplashifyPro-Signature is sha256= + the lowercase hex encoding of
HMAC_SHA256(raw_request_body, secret). Verify BEFORE parsing.
Event catalogue
There are twenty-three subscribable event types, plus the synthetic
webhook.ping fired by the "Send test" button. Every event follows
the locked envelope, the only thing that varies
between events is the shape of data.
Scope indicates which bot_scope filter matches:
- per-message / per-broadcast / per-bot / per-webhook, the event
carries
bot_idon the envelope, so a bot-scoped subscription only receives events for that bot. - per-account, the event is not tied to a specific bot; every
matching subscription for the account receives it regardless of
bot_scope. Today onlycontact.opted_outandcontact.opted_inare account-scoped.
Broadcast sends vs direct API sends
Every message.* event fires for BOTH:
-
Broadcasts (fanning out to a contact list),
dataincludesbroadcast_idso you can group events by campaign. -
Direct API sends via
POST /api/v1/rcs/messages/send(both themessageandtemplate_idshapes),data.direct_send = true, and the original message body is echoed back so your consumer has full context without a follow-up API read:text{ "direct_send": true, "message_id": "msg_01…", "recipient_phone":"+919812345678", "content": "Your OTP is 483920. Valid for 5 minutes.", "message_type": "text", "template_name": "otp_login_v2", // only on template sends "raw_payload": "…", // full JSON we sent to the gateway "sent_at": "2026-08-08T09:12:00Z", "failure_reason": "…" // only on message.failed }broadcast_idis OMITTED on the direct-send variant. If your consumer needs to distinguish, filter ondata.direct_send.
message.sent
Gateway accepted the send. This means the message is on its way to the
handset, not that the handset has received it yet (message.delivered
is the acknowledgement for that).
Sample:
{
"event_type": "message.sent",
"delivery_id": "wd_9a2b7c1d-3f4e-4a5b-8c9d-1e2f3a4b5c6d",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T09:12:00Z",
"data": {
"broadcast_id": "b0f4a1e2-3d4c-4b5a-9e8f-1a2b3c4d5e6f",
"recipient_phone": "+919812345678",
"message_id": "msg_01HZ8QK4YQ5G3W2N1V0PYX9M4C"
}
}message.failed
Gateway rejected the send outright, invalid phone, opted-out
recipient, template not approved on the destination, etc. data
includes a failure_reason string suitable for logging.
Sample:
{
"event_type": "message.failed",
"delivery_id": "wd_c1d2e3f4-5a6b-4c7d-8e9f-a0b1c2d3e4f5",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T09:12:04Z",
"data": {
"broadcast_id": "b0f4a1e2-3d4c-4b5a-9e8f-1a2b3c4d5e6f",
"recipient_phone": "+919812345690",
"message_id": "msg_01HZ8QK4YQ5G3W2N1V0PYX9M4D",
"failure_reason": "User has opted out"
}
}message.delivered
Handset acknowledged. Depending on the operator, this can lag the send
by several seconds and land out-of-order with message.read.
Sample:
{
"event_type": "message.delivered",
"delivery_id": "wd_5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T09:12:07Z",
"data": {
"broadcast_id": "b0f4a1e2-3d4c-4b5a-9e8f-1a2b3c4d5e6f",
"recipient_phone": "+919812345678",
"message_id": "msg_01HZ8QK4YQ5G3W2N1V0PYX9M4C"
}
}message.read
Recipient opened the chat containing the message. Fires at most once per message, subsequent re-reads are not echoed.
Sample:
{
"event_type": "message.read",
"delivery_id": "wd_1a2b3c4d-5e6f-4a7b-8c9d-e0f1a2b3c4d5",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T09:14:32Z",
"data": {
"broadcast_id": "b0f4a1e2-3d4c-4b5a-9e8f-1a2b3c4d5e6f",
"recipient_phone": "+919812345678",
"message_id": "msg_01HZ8QK4YQ5G3W2N1V0PYX9M4C"
}
}message.replied
Recipient typed a free-form text / media reply. The reply body lands
verbatim in data.text. This event does NOT carry broadcast_id
because a free-form reply is not attributed to any specific outbound
send.
Sample:
{
"event_type": "message.replied",
"delivery_id": "wd_2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T09:15:11Z",
"data": {
"from_phone": "+919812345678",
"message_id": "msg_01HZ8QM7XN4A2W1V0PYX9M4C4D",
"text": "Yes, please book me for Saturday"
}
}message.clicked
Recipient tapped a suggestion chip or reply button on a message you
sent as part of a broadcast. data.broadcast_id identifies the
originating broadcast so you can attribute the click.
Sample:
{
"event_type": "message.clicked",
"delivery_id": "wd_3c4d5e6f-7a8b-4c9d-0e1f-2a3b4c5d6e7f",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T09:16:00Z",
"data": {
"broadcast_id": "b0f4a1e2-3d4c-4b5a-9e8f-1a2b3c4d5e6f",
"recipient_phone": "+919812345678",
"message_id": "msg_01HZ8QK4YQ5G3W2N1V0PYX9M4C"
}
}message.expired_revoked
Message's TTL elapsed before the handset acknowledged, so it was pulled from the queue. The recipient will never see it.
Sample:
{
"event_type": "message.expired_revoked",
"delivery_id": "wd_4d5e6f7a-8b9c-4d0e-1f2a-3b4c5d6e7f80",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T13:12:00Z",
"data": {
"broadcast_id": "b0f4a1e2-3d4c-4b5a-9e8f-1a2b3c4d5e6f",
"recipient_phone": "+919812345690",
"message_id": "msg_01HZ8QK4YQ5G3W2N1V0PYX9M4D"
}
}message.revoke_failed
TTL expired but the revoke attempt failed upstream, the message may
still be delivered, or may not, depending on the operator. Treat this
as advisory; downstream reconciliation should still trust the eventual
message.delivered (or its absence).
Sample:
{
"event_type": "message.revoke_failed",
"delivery_id": "wd_5e6f7a8b-9c0d-4e1f-2a3b-4c5d6e7f8091",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T13:12:05Z",
"data": {
"broadcast_id": "b0f4a1e2-3d4c-4b5a-9e8f-1a2b3c4d5e6f",
"recipient_phone": "+919812345690",
"message_id": "msg_01HZ8QK4YQ5G3W2N1V0PYX9M4D"
}
}broadcast.started
A broadcast has transitioned to running, the first send-batch is
in flight. Counters in data reflect the snapshot at emit time; the
sent_count, delivered_count, etc will all be 0 on this event.
Sample:
{
"event_type": "broadcast.started",
"delivery_id": "wd_6f7a8b9c-0d1e-4f2a-3b4c-5d6e7f8091a2",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T09:11:59Z",
"data": {
"broadcast_id": "b0f4a1e2-3d4c-4b5a-9e8f-1a2b3c4d5e6f",
"name": "August launch announcement",
"total_count": 50000,
"sent_count": 0,
"delivered_count": 0,
"read_count": 0,
"clicked_count": 0,
"failed_count": 0
}
}broadcast.completed
Every recipient row has reached a terminal state (sent + delivered /
failed / revoked). The counters in data are the final tallies you'll
see for this broadcast.
Sample:
{
"event_type": "broadcast.completed",
"delivery_id": "wd_7a8b9c0d-1e2f-4a3b-4c5d-6e7f8091a2b3",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T10:47:22Z",
"data": {
"broadcast_id": "b0f4a1e2-3d4c-4b5a-9e8f-1a2b3c4d5e6f",
"name": "August launch announcement",
"total_count": 50000,
"sent_count": 50000,
"delivered_count": 48912,
"read_count": 31201,
"clicked_count": 1120,
"failed_count": 1088
}
}broadcast.cancelled
Broadcast state flipped to cancelled, either the customer clicked
Cancel or an admin killed the job. Counters reflect what actually
went out before the cancellation took hold.
Sample:
{
"event_type": "broadcast.cancelled",
"delivery_id": "wd_8b9c0d1e-2f3a-4b4c-5d6e-7f8091a2b3c4",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T09:34:18Z",
"data": {
"broadcast_id": "b0f4a1e2-3d4c-4b5a-9e8f-1a2b3c4d5e6f",
"name": "August launch announcement",
"total_count": 50000,
"sent_count": 12403,
"delivered_count": 11897,
"read_count": 412,
"clicked_count": 9,
"failed_count": 217
}
}contact.opted_out
A phone number was added to the account opt-out list. This event is
account-scoped (no bot_id on the envelope) because opt-out applies
across every bot on the account. You can no longer send to that
number from any bot until they opt back in.
data.reason is one of:
keyword_stop, user texted STOP / UNSUBSCRIBE / a similar word.provider_optout, the upstream operator signalled an opt-out (e.g. errCode=24).manual, an operator or API caller added the row explicitly.
Sample:
{
"event_type": "contact.opted_out",
"delivery_id": "wd_9c0d1e2f-3a4b-4c5d-6e7f-8091a2b3c4d5",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"occurred_at": "2026-08-08T11:02:45Z",
"data": {
"phone": "+919812345691",
"reason": "keyword_stop"
}
}contact.opted_in
A phone number was removed from the account opt-out list. Only
manual_removed (operator removed via UI or API) is fired today;
there is no upstream START handler.
Sample:
{
"event_type": "contact.opted_in",
"delivery_id": "wd_0d1e2f3a-4b5c-4d6e-7f80-91a2b3c4d5e6",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"occurred_at": "2026-08-08T11:31:10Z",
"data": {
"phone": "+919812345691",
"reason": "manual_removed"
}
}bot.activated
An admin activated a bot slot, the bot is now allowed to send.
Fired every time the admin runs the activation form, including
re-activations that swap the underlying gateway credentials on an
already-active bot. data.status is always "active".
Sample:
{
"event_type": "bot.activated",
"delivery_id": "wd_1e2f3a4b-5c6d-4e7f-8091-a2b3c4d5e6f7",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T08:00:14Z",
"data": {
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"status": "active"
}
}bot.suspended
Admin suspended the bot. Sends are blocked at the gateway until an
admin re-activates. data.suspend_reason is the admin-authored
justification, safe to surface to your operators verbatim.
Sample:
{
"event_type": "bot.suspended",
"delivery_id": "wd_2f3a4b5c-6d7e-4f80-91a2-b3c4d5e6f708",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T15:22:04Z",
"data": {
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"status": "suspended",
"suspend_reason": "Operator flagged repeated policy violations, pending brand review."
}
}bot.reactivated
A previously-suspended bot was re-activated. Fires only from the
Reactivate endpoint (which itself gates on the current status being
suspended), so subscribers can safely treat this as
"suspended → active" without checking prior state.
Sample:
{
"event_type": "bot.reactivated",
"delivery_id": "wd_3a4b5c6d-7e8f-4091-a2b3-c4d5e6f70819",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T16:05:41Z",
"data": {
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"status": "active"
}
}bot.deleted is reserved for a future release. There is no admin
delete endpoint today, suspended bots stay in your bot list so their
history remains queryable, so this event is never fired.
kyc.submitted
The customer submitted the /apply-rcs form. A pending bot slot has
been created and the KYC is now queued for admin review. kyc_id
equals the newly-allocated bot_id.
Sample:
{
"event_type": "kyc.submitted",
"delivery_id": "wd_4b5c6d7e-8f90-4a1b-c2d3-e4f5a6b7c809",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T07:41:18Z",
"data": {
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"kyc_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"kyc_status": "submitted"
}
}kyc.approved
Admin approved the KYC. The bot slot is now eligible for activation;
the admin still runs the activation form separately (which will
subsequently emit bot.activated).
Sample:
{
"event_type": "kyc.approved",
"delivery_id": "wd_5c6d7e8f-9012-4b3c-d4e5-f6a7b8c9d0e1",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T07:58:02Z",
"data": {
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"kyc_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"kyc_status": "approved"
}
}kyc.rejected
Admin rejected the KYC. data.admin_reason is the admin-authored
message the customer will also see in-panel, safe to surface to your
operators verbatim.
Sample:
{
"event_type": "kyc.rejected",
"delivery_id": "wd_6d7e8f90-1234-4c5d-e6f7-a8b9c0d1e2f3",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T07:59:47Z",
"data": {
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"kyc_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"kyc_status": "rejected",
"admin_reason": "Brand website could not be reached from India. Please share a live URL and resubmit."
}
}Both kyc.approved and kyc.rejected fire only when the review
was performed against a specific bot (the modern per-bot flow); the
legacy user-scoped review path is not fanned out as a webhook.
template.submitted
Customer POSTed a template to /api/v1/rcs/templates. Review kicks
off immediately after this event, usually a verdict lands within a
few seconds and you'll see a follow-up template.approved,
template.rejected, or template.needs_admin event with the same
template_id.
Sample:
{
"event_type": "template.submitted",
"delivery_id": "wd_7e8f9012-3456-4d6e-f7a8-b9c0d1e2f304",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T12:10:00Z",
"data": {
"template_id": "8f1a2b3c-4d5e-4f60-91a2-b3c4d5e6f708",
"template_name": "order_shipped_v2",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"category": "transactional",
"language": "en_US"
}
}template.approved
The content review approved the template (a high confidence score with no policy hits), or an admin overrode a prior verdict with an approval. Approved templates are immediately eligible for broadcasts.
The automated first pass includes ai_review_score and ai_review_reason;
the admin-override path omits both and populates admin_reason
instead.
Sample (AI approval):
{
"event_type": "template.approved",
"delivery_id": "wd_8f901234-5678-4e7f-a8b9-c0d1e2f30415",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T12:10:04Z",
"data": {
"template_id": "8f1a2b3c-4d5e-4f60-91a2-b3c4d5e6f708",
"template_name": "order_shipped_v2",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"category": "transactional",
"language": "en_US",
"ai_review_score": 0.94,
"ai_review_reason": "Clean transactional order-update message with unambiguous brand identity."
}
}template.rejected
The content review or an admin rejected the template. Automated
early rejections carry ai_review_reason (and score); later ones carry the
operator's admin_reason.
Sample (admin rejection):
{
"event_type": "template.rejected",
"delivery_id": "wd_90123456-789a-4f80-b9c0-d1e2f3041526",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T12:38:11Z",
"data": {
"template_id": "8f1a2b3c-4d5e-4f60-91a2-b3c4d5e6f708",
"template_name": "flash_sale_v3",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"category": "promotional",
"language": "en_US",
"admin_reason": "Missing an unsubscribe suggestion on a promotional template, please add STOP and resubmit."
}
}template.needs_admin
The first pass could not decide (score between
0.50 and 0.85, or the reviewer was unavailable) and queued it for a
human admin. The template is unusable for broadcasts until the admin
decides. That decision will land as a follow-up template.approved
or template.rejected event with the same template_id.
Sample:
{
"event_type": "template.needs_admin",
"delivery_id": "wd_01234567-89ab-4091-c0d1-e2f304152637",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"occurred_at": "2026-08-08T12:10:04Z",
"data": {
"template_id": "8f1a2b3c-4d5e-4f60-91a2-b3c4d5e6f708",
"template_name": "reactivation_offer",
"bot_id": "48b21f9a-7c3d-4e5f-9a2b-1c2d3e4f5a6b",
"bot_name": "My Store Bot",
"category": "promotional",
"language": "en_US",
"ai_review_score": 0.62,
"ai_review_reason": "Sender brand is clear but the urgency phrasing (\"today only\") warrants a human review."
}
}webhook.ping
Synthetic event fired only by the "Send test event" button on a webhook's page
(POST /api/v1/rcs/webhooks/:id/test). Bypasses the retry queue:
one attempt only, but it is still recorded in the delivery log so
you can inspect the exact envelope your endpoint received.

Sample:
{
"event_type": "webhook.ping",
"delivery_id": "wd_12345678-9abc-40d1-e2f3-041526374859",
"account_id": "3c9f2b8e-1a2b-4c3d-8e5f-9a0b1c2d3e4f",
"occurred_at": "2026-08-08T18:00:00Z",
"data": {
"message": "This is a test event from SplashifyPro RCS Panel."
}
}Signature verification
Always verify BEFORE parsing the body. Use a constant-time
comparison, a plain === leaks timing information.
Node.js
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use(express.raw({ type: "application/json" }));
app.post("/webhooks/splashifypro", (req, res) => {
const sigHeader = req.header("X-SplashifyPro-Signature") || "";
const expected = "sha256=" +
crypto.createHmac("sha256", process.env.WEBHOOK_SECRET).update(req.body).digest("hex");
const ok = sigHeader.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sigHeader), Buffer.from(expected));
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString());
// dedupe on event.delivery_id, then process event.data
res.sendStatus(200);
});Python (Flask)
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["WEBHOOK_SECRET"].encode()
@app.route("/webhooks/splashifypro", methods=["POST"])
def webhook():
raw = request.get_data()
got = request.headers.get("X-SplashifyPro-Signature", "")
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(got, expected):
abort(401)
event = request.get_json()
# dedupe on event["delivery_id"], then process event["data"]
return "", 200Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
var secret = []byte(os.Getenv("WEBHOOK_SECRET"))
func handler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
mac := hmac.New(sha256.New, secret)
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
got := r.Header.Get("X-SplashifyPro-Signature")
if !hmac.Equal([]byte(expected), []byte(got)) {
http.Error(w, "bad signature", 401)
return
}
// dedupe on delivery_id, then process
w.WriteHeader(200)
}PHP
<?php
$secret = getenv('WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$got = $_SERVER['HTTP_X_SPLASHIFYPRO_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $body, $secret);
if (!hash_equals($expected, $got)) {
http_response_code(401);
exit('bad signature');
}
$event = json_decode($body, true);
// dedupe on $event['delivery_id'], then process
http_response_code(200);Ruby
require 'openssl'
require 'sinatra'
SECRET = ENV.fetch('WEBHOOK_SECRET')
post '/webhooks/splashifypro' do
body = request.body.read
got = request.env['HTTP_X_SPLASHIFYPRO_SIGNATURE'].to_s
expected = 'sha256=' + OpenSSL::HMAC.hexdigest('SHA256', SECRET, body)
halt 401 unless Rack::Utils.secure_compare(got, expected)
event = JSON.parse(body)
# dedupe on event['delivery_id'], then process
200
endRetry policy
Any non-2xx response, transport error, or timeout is a transient failure. We retry with exponential backoff:
After 4 failed attempts the delivery is permanently archived to the dead-letter queue with the full envelope preserved. There is no automatic 5th attempt. You have to replay.
The request timeout is 12 seconds. Your endpoint should respond
200 OK as soon as it accepts the event, then do its work
asynchronously. A slow synchronous consumer will land in retry hell.
Delivery guarantees
At-least-once. Your consumer MUST dedupe on delivery_id, the
same event can land twice if:
- Your endpoint returns 2xx but we don't receive the response before timeout.
- Our promoter races two replicas on the retry ZSET (rare, ~ms window).
- You use the "Replay" button in the DLQ.
delivery_id is stable across retries, so a simple SET NX in Redis
keyed on it, with a 7-day TTL, is a safe dedupe primitive.
Dead-letter queue
Every archived delivery shows up at
Settings → Webhooks → Dead-letter queue. Each row has:
- The full envelope (event type, data, occurred_at, delivery_id).
- The last failure reason (HTTP status + snippet, or transport error).
- First-attempt-at vs permanently-failed-at timestamps.
Click Replay to enqueue the same envelope with a fresh
delivery_id. The retry chain restarts from attempt 1. The DLQ row
is deleted on replay so you don't accidentally replay twice.

IP allowlist
Outbound webhook POSTs originate from our server at
103.95.50.202 (since 6 October 2026; before that 103.117.50.102).
Add it to your firewall's allowlist if you gate inbound traffic.
Endpoints
Full CRUD on the panel's webhook subscriptions:
GET /api/v1/rcs/webhooks
POST /api/v1/rcs/webhooks
PATCH /api/v1/rcs/webhooks/{id}
DELETE /api/v1/rcs/webhooks/{id}
POST /api/v1/rcs/webhooks/{id}/test
GET /api/v1/rcs/webhooks/{id}/deliveries?limit=100
GET /api/v1/rcs/webhooks/dead-letter
POST /api/v1/rcs/webhooks/dead-letter/{delivery_id}/replayEvery response includes success: bool. Successful creates + secret
rotations also return secret and a warning message. This is the
only time the secret is surfaced.
Create body
{
"name": "Sales-ops inbox",
"url": "https://api.example.com/webhooks/splashifypro",
"events": ["message.delivered", "message.replied", "broadcast.completed"],
"bot_scope": "48b21f9a-…", // optional, restrict to one bot
"active": true
}urlmust start withhttps://. Loopback + private ranges are rejected at save time and re-checked at delivery time.events, subset of the documented event types (see the Event catalogue). Empty = 400.bot_scope, optional bot UUID. Team members must have the bot in their JWTbotsclaim to create/edit a scoped subscription.
Rotate the signing secret
PATCH /api/v1/rcs/webhooks/{id}
Content-Type: application/json
{ "rotate_secret": true }Returns { "success": true, "secret": "whsec_…", "warning": "…" }. The
old secret is invalidated immediately, update your consumer before
the next event fires.
Test a webhook
POST /api/v1/rcs/webhooks/{id}/testFires a synthetic webhook.ping envelope directly (bypassing the
retry queue) and returns the HTTP status + latency. Useful when wiring
up a new endpoint. The ping IS logged into the deliveries table so
you can inspect the payload afterwards.