All posts

The Sailup team · · 12 min read

SMS Delivery Reports Explained: What Sent, Delivered, Failed, Expired and Rejected Actually Mean

Delivered means the carrier confirmed the handset got your SMS. Sent means the carrier has it but has not reported back. Failed, Expired and Rejected are final, and each one points to a different fix.

A delivery report is the carrier telling Sailup what happened to a message after it left our hands. Delivered means the carrier confirmed the message reached the handset. Sent means the carrier has accepted it and has not reported back yet, so it can still turn into anything. Failed, Expired and Rejected are final, and each points at a different problem: a carrier-side failure, a phone that stayed off too long, or a number the network would not take. Nothing in a report is settled until the carrier has spoken.

That is the whole model. What follows is how each label appears in the API and the dashboard, how the delivery rate is worked out, why messages fail in Ghana specifically, and how to have reports pushed to you instead of asking for them.

How a message travels

There are five hops, and it helps to know which ones Sailup controls.

  1. Your code calls POST /v1/sms/, or someone opens Messages in the dashboard and presses Send message.
  2. Sailup checks the request and your balance, and queues the message. The API answers 202 Accepted with status: queued and delivery_status: pending.
  3. Sailup hands the message to the carrier that serves the number: MTN, Telecel or AirtelTigo. The dashboard now shows Sent.
  4. The carrier tries to deliver it to the handset. If the phone is on and in coverage this takes seconds. If not, the carrier keeps trying for a while and then gives up.
  5. The carrier sends a delivery report back to Sailup. That report sets delivery_status to its final value, and it is what the dashboard, the project stats and your webhooks are built on.

Hop 4 belongs entirely to the carrier, and it is where nearly every interesting outcome happens. Sailup cannot see inside it. All we get is the report at hop 5, which is why a message stays at Sent until that report arrives.

The two status fields, and what the dashboard calls them

A message in the API carries two status fields, and reading the wrong one is the commonest source of confusion we see.

delivery_status is the one to use. It reads pending until the carrier reports back, then one of delivered, failed, expired or rejected.

status is the older dispatch state and is deprecated. It only says whether Sailup got the message out of the door: pending_balance, queued, dispatched or failed. A status of failed means the message never went to a carrier at all, for example because there was no balance to cover it. That is a different thing from a carrier reporting a delivery failure, and mixing the two up makes a billing problem look like a network problem.

Here is how the fields and the dashboard labels line up.

Dashboard labelAPI field and valueWhat it meansWhat to do
Pending balancestatus: pending_balanceWaiting for credit. Auto top-up is on and the balance ran out part-way through a send.Nothing, unless the balance stays red. Then check the card.
Pendingstatus: queuedQueued on Sailup's side, not yet handed to a carrier.Wait. This is normally seconds.
Sentstatus: dispatched, delivery_status: pendingThe carrier has it and has not reported back. Counted as unconfirmed.Wait. Do not treat it as delivered or as failed.
Delivereddelivery_status: deliveredThe carrier confirmed it reached the handset.Nothing. This is the only confirmation you will get.
Faileddelivery_status: failedThe carrier reported a failure it did not classify as expired or rejected.Check the number and the sender ID. Retry once, later, if the message still matters.
Failed, with the detail "Expired — the carrier gave up delivering"delivery_status: expiredThe phone stayed off or out of coverage for longer than the carrier would keep trying.Retry later if it still matters. Flag the number if it keeps happening.
Failed, with the detail "Rejected — the carrier refused the number"delivery_status: rejectedThe carrier would not accept the number.Check the number. If it rejects again, remove it.

Sent is not Delivered

The label that causes the most trouble is Sent, because it sounds like a result. It is not. It means the carrier took the message and has said nothing since. The project stats call it unconfirmed, and the arithmetic is simple: delivered + failed + unconfirmed = sent.

Most messages leave Sent within seconds. The ones that linger are the ones where the phone was off, and those resolve to Delivered when it comes back on or to Expired when the carrier stops trying. So a report you read the moment a campaign finishes will show a bank of unconfirmed messages, and the same report an hour later will show most of them settled.

Two practical consequences. First, do not build logic that treats Sent as success. An OTP flow that marks a code as received at hop 3 will tell you a customer got a code that expired unread in a switched-off phone. Second, do not treat Sent as failure either, and do not resend into it. If a customer says they got nothing, check whether the message is still unconfirmed before you send a second one, because the second one will sit in the same carrier queue as the first.

How Sailup calculates delivery rate

Every project has a stats view with sent, delivered, failed, unconfirmed, delivery rate, failure rate, total spent and the change against the previous period. The delivery rate counts carrier-confirmed deliveries only. A message that is Sent and unconfirmed is not delivered, so it does not count towards the rate, even if it almost certainly arrived.

