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.

Pass a callback_url and we push it to you. Skip it 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.

Pass callback_url on the send, and we POST JSON to it when a terminal receipt arrives:

{
"message_id": "8f2c41ba9d7e40a1b5c3",
"id_smsc": "a41f9e2b",
"status": "DELIVRD",
"error_code": "",
"dest_addr": "2348020000000",
"source_addr": "FirstDigits",
"done_date": "2026-09-02T14:31:08Z"
}
FieldDescription
message_idThe referenceId the send returned. This is your join key.
id_smscThe gateway’s own identifier. Useful when raising a ticket.
statusThe raw gateway code — see the mapping below.
error_codeThe gateway’s failure code, empty on success.
dest_addrThe recipient.
source_addrThe sender that actually went out — which may differ from what you passed.
done_dateRFC 3339 UTC.
app.post('/hooks/rcs', express.json(), async (req, res) => {
// 1. Acknowledge first. Do the work after.
res.sendStatus(200);
const { message_id, status, error_code, done_date } = req.body;
const outcome =
status === 'DELIVRD' ? 'delivered'
: ['UNDELIV', 'EXPIRED', 'DELETED', 'UNDELIVERABLE'].includes(status) ? 'undelivered'
: ['REJECTD', 'REJECTED'].includes(status) ? 'rejected'
: null; // intermediate — ignore it
if (!outcome) return;
// 2. Be idempotent: the same terminal report can arrive more than once.
await db.messages.updateOne(
{ referenceId: message_id, outcome: null },
{ $set: { outcome, errorCode: error_code, doneAt: done_date } },
);
});

Three things your endpoint must do:

  1. Return 2xx quickly. A non-2xx is treated as a failure and logged; the report is not queued for a later retry, so a slow or erroring endpoint loses the notification.
  2. Be idempotent. Key on message_id and ignore a report for a message already in a terminal state.
  3. Tolerate unknown status values. Codes you have not seen are intermediate, not failures.

Without a callback_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.