ready. Try Ready →Sign up

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.

Base URL
https://api.tryready.com

A 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": true and put the result in data. 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, an error sentence and a stable code. See Errors.
  • Requests are JSON (Content-Type: application/json), up to 1 MB (413 PAYLOAD_TOO_LARGE). Malformed JSON returns 400 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.

  1. 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.
    Shell
    export READY_API_KEY="rsms_your_key_here"
  2. 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_KEY means the key was revoked, mistyped or cut off.

    GET /v1/account
    curl "https://api.tryready.com/v1/account" \
      -H "X-API-Key: $READY_API_KEY"
    Response
    {
      "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
      }
    }
  3. 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.

    POST /v1/messages
    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
      }'
    Response
    {
      "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
      }
    }
  4. Read the response 30 s

    successboolean
    true: Ready accepted the text.
    data.message_idstring
    Store it. You use it to look up delivery, and webhooks carry it.
    data.statusstring
    queued. It becomes delivered or failed a few seconds later.
    data.segmentsinteger
    What you're billed for. A plain text segment is up to 160 characters.
    Tip. A 200 means Ready accepted the text, not that it was delivered. To hear about delivery, use a webhook (next step) or Get a message.
  5. 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": true on your first setup so the response includes the signing secret.

    Now reply to the text from your phone and watch the inbound_reply event arrive. Before you go live, verify the signature on every event.

    PUT /v1/webhooks
    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
      }'
  6. Go live checklist

Before your first send

  • New accounts start in a trial. Sends go out from a shared Ready number (a from you pass is ignored), a card on file is required (402 CARD_REQUIRED), and there's a small allowance of free sends (402 SANDBOX_LIMIT when 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_balance when it can't cover the text). With no active number, a send answers 400 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
  1. Open Settings (the gear, top right), then Integrations.
  2. 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.
  3. 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": true and a note, and some shapes differ. POST /v1/messages and POST /sms/send return a flat body with no data: { "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, so GET /v1/messages/:id answers 404 message_not_found for it. POST /v1/messages/bulk returns message_id: null and segments: null in each result. POST /v1/numbers returns data.phone_number with no phone_number_id. Registering a brand or campaign returns registration_id: null. Registration resubmit, vet, vet/confirm and cancel return data.reg_id with test and note, not the live shape; rename returns only data.reg_id and test. Confirm returns no registration_id. Creating a client account returns id: null. Checkout returns a test-… payment_intent_id and no client_secret. Validate returns line_type: "unknown", carrier: null and textable: null. Setting a client's markup returns only data.id and test.
  • Not simulated: /v1/scrub and /sms/generate are 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 your contact_created webhook 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_delivered or message_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_sms is simulated. send_campaign and enroll_in_drip send real texts and are charged at your normal rate, 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.
Changing soon. Test keys will be limited to the simulated endpoints; calls outside them will be refused.

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.

HTTPCodeMeaning
401AUTH_REQUIREDNo key was sent, the variable is empty, or bearer was lower case.
401INVALID_API_KEYStarts with rsms_ but isn't valid: revoked, mistyped or cut off.
401INVALID_TOKENThe value isn't an rsms_ key or a valid OAuth token.
403ACCOUNT_SUSPENDEDThe account is suspended; every call is refused. Contact support.
503AUTH_CHECK_UNAVAILABLEWe couldn't check the key. Nothing ran; retry.
Header, option 1
X-API-Key: rsms_your_key_here
Header, option 2
Authorization: Bearer rsms_your_key_here
Response
{
  "success": false,
  "error": "Sign in to continue. For API calls, send your API key in the X-API-Key header.",
  "code": "AUTH_REQUIRED"
}

#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.
Note. Read 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

ResponseWhat to do
429Wait Retry-After seconds, then retry with the same Idempotency-Key.
409 idempotency_in_progressWait a few seconds, then retry with the same key.
503, 504, 202 send_outcome_unknownCheck 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 4xxFix the request, then send it with a new key.
422Don't retry. The number can't receive texts.

