SMS DLR status codes explained — what SMD, DELIVRD, UNDELIV and REJECTD actually mean
Published Updated
On this page
Key takeaways
- A synchronous
rc: 100response only means the platform accepted and queued the request, not that the handset received it.- A 1st callback of
SMDmeans submitted to the carrier and charged; only a 2nd callback withdelivery_statusofSS(that is,DELIVRD) means delivered.- The five 1st-callback failure codes (
rc = -100) are not charged and are all avoidable on the sender’s side.UNDELIVis usually transient, so retry once or twice after an interval instead of treating it as permanent failure.- Do not resend
REJECTDas-is; page a human and fix the content or check the sender setup.
Anyone who has integrated SMS has hit this: the API returned rc: 100, the report says success, and the customer says nothing arrived. For the usual reasons behind it, see SMS not getting delivered? The surprising reasons behind it.
The problem sits in a distinction many systems collapse. “We accepted it”, “we submitted it” and “the handset received it” are three different events — and plenty of reporting treats them as one.
Here is how SMS reporting actually works.
Three moments, not one
Between your server and the customer’s phone there are at least three observable states:
- The synchronous response — what you get back immediately from the POST. It means the platform accepted the request and queued it.
- The 1st callback (send request report) — whether the request passed validation and reached the carrier. This tier decides billing.
- The 2nd callback (delivery report / DLR) — the carrier’s report on the final handset state.
These can be seconds or hours apart. If your reporting only records the first, it is essentially telling you how many HTTP requests you issued.
The synchronous response: accepted, nothing more
{ "rc": 100, "rm": "OK", "ref_id": "145737475" }
rc: 100 means the request was well-formed and accepted. ref_id identifies it — both callbacks echo the same ref_id, which is the key that joins the whole chain together.
If the request itself is malformed (a missing required field, say), you get rc: -100 here with the reason in rm.
The 1st callback: submission
This tells you whether the request actually reached the carrier.
Success (rc = 100, charged):
| Status | Meaning |
|---|---|
SMD | A valid send — submitted to the carrier for delivery |
Failure (rc = -100, not charged):
| Status | Meaning | Usually caused by |
|---|---|---|
IM | Incorrect country code or mobile number | Numbers not cleaned — a +, spaces, or a prefix |
OFCA | On the Do-not-call Register and filtered (Hong Kong) | The recipient opted out and you have no consent |
IUL | On the unsend list — not sent | The number previously unsubscribed |
IP | Insufficient points | The account balance ran out |
IS | Invalid or unregistered sender name | The Sender ID is not attached to your account |
All five failures share one property: they are avoidable on your side. Number hygiene, unsubscribe management, balance monitoring, Sender ID binding — all within your control. On registering the Sender ID in the first place, see Registering a # Sender ID in Hong Kong — a practical guide to the OFCA scheme.
IP deserves special attention. If nothing monitors your balance, a large batch that runs out mid-flight fails silently for the remainder. Call /v1/acct/get-points-balance before a big send.
The 2nd callback: the real delivery report
This one only arrives when the carrier reports back. Its fields differ from the 1st callback: the result is in delivery_status, where SS means delivered — the DELIVRD row below — and SF means not delivered, with an extra desc_code holding one of the other codes in the table.
{ "ref_id": "145737475", "rc": 100, "rm": "Carrier has successfully delivered to the recipient's device", "delivery_status": "SS" }
{ "ref_id": "145737477", "rc": 100, "rm": "Undelivered due to unreachable destination, network issues, or recipient", "delivery_status": "SF", "desc_code": "UNDELIV" }
| Status | Meaning | What to do |
|---|---|---|
DELIVRD | Delivered to the recipient device | This is what “delivered” means |
EXPIRED | Validity period elapsed without delivery | Confirm the number is still live before retrying |
UNDELIV | Unreachable destination, network issue, or device off | Retry later — success rate is decent |
REJECTD | Invalid content, blacklisted, or policy restriction | Do not resend as-is — fix content or check sender setup |
UNKNWN | Carrier cannot determine the final status | Do not treat as failure and blindly resend; watch the rate |
The three most commonly mishandled
UNDELIV treated as permanent failure. It is usually transient — phone off, in a tunnel, roaming abroad. Flagging the number as dead throws away list value. Retry once or twice after an interval.
REJECTD fed into an automatic retry. This is the most wasteful pattern there is. A content or policy problem does not resolve by sending again; it just gets rejected again. This status should page a human, not enter a retry queue.
UNKNWN counted as a failure. That makes your delivery rate look worse than it is. Track it as its own bucket instead — a route with a conspicuously high UNKNWN share is itself a signal worth acting on. For why route quality differs, see Grey routes versus direct carrier connections — why cheap SMS costs more.
Reconciling the numbers
Join three counts on ref_id:
- Requests you sent
- 1st callbacks returning
SMD← this is the billing basis - 2nd callbacks returning
delivery_status: SS(DELIVRD)
The gaps between them are the interesting part. You can also pull sms_count and points_used for a date range from /v1/sms/get-usage-report and cross-check against your own records. For how messages are counted into billable segments, see How Hong Kong SMS billing works — characters, Unicode and segmentation.
If a provider gives you a single “sent successfully” number, you have no way to see what happened in between — which is why we pass every failure code through untouched rather than packaging it into a flattering percentage. See Honest Delivery.
Two implementation notes
Make callback handling idempotent. Key on ref_id plus the status (status on the 1st callback, delivery_status on the 2nd). The same callback can be delivered more than once, and if your handler decrements stock or sends email, running twice is a real bug.
Set the Callback URL in the dashboard first. Without it neither tier fires at all — plenty of people debug their own endpoint for a day before discovering nothing was ever being sent. For the full path from creating an API key to receiving a first callback, see Taiwan SMS API quickstart — from sign-up to your first SMS to +886 (cURL, Node.js, Python, PHP).
Full field definitions and example payloads are in the SMS API integration guide.