Ready API reference
Send texts, manage contacts and numbers, and get events by webhook. JSON over HTTPS.
#Introduction
Every endpoint is relative to this base URL. It's HTTPS only: http:// redirects, and a redirected POST can lose its body.
https://api.tryready.comA live key acts on your real account. To try sends, number purchases and registration without sending or paying, use a test key.
- Responses. Most success responses have
"success": trueand put the result indata. List endpoints add paging fields next to it. A few put their fields at the top level instead; each endpoint's Returns section says where to look. - Errors have
"success": false, anerrorsentence and a stable code. See Errors. - Requests are JSON (
Content-Type: application/json), up to 1 MB (413 PAYLOAD_TOO_LARGE). Malformed JSON returns400 INVALID_JSON. - Which paths.
/v1/*is the versioned API. The other paths here are supported too, but new endpoints are added under/v1. An API key works on the endpoints in this reference. Other paths the dashboard uses may accept it today, but they aren't part of the API, can change without notice, and will be closed to API keys.
New here? Start with the Quickstart.
#Quickstart: send your first text in 5 minutes
You need a Ready account with a card on file, a terminal, and a phone that can receive texts. Your first texts go out on the trial, from a shared Ready number.
The Node.js examples on this page use built-in fetch and top-level await: run them as an .mjs file with Node 20 or later. The Python examples use requests.
Get an API key 1 min
In Ready, open Settings → Integrations and click Generate new key. Copy it: it's shown once and starts with
rsms_. Then put it in your shell.Important. An account has one live key (a test key can exist alongside it). If you already have one, Generate new key replaces it, and the old key stops working immediately. Update everything that uses it first.export READY_API_KEY="rsms_your_key_here"Check the key works 30 s
This proves your key before anything else can go wrong.
Got
401 AUTH_REQUIRED? The variable is usually empty.401 INVALID_API_KEYmeans 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. The text arrives in about 5 to 30 seconds.Legal."consent_attested": trueis your promise that this person agreed to get texts from you (prior express written consent, under the TCPA). Texting your own phone is fine. Ready handlesSTOPand other opt-outs automatically. 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, "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. You use it to look up delivery, and webhooks carry it.
data.statusstringqueued. It becomesdeliveredorfaileda few seconds later.data.segmentsinteger- What you're billed for. A plain text segment is up to 160 characters.
Tip. A200means Ready accepted the text, not that it was delivered. To hear about delivery, use a webhook (next step) or Get a message.Get delivery updates and replies 2 min
Point Ready at any public HTTPS URL that accepts a
POST. A free request inspector works for a first test. Send"regenerate_secret": trueon your first setup so the response includes the signing secret.Now reply to the text from your phone and watch the
inbound_replyevent arrive. Before you go 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, sends use the trial limits below.
- Buy a number: Phone numbers.
- Handle errors by code: Errors. Send an Idempotency-Key on every send.
- Subscribe to
contact_opted_out, and keep your own copy of opt-outs in sync.
Before your first send
- New accounts start in a trial. Sends go out from a shared Ready number (a
fromyou pass is ignored), a card on file is required (402 CARD_REQUIRED), and there's a small allowance of free sends (402 SANDBOX_LIMITwhen it runs out). The free sends are real texts, not a sandbox. - Once your campaign is approved, you send from your own numbers and every send draws from your balance (
402 insufficient_balancewhen it can't cover the text). With no active number, a send answers400 no_sender_number.
Stuck? Email support@tryready.com with the X-Request-Id from the response headers.
#Authentication
Use an API key for server-to-server calls you own. Third-party apps such as Zapier use OAuth 2.0.
API keys
Send the key in an X-API-Key header, or as a bearer token. Send one or the other: if both are present, Authorization: Bearer is used and X-API-Key is ignored. Write Bearer with a capital B.
- Keys start with
rsms_and are shown once. - Only the account owner can create one, and a key acts with the owner's full permissions.
- An account has one live key. A test key (
rsms_test_) can exist alongside it. Generating a new live key replaces the old one, which 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 that starts with rsms_test_ is a test key. It simulates the call instead of doing it: nothing is sent and nothing is charged. Settings creates live keys only, so you can't create a test key yourself today.
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 (single and bulk), number checkout and purchase, every registration write,
/v1/validate, and creating client accounts and setting their markup. - Simulated responses add
"test": trueand anote, and some shapes differ.POST /v1/messagesandPOST /sms/sendreturn a flat body with nodata:{ "success": true, "test": true, "message_id": "test-msg-…", "status": "accepted", "segments": 1, "note": "test mode: no real message sent, no charge" }. The message isn't stored, soGET /v1/messages/:idanswers404 message_not_foundfor it.POST /v1/messages/bulkreturnsmessage_id: nullandsegments: nullin each result.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. - Not simulated:
/v1/scruband/sms/generateare billed even with a test key. Contacts, conversations, webhook settings, screening lists (/compliance/scrub-categories) and number releases (DELETE /v1/numbers/:id) change for real. Creating a contact with a test key also fires yourcontact_createdwebhook and Zapier triggers, and runs the TCPA screen at your scrub rate. - Reads are real: a test key reads your real balance, rates, numbers, messages and contacts. Only the actions above are simulated.
- No delivery webhooks: test sends never trigger
message_deliveredormessage_failed. To try your endpoint, use Send test (see Webhooks). - MCP: don't use a test key there. A test key works on the MCP server as a bearer token, but only
send_smsis simulated.send_campaignandenroll_in_dripsend real texts and are charged at your normal rate, andai_generate_messageis billed. Contact, conversation, campaign, drip and AI-agent changes are real. MCP has no tools for buying numbers or registering. Test keys will soon be blocked from MCP entirely.
OAuth 2.0
The authorization-code flow used by the published Zapier app. Tokens from /zapier/oauth/token are JWTs: send them as Authorization: Bearer <token>. OAuth endpoint errors follow RFC 6749 (error, error_description). The MCP server has its own OAuth flow; see MCP.
Acting as a client account
Agencies can send X-Sub-Account-Id with an API key to run a /v1 call as one of their client accounts. See Client accounts.
Authentication errors
These carry the code in code.
| HTTP | Code | Meaning |
|---|---|---|
| 401 | AUTH_REQUIRED | No key was sent, the variable is empty, or bearer was lower case. |
| 401 | INVALID_API_KEY | Starts with rsms_ but isn't valid: revoked, mistyped or cut off. |
| 401 | INVALID_TOKEN | The value isn't an rsms_ key or a valid OAuth token. |
| 403 | ACCOUNT_SUSPENDED | The account is suspended; every call is refused. Contact support. |
| 503 | AUTH_CHECK_UNAVAILABLE | We couldn't check the key. 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
An error has a non-2xx status (one exception is listed below) and a JSON body:
successboolean- Always
false. errorstring- A sentence for people. It can change, so don't parse it. Some say "refresh the page" because the dashboard shares these endpoints.
error_codestring- A stable code to branch on.
codestring- The same kind of stable code. Some errors use this field instead of
error_code, some use both. …- Extra fields where they apply, such as
next_allowed_at,alternatives,field,balance_cents.
error_code, and fall back to code. 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). A 400 with no code at all is a malformed request: fix the body, don't retry.An unknown path, or an HTTP method a path doesn't support, returns an HTML 404 page, not JSON. Check the method and URL before parsing the body. A missing resource on a real path returns a JSON 404 with a code.
When to retry
| Response | What to do |
|---|---|
429 | Wait Retry-After seconds, then retry with the same Idempotency-Key. |
409 idempotency_in_progress | Wait a few seconds, then retry with the same key. |
503, 504, 202 send_outcome_unknown | Check GET /v1/messages first. If it didn't go, send again with a new key (an error stored under the old key would be replayed). |
Other 4xx | Fix the request, then send it with a new key. |
422 | Don't retry. The number can't receive texts. |
Status codes
200OK.201created (contacts).202accepted but not finished, or the outcome is unknown.400the request is wrong.401no or bad credentials.402payment needed.403not allowed (often: you can't text this contact).404not found.406unsupported API version.408timed out, nothing changed.409conflict, or not sendable right now.413body over 1 MB.422the number can't receive texts.423legal hold.429too fast or over a limit.500,502something failed on our side.503a safety check couldn't run, nothing was sent and you weren't charged.504a write timed out and may still go through.
One 200 isn't a success: empty_merge_field (see below). One 202 isn't either: send_outcome_unknown.
{
"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 appears (error_code, code, both, or error on the few endpoints that put it there; either means read error_code ?? code). Retry: Same key and After Retry-After reuse your Idempotency-Key; anything else that resends needs a new key.
| Code | HTTP | Field | Retry? | What to do |
|---|---|---|---|---|
empty_merge_field | 200 | error_code | After fixing | A merge field is blank for this contact. Nothing was sent. Resend with "confirm_empty_merge": true to send it with the blank. |
payment_processing | 202 | either | Later | The registration payment is still processing. |
send_outcome_unknown | 202 | error_code | No | We couldn't confirm the carrier took the text. Check GET /v1/messages before you send again. |
address_incomplete | 400 | code | After fixing | A brand registration is missing part of the business address: street, city, state or 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 | A brand registration needs business_description: one or two sentences on what the business does. |
ca_not_provisioned | 400 | error_code | No | Sending to Canada isn't set up on this account. |
categories_required | 400 | error | After fixing | Scrub categories: categories is missing. This endpoint puts the code in error and the sentence in message. |
consent_required | 400 | both | After fixing | Registration needs "consent": true (the boolean, not a string). |
CONTACT_UPDATE_FAILED | 400 | code | After fixing | A value can't be saved (for example a non-integer score). |
CONTENT_BLOCKED | 400 | both | No | Link shorteners, prohibited or regulated content. Nothing is sent, but a compliance fee may apply; error states the amount and reasons lists what was flagged. Lower case content_blocked from other send paths. |
ein_name_mismatch | 400 | error | After fixing | The EIN doesn't match the business name. The body has title, message, steps, can_override. |
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 | A field has the 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_phone | 400 | error_code | After fixing | On GET /v1/validate/:phone, the path value is longer than 40 characters. A short value that isn't a number answers 200 with valid: false. |
invalid_request | 400 | both | After fixing | The request body is missing something or has the wrong shape. error says what. |
invalid_status | 400 | code | After fixing | Conversation status 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 | The webhook URL isn't usable (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 over 1,600 characters with an image. Also seen in lower case (message_too_long) from other send paths. |
NO_APPROVED_CAMPAIGN | 400 | error_code | No | The number has no approved 10DLC campaign yet. |
no_country_sender | 400 | error_code | No | You have no number that can send to this country. |
no_sender_number | 400 | error_code | After fixing | The account has 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 a payment_intent_id. Also 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 | A contact's phone number can't be changed. Create a new contact. |
phones_required | 400 | error_code | After fixing | Validate needs phones: a list of 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 | An approved registration can't be cancelled over the API. Contact support. |
REQUEST_BLOCKED | 400 | code | After fixing | The query string contains text that looks like code (HTML, script or SQL). Remove it. Repeats can get your IP blocked (IP_TEMPORARILY_BLOCKED). |
reseller_not_approved | 400 | both | After fixing | Markup needs an approved reseller account. |
sub_account_header_needs_api_key | 400 | both | After fixing | X-Sub-Account-Id needs an API key, not an OAuth or session token. |
sub_account_header_not_supported | 400 | both | After fixing | X-Sub-Account-Id works only on /v1 paths. |
terms_required | 400 | both | After fixing | Accept the reseller terms in your dashboard before setting a markup. |
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: a key isn't tcpa, dnc_complainers, federal_dnc or state_dnc. |
unknown_field | 400 | code | After fixing | The endpoint doesn't accept a field you sent. Remove it. |
UNRENDERED_MERGE_TOKEN | 400 | error_code | After fixing | A merge tag didn't fill in. Nothing sent, not charged. |
AUTH_REQUIRED | 401 | code | After fixing | No key was sent, the variable is empty, or bearer was lower case. |
INVALID_API_KEY | 401 | code | After fixing | Starts with rsms_ but isn't valid: revoked, mistyped or cut off. |
INVALID_TOKEN | 401 | code | After fixing | The value isn't an rsms_ key or a valid OAuth token. |
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 | Your balance can't cover this. balance_cents and required_cents say by how much. |
insufficient_credits | 402 | both | After adding funds | Your balance can't cover the whole batch (bulk send, validation). Nothing was sent or charged. |
MONTHLY_CAP_REACHED | 402 | code | No | Your monthly sending cap is reached. Same limit as monthly_cap (429) from other send paths. |
PURCHASE_BLOCKED | 402 | code | No | Number purchases are blocked (unpaid bill or account review). Contact support. |
SANDBOX_LIMIT | 402 | error_code | No | The trial's free sends are used up. Register your business to keep sending. |
ACCOUNT_SUSPENDED | 403 | code | No | The account is suspended and every call is refused. Contact support. The send path can also answer lower case account_suspended in error_code. |
api_consent_attested_required | 403 | error_code | After fixing | Send "consent_attested": true. Not returned yet: today a send without it goes through. It will be once enforcement is switched on. |
dnc_list | 403 | error_code | No | The number is on a do-not-call list your account screens against. |
dnd_sms | 403 | error_code | No | The contact is marked Do Not Disturb for texts. |
EXPORT_DISABLED | 403 | code | No | The owner turned off exports for teammates. |
global_blocklist | 403 | error_code | No | The number is on Ready's blocklist. |
IP_TEMPORARILY_BLOCKED | 403 | code | Later | Too many over-limit requests without a valid key. Blocked for 15 minutes or longer. |
opt_out_locked | 403 | error_code | No | The contact replied STOP (or was locked by TCPA scrubbing). Only the contact texting START lifts it. |
opted_out | 403 | error_code | No | You tried to re-enable texting for an opted-out contact. 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 sending hours (other 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 | Your client's User-Agent looks like a security scanner. Send your own User-Agent. |
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. |
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 message with this id on your account. The body also 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 | Accept-Version names a version this server doesn't serve. supported lists them. |
REQUEST_TIMEOUT | 408 | code | Yes | A read, or an upload that never finished, took longer than 30 seconds. 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 | No numbers in stock for this area code. alternatives suggests nearby ones. |
CONTACT_CHANGED | 409 | code | After fixing | The contact opted out while you were saving. Read it again. |
CONTACT_EXISTS | 409 | code | No | A contact with this phone number already exists. Look it up with GET /contacts?search=. |
duplicate_brand | 409 | error_code | No | You already have this brand (existing_reg_id). |
duplicate_send | 409 | error_code | No | The same text went to the same number in the last 60 seconds without an Idempotency-Key. |
duplicate_use_case | 409 | error_code | No | You already have a campaign with this use case. Your brand created a MIXED campaign for you. |
idempotency_in_progress | 409 | error_code | Same key | The first request with this key is still running. Wait a few seconds. |
idempotency_outcome_unknown | 409 | error_code | No | The first request with this key didn't finish within 20 minutes. Check GET /v1/messages before sending again. |
INVALID_SENDER | 409 | error_code | No | The number you're sending from can't send right now. Try another number. |
needs_transfer_confirm | 409 | error_code | After fixing | The number is on another brand. Resend with "transfer_confirm": true. |
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 | The number isn't assigned to this contact's owner (dashboard sends). |
NUMBER_RESERVED | 409 | code | No | This number can't be released right now. |
paid_not_filed | 409 | error | No | The registration is paid but not filed yet. 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 | Outside the recipient's quiet hours. Body has next_allowed_at, reason, recipient_tz, can_override. When can_override is true, you can send again with override_quiet_hours. |
RAMP_CAP_REACHED | 409 | code | Later | A new campaign's warm-up cap is used up. Body has cap, 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 turned off for this account. 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 | The number is disconnected. |
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 | A legal hold covers this contact, so it can't be deleted. |
daily_cap | 429 | error_code | Later | Daily sending cap reached. |
duplicate_submission | 429 | error_code | No | The same registration was submitted in the last 2 minutes (existing_reg_id). |
monthly_cap | 429 | error_code | No | Monthly sending cap reached (same limit as MONTHLY_CAP_REACHED). |
number_daily_cap | 429 | error_code | Later | This number's daily cap is reached. |
ramp_cap | 429 | error_code | Later | Warm-up cap (same limit as RAMP_CAP_REACHED). |
rate_limited | 429 | error_code | After Retry-After | Too many requests for this account. Body has retry_after_ms (and cap_per_sec when it's carrier pacing). |
RATE_LIMITED | 429 | code | After Retry-After | Too many requests from this IP address. Body has retry_after_seconds. |
throughput_exceeded | 429 | both | Later | The batch is more than your carriers allow right now. Nothing was sent. |
validation_in_progress | 429 | both | After Retry-After | The same numbers are already being validated. |
buy_incomplete | 500 | error_code | No | The number was added but setup didn't finish. Don't buy again; check GET /v1/numbers. |
generation_failed | 500 | either | Yes | Writing drafts failed. Refunded. |
internal_error | 500 | both | Check first | Something failed on our side. On billed calls (validate, scrub), check your balance before you retry. |
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 | Something failed on our side (seen on a bad offset to GET /conversations). |
send_failed | 500 | error_code | No | The send failed. In bulk results, also used when a recipient's refusal had no error_code: read error. |
BILLING_CANCEL_FAILED | 502 | code | Later | We couldn't stop the number's monthly fee, so nothing was released. |
checkout_unconfirmed | 502 | error_code | No | We couldn't confirm the checkout. Check GET /v1/numbers before retrying. |
payment_unknown | 502 | error_code | No | We couldn't confirm the registration payment. Don't submit again; poll GET /v1/registration. |
subscription_cancel_failed | 502 | either | Later | We couldn't confirm the registration's monthly fee stopped, so it was kept. |
ADMISSION_SHED | 503 | code | Same key | We're overloaded and didn't act on the request. Wait Retry-After (1 second) and retry. |
AUTH_CHECK_UNAVAILABLE | 503 | code | Yes | We couldn't check your key. Nothing ran. |
checkout_unavailable | 503 | either | Later | Number checkout isn't available. Also availability_check_unavailable, cap_check_unavailable. Nothing charged. |
consent_check_failed | 503 | error_code | New key | The consent check couldn't run. Nothing sent, not charged. |
contact_unavailable | 503 | error_code | New key | The contact couldn't be read. Nothing sent. |
could_not_retrieve_payment_intent | 503 | either | Yes | We couldn't check the vetting payment. Don't pay again; retry the confirm call. |
gate_check_failed | 503 | error_code | New key | A safety check couldn't run. Nothing 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. |
provider_busy | 503 | error_code | New key | The carrier is limiting how fast we can send. Nothing sent, not charged. Retry in a minute. |
sending_paused | 503 | error_code | Later | Sending is paused. Nothing sent, not charged. |
tcpa_not_configured | 503 | both | Later | Scrubbing isn't available right now. 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 | Check first | A write took longer than 30 seconds and may still go through. Check whether it worked before you retry. |
#Conventions
Pagination
Lists page in one of two styles.
Offset lists (GET /contacts, /conversations, /conversations/:id/messages, /billing/transactions, /sms/logs) take limit and offset.
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, and0, negative or non-numeric values fall back to the default (exceptGET /billing/transactions, wherelimit=0returns an empty page).offsetmust be a whole number, 0 or more. Today a bad offset returns an empty page on/contactsand a500on/conversations.
Cursor lists (GET /v1/messages, /v1/opt-outs, /v1/sub-accounts) take limit and before_id. They ignore offset, so a client that sends it gets the first page again, forever. Pass the previous page's next_before_id as before_id.
When to stop. Stop when has_more (or hasMore on /sms/logs) is false, not on a short page and not on next_before_id (it's set on the last page too, and is null when the page is empty). has_more can be true on a page that is exactly full, so the last page may be empty. /contacts returns only total: stop when a page has fewer rows than limit (or none). total can be an estimate on large or slow queries, can be up to 2 minutes old, and can show 1 when nothing matches, so use it for display only, 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 means slow down: wait Retry-After seconds and retry. Each limit counts something different:
| What | Limit | Counted | Over it |
|---|---|---|---|
Sending (/v1/messages, /sms/send) | Paced to your carrier throughput for AT&T and unknown-carrier recipients (3 texts a second if your cap is unknown). Ready holds a request up to 1.5 s before refusing. Hard ceiling 6,000 sends a minute. | Per account | 429 rate_limited with retry_after_ms, cap_per_sec |
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 (approximate: counted per server) | 429 rate_limited |
| 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 (for example a mistyped key in a retry loop) block the IP for 15 minutes or longer: 403 IP_TEMPORARILY_BLOCKED. Requests with a valid key are throttled but never blocked. A dashboard session and an API key on the same account share the per-account limits.
Headers. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (a Unix time in seconds) for the limit that applied. Treat Remaining as approximate. 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, so rely on Retry-After.
{
"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
Use one new Idempotency-Key per message, and reuse it only to retry that same message. Any unique string up to 255 characters works; a UUID is easiest. If a request times out, retry with the same key: Ready returns the original response, with Idempotent-Replayed: true, instead of 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. Reusing it with a different body returns the first result and does not send the new one. There's no mismatch error.
- Errors are stored too (everything except
429), once the key is recorded. After you fix a request, or after a5xx, send it with a new key. Exceptions, 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. Over 255 characters:
400 idempotency_key_too_long. - While the first request is still running, a retry gets
409 idempotency_in_progress. If it hasn't finished after 20 minutes, a retry gets409 idempotency_outcome_unknown: checkGET /v1/messagesbefore sending again.
Duplicate guard. Without an Idempotency-Key, an identical text (same recipient, body and media) sent again within 60 seconds is refused with 409 duplicate_send. To send the same text twice on purpose, give each request its own key.
Versioning
/v1 responses carry X-API-Version: 1. Send Accept-Version: 1 to pin it; a version the server doesn't serve 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 and nothing changed. A write that may have gone through returns 504 REQUEST_TIMEOUT_OUTCOME_UNKNOWN: check whether it worked before you retry.
Timestamps, money and ids
- Timestamps are ISO 8601 strings in UTC with milliseconds, like
2026-10-01T20:32:03.473Z. Empty timestamps arenull. - Amounts ending
_centsare integer cents. Amounts ending_usd, and rates, are US dollars. On/billing/balance,balanceis a count of texts, not money. - Treat ids as opaque. Message ids are strings of up to 64 characters: digits for texts you send, often letters and digits for texts you receive. Don't parse them or assume a format.
- 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. |
#Messages
POST /v1/messages is the send to use. POST /sms/send is an older equivalent, kept under Legacy endpoints. For what a text costs, see How a text is priced.
#Send a message
/v1/messagesSends one text. A 200 means Ready accepted it for sending, not that it was delivered.
Headers
Idempotency-KeystringRecommended- A new unique value per message, reused only to retry it. See Idempotency.
Body parameters
toE.164 stringRequired- Recipient. E.164 (
+12125550199) is recommended. A 10-digit US number is treated as +1. bodystringRequired- The text.
messageis accepted as an alias. Up to 25 segments: about 3,825 plain characters, or about 1,600 with emoji or other Unicode. Longer returns400 MESSAGE_TOO_LONG. consent_attestedbooleanRequired- Your promise that this recipient agreed to get texts from you. Required, and it will be enforced: a send without it will be refused with
403 api_consent_attested_required. Today that isn't switched on yet, so a send without it still goes through and gets the normal response; Ready records a warning on its side."true"and1are also accepted. fromE.164 stringOptional- Send from this one of your numbers. A number that isn't on your account returns
400 invalid_from. Leave it out and Ready picks the number.from_phone_number_idisn't supported here; usefrom. conversation_idintegerOptional- A conversation id from
GET /conversations. The text is filed in that conversation and, unless you setfrom, Ready prefers the number that conversation uses. An id that isn't on your account is ignored. quoted_message_idintegerOptional- The
idof a message in that conversation (fromGET /conversations/:id/messages). Ready saves its first 200 characters asquoted_texton the new message in your inbox. The text the recipient gets is unchanged. override_quiet_hoursbooleanOptional- Send anyway after a
409 QUIET_HOURS_BLOCKthat has"can_override": true. It lifts only the quiet hours set on your account, never the federal and state sending hours. Ready records it as your statement that this is a consented, conversational text.
More parameters (3)
media_urlstringOptional- A public
httporhttpsURL of an image to attach. Any other value is ignored and the text goes out as plain SMS. With an image, the text is limited to 1,600 characters and the message is billed as one image message. from_strategyenumOptionalDefaultlocal- How Ready picks the number when you don't set
from.localmatch the recipient's area codeevenspread across your numbers, least used first (deliverabilityis the same)random
confirm_empty_mergebooleanOptional- Send
trueto send even though a merge field is blank (seeempty_merge_fieldbelow).
Returns
data.message_idstring- Store it. Opaque, up to 64 characters: don't parse it or assume a format. Webhooks and
GET /v1/messages/:iduse the same value. data.statusstringqueued. The carrier confirmsdeliveredorfailedwithin about 5 to 30 seconds.data.segmentsinteger- What you're billed for.
data.sent_atISO 8601 string- When Ready handed it to the carrier.
sent_at_unixis the same time in Unix seconds. data.carrier_feenumber- The carrier fee charged for this message, in USD (
segments × carrier_fee_usd_per_segment). data.balance_usdnumber- Your balance after this send.
carrier_balanceis the same balance to 4 decimal places. data.credits_remaininginteger- Roughly how many more 1-segment texts your balance covers, at the standard registered rate. Approximate.
What the response means
200,"success": true: accepted,status: "queued".202,"success": false,error_code: "send_outcome_unknown",outcome: "unknown": we couldn't confirm the carrier took it. CheckGET /v1/messagesbefore you send again.200,"success": false,error_code: "empty_merge_field": a merge field is blank for this contact, so nothing was sent. The body hasneeds_confirmationandempty_fields. Send again with"confirm_empty_merge": trueto send it with the blank.- Rarely,
200withwarningand"recorded": falseand nomessage_id: the text was sent and charged, but we couldn't save it. Don't send it again.
Opt-outs, blocklisted numbers and known landlines are refused up front, so you find out right away.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | no code | Missing to or body. |
| 400 | invalid_field | A field has the wrong type (field names it). |
| 400 | invalid_from | from isn't one of your numbers. |
| 400 | MESSAGE_TOO_LONG | Over 25 segments, or over 1,600 characters with an image. |
| 400 | CONTENT_BLOCKED | Blocked content. Nothing sent, but a compliance fee may apply. |
| 402 | insufficient_balance | Balance too low. Also CARD_REQUIRED, SANDBOX_LIMIT, MONTHLY_CAP_REACHED. |
| 403 | opt_out_locked | The contact opted out. Also dnd_sms, tcpa_block, global_blocklist. |
| 403 | api_consent_attested_required | consent_attested missing, once enforced. Today the send goes through. |
| 409 | QUIET_HOURS_BLOCK | Outside quiet hours (in code, with next_allowed_at). |
| 409 | RAMP_CAP_REACHED | Warm-up cap used up (in code). |
| 409 | duplicate_send | Same text to the same number within 60 seconds, without a key. |
| 422 | INVALID_DESTINATION | Not a valid phone number. Not charged. |
| 429 | rate_limited | Faster than your carriers allow. Wait Retry-After. |
| 503 | provider_busy | Carrier is busy. Not sent, not charged. |
#Send to many
/v1/messages/bulkSends one text to up to 100 recipients in one request, with a result for each recipient.
Idempotency-Key (you get 409 idempotency_in_progress while it's still running).Headers
Idempotency-KeystringRecommended- One key for the whole batch.
Body parameters
tostring[]Required- Up to 100 recipients, E.164 recommended. Entries that aren't non-empty strings are skipped and not counted in
total. Duplicate numbers aren't merged: the repeat is refused asduplicate_send. bodystringRequired- The text, sent to every recipient.
messageis accepted as an alias. consent_attestedbooleanRequired- Your promise that every recipient agreed to get texts from you. Enforced the same way as on
/v1/messages: required, not refused yet. fromE.164 stringOptional- One of your numbers. A number that isn't yours returns
400 invalid_from. media_urlstringOptional- Image URL, as in Send a message.
Returns
The call returns 200 whenever the batch was processed, even if every recipient failed. Check failed and each results[].sent. With a test key, message_id and segments are null in every result.
data.totalinteger- Recipients processed.
data.sentinteger- How many were accepted.
data.failedinteger- How many were refused.
data.results[]object[]- One per recipient:
to,status(the HTTP status a single send would have returned),sent, and eithermessage_idandsegments, orerroranderror_code.
Before sending anything, Ready checks the whole batch: 402 insufficient_credits if your balance can't cover it (with required_cents, balance_cents, recipients), 429 throughput_exceeded if it's more than your carriers allow right now (with retry_after_ms, cap_per_sec). In both cases nothing is sent.
When a recipient is refused with a code that a single send puts in code only (quiet hours, warm-up cap, monthly cap), or with no code, its result reads error_code: "send_failed". Read its error before you retry it.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_request | to isn't a list, or there's no body. |
| 400 | too_many_recipients | More than 100 recipients. |
| 400 | invalid_from | from isn't one of your numbers. |
| 402 | insufficient_credits | Balance can't cover the batch. Nothing sent. |
| 429 | throughput_exceeded | Over your carrier throughput. Nothing sent. |
#Get a message
/v1/messages/:idThe delivery status of one message you sent or received.
Path parameters
idstringRequired- The
message_ida send returned.
Returns
data.statusstringqueued,delivered,failedorundeliveredfor messages you sent;receivedfor inbound messages.data.error_reasonstring | null- A stable value to branch on,
nullunlessstatusisfailedorundelivered. Specific reasons:recipient_cannot_receive_sms(landline, VoIP or data-only line, don't retry),recipient_out_of_credit(prepaid plan with no credit),recipient_network_unknown(new, ported or out-of-service number),recipient_unreachable(phone off or out of coverage),recipient_blocked,carrier_delivery_failed(accepted, then not delivered, no reason given) andcarrier_filtered_content(spam filter, reword the text). General reasons when the carrier gives no cause:carrier_rejected,undeliverable,expired,invalid_destination,temporary_failure(worth retrying),rejectedandunknown. data.error_detailstring | null- A plain sentence explaining the failure that you can show to a person,
nullwhenerror_reasonis. Don't match on it, because its wording can change. data.from, data.toE.164 string- Sender and recipient.
data.segmentsinteger- Billed segments.
data.created_at, sent_at, delivered_atISO 8601 string | null- When it was created, sent and delivered.
Errors
| HTTP | Code | When |
|---|---|---|
| 404 | message_not_found | No message with this id on your account (a message on another account answers the same way). The body also has status: "unknown". |
#List messages
/v1/messagesYour sent and received messages, newest first. Pages with a cursor.
Query parameters
directionenumOptionalinboundoroutbound.toE.164 stringOptional- Only messages to this number.
fromE.164 stringOptional- Only messages from this number.
statusstringOptionalqueued,delivered,failed,undeliveredorreceived.limitintegerOptionalDefault50Max200- Page size.
before_idintegerOptional- The
next_before_idfrom the previous page (not a message id).offsetisn't supported and is ignored.
Returns
data[]object[]id(the same value asmessage_id),direction,from,to,body,status,segments,cost_usd,media_url,created_at,delivered_at.has_moreboolean- Keep paging while
true. It'strueon an exactly full page, so the last page can be empty. next_before_idinteger | null- Pass as
before_idfor the next page.nullwhen the page is empty.
#Conversations
The inbox has one thread per contact. Read threads and their messages here. To reply, send to the contact with POST /v1/messages; it lands in the same thread.
Errors in this section carry their code in code. Teammates with view-only access can read conversations but not change them.
#List conversations
/conversationsYour inbox threads: starred first, then by latest message.
Query parameters
limitintegerOptionalDefault50Max1000- Page size.
offsetintegerOptionalDefault0- Rows to skip. A whole number, 0 or more (other values return
500 server_errortoday). filterenumOptional- Which view. Default: open threads, with archived and snoozed ones hidden. Unknown values are ignored.
unreadstarredarchivedsnoozedspamblocked
searchstringOptional- Matches the contact's name, phone, email or company, or the text of the thread's most recent message. Older messages aren't searched. A full phone number in any format matches exactly.
line_idsstringOptional- Comma-separated number ids from
GET /v1/numbers, not phone numbers, for exampleline_ids=2647,2648. Up to 100. Invalid values are ignored, which returns the whole inbox. freshintegerOptional- Lists are cached for up to 30 seconds. Send
1right after a change to read live.
Returns
data[]object[]- Threads:
id,contact_id,first_name,last_name,phone,status,unread_count,starred,pinned,assigned_to,last_message_at,last_message_preview,phone_number_id,from_number,tagsand more. Ignore fields you don't recognize. has_moreboolean- Keep paging while
true. totalinteger- Threads matching
filterandsearch. Stops counting at 1,000 (total_capped: true,count_cap: 1000). Withline_ids,totalandcountsignore the lines and count every line's threads, so usehas_more. countsobject- Thread counts per view:
all(the default view: archived and snoozed threads aren't in it),unread,starred,pinned,archived,snoozed,spam.
#List messages in a conversation
/conversations/:id/messagesOne thread's messages: the newest page, oldest first within the page.
mark_read=0.Path parameters
idintegerRequired- The conversation id.
Query parameters
mark_readintegerOptional- Send
0to read without changing the unread state. limitintegerOptionalDefault50Max500- Page size.
offsetintegerOptionalDefault0- Increase to go further back in time.
Returns
data[]object[]- Messages:
id,direction(inboundoroutbound),body,status,to_number,from_number,segments,cost,media_url,created_at,sent_at,delivered_at,failed_at, and more. Ignore fields you don't recognize.
Errors
| HTTP | Code | When |
|---|---|---|
| 404 | CONVERSATION_NOT_FOUND | No conversation with this id on your account. |
#Find a contact's conversation
/conversations/by-contact/:contactIdThe thread for one contact, or null if there isn't one.
Path parameters
contactIdintegerRequired- The contact id.
Returns
dataobject | null- The thread, or
nullwhen the contact has no thread. An unknown contact id also returnsnull, not a404, so check fornull. This row hascontact_status,dnd_smsandopt_out_at, and notags.
#Assign a conversation
/conversations/:id/assignReplaces the thread's assignees.
Path parameters
idintegerRequired- The conversation id.
Body parameters
assignee_idsinteger[]Required- Team member ids (find them in Settings → Team). The first id is the primary owner and also becomes the contact's owner. Send
[]to unassign. It must be a JSON array: today any other value unassigns the thread and its contact.
Returns
assignee_idsinteger[]- The new assignees, at the top level.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_assignee | An id isn't on your team. |
| 404 | not_found | No conversation with this id (lower case here). |
#Set conversation status
/conversations/:id/statusOpens or closes a thread.
Path parameters
idintegerRequired- The conversation id.
Body parameters
statusenumRequiredopenorclosed(alsoactive,archived). To archive, prefer Archive, which also sets the archive time.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_status | Missing or unknown status. |
| 404 | no code | No conversation with this id (no code here). |
#Mark read
/conversations/:id/readClears the thread's unread count. No body.
Path parameters
idintegerRequired- The conversation id.
Errors
| HTTP | Code | When |
|---|---|---|
| 404 | CONVERSATION_NOT_FOUND | No conversation with this id on your account. |
#Mark unread
/conversations/:id/unreadSets the thread's unread count to 1. No body.
Path parameters
idintegerRequired- The conversation id.
Errors
| HTTP | Code | When |
|---|---|---|
| 404 | CONVERSATION_NOT_FOUND | No conversation with this id on your account. |
#Archive
/conversations/:id/archiveArchives the thread and clears its unread count. No body.
Path parameters
idintegerRequired- The conversation id.
Errors
| HTTP | Code | When |
|---|---|---|
| 404 | CONVERSATION_NOT_FOUND | No conversation with this id on your account. |
#Unarchive
/conversations/:id/unarchiveBrings the thread back to the inbox with status: "open", even if it was closed. No body.
Path parameters
idintegerRequired- The conversation id.
Errors
| HTTP | Code | When |
|---|---|---|
| 404 | CONVERSATION_NOT_FOUND | No conversation with this id on your account. |
#Star or unstar
/conversations/:id/starToggles the star: it stars an unstarred thread and unstars a starred one. No body.
Path parameters
idintegerRequired- The conversation id.
Returns
data.starredinteger- The new value:
1or0. To reach a specific state, readstarredfrom the list first.
Errors
| HTTP | Code | When |
|---|---|---|
| 404 | CONVERSATION_NOT_FOUND | No conversation with this id on your account. |
#Opt-outs and scrubbing
Ready blocks opted-out numbers for you: it refuses sends to anyone in GET /v1/opt-outs (403 opt_out_locked or 403 dnd_sms), so you don't need to check before every send. This section shows how to read the list, add to it, and scrub numbers on demand.
How people opt out
An inbound STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, REVOKE, OPT OUT and similar (plus clear phrases like "stop texting me", and any custom keywords you add in Settings → Compliance) opts the contact out immediately. If they later text START, they're opted back in, unless the lock came from TCPA scrubbing or your CRM.
When a contact texts an opt-out keyword, you get a contact_opted_out webhook. Other opt-outs (TCPA scrubbing, CRM sync, your own dnd_sms updates) don't fire it, so reconcile with GET /v1/opt-outs.
Opting someone out yourself
To record an opt-out that came in another way (a phone call, an email, a web form), send PUT /contacts/:id with {"dnd_sms": 1}. This can't be undone over the API (403 opted_out): only the contact texting START lifts it.
TCPA litigator scrubbing
Ready scrubs contacts automatically when they're created or imported and before sends. A match locks the contact like a STOP (opt_out_locked=1, status=opted_out, opt_out_source="tcpa_scrub"), sets is_litigator=1 and records the matched lists in tcpa_status (comma-separated). START doesn't lift it. Choose the lists with scrub categories.
Scrubbing costs your scrub rate per segment when it's on: $0.005 by default. Your rate is scrub_per_segment_usd in GET /compliance/scrub-categories.
Suppression fields on a contact
Every contact from /contacts carries these. Flags are integers, 0 or 1, not booleans.
| Field | Meaning | Blocks texts? |
|---|---|---|
opt_out_locked | Set by a STOP or a TCPA lock. Cleared only by START. | Yes |
status | opted_out when opted out. | Yes, when opted_out |
dnd_sms | Do not text. | Yes |
dnd_email, dnd_calls | Do not email, do not call. | No |
opt_out_source | sms_stop_keyword, profanity_opt_out, tcpa_scrub, ghl_dnd, ai_intent, intent_scan, cross_family_stop_propagation, or null for a manual DND. | |
opt_out_at | When they opted out (ISO 8601). This is the real opt-out time. | |
is_litigator | Matched a TCPA litigator list. | Yes, through the lock |
tcpa_status | The matched lists, comma-separated, or null. | |
sms_invalid | The number can't receive texts. | Depends on an account setting |
Which list to use
| List | What's in it | Ready blocks texts to all of it? | Format |
|---|---|---|---|
GET /v1/opt-outs | Everyone Ready won't text: STOP, opted out, DND for texts, TCPA lock. | Yes | JSON, cursor pages |
?compliance=optout | The same people, minus contacts marked spam (unless include_spam=1). | Yes | CSV, 6 an hour |
?compliance=dnc | Do not contact on any channel: the above plus email-only and call-only DND, litigators and invalid numbers. Not the federal Do-Not-Call registry. | No: email-only and call-only DND contacts can still get texts | CSV, 6 an hour |
Keeping your own copy in sync
Walk GET /v1/opt-outs?limit=500, passing next_before_id as before_id until has_more is false. Then listen for contact_opted_out for keyword opt-outs as they happen, and walk the list again on a schedule for the rest. Deleting a contact removes it from /v1/opt-outs. After a delete, a re-created contact and GET /v1/opt-outs/:phone show the number as not opted out. Ready keeps the opt-out in a separate record for its own send check, so sends to the number should still be refused, but don't rely on the API to tell you: keep your own copy and check it before you add a number back.
#Check one number
/v1/opt-outs/:phoneWhether a number is opted out on your account.
Path parameters
phonestringRequired- US or Canada (+1) only. E.164 (URL-encode the
+as%2Bif your client needs it) or 10 digits. Fewer than 10 digits returns400 invalid_number. Other countries aren't supported: only the last 10 digits are used, so the answer is for a different number.
Returns
data.phoneE.164 string- The number checked.
data.opted_outbooleantrueif the number replied STOP, was marked opted out, or is marked Do Not Disturb for texts.data.sinceISO 8601 string | null- When this contact record was last changed. It is not the opt-out time; for that, read
opt_out_atfrom GET /contacts/:id.nullwhen not opted out.
On a 500, treat the number as not safe to text, and retry.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_number | Fewer than 10 digits. |
#List opt-outs
/v1/opt-outsEvery number your account must not text, by contact, newest contact first.
Query parameters
limitintegerOptionalDefault100Max500- Page size.
before_idintegerOptional- The
next_before_idfrom the previous page.offsetisn't supported and is ignored.
Returns
data[]object[]phoneandsince(last change to the contact, not the opt-out time). Sorted by contact, not by opt-out date.has_moreboolean- Loop until it's
false. Don't loop onnext_before_id: it's set on the last page too. next_before_idinteger- Pass as
before_id.
#Scrub numbers
/v1/scrubChecks up to 1,000 US numbers against the TCPA litigator and do-not-call lists your account screens. Billed per number.
scrub_per_segment_usd, $0.005 by default). A number your account scrubbed recently can cost less. Invalid numbers aren't charged. The whole batch is checked against your balance first.Headers
Idempotency-KeystringRecommended- Retry safely without paying twice.
Body parameters
phonesstring[]Required- 1 to 1,000 US numbers, E.164 or 10 digits.
Returns
data.totalinteger- Numbers in the request.
data.results[]object[]- One per number:
phone,normalized,valid,litigator,dnc_complainer,federal_dnc,state_dnc,blocked,cached,scrubbed_at. A list flag ofnullmeans your account doesn't screen that list. On a problem,error_codeanderror:invalid_number(not charged),scrub_failed, orscrub_unavailable(not checked, not charged).
It creates no contacts. A number that's already one of your contacts gets the new result written onto it.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | no code | phones is missing, empty or not a list. |
| 400 | too_many_numbers | More than 1,000 numbers. |
| 402 | insufficient_balance | The batch could cost more than your balance (required_cents, balance_cents). Nothing scrubbed. |
| 409 | scrub_off | Scrubbing is off for your account. |
| 503 | tcpa_not_configured | Scrubbing isn't available right now. |
#Scrub one number
/v1/scrub/:phoneThe same check for one number. This GET is billed, like one number in Scrub numbers.
Path parameters
phonestringRequired- A US number, E.164 or 10 digits.
Returns
dataobject- One result, shaped like an item of
results[]above.
Errors
| HTTP | Code | When |
|---|---|---|
| 402 | insufficient_balance | Balance too low. |
| 409 | scrub_off | Scrubbing is off for your account. |
#Get scrub categories
/compliance/scrub-categoriesWhich lists your account screens contacts against, and your scrub rate.
Returns
Fields are at the top level of data.
master_enforceboolean- Whether scrubbing is on at all.
categories[]object[]key(tcpa,dnc_complainers,federal_dnc,state_dnc),label,enabled,effective,default_on,source,attestation_required_to_disable.required_phrasestringI UNDERSTAND: whatconfirmation_textmust say to turn off a protected list.scrub_per_segment_usdnumber- Your scrub rate. The response has a few more billing fields used by the dashboard.
#Change scrub categories
/compliance/scrub-categoriesTurns screening lists on or off. Account owner only.
error holds the machine code and message the sentence for people (except invalid_field, below). A test key does this for real.Body parameters
categoriesobjectRequired- Any of
tcpaanddnc_complainers(on by default),federal_dncandstate_dnc(off by default), each a JSON boolean. Send realtrueandfalse: onlytrue,1,"1"and"true"mean on, so"yes"or"on"silently mean off. Turning every list off turns scrubbing off. confirmation_textstringTo turn off tcpa or dnc_complainers- Exactly
I UNDERSTAND.
Returns
dataobjectmaster_enforceand the savedcategories.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | categories_required | categories is missing (in error). |
| 400 | unknown_category | A key isn't one of the four (in error). |
| 400 | attestation_required | Turning off tcpa or dnc_complainers needs confirmation_text (in error; disabling lists them). |
| 400 | invalid_field | confirmation_text is a list or object. This one uses the usual shape: the code is in code, the sentence is in error, and field names the field. |
#Export opt-outs as CSV
/contacts/exportA CSV of opted-out contacts, for audits and list hygiene.
Query parameters
complianceenumRequired- Must be exactly one of these. Any other value exports all your contacts.
optouteveryone Ready won't text: replied STOP, DND for texts, or locked by TCPA scrubbing.opt_out_sourcesays which. Fileopted-out-contacts.csv.dncdo not contact on any channel: the above plus email-only and call-only DND, litigators and invalid numbers. Not the federal Do-Not-Call registry. Filednc-list.csv.
include_spamintegerOptional- Contacts marked spam are left out unless you send
1. Add it for an audit-complete file.
The file starts with a UTF-8 byte-order mark (BOM). Decode it as utf-8-sig in Python, or the first column name reads \ufeffid.
API keys can always export. Dashboard teammates can too, unless the owner turns off Managers can export contacts.
Errors
| HTTP | Code | When |
|---|---|---|
| 403 | EXPORT_DISABLED | The owner turned off exports for teammates. |
| 429 | no code | More than 6 exports in an hour. |
#Webhooks
Ready POSTs a JSON event to a URL you own when something happens: a reply, a delivery, an opt-out. You need a public URL that accepts POST; no-code tools like n8n and Make.com give you one.
Events
| Event | Sent when | data |
|---|---|---|
inbound_reply | A contact texts one of your numbers. | message_id (Ready's numeric id for the incoming message), contact_id, from, to, body, segments (missing for numbers connected through GoHighLevel) |
message_delivered | The carrier confirms delivery. Once per message. | message_id (the string your send returned), to, contact_id, status, error (the carrier's reason, or null) |
message_failed | The carrier reports a failure. Once per message, and only for messages the carrier accepted: a send refused up front returns its error directly instead. | |
contact_opted_out | A contact texts STOP or another opt-out keyword. Opt-outs you add yourself, TCPA scrubbing and CRM sync don't trigger it. | contact_id, phone |
contact_created dashboard only | A contact is created one at a time (POST /contacts or in the app). Imports, forms, Zapier, GoHighLevel sync and contacts created by an incoming text don't trigger it. | contact_id, first_name, last_name, phone, email, source |
Every event is a JSON POST shaped { event, timestamp, data }. timestamp is ISO 8601 UTC with milliseconds. There's no event id: to dedupe, use event plus data.message_id.
Test it: Settings → Integrations → Send test POSTs event: "test", signed with your secret, with a 10-second timeout. Return a 2xx, and ignore events you don't handle. Sends made with a test key don't trigger message_delivered or message_failed, so use Send test to try your endpoint.
{
"event": "inbound_reply",
"timestamp": "2026-10-01T20:40:12.004Z",
"data": {
"message_id": 19408828,
"contact_id": 101,
"from": "+12125550199",
"to": "+14155550100",
"body": "Yes, I'm interested!",
"segments": 1
}
}
{
"event": "message_delivered",
"timestamp": "2026-10-01T20:32:09.912Z",
"data": {
"message_id": "179088672338905475132359",
"to": "+12125550199",
"contact_id": 101,
"status": "delivered",
"error": null
}
}
{
"event": "message_failed",
"timestamp": "2026-10-01T20:33:14.380Z",
"data": {
"message_id": "179088672338905475132360",
"to": "+15559876543",
"contact_id": 102,
"status": "failed",
"error": "Absent subscriber"
}
}
{
"event": "contact_opted_out",
"timestamp": "2026-10-01T20:45:00.731Z",
"data": {
"contact_id": 102,
"phone": "+15559876543"
}
}
{
"event": "contact_created",
"timestamp": "2026-10-01T21:04:51.779Z",
"data": {
"contact_id": 103,
"first_name": "Ada",
"last_name": "Lovelace",
"phone": "+12125550199",
"email": "ada@example.com",
"source": "manual"
}
}
{
"event": "test",
"timestamp": "2026-10-01T21:10:00.000Z",
"data": {
"message": "Test event from Ready",
"user_id": 116
}
}
Verifying the signature
Every event carries X-Signature: sha256=<hex>: the lowercase hex HMAC-SHA256 of the exact raw request body, keyed with your signing secret. Use the whole secret, including the whsec_ prefix, as the key. Compare in constant time, and reject anything that doesn't match.
- Verify the raw bytes. A body that was parsed and re-serialized won't match.
- To block replays, reject events whose
timestampis more than 5 minutes old. The timestamp is inside the signed body. - No
X-Signatureheader? Webhooks saved before signing existed are sent unsigned. Save your webhook once, or rotate the secret, to start signing. - Treat the secret like a password. Rotating it takes effect immediately, so update your receiver at the same time.
Request headers
Content-Type: application/json, User-Agent: ReadySMS-Webhooks/1.0 (not on the test event) and X-Signature.
Delivery
Each event is sent once, with a 5-second timeout and no retries. Respond quickly with a 2xx and do slow work afterwards. Redirects are followed (up to 5) and stay POST. Events can arrive in any order. If your endpoint was down, reconcile with GET /v1/messages and GET /v1/opt-outs.
Using n8n or Make.com
Add a Webhook trigger; it gives you a URL. Paste it into Ready's Webhook URL field (or send it with PUT /v1/webhooks), pick your events, and save. In n8n, use the Production URL, not the Test URL, which only works while the editor is listening. Signature checks are optional for no-code tools.
// Node.js + Express (save as .mjs, like the other Node examples)
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const SECRET = process.env.READY_WEBHOOK_SECRET; // whsec_...
function isFromReady(rawBody, header) {
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
const got = Buffer.from(String(header || ''));
const want = Buffer.from(expected);
return got.length === want.length && crypto.timingSafeEqual(got, want);
}
// express.raw keeps the exact bytes Ready signed; do not use express.json() on this route
app.post('/ready-webhook', express.raw({ type: 'application/json' }), (req, res) => {
if (!isFromReady(req.body, req.get('X-Signature'))) return res.sendStatus(401);
const { event, timestamp, data } = JSON.parse(req.body);
res.sendStatus(200); // answer within 5 seconds
setImmediate(() => console.log('OK', event, timestamp, JSON.stringify(data)));
});
app.listen(4789);
# Python (standard library only)
import hmac, hashlib, json, os
SECRET = os.environ["READY_WEBHOOK_SECRET"].encode() # whsec_...
def is_from_ready(raw_body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header or "")
# Flask: is_from_ready(request.get_data(), request.headers.get("X-Signature"))
# Django: is_from_ready(request.body, request.headers.get("X-Signature"))
#Get webhook settings
/v1/webhooksYour webhook URL, events and on/off state.
Returns
data.urlstring | null- Your endpoint.
data.eventsstring[]- Subscribed events.
data.enabledboolean- Always
falsewhen there's no URL.
#Set webhook settings
/v1/webhooksSets your webhook URL and events. Saving replaces the previous settings.
Body parameters
urlstringRequired- Your endpoint. Send it on every call: a
PUTwithouturlclears your saved URL and stops delivery. Usehttps://:http://is accepted but sends your customers' messages unencrypted. Private and internal addresses are refused. The response returns the URL normalized. eventsstring[]Optional- Any of
inbound_reply,message_delivered,message_failed,contact_opted_out. Leave it out for all four. It must be an array: anything else subscribes you to all four. Unknown names are ignored, not rejected, so checkeventsin the response: an empty list means you'll receive nothing.contact_createdcan only be set in the dashboard, and saving over the API removes it. enabledbooleanOptionalDefaulttrue- Send the JSON boolean
falseto pause. The string"false"or0leaves delivery on. regenerate_secretbooleanOptional- Send the JSON boolean
trueto create a new signing secret, returned assecret. The old one stops working immediately. Any other non-empty value, even"false", also rotates it. Your firstPUTcreates a secret but doesn't return it, so sendtrueon first setup. You can also see the current secret in Settings → Integrations.
Returns
dataobjecturl,events,enabled, andsecretonly when it was rotated.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_url | Not a URL, not http or https, or a private or internal host. |
| 400 | invalid_field | url is a list or object. |
| 400 | INVALID_JSON | The body isn't JSON. |
#Contacts
One contact per phone number. Errors in this section carry their code in code.
tagscomes back as a JSON-encoded string, for example"[\"vip\",\"hot\"]". Parse it withJSON.parse(contact.tags). When you send tags, send a real array.- Contacts have more fields than shown here. Ignore fields you don't recognize; new ones may be added.
- Suppression fields (
opt_out_locked,dnd_smsand others) are explained under Opt-outs.
#List contacts
/contactsYour contacts, most recently updated first, with filters.
Query parameters
searchstringOptional- Matches first name, last name, email, company or phone, case-insensitive and partial. Phone numbers match in any format. Without
tag, at most the 5,000 best matches are considered, so narrow broad searches. tagstringOptional- An exact tag name, case-sensitive (
VIPandvipare different tags). statusstringOptional- For example
opted_out. dndintegerOptional1: only contacts with texting turned off.limitintegerOptionalDefault50Max1000- Page size.
offsetintegerOptionalDefault0- Rows to skip. A whole number, 0 or more: today other values return an empty page.
sortenumOptional- Default: most recently updated first. A contact edited while you page jumps to the front, so for a full export use
date_added. Unknown values fall back to the default.date_addedname_asc,name_descphone_asc,phone_descstatus_asc,status_descscore_high,score_lowlastact_asc,lastact_descsource_asc,source_descassignee_asc,assignee_desc
More parameters (7)
stagestringOptional- Pipeline stage.
sourcestringOptional- Where the contact came from.
companystringOptional- Company name.
statestringOptional- US state.
assigned_tostringOptional- A team member id, or
unassigned. score_min, score_maxintegerOptional- Lead score range.
include_spamintegerOptional- Contacts marked spam are left out unless you send
1.
Returns
data[]object[]- Contacts.
totalinteger- Matching contacts, for display only: it can be an estimate, up to 2 minutes old, and can show 1 when nothing matches. Stop paging when a page has fewer rows than
limit(or none).
#Get a contact
/contacts/:idOne contact by id.
Path parameters
idintegerRequired- The contact id.
Returns
dataobject- The contact.
Errors
| HTTP | Code | When |
|---|---|---|
| 404 | CONTACT_NOT_FOUND | No contact with this id on your account (also for a non-numeric id). |
#Create a contact
/contactsAdds a contact. Returns 201 with the new contact.
409 CONTACT_EXISTS, which makes retrying a timed-out create safe. (Idempotency-Key isn't used here.) A new contact is scrubbed and fires the contact_created webhook.Body parameters
phonestringRequired- E.164 (
+12125550199) recommended. US numbers in any common format are accepted, and it's stored as E.164. first_namestringOptionallast_namestringOptionalemailstringOptionaltagsstring[]Optional- An array of strings. A plain string returns
400 invalid_field. company_namestringOptionalpipeline_stagestringOptionalDefaultnew_lead- Free text; use the stage ids from your pipeline.
sourcestringOptionalDefaultmanualtimezonestringOptional- An IANA name such as
America/Chicago. Set from the area code for US numbers when you leave it out.
More parameters (6)
websitestringOptionaladdress1, address2, city, state, postal_code, countrystringOptional- Address fields.
date_of_birthstringOptionalgenderstringOptionalassigned_tointegerOptional- A team member id.
dnd_sms, dnd_email, dnd_callsintegerOptional1to turn off texting, email or calls. Turning texting off is an opt-out that can't be undone over the API.
Returns
dataobject- The new contact. Defaults:
status: "active",score: 50.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | no code | No phone number. |
| 400 | INVALID_PHONE | The number can't be read. (Its message mentions a 10-digit US number, but E.164 numbers from other countries are accepted.) |
| 400 | invalid_field | A field has the wrong type, such as tags that isn't a list. |
| 409 | CONTACT_EXISTS | You already have a contact with this number. Find it with GET /contacts?search=<phone>. |
#Update a contact
/contacts/:idChanges only the fields you send. Use PUT: PATCH isn't supported.
200, not rejected, so check the returned contact. tags replaces the whole list, and must be an array: today a plain string is accepted and corrupts the field.Path parameters
idintegerRequired- The contact id.
Body parameters
first_name, last_name, emailstringOptionaltagsstring[]Optional- The full new list of tags. Send
tags_base(the list you started from) too, and only your changes are applied. pipeline_stagestringOptional- Free text.
statusstringOptional- You can't move an opted-out contact off
opted_out. dnd_smsintegerOptional1opts the contact out of texts. You can't set it back to0on an opted-out contact (403): the contact must text START.custom_fieldsobjectOptional- Merged key by key.
nullfor a key deletes it. phonestringOptional- Can't be changed (
400 PHONE_NOT_EDITABLE). To change a number, create a new contact. The same number in another format is fine.
More parameters (7)
dnd_email, dnd_callsintegerOptionalnotesstringOptionalscoreintegerOptional- A non-integer returns
400 CONTACT_UPDATE_FAILED. source, company_name, timezonestringOptionaladdress1, address2, city, state, postal_code, countrystringOptionalassigned_tointegerOptionalinbound_blockedintegerOptional
Returns
dataobject- The updated contact.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | PHONE_NOT_EDITABLE | You tried to change the phone number. |
| 400 | CONTACT_UPDATE_FAILED | A value can't be saved. |
| 403 | opt_out_locked | The contact replied STOP (in error_code; also opted_out). |
| 404 | CONTACT_NOT_FOUND | No contact with this id. |
| 409 | CONTACT_CHANGED | The contact opted out while you were saving. |
#Delete a contact
/contacts/:idPermanently removes the contact and its conversation history, notes and tasks.
GET /v1/opt-outs/:phone show it as not opted out. Ready keeps the opt-out in a separate record for its own send check, but keep your own opt-out list and check it before you add a number back.Path parameters
idintegerRequired- The contact id, not its phone number.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | INVALID_CONTACT_ID | You sent a phone number or other non-id. |
| 404 | CONTACT_NOT_FOUND | No contact with this id, or it was already deleted. |
| 423 | LITIGATION_HOLD | A legal hold covers this contact. |
#Validate numbers
Find out whether numbers are mobile, landline or disconnected before you import or text them. US and Canada only. This is the same lookup and price as the dashboard's Validate action.
#Validate numbers
/v1/validateChecks up to 1,000 numbers. Billed per number that gets an answer.
Headers
Idempotency-KeystringRecommended- Retry safely without paying twice.
Body parameters
phonesstring[]Required- 1 to 1,000 numbers, E.164 or 10 digits.
Returns
data[]object[]- One per number, in the order you sent them.
data[].normalizedE.164 string | nullnullwhen the number can't be read.data[].validbooleanfalsefor a malformed or disconnected number.reasonsays why:invalid_format,unsupported_countryordisconnected.data[].line_typeenummobile,landlineorunknown. VoIP lines read aslandline.data[].carrierstring | null- The carrier, for mobile numbers.
data[].textableboolean | nullnullwhen the lookup gave no answer.data[].cached, checked_atboolean, ISO 8601 string- Whether the answer came from cache, and when it was checked.
billed_numbers, charged_usdinteger, number- At the top level: what this call cost. The $0.005 price is charged a whole cent at a time, so a single number shows
0.01on one call and0(withbilled_numbers: 0) on the next.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | phones_required | phones is missing or empty. |
| 400 | too_many_phones | More than 1,000 numbers. |
| 402 | insufficient_credits | Balance can't cover the batch (required_cents, balance_cents, numbers). Nothing checked. |
| 429 | validation_in_progress | The same numbers are already being checked. Wait Retry-After. |
| 500 | internal_error | Check your balance before you retry: some numbers may have been billed. |
| 503 | validation_unavailable | Validation isn't available right now. Nothing charged. |
#Validate one number
/v1/validate/:phoneThe same check for one number. This GET is billed like one number above.
Path parameters
phonestringRequired- E.164 or 10 digits. A value that isn't a phone number answers
200withvalid: false, reason: "invalid_format", not billed.
Returns
dataobject- One result, shaped like an item of
data[]above, plusbilled_numbersandcharged_usd.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_phone | The path value is longer than 40 characters. |
| 403 | READ_ONLY_SESSION | View-only access can't spend money. |
| 402 | insufficient_credits | Balance too low. |
#Phone numbers
List, buy and release the numbers on your account. A number is $5 a month, with the first month paid up front and no setup fee. Buying needs a card on file (Settings → Billing).
Buy a number in 3 calls
- Optional:
GET /v1/numbers/available?area_code=415to see whether Ready can offer the area code. POST /v1/numbers/checkoutwith{ "area_code": "415" }pays the first month and returnsdata.payment_intent_id.POST /v1/numberswith{ "area_code": "415", "payment_intent_id": "…" }. Often the number is ready at once; otherwise pollGET /v1/numbers/:iduntil it'sactive, usually about 2 minutes.
Store phone_number_id from step 3, and send from the number with from. Rarely, the carrier rejects a number; the status table says what to do.
Number status
| status | Meaning |
|---|---|
active | Can send. |
pending_provisioning, requested, submitted | Being set up. phone_number is a placeholder like "Pending (415)", not a phone number. |
provider_rejected, payment_declined | Failed and won't become active. Stop polling, sort out the payment with support if needed, and buy again. |
billing_suspended, suspended | Paused for billing or review. |
released, inactive, retired | No longer in use. |
deleted | Released. Hidden from the list; GET /v1/numbers/:id can still return it. |
#List numbers
/v1/numbersEvery number on the account, newest first. Not paginated.
Returns
data[]object[]id,phone_number,status,created_at. Includes numbers that aren't active yet or failed, so checkstatus.
#Get a number
/v1/numbers/:idOne number. Poll this after a purchase until it's ready.
Path parameters
idintegerRequired- The
phone_number_idfrom the purchase.
Returns
dataobject- Same shape as one item of List numbers.
Poll every 10 to 30 seconds until status is active (usually about 2 minutes). Stop if it becomes provider_rejected or payment_declined: that number won't activate. After you release a number this returns status: "deleted" or a 404.
Errors
| HTTP | Code | When |
|---|---|---|
| 404 | no code | Not a number on this account. The body is {"success": false, "error": "Number not found"}, with no code. |
#Check an area code
/v1/numbers/availableWhether you can buy a number in an area code.
Query parameters
area_codestringRequired- A 3-digit US or Canada area code. Only the format is checked, not that the area code exists.
Returns
Fields are at the top level, not under data, and there's no success.
availablebooleanavailable: truemeans Ready will try to get a number there. It doesn't check that the area code exists:000,911and999answer true, so validate the area code yourself.numbers[]object[]- Format examples, not real numbers: you can't pick one, and the carrier assigns your actual number when you buy.
alternatives[]object[]- When out of stock: up to 5 nearby area codes, each
{ area_code, state, ready }.ready: truemeans a number there can be ready right away.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | area_code_invalid | Missing or not 3 digits (in code). |
#Start a number purchase
/v1/numbers/checkoutPays the first month ($5) on your saved card and returns the payment to buy with.
POST /phone-numbers/checkout is an older alias limited to 20 a minute. It doesn't accept X-Sub-Account-Id, so use this path for client accounts.Headers
Idempotency-KeystringRecommended- Retry safely without paying twice. A key is tied to its first body.
Body parameters
area_codestringRequired- The 3-digit area code you'll buy in.
replaces_phone_idandcheckout_idare also accepted; they're for the dashboard.
Returns
data.payment_intent_idstring- Pass it to Buy a number.
data.charged, data.authorizedboolean- Checkout either charges or holds the first month. If the number can't be provisioned, the hold is released or the charge refunded.
data.card_brand, data.card_last4string- The card used.
data.client_secret, data.has_cardstring, boolean- Instead of the above when there's no card on file. The API can't finish that payment: add a card in Settings → Billing and call again.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | area_code_invalid | Missing or not 3 digits. |
| 402 | card_declined | Also card_authentication_required. |
| 402 | PURCHASE_BLOCKED | Unpaid bill or account review (in code). |
| 409 | area_code_unavailable | No stock. alternatives suggests nearby area codes. |
| 409 | purchase_in_progress | Another purchase is running. Also campaign_number_limit. |
| 502 | checkout_unconfirmed | Don't retry: check GET /v1/numbers first. |
| 503 | checkout_unavailable | Also availability_check_unavailable, cap_check_unavailable. Nothing charged. |
#Buy a number
/v1/numbersProvisions the number you paid for at checkout.
Headers
Idempotency-KeystringRecommended- A new key for each new purchase.
Body parameters
area_codestringRequired- Pass the same area code you checked out with. The number comes from the area code on this call.
payment_intent_idstringRequireddata.payment_intent_idfrom checkout. Each one provisions exactly one number.
Returns
Fields are at the top level, not under data. Always read phone_number_id: it's on both outcomes, while id is only on the instant one.
phone_number_idinteger- The number's id. Poll it with
GET /v1/numbers/:id. instantbooleantrue: the number is ready now andphone_numberis real.pending_provisioningbooleantrue: being set up; poll untilactive.pending_campaign_approvalbooleantrue: the number can't send until your 10DLC campaign is approved (usually within 48 hours).replaced_phone_id, noteinteger | null, string- The number this one replaced, if any, and a sentence about what happens next.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | area_code_invalid | Missing or not 3 digits (in code). |
| 400 | payment_intent_required | Also payment_not_confirmed, payment_not_for_number, payment_not_taken. |
| 400 | invalid_field | A field has the wrong type. |
| 402 | PURCHASE_BLOCKED | Unpaid bill or account review (in code). |
| 403 | payment_not_owned | The payment belongs to another account. |
| 409 | payment_intent_already_used | This payment already bought a number. |
| 500 | buy_incomplete | Added but not finished. Don't buy again: check GET /v1/numbers. |
#Release a number
/v1/numbers/:idPermanent. The number goes back to the carrier and you can't get it back.
Path parameters
idintegerRequired- The number id.
Errors
| HTTP | Code | When |
|---|---|---|
| 404 | NUMBER_NOT_FOUND | Not on your account, or already released (in code). |
| 409 | NUMBER_RESERVED | Can't be released right now. |
| 502 | BILLING_CANCEL_FAILED | We couldn't stop the monthly fee, so nothing was released. |
| 500 | RELEASE_INCOMPLETE | The release didn't finish. Check GET /v1/numbers/:id. |
#Client accounts
For agencies and partners that run one Ready account per customer. Each client account has its own numbers, 10DLC registration, card and balance, and pays your markup on every send. Use an rsms_ key from your own account. These endpoints need the team-management permission.
Acting as a client
Add X-Sub-Account-Id: <client id> to any /v1 request made with an API key (sent as X-API-Key or Authorization: Bearer). The request runs as that client: what you create belongs to the client, and the client's card and balance pay for it. Your own numbers aren't visible while you act as a client. The header works only on /v1 paths, so buy with POST /v1/numbers/checkout, not /phone-numbers/checkout.
Buy a number for a client: POST /v1/numbers/checkout then POST /v1/numbers, both with the header. A payment your own account made also works for the second call. Then poll GET /v1/numbers/:id, with the header, until it's active.
The header works only for client accounts you manage. Anything else is refused and nothing runs:
| HTTP | Code | Meaning |
|---|---|---|
| 400 | invalid_sub_account_id | Not a numeric account id (also 0 or empty). |
| 400 | sub_account_header_not_supported | Sent to a path outside /v1. |
| 400 | sub_account_header_needs_api_key | Sent without an rsms_ API key. |
| 403 | sub_account_forbidden | Not a client account you manage (including your own id). |
| 403 | sub_account_suspended | That client account is suspended. |
| 404 | sub_account_not_found | No account has that id. |
| 503 | AUTH_CHECK_UNAVAILABLE | We couldn't check. Nothing ran; retry. |
curl "https://api.tryready.com/v1/numbers" \
-H "X-API-Key: $READY_API_KEY" \
-H "X-Sub-Account-Id: 4812"
const res = await fetch('https://api.tryready.com/v1/numbers', {
headers: {
'X-API-Key': process.env.READY_API_KEY,
'X-Sub-Account-Id': '4812',
},
});
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/numbers",
headers={"X-API-Key": os.environ["READY_API_KEY"], "X-Sub-Account-Id": "4812"},
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")}')
#Create a client account
/v1/sub-accountsCreates a client account with its own login, card, numbers and registration.
Body parameters
emailstringRequired- The client owner's email.
first_name, last_namestringOptionalcompany_namestringOptionalsend_invitebooleanOptionalDefaulttrue- Emails the client an invite to set up their login and card. Send
falseto skip the email and send theminvite_linkyourself.
Returns
dataobjectid(use it asX-Sub-Account-Id),email,company_name,account_kind: "client",client_rate,invite_link.
Any other field returns 400 unknown_field.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_request | Missing email. |
| 400 | unknown_field | A field this endpoint doesn't accept. |
#List client accounts
/v1/sub-accountsThe client accounts you manage, newest first. Pages with a cursor.
Query parameters
limitintegerOptionalDefault50Max200- Page size.
before_idintegerOptional- The
next_before_idfrom the previous page.
Returns
data[]object[]id,email,first_name,last_name,company_name,status,markup_usd_per_segment,created_at.has_moreboolean- Today it's
truewhenever the page is full, even if nothing follows. Keep requesting untildatais empty. next_before_idinteger | null- Pass as
before_id.
#Get a client account
/v1/sub-accounts/:idOne client account.
Path parameters
idintegerRequired- The client account id.
Errors
| HTTP | Code | When |
|---|---|---|
| 404 | sub_account_not_found | Not a client account you manage. |
#Set a client's markup
/v1/sub-accounts/:id/rateWhat the client pays you per segment on top of your own rate.
Path parameters
idintegerRequired- The client account id.
Body parameters
markupnumberOptional- Dollars per segment, 0 or more.
use_defaultbooleanOptionaltrueto use your default markup instead.dialer_markup, use_default_dialernumber, booleanOptional- The same, for calls.
Returns
dataobjectmarkup,is_custom,base_rate(your rate) andclient_rate(what the client pays per segment).
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_request | A negative markup. |
| 400 | unknown_field | A field this endpoint doesn't accept. |
| 400 | terms_required | Accept the reseller terms in your dashboard first. |
| 400 | reseller_not_approved | Finish reseller setup in your dashboard first. |
| 404 | sub_account_not_found | Not a client account you manage. |
#A2P registration
Register your business with the carriers (A2P 10DLC) so your texts deliver at full throughput, entirely over the API.
The flow: register a brand (this also creates its first campaign), confirm the payment if you got a client_secret, then poll GET /v1/registration. Add more campaigns or vet the brand later if you need them. Approval takes days, so poll every 15 to 60 minutes.
- Brand setup: $30 if you keep your current number, $35 with a new number (
preferred_area_code). A negotiated price replaces these (never more). - Monthly: brand $10, campaign $20, plus $5 per number. Your first campaign is created with the brand and has no setup fee; each extra campaign is $20 a month. The brand and campaign fees start when you pay the setup fee (for a campaign with no setup fee, when you submit it). The first 30 days are included, so the first monthly charge comes 30 days later. It doesn't wait for carrier approval.
- Vetting (optional): $79 Standard, $179 Enhanced, or $100 to upgrade Standard to Enhanced.
- Cancelling doesn't refund the setup fee.
With a test key, nothing is registered or charged, and the ownership, EIN-to-name match (ein_name_mismatch), duplicate, brand-first (brand_required_first) and vetting-state checks don't run. The format checks (invalid_ein, address_incomplete, business_description_required, consent_required) still run. Test responses are wrapped in data and aren't the live shape: brand and campaign return registration_id: null, confirm returns only test and note, and the other calls return reg_id (see Test keys).
:id in these paths is a registration_id: the brand.id or campaign.id from GET /v1/registration, not a carrier id. Vetting takes the brand's id.
Every write here needs the manage_brands permission, which an owner's key has. Errors here vary in shape: branch on code (or error_code) when present, otherwise on error, which holds a machine code for some of them. Don't retry a brand or campaign POST after a timeout: poll GET /v1/registration first.
#Get registration requirements
/v1/registration/use-casesValid use cases, required fields and prices. Call this first.
Returns
data.use_casesstring[]- Valid campaign
use_casevalues. data.brand_required, campaign_requiredstring[]- Fields a request must include. The API refuses one without them.
data.auto_filledobject- Fields Ready fills in for you. Leave them blank.
data.pricing_usdobjectbrand.setupis the price with a new number. Withoutpreferred_area_codeyou keep your current number and pay $30.
#Register a brand
/v1/registration/brandRegisters your business and creates its first campaign. Paid.
MIXED, named after your business). It's submitted automatically once the brand is approved, so most accounts never call Add a campaign.Body parameters
company_namestringRequired- Legal business name.
business_nameis an alias. einstringRequired- Federal EIN (tax id), 9 digits:
12-3456789or123456789.reg_numberis an alias. It can't be changed later over the API. business_descriptionstringRequired- One or two sentences on what the business does, such as
Family dental practice in Austin. consentbooleanRequired- Must be the JSON boolean
true, not the string: your attestation that you have prior express written consent to text your recipients. entity_typeenumCarriers need itDefaultPRIVATE_PROFITPRIVATE_PROFITPUBLIC_PROFITNON_PROFITGOVERNMENT
industrystringCarriers need itstreet, city, state, zipstringRequired- Business address. All four are needed (
400 address_incomplete). email, phone, first_name, last_namestringCarriers need it- A contact person.
phonein E.164. preferred_area_codestringOptional- 3-digit area code. Buys a new number with the registration ($35 instead of $30).
number_choiceenumOptionalnew(buy a number inpreferred_area_code) orkeep(use your current number). Defaults tonewwith an area code, otherwisekeep. Other values aren't rejected yet, and are billed $35 without buying a number.
More parameters (5)
websitestringOptional- Leave blank. Ready hosts a compliant opt-in page for your brand and registers that URL; anything you send is replaced.
dbastringOptional- Doing-business-as name.
countrystringOptionalDefaultUSphone_number_idintegerOptional- Which of your numbers to keep.
transfer_confirmbooleanOptional- Send
trueafter a409 needs_transfer_confirmto move the number from its other brand.
Returns
data.charged, amount, card_brand, card_last4, registration_id- With a card on file: charged right away (
amountin cents). data.client_secret, amount, monthly, label, has_card- No card on file, or the saved card was declined (
has_card: true). Confirm the payment with Stripe.js, then call Confirm a payment with thepayment_intent_id(the part ofclient_secretbefore_secret_). Theregistration_idcomes back from that call. data.zero_feebooleantruewhen no fee is due (for example a negotiated $0): submitted immediately.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | no code | company_name is required, ein is required, or a bad preferred_area_code (no code; read error). |
| 400 | invalid_ein | ein isn't 9 digits (in code). |
| 400 | address_incomplete | street, city, state or zip is missing (in code; error names them). |
| 400 | business_description_required | business_description is missing (in code). |
| 400 | preferred_area_code_required | number_choice: "new" without an area code (in error). |
| 400 | consent_required | consent isn't the boolean true. |
| 400 | ein_name_mismatch | The EIN doesn't match the name (in error, with title, message, steps). |
| 409 | duplicate_brand | You already have this brand (existing_reg_id). |
| 409 | needs_transfer_confirm | The number is on another brand. Resend with transfer_confirm: true. |
| 409 | paid_not_filed | Paid but not yet filed. Don't pay again; contact support. |
| 409 | area_code_unavailable | No stock in preferred_area_code (alternatives). |
| 429 | duplicate_submission | Same submission within 2 minutes. Don't retry. |
| 202 | payment_processing | The payment is still processing. |
| 502 | payment_unknown | Don't submit again; poll GET /v1/registration. |
| 503 | payments_unavailable | Payments are unavailable. Nothing charged. |
#Add a campaign
/v1/registration/campaignAdds another campaign with a different use case. Your brand already has one. Only use_case is required; consent is given once, on the brand.
Body parameters
campaign_namestringOptionalDefault<business name> Campaignuse_caseenumRequired- One of the use cases, case-insensitive.
brand_idintegerOptional- The brand's
registration_id. Send it when the brand is already approved so the campaign goes straight to review; otherwise it waits asawaiting_brand. campaign_descriptionstringOptional- Optional. Ready writes the carrier description from your
use_caseand business name.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | no code | use_case is missing (use_case is required in error). |
| 400 | brand_required_first | No brand yet. A brand still awaiting payment counts. |
| 400 | invalid_use_case | Unknown use case (valid lists them). |
| 409 | duplicate_use_case | You already have a campaign with this use case (existing_reg_id, existing_campaign_name). Your brand's first campaign is MIXED. |
| 429 | duplicate_submission | Same submission within 2 minutes. |
#Confirm a payment
/v1/registration/confirmAfter you confirm a client_secret with Stripe.js, this submits the registration right away. Safe to call twice.
Body parameters
payment_intent_idstringRequired- The part of
client_secretbefore_secret_.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | no code | payment_intent_id is required. |
| 400 | payment_not_completed | The payment didn't go through (status). |
| 202 | payment_processing | Still processing. Try again shortly. |
| 404 | payment_not_found | No such payment on your account. |
| 503 | payment_check_unavailable | We couldn't check the payment. Retry. |
#Get registration status
/v1/registrationYour most recent brand and campaign, and whether you can send at registered rates.
Returns
data.brand, data.campaignobject | null- Your most recent brand and campaign only.
status,carrier_status(the carrier's own text, such asVERIFIED,VETTED_VERIFIED,UNVERIFIED,REGISTERED),approved_at; a rejected one addsrejection_reasonandrejection_help. statusstringnot_started,pending_payment,pending,awaiting_brand(a campaign waiting on its brand),approvedoractive,rejected,suspended,cancelled(alsocanceled),deregistered,expired,payment_declined. Treat any value you don't recognize as "not sending".data.can_send_registeredbooleantrueonce your campaign is approved by the carriers. Registered pricing starts earlier, as soon as the paid registration is submitted.
#Get rejection details
/v1/registration/deniedThe latest rejected registration, the carrier's reasons and a suggested fix.
Returns
data.deniedbooleanfalsewhen nothing is rejected (and nothing else is returned).data.reg_id, type, nameinteger, string, string- The registration to resubmit.
data.reasons, reasonstring- The carrier's text, and the same in plain English.
data.ai_fixobject | null- Suggested changes, when we have them.
#Resubmit a registration
/v1/registration/:id/resubmitResubmits a rejected registration, optionally with corrected fields.
Idempotency-Key: resubmitting deregisters and re-registers with the carrier.Path parameters
idintegerRequired- The rejected registration's id.
Headers
Idempotency-KeystringRecommended
Body parameters
fieldsobjectOptional- Corrections. Only these keys apply:
company_name,dba,website,industry,entity_type,use_cases,campaign_description,street,city,state,zip,email,phone,first_name,last_name. Others, and empty strings, are ignored, includingein: to change the EIN, contact support.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_field | fields isn't an object. |
| 404 | not_rejected | This registration isn't rejected. |
| 500 | resubmit_unconfirmed | Don't resubmit again yet; poll GET /v1/registration. |
| 502 | no code | The carrier refused it (error and code). |
#Vet a brand
/v1/registration/:id/vetExternal vetting scores your brand higher with the carriers, which raises your daily throughput. Paid, one time.
Path parameters
idintegerRequired- The brand's registration id.
Body parameters
vetting_typeenumOptionalDefaultSTANDARDSTANDARDorENHANCED, case-insensitive. A brand already vetted Standard can upgrade to Enhanced for the $100 difference (upgrade: true). If vetting can't be submitted, the fee is refunded automatically.
Returns
Fields are at the top level, not under data.
charged, amount_cents, vetting_type, upgrade, card_last4, payment_intent_id- With a card on file: charged right away.
client_secret, has_card, payment_intent_id- No card: confirm with Stripe.js, then call Confirm the vetting payment with the
payment_intent_idreturned here.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | no code | vetting_type must be STANDARD or ENHANCED. |
| 409 | already_vetted | The same tier or a lower one again. |
#Confirm the vetting payment
/v1/registration/:id/vet/confirmApplies the vetting after a client_secret payment. Safe to call twice.
Path parameters
idintegerRequired- The brand's registration id.
Body parameters
payment_intent_idstringRequired- From Vet a brand.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | payment_intent_mismatch | The payment isn't for this vetting. |
| 400 | no code | Not paid yet (or 202 while processing). |
| 503 | could_not_retrieve_payment_intent | We couldn't check the payment. Don't pay again; retry this call. |
#Get throughput
/v1/registration/throughputYour live sending capacity: 24-hour volume and rejections, T-Mobile daily cap and tier, and warm-up state.
Returns
data.total_24h, rejected_24h, reject_pctinteger, integer, number- Messages in the last 24 hours, how many were rejected (messages, not segments), and the percentage.
data.throttledbooleantrueat 50 or more rejections and 5% or more of the last 24 hours. Thendaily_loss_usdestimates what they cost, andbrandnames the brand to vet (cta:vet,upgradeormaxed).data.tmobileobject | nulldaily_cap,tier,next_cap(the ladder is 2,000, 10,000, 40,000, 100,000, 200,000;nullat the top),sent_today,pct,coverage,usage_reliable. Readsent_todayonly whenusage_reliableistrue.nulluntil you have a T-Mobile tier.data.rampobject- Warm-up state; see below.
cachedboolean- Refreshed at most once a minute. A cached answer includes
"cached": true.
Warm-up limits
Your first campaign: once your brand is approved you can send up to 500 texts until the campaign is approved, then full volume unlocks. A campaign added later to an account that's already live is also capped at 500 texts for its first 2 business days after approval. Sends over the cap get 409 RAMP_CAP_REACHED. ramp.in_warmup is false once you're past it; while it's true, pace within ramp.remaining. ends_at is an estimate. If more than one warm-up runs at once (a brand ramp and a newly approved campaign), this reports the one you're most limited by, and ramp.campaign_id names it (null for the brand-wide ramp).
Errors
| HTTP | Code | When |
|---|---|---|
| 503 | throughput_unavailable | Couldn't be read. Never a fake all-clear. |
#Rename a registration
/v1/registration/:id/nameA label for the Ready dashboard. Doesn't change anything with the carriers.
Path parameters
idintegerRequired- The registration id.
Body parameters
namestringRequired- Up to 80 characters; longer is cut. It isn't returned by
GET /v1/registration, which shows the carrier campaign name.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | no code | name is required. |
| 404 | registration_not_found | No registration with this id. |
#Cancel a registration
/v1/registration/:idCancels the registration and its monthly fee. The setup fee isn't refunded.
Path parameters
idintegerRequired- The registration id.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | registration_approved | An approved registration can't be cancelled here. Contact support. |
| 403 | no code | Only the account owner can cancel. |
| 502 | subscription_cancel_failed | We couldn't confirm the monthly fee stopped, so the registration was kept. |
#Balance and billing
For your balance and rates in code, use GET /v1/account. The /billing endpoints power the dashboard: they aren't versioned, and fields not listed here can change.
How a text is priced
Cost of one text = segments × (sms_rate_usd_per_segment + carrier_fee_usd_per_segment + compliance_per_segment).
sms_rate_usd_per_segmentandcarrier_fee_usd_per_segmentcome from GET /v1/account. Typical rates are0.05before your campaign is submitted and0.02after, or your negotiated rate.compliance_per_segmentis $0.005 for number validation (on by default), plus $0.005 if TCPA scrubbing is on. Read it fromGET /billing/balance.- A text with an image is one flat MMS charge, whatever its length: $0.0625 by default, or your negotiated rate, plus the compliance charge once.
GET /billing/balancereturns it asmms_cost_per_message. - Replies you receive are billed too, the same way as texts you send:
segments × (sms_rate + carrier_fee).
Example: a 2-segment text at $0.02 with validation on costs 2 × (0.02 + 0.0045 + 0.005) = $0.059.
#Get balance and rates
/v1/accountYour balance and the exact rates you're billed at, including any negotiated rate.
Returns
data.balance_usdnumber- Your balance, rounded to cents. Use
balance_centsfor exact math. data.balance_centsinteger- Your balance in cents.
data.sms_rate_usd_per_segmentnumber- Your per-segment rate.
data.carrier_fee_usd_per_segmentnumber- The carrier fee per segment.
data.plannull- A legacy field. Don't rely on it: pricing is pay as you go from your balance.
Billing
#Get balance details
/billing/balanceYour balance, prices and usage, as the dashboard shows them.
balance and estimated_segments are a count of 1-segment texts your balance buys, not money. Your money balance is balance_usd.Returns
The response has many more fields; those are for the dashboard and can change.
data.balance_usd, balance_centsnumber, integer- Your balance. Read live.
data.rate, carrier_fee_per_segment, compliance_per_segmentnumber- Your per-segment prices in USD.
data.mms_cost_per_messagenumber- Your price for a text with an image.
data.number_price_centsinteger- Monthly price per number.
data.monthly_spendnumber- USD spent this calendar month.
data.monthly_messagesinteger- Texts sent in the last 30 days (not this calendar month).
data.daily_avg_send, estimated_days_leftnumber- Forecast. Usage and forecast fields can lag by up to a minute.
#List billing history
/billing/transactionsTop-ups, credits, refunds and card charges one by one, and usage summed per day.
Query parameters
from, toYYYY-MM-DDOptional- UTC dates, inclusive. Without
fromyou get the last 90 days. Any other format is ignored, not rejected: checksummary.fromandsummary.toto confirm your filter applied. limitintegerOptionalDefault20Max200- Page size. Negative or non-numeric values fall back to 20, but
0returns an empty page. offsetintegerOptionalDefault0- Rows to skip.
Returns
data[]object[]- Rows:
row_type(transaction,daily_usageorstripe_charge),id(a string or an integer: treat it as an opaque string),type,cost_usd,description,created_at(usage rows carry the end of their day). data[].cost_usdnumber- Always positive, whether money came in or went out. Use
typefor the direction (table below). Ignoreamount: it's a legacy count, not money. totalinteger- Rows in the range.
summaryobjectspend_usd(money charged to your card in the range),count,from,to.
| Direction | type |
|---|---|
| Into your balance | purchase, auto_rebill, carrier_auto_rebill, carrier_deposit, bonus, credit, survey_completion_credit, onboarding_welcome_credit, admin_grant, card_verification_credit, refund |
| Out of your balance | daily_sms, daily_inbound, daily_other and other daily_*, campaign_hold, tcpa_scrub_fee, data_purchase, fine, admin_deduction |
| Card charge that doesn't touch the balance | stripe_charge (for example a brand registration) |
Usage isn't itemized per text: it's one row per day per category. For per-message cost, use cost_usd on GET /v1/messages. At most the 200 newest top-up and credit rows are returned per range, so narrow from and to to see older ones. Results can lag up to 30 seconds.
#AI copy
#Write message drafts
/sms/generateWrites up to 5 draft texts from a short description.
500 generation_failed). Not refunded if the request breaks texting rules: that answers 200 with "success": false, "refused": true and error_code: "generation_refused". Idempotency-Key isn't supported here, so a retry is a new charge.Body parameters
descriptionstringRequired- What the message should say. It's the only field: anything else returns
400 unknown_field.
Returns
datastring[]- Usually 5 drafts. Rarely, an item is a stray line such as "Reply STOP to opt out.", so drop very short items.
credits_remaininginteger- At the top level, not in
data.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | no code | description is missing or empty (Describe the message you want us to write.). |
| 400 | unknown_field | A field other than description. |
| 402 | no code | Insufficient balance. Add funds in Billing. (no code). |
| 500 | generation_failed | Refunded. Retry. |
#Zapier
Ready has a published Zapier app to connect thousands of apps with no code. Add Ready in Zapier, sign in, then build Zaps. The app connects with OAuth. (Its endpoints also accept an API key, but they're built for Zapier and aren't part of this API.)
- Triggers: New Inbound Message, New Contact, New Conversation, New Opt-Out, Message Delivered, Message Failed, Pipeline Stage Changed, Tag Added to Contact, Tag Removed From Contact, Keyword Reply, Drip Sequence Completed, Contact DND Changed.
- Actions: Send SMS, Create Contact, Update Contact, Add Tag to Contact, Remove Tag From Contact, Add to Drip Sequence.
- Searches: Find Contact, by phone first and then by email.
#MCP for AI agents
Ready runs an MCP (Model Context Protocol) server, so AI agents can work in your account in plain language: send texts, manage contacts and conversations, build and send campaigns, enroll drips, check number deliverability and read billing. It has 58 tools. Any MCP client that supports remote servers works.
https://api.tryready.com/mcpConnecting
| Client | How |
|---|---|
| Claude.ai, ChatGPT | Add a custom connector with the URL above, then sign in to Ready. |
| Claude Code | claude mcp add --transport http ready https://api.tryready.com/mcp --header "Authorization: Bearer $READY_API_KEY" |
Cursor, and clients that accept url and headers | The config below. |
Authenticate with OAuth 2.0 (clients that support discovery prompt you to sign in, with no key) or send an rsms_ API key as Authorization: Bearer rsms_…. Unlike the REST API, MCP doesn't accept X-API-Key. A test key is accepted, but don't use one here: only send_sms is simulated. send_campaign and enroll_in_drip send real texts and are charged, and ai_generate_message is billed. Contact, conversation, campaign, drip and AI-agent changes are real. MCP has no tools for buying numbers or registering. Test keys will soon be blocked from MCP entirely.
OAuth details
- Discovery:
/.well-known/oauth-authorization-serverand/.well-known/oauth-protected-resource/mcp. - PKCE with
S256. Dynamic client registration at/mcp/register. Unused clients are removed after 30 days. - Scopes:
sms:send,contacts:read,contacts:write,campaigns:read,campaigns:write,billing:read, enforced per tool. API keys aren't scoped: they have full access. - Access tokens last 30 days and come with refresh tokens.
Transport
Streamable HTTP with JSON-RPC 2.0. POST /mcp carries every call; replies are JSON, or SSE when your Accept header asks only for text/event-stream. Notifications get 202. GET /mcp opens a stream that only sends keep-alives (clients use it to check the server is reachable; it needs no key), and Ready doesn't push messages over it. initialize returns an Mcp-Session-Id header; sending it back is optional for stateless clients.
Protocol versions 2024-11-05, 2025-03-26, 2025-06-18 and 2025-11-25 are supported; the server answers in the version you ask for. An unknown method returns JSON-RPC error -32601. An unknown tool returns a result with isError: true.
{
"mcpServers": {
"ready": {
"url": "https://api.tryready.com/mcp",
"headers": {
"Authorization": "Bearer rsms_your_key_here"
}
}
}
}#End an MCP session
/mcpEnds the session named by the Mcp-Session-Id header.
Headers
Mcp-Session-IdstringRequired- From the
initializeresponse.
Errors
| HTTP | Code | When |
|---|---|---|
| 404 | -32001 | The session is already gone (a JSON-RPC error body). |
#Legacy endpoints
Still supported. New integrations should use the endpoints above.
#Send a message (older path)
/sms/sendThe same send as POST /v1/messages, with the text in message.
Body parameters
toE.164 stringRequired- Recipient. A 10-digit US number is treated as +1.
messagestringRequired- The text. Same length limits as
bodyon/v1/messages. consent_attestedbooleanRequired- Your promise that this recipient agreed to get texts from you. Enforced the same way as on
/v1/messages: required, not refused yet. fromE.164 stringOptional- One of your numbers. Unlike
/v1/messages, a number that isn't on your account is ignored here and Ready picks one. from_phone_number_idintegerOptional- Send from this number id instead (the
phone_number_idfrom a purchase). from_strategyenumOptionalDefaultlocallocal,even(ordeliverability) orrandom. Ignored whenfrom_phone_number_idis set.media_urlstringOptional- As on
/v1/messages. confirm_empty_mergebooleanOptional- As on
/v1/messages. conversation_idintegerOptional- As on
/v1/messages. quoted_message_idintegerOptional- As on
/v1/messages. override_quiet_hoursbooleanOptional- As on
/v1/messages.
#Message log (dashboard)
/sms/logsThe dashboard's message log. Use GET /v1/messages instead: rows here are raw and can change.
Query parameters
limitintegerOptionalDefault50Max1000- Page size.
offsetintegerOptionalDefault0- Rows to skip.
directionenumOptionalinboundoroutbound. Anything else returns no rows, with no error.statusstringOptional- Internal values:
sent,pending,delivered,failed,received.queuedmatches nothing here. searchstringOptional- A number or message text. Text searches cover only your most recent 10,000 messages.
Returns
data[]object[]- Message rows with internal fields. Stable ones:
id(integer),direction,from_number,to_number,body,status,segments,cost,created_at,delivered_at. Rows don't include themessage_ida send returned. hasMoreboolean- camelCase here.
totalis alwaysnull.