Status codes

  • 200 OK. 201 created (contacts). 202 accepted but not finished, or the outcome is unknown.
  • 400 the request is wrong. 401 no or bad credentials. 402 payment needed. 403 not allowed (often: you can't text this contact). 404 not found.
  • 406 unsupported API version. 408 timed out, nothing changed. 409 conflict, or not sendable right now. 413 body over 1 MB. 422 the number can't receive texts. 423 legal hold. 429 too fast or over a limit.
  • 500, 502 something failed on our side. 503 a safety check couldn't run, nothing was sent and you weren't charged. 504 a 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.

Response
{
  "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"
}
Branch on the code
const body = await res.json();
const code = body.error_code ?? body.code; // may be undefined on a malformed request

Every 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.

CodeHTTPFieldRetry?What to do
empty_merge_field200error_codeAfter fixingA merge field is blank for this contact. Nothing was sent. Resend with "confirm_empty_merge": true to send it with the blank.
payment_processing202eitherLaterThe registration payment is still processing.
send_outcome_unknown202error_codeNoWe couldn't confirm the carrier took the text. Check GET /v1/messages before you send again.
address_incomplete400codeAfter fixingA brand registration is missing part of the business address: street, city, state or zip. error names what's missing.
area_code_invalid400codeAfter fixingarea_code is missing or not 3 digits.
attestation_required400errorAfter fixingTurning off tcpa or dnc_complainers needs confirmation_text: "I UNDERSTAND".
brand_required_first400error_codeAfter fixingRegister a brand before a campaign.
business_description_required400codeAfter fixingA brand registration needs business_description: one or two sentences on what the business does.
ca_not_provisioned400error_codeNoSending to Canada isn't set up on this account.
categories_required400errorAfter fixingScrub categories: categories is missing. This endpoint puts the code in error and the sentence in message.
CONTACT_UPDATE_FAILED400codeAfter fixingA value can't be saved (for example a non-integer score).
CONTENT_BLOCKED400bothNoLink 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_mismatch400errorAfter fixingThe EIN doesn't match the business name. The body has title, message, steps, can_override.
idempotency_key_too_long400error_codeAfter fixingIdempotency-Key is over 255 characters.
invalid_assignee400codeAfter fixingOne of the ids isn't on your team.
INVALID_CONTACT_ID400codeAfter fixingUse the contact's id, not its phone number.
invalid_ein400codeAfter fixingein isn't 9 digits (XX-XXXXXXX or XXXXXXXXX).
invalid_field400codeAfter fixingA field has the wrong type. field names it.
invalid_from400error_codeAfter fixingfrom isn't one of your numbers.
INVALID_JSON400codeAfter fixingThe body isn't valid JSON.
invalid_number400bothAfter fixingThe phone number has fewer than 10 digits.
INVALID_PHONE400codeAfter fixingThe contact's phone number can't be read.
invalid_phone400error_codeAfter fixingOn 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_request400bothAfter fixingThe request body is missing something or has the wrong shape. error says what.
invalid_status400codeAfter fixingConversation status must be open, active, closed or archived.
invalid_sub_account_id400bothAfter fixingX-Sub-Account-Id isn't a numeric account id.
invalid_url400bothAfter fixingThe webhook URL isn't usable (bad URL, not http or https, or a private or internal host).
invalid_use_case400error_codeAfter fixingUnknown campaign use_case. valid lists the allowed values.
MESSAGE_TOO_LONG400error_codeAfter fixingOver 25 segments, or over 1,600 characters with an image. Also seen in lower case (message_too_long) from other send paths.
NO_APPROVED_CAMPAIGN400error_codeNoThe number has no approved 10DLC campaign yet.
no_country_sender400error_codeNoYou have no number that can send to this country.
no_sender_number400error_codeAfter fixingThe account has no active number to send from.
payment_intent_mismatch400eitherNoThe payment isn't for this vetting.
payment_intent_required400eitherAfter fixingBuying a number needs a payment_intent_id. Also payment_not_confirmed, payment_not_for_number, payment_not_taken.
payment_not_completed400eitherAfter payingThe registration payment didn't go through (status).
PHONE_NOT_EDITABLE400codeNoA contact's phone number can't be changed. Create a new contact.
phones_required400error_codeAfter fixingValidate needs phones: a list of 1 to 1,000 numbers.
preferred_area_code_required400errorAfter fixingnumber_choice: "new" without preferred_area_code.
registration_approved400eitherNoAn approved registration can't be cancelled over the API. Contact support.
REQUEST_BLOCKED400codeAfter fixingThe 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_approved400bothAfter fixingMarkup needs an approved reseller account.
sub_account_header_needs_api_key400bothAfter fixingX-Sub-Account-Id needs an API key, not an OAuth or session token.
sub_account_header_not_supported400bothAfter fixingX-Sub-Account-Id works only on /v1 paths.
terms_required400bothAfter fixingAccept the reseller terms in your dashboard before setting a markup.
too_many_numbers400bothAfter fixingScrub with more than 1,000 numbers.
too_many_phones400bothAfter fixingValidate with more than 1,000 numbers.
too_many_recipients400error_codeAfter fixingBulk send with more than 100 recipients.
unknown_category400errorAfter fixingScrub categories: a key isn't tcpa, dnc_complainers, federal_dnc or state_dnc.
unknown_field400codeAfter fixingThe endpoint doesn't accept a field you sent. Remove it.
UNRENDERED_MERGE_TOKEN400error_codeAfter fixingA merge tag didn't fill in. Nothing sent, not charged.
AUTH_REQUIRED401codeAfter fixingNo key was sent, the variable is empty, or bearer was lower case.
INVALID_API_KEY401codeAfter fixingStarts with rsms_ but isn't valid: revoked, mistyped or cut off.
INVALID_TOKEN401codeAfter fixingThe value isn't an rsms_ key or a valid OAuth token.
card_declined402eitherAfter fixingThe saved card was declined. Also card_authentication_required.
CARD_REQUIRED402error_codeAfter fixingAdd a card in Settings → Billing.
insufficient_balance402error_codeAfter adding fundsYour balance can't cover this. balance_cents and required_cents say by how much.
insufficient_credits402bothAfter adding fundsYour balance can't cover the whole batch (bulk send, validation). Nothing was sent or charged.
MONTHLY_CAP_REACHED402codeNoYour monthly sending cap is reached. Same limit as monthly_cap (429) from other send paths.
PURCHASE_BLOCKED402codeNoNumber purchases are blocked (unpaid bill or account review). Contact support.
SANDBOX_LIMIT402error_codeNoThe trial's free sends are used up. Register your business to keep sending.
ACCOUNT_SUSPENDED403codeNoThe account is suspended and every call is refused. Contact support. The send path can also answer lower case account_suspended in error_code.
dnc_list403error_codeNoThe number is on a do-not-call list your account screens against.
dnd_sms403error_codeNoThe contact is marked Do Not Disturb for texts.
EXPORT_DISABLED403codeNoThe owner turned off exports for teammates.
global_blocklist403error_codeNoThe number is on Ready's blocklist.
IP_TEMPORARILY_BLOCKED403codeLaterToo many over-limit requests without a valid key. Blocked for 15 minutes or longer.
opt_out_locked403error_codeNoThe contact replied STOP (or was locked by TCPA scrubbing). Only the contact texting START lifts it.
opted_out403error_codeNoYou tried to re-enable texting for an opted-out contact. The contact must text START.
payment_not_owned403eitherNoThe payment belongs to another account.
permission_denied403bothNoThe key's user lacks the permission this endpoint needs.
quiet_hours403error_codeLaterOutside the recipient's allowed sending hours (other send paths; /v1/messages answers 409 QUIET_HOURS_BLOCK).
READ_ONLY_SESSION403error_codeNoView-only access can't make changes or spend money.
REQUEST_BLOCKED403codeNoYour client's User-Agent looks like a security scanner. Send your own User-Agent.
sub_account_forbidden403bothNoThat account isn't a client account you manage.
sub_account_suspended403bothNoThat client account is suspended.
tcpa_block403error_codeNoKnown TCPA litigator.
CONTACT_NOT_FOUND404codeNoNo contact with this id on your account.
CONVERSATION_NOT_FOUND404codeNoNo conversation with this id on your account.
message_not_found404bothNoNo message with this id on your account. The body also has status: "unknown".
not_found404codeNoConversation not found (from PUT /conversations/:id/assign).
not_rejected404eitherNoResubmit works only on a rejected registration.
NUMBER_NOT_FOUND404codeNoNo number with this id on your account.
payment_not_found404eitherNoNo such registration payment on your account.
registration_not_found404error_codeNoNo registration with this id on your account.
sub_account_not_found404bothNoNo account has that id.
unsupported_api_version406error_codeAfter fixingAccept-Version names a version this server doesn't serve. supported lists them.
REQUEST_TIMEOUT408codeYesA read, or an upload that never finished, took longer than 30 seconds. Nothing changed.
already_vetted409error_codeNoThe brand already has this vetting tier or higher.
area_code_unavailable409error_codeAfter fixingNo numbers in stock for this area code. alternatives suggests nearby ones.
CONTACT_CHANGED409codeAfter fixingThe contact opted out while you were saving. Read it again.
CONTACT_EXISTS409codeNoA contact with this phone number already exists. Look it up with GET /contacts?search=.
duplicate_brand409error_codeNoYou already have this brand (existing_reg_id).
duplicate_send409error_codeNoThe same text went to the same number in the last 60 seconds without an Idempotency-Key.
duplicate_use_case409error_codeNoYou already have a campaign with this use case. Your brand created a MIXED campaign for you.
idempotency_in_progress409error_codeSame keyThe first request with this key is still running. Wait a few seconds.
idempotency_outcome_unknown409error_codeNoThe first request with this key didn't finish within 20 minutes. Check GET /v1/messages before sending again.
INVALID_SENDER409error_codeNoThe number you're sending from can't send right now. Try another number.
needs_transfer_confirm409error_codeAfter fixingThe number is on another brand. Resend with "transfer_confirm": true.
number_activating409error_codeLaterThe sending number is still being set up.
number_not_active409error_codeNoThe sending number isn't active.
NUMBER_NOT_ASSIGNED_CONFIRM409error_codeAfter fixingThe number isn't assigned to this contact's owner (dashboard sends).
NUMBER_RESERVED409codeNoThis number can't be released right now.
paid_not_filed409errorNoThe registration is paid but not filed yet. Don't pay again; contact support.
payment_intent_already_used409error_codeNoEach payment_intent_id buys exactly one number.
purchase_in_progress409eitherLaterAnother number purchase is running. Also campaign_number_limit.
QUIET_HOURS_BLOCK409codeLaterOutside 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_REACHED409codeLaterA 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_unavailable409error_codeLaterNo route to this recipient right now.
scrub_off409bothAfter fixingScrubbing is turned off for this account. Turn it on in Settings → Compliance.
PAYLOAD_TOO_LARGE413codeAfter fixingThe body is over 1 MB.
auto_reply_suppressed422error_codeNoAn automatic reply was held back.
INVALID_DESTINATION422error_codeNoNot a valid phone number. Nothing sent, not charged.
landline_no_sms422error_codeNoThe number is a landline.
number_disconnected422error_codeNoThe number is disconnected.
PROVIDER_REJECTED422error_codeNoThe carrier refused this text. Not charged.
sms_invalid422error_codeNoThe number was marked as unable to receive texts.
undeliverable422error_codeNoThe carrier can't deliver to this number.
LITIGATION_HOLD423codeNoA legal hold covers this contact, so it can't be deleted.
daily_cap429error_codeLaterDaily sending cap reached.
duplicate_submission429error_codeNoThe same registration was submitted in the last 2 minutes (existing_reg_id).
monthly_cap429error_codeNoMonthly sending cap reached (same limit as MONTHLY_CAP_REACHED).
number_daily_cap429error_codeLaterThis number's daily cap is reached.
ramp_cap429error_codeLaterWarm-up cap (same limit as RAMP_CAP_REACHED).
rate_limited429error_codeAfter Retry-AfterToo many requests for this account. Body has retry_after_ms (and cap_per_sec when it's carrier pacing).
RATE_LIMITED429codeAfter Retry-AfterToo many requests from this IP address. Body has retry_after_seconds.
throughput_exceeded429bothLaterThe batch is more than your carriers allow right now. Nothing was sent.
validation_in_progress429bothAfter Retry-AfterThe same numbers are already being validated.
buy_incomplete500error_codeNoThe number was added but setup didn't finish. Don't buy again; check GET /v1/numbers.
generation_failed500eitherYesWriting drafts failed. Refunded.
internal_error500bothCheck firstSomething failed on our side. On billed calls (validate, scrub), check your balance before you retry.
RELEASE_INCOMPLETE500codeCheck firstThe number release didn't finish. Check GET /v1/numbers/:id.
resubmit_unconfirmed500eitherCheck firstDon't resubmit again yet; poll GET /v1/registration.
server_error500codeCheck firstSomething failed on our side (seen on a bad offset to GET /conversations).
send_failed500error_codeNoThe send failed. In bulk results, also used when a recipient's refusal had no error_code: read error.
BILLING_CANCEL_FAILED502codeLaterWe couldn't stop the number's monthly fee, so nothing was released.
checkout_unconfirmed502error_codeNoWe couldn't confirm the checkout. Check GET /v1/numbers before retrying.
payment_unknown502error_codeNoWe couldn't confirm the registration payment. Don't submit again; poll GET /v1/registration.
subscription_cancel_failed502eitherLaterWe couldn't confirm the registration's monthly fee stopped, so it was kept.
ADMISSION_SHED503codeSame keyWe're overloaded and didn't act on the request. Wait Retry-After (1 second) and retry.
AUTH_CHECK_UNAVAILABLE503codeYesWe couldn't check your key. Nothing ran.
checkout_unavailable503eitherLaterNumber checkout isn't available. Also availability_check_unavailable, cap_check_unavailable. Nothing charged.
contact_unavailable503error_codeNew keyThe contact couldn't be read. Nothing sent.
could_not_retrieve_payment_intent503eitherYesWe couldn't check the vetting payment. Don't pay again; retry the confirm call.
gate_check_failed503error_codeNew keyA safety check couldn't run. Nothing sent, not charged.
payment_check_unavailable503eitherYesWe couldn't check the registration payment.
payments_unavailable503eitherLaterRegistration payments are unavailable. Nothing charged.
provider_busy503error_codeNew keyThe carrier is limiting how fast we can send. Nothing sent, not charged. Retry in a minute.
sending_paused503error_codeLaterSending is paused. Nothing sent, not charged.
tcpa_not_configured503bothLaterScrubbing isn't available right now. Nothing scrubbed or charged.
throughput_unavailable503error_codeLaterThroughput couldn't be read.
validation_unavailable503bothLaterNumber validation isn't available right now. Nothing charged.
REQUEST_TIMEOUT_OUTCOME_UNKNOWN504codeCheck firstA 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.

  • limit defaults 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, and 0, negative or non-numeric values fall back to the default (except GET /billing/transactions, where limit=0 returns an empty page).
  • offset must be a whole number, 0 or more. Today a bad offset returns an empty page on /contacts and a 500 on /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.

EndpointStylePaging fields next to dataMax limit
GET /contactsoffsettotal1,000
GET /conversationsoffsethas_more, total, total_capped, count_cap, counts1,000
GET /conversations/:id/messagesoffsetnone500
GET /billing/transactionsoffsettotal, summary200
GET /sms/logsoffsethasMore, total (always null)1,000
GET /v1/messagescursorhas_more, next_before_id200
GET /v1/opt-outscursorhas_more, next_before_id500 (default 100)
GET /v1/sub-accountscursorhas_more, next_before_id200
Offset style
GET /conversations?limit=100&offset=0
GET /conversations?limit=100&offset=100
# stop when has_more is false
Cursor style
GET /v1/messages?limit=200
GET /v1/messages?limit=200&before_id=<next_before_id>
# stop when has_more is false

Rate limits

A 429 means slow down: wait Retry-After seconds and retry. Each limit counts something different:

WhatLimitCountedOver 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 account429 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 minutePer 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 account429 rate_limited
Contact exports6 an hour, shared with the dashboardPer account429
Everything1,200 requests a minute with a valid key, 300 without onePer IP address429 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.

Response
{
  "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/validate and /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 a 5xx, 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-Id errors, permission_denied, IP_TEMPORARILY_BLOCKED, REQUEST_BLOCKED (400 or 403), REQUEST_TIMEOUT (408, the body never arrived), ADMISSION_SHED (503), and on /v1 paths unsupported_api_version.
    • On POST /v1/messages: a missing to or body, invalid_field and invalid_from.
    • On POST /v1/numbers and POST /v1/numbers/checkout: invalid_field. With a live key, the corrected retry is a real purchase.
  • 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 gets 409 idempotency_outcome_unknown: check GET /v1/messages before 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 are null.
  • Amounts ending _cents are integer cents. Amounts ending _usd, and rates, are US dollars. On /billing/balance, balance is 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

HeaderWhat it is
X-Request-IdA UUID on every response. Include it when you contact support.
X-API-Version1 on /v1 responses.
X-RateLimit-Limit, -Remaining, -ResetThe limit that applied (see Rate limits).
X-RateLimit-PolicyOn sends: your carrier pace, N;w=1.
Retry-AfterOn 429 (and some 503s): seconds to wait.
Idempotent-Replayedtrue 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

POST/v1/messages

Sends one text. A 200 means Ready accepted it for sending, not that it was delivered.

  • Auth API key
  • Idempotent Yes, with Idempotency-Key
  • Rate limit Paced to your carrier throughput; 6,000/min ceiling
  • Cost segments × your rate, plus fees (pricing)

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. message is accepted as an alias. Up to 25 segments: about 3,825 plain characters, or about 1,600 with emoji or other Unicode. Longer returns 400 MESSAGE_TOO_LONG.
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_id isn't supported here; use from.
conversation_idintegerOptional
A conversation id from GET /conversations. The text is filed in that conversation and, unless you set from, Ready prefers the number that conversation uses. An id that isn't on your account is ignored.
quoted_message_idintegerOptional
The id of a message in that conversation (from GET /conversations/:id/messages). Ready saves its first 200 characters as quoted_text on the new message in your inbox. The text the recipient gets is unchanged.
override_quiet_hoursbooleanOptional
Send anyway after a 409 QUIET_HOURS_BLOCK that 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 http or https URL 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_strategyenumOptionalDefault local
How Ready picks the number when you don't set from.
  • local match the recipient's area code
  • even spread across your numbers, least used first (deliverability is the same)
  • random
confirm_empty_mergebooleanOptional
Send true to send even though a merge field is blank (see empty_merge_field below).

Returns

data.message_idstring
Store it. Opaque, up to 64 characters: don't parse it or assume a format. Webhooks and GET /v1/messages/:id use the same value.
data.statusstring
queued. The carrier confirms delivered or failed within 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_unix is 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_balance is 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. Check GET /v1/messages before 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 has needs_confirmation and empty_fields. Send again with "confirm_empty_merge": true to send it with the blank.
  • Rarely, 200 with warning and "recorded": false and no message_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

HTTPCodeWhen
400no codeMissing to or body.
400invalid_fieldA field has the wrong type (field names it).
400invalid_fromfrom isn't one of your numbers.
400MESSAGE_TOO_LONGOver 25 segments, or over 1,600 characters with an image.
400CONTENT_BLOCKEDBlocked content. Nothing sent, but a compliance fee may apply.
402insufficient_balanceBalance too low. Also CARD_REQUIRED, SANDBOX_LIMIT, MONTHLY_CAP_REACHED.
403opt_out_lockedThe contact opted out. Also dnd_sms, tcpa_block, global_blocklist.
403api_consent_attested_requiredconsent_attested missing, once enforced. Today the send goes through.
409QUIET_HOURS_BLOCKOutside quiet hours (in code, with next_allowed_at).
409RAMP_CAP_REACHEDWarm-up cap used up (in code).
409duplicate_sendSame text to the same number within 60 seconds, without a key.
422INVALID_DESTINATIONNot a valid phone number. Not charged.
429rate_limitedFaster than your carriers allow. Wait Retry-After.
503provider_busyCarrier is busy. Not sent, not charged.

#Send to many

POST/v1/messages/bulk

Sends one text to up to 100 recipients in one request, with a result for each recipient.

  • Auth API key
  • Idempotent Yes, with Idempotency-Key
  • Rate limit About 120 requests/min, 140 bulk calls/min per account
  • Cost Each recipient billed as one send
Note. Bulk is synchronous: recipients are sent 5 at a time and the call returns when all are done. Set a client timeout of at least 60 seconds, and after a timeout retry with the same 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 as duplicate_send.
bodystringRequired
The text, sent to every recipient. message is accepted as an alias.
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 either message_id and segments, or error and error_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

HTTPCodeWhen
400invalid_requestto isn't a list, or there's no body.
400too_many_recipientsMore than 100 recipients.
400invalid_fromfrom isn't one of your numbers.
402insufficient_creditsBalance can't cover the batch. Nothing sent.
429throughput_exceededOver your carrier throughput. Nothing sent.

#Get a message

GET/v1/messages/:id

The delivery status of one message you sent or received.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Path parameters

idstringRequired
The message_id a send returned.

Returns

data.statusstring
queued, delivered, failed or undelivered for messages you sent; received for inbound messages.
data.error_reasonstring | null
A stable value to branch on, null unless status is failed or undelivered. 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) and carrier_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), rejected and unknown.
data.error_detailstring | null
A plain sentence explaining the failure that you can show to a person, null when error_reason is. 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

HTTPCodeWhen
404message_not_foundNo message with this id on your account (a message on another account answers the same way). The body also has status: "unknown".

#List messages

GET/v1/messages

Your sent and received messages, newest first. Pages with a cursor.

  • Auth API key
  • Idempotent No
  • Rate limit About 120 requests/min per account
  • Cost Free

Query parameters

directionenumOptional
inbound or outbound.
toE.164 stringOptional
Only messages to this number.
fromE.164 stringOptional
Only messages from this number.
statusstringOptional
queued, delivered, failed, undelivered or received.
limitintegerOptionalDefault 50Max 200
Page size.
before_idintegerOptional
The next_before_id from the previous page (not a message id). offset isn't supported and is ignored.

Returns

data[]object[]
id (the same value as message_id), direction, from, to, body, status, segments, cost_usd, media_url, created_at, delivered_at.
has_moreboolean
Keep paging while true. It's true on an exactly full page, so the last page can be empty.
next_before_idinteger | null
Pass as before_id for the next page. null when 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

GET/conversations

Your inbox threads: starred first, then by latest message.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Query parameters

limitintegerOptionalDefault 50Max 1000
Page size.
offsetintegerOptionalDefault 0
Rows to skip. A whole number, 0 or more (other values return 500 server_error today).
filterenumOptional
Which view. Default: open threads, with archived and snoozed ones hidden. Unknown values are ignored.
  • unread
  • starred
  • archived
  • snoozed
  • spam
  • blocked
line_idsstringOptional
Comma-separated number ids from GET /v1/numbers, not phone numbers, for example line_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 1 right 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, tags and more. Ignore fields you don't recognize.
has_moreboolean
Keep paging while true.
totalinteger
Threads matching filter and search. Stops counting at 1,000 (total_capped: true, count_cap: 1000). With line_ids, total and counts ignore the lines and count every line's threads, so use has_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

GET/conversations/:id/messages

One thread's messages: the newest page, oldest first within the page.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free
Important. Reading the newest page marks the thread as read and clears its unread badge in the inbox. Sync jobs should add mark_read=0.

Path parameters

idintegerRequired
The conversation id.

Query parameters

mark_readintegerOptional
Send 0 to read without changing the unread state.
limitintegerOptionalDefault 50Max 500
Page size.
offsetintegerOptionalDefault 0
Increase to go further back in time.

Returns

data[]object[]
Messages: id, direction (inbound or outbound), 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

HTTPCodeWhen
404CONVERSATION_NOT_FOUNDNo conversation with this id on your account.

#Find a contact's conversation

GET/conversations/by-contact/:contactId

The thread for one contact, or null if there isn't one.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Path parameters

contactIdintegerRequired
The contact id.

Returns

dataobject | null
The thread, or null when the contact has no thread. An unknown contact id also returns null, not a 404, so check for null. This row has contact_status, dnd_sms and opt_out_at, and no tags.

#Assign a conversation

PUT/conversations/:id/assign

Replaces the thread's assignees.

  • Auth API key; view-only teammates can't
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

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

HTTPCodeWhen
400invalid_assigneeAn id isn't on your team.
404not_foundNo conversation with this id (lower case here).

#Set conversation status

PUT/conversations/:id/status

Opens or closes a thread.

  • Auth API key; view-only teammates can't
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Path parameters

idintegerRequired
The conversation id.

Body parameters

statusenumRequired
open or closed (also active, archived). To archive, prefer Archive, which also sets the archive time.

Errors

HTTPCodeWhen
400invalid_statusMissing or unknown status.
404no codeNo conversation with this id (no code here).

#Mark read

PUT/conversations/:id/read

Clears the thread's unread count. No body.

  • Auth API key; view-only teammates can't
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Path parameters

idintegerRequired
The conversation id.

Errors

HTTPCodeWhen
404CONVERSATION_NOT_FOUNDNo conversation with this id on your account.

#Mark unread

PUT/conversations/:id/unread

Sets the thread's unread count to 1. No body.

  • Auth API key; view-only teammates can't
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Path parameters

idintegerRequired
The conversation id.

Errors

HTTPCodeWhen
404CONVERSATION_NOT_FOUNDNo conversation with this id on your account.

#Archive

PUT/conversations/:id/archive

Archives the thread and clears its unread count. No body.

  • Auth API key; view-only teammates can't
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Path parameters

idintegerRequired
The conversation id.

Errors

HTTPCodeWhen
404CONVERSATION_NOT_FOUNDNo conversation with this id on your account.

#Unarchive

PUT/conversations/:id/unarchive

Brings the thread back to the inbox with status: "open", even if it was closed. No body.

  • Auth API key; view-only teammates can't
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Path parameters

idintegerRequired
The conversation id.

Errors

HTTPCodeWhen
404CONVERSATION_NOT_FOUNDNo conversation with this id on your account.

#Star or unstar

PUT/conversations/:id/star

Toggles the star: it stars an unstarred thread and unstars a starred one. No body.

  • Auth API key; view-only teammates can't
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Path parameters

idintegerRequired
The conversation id.

Returns

data.starredinteger
The new value: 1 or 0. To reach a specific state, read starred from the list first.

Errors

HTTPCodeWhen
404CONVERSATION_NOT_FOUNDNo 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.

FieldMeaningBlocks texts?
opt_out_lockedSet by a STOP or a TCPA lock. Cleared only by START.Yes
statusopted_out when opted out.Yes, when opted_out
dnd_smsDo not text.Yes
dnd_email, dnd_callsDo not email, do not call.No
opt_out_sourcesms_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_atWhen they opted out (ISO 8601). This is the real opt-out time.
is_litigatorMatched a TCPA litigator list.Yes, through the lock
tcpa_statusThe matched lists, comma-separated, or null.
sms_invalidThe number can't receive texts.Depends on an account setting

Which list to use

ListWhat's in itReady blocks texts to all of it?Format
GET /v1/opt-outsEveryone Ready won't text: STOP, opted out, DND for texts, TCPA lock.YesJSON, cursor pages
?compliance=optoutThe same people, minus contacts marked spam (unless include_spam=1).YesCSV, 6 an hour
?compliance=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.No: email-only and call-only DND contacts can still get textsCSV, 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

GET/v1/opt-outs/:phone

Whether a number is opted out on your account.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Path parameters

phonestringRequired
US or Canada (+1) only. E.164 (URL-encode the + as %2B if your client needs it) or 10 digits. Fewer than 10 digits returns 400 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_outboolean
true if 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_at from GET /contacts/:id. null when not opted out.

On a 500, treat the number as not safe to text, and retry.

Errors

HTTPCodeWhen
400invalid_numberFewer than 10 digits.

#List opt-outs

GET/v1/opt-outs

Every number your account must not text, by contact, newest contact first.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Query parameters

limitintegerOptionalDefault 100Max 500
Page size.
before_idintegerOptional
The next_before_id from the previous page. offset isn't supported and is ignored.

Returns

data[]object[]
phone and since (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 on next_before_id: it's set on the last page too.
next_before_idinteger
Pass as before_id.

#Scrub numbers

POST/v1/scrub

Checks up to 1,000 US numbers against the TCPA litigator and do-not-call lists your account screens. Billed per number.

  • Auth API key with edit_contacts
  • Idempotent Yes, with Idempotency-Key
  • Rate limit About 120 requests/min per account
  • Cost Your scrub rate per valid number
Important. Each valid, unique number is billed at your scrub rate, the same as a send's scrub (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 of null means your account doesn't screen that list. On a problem, error_code and error: invalid_number (not charged), scrub_failed, or scrub_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

HTTPCodeWhen
400no codephones is missing, empty or not a list.
400too_many_numbersMore than 1,000 numbers.
402insufficient_balanceThe batch could cost more than your balance (required_cents, balance_cents). Nothing scrubbed.
409scrub_offScrubbing is off for your account.
503tcpa_not_configuredScrubbing isn't available right now.

#Scrub one number

GET/v1/scrub/:phone

The same check for one number. This GET is billed, like one number in Scrub numbers.

  • Auth API key with edit_contacts
  • Idempotent No
  • Rate limit About 120 requests/min per account
  • Cost Your scrub rate

Path parameters

phonestringRequired
A US number, E.164 or 10 digits.

Returns

dataobject
One result, shaped like an item of results[] above.

Errors

HTTPCodeWhen
402insufficient_balanceBalance too low.
409scrub_offScrubbing is off for your account.

#Get scrub categories

GET/compliance/scrub-categories

Which lists your account screens contacts against, and your scrub rate.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

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_phrasestring
I UNDERSTAND: what confirmation_text must 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

POST/compliance/scrub-categories

Turns screening lists on or off. Account owner only.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free
Important. Errors here are shaped differently: 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 tcpa and dnc_complainers (on by default), federal_dnc and state_dnc (off by default), each a JSON boolean. Send real true and false: only true, 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

dataobject
master_enforce and the saved categories.

Errors

HTTPCodeWhen
400categories_requiredcategories is missing (in error).
400unknown_categoryA key isn't one of the four (in error).
400attestation_requiredTurning off tcpa or dnc_complainers needs confirmation_text (in error; disabling lists them).
400invalid_fieldconfirmation_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

GET/contacts/export

A CSV of opted-out contacts, for audits and list hygiene.

  • Auth API key
  • Idempotent No
  • Rate limit 6 exports/hour per account, shared with the dashboard (RateLimit-Remaining header)
  • Cost Free

Query parameters

complianceenumRequired
Must be exactly one of these. Any other value exports all your contacts.
  • optout everyone Ready won't text: replied STOP, DND for texts, or locked by TCPA scrubbing. opt_out_source says which. File opted-out-contacts.csv.
  • 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. File dnc-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

HTTPCodeWhen
403EXPORT_DISABLEDThe owner turned off exports for teammates.
429no codeMore 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

EventSent whendata
inbound_replyA 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_deliveredThe 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_failedThe 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_outA 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 onlyA 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.

Response
{
  "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
  }
}

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 timestamp is more than 5 minutes old. The timestamp is inside the signed body.
  • No X-Signature header? 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.

Verify a webhook
// 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);

#Get webhook settings

GET/v1/webhooks

Your webhook URL, events and on/off state.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Returns

data.urlstring | null
Your endpoint.
data.eventsstring[]
Subscribed events.
data.enabledboolean
Always false when there's no URL.

#Set webhook settings

PUT/v1/webhooks

Sets your webhook URL and events. Saving replaces the previous settings.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Body parameters

urlstringRequired
Your endpoint. Send it on every call: a PUT without url clears your saved URL and stops delivery. Use https://: 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 check events in the response: an empty list means you'll receive nothing. contact_created can only be set in the dashboard, and saving over the API removes it.
enabledbooleanOptionalDefault true
Send the JSON boolean false to pause. The string "false" or 0 leaves delivery on.
regenerate_secretbooleanOptional
Send the JSON boolean true to create a new signing secret, returned as secret. The old one stops working immediately. Any other non-empty value, even "false", also rotates it. Your first PUT creates a secret but doesn't return it, so send true on first setup. You can also see the current secret in Settings → Integrations.

Returns

dataobject
url, events, enabled, and secret only when it was rotated.

Errors

HTTPCodeWhen
400invalid_urlNot a URL, not http or https, or a private or internal host.
400invalid_fieldurl is a list or object.
400INVALID_JSONThe body isn't JSON.

#Contacts

One contact per phone number. Errors in this section carry their code in code.

  • tags comes back as a JSON-encoded string, for example "[\"vip\",\"hot\"]". Parse it with JSON.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_sms and others) are explained under Opt-outs.

