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
5
exponential 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","id": "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). |
| id | 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 | Same as body event field (convenience for routing) |
| X-Sendexa-Delivery | Delivery attempt number (1–5) |
| User-Agent | Sendexa-Webhooks/1.0 |
{"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.
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: ~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:
- 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.