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 by POST /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 sentdelivered. 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):

  1. By IDs: messageIds=<id1>,<id2>,…1 to 10 IDs per call.
  2. By time range: both timeFrom and timeTo required; paginate with offset / limit (limit is 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

  1. Group up to 10 messageIds per request.
  2. Poll at a modest rate (about one batch per second is plenty).
  3. Stop polling a message once isDelivered is true or mdnStatus shows a final negative state (UNDELIV, EXPIRED, REJECTD).
  4. 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 messageStatus event with status: "delivered" for your messageId.
  • Pull: GET /edrs?messageIds=<id> returns a row with mdnStatus: "DELIVRD" and isDelivered: 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