Track delivery of a message
Goal: know whether an SMS you sent actually reached the handset — in real time via webhooks, or on demand via the EDR (Event Detail Record) endpoint.
Prerequisites:
- A sent message and its
messageId(returned byPOST /messages— see Send an SMS). - For the webhook path: a publicly reachable HTTPS endpoint — see Receive webhooks.
POST /messages returns 202 Accepted — that only means the platform took the message. Delivery itself is asynchronous, and there are two complementary ways to follow it.
Which path to use
| Use webhooks (push) when… | Use GET /edrs (pull) when… |
|---|---|
| You want real-time status without polling | You cannot host a public HTTPS endpoint |
| You process statuses per message as they happen | You reconcile in bulk (reports, billing, audits) |
| You already run an event-driven backend | You need cost fields and operator details after the fact |
Most integrations use both: webhooks for real-time handling, EDR pulls for reconciliation and as a fallback when a webhook was missed.
Path A — webhooks (messageStatus events)
If a webhook URL is configured (in the request body, on the sender, or on the API key — see Receive webhooks for the resolution order), your endpoint receives a messageStatus event on each state change:
{
"event": "messageStatus",
"message": {
"messageId": "4aab1947-7a34-4efe-8c51-e9c8f9b1412b",
"omniTransactionId": "57f29fdd-1127-45b3-a7d5-260a831a4662",
"channel": 1,
"senderId": "Routeon",
"status": "delivered",
"timestamp": "2026-07-14T15:30:00Z",
"contact": "905324546496",
"cost": "1.00"
},
"reason": { "code": 0, "text": "" }
}
Lifecycle: a message typically goes sent → delivered. Possible status values:
| Status | Meaning |
|---|---|
sent |
Successfully handed to the operator |
delivered |
Operator confirmed delivery to the handset |
failed |
Channel rejected the send |
undeliverable |
Operator could not deliver |
displayed |
User opened the message (not applicable to plain SMS) |
unknown |
Channel did not report a final state |
On negative outcomes, reason.code / reason.text carry the error details when available.
Match events to your sends by message.messageId. Treat delivery as at-least-once: duplicates are possible, so deduplicate by messageId + status.
Path B — polling GET /edrs
EDRs are the per-message delivery log. Two filtering modes (use exactly one):
- By IDs:
messageIds=<id1>,<id2>,…— 1 to 10 IDs per call. - By time range: both
timeFromandtimeTorequired; paginate withoffset/limit(limitis capped at 100).
Optional extra filters in either mode: broadcastRunId, phoneNumbers.
Look up specific messages
curl -sS -H "X-API-Key: <API_KEY>"
"https://client.routeon.io/api/v1/edrs?messageIds=6719b5f4-36af-4fba-b111-49ff07f4f96b"
Expected response:
{
"count": 1,
"limit": 100,
"offset": 0,
"data": [
{
"messageId": "6719b5f4-36af-4fba-b111-49ff07f4f96b",
"channelType": 1,
"senderId": "Routeon",
"incomingDestNumber": "905063565285",
"country": "Turkey",
"countryCode": "TR",
"network": "Turkcell",
"body": "Hello from Routeon",
"clientInfo": "batchId=2026-07-14-001",
"rate": 0.018,
"finalCost": 0.018,
"isSent": true,
"isDelivered": true,
"isSeen": false,
"mdnStatus": "DELIVRD",
"errorMessage": "",
"time": "2026-07-14T08:11:00Z",
"timeSent": "2026-07-14T08:11:01Z",
"timeDelivered": "2026-07-14T08:11:02Z",
"timeSeen": ""
}
]
}
Key fields:
| Field | Meaning |
|---|---|
mdnStatus |
Operator-level status: DELIVRD, UNDELIV, EXPIRED, REJECTD, … |
isSent / isDelivered |
Lifecycle flags |
rate / finalCost |
Per-message price and the final charged amount |
errorMessage |
Error description when the final status is negative |
timeSent / timeDelivered |
Timestamps of each stage |
clientInfo |
The free-form tag you set on send — use it to correlate EDRs with your own batches |
Reconcile by time range
curl -sS -H "X-API-Key: <API_KEY>"
"https://client.routeon.io/api/v1/edrs?timeFrom=2026-07-14T00:00:00Z&timeTo=2026-07-14T23:59:59Z&limit=100&offset=0"
Page through with offset until you have count rows.
Polling pattern
- Group up to 10
messageIds per request. - Poll at a modest rate (about one batch per second is plenty).
- Stop polling a message once
isDeliveredistrueormdnStatusshows a final negative state (UNDELIV,EXPIRED,REJECTD). - Use the time-range mode for nightly reconciliation jobs.
Correlating with your own data
Set clientInfo on every POST /messages (e.g. your order ID or batch ID). It is stored verbatim on the EDR, so you can tie platform delivery records back to your own database rows even without storing every messageId mapping.
Verify
- Webhook: your endpoint logged a
messageStatusevent withstatus: "delivered"for yourmessageId. - Pull:
GET /edrs?messageIds=<id>returns a row withmdnStatus: "DELIVRD"andisDelivered: true.
Common failures
| Symptom | Cause | Fix |
|---|---|---|
| No webhook events arrive | No webhook URL configured at any level (request, sender, API key) — events are dropped silently | Configure a webhook (see Receive webhooks) or use GET /edrs |
400 from GET /edrs |
More than 10 messageIds, or a time-range query missing timeFrom/timeTo |
Split into batches of ≤ 10 IDs; always pass both time bounds |
| EDR row exists but no final status yet | Delivery still in progress — operators can take time to confirm | Keep polling; EXPIRED appears if the operator’s delivery window runs out |
| Duplicate webhook events for the same status | Delivery is at-least-once | Deduplicate by messageId + status |
| Can’t find messages sent within a broadcast | Per-message records for campaigns live in EDRs too | Query GET /edrs with the time range of the campaign, optionally adding the broadcastRunId filter |