#List contacts

GET/contacts

Your contacts, most recently updated first, with filters.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Query parameters

tagstringOptional
An exact tag name, case-sensitive (VIP and vip are different tags).
statusstringOptional
For example opted_out.
dndintegerOptional
1: only contacts with texting turned off.
limitintegerOptionalDefault 50Max 1000
Page size.
offsetintegerOptionalDefault 0
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_added
  • name_asc , name_desc
  • phone_asc , phone_desc
  • status_asc , status_desc
  • score_high , score_low
  • lastact_asc , lastact_desc
  • source_asc , source_desc
  • assignee_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

GET/contacts/:id

One contact by id.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Path parameters

idintegerRequired
The contact id.

Returns

dataobject
The contact.

Errors

HTTPCodeWhen
404CONTACT_NOT_FOUNDNo contact with this id on your account (also for a non-numeric id).

#Create a contact

POST/contacts

Adds a contact. Returns 201 with the new contact.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free; scrubbing runs if it's on (scrub rate)
Note. One contact per phone number. Creating an existing number, in any format, returns 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_namestringOptional
last_namestringOptional
emailstringOptional
tagsstring[]Optional
An array of strings. A plain string returns 400 invalid_field.
company_namestringOptional
pipeline_stagestringOptionalDefault new_lead
Free text; use the stage ids from your pipeline.
sourcestringOptionalDefault manual
timezonestringOptional
An IANA name such as America/Chicago. Set from the area code for US numbers when you leave it out.
More parameters (6)
websitestringOptional
address1, address2, city, state, postal_code, countrystringOptional
Address fields.
date_of_birthstringOptional
genderstringOptional
assigned_tointegerOptional
A team member id.
dnd_sms, dnd_email, dnd_callsintegerOptional
1 to 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

