RCS Business Messaging
Preview
REST

Webhooks

Receive real-time HTTP callbacks for RCS delivery updates and user engagement — rcs.delivered, rcs.read, rcs.failed, rcs.user_reply, and rcs.suggestion_click. No polling required.

Real-time delivery

Push notifications for delivered, read, and failed states without polling

Engagement events

Capture suggestion clicks and free-form user replies for bot flows

HMAC-SHA256

Verify every payload with X-Sendexa-Signature and your webhook secret

Auto retry

Failed deliveries retry up to 5 times with exponential backoff

Delivery latency

<500ms

event → your URL

Max retries

5

exponential backoff

Event types

5

delivery + engagement

Signature

SHA256

HMAC verification

Event types

rcs.delivered

Message delivered to the handset messaging app (RCS or SMS fallback)

rcs.read

User opened the conversation (when the client reports read receipts)

rcs.failed

Terminal delivery failure with structured error details

rcs.user_reply

Inbound free-text (or media) reply from the end user

rcs.suggestion_click

User tapped a reply / openUrl / dial suggestion chip

Common envelope

Every webhook POST uses JSON with a shared envelope. Event-specific fields live under data.

JSON
{
"event": "rcs.delivered",
"id": "evt_01J5RCS9EXAMPLE0001",
"timestamp": "2026-07-12T10:02:04.010Z",
"businessId": "BU-VOIF86X7",
"data": { }
}
FieldTypeRequiredDescription
eventstring
required
Event name (see table above).
idstring
required
Unique event id — use for idempotent processing.
timestampstring (ISO 8601)
required
When the event was generated (UTC).
businessIdstring
required
Your Sendexa business identifier.
dataobject
required
Event payload (varies by type).

HTTP headers

HeaderDescription
Content-Typeapplication/json
X-Sendexa-SignatureHex-encoded HMAC-SHA256 of the raw body using your webhook secret
X-Sendexa-EventSame as body event field (convenience for routing)
X-Sendexa-DeliveryDelivery attempt number (1–5)
User-AgentSendexa-Webhooks/1.0
Event payloads
JSON
{
"event": "rcs.delivered",
"id": "evt_01J5RCS9DELIVERED001",
"timestamp": "2026-07-12T10:02:04.010Z",
"businessId": "BU-VOIF86X7",
"data": {
"messageId": "MSG-RCS-a91f0c2e-8b44-4d11-9e20-6f3c1b0d8e77",
"agentId": "AGT-SENDXA-GH",
"to": "233555539152",
"channel": "rcs",
"type": "text",
"reference": "wallet-credit-88421",
"network": "MTN",
"rcsCapable": true,
"deliveredAt": "2026-07-12T10:02:04.010Z",
"fallback": {
"enabled": true,
"triggered": false
}
}
}

Signature verification

Compute HMAC-SHA256 over the raw request body using your webhook secret. Compare with X-Sendexa-Signature using a timing-safe equality check.

TypeScript
import crypto from "crypto";
export function verifyRcsWebhookSignature(
rawBody: string | Buffer,
signature: string,
secret: string
): boolean {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const a = Buffer.from(signature || "", "utf8");
const b = Buffer.from(expected, "utf8");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}

Handler examples

JavaScript
import express from "express";
import crypto from "crypto";
const app = express();
const SECRET = process.env.RCS_WEBHOOK_SECRET;
// Important: verify against the raw body
app.post(
"/webhooks/rcs",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = String(req.headers["x-sendexa-signature"] || "");
const expected = crypto
.createHmac("sha256", SECRET)
.update(req.body)
.digest("hex");
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) {
return res.status(401).json({ error: "Invalid signature" });
}
const payload = JSON.parse(req.body.toString("utf8"));
// Idempotency: ignore duplicate event ids
// await db.events.ensureUnique(payload.id)
switch (payload.event) {
case "rcs.delivered":
console.log("Delivered", payload.data.messageId, payload.data.channel);
break;
case "rcs.read":
console.log("Read", payload.data.messageId);
break;
case "rcs.failed":
console.error("Failed", payload.data.messageId, payload.data.error);
break;
case "rcs.user_reply":
console.log("Reply from", payload.data.from, payload.data.text?.body);
// Route into bot / helpdesk
break;
case "rcs.suggestion_click":
console.log(
"Click",
payload.data.suggestion?.type,
payload.data.suggestion?.postbackData ||
payload.data.suggestion?.url ||
payload.data.suggestion?.phoneNumber
);
break;
default:
console.log("Unhandled", payload.event);
}
// Respond quickly — do heavy work async
res.status(200).json({ received: true });
}
);
app.listen(3000);

Retry policy

When we retry

  • Non-2xx HTTP responses
  • Connection timeouts (> 5s)
  • TLS / DNS failures

Backoff schedule

  • Attempt 1: immediate
  • Attempt 2: ~30 seconds
  • Attempt 3: ~2 minutes
  • Attempt 4: ~10 minutes
  • Attempt 5: ~30 minutes

Return 200 quickly. Perform CRM updates, SMS re-sends, or heavy analytics asynchronously so retries are not caused by slow handlers. Use event.id for idempotency.

Conversational pattern

Combine outbound rich cards with inbound events to build lightweight bots without a separate chat product:

  1. Send a card/carousel with reply suggestions and meaningful postbackData.
  2. On rcs.suggestion_click, branch on postbackData (e.g. loan:5k:info).
  3. On rcs.user_reply, parse free text or hand off to a human agent.
  4. Reply with another POST /v1/rcs/send in the same thread (same agentId + user MSISDN).
JavaScript
// Pseudocode routing for Ghana retail support
async function onSuggestionClick(data) {
const key = data.suggestion?.postbackData;
if (key === 'order:confirm') {
await sendRcsText(data.from, 'Thanks! Your order is confirmed for delivery.');
} else if (key === 'instruction:reschedule') {
await sendRcsText(data.from, 'Reply with a preferred day (Mon–Sat) and time window.');
} else if (data.suggestion?.type === 'openUrl') {
// Optional analytics only — URL already opened on device
await track('rcs_open_url', { url: data.suggestion.url, msisdn: data.from });
}
}