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
Authentication
Authorization: Basic <token>. Auth guide →Configure in the dashboard
X-Sendexa-Signature verification.Always verify signatures
<500ms
event → your URL
3
1m / 5m backoff
5
delivery + engagement
SHA256
HMAC verification
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.
{"event": "RCS_DELIVERED","eventId": "evt_01J5RCS9EXAMPLE0001","timestamp": "2026-07-12T10:02:04.010Z","businessId": "BU-VOIF86X7","data": { }}
| Field | Type | Required | Description |
|---|---|---|---|
| event | string | required | Event name (see table above). |
| eventId | string | required | Unique event id — use for idempotent processing. |
| timestamp | string (ISO 8601) | required | When the event was generated (UTC). |
| businessId | string | required | Your Sendexa business identifier. |
| data | object | required | Event payload (varies by type). |
HTTP headers
| Header | Description |
|---|---|
| Content-Type | application/json |
| X-Sendexa-Signature | Hex-encoded HMAC-SHA256 of the raw body using your webhook secret |
| x-sendexa-event | Dot-notation alias of the body event field (e.g. rcs.delivered) — convenience for routing without parsing JSON |
{"event": "RCS_DELIVERED","eventId": "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.
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
import express from "express";import crypto from "crypto";const app = express();const SECRET = process.env.RCS_WEBHOOK_SECRET;// Important: verify against the raw bodyapp.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 / helpdeskbreak;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 asyncres.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: ~1 minute after attempt 1 fails
- Attempt 3: ~5 minutes after attempt 2 fails
- 3 attempts total by default — no further retries after that
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:
- Send a card/carousel with
replysuggestions and meaningfulpostbackData. - On
RCS_SUGGESTION_CLICK, branch onpostbackData(e.g.loan:5k:info). - On
RCS_USER_REPLY, parse free text or hand off to a human agent. - Reply with another
POST /v1/rcs/sendin the same thread (sameagentId+ user MSISDN).
// Pseudocode routing for Ghana retail supportasync 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 deviceawait track('rcs_open_url', { url: data.suggestion.url, msisdn: data.from });}}
Best practices
- Verify signatures on the raw body before JSON parsing side effects.
- Treat webhooks as at-least-once — de-dupe on
event.id. - Inspect
data.channelandfallback.triggeredbefore assuming rich engagement is possible. - For OTPs, mark delivered on either RCS or SMS fallback and avoid double-sending codes.
- See also platform webhooks overview and Send Message.
Preview note
data before General Availability — ignore unknown fields defensively.