HTTPCodeWhen
400no codeNo phone number.
400INVALID_PHONEThe number can't be read. (Its message mentions a 10-digit US number, but E.164 numbers from other countries are accepted.)
400invalid_fieldA field has the wrong type, such as tags that isn't a list.
409CONTACT_EXISTSYou already have a contact with this number. Find it with GET /contacts?search=<phone>.

#Update a contact

PUT/contacts/:id

Changes only the fields you send. Use PUT: PATCH isn't supported.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free
Important. Unknown fields are ignored with a 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, emailstringOptional
tagsstring[]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_smsintegerOptional
1 opts the contact out of texts. You can't set it back to 0 on an opted-out contact (403): the contact must text START.
custom_fieldsobjectOptional
Merged key by key. null for 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_callsintegerOptional
notesstringOptional
scoreintegerOptional
A non-integer returns 400 CONTACT_UPDATE_FAILED.
source, company_name, timezonestringOptional
address1, address2, city, state, postal_code, countrystringOptional
assigned_tointegerOptional
inbound_blockedintegerOptional

Returns

dataobject
The updated contact.

Errors

HTTPCodeWhen
400PHONE_NOT_EDITABLEYou tried to change the phone number.
400CONTACT_UPDATE_FAILEDA value can't be saved.
403opt_out_lockedThe contact replied STOP (in error_code; also opted_out).
404CONTACT_NOT_FOUNDNo contact with this id.
409CONTACT_CHANGEDThe contact opted out while you were saving.

