Splashify RCS

Docs

Open panel
Panel

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.

The Add webhook dialog over Settings, Webhooks, with the name Order system, the URL https://hooks.acmeboutique.in/rcs/events, the bot filter on All bots, and four of 23 events ticked: message.delivered, message.read, message.failed and broadcast.completed
Add webhook in the panel: a name, an https URL and the events you want.

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:

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

http
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_id on 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 only contact.opted_out and contact.opted_in are account-scoped.

Broadcast sends vs direct API sends

Every message.* event fires for BOTH:

  • Broadcasts (fanning out to a contact list), data includes broadcast_id so you can group events by campaign.

  • Direct API sends via POST /api/v1/rcs/messages/send (both the message and template_id shapes), 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_id is OMITTED on the direct-send variant. If your consumer needs to distinguish, filter on data.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:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The Order system webhook page after Send test event, with the green line Test delivered. HTTP 200 · 184ms, and Recent deliveries below whose first row is the webhook.ping attempt with status 200
Send test event on a webhook's page fires webhook.ping. It shows in Recent deliveries too.

Sample:

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

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

python
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 "", 200

Go

go
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

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

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

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

The Dead-letter queue page with three deliveries that failed every attempt, each with its event, the endpoint URL, the reason (http_503 or http_404), when it was first tried and given up on, and a Replay button
Each row is one failed delivery. Replay sends it again.

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:

http
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}/replay

Every 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

json
{
  "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
}
  • url must start with https://. 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 JWT bots claim to create/edit a scoped subscription.

Rotate the signing secret

http
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

http
POST /api/v1/rcs/webhooks/{id}/test

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