#Quickstart: send your first text in 5 minutes
You need a Ready account with a card on file and a phone that can receive texts. Trial texts go out from a shared Ready number.
Node examples use built-in fetch and top-level await (an .mjs file, Node 20+). Python examples use requests.
Get an API key 1 min
Open Settings → Integrations, click Generate new key and copy it (shown once, starts with
rsms_).Important. Generating a key replaces your live key, and the old one stops working immediately. An account has one live key; a test key can exist alongside it.export READY_API_KEY="rsms_your_key_here"Check the key works 30 s
401 AUTH_REQUIRED: the variable is usually empty.401 INVALID_API_KEY: the key was revoked, mistyped or cut off.curl "https://api.tryready.com/v1/account" \ -H "X-API-Key: $READY_API_KEY"const res = await fetch('https://api.tryready.com/v1/account', { headers: { 'X-API-Key': process.env.READY_API_KEY, }, }); const json = await res.json(); if (!res.ok || json.success === false) { throw new Error(`${res.status} ${json.error_code || json.code}: ${json.error}`); }import os, requests r = requests.get( "https://api.tryready.com/v1/account", headers={"X-API-Key": os.environ["READY_API_KEY"]}, timeout=30, ) data = r.json() if not r.ok or data.get("success") is False: raise RuntimeError(f'{r.status_code} {data.get("error_code") or data.get("code")}: {data.get("error")}'){ "success": true, "data": { "balance_usd": 25, "balance_cents": 2500, "plan": null, "sms_rate_usd_per_segment": 0.05, "carrier_fee_usd_per_segment": 0.0045 } }Send a text to your own phone 1 min
Put your own mobile number in
to. It arrives in 5 to 30 seconds.Legal."consent_attested": trueis your promise that the recipient gave prior express written consent (TCPA). Your own phone is fine. Ready handlesSTOPand other opt-outs. See Compliance.curl -X POST "https://api.tryready.com/v1/messages" \ -H "X-API-Key: $READY_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "to": "+12125550199", "body": "Hello from Ready", "consent_attested": true }'const res = await fetch('https://api.tryready.com/v1/messages', { method: 'POST', headers: { 'X-API-Key': process.env.READY_API_KEY, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ "to": "+12125550199", "body": "Hello from Ready", "consent_attested": true }), }); const json = await res.json(); if (!res.ok || json.success === false) { throw new Error(`${res.status} ${json.error_code || json.code}: ${json.error}`); }import os, uuid, requests r = requests.post( "https://api.tryready.com/v1/messages", headers={"X-API-Key": os.environ["READY_API_KEY"], "Idempotency-Key": str(uuid.uuid4())}, json={ "to": "+12125550199", "body": "Hello from Ready", "consent_attested": True }, timeout=30, ) data = r.json() if not r.ok or data.get("success") is False: raise RuntimeError(f'{r.status_code} {data.get("error_code") or data.get("code")}: {data.get("error")}'){ "success": true, "data": { "message_id": "179088672338905475132359", "segments": 1, "sent_body": "Hello from Ready Reply STOP to opt out.", "compliance_text_added": "Reply STOP to opt out.", "status": "queued", "sent_at": "2026-10-01T20:32:03.473Z", "sent_at_unix": 1790886723, "credits_remaining": 499, "carrier_fee": 0.0045, "balance_usd": 24.95, "carrier_balance": 24.9455 } }Read the response 30 s
successbooleantrue: Ready accepted the text.data.message_idstring- Store it: delivery lookups and webhooks use it.
data.statusstringqueued, thendeliveredorfailedseconds later.data.segmentsinteger- What you're billed for. A plain segment is up to 160 characters.
data.sent_bodystring- The exact text sent. Ready may add your opt-out line and business name to meet carrier rules;
sent_bodyis the exact text sent andsegmentsare counted on it. Include your own opt-out in the body to stop us adding ours.
Tip.200means accepted, not delivered. For delivery, use a webhook (next step) or Get a message.Get delivery updates and replies 2 min
Point Ready at a public HTTPS URL that accepts
POST(a free request inspector works). Send"regenerate_secret": truethe first time to get the signing secret in the response.Reply from your phone and an
inbound_replyevent arrives. Before going live, verify the signature on every event.curl -X PUT "https://api.tryready.com/v1/webhooks" \ -H "X-API-Key: $READY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://you.example.com/ready-webhook", "events": [ "message_delivered", "message_failed", "inbound_reply" ], "regenerate_secret": true }'const res = await fetch('https://api.tryready.com/v1/webhooks', { method: 'PUT', headers: { 'X-API-Key': process.env.READY_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ "url": "https://you.example.com/ready-webhook", "events": [ "message_delivered", "message_failed", "inbound_reply" ], "regenerate_secret": true }), }); const json = await res.json(); if (!res.ok || json.success === false) { throw new Error(`${res.status} ${json.error_code || json.code}: ${json.error}`); }import os, requests r = requests.put( "https://api.tryready.com/v1/webhooks", headers={"X-API-Key": os.environ["READY_API_KEY"]}, json={ "url": "https://you.example.com/ready-webhook", "events": ["message_delivered", "message_failed", "inbound_reply"], "regenerate_secret": True }, timeout=30, ) data = r.json() if not r.ok or data.get("success") is False: raise RuntimeError(f'{r.status_code} {data.get("error_code") or data.get("code")}: {data.get("error")}')Go live checklist
- Register your business and campaign (A2P registration). Until then the trial limits below apply.
- Buy a number: Phone numbers.
- Branch on error codes (Errors). Send an Idempotency-Key on every send.
- Subscribe to
contact_opted_outand keep your copy of opt-outs in sync.
Before your first send
- Trial: sends go out from a shared Ready number and ignore
from. A card is required (402 CARD_REQUIRED). A small allowance of free sends, which are real texts, ends in402 SANDBOX_LIMIT. - After campaign approval: you send from your own numbers and pay from your balance (
402 insufficient_balance). No active number:400 no_sender_number.
Stuck? Email support@tryready.com with the response's X-Request-Id.
#Authentication
Server-to-server calls use an API key. Third-party apps such as Zapier use OAuth 2.0.
API keys
Send the key as X-API-Key: rsms_… or Authorization: Bearer rsms_… (capital B). If both are present, the bearer token is used.
- Keys start with
rsms_and are shown once. Only the account owner can create one, and it acts with the owner's full permissions. - One live key per account. Generating a new one replaces it, and the old key stops working immediately.
Walkthrough: create an API key3 steps
- Open Settings (the gear, top right), then Integrations.
- In the API section, click Generate new key. If you already have a key, this replaces it: the old key stops working immediately. Update every system that uses it first.
- Copy the key. It's shown once and starts with
rsms_.
Test keys
A key starting rsms_test_ simulates sends, purchases and registration: nothing is sent or charged. Other writes are refused and reads are real. It reads your real account, so keep it as safe as a live key.
Walkthrough: create a test key3 steps
- Open Settings (the gear, top right), then Integrations.
- In the API access section, choose Test key next to Generate new key, then click it. If you already have a test key, this replaces it, as does Replace on its row: the old test key stops working immediately. Revoke on the row turns it off.
- Copy the key. It's shown once and starts with
rsms_test_.
An account has one test key. Only the account owner can create, replace or revoke it, the same as a live key, and making or revoking a test key never touches your live key.
Details: what a test key checks and returns
A test key checks the request itself (required fields, types, lengths and Ready's global block list) and that from is one of your numbers (invalid_from), but not the rest of your account's state. Sends aren't checked against opt-outs, DND, your balance, consent_attested or the 60-second duplicate guard (duplicate_send), and to isn't checked for being a real phone number ("123" is accepted). Number purchases don't check payment_intent_id: a made-up id, a reused one or none at all succeeds. Registration calls don't check that the id is yours or that the payment exists, and skip the EIN-to-name match (ein_name_mismatch), duplicate, brand-first (brand_required_first) and vetting-state checks. The format checks (invalid_ein, address_incomplete, business_description_required, consent_required) still run. A 200 from a test key doesn't mean the live call will succeed.
- Simulated: sending texts (
POST /v1/messages,/v1/messages/bulk,/sms/sendand the Zapier Send SMS action), number checkout and purchase, every registration write,POST /v1/validate, and creating client accounts and setting their markup. - Refused: every other write (
POST,PUT,PATCH,DELETE) answers403with{ "success": false, "error": "test_key_not_allowed", "message": "Test keys can only call simulated endpoints. Use a live key for this." }and changes nothing. That covers contacts, conversations, webhook settings, screening lists (/compliance/scrub-categories),POST /v1/scrub,/sms/generateand number releases (DELETE /v1/numbers/:id). Two billed reads are refused the same way:GET /v1/scrub/:phoneandGET /ghl-dialer/token. The code is inerror, noterror_code. - Reads are real: every other
GETworks and reads your real balance, rates, numbers, messages and contacts. - Simulated responses add
"test": trueand anote, and some shapes differ.POST /v1/messagesandPOST /sms/sendreturn the livedataobject, with the same fields repeated at the top level:{ "success": true, "test": true, "data": { "message_id": "test-msg-…", "segments": 1, "status": "accepted", "sent_body": "…", "compliance_text_added": null }, "message_id": "test-msg-…", "segments": 1, "status": "accepted", "sent_body": "…", "compliance_text_added": null, "note": "test mode: no real message sent, no charge" }.sent_bodyandcompliance_text_addedpreview the opt-out line and business name a live send would add, andsegmentsis counted onsent_body. Both fields are left out if your settings can't be read. The message isn't stored, soGET /v1/messages/:idanswers404 message_not_foundfor it.POST /v1/messages/bulkgives each result atest-msg-…message_idand itssegments, but still checks your balance first, so a low balance can get402 insufficient_credits.POST /v1/numbersreturnsdata.phone_numberwith nophone_number_id. Registering a brand or campaign returnsregistration_id: null. Registration resubmit, vet, vet/confirm and cancel returndata.reg_idwithtestandnote, not the live shape; rename returns onlydata.reg_idandtest. Confirm returns noregistration_id. Creating a client account returnsid: null. Checkout returns atest-…payment_intent_idand noclient_secret. Validate returnsline_type: "unknown",carrier: nullandtextable: null. Setting a client's markup returns onlydata.idandtest. - No delivery webhooks: test sends never trigger
message_deliveredormessage_failed. To try your endpoint, use Send test (see Webhooks). - MCP: on the MCP server a test key works as a bearer token, but only
send_smsis simulated. Every tool that changes or bills something, includingsend_campaign,enroll_in_dripandai_generate_message, is refused withtest_key_not_allowed, so it sends and charges nothing. Tools that only read work.
OAuth 2.0
The authorization-code flow of the published Zapier app. Tokens from /zapier/oauth/token are JWTs: send Authorization: Bearer <token>. Errors follow RFC 6749 (error, error_description). The MCP server has its own OAuth flow.
Acting as a client account
Agencies send X-Sub-Account-Id with an API key to run a /v1 call as a client account. See Client accounts.
Authentication errors
In code: 401 AUTH_REQUIRED (no key, empty variable, or lower case bearer), 401 INVALID_API_KEY, 401 INVALID_TOKEN, 403 ACCOUNT_SUSPENDED, 503 AUTH_CHECK_UNAVAILABLE (nothing ran; retry).
X-API-Key: rsms_your_key_hereAuthorization: Bearer rsms_your_key_here{
"success": false,
"error": "Sign in to continue. For API calls, send your API key in the X-API-Key header.",
"code": "AUTH_REQUIRED"
}
{
"success": false,
"error": "That API key isn't valid or was revoked. Create a new one in Settings.",
"code": "INVALID_API_KEY"
}
#Errors
Errors have a non-2xx status (three exceptions below) and a JSON body:
successboolean- Always
false. errorstring- A sentence for people. It can change: don't parse it.
Details
Some say "refresh the page" because the dashboard shares these endpoints. error_code,codestring- The stable code to branch on. Read
error_code ?? code.Details
Some errors usecodeinstead oferror_code, some use both. Codes are case-sensitive, and a few conditions have an upper and a lower case form from different endpoints (both are in the table below). …- Extra fields where they apply, such as
next_allowed_at,alternatives,field,balance_cents.
- A
400with no code is a malformed request: fix the body, don't retry. - An unknown path or an unsupported method returns an HTML 404 page, not JSON. A missing resource on a real path returns a JSON 404 with a code.
- Not successes:
200 empty_merge_field,200 generation_refusedand202 send_outcome_unknown.
When to retry
| Response | What to do |
|---|---|
429 rate_limited, throughput_exceeded, RATE_LIMITED, validation_in_progress | Nothing ran. Wait Retry-After (a minute if absent), retry with the same key.DetailsA429 is never stored under the key. |
429 daily_cap, number_daily_cap | A cap, not a speed limit, with no Retry-After: don't loop. Resets at midnight Pacific (America/Los_Angeles). |
429 monthly_cap, 402 MONTHLY_CAP_REACHED | Don't loop. The cap uses a 30-day window, not a calendar month. Wait for capacity or complete registration with a paid number (see Sending caps). |
429 ramp_cap, 409 RAMP_CAP_REACHED | Wait for the warm-up to end (ramp_ends_at when present). |
409 idempotency_in_progress | Wait a few seconds, retry with the same key. |
500 send_failed, 500 hold_not_returned, any 503 on a send | Not sent, not charged. Retry with a new key (the old one replays the error).DetailsForhold_not_returned, support returns the held amount. Wait Retry-After first when it's there (60 seconds on provider_busy). ADMISSION_SHED and permission_check_unavailable come before the key is recorded, so the same key works for them too. |
504 REQUEST_TIMEOUT_OUTCOME_UNKNOWN, or your client timed out | May still be running. Retry with the same key after a minute. A new key can send twice.DetailsOnce the first request has finished, you get its real result withIdempotent-Replayed: true. Before that you get 409 idempotency_in_progress or the 504 again; wait and retry with the same key. |
202 send_outcome_unknown, 500 INTERNAL_ERROR_OUTCOME_UNKNOWN | May have been delivered and charged. Decide whether a duplicate or a missed text is worse.DetailsRetrying with the same key returns this same answer, and the API can't confirm the outcome (see Checking whether a text went out). Sending again with a new key can deliver it twice. |
Other 4xx | Fix the request, send it with a new key. |
422 | Don't retry. The number can't receive texts. |
Checking whether a text went out
There is no lookup by Idempotency-Key, and GET /v1/messages lists refused and unconfirmed attempts too. A listed attempt is not proof of delivery. queued means handed to the carrier; delivered means delivery confirmed. unknown means carrier acceptance could not be confirmed: it may still have delivered.
Details
- The list filters only on
direction,to,fromandstatus. - A send refused with an error (an opt-out, a cap, a
send_failed) is saved as an outbound row withstatus: "failed",cost_usd: 0and the samebody. Finding the body in the list doesn't mean it was sent. - A
202 send_outcome_unknownattempt is recorded asunknown, separate from a definitive failure. A zero cost does not prove it was unsent or refunded. Later evidence may update the outcome; do not resend with a new key just because delivery is unconfirmed. - A
failedrow withcost_usdabove 0 went out and the carrier didn't deliver it. See Failures Ready resends for you before you send it again.
Status codes
Standard HTTP meanings. The ones to know: 402 payment needed, 409 not sendable right now, 422 the number can't receive texts, 503 a safety check couldn't run (not sent, not charged), 504 a write may still go through.
Details: every status
200OK.201created (contacts).202accepted but not finished, or outcome unknown.400bad request.401no or bad credentials.402payment needed.403not allowed (often: can't text this contact).404not found.406unsupported API version.408timed out, nothing changed.409conflict, or not sendable now.413body over 1 MB.422number can't receive texts.423legal hold.429too fast or over a limit.500,502failed on our side.503a safety check couldn't run: not sent, not charged.504a write timed out and may still go through.
{
"success": false,
"error": "This contact replied STOP and is opted out. They must reply START from their phone to opt back in.",
"error_code": "opt_out_locked"
}
{
"success": false,
"error": "to should be a single value, not a list or object.",
"code": "invalid_field",
"field": "to"
}
{
"success": false,
"error": "Required fields: to, body. Optional: from (E.164)."
}
const body = await res.json();
const code = body.error_code ?? body.code; // may be undefined on a malformed requestEvery error code
Field: where the code is (error_code, code, both, or error; either: read error_code ?? code). Retry: Same key and After Retry-After reuse your Idempotency-Key; any other resend needs a new key.
| Code | HTTP | Field | Retry? | What to do |
|---|---|---|---|---|
empty_merge_field | 200 | error_code | After fixing | A merge field is blank. Nothing sent.DetailsResend with"confirm_empty_merge": true to send it with the blank. |
generation_refused | 200 | error_code | After fixing | AI drafts: the ask breaks texting rules. Charged, not refunded.DetailsBody has"success": false and "refused": true. |
payment_processing | 202 | either | Later | The registration payment is still processing. |
send_outcome_unknown | 202 | error_code | No | May have been delivered and charged.DetailsWe couldn't confirm the carrier took it. A retry with the same key returns this answer again. See When to retry. |
address_incomplete | 400 | code | After fixing | Brand address missing a part.DetailsOne ofstreet, city, state, zip. error names what's missing. |
area_code_invalid | 400 | code | After fixing | area_code is missing or not 3 digits. |
attestation_required | 400 | error | After fixing | Turning off tcpa or dnc_complainers needs confirmation_text: "I UNDERSTAND". |
brand_required_first | 400 | error_code | After fixing | Register a brand before a campaign. |
business_description_required | 400 | code | After fixing | Brand needs business_description.DetailsOne or two sentences on what the business does. |
ca_not_provisioned | 400 | error_code | No | Canada sending not set up on this account. |
categories_required | 400 | error | After fixing | Scrub categories: categories missing.DetailsThis endpoint puts the code inerror and the sentence in message. |
consent_required | 400 | both | After fixing | Registration needs boolean "consent": true, not a string. |
CONTACT_UPDATE_FAILED | 400 | code | After fixing | A value can't be saved (e.g. non-integer score). |
CONTENT_BLOCKED | 400 | both | No | Blocked content. Nothing sent; a compliance fee may apply.DetailsLink shorteners, prohibited or regulated content.error states the fee and reasons lists what was flagged. Other send paths use lower case content_blocked. |
ein_name_mismatch | 400 | error | After fixing | EIN doesn't match the business name.DetailsThe body hastitle, message, steps, can_override. |
idempotency_key_required | 400 | error_code | After fixing | API-key send without Idempotency-Key. Not enforced yet.DetailsReturned once Ready requires the header; today the send goes through. Add a new unique key per request. |
idempotency_key_too_long | 400 | error_code | After fixing | Idempotency-Key is over 255 characters. |
invalid_assignee | 400 | code | After fixing | One of the ids isn't on your team. |
INVALID_CONTACT_ID | 400 | code | After fixing | Use the contact's id, not its phone number. |
invalid_ein | 400 | code | After fixing | ein isn't 9 digits (XX-XXXXXXX or XXXXXXXXX). |
invalid_field | 400 | code | After fixing | Wrong type; field names it. |
invalid_from | 400 | error_code | After fixing | from isn't one of your numbers. |
INVALID_JSON | 400 | code | After fixing | The body isn't valid JSON. |
invalid_number | 400 | both | After fixing | The phone number has fewer than 10 digits. |
INVALID_PHONE | 400 | code | After fixing | The contact's phone number can't be read. |
invalid_offset | 400 | error_code | After fixing | offset isn't a whole number, 0 or more. |
invalid_phone | 400 | error_code | After fixing | GET /v1/validate/:phone: path value over 40 characters.DetailsA short value that isn't a number answers200 with valid: false. |
invalid_request | 400 | both | After fixing | Missing or misshapen body; error says what. |
invalid_status | 400 | code | After fixing | Must be open, active, closed or archived. |
invalid_sub_account_id | 400 | both | After fixing | X-Sub-Account-Id isn't a numeric account id. |
invalid_url | 400 | both | After fixing | Webhook URL not usable.DetailsA bad URL, not http or https, or a private or internal host. |
invalid_use_case | 400 | error_code | After fixing | Unknown campaign use_case. valid lists the allowed values. |
MESSAGE_TOO_LONG | 400 | error_code | After fixing | Over 25 segments, or 1,600 characters with an image.DetailsOther send paths use lower casemessage_too_long. |
NO_APPROVED_CAMPAIGN | 400 | error_code | No | The number has no approved 10DLC campaign yet. |
no_country_sender | 400 | error_code | No | No number of yours can send to this country. |
no_sender_number | 400 | error_code | After fixing | No active number to send from. |
payment_intent_mismatch | 400 | either | No | The payment isn't for this vetting. |
payment_intent_required | 400 | either | After fixing | Buying a number needs payment_intent_id.DetailsRelated:payment_not_confirmed, payment_not_for_number, payment_not_taken. |
payment_not_completed | 400 | either | After paying | The registration payment didn't go through (status). |
PHONE_NOT_EDITABLE | 400 | code | No | Contact phone can't change. Create a new contact. |
phones_required | 400 | error_code | After fixing | Validate needs phones: 1 to 1,000 numbers. |
preferred_area_code_required | 400 | error | After fixing | number_choice: "new" without preferred_area_code. |
registration_approved | 400 | either | No | Approved registrations can't be cancelled via API. Contact support. |
REQUEST_BLOCKED | 400 | code | After fixing | Query string looks like code (HTML, script, SQL).DetailsRemove it. Repeats can get your IP blocked (IP_TEMPORARILY_BLOCKED). |
sub_account_header_needs_api_key | 400 | both | After fixing | X-Sub-Account-Id needs an API key, not OAuth or session. |
sub_account_header_not_supported | 400 | both | After fixing | X-Sub-Account-Id works only on /v1 paths. |
terms_required | 400 | both | After fixing | Reseller terms aren't accepted yet. Contact support to set this up. |
too_many_numbers | 400 | both | After fixing | Scrub with more than 1,000 numbers. |
too_many_phones | 400 | both | After fixing | Validate with more than 1,000 numbers. |
too_many_recipients | 400 | error_code | After fixing | Bulk send with more than 100 recipients. |
unknown_category | 400 | error | After fixing | Scrub categories: unknown key.DetailsAllowed:tcpa, dnc_complainers, federal_dnc, state_dnc. |
unknown_field | 400 | code | After fixing | Field not accepted here. Remove it. |
UNRENDERED_MERGE_TOKEN | 400 | error_code | After fixing | A merge tag didn't fill. Not sent, not charged. |
AUTH_REQUIRED | 401 | code | After fixing | No key, an empty variable, or lower case bearer. |
INVALID_API_KEY | 401 | code | After fixing | rsms_ key not valid.DetailsRevoked, mistyped or cut off. |
INVALID_TOKEN | 401 | code | After fixing | Not an rsms_ key or valid OAuth token. |
BILLING_PAST_DUE | 402 | code | After fixing | Payment failed or plan cancelled, and no balance. Nothing ran.DetailsUpdate your card (billing_url). FRAUD_HOLD (402) means the account is on hold: contact support. |
card_declined | 402 | either | After fixing | The saved card was declined. Also card_authentication_required. |
CARD_REQUIRED | 402 | error_code | After fixing | Add a card in Settings → Billing. |
insufficient_balance | 402 | error_code | After adding funds | Balance too low; see balance_cents, required_cents. |
insufficient_credits | 402 | both | After adding funds | Balance can't cover the whole batch.DetailsBulk send, validation. Nothing was sent or charged. |
MONTHLY_CAP_REACHED | 402 | code | No | Monthly sending cap reached.DetailsSame limit asmonthly_cap (429) from other send paths. |
PURCHASE_BLOCKED | 402 | code | No | Number purchases blocked. Contact support.DetailsUnpaid bill or account review. |
SANDBOX_LIMIT | 402 | error_code | No | Trial free sends used up. Register to keep sending. |
ACCOUNT_SUSPENDED | 403 | code | No | Account suspended; every call refused. Contact support.DetailsThe send path can also answer lower caseaccount_suspended in error_code. |
api_consent_attested_required | 403 | error_code | After fixing | Send "consent_attested": true. Not enforced yet.DetailsNot returned today: a send without it goes through. It will be once enforcement is on. |
dnc_list | 403 | error_code | No | On a do-not-call list your account screens against. |
EXPORT_DISABLED | 403 | code | No | Only the account owner can export contacts on this account. |
global_blocklist | 403 | error_code | No | The number is on Ready's blocklist. |
IP_TEMPORARILY_BLOCKED | 403 | code | Later | IP blocked 15+ minutes.DetailsToo many over-limit requests without a valid key. |
opt_out_locked | 403 | error_code | No | Recipient opted out. Nothing sent, not charged.DetailsSTOP, marked opted out, or Do Not Disturb for texts;reason says which. On PUT /contacts/:id: you tried to re-enable texting for a STOP-locked contact. Only the contact texting START lifts a STOP. |
number_not_assigned | 403 | error_code | No | Sending number not assigned to you. Nothing sent.DetailsNot assigned to this user or client account. |
opted_out | 403 | error_code | No | PUT /contacts/:id: re-enabling texting on an opted-out contact.DetailsThe contact is opted out but not STOP-locked (for example marked Do Not Disturb). The contact must text START. |
payment_not_owned | 403 | either | No | The payment belongs to another account. |
permission_denied | 403 | both | No | The key's user lacks the permission this endpoint needs. |
quiet_hours | 403 | error_code | Later | Outside the recipient's allowed hours.DetailsOther send paths;/v1/messages answers 409 QUIET_HOURS_BLOCK. |
READ_ONLY_SESSION | 403 | error_code | No | View-only access can't make changes or spend money. |
REQUEST_BLOCKED | 403 | code | No | User-Agent looks like a scanner. Send your own. |
reseller_not_approved | 403 | both | Later | Markup set while reseller setup is in review. |
sub_account_forbidden | 403 | both | No | That account isn't a client account you manage. |
sub_account_suspended | 403 | both | No | That client account is suspended. |
test_key_not_allowed | 403 | error | No | A test key called a non-simulated endpoint. Nothing changed. |
tcpa_block | 403 | error_code | No | Known TCPA litigator. |
CONTACT_NOT_FOUND | 404 | code | No | No contact with this id on your account. |
CONVERSATION_NOT_FOUND | 404 | code | No | No conversation with this id on your account. |
message_not_found | 404 | both | No | No such message. Body has status: "unknown". |
not_found | 404 | code | No | Conversation not found (from PUT /conversations/:id/assign). |
not_rejected | 404 | either | No | Resubmit works only on a rejected registration. |
NUMBER_NOT_FOUND | 404 | code | No | No number with this id on your account. |
payment_not_found | 404 | either | No | No such registration payment on your account. |
registration_not_found | 404 | error_code | No | No registration with this id on your account. |
sub_account_not_found | 404 | both | No | No account has that id. |
unsupported_api_version | 406 | error_code | After fixing | Unserved Accept-Version; supported lists versions. |
REQUEST_TIMEOUT | 408 | code | Yes | Read or upload over 30 s. Nothing changed. |
already_vetted | 409 | error_code | No | The brand already has this vetting tier or higher. |
area_code_unavailable | 409 | error_code | After fixing | Area code out of stock; see alternatives. |
CONTACT_CHANGED | 409 | code | After fixing | Contact opted out mid-save. Read it again. |
CONTACT_EXISTS | 409 | code | No | Phone already a contact (GET /contacts?search=). |
duplicate_brand | 409 | error_code | No | You already have this brand (existing_reg_id). |
duplicate_send | 409 | error_code | No | Same text, same number, within 60 s, no key. |
duplicate_use_case | 409 | error_code | No | A campaign with this use case exists.DetailsYour brand created aMIXED campaign for you. |
idempotency_in_progress | 409 | error_code | Same key | First request still running. Wait a few seconds. |
idempotency_outcome_unknown | 409 | error_code | No | First request with this key ran over 20 minutes; outcome unknown.DetailsSee Checking whether a text went out. |
INVALID_SENDER | 409 | error_code | No | Sending number can't send now. Try another. |
needs_transfer_confirm | 409 | flag | After fixing | Number is on another brand. Resend with "transfer_confirm": true.DetailsNot a code: the 409 body hasneeds_transfer_confirm: true, number, current_brand and message. |
number_activating | 409 | error_code | Later | The sending number is still being set up. |
number_not_active | 409 | error_code | No | The sending number isn't active. |
NUMBER_NOT_ASSIGNED_CONFIRM | 409 | error_code | After fixing | Number not assigned to the contact's owner (dashboard). |
NUMBER_RESERVED | 409 | code | No | This number can't be released right now. |
paid_not_filed | 409 | error | No | Paid, not filed. Don't pay again; contact support. |
payment_intent_already_used | 409 | error_code | No | Each payment_intent_id buys exactly one number. |
purchase_in_progress | 409 | either | Later | Another number purchase is running. Also campaign_number_limit. |
QUIET_HOURS_BLOCK | 409 | code | Later | Recipient's quiet hours.DetailsBody hasnext_allowed_at, reason, recipient_tz, can_override. When can_override is true, send again with override_quiet_hours. |
RAMP_CAP_REACHED | 409 | code | Later | New campaign's warm-up cap used up.DetailsBody hascap, count, ramp_ends_at. Same limit as ramp_cap (429) from other send paths. |
route_unavailable | 409 | error_code | Later | No route to this recipient right now. |
scrub_off | 409 | both | After fixing | Scrubbing is off. Turn it on in Settings → Compliance. |
PAYLOAD_TOO_LARGE | 413 | code | After fixing | The body is over 1 MB. |
auto_reply_suppressed | 422 | error_code | No | An automatic reply was held back. |
INVALID_DESTINATION | 422 | error_code | No | Not a valid phone number. Nothing sent, not charged. |
landline_no_sms | 422 | error_code | No | The number is a landline. |
number_disconnected | 422 | error_code | No | A deactivation record or previous delivery failures blocked this attempt. This does not confirm the number is currently disconnected. Read the response’s error for the specific explanation. |
PROVIDER_REJECTED | 422 | error_code | No | The carrier refused this text. Not charged. |
sms_invalid | 422 | error_code | No | The number was marked as unable to receive texts. |
undeliverable | 422 | error_code | No | The carrier can't deliver to this number. |
LITIGATION_HOLD | 423 | code | No | Legal hold: the contact can't be deleted. |
daily_cap | 429 | error_code | Next day | Account daily cap reached. No Retry-After.DetailsOff unless Ready set one for your account. Resets at midnight Pacific time. |
duplicate_submission | 429 | error_code | No | Same registration in the last 2 minutes (existing_reg_id). |
monthly_cap | 429 | error_code | No | Monthly cap reached. No Retry-After.DetailsSame limit asMONTHLY_CAP_REACHED, which /v1/messages answers. |
number_daily_cap | 429 | error_code | Next day | Sending number's daily cap reached. No Retry-After.DetailsYou set it per number. Resets at midnight Pacific time. Send from another number or wait. |
ramp_cap | 429 | error_code | Later | Warm-up cap. No Retry-After.DetailsSame limit asRAMP_CAP_REACHED. Wait for the warm-up to end. |
rate_limited | 429 | either | After Retry-After | Too many requests for this account.DetailsCarrier pacing on a send: inerror_code with retry_after_ms and cap_per_sec. Per-minute limits: in error_code (about 120 a minute) or code (the 6,000 sends a minute ceiling, 140 bulk calls a minute), with no retry_after_ms: wait Retry-After, or a minute. |
RATE_LIMITED | 429 | code | After Retry-After | Too many from this IP; see retry_after_seconds. |
throughput_exceeded | 429 | both | After Retry-After | Batch over your carrier pace. Nothing sent.DetailsBody hasretry_after_ms and cap_per_sec. |
validation_in_progress | 429 | both | After Retry-After | The same numbers are already being validated. |
buy_incomplete | 500 | error_code | No | Added but setup unfinished. Don't rebuy; check GET /v1/numbers. |
generation_failed | 500 | either | Yes | Writing drafts failed. Refunded. |
hold_not_returned | 500 | error_code | New key | Not sent, but the hold wasn't returned yet.DetailsSupport refunds the held amount; you don't need to do anything. |
internal_error | 500 | both | Check first | Failed on our side.DetailsOn billed calls (validate, scrub), check your balance before you retry. |
INTERNAL_ERROR_OUTCOME_UNKNOWN | 500 | code | No | A write failed on our side; outcome unknown.DetailsTreat a send like202 send_outcome_unknown. Reads answer INTERNAL_ERROR instead. |
RELEASE_INCOMPLETE | 500 | code | Check first | The number release didn't finish. Check GET /v1/numbers/:id. |
resubmit_unconfirmed | 500 | either | Check first | Don't resubmit again yet; poll GET /v1/registration. |
server_error | 500 | code | Check first | Failed on our side. |
send_failed | 500 | error_code | New key | Not sent, not charged. Retry with a new key.DetailsWait a minute. The same key replays this error. In bulk results, also used when a recipient's refusal had no code at all: readerror. |
BILLING_CANCEL_FAILED | 502 | code | Later | Monthly fee not stopped, so nothing released. |
checkout_unconfirmed | 502 | error_code | No | Checkout unconfirmed. Check GET /v1/numbers first. |
payment_unknown | 502 | error_code | No | Registration payment unconfirmed. Don't resubmit; poll GET /v1/registration. |
subscription_cancel_failed | 502 | either | Later | Monthly fee stop unconfirmed, so it was kept. |
ADMISSION_SHED | 503 | code | Same key | Overloaded; nothing done. Wait Retry-After (1 s) and retry. |
AUTH_CHECK_UNAVAILABLE | 503 | code | Yes | We couldn't check your key. Nothing ran. |
carrier_capacity_busy | 503 | error_code | New key | Carrier capacity busy. Not sent, not charged. |
checkout_unavailable | 503 | either | Later | Checkout unavailable. Nothing charged.DetailsAlsoavailability_check_unavailable, cap_check_unavailable. |
consent_check_failed | 503 | error_code | New key | Consent check couldn't run. Not sent, not charged. |
contact_unavailable | 503 | error_code | New key | The contact couldn't be read. Nothing sent. |
could_not_retrieve_payment_intent | 503 | error | Yes | Vetting payment unchecked. Don't pay again; retry.DetailsRetry the confirm call. The same code with400 means the payment wasn't found: contact support. |
gate_check_failed | 503 | error_code | New key | Safety check couldn't run. Not sent, not charged. |
payment_check_unavailable | 503 | either | Yes | We couldn't check the registration payment. |
payments_unavailable | 503 | either | Later | Registration payments are unavailable. Nothing charged. |
permission_check_unavailable | 503 | both | New key | We couldn't check the key's permissions. Nothing ran. |
provider_busy | 503 | error_code | New key | Carrier is limiting our rate. Not sent, not charged.DetailsHasRetry-After: 60: retry after that. |
sending_paused | 503 | error_code | Later | Sending is paused. Nothing sent, not charged. |
tcpa_not_configured | 503 | both | Later | Scrubbing unavailable. Nothing scrubbed or charged. |
throughput_unavailable | 503 | error_code | Later | Throughput couldn't be read. |
validation_unavailable | 503 | both | Later | Number validation isn't available right now. Nothing charged. |
REQUEST_TIMEOUT_OUTCOME_UNKNOWN | 504 | code | Same key | A write ran over 30 s and may still go through.DetailsWith anIdempotency-Key, retry with the same key after a minute to get its result. Elsewhere, check whether it worked before you retry. |
#Conventions
Pagination
Offset lists (GET /contacts, /conversations, /conversations/:id/messages, /billing/transactions, /sms/logs) take limit and offset. Cursor lists (GET /v1/messages, /v1/opt-outs, /v1/sub-accounts) take limit and before_id: pass the previous page's next_before_id. They ignore offset, so a client that sends it gets page one forever.
Stop when has_more is false (hasMore on /sms/logs), not on a short page or on next_before_id. /contacts has only total: stop on a page with fewer rows than limit.
Details
limitdefaults to 50 and is capped at 1,000 (500 for thread messages; 200 for transactions, where the default is 20). Larger values are capped without an error.0, negative or non-numeric values fall back to the default, except onGET /billing/transactions, wherelimit=0returns an empty page.offsetmust be a whole number, 0 or more. Other values return400 invalid_offseton/contactsand/conversations.next_before_idis set on the last page too, and isnullwhen the page is empty.has_morecan betrueon a page that is exactly full, so the last page may be empty.totalcan be an estimate on large or slow queries, can be up to 2 minutes old, and can show 1 when nothing matches. Use it for display, not to end a loop.
| Endpoint | Style | Paging fields next to data | Max limit |
|---|---|---|---|
GET /contacts | offset | total | 1,000 |
GET /conversations | offset | has_more, total, total_capped, count_cap, counts | 1,000 |
GET /conversations/:id/messages | offset | none | 500 |
GET /billing/transactions | offset | total, summary | 200 |
GET /sms/logs | offset | hasMore, total (always null) | 1,000 |
GET /v1/messages | cursor | has_more, next_before_id | 200 |
GET /v1/opt-outs | cursor | has_more, next_before_id | 500 (default 100) |
GET /v1/sub-accounts | cursor | has_more, next_before_id | 200 |
GET /conversations?limit=100&offset=0
GET /conversations?limit=100&offset=100
# stop when has_more is falseGET /v1/messages?limit=200
GET /v1/messages?limit=200&before_id=<next_before_id>
# stop when has_more is falseRate limits
A 429 from these limits means nothing ran: wait Retry-After seconds and retry with the same Idempotency-Key. The sending caps (daily_cap, number_daily_cap, monthly_cap, ramp_cap) also answer 429 but have no Retry-After and won't clear in a retry loop: see When to retry and Sending caps.
| What | Limit | Counted | Over it |
|---|---|---|---|
Sending (/v1/messages, /sms/send) | Paced to your carrier throughput for AT&T and unknown-carrier recipients (3 a second if your cap is unknown). Hard ceiling 6,000 sends a minute.DetailsReady holds a request up to 1.5 s before refusing, for up to 40 waiting requests per server; past that a send is refused at once. | Per account (pacing is counted per server) | Pacing: 429 rate_limited in error_code, with Retry-After. Ceiling: 429 rate_limited in code; wait a minute.DetailsThe pacing body hasretry_after_ms and cap_per_sec; Retry-After is retry_after_ms rounded up to whole seconds. The ceiling response has no retry_after_ms. |
Bulk send, message history, validate, scrub (/v1/messages/bulk, GET /v1/messages, /v1/validate, /v1/scrub) | About 120 requests a minute, plus 140 bulk calls a minute | Per account (the 120 is counted per server, so it's approximate) | 429 rate_limited (in error_code for the 120, in code for the 140). Bulk also gets 429 throughput_exceeded, below. |
| Number purchase | /phone-numbers/checkout: 20 a minute. POST /v1/numbers/checkout and POST /v1/numbers: 120 a minute. Shared ceilings of 700 checkouts and 140 buys a minute. | Per account | 429 rate_limited |
| Contact exports | 6 an hour, shared with the dashboard | Per account | 429 |
| Everything | 1,200 requests a minute with a valid key, 300 without one | Per IP address | 429 RATE_LIMITED with retry_after_seconds |
Repeated 429s without a valid key block the IP for 15 minutes or longer (403 IP_TEMPORARILY_BLOCKED). A dashboard session and an API key on the same account share the per-account limits.
Details
For example a mistyped key in a retry loop. Requests with a valid key are throttled but never blocked.
Bulk isn't faster. POST /v1/messages/bulk counts every recipient against the same per-second pace before it sends anything, and refuses an over-pace batch whole: 429 throughput_exceeded with Retry-After.
Details
Bulk counts every recipient on every carrier; single sends only count AT&T and unknown-carrier recipients. The pacing 429 on a single send suggests bulk, but bulk won't get you past your carrier pace.
Sending caps
- Daily caps: none on your account unless Ready sets one (
429 daily_cap). You can cap a number yourself (429 number_daily_cap). Both reset at midnight Pacific time (America/Los_Angeles), whatever your account's time zone. - Unregistered accounts created since June 23, 2026: 500 texts per 30-day cap window until they have an approved campaign and a paid number (
402 MONTHLY_CAP_REACHEDon/v1/messages). No daily cap. The send counter resets 30 days after its window starts, not on the first of the month; recent-send counts use the last 30 days. - Warm-up: after brand and campaign approval, see Warm-up limits.
- T-Mobile gives your brand a daily tier for texts to T-Mobile numbers (GET /v1/registration/throughput). Ready doesn't hold or refuse
/v1/messagesor bulk sends for it, so a send over your tier isn't refused by Ready.
Headers. Every response has X-RateLimit-Limit, X-RateLimit-Remaining (approximate) and X-RateLimit-Reset (Unix seconds) for the limit that applied. On sends, rely on Retry-After.
Details
On sends, X-RateLimit-Policy: 3;w=1 is your carrier pace in texts per second, and Limit and Remaining can come from two different limits.
{
"success": false,
"error": "You're sending to AT&T faster than your numbers allow (about 3 texts a second), so this text wasn't sent. Wait 2 seconds and try again, or send in one call with POST /v1/messages/bulk.",
"error_code": "rate_limited",
"retry_after_ms": 1500,
"cap_per_sec": 3
}
Retries and idempotency
Send a new Idempotency-Key per message, and reuse it only to retry that message. Any unique string up to 255 characters (a UUID is easiest; longer is 400 idempotency_key_too_long). A retry with the same key returns the original response with Idempotent-Replayed: true, without sending or charging twice.
- Where:
POST /v1/messages,/v1/messages/bulk,/sms/send,/v1/numbers/checkout,/phone-numbers/checkout,POST /v1/numbers,/v1/registration/:id/resubmit,/v1/validateand/v1/scrub. - A key is tied to its first request. The same key with a different body returns the first result and does not send the new one. There's no mismatch error.
- Errors are stored too (all but
429). After fixing a request, or after a5xxthat says nothing was sent, use a new key. After a504or your own timeout, reuse the key (When to retry).Details: errors that aren't stored
Errors are stored once the key is recorded, including a202, a500, a503and a504. These are refused before the key is recorded, so they aren't stored and a corrected retry with the same key goes through:- On any endpoint: malformed JSON (
INVALID_JSON),PAYLOAD_TOO_LARGE,idempotency_key_too_long, authentication and account errors (AUTH_REQUIRED,INVALID_API_KEY,INVALID_TOKEN,ACCOUNT_SUSPENDED,AUTH_CHECK_UNAVAILABLE),X-Sub-Account-Iderrors,permission_denied,IP_TEMPORARILY_BLOCKED,REQUEST_BLOCKED(400 or 403),REQUEST_TIMEOUT(408, the body never arrived),ADMISSION_SHED(503), and on/v1pathsunsupported_api_version. - On
POST /v1/messages: a missingtoorbody,invalid_fieldandinvalid_from. - On
POST /v1/numbersandPOST /v1/numbers/checkout:invalid_field. With a live key, the corrected retry is a real purchase.
- On any endpoint: malformed JSON (
- Keys are scoped per account and per endpoint, and remembered for 7 days.
- A retry while the first request runs gets
409 idempotency_in_progress. Unfinished after 20 minutes, it gets409 idempotency_outcome_unknown: treat it like202 send_outcome_unknown(Checking whether a text went out).
Duplicate guard. Without a key, an identical text (same recipient, body and media) within 60 seconds is refused: 409 duplicate_send. To send it twice on purpose, give each request its own key.
Versioning
/v1 responses carry X-API-Version: 1. Pin it with Accept-Version: 1; an unserved version returns 406 unsupported_api_version with a supported list. Paths outside /v1 aren't versioned.
Timeouts
Requests time out after 30 seconds (90 for number checkout and purchase). A read, or an upload that never finished, returns 408 REQUEST_TIMEOUT: nothing changed. A write that may have gone through returns 504 REQUEST_TIMEOUT_OUTCOME_UNKNOWN and Ready keeps working on it.
Details
On the endpoints that take an Idempotency-Key, retry with the same key after a minute to get its real result; a new key can do it twice. If your own client times out first, do the same. Elsewhere, check whether it worked before you retry.
Timestamps, money and ids
- Timestamps: ISO 8601 in UTC with milliseconds (
2026-10-01T20:32:03.473Z), ornull. _centsamounts are integer cents._usdamounts and rates are US dollars. On/billing/balance,balanceis a count of texts, not money.- Ids are opaque. Message ids are strings up to 64 characters (digits for texts you send, often alphanumeric for texts you receive): don't parse them.
- Phone numbers come back in E.164 (
+12125550199).
Response headers
| Header | What it is |
|---|---|
X-Request-Id | A UUID on every response. Include it when you contact support. |
X-API-Version | 1 on /v1 responses. |
X-RateLimit-Limit, -Remaining, -Reset | The limit that applied (see Rate limits). |
X-RateLimit-Policy | On sends: your carrier pace, N;w=1. |
Retry-After | On 429 (and some 503s): seconds to wait. |
Idempotent-Replayed | true when the response is a stored replay. |