A worked example. You send 1,000 messages. Ten minutes later the carriers have confirmed 910, reported 40 as failed, expired or rejected, and said nothing about 50. Delivery rate: 91%. Failure rate: 4%. The remaining 5% are unconfirmed. By the evening, 45 of the 50 have been delivered and 5 have expired, so the same period now reads 95.5% delivered and 4.5% failed.

Reading it:

  • Above 90% on transactional traffic (OTPs, receipts, alerts the customer asked for) is where we would expect a healthy integration to sit. That is our own rule of thumb, not an industry study. We do not think open-rate or benchmark statistics for SMS are dependable, and delivery reports are the only measurement you can trust.
  • A low rate on a fresh report is usually unconfirmed messages, not failures. Look at the split before you worry.
  • A rate that drops between periods on the same kind of traffic means something changed: a bad import, a sender ID problem, or a list that has aged.
  • Promotional lists tend to run lower than transactional traffic, because a list collected months ago contains numbers that have since gone quiet. That is the list, not the network.

Why messages fail in Ghana, and what to do about each

A wrong or inactive number. This is the biggest single cause on any list that was typed in or imported from a spreadsheet. A digit dropped from 0241234567, a leading zero lost by Excel, a number that was issued years ago and has since been recycled. The carrier reports Rejected. Sailup accepts both the local form (0201234567) and the international form (+233201234567), so the format is rarely the issue; the digits are. Fix the number if you can, and remove it if it rejects a second time.

A phone that has been off for days. The carrier holds the message and retries, then gives up and reports Expired. In Ghana this is very often a second SIM that lives in a drawer, a feature phone with a dead battery, or someone travelling through poor coverage. The number is fine; the phone was not reachable. For a time-sensitive message, resend when it matters again. For a campaign, do not resend at all; the message expired precisely because nobody was there to read it.

The carrier refused the number. Also Rejected, but the number can look valid. Sometimes it belongs to a range the carrier has not put into service, sometimes the SIM has been deactivated. Treat it like a wrong number: check once, then remove.

A sender ID that is not approved. Every message goes out under a sender ID registered on the project, and that name has to be Active. A name still Pending with the carriers, a Rejected one, or one Sailup has withdrawn because the traffic stopped matching what was registered, will not carry your messages the way you expect. Open the Sender IDs page and check the status before you look anywhere else. The sender ID post covers why names get rejected and what to resubmit.

The message never reached a carrier at all. This is the one that has nothing to do with the network. With auto top-up off, a send your balance cannot cover is rejected outright with insufficient_balance and nothing goes out. With auto top-up on, Sailup sends what the balance covers and holds the rest in pending_balance until the top-up lands; if the card is declined, the credits never arrive and the balance stays red. Either way the symptom is a message that never shows Sent. Look at the balance before the report. Auto top-up is the fix for the first case and the thing to check for the second.

Have the report pushed to you

Polling GET /v1/sms/{message_id}/ works for a one-off check:

bash
curl https://api.sailup.io/v1/sms/58cf292d-417e-4f61-9687-662b845574cf/ \
  -H "Authorization: Bearer sailup_a1b2c3..."

The response carries both fields, so you can read delivery_status straight off it. To reconcile a batch, GET /v1/sms/ lists messages with page and page_size (30 by default, up to 1,000) in a {count, next, previous, results} envelope. Both are in the SMS API docs. But polling every message you send is wasteful, and you never know when to stop asking. Webhooks are the better tool.

Register one per project in the dashboard under Settings. The endpoint must be HTTPS. There are four events, and they are all terminal outcomes:

  • message.delivered
  • message.failed
  • message.expired
  • message.rejected

Nothing fires for queued, sent or dispatched, because those are not results. If you subscribe and never receive a call for a message, the message is still unconfirmed.

Subscribe to all four. Leaving the event list empty does that. The mistake we see is subscribing to message.failed alone on the theory that it covers anything that went wrong. It does not. A phone that stayed off produces message.expired, and a refused number produces message.rejected, and if you only listen for message.failed you will never hear about either. Both are common.

Every event carries the same shape:

json
{
  "event": "message.delivered",
  "created_at": "2026-08-30T18:05:00Z",
  "data": {
    "message_id": "58cf292d-417e-4f61-9687-662b845574cf",
    "project_id": "a7bd4b07-7e58-4785-933c-424d728a2790",
    "recipient": "+233241234567",
    "sender": "YourBrand",
    "delivery_status": "delivered"
  }
}

