When a text fails to send, the cause sits in one of two places. Either the API refused your request, or the API accepted it and the carrier dropped it later. These two failures need different code. Treating them as the same is how platforms end up retrying an opted-out number six times, or giving up on a send that only hit a temporary timeout.
This post covers the API side: how to sort responses into "fix the request", "fix the account", "retry" and "not actually an error", and then how to log and surface each one so your support team isn't reading raw JSON. For the carrier side (filtering, unregistered traffic, content blocks), our guide why business texts aren't sending goes through the carrier errors by type. We make Ready, so the examples use Ready's API. The retry and logging patterns work with any provider.
Two layers of failure, two kinds of handling
A send passes through two checkpoints:
- The API checkpoint. The request reaches the provider and gets validated against your account: auth, request shape, number format, opt-out status, whether the account can send at all. A failure here comes back synchronously, in the HTTP response to your
POST. - The carrier checkpoint. The message has been accepted and handed to carrier infrastructure, and it can still be filtered, throttled or undeliverable. A failure here arrives later, through message status, not in the response to your request.
The common mistake is reading a successful HTTP response as "delivered". It means "accepted". Keep the two states as separate columns in your database. Only the second one tells you whether the customer's customer got the text.
Read the status class before you read the body
We don't reproduce Ready's exact error codes and response fields here. They live in the docs at docs.tryready.com/api/errors, and your parser should follow those, not a blog post. The general HTTP conventions are stable enough to build your retry logic on:
| Status class | What it usually means | Retry? | Who fixes it |
|---|---|---|---|
| 2xx | Accepted (not necessarily delivered) | No | Nobody, record the acceptance |
| 400 / 422 | Bad request: malformed number, missing field, too many recipients | No | Your code |
| 401 / 403 | Bad or missing API key, or the key can't do this action | No | Your config |
409 / refusal-type 4xx (not QUIET_HOURS_BLOCK) | The send was refused for a recipient-level reason (opt-out, blocklist) | No | Nobody, suppress the contact |
409 QUIET_HOURS_BLOCK | Outside the recipient's hours, and the window opens too far away to hold the text | Yes, after next_allowed_at, with a new key | Time |
| 429 | Rate limited | Yes, with backoff | Time |
Timeout / connection reset / 409 idempotency_in_progress | Network failure, or the first attempt is still running | Yes, with the same idempotency key | Time |
500 send_failed / 503 | Not sent, not charged | Yes, with a new idempotency key (the same key replays the error) | Time |
504 / a code ending in OUTCOME_UNKNOWN | May have gone out | Check GET /v1/messages first | You |
The rule underneath the table is simple. 4xx means the same request will fail again, so don't retry it. Timeouts and a 5xx mean it might succeed, so retry it carefully: a timeout with the same key, a 500 or 503 with a new key, because Ready stores a 5xx under its key and replays it. The one exception is 429. Back off and try again, because you sent too fast, not wrong.
Refusals that are working as designed
Some rejected sends aren't bugs. They're the compliance layer doing its job, and your code should treat them as final for that contact:
- Opted-out numbers. Once a contact texts STOP, the opt-out applies across every campaign on the account. A send to that number is refused up front and not charged. You can check ahead of time with
GET /v1/opt-outs/:phone, or sync this account's opted-out contacts withGET /v1/opt-outs(that list leaves out some numbers Ready blocks, so the single-number check is the one to trust before a send). - Blocklisted numbers. These are also refused before sending, and also not charged.
- Landlines. Numbers Ready has already seen as landlines are refused on every account at no charge. Landline validation is off by default; when it's on, it costs $0.003 per segment and a send-time lookup refuses more of them. A landline that is neither known nor looked up goes out and fails at the carrier instead. Don't build UI copy that promises every landline is caught up front.
GET /v1/validate/:phonelets you check a number on its own.
There's also one case that looks like a failure and isn't one: quiet hours. A text sent during the recipient's quiet hours is held (202, code QUIET_HOURS_HELD) if sending opens within about 6 hours, and it's only charged when it actually sends. If your dashboard marks every held send as "failed", you'll get support tickets about texts that went out at 9 AM local time exactly as intended. Show it as "scheduled" or "held". Otherwise the API refuses it with 409 QUIET_HOURS_BLOCK and next_allowed_at, nothing is queued, and you send it again then. Campaigns, drip campaigns and automations wait up to 24 hours for the window.
We wrote a whole post on designing your platform's send-error UX around pre-send refusals. The short version: map each refusal to a contact state, not to an error toast.
"Expired" and blocked accounts
If every send from one account starts failing at once, the cause is almost always the account, not the request. These are the account-level reasons sends stop on Ready:
- No card on file. A card is required to send, and billing runs on prepaid credits through Stripe. If the credits are used up, sends stop until the balance is topped up. This is the "expired account" pattern people hit most often.
- The free texts are used up. The first 20 texts are free one-to-one sends, and they end the moment a registration is paid for. Campaigns are paid from the first text.
- The unregistered monthly limit. An account can send up to 500 texts a month before it registers, at its normal per-text rate. After that it has to register. A test account that worked fine in development can hit this limit on its first real week.
- Registration in review. If the account bought its number before registering, it sends on Ready's shared registration at the same per-text rate while its own registration is in review. Most approvals take 2 to 7 days. Check where things stand with
GET /v1/registrationrather than guessing.
For platforms with many sub-accounts, run that registration check per sub-account and show it in your own admin panel. "Sub-account X is in review, approval usually takes 2 to 7 days" saves a support conversation. If you register customers programmatically, the 10DLC registration API guide covers that flow.
Request validation errors you can prevent
Most 4xx errors can be caught before the request leaves your server. Check these client-side, because the API rejects most of them (and, for consent_attested, accepts the send without recording anything):
tomust be E.164. A 10-digit US number is treated as +1, but anything else (spaces, parentheses, a leading 1 without the plus, international numbers without a country code) should be normalized first.- Send
consent_attested: trueon every send. Today the API still sends a text without it, logs a warning and records no attestation, so leaving it out fails silently rather than with an error. It's your promise that the recipient agreed to texts. Don't hardcode it totrueacross your whole platform. Set it from your real consent data. Ready records the attestation per send (who sent, to how many, when), but it doesn't store the recipient's opt-in time, wording or page URL. Your system has to keep that. (A contact who signs up on a Ready-hosted opt-in page is the exception: Ready stores that sign-up, with the time, IP address and exact wording, and support@tryready.com can send you a copy.) - Bulk sends cap at 100 recipients per call.
POST /v1/messages/bulktakes at most 100 numbers into. If you have 4,350 contacts, that's 44 calls, not one call that gets rejected. frommust be one of your numbers. If you pass afrom, pull it fromGET /v1/numbersrather than from a config value that may be out of date. Or leavefromout and Ready picks the number: the one this contact already texts with, otherwise your number closest to their area code.
A clean single send looks like this:
curl -X POST https://api.tryready.com/v1/messages \
-H "X-API-Key: $READY_API_KEY" \
-H "Idempotency-Key: 3f1c9a2e-7b44-4d1a-9c0e-5a8f2d6b1e77" \
-H "Content-Type: application/json" \
-d '{"to": "+12125550199", "body": "Your order #1042 has shipped.", "consent_attested": true}' Retrying without sending twice
Retries cause more customer-facing damage than errors do. Here's what happens without protection: you send, the connection times out, and you have no idea whether the message was accepted. You retry, and if it was accepted the first time, the customer gets two texts.
The fix is the Idempotency-Key header. Generate one UUID per logical message, store it with the message row, and reuse the same key on every retry of that message. A retried request carrying the same key never sends twice. Generating a new key per HTTP attempt defeats the whole point.
A sensible retry policy for 5xx, timeouts and 429:
- Retry with exponential backoff and jitter, for example after about 1s, 2s, 4s and 8s.
- Cap the attempts (four or five is plenty), then mark the message "failed, retryable" for a person or a later job to handle. If its last answer was a
500or a503, that job sends it with a new key. - Never retry 4xx responses other than 429.
Worked example (assumptions stated): a platform sends 10,000 single-segment texts in a day. About 2% of requests time out, which is 200 sends, and the code retries them without an idempotency key. Assume every original was actually accepted. That's 200 duplicate texts at $0.0075 per segment plus a $0.0045 carrier fee each: $4.00 in rate alone, plus 200 × $0.0045 in carrier fees. The money is small. The real cost is 200 people getting the same text twice, and some share of them replying STOP, which permanently suppresses them for that sender. Our post on stopping duplicate texts goes deeper on key storage.
What to log, and what to show users
Log every attempt with:
- your internal message ID and the idempotency key
- the recipient, the sub-account and the
fromnumber used - the HTTP status and the raw response body, stored verbatim
- the attempt number and timestamp
- your classification: accepted, refused (final), account-blocked, retryable or failed
Store the raw body even if you parse it. When the docs add a field, you'll want the history. To reconcile, GET /v1/messages pages through sent messages with before_id.
On the user side, translate each class into a sentence and an action:
- Refused (opt-out/blocklist): "This contact unsubscribed. Texts can't be sent to them." No retry button.
- Account-blocked: "Sending is paused: add a card" or "registration is in review". Send it to the account owner, not the person who clicked send.
- Retryable, still failing: "We couldn't reach the messaging service. We'll keep trying." Then actually keep trying.
- Held for quiet hours: "Scheduled for the recipient's morning.". Campaigns, drip campaigns and automations wait up to 24 hours for the window; a one-to-one or API text is held only if the window opens within 6 hours, otherwise it is refused with 409 QUIET_HOURS_BLOCK and must be resent; a one-to-one reply to someone who texted you in the last 24 hours goes out at any hour.
The practical takeaway
Split API errors from carrier errors. Never retry a 4xx except 429 and a quiet-hours 409 (after its next_allowed_at). Retry timeouts with the same idempotency key, and a 500 send_failed or 503 with a new one. Treat opt-out and blocklist refusals as final contact states, which are also unbilled. When one account fails across the board, check its card, credits and registration before you check your code.
If you want to test these paths against a real API, you can sign up, add a card, and use your 20 free one-to-one texts before you register. The full error reference is at docs.tryready.com/api/errors, and the request shapes are at docs.tryready.com/api/messages.