#Delete a contact

DEL/contacts/:id

Permanently removes the contact and its conversation history, notes and tasks.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Path parameters

idintegerRequired
The contact id, not its phone number.

Errors

HTTPCodeWhen
400INVALID_CONTACT_IDYou sent a phone number or other non-id.
404CONTACT_NOT_FOUNDNo contact with this id, or it was already deleted.
423LITIGATION_HOLDA 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

POST/v1/validate

Checks up to 1,000 numbers. Billed per number that gets an answer.

  • Auth API key with edit_contacts
  • Idempotent Yes, with Idempotency-Key
  • Rate limit About 120 requests/min per account
  • Cost $0.005 per number answered, cached answers included
Important. Every number that gets an answer is billed, including answers from cache. Malformed numbers are never looked up or billed. 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 numbers, E.164 or 10 digits.

Returns

data[]object[]
One per number, in the order you sent them.
data[].normalizedE.164 string | null
null when the number can't be read.
data[].validboolean
false for a malformed or disconnected number. reason says why: invalid_format, unsupported_country or disconnected.
data[].line_typeenum
mobile, landline or unknown. VoIP lines read as landline.
data[].carrierstring | null
The carrier, for mobile numbers.
data[].textableboolean | null
null when 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.01 on one call and 0 (with billed_numbers: 0) on the next.