message_id matches the id you got back from the send, which is how you tie the report to your own record. Each request also carries X-Sailup-Signature, X-Sailup-Timestamp, X-Sailup-Event and X-Sailup-Delivery-Id headers. Verify the signature: it is an HMAC-SHA256 of the timestamp, a full stop and the raw request body, using the secret shown once when you registered the webhook. Sign the raw bytes, not re-serialised JSON, and compare with a constant-time function. The webhooks documentation has a working verifier you can paste.

The retry rules matter for how you write the handler. Sailup treats any non-2xx response as a failure and retries up to five times with exponential backoff. Every attempt is recorded in the delivery log in the dashboard. After ten consecutive failed delivery cycles the webhook is disabled and the project owner gets an email; you re-enable it from the dashboard and can retry past deliveries from the log. So respond with a 2xx immediately and do the work afterwards. A handler that writes to a slow database before answering, and times out, will get itself disabled during the exact campaign you wanted reports for.

Reading a campaign report

A campaign report in the dashboard shows total recipients, sent, delivered, failed, opted out, delivery rate, and the triggered and completed times. Three things are worth knowing.

Opted out is not a failure. It is the number of contacts the campaign skipped because their opted_in flag was off. They were never sent, and they cost nothing. Numbers typed directly into a campaign are not checked against opt-out, so they never appear here.

Sent will settle upwards. The same delivered + failed + unconfirmed = sent rule applies, so a report opened the moment the campaign completes is a snapshot with unconfirmed messages in it. Give it an hour before you judge.

Credits tell you the segment count. A campaign consumes recipients multiplied by segments per recipient. If your balance dropped by double the recipient count, the message was two segments, most likely because it ran past 160 characters or contained a character that flipped it to Unicode. The character limit post explains how one curly quote does that.

Keep the list clean

Delivery reports are the only feedback a list ever gives you, so use them. Our own rules of thumb:

  1. Rejected twice, remove. One rejection is worth a check; two means the number is wrong or gone.
  2. Expired on three consecutive campaigns, remove. That is a SIM nobody is using. Keeping it costs a credit every send and drags your rate down.
  3. Prune between campaigns, not during. Correct what you can, then delete the rest, from the dashboard or through the contacts API.
  4. Fix imports at the source. If a whole import rejects, the spreadsheet lost its leading zeros or a column was matched wrongly. Re-import; the Review step flags invalid rows before anything is saved.

If you are looking at a report right now: ignore Sent for an hour, remove anything Rejected twice, and check the sender ID and the balance before you blame the network. Then register a webhook for all four events so the next report comes to you. The webhook docs take about ten minutes to work through.

Frequently asked questions

What is the difference between sent and delivered in SMS?

Sent means the carrier has accepted the message from Sailup and has not yet said what happened to it. Delivered means the carrier sent back a delivery report confirming the message reached the handset. A sent message can still become delivered, expired or failed, so treat it as unconfirmed rather than as a success.

Why does my SMS delivery report say expired?

Expired means the carrier tried to deliver the message, could not reach the phone, and eventually gave up. The usual cause is a handset that was switched off, out of coverage or without its SIM for longer than the carrier was willing to keep trying. The number itself may be fine. If it keeps expiring across several sends, the SIM is probably no longer in use.

What does rejected mean on an SMS delivery report?

Rejected means the carrier refused to accept the message for that number. It usually points at the number rather than the message: a digit missing or added, a number that was never issued, or one the network has taken out of service. Check the number once. If it rejects again, remove it from your contact lists so you stop paying to send to it.

How is SMS delivery rate calculated?

On Sailup, delivery rate is the share of sent messages the carrier confirmed as delivered. Only carrier-confirmed deliveries count. Messages still waiting for a report are unconfirmed, and delivered plus failed plus unconfirmed equals sent. Because unconfirmed messages settle over time, the rate for a period keeps rising for a while after the sends go out.

Can I get SMS delivery reports without polling the API?

Yes. Register an HTTPS webhook in your project settings and Sailup will POST to it whenever a message reaches a final outcome: delivered, failed, expired or rejected. Each call carries the message id, recipient, sender and delivery status, plus a signature header you can verify. Webhooks do not fire for queued or sent, because those are not final.

Should I delete contacts whose messages keep failing?

Delete the ones that reject or expire every time. A number that rejects twice is almost certainly wrong or out of service, and a number that expires on three consecutive campaigns is probably a SIM nobody uses. Keeping them costs a credit on every send and drags your delivery rate down. Remove them from the dashboard or with the contacts API.

Send your first SMS in five minutes.

No setup fees, no contracts — pay only for what you send, and volume discounts when you scale.