Skip to content

Delivery reports

A 200 from the send endpoint means accepted and queued. A delivery report is how you find out what actually happened.

Set a webhook URL and we push every outcome to you. Set none and the outcome is only visible in the reports.

Queued ──submit──▶ Dispatched ──receipt──▶ Delivered
│ └─▶ Undelivered
│ └─▶ Rejected
└──(no response)──▶ Sent
any pre-wire failure ─────────────────────▶ Failed
StatusMeaningTerminal
QueuedAccepted, billed, waiting for a worker.
DispatchedHanded to the gateway, awaiting a receipt.
SentSubmitted, but no response came back. Indeterminate.
DeliveredIt reached the handset.
UndeliveredIt did not, and will not — expired, deleted, unreachable.
RejectedRefused by the network or the platform.
FailedFailed before it ever went out.

`Sent` is not `Delivered`, and not a failure either

Sent is reserved for one specific case: the message reached the wire but no acknowledgement came back, so nobody knows whether it landed.

Such a message is never re-sent and never refunded — re-sending risks delivering twice, and refunding risks giving away a message that arrived. A late receipt resolves it.

Treat it as “in flight” in your UI, not as an error. In the reports it is grouped with Dispatched under the sent filter for exactly this reason: from your side, both mean gone, not yet confirmed.

Set your endpoint once, in the portal: Settings → Webhooks → RCS. Every RCS event for the account is posted there — delivery outcomes and replies alike.

One URL per account, and that is the only place it is set — the send endpoints take no callback_url of their own. Leave it empty and events are still recorded and visible in the reports, just not pushed.

{
"event": "message.delivered",
"message_id": "8f2c41ba9d7e40a1b5c3",
"reference_id": "8f2c41ba9d7e40a1b5c3",
"rbm_message_id": "2f5a1c9e-7b04-4f8a-9d31-6c0e8a2b4d17",
"agent": "FirstDigits",
"destination": "2348020000000",
"status": "Delivered",
"raw_event": "READ",
"timestamp": "2026-09-02T14:31:08Z"
}
FieldDescription
eventWhat happened. Switch on this.
message_idThe referenceId the send returned. This is your join key.
reference_idThe same value, under the name the send response used.
rbm_message_idThe RBM platform’s own id. Useful when raising a ticket.
agentThe agent it was sent from.
destinationThe recipient.
statusThe message’s status, in the same words the reports use.
raw_eventThe provider’s own event name, before we mapped it.
timestampRFC 3339 UTC.

Four event names, and no others will appear:

eventMeaning
message.deliveredIt reached the handset.
message.undeliveredIt did not, and will not.
message.failedRefused, or failed before it went out.
message.receivedSomeone replied to your agent.

message.received carries the reply instead of a delivery outcome:

{
"event": "message.received",
"raw_event": "message",
"agent_id": "firstdigits_agent",
"msisdn": "2348020000000",
"text": "STOP",
"rbm_message_id": "9c1e4a77-2b30-40de-8f61-1a5d3e90c284",
"kind": "message",
"raw": { }
}

raw is the provider’s original body, passed through untouched. Read it if you need something we do not surface yet — but do not build on its shape: it belongs to the RBM platform and changes without notice.

app.post('/hooks/rcs', express.json(), async (req, res) => {
// 1. Acknowledge first. Do the work after.
res.sendStatus(200);
const { event, message_id, status, timestamp } = req.body;
if (event === 'message.received') {
await handleReply(req.body.msisdn, req.body.text);
return;
}
// 2. Be idempotent: the same outcome can arrive more than once.
await db.messages.updateOne(
{ referenceId: message_id, outcome: null },
{ $set: { outcome: status, doneAt: timestamp } },
);
});

Three things your endpoint must do:

  1. Return 2xx quickly. A non-2xx is retried — five attempts, a minute apart — after which the event is abandoned and the outcome is only in the reports. Acknowledge first, process after.
  2. Be idempotent. Retries and repeated receipts both mean the same event can arrive more than once. Key on message_id and ignore an outcome for a message already in a terminal state.
  3. Tolerate unknown event values. Ignore what you do not recognise rather than erroring on it — an error costs you the retry budget for events you do care about.

Without a webhook URL, or to reconcile after the fact, query the reports. Timestamps are YYYY-MM-DD HH:MM in your account’s local time.

POST/ng/v1/rcs/reports/messages

Terminal window
curl -X POST https://api.9bits.net/ng/v1/rcs/reports/messages \
-H "Authorization: Bearer $NINEBITS_RCS_KEY" \
-H "Content-Type: application/json" \
-d '{
"startDateTime": "2026-09-01 00:00",
"endDateTime": "2026-09-02 23:59",
"selectedStatus": "undelivered",
"page": 1,
"pageSize": 50
}'

selectedStatus accepts all, delivered, undelivered, sent, queued, rejected and failed. An unrecognised value shows everything rather than nothing — so a typo produces a confusingly large result set, not an empty one.

Companion endpoints:

EndpointReturns
POST /ng/v1/rcs/reports/messages/kpiSummary counts across the whole filter, not just the page.
POST /ng/v1/rcs/reports/messages/exportThe full range as JSON rows plus a filename.
POST /ng/v1/rcs/reports/broadcastsYour campaigns and their progress.
POST /ng/v1/rcs/reports/broadcasts/messagesPer-recipient drill-down for one campaign.
POST /ng/v1/rcs/reports/broadcasts/exportA real CSV file, named in Content-Disposition.

All of them are runnable in the API reference.

Undelivered on RCS often means the handset simply does not have it — not a transient failure worth retrying on the same channel.

The usual pattern is to fall back once, to SMS:

if (outcome === 'undelivered' && !message.smsFallbackSent) {
await sendSms({ from: 'FirstDigits', to: [dest], content: message.plainText });
await db.messages.updateOne({ referenceId: message_id },
{ $set: { smsFallbackSent: true } });
}

Guard it with a flag. Without one, a duplicate delivery report sends the SMS twice — see Idempotency & retries.