Opt-outs and scrubbing
Ready blocks opted-out numbers for you, so you don’t need to check before every send: a send to one is refused with a 403. The error_code is opt_out_locked for a STOP or an opted-out contact, dnd_sms for a contact marked Do Not Disturb for texts, and dnc_list for a number on your ban list. reason says why: opt_out_locked, status_opted_out, prior_opt_out, dnd_sms, dialer_dnc or tcpa_scrub. How the contact opted out (for example sms_stop_keyword) is the contact’s opt_out_source, not the reason. A refused send isn’t charged and shows in GET /v1/messages as a failed row with cost_usd: 0. Opt-out keywords and START are in Compliance, litigator scrubbing in DNC and scrubbing, and the suppression fields every contact carries are below.
Moving from another provider? Don’t text your old opt-out list one contact at a time. Import your contact file with Contacts → Import, mapping a column to Opted out. Those rows land opted out, and a later import never clears them. Steps: Switching from another tool.
Suppression fields on a contact
Flags are integers, 0 or 1, not booleans.
| Field | Meaning | Blocks texts? |
|---|---|---|
opt_out_locked | Set by a STOP or a TCPA lock. A STOP lock is cleared only by the contact’s START; a TCPA lock is not cleared by START. | Yes |
status | opted_out when opted out. | Yes, when opted_out |
dnd_sms | Do not text. | Yes |
dnd_email, dnd_calls | Do not email, do not call. | No |
opt_out_source |
sms_stop_keyword (the person texted STOP), profanity_detected (a reply had profanity in it), tcpa_scrub (the number matched a TCPA list), ghl_dnd (marked Do Not Disturb in GoHighLevel), ghl_import (opted out in a GoHighLevel import; older contacts only), import (opted out in your import file), ai_intent (Ready read a reply as asking to stop), intent_scan and retroactive_intent_sweep (Ready reread older replies and found a stop request; the second is on older contacts only), cross_family_stop_propagation (the person texted STOP to another account in your agency), harassment_complaint and manual_abuse_threat (older contacts only, no longer written), marked_spam, assistant (opted out by Ask AI at your request), data_feedback_deceased (a reply said the person has died), or null for a manual DND. |
|
opt_out_at | When they opted out (ISO 8601). This is the real opt-out time. | |
is_litigator | Matched a TCPA litigator list. | Yes, through the lock |
tcpa_status | The matched lists, comma-separated, or null. | |
sms_invalid | The number can’t receive texts. | Depends on an account setting |
Scrubbing starts off for new accounts. When it’s on in Settings, Ready scrubs contacts for TCPA litigators when they’re created or imported and before sends, at your scrub rate per segment when it’s on (your rate is scrub_per_segment_usd in Get scrub categories). A match sets opt_out_locked=1, status=opted_out, dnd_sms, dnd_email and dnd_calls to 1 and is_litigator=1, sets opt_out_source="tcpa_scrub" unless the contact already had a source of its own, and records the matched lists in tcpa_status. START doesn’t lift it.
GET /v1/opt-outs?limit=500 until has_more is false, listen for contact_opted_out (a STOP or other opt-out keyword fires it, and so does an opt-out Ready finds by reading a reply, or one you ask Ask AI to make; your own dnd_sms updates, TCPA scrubbing and CRM sync don’t), and walk the list again on a schedule. Deleting a contact removes it from List opt-outs, but Ready keeps its opt-out: Check one number still answers opted_out: true (with since: null) and sends to the number stay refused. Numbers on your ban list with no opted-out contact are not in the list either, unless you add scope=all. Check your copy before you add a number back.#The Opt-out object
Check one number returns this object. List opt-outs returns phone and since for each opted-out contact.
phoneE.164 string- The number.
opted_outbooleantrueif the number replied STOP, was marked opted out, is Do Not Disturb for texts, or is on your ban list (where a deleted contact’s opt-out is kept).Opting someone out yourself
To record an opt-out from a call, an email or a web form, send
PUT /contacts/:idwith{"dnd_sms": 1}. This can’t be undone over the API: trying answers403withcodeCONTACT_OPTED_OUT(error_codeopted_out), orCONTACT_OPT_OUT_LOCKED(error_codeopt_out_locked) if the contact also replied STOP. ASTARTfrom the contact lifts only their own STOP, never an opt-out you set, so treat yours as permanent.sinceISO 8601 string | null- When the number opted out (the contact’s
opt_out_at).nullwhen not opted out, or when Ready has no opt-out time.
#Check one number
Whether a number is opted out on your account, including an opt-out on another account in your agency. Returns an Opt-out object in data.
Parameters
phonestring, pathRequired- US or Canada (+1) only, E.164 or 10 digits.
More
URL-encode the+as%2Bif your client needs it. Other countries aren’t supported: the check can match a US number with the same last 10 digits.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_number | Not a phone number, or fewer than 10 digits. Send E.164, like +15551234567. Nothing is answered for a number this short. |
| 500 | internal_error | Check failed. Don’t text the number; retry. |
#List opt-outs
Your account’s opted-out contacts, newest contact first. Narrower than Check one number: a deleted contact’s opt-out, your ban list and opt-outs on other accounts in your agency are not in it. Returns a list of Opt-out objects in data, each with phone and since, sorted by contact, not by opt-out date. Needs the view_contacts permission.
Parameters
limitinteger, queryDefault100Max500- Page size.
before_idinteger, query- The
next_before_idfrom the previous page.offsetisn’t supported and is ignored. scopestring, queryalllists every number Ready won’t text on your account: your opted-out contacts first (source: "contact"), then your whole ban list (source: "ban_list"), which keeps a deleted contact’s STOP and every do not call number. A number on both is listed once for each. Opt-outs on other accounts in your agency still need Check one number. Page withcursor, notbefore_id.cursorstring, query- With
scope=all: thenext_cursorfrom the previous page.
Returns
has_moreboolean- Loop until it’s
false. Don’t loop onnext_before_id: it’s set on the last page too. next_before_idinteger | null- Pass as
before_id.nullwhen the page is empty. next_cursorstring | null- With
scope=all, in place ofnext_before_id: pass ascursor.nullon the last page.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_before_id | before_id isn’t a whole number. |
| 400 | invalid_scope | scope is set to anything but all. |
| 400 | invalid_cursor | cursor isn’t a next_cursor this list gave. |
| 500 | internal_error | Read failed. Don’t treat the list as empty. |
#Export contacts as CSV
Every contact as a CSV, or with compliance only the opted-out ones, for audits and list hygiene.
Parameters
complianceenum, queryoptoutordncgives the opt-out file below. Any other value is refused with 400. Leave it out to get every contact instead, ascontacts.csvwith one column per custom field.Possible values
optoutyour opted-out contacts: replied STOP, DND for texts, or locked by TCPA scrubbing.opt_out_sourcesays which. Your ban list, a deleted contact’s opt-out and opt-outs on other accounts in your agency are not in it, though Ready won’t text them either; Check one number gives Ready’s full answer. Contacts marked spam are left out unlessinclude_spam=1. Fileopted-out-contacts.csv.dncdo not contact on any channel: the above plus email-only and call-only DND, litigators and invalid numbers, so not everyone in it is blocked from texts. Not the federal Do-Not-Call registry. Filednc-list.csv.
include_spaminteger, query- 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 id.
Errors
| HTTP | Code | When |
|---|---|---|
| 403 | EXPORT_DISABLED | A teammate, on an account where only the owner can export contacts. |
| 429 | rate_limited | More than 6 exports in an hour per account, shared with the dashboard (in code). The RateLimit-Remaining header shows what’s left. |
#Scrub numbers
Checks up to 1,000 US numbers against the TCPA litigator and DNC complainer lists, plus the federal and state Do Not Call lists if your account screens them. Each valid, unique number is billed at your scrub rate. Needs the edit_contacts permission.
scrub_rate_usd in Get scrub categories), charged even when scrubbing on sends is off. On an account set to never re-scrub, a number it already paid to scrub is free. Otherwise every scrub is charged. Invalid numbers aren’t charged.Parameters
Idempotency-Keystring, headerRecommended- Retry safely without paying twice (Idempotency).
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.More
Litigators and DNC complainers are always checked.federal_dncorstate_dncisnullwhen your account doesn’t screen that list. On a problem,error_codeanderror:invalid_number(not charged),scrub_failed,scrub_unavailable(not checked, not charged), orinsufficient_balance(your balance ran out during the call, often from another call at the same time; not checked, not charged).
It creates no contacts. A number that’s already one of your contacts gets the new result written onto it.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_field | phones is missing, empty or not a list (the code is in code and error_code). |
| 400 | too_many_numbers | More than 1,000 numbers. |
| 402 | insufficient_balance | The batch could cost more than your balance (required_cents, balance_cents). Nothing was scrubbed or charged. A sub-account that pays with its own card and has not added one gets client_card_required instead: add a card in Billing. |
| 503 | tcpa_not_configured | Scrubbing isn’t available right now. |
#Scrub one number
The same check for one number. This GET is billed at your scrub rate, like one number in Scrub numbers. Needs the edit_contacts permission.
Parameters
Idempotency-Keystring, headerRecommended- Retry safely without paying twice (Idempotency).
phonestring, pathRequired- A US number, E.164 or 10 digits.
Returns
dataobject- One result, shaped like an item of
results[]in Scrub numbers.
Errors
| HTTP | Code | When |
|---|---|---|
| 402 | insufficient_balance | Balance too low. A sub-account that pays with its own card and has not added one gets client_card_required instead: add a card in Billing. |
| 403 | READ_ONLY_SESSION | Your access is view only. This lookup is charged, so it counts as a change. |
| 503 | tcpa_not_configured | Scrubbing isn’t available right now. |
#Get scrub categories
Which lists your account screens contacts against, and your scrub rates. Fields are at the top level of data.
Only the account owner or an admin can change these lists, in the app under Settings → Compliance.
Returns
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.tcpaanddnc_complainersare on by default,federal_dncandstate_dncoff by default.required_phrasestringI UNDERSTAND: the phrase the owner must type to turn offtcpaordnc_complainers.scrub_rate_usdnumber- Your per-number rate for Scrub numbers.
0on a free account. scrub_per_segment_usdnumber- What scrubbing adds per segment on sends.
0when scrubbing is off. The response has a few more fields used by the dashboard (can_changeand billing rates).
Errors
| HTTP | Code | When |
|---|---|---|
| 500 | internal_error | Read failed (the code is in error). |