RCS Incoming Events
Every Route Mobile callback for a customer that is RCS-Active under your partner account is forwarded to your account-level Webhook URL (set under Settings → Webhook URL on the partner panel).
There are two sources of these callbacks:
- Inbound messages — the end-user sent a text, media file, location, or tapped a suggestion.
- Status updates — a message you sent via
POST …/customers/{customer_id}/rcs/sendchanged state (sent → delivered → read, or failed).
Inbound events are routed to your partner account based on the customer the message was sent to. You only ever see events for customers under your account — there is no cross-tenant leakage.
Event types
| Event | When | RM event_type |
|---|---|---|
rcs.message.text | End-user replied with text | text_message |
rcs.message.media | End-user sent image / video / PDF / other file | media_message |
rcs.message.location | End-user shared a location | location_message |
rcs.suggestion.response | End-user tapped a suggestion (reply / URL / dial / calendar / location) | response |
rcs.status.sent | Message you sent was accepted by the recipient’s carrier | status + SENT |
rcs.status.delivered | Message was delivered to the recipient’s device | status + DELIVERED |
rcs.status.read | Recipient opened the message | status + READ |
rcs.status.failed | Message could not be delivered | status + FAILED |
rcs.status.unknown | RM emitted a message_status we don’t recognise — surface verbatim | status + other |
Envelope
Same envelope as the rest of the account-level webhooks (production access, account deletion, RCS KYC, etc.):
{
"event_type": "rcs.message.text",
"partner_id": "11111111-2222-3333-4444-555555555555",
"occurred_at": "2026-05-20T08:26:32.082Z",
"data": {
"customer_id": "8f3b2a1e-49d8-4c2d-9e1a-b7e6d5f8a3c2",
"bot_name": "SplashifyPro_promo",
"rm_event": { /* unmodified Route Mobile payload — see below */ }
}
}data.rm_event is the raw body Route Mobile delivered to us. The
normalised fields we extract for routing live alongside (customer_id,
bot_name), but everything else is in rm_event for partners who want
to read RM’s payload directly without us getting in the way.
Inbound payloads
rcs.message.text
{
"event_type": "rcs.message.text",
"partner_id": "...",
"occurred_at": "2026-05-20T08:26:32.082Z",
"data": {
"customer_id": "...",
"bot_name": "SplashifyPro_promo",
"rm_event": {
"bot_name": "SplashifyPro_promo",
"event_type": "text_message",
"request_id": "MxbipjUYLMRD27ZaJRMaSKgA2",
"session_id": "ecfd6ac3-3c9c-4dfc-97df-9e759c2a93d5",
"text_message": "Hi",
"timestamp": "2026-05-20T08:26:32.080697Z",
"user_contact": "+919876543210",
"username": "demo"
}
}
}rcs.message.media
rm_event.media_type is one of image, video, application
(documents). media_uri is a temporary Google RCS blob URL — fetch
within minutes if you want to persist the file.
{
"event_type": "rcs.message.media",
"partner_id": "...",
"occurred_at": "2026-05-20T08:27:44.448Z",
"data": {
"customer_id": "...",
"bot_name": "SplashifyPro_promo",
"rm_event": {
"bot_name": "SplashifyPro_promo",
"event_type": "media_message",
"media_name": "5023611655770832826.jpg",
"media_size": "67541",
"media_type": "image",
"media_uri": "https://rcs-copper-ap.googleapis.com/blob/...",
"request_id": "MxWpHXTmckS3GRVTVa6VMydg2",
"session_id": "ecfd6ac3-3c9c-4dfc-97df-9e759c2a93d5",
"timestamp": "2026-05-20T08:27:44.445575Z",
"user_contact": "+919876543210",
"username": "demo"
}
}
}rcs.message.location
{
"event_type": "rcs.message.location",
"partner_id": "...",
"occurred_at": "2026-05-20T08:28:25.144Z",
"data": {
"customer_id": "...",
"bot_name": "SplashifyPro_promo",
"rm_event": {
"bot_name": "SplashifyPro_promo",
"event_type": "location_message",
"latitude": "12.972652",
"longitude": "77.7110861",
"request_id": "Mx93B8zFOdSzCySLkCcHxxIQ2",
"session_id": "ecfd6ac3-3c9c-4dfc-97df-9e759c2a93d5",
"timestamp": "2026-05-20T08:28:25.142098Z",
"user_contact": "+919876543210",
"username": "demo"
}
}
}rcs.suggestion.response
End-user tapped one of the suggestions you attached to a previous
message. rm_event.suggestion_type is reply, action (URL or dial),
or view_location. The response_postback matches the postback you
sent with the suggestion — use it as a stable correlation key.
{
"event_type": "rcs.suggestion.response",
"partner_id": "...",
"occurred_at": "2026-05-20T10:49:08.724Z",
"data": {
"customer_id": "...",
"bot_name": "SplashifyPro_promo",
"rm_event": {
"bot_name": "SplashifyPro_promo",
"event_type": "response",
"request_id": "MxU4UwnudnTuuexqE6ZSYeDQ2",
"response_postback": "track",
"response_text": "Track order",
"session_id": "ecfd6ac3-3c9c-4dfc-97df-9e759c2a93d5",
"suggestion_type": "action",
"timestamp": "2026-05-20T10:49:08.720679Z",
"user_contact": "+919876543210",
"username": "demo"
}
}
}Status payloads
Status events reference the message_id you got back from the send
call. Use it as your join key against your own outbound table.
rcs.status.sent / delivered / read
{
"event_type": "rcs.status.delivered",
"partner_id": "...",
"occurred_at": "2026-05-25T12:58:13.683Z",
"data": {
"customer_id": "...",
"bot_name": "SplashifyPro_promo",
"rm_event": {
"bot_name": "SplashifyPro_promo",
"event_type": "status",
"message_id": "61c11dfe-5839-11f1-a96f-0a58a9feac021",
"message_status": "DELIVERED",
"timestamp": "2026-05-25T12:58:13.683308Z",
"user_contact": "+917014311953",
"username": "EvolveRCS",
"extra": "lifecycle_trial_expired"
}
}
}sent and read payloads are identical apart from the event_type
and the rm_event.message_status field.
About rm_event.extra — your passthrough field
Whatever string you passed in the extra field of the original
Send RCS call comes back here
verbatim on every status callback. We don’t interpret, modify, or
truncate it. Common uses:
- Idempotency key — set
extrato a UUID per logical send; if network retries cause the same logical message to send twice, you see the sameextraon both delivery callbacks and dedupe on your side. - Lifecycle tag — set
extrato your internal campaign / segment name (e.g.lifecycle_trial_expired,cart_abandon_24h). Pivot delivery rates by tag without a separate join table. - Trace ID — set
extrato the trace ID from your job system, so the callback lands in the same trace as the originating send.
If you don’t send extra, the field is omitted from the callback.
rcs.status.failed
{
"event_type": "rcs.status.failed",
"partner_id": "...",
"occurred_at": "2026-05-20T08:12:12.903Z",
"data": {
"customer_id": "...",
"bot_name": "SplashifyPro_promo",
"rm_event": {
"bot_name": "SplashifyPro_promo",
"event_type": "status",
"failure_reason": "user is not in a conversation and provided message template is not approved",
"message_id": "faea56dc-2b1a-11f0-a4b9-0a58a9feac02",
"message_status": "FAILED",
"timestamp": "2026-05-20T08:12:12.900Z",
"user_contact": "+919876543210",
"username": "demo",
"extra": "cart_abandon_24h"
}
}
}rm_event.extra is also echoed on failure callbacks, so your
idempotency / lifecycle / trace tagging works end-to-end even when
the send doesn’t reach the recipient.
failure_reason is the carrier-side reason. Common values:
- “user is not in a conversation and provided message template is not approved” — recipient hasn’t opted in / your bot is sandboxed.
- “user device does not support RCS” — fall back to SMS / WhatsApp.
- “invalid recipient number” — your
phone_nowas rejected.
Headers
Same headers as every account-level webhook:
| Header | Value / purpose |
|---|---|
Content-Type | application/json |
User-Agent | Splashify-Pro-Webhook/1.0 |
X-Splashify-Event | rcs.message.text / rcs.status.delivered / … |
X-Splashify-Delivery-ID | UUID per delivery attempt — idempotency key |
X-Splashify-Timestamp | Unix seconds — replay protection |
Signature verification is not emitted on this surface today. Pin the source by hostname (
api.splashifypro.com) if you need stricter auth.
Node handler example
app.post("/webhooks/splashify", express.json(), async (req, res) => {
const event = req.headers["x-splashify-event"];
const { data } = req.body;
switch (event) {
case "rcs.message.text":
await store.inbound({
customer_id: data.customer_id,
from: data.rm_event.user_contact,
text: data.rm_event.text_message,
session_id: data.rm_event.session_id,
});
break;
case "rcs.suggestion.response":
// Use the postback you sent originally to route the reply
await store.handleSuggestion(data.rm_event.response_postback, data);
break;
case "rcs.status.sent":
case "rcs.status.delivered":
case "rcs.status.read":
await store.updateOutbound(data.rm_event.message_id, event.split(".").pop());
break;
case "rcs.status.failed":
await alerts.failedRCS(data.rm_event.message_id, data.rm_event.failure_reason);
break;
}
res.status(200).end();
});Delivery semantics
- Fire-and-forget. Route Mobile gets a 200 OK from us regardless of whether your endpoint succeeds. A failed delivery doesn’t queue a retry — RM doesn’t know about your endpoint.
- No retries on this surface. 4xx/5xx from your endpoint is logged and dropped. Keep the endpoint healthy.
- 10 s timeout. If your endpoint doesn’t respond in 10 s the request is cancelled. Acknowledge fast, process async.
- Order is not guaranteed.
sent,delivered, andreadfor the samemessage_idcan arrive out of order. Always trust the latestrm_event.timestamp.
Idempotency
X-Splashify-Delivery-ID is unique per HTTP delivery attempt. For the
underlying event, use:
- inbound messages:
rm_event.request_id(RM’s own dedupe key) - status events:
(rm_event.message_id, event_type)