Errors

HTTPCodeWhen
400phones_requiredphones is missing or empty.
400too_many_phonesMore than 1,000 numbers.
402insufficient_creditsBalance can't cover the batch (required_cents, balance_cents, numbers). Nothing checked.
429validation_in_progressThe same numbers are already being checked. Wait Retry-After.
500internal_errorCheck your balance before you retry: some numbers may have been billed.
503validation_unavailableValidation isn't available right now. Nothing charged.

#Validate one number

GET/v1/validate/:phone

The same check for one number. This GET is billed like one number above.

  • Auth API key with edit_contacts
  • Idempotent No
  • Rate limit About 120 requests/min per account
  • Cost $0.005 if answered

Path parameters

phonestringRequired
E.164 or 10 digits. A value that isn't a phone number answers 200 with valid: false, reason: "invalid_format", not billed.

Returns

dataobject
One result, shaped like an item of data[] above, plus billed_numbers and charged_usd.

Errors

HTTPCodeWhen
400invalid_phoneThe path value is longer than 40 characters.
403READ_ONLY_SESSIONView-only access can't spend money.
402insufficient_creditsBalance 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

  1. Optional: GET /v1/numbers/available?area_code=415 to see whether Ready can offer the area code.
  2. POST /v1/numbers/checkout with { "area_code": "415" } pays the first month and returns data.payment_intent_id.
  3. POST /v1/numbers with { "area_code": "415", "payment_intent_id": "…" }. Often the number is ready at once; otherwise poll GET /v1/numbers/:id until it's active, 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

statusMeaning
activeCan send.
pending_provisioning, requested, submittedBeing set up. phone_number is a placeholder like "Pending (415)", not a phone number.
provider_rejected, payment_declinedFailed and won't become active. Stop polling, sort out the payment with support if needed, and buy again.
billing_suspended, suspendedPaused for billing or review.
released, inactive, retiredNo longer in use.
deletedReleased. Hidden from the list; GET /v1/numbers/:id can still return it.

#List numbers

GET/v1/numbers

Every number on the account, newest first. Not paginated.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Returns

data[]object[]
id, phone_number, status, created_at. Includes numbers that aren't active yet or failed, so check status.

#Get a number

GET/v1/numbers/:id

One number. Poll this after a purchase until it's ready.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Path parameters

idintegerRequired
The phone_number_id from 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

HTTPCodeWhen
404no codeNot a number on this account. The body is {"success": false, "error": "Number not found"}, with no code.

#Check an area code

GET/v1/numbers/available

Whether you can buy a number in an area code.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

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.

availableboolean
available: true means Ready will try to get a number there. It doesn't check that the area code exists: 000, 911 and 999 answer 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: true means a number there can be ready right away.

Errors

HTTPCodeWhen
400area_code_invalidMissing or not 3 digits (in code).

#Start a number purchase

POST/v1/numbers/checkout

Pays the first month ($5) on your saved card and returns the payment to buy with.

  • Auth API key
  • Idempotent Yes, with Idempotency-Key
  • Rate limit 120/min (700/min shared ceiling)
  • Cost $5, the first month
Note. 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_id and checkout_id are 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

HTTPCodeWhen
400area_code_invalidMissing or not 3 digits.
402card_declinedAlso card_authentication_required.
402PURCHASE_BLOCKEDUnpaid bill or account review (in code).
409area_code_unavailableNo stock. alternatives suggests nearby area codes.
409purchase_in_progressAnother purchase is running. Also campaign_number_limit.
502checkout_unconfirmedDon't retry: check GET /v1/numbers first.
503checkout_unavailableAlso availability_check_unavailable, cap_check_unavailable. Nothing charged.

#Buy a number

POST/v1/numbers

Provisions the number you paid for at checkout.

  • Auth API key
  • Idempotent Yes, with Idempotency-Key
  • Rate limit 120/min (140/min shared ceiling)
  • Cost Paid 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_idstringRequired
data.payment_intent_id from 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.
instantboolean
true: the number is ready now and phone_number is real.
pending_provisioningboolean
true: being set up; poll until active.
pending_campaign_approvalboolean
true: 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

HTTPCodeWhen
400area_code_invalidMissing or not 3 digits (in code).
400payment_intent_requiredAlso payment_not_confirmed, payment_not_for_number, payment_not_taken.
400invalid_fieldA field has the wrong type.
402PURCHASE_BLOCKEDUnpaid bill or account review (in code).
403payment_not_ownedThe payment belongs to another account.
409payment_intent_already_usedThis payment already bought a number.
500buy_incompleteAdded but not finished. Don't buy again: check GET /v1/numbers.

#Release a number

DEL/v1/numbers/:id

Permanent. The number goes back to the carrier and you can't get it back.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free
Important. Releasing stops the monthly fee. The current month isn't refunded. A test key does this for real.

Path parameters

idintegerRequired
The number id.

Errors

HTTPCodeWhen
404NUMBER_NOT_FOUNDNot on your account, or already released (in code).
409NUMBER_RESERVEDCan't be released right now.
502BILLING_CANCEL_FAILEDWe couldn't stop the monthly fee, so nothing was released.
500RELEASE_INCOMPLETEThe 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:

HTTPCodeMeaning
400invalid_sub_account_idNot a numeric account id (also 0 or empty).
400sub_account_header_not_supportedSent to a path outside /v1.
400sub_account_header_needs_api_keySent without an rsms_ API key.
403sub_account_forbiddenNot a client account you manage (including your own id).
403sub_account_suspendedThat client account is suspended.
404sub_account_not_foundNo account has that id.
503AUTH_CHECK_UNAVAILABLEWe couldn't check. Nothing ran; retry.
GET /v1/numbers as a client
curl "https://api.tryready.com/v1/numbers" \
  -H "X-API-Key: $READY_API_KEY" \
  -H "X-Sub-Account-Id: 4812"

#Create a client account

POST/v1/sub-accounts

Creates a client account with its own login, card, numbers and registration.

  • Auth API key with team management
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Body parameters

emailstringRequired
The client owner's email.
first_name, last_namestringOptional
company_namestringOptional
send_invitebooleanOptionalDefault true
Emails the client an invite to set up their login and card. Send false to skip the email and send them invite_link yourself.

Returns

dataobject
id (use it as X-Sub-Account-Id), email, company_name, account_kind: "client", client_rate, invite_link.

Any other field returns 400 unknown_field.

Errors

HTTPCodeWhen
400invalid_requestMissing email.
400unknown_fieldA field this endpoint doesn't accept.

#List client accounts

GET/v1/sub-accounts

The client accounts you manage, newest first. Pages with a cursor.

  • Auth API key with team management
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Query parameters

limitintegerOptionalDefault 50Max 200
Page size.
before_idintegerOptional
The next_before_id from 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 true whenever the page is full, even if nothing follows. Keep requesting until data is empty.
next_before_idinteger | null
Pass as before_id.

#Get a client account

GET/v1/sub-accounts/:id

One client account.

  • Auth API key with team management
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Path parameters

idintegerRequired
The client account id.

Errors

HTTPCodeWhen
404sub_account_not_foundNot a client account you manage.

#Set a client's markup

PUT/v1/sub-accounts/:id/rate

What the client pays you per segment on top of your own rate.

  • Auth API key with team management
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free
Note. Markup needs an approved reseller account, and the reseller terms accepted in your dashboard.

Path parameters

idintegerRequired
The client account id.

Body parameters

markupnumberOptional
Dollars per segment, 0 or more.
use_defaultbooleanOptional
true to use your default markup instead.
dialer_markup, use_default_dialernumber, booleanOptional
The same, for calls.

Returns

dataobject
markup, is_custom, base_rate (your rate) and client_rate (what the client pays per segment).

Errors

HTTPCodeWhen
400invalid_requestA negative markup.
400unknown_fieldA field this endpoint doesn't accept.
400terms_requiredAccept the reseller terms in your dashboard first.
400reseller_not_approvedFinish reseller setup in your dashboard first.
404sub_account_not_foundNot 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.

What it costs.
  • 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

GET/v1/registration/use-cases

Valid use cases, required fields and prices. Call this first.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Returns

data.use_casesstring[]
Valid campaign use_case values.
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_usdobject
brand.setup is the price with a new number. Without preferred_area_code you keep your current number and pay $30.

#Register a brand

POST/v1/registration/brand

Registers your business and creates its first campaign. Paid.

  • Auth API key with manage_brands
  • Idempotent No: a repeat within 2 minutes is refused
  • Rate limit Per IP, 1,200/min
  • Cost $30 or $35 setup, then $10/month
Note. Registering a brand also creates its first campaign (use case 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_name is an alias.
einstringRequired
Federal EIN (tax id), 9 digits: 12-3456789 or 123456789. reg_number is 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.
entity_typeenumCarriers need itDefault PRIVATE_PROFIT
  • PRIVATE_PROFIT
  • PUBLIC_PROFIT
  • NON_PROFIT
  • GOVERNMENT
industrystringCarriers need it
street, city, state, zipstringRequired
Business address. All four are needed (400 address_incomplete).
email, phone, first_name, last_namestringCarriers need it
A contact person. phone in E.164.
preferred_area_codestringOptional
3-digit area code. Buys a new number with the registration ($35 instead of $30).
number_choiceenumOptional
new (buy a number in preferred_area_code) or keep (use your current number). Defaults to new with an area code, otherwise keep. 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.
countrystringOptionalDefault US
phone_number_idintegerOptional
Which of your numbers to keep.
transfer_confirmbooleanOptional
Send true after a 409 needs_transfer_confirm to 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 (amount in 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 the payment_intent_id (the part of client_secret before _secret_). The registration_id comes back from that call.
data.zero_feeboolean
true when no fee is due (for example a negotiated $0): submitted immediately.

Errors

HTTPCodeWhen
400no codecompany_name is required, ein is required, or a bad preferred_area_code (no code; read error).
400invalid_einein isn't 9 digits (in code).
400address_incompletestreet, city, state or zip is missing (in code; error names them).
400business_description_requiredbusiness_description is missing (in code).
400preferred_area_code_requirednumber_choice: "new" without an area code (in error).
400consent_requiredconsent isn't the boolean true.
400ein_name_mismatchThe EIN doesn't match the name (in error, with title, message, steps).
409duplicate_brandYou already have this brand (existing_reg_id).
409needs_transfer_confirmThe number is on another brand. Resend with transfer_confirm: true.
409paid_not_filedPaid but not yet filed. Don't pay again; contact support.
409area_code_unavailableNo stock in preferred_area_code (alternatives).
429duplicate_submissionSame submission within 2 minutes. Don't retry.
202payment_processingThe payment is still processing.
502payment_unknownDon't submit again; poll GET /v1/registration.
503payments_unavailablePayments are unavailable. Nothing charged.

#Add a campaign

POST/v1/registration/campaign

Adds another campaign with a different use case. Your brand already has one. Only use_case is required; consent is given once, on the brand.

  • Auth API key with manage_brands
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost No setup fee; $20/month once approved

Body parameters

campaign_namestringOptionalDefault <business name> Campaign
use_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 as awaiting_brand.
campaign_descriptionstringOptional
Optional. Ready writes the carrier description from your use_case and business name.

Errors

HTTPCodeWhen
400no codeuse_case is missing (use_case is required in error).
400brand_required_firstNo brand yet. A brand still awaiting payment counts.
400invalid_use_caseUnknown use case (valid lists them).
409duplicate_use_caseYou already have a campaign with this use case (existing_reg_id, existing_campaign_name). Your brand's first campaign is MIXED.
429duplicate_submissionSame submission within 2 minutes.

#Confirm a payment

POST/v1/registration/confirm

After you confirm a client_secret with Stripe.js, this submits the registration right away. Safe to call twice.

  • Auth API key with manage_brands
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Body parameters

payment_intent_idstringRequired
The part of client_secret before _secret_.

Errors

HTTPCodeWhen
400no codepayment_intent_id is required.
400payment_not_completedThe payment didn't go through (status).
202payment_processingStill processing. Try again shortly.
404payment_not_foundNo such payment on your account.
503payment_check_unavailableWe couldn't check the payment. Retry.

#Get registration status

GET/v1/registration

Your most recent brand and campaign, and whether you can send at registered rates.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Returns

data.brand, data.campaignobject | null
Your most recent brand and campaign only. status, carrier_status (the carrier's own text, such as VERIFIED, VETTED_VERIFIED, UNVERIFIED, REGISTERED), approved_at; a rejected one adds rejection_reason and rejection_help.
statusstring
not_started, pending_payment, pending, awaiting_brand (a campaign waiting on its brand), approved or active, rejected, suspended, cancelled (also canceled), deregistered, expired, payment_declined. Treat any value you don't recognize as "not sending".
data.can_send_registeredboolean
true once your campaign is approved by the carriers. Registered pricing starts earlier, as soon as the paid registration is submitted.

#Get rejection details

GET/v1/registration/denied

The latest rejected registration, the carrier's reasons and a suggested fix.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Returns

data.deniedboolean
false when 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

POST/v1/registration/:id/resubmit

Resubmits a rejected registration, optionally with corrected fields.

  • Auth API key with manage_brands
  • Idempotent Yes, with Idempotency-Key
  • Rate limit Per IP, 1,200/min
  • Cost Free
Note. Send an 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, including ein: to change the EIN, contact support.

Errors

HTTPCodeWhen
400invalid_fieldfields isn't an object.
404not_rejectedThis registration isn't rejected.
500resubmit_unconfirmedDon't resubmit again yet; poll GET /v1/registration.
502no codeThe carrier refused it (error and code).

#Vet a brand

POST/v1/registration/:id/vet

External vetting scores your brand higher with the carriers, which raises your daily throughput. Paid, one time.

  • Auth API key with manage_brands
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost $79 Standard, $179 Enhanced, $100 upgrade

Path parameters

idintegerRequired
The brand's registration id.

Body parameters

vetting_typeenumOptionalDefault STANDARD
STANDARD or ENHANCED, 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_id returned here.

Errors

HTTPCodeWhen
400no codevetting_type must be STANDARD or ENHANCED.
409already_vettedThe same tier or a lower one again.

#Confirm the vetting payment

POST/v1/registration/:id/vet/confirm

Applies the vetting after a client_secret payment. Safe to call twice.

  • Auth API key with manage_brands
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Path parameters

idintegerRequired
The brand's registration id.

Body parameters

payment_intent_idstringRequired
From Vet a brand.

Errors

HTTPCodeWhen
400payment_intent_mismatchThe payment isn't for this vetting.
400no codeNot paid yet (or 202 while processing).
503could_not_retrieve_payment_intentWe couldn't check the payment. Don't pay again; retry this call.

#Get throughput

GET/v1/registration/throughput

Your live sending capacity: 24-hour volume and rejections, T-Mobile daily cap and tier, and warm-up state.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

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.throttledboolean
true at 50 or more rejections and 5% or more of the last 24 hours. Then daily_loss_usd estimates what they cost, and brand names the brand to vet (cta: vet, upgrade or maxed).
data.tmobileobject | null
daily_cap, tier, next_cap (the ladder is 2,000, 10,000, 40,000, 100,000, 200,000; null at the top), sent_today, pct, coverage, usage_reliable. Read sent_today only when usage_reliable is true. null until 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

HTTPCodeWhen
503throughput_unavailableCouldn't be read. Never a fake all-clear.

#Rename a registration

PUT/v1/registration/:id/name

A label for the Ready dashboard. Doesn't change anything with the carriers.

  • Auth API key with manage_brands
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

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

HTTPCodeWhen
400no codename is required.
404registration_not_foundNo registration with this id.

#Cancel a registration

DEL/v1/registration/:id

Cancels the registration and its monthly fee. The setup fee isn't refunded.

  • Auth Account owner's API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Path parameters

idintegerRequired
The registration id.

Errors

HTTPCodeWhen
400registration_approvedAn approved registration can't be cancelled here. Contact support.
403no codeOnly the account owner can cancel.
502subscription_cancel_failedWe 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_segment and carrier_fee_usd_per_segment come from GET /v1/account. Typical rates are 0.05 before your campaign is submitted and 0.02 after, or your negotiated rate.
  • compliance_per_segment is $0.005 for number validation (on by default), plus $0.005 if TCPA scrubbing is on. Read it from GET /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/balance returns it as mms_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

GET/v1/account

Your balance and the exact rates you're billed at, including any negotiated rate.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Returns

data.balance_usdnumber
Your balance, rounded to cents. Use balance_cents for 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

GET/billing/balance

Your balance, prices and usage, as the dashboard shows them.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free
Important. 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

GET/billing/transactions

Top-ups, credits, refunds and card charges one by one, and usage summed per day.

  • Auth API key with view_billing
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Query parameters

from, toYYYY-MM-DDOptional
UTC dates, inclusive. Without from you get the last 90 days. Any other format is ignored, not rejected: check summary.from and summary.to to confirm your filter applied.
limitintegerOptionalDefault 20Max 200
Page size. Negative or non-numeric values fall back to 20, but 0 returns an empty page.
offsetintegerOptionalDefault 0
Rows to skip.

Returns

data[]object[]
Rows: row_type (transaction, daily_usage or stripe_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 type for the direction (table below). Ignore amount: it's a legacy count, not money.
totalinteger
Rows in the range.
summaryobject
spend_usd (money charged to your card in the range), count, from, to.
Directiontype
Into your balancepurchase, auto_rebill, carrier_auto_rebill, carrier_deposit, bonus, credit, survey_completion_credit, onboarding_welcome_credit, admin_grant, card_verification_credit, refund
Out of your balancedaily_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 balancestripe_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

POST/sms/generate

Writes up to 5 draft texts from a short description.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost 5 × your per-segment rate (no carrier fee)
Important. Refunded if generation fails (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

HTTPCodeWhen
400no codedescription is missing or empty (Describe the message you want us to write.).
400unknown_fieldA field other than description.
402no codeInsufficient balance. Add funds in Billing. (no code).
500generation_failedRefunded. 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.

MCP server
https://api.tryready.com/mcp

Connecting

ClientHow
Claude.ai, ChatGPTAdd a custom connector with the URL above, then sign in to Ready.
Claude Codeclaude mcp add --transport http ready https://api.tryready.com/mcp --header "Authorization: Bearer $READY_API_KEY"
Cursor, and clients that accept url and headersThe 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-server and /.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.

MCP config (Cursor and similar)
{
  "mcpServers": {
    "ready": {
      "url": "https://api.tryready.com/mcp",
      "headers": {
        "Authorization": "Bearer rsms_your_key_here"
      }
    }
  }
}

#End an MCP session

DEL/mcp

Ends the session named by the Mcp-Session-Id header.

  • Auth Authorization: Bearer (API key or OAuth token)
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Headers

Mcp-Session-IdstringRequired
From the initialize response.

Errors

HTTPCodeWhen
404-32001The session is already gone (a JSON-RPC error body).
Tip. Prefer no code? The Integration Assistant can subscribe webhooks and send a test message for you.

#Legacy endpoints

Still supported. New integrations should use the endpoints above.

#Send a message (older path)

POST/sms/send

The same send as POST /v1/messages, with the text in message.

  • Auth API key
  • Idempotent Yes, with Idempotency-Key
  • Rate limit Paced to your carrier throughput; 6,000/min ceiling
  • Cost Same as a send

Body parameters

toE.164 stringRequired
Recipient. A 10-digit US number is treated as +1.
messagestringRequired
The text. Same length limits as body on /v1/messages.
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_id from a purchase).
from_strategyenumOptionalDefault local
local, even (or deliverability) or random. Ignored when from_phone_number_id is 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)

GET/sms/logs

The dashboard's message log. Use GET /v1/messages instead: rows here are raw and can change.

  • Auth API key
  • Idempotent No
  • Rate limit Per IP, 1,200/min
  • Cost Free

Query parameters

limitintegerOptionalDefault 50Max 1000
Page size.
offsetintegerOptionalDefault 0
Rows to skip.
directionenumOptional
inbound or outbound. Anything else returns no rows, with no error.
statusstringOptional
Internal values: sent, pending, delivered, failed, received. queued matches nothing here.

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 the message_id a send returned.
hasMoreboolean
camelCase here. total is always null.