Most platforms that embed texting ship with two message states, "sent" and "failed." Then a customer's support inbox fills with tickets like "why did 312 of my texts fail?" Most of those texts didn't fail. They were refused on purpose, for reasons the customer would agree with if your UI explained them.

On the Ready API, a send to an opted-out number or a blocklisted number is refused up front, and it isn't charged. A number Ready's sends have already seen as a landline is refused too, on every account at no charge; with landline validation switched on, numbers a lookup marks as landlines are refused as well. A text that would land during the recipient's quiet hours is held and sent later, and it's charged only when it actually goes out, as long as sending opens within about 6 hours. Past that, the API refuses it and you send it again once sending opens. Your send-error UX should reflect those rules. This guide covers how to model the states, what to show users, how to keep your own contact records in sync with opt-outs, and how to retry without double-sending. The only code shown is the documented request shapes. Exact response bodies live in the messages API docs, and you should build against those, not against anything paraphrased here.

What gets refused, and why it costs nothing

Three kinds of destination are refused before the message goes to a carrier:

  • Opted-out numbers. The contact texted STOP (or UNSUBSCRIBE) to one of the account's numbers. The opt-out propagates across the account, so no campaign or one-off send reaches them again.
  • Blocklisted numbers. These numbers are on a blocklist and shouldn't be texted at all.
  • Known landlines. A landline can't receive an SMS, so sending to one wastes a segment and does nothing for deliverability. A landline Ready has already seen is refused free on every account; with landline validation on, a lookup can mark more numbers as landlines, and that refusal is billed the $0.003 landline check, not the text.

No text is billed for any of these. That changes how much a dirty list costs. Here's a worked example with stated assumptions. A customer imports 10,000 contacts and sends a one-segment campaign, and 400 of those numbers are opted out or blocklisted. If those 400 were billed, the waste would be 400 × $0.0075, which is $8 in rate plus 400 × $0.0045 in carrier fees. Because they're refused, the customer pays for the 9,600 that went out and nothing for the rest.

Ready's all-in prices per segment include the carrier fee but not screening: Scrub lists and Number validation, the two switches in Settings under Compliance, each add $0.003 per segment when switched on, shown as screening on your bill, and both are off by default. Unless you turn the opt-out footer off, the first text to each contact, and one again from time to time, also gets "Reply STOP to opt out" and your business name if the message does not already have them, so a text close to the segment limit can bill one more segment.

The money saved is small. The bigger cost is a confused customer. If your UI paints those 400 red with "Failed," the customer thinks your platform is broken, retries the batch, and opens a ticket.

Two optional add-ons sit beside the refusals. Litigator and DNC scrubbing, and landline validation, can each be switched on for an account at $0.003 extra per segment, and both are off by default. If your customers turn scrubbing on, plan a UI state for scrub matches too. The docs describe how those come back.

Model at least five states, not two

A useful message model separates "we chose not to send this" from "we tried and it didn't work." Here's a starting set:

StateWhat happenedBilled?What the user should seeWhat the user can do
SentAccepted and handed to the carrierYes, per segmentSentNothing
Refused: opted outContact replied STOP earlierNo"Not sent: this contact unsubscribed"Nothing by text. Contact them another way if they've agreed to it
Refused: blockedNumber is blocklistedNo"Not sent: this number is blocked"Review the contact record
Refused: landlineKnown landlineNothing for a landline Ready has already seen; with validation on, a lookup can bill the $0.003 landline check"Not sent: landline, can't receive texts"Call instead, or update the number
Scheduled (quiet hours)Held for permitted local hours: campaigns, drip campaigns and automations up to 24 hours, a one-to-one or API text up to 6, otherwise refusedOnly when it sends"Scheduled: sends at the recipient's morning"Cancel if no longer relevant
FailedA genuine error after the attemptPer docs"Failed" plus the reasonRetry or contact support

Treat the three refusals as neutral states, not errors. Use grey, not red. Use an info icon, not a warning triangle. Leave out the "Retry" button, because retrying an opted-out contact can't succeed, and offering the button implies it might.

Copy that prevents tickets

Short, specific and non-blaming copy works best:

  • Opted out: "Not sent. Maria unsubscribed from texts on this account. We don't text contacts who opted out."
  • Landline: "Not sent. 555-0142 is a landline and can't receive texts."
  • Blocked: "Not sent. This number is blocked from receiving texts."
  • Batch summary: "9,600 sent · 400 not sent (unsubscribed or landline, texts not charged) · View details"

"No charge" is the phrase that matters most in that summary. It answers the billing question before anyone asks it.

Quiet hours: show "scheduled", never "failed"

Ready holds sends outside permitted local hours, based on the recipient's area. Suppose a customer in New York sends a campaign at 8:30 AM Eastern. For a contact in California, that's 5:30 AM. That text is held and goes out later in the morning, local time, and it's charged only when it sends. That is the rule for campaign, drip and automation texts, which can wait up to 24 hours. A one-to-one or API text is held only if sending opens within about 6 hours of when you send it. A text sent through the API at 11:40 PM, with sending opening at 8 AM, is refused instead of held, so send it again once sending opens.

In your UI, a held message is a scheduled message. Show it in the same place you show user-scheduled sends, with a label that explains why: "Held for quiet hours. Sends during the recipient's permitted hours." 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.

Avoid three common mistakes:

  1. Showing held texts as pending forever. Users read a spinner that lasts six hours as a hang.
  2. Counting held texts as failed in campaign stats. That inflates the failure rate and drags down the campaign's apparent performance.
  3. Letting users retry a held text. If a retry goes out as a new send, the contact can get two texts in the morning. That's how you earn STOP replies.

For campaign reporting, split the numbers into "sent now" and "scheduled for quiet hours." Customers sending across time zones will see the second number every time, and once it's labelled they'll stop asking about it.

This feature exists to reduce TCPA exposure, and quiet-hours enforcement is good practice. It doesn't make a sender immune. Compliance stays the sender's responsibility, and your UI shouldn't suggest otherwise.

Sync opt-outs into your own contact records

Ready refusing an opted-out number protects the send. Your product still needs to know about the opt-out. Otherwise your contact list shows Maria as reachable, your segment builder counts her in the audience, and your customer's "estimated cost" for the next campaign is wrong.

Two documented endpoints cover this:

GET https://api.tryready.com/v1/opt-outs
X-API-Key: <key>
GET https://api.tryready.com/v1/opt-outs/+12125550199
X-API-Key: <key>

A practical pattern:

  • Periodic sync. Pull GET /v1/opt-outs on a schedule and mark matching contacts as "unsubscribed from SMS" in your own database. That list holds only this account's opted-out contacts, so check GET /v1/opt-outs/:phone before another tool texts a number. Exclude them from audience counts and cost estimates.
  • Check at compose time. When a user opens a one-to-one thread or adds a contact to a drip campaign, call GET /v1/opt-outs/:phone and grey out the compose box with the reason. That beats letting them type a message that gets refused.
  • One flag, many surfaces. Use the same flag in the inbox, the contact profile, list exports and audience builder. If any one of them disagrees, a user will eventually text someone who said STOP. Ready would refuse it, but the user would still see a confusing result.

If you also run email, calls or a dialer, decide what an SMS opt-out means for those channels. Our post on why opt-out rarely crosses channels covers that decision, and SMS opt-out management covers keeping STOP working across every number. If you're moving customers from another provider, import their historical STOP list before the first send. See preserving opt-out history on migration.

Ready records the sender's consent attestation on each send that sets "consent_attested": true: who sent, to how many, and when. A send without it still goes out today but leaves no attestation, so set it on every request. That record is not a per-contact consent record. Ready doesn't store the time, the opt-in wording or the page a contact agreed on. Your platform, or your customer's store or app, keeps that. Your contact model should have a field for it. (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.)

Validate before you send, not after

To catch landlines earlier, for example at import time, GET /v1/validate/:phone checks a number:

GET https://api.tryready.com/v1/validate/+15559876543
X-API-Key: <key>

Running it at import lets you flag landlines in the contact list right away ("landline, call only"), instead of finding out at send time. GET /v1/scrub/:phone does the litigator and DNC check for a single number. The standalone scrub is priced at $0.005 per contact. The response fields are documented at docs.tryready.com. Map them to your own contact flags rather than surfacing raw values to users.

Retries without double-sending

Networks time out. If your worker sends a request, the connection drops, and you don't know whether the text went out, a naive retry can text the contact twice. Send an Idempotency-Key header (any UUID) with every send, and reuse the same key when you retry the same logical message:

POST https://api.tryready.com/v1/messages
X-API-Key: <key>
Idempotency-Key: 6f1c2a9e-3b7d-4e1a-9c55-2d8f0b7a41e3
Content-Type: application/json

{"to": "+12125550199", "body": "Your order #1042 has shipped.", "consent_attested": true}

With the key in place, a retried request never sends twice. Implementation notes:

  • Generate the key when the message is created in your database, not when the HTTP call fires. Store it on the message row so every retry reads the same value.
  • Don't retry refusals. An opted-out, blocked or landline refusal is a final answer, and retrying wastes a request. The exception is a quiet-hours refusal on an API text: nothing was queued, so send the text again once sending opens.
  • Don't retry quiet-hours holds. The message is already scheduled.
  • Do retry ambiguous failures (timeouts, connection resets), with the same key and some backoff.

The same applies to bulk:

POST https://api.tryready.com/v1/messages/bulk
X-API-Key: <key>
Idempotency-Key: 0b9e7d42-8a11-4c6f-b2e3-71f5a0c9d8e4
Content-Type: application/json

{"to": ["+12125550199", "+15559876543"], "body": "Your appointment is tomorrow at 9 AM. Reply C to confirm.", "consent_attested": true}

A bulk send almost always comes back mixed: most numbers sent, a few refused, some held for quiet hours. Design the results screen for that mix from the start. Show the summary line from earlier, with a drill-down by state. Our bulk SMS API guide covers batching and throughput.

To reconcile later, GET /v1/messages lists messages and pages with before_id. That's useful for a nightly job that confirms your message table matches what was actually sent and billed.

The practical checklist

If you're building or reworking send-error UX on Ready, this is the short version:

  1. Model refused, scheduled and failed as separate states. Refusals are grey and neutral, with no retry button.
  2. Say "no charge" on refused texts, because the text is never charged and it heads off billing tickets. With landline validation on, a lookup that finds a landline still bills its number check, so say that on those.
  3. Show quiet-hours holds as scheduled, and keep them out of failure stats.
  4. Sync GET /v1/opt-outs into your contact records, and check GET /v1/opt-outs/:phone at compose time.
  5. Validate at import so landlines are flagged before anyone tries to text them.
  6. Put an Idempotency-Key on every send, stored with the message, and reuse it on retries.
  7. Keep per-contact consent records yourself. The API stores the sender's attestation, not the contact's opt-in.

For a wider pre-embed checklist, see what to check before you embed texting. When you're ready to try the request shapes above, the quickstart gets you to a first send. You can create an account and use the first 20 one-to-one texts to test your refusal and scheduled states before your customers ever see them.

Frequently asked questions

Does Ready charge for texts sent to opted-out numbers or landlines?

No. On the Ready API, a send to an opted-out number or a blocklisted number is refused before it goes to a carrier, and it isn't charged. A number Ready's sends have already seen as a landline is refused too, on every account at no charge; with landline validation switched on, numbers a lookup marks as landlines are refused as well. That's separate from the optional compliance add-ons. Litigator and DNC scrubbing, and landline validation, each add $0.003 per segment when switched on for an account, and both are off by default. In your UI, show refusals as a neutral grey state marked "no charge" rather than a red failure.

Is a text held for quiet hours charged?

Only when it actually sends. Ready holds campaign, drip and automation texts that would land outside the recipient's permitted local hours, based on the recipient's area, and sends them later. A campaign sent at 8:30 AM Eastern holds texts to California contacts until later that morning, their time. A one-to-one or API text is held only if sending opens within about 6 hours; otherwise it is refused and you send it again once sending opens. Show these as scheduled, not failed, and keep them out of failure stats. Don't let users retry them, because a retry sent as a new message could text the contact twice.

How do I stop the Ready API from sending a text twice on retry?

Send an Idempotency-Key header (any UUID) with every request to POST /v1/messages or POST /v1/messages/bulk. Reuse the same key when you retry the same logical message. With the key in place, a retried request never sends twice. Generate the key when the message is created in your database and store it on the message row. Retry timeouts and connection resets with backoff, but don't retry refusals or quiet-hours holds. The exception is an API text refused for quiet hours: nothing was queued, so send it again once sending opens.

Does Ready store per-contact consent records?

No. Ready records the sender's consent attestation on each send that sets "consent_attested": true: who sent, to how many and when. A send without it still goes out today but leaves no attestation, so set it on every request. It doesn't store when a contact opted in, the opt-in wording or the page where they agreed. Your platform, or your customer's store or app, keeps that record, so your contact model should include a field for it. Compliance stays the sender's responsibility. (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.)