To send SMS with Python, make one requests.post call to an SMS API with your API key in a header and a JSON body holding the recipient's number, the message text and a consent flag. On Ready, that call is POST https://api.tryready.com/v1/messages, and it should carry an Idempotency-Key header and a timeout so a retry can never send the same text twice.

The code takes about 20 lines. What decides whether the text actually reaches a phone is everything around it: whether your sender is registered for A2P 10DLC, whether the recipient agreed to hear from you, and whether your retry logic double-sends when the network hiccups. This post covers all three, with code that uses only the documented request shapes. Where you need a response field name, we point you to the docs instead of guessing.

What you need before writing any code

Have these ready before the first call:

  • A Ready account with a card on file. Sending requires one. Sign up at tryready.com/signup.
  • An API key. Store it in an environment variable, not in your repo. We use READY_API_KEY below.
  • A number on the account. You can list yours with GET /v1/numbers or add one with POST /v1/numbers.
  • Python 3.8+ and requests. Run pip install requests. Nothing else is needed.
  • A recipient who opted in. This is a legal requirement and a deliverability requirement, not a formality. More on it below.

Your first 20 texts are free, as long as they are one-to-one sends. A campaign is paid from the first text. Use those free texts to test your integration before you register, because they end the moment a registration is paid for.

The full script to send SMS with Python

This is the complete call. It sends one text, sets a connect timeout and a read timeout, and attaches an idempotency key.

import os
import uuid
from typing import Optional
import requests

API_URL = "https://api.tryready.com/v1/messages"
API_KEY = os.environ["READY_API_KEY"]


def send_sms(to: str, body: str, idempotency_key: Optional[str] = None) -> requests.Response:
    key = idempotency_key or str(uuid.uuid4())
    response = requests.post(
        API_URL,
        headers={
            "X-API-Key": API_KEY,
            "Idempotency-Key": key,
        },
        json={
            "to": to,
            "body": body,
            "consent_attested": True,
        },
        timeout=(5, 15),  # 5s to connect, 15s to read
    )
    return response


if __name__ == "__main__":
    resp = send_sms("+12125550199", "Your order #1042 has shipped.")
    print(resp.status_code)
    print(resp.json())

What each piece does

  • X-API-Key authenticates the request. Send your key in the X-API-Key header. The docs use it in every example.
  • Idempotency-Key is any UUID. If Ready sees the same key again, it won't send a second text. This is your protection against duplicate sends.
  • to is the recipient in E.164 format (+12125550199). A bare 10-digit US number is treated as +1, but normalizing to E.164 in your own code avoids surprises when international numbers show up later.
  • body is the message text.
  • consent_attested is your promise that this recipient agreed to receive texts from you. Don't hard-code it to True without thinking about it. Wire it to a real consent record in your database.
  • timeout=(5, 15) matters more than it looks. Without a timeout, requests waits forever on a stalled connection, and a stuck worker in a queue is worse than a failed one.

Optional fields

Two optional fields cover most real-world needs:

  • from: pick one of your numbers explicitly.
  • media_url: attach a picture, which sends as MMS.
  • Leave the number out and Ready picks it: the number this contact already texts with, otherwise your number closest to their area code. Local presence has a listed price of $0.0015 for each segment of a delivered text.
json={
    "to": "+12125550199",
    "body": "Your appointment is tomorrow at 10 AM. Reply C to confirm.",
    "consent_attested": True,
}

Reading the response

Branch on the HTTP status code first and read the JSON body second:

resp = send_sms("+12125550199", "Your order #1042 has shipped.")

if resp.ok:
    data = resp.json()
    # Store the message identifier from `data` alongside your own record.
    print("Accepted:", data)
elif 400 <= resp.status_code < 500:
    # Your request is wrong (bad number, bad key, missing field). Don't retry blindly.
    print("Client error:", resp.status_code, resp.text)
else:
    # 500 send_failed or a 503: not sent, not charged. Send it again with a NEW key,
    # because the same key replays this error. A 504 or a code ending in
    # OUTCOME_UNKNOWN may have gone out: check GET /v1/messages before you resend.
    print("Server error:", resp.status_code, resp.text)

We're deliberately not showing response field names here. The exact shape is documented at docs.tryready.com/api/messages, and you should code against that page rather than against a blog post.

Some behaviors look like failures at first but aren't:

  • Opt-outs, numbers on your Ban list and known landlines are refused up front and not charged. A refusal for a number that texted STOP last month is the system working correctly. Don't retry it.
  • Texts sent during the recipient's quiet hours are held and sent later, if sending opens within about 6 hours. You're charged only when the text actually goes out. If you send at 4 AM Pacific to a California number, an accepted response doesn't mean the phone buzzed at 4 AM. At 11 PM Pacific, sending opens 9 hours away, so the API refuses the text instead of holding it; send it again once sending opens.

To check a number's opt-out status before sending, call GET /v1/opt-outs/:phone. GET /v1/opt-outs lists the opted-out contacts on this account, but it leaves out a STOP from a deleted contact, a dialer do-not-call entry and a STOP held on another account of the same family, so the single-number check is the one to trust before a send. Our post on handling SMS opt-outs covers the keyword rules and the mistakes that cause trouble.

Safe retries: one key per message, not per attempt

The most common bug in SMS integrations is generating a new UUID inside the retry loop. Say the request times out after Ready already accepted it. Your code retries with a fresh key, and the customer gets two texts. Send to 10,000 people and you've doubled your bill and probably your opt-out count.

The fix is to create the key once per logical message and reuse it on every attempt:

import time
import uuid
import requests


def send_with_retries(to: str, body: str, key: str, attempts: int = 4) -> requests.Response:
    last_error = None
    for attempt in range(attempts):
        try:
            resp = send_sms(to, body, idempotency_key=key)
            # Still running under this key, or rate limited (a 429 is never stored): wait, then retry with the same key.
            # Other answers are stored under the key, so the same key only replays them.
            if resp.status_code == 429:
                last_error = "HTTP 429 rate limited"
            elif resp.status_code == 409 and resp.json().get("code") == "idempotency_in_progress":
                last_error = "HTTP 409 idempotency_in_progress"
            else:
                return resp
        except (requests.Timeout, requests.ConnectionError) as exc:
            last_error = repr(exc)

        if attempt < attempts - 1:
            time.sleep(2 ** (attempt + 1))  # 2s, 4s, 8s

    raise RuntimeError(f"Send failed after {attempts} attempts: {last_error}")


# Generate the key when you create the message record, and persist it.
message_key = str(uuid.uuid4())
send_with_retries("+12125550199", "Your order #1042 has shipped.", key=message_key)

Two rules make this safe in production:

  1. Persist the key with the message row. If your worker crashes and a different worker picks up the job, it should read the stored key, not mint a new one. The idempotency guarantee only holds if the key survives a restart.
  2. Retry timeouts, connection errors and a 409 idempotency_in_progress with the same key. Every other answer is stored under the key, so the same key only replays it. A 500 send_failed or a 503 was not sent and needs a new key; a 4xx means the request itself is wrong, and sending it again won't fix it.

Our SMS API integration walk-through goes further into queueing, webhooks and failure handling if you're building this into a larger system.

Sending the same text to many numbers

Looping send_sms over a list works, but when every recipient gets the same body, use the bulk endpoint instead:

batch_key = str(uuid.uuid4())  # create once per batch and store it; reuse it on a retry

resp = requests.post(
    "https://api.tryready.com/v1/messages/bulk",
    headers={"X-API-Key": API_KEY, "Idempotency-Key": batch_key},
    json={
        "to": ["+12125550199", "+15559876543"],
        "body": "Doors open at 9 AM Saturday. Reply STOP to opt out.",
        "consent_attested": True,
    },
    timeout=(5, 30),
)

Details are at docs.tryready.com/api/bulk-messages.

Keep throughput in mind. Carriers meter sends per campaign, and a fresh standard 10DLC campaign with no vetting usually gets roughly 1 to 4 segments per second. Higher use-case tiers get tens. At an assumed 2 segments per second, 5,000 single-segment texts take around 42 minutes to drain. Plan your send windows around that instead of expecting a list to go out in seconds.

Registration and consent: what decides whether texts deliver

Your Python code can be perfect and your texts can still get filtered. US carriers filter unregistered application-to-person traffic, so registration is the step that matters most.

Registering for A2P 10DLC

On Ready, an account can send up to 500 texts a month before it registers, at its normal per-text rate. After that, it has to register. Brand and campaign registration happen in-app, and you can check status from code with GET /v1/registration.

The costs are:

ItemCost
Charge at submit (first month)$25
Registration, monthly$25
Each number, monthly$2
Setup feeNone
Optional brand vetting$79 Standard / $179 Enhanced, one-time

Most approvals take 2 to 7 days. If you already bought a number before registering, it sends on Ready's shared registration at the same per-text rate while your own registration is in review, so you're not stuck waiting. Brand vetting is only worth it for higher-volume senders. Standard registration is all most teams need. For the step-by-step timeline, see how long 10DLC approval takes.

Consent is yours to record

When you set consent_attested: true, Ready records your attestation for that send: who sent it, to how many people, and when. That gives you an audit trail of your promises. It is not a per-contact consent record. Ready doesn't store the time a recipient opted in, the wording they agreed to, or the page they signed up on, and a keyword reply doesn't create one either. Your application has to keep that, ideally in the same database row your consent_attested value is read from. (A contact who signs up on a Ready-hosted opt-in page is the exception: Ready stores that sign-up, with the time, IP address and exact wording, and support@tryready.com can send you a copy.)

Text people who opted in or replied to you. Don't run cold lists through this code. Cold outreach belongs on phone and mail. If you want an extra layer, Ready can screen known TCPA-litigator and DNC numbers. The standalone scrub costs $0.005 per contact (GET /v1/scrub/:phone), and number validation is available at GET /v1/validate/:phone. Treat both as risk reduction, not immunity. Compliance stays the sender's responsibility.

What this costs, worked out

Ready charges $0.0075/segment plus a $0.0045 carrier fee, one flat Standard rate. Replies you receive are billed the same way. Segments are what get counted:

  • 160 GSM-7 characters fit in one segment. Longer messages split into 153-character parts.
  • One emoji drops the limit to 70 characters (67 per part).
  • Ready swaps curly quotes, long dashes and ellipses for plain ones before sending, so those never inflate your count.

Example assumptions: 1,000 order-shipped texts, each 120 plain characters, so 1 segment each. The cost is 1,000 × $0.0075, which comes to $20 in segment fees plus 1,000 × $0.0045 in carrier fees. Add one emoji and stretch the text to 140 characters, and each message becomes 3 segments, which triples the bill.

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

To be straight about price: Twilio's raw rate of about $0.0083/segment is lower per segment, and so is GoHighLevel's LeadConnector. What you get with Ready is in-app 10DLC registration, built-in opt-out and quiet-hours handling, and a full platform with an inbox around the API. Our Twilio comparison lays out where each one wins. Current rates are on the pricing page.

The short version

Use one requests.post call to /v1/messages with X-API-Key, an Idempotency-Key that you generate once per message and persist, and a timeout. Retry timeouts and connection errors with the same key; a 500 send_failed or a 503 was not sent and needs a new key. Register before you pass 500 texts a month, keep your own per-contact consent records, and count segments before you add emoji. The quickstart gets you a key and a first send in a few minutes.

FAQ

How do I send a text message using Python?

Make a requests.post call to an SMS API with your API key in a header and a JSON body containing the recipient number, the message and a consent flag. On Ready, that's POST https://api.tryready.com/v1/messages with to, body and consent_attested. Add an Idempotency-Key header and a timeout so retries are safe.

Is there a free way to send SMS with Python?

Ready's first 20 texts are free if they're one-to-one sends, which is enough to test an integration. After that, each segment is billed at $0.0075 plus the carrier fee. To send, you add a card and get a number, which is $2 a month with the first month charged when you get it.

Why are my SMS messages from Python not being delivered?

The usual cause is an unregistered sender, since US carriers filter unregistered A2P 10DLC traffic. Other causes are an opted-out recipient, a landline number, or a text held for the recipient's quiet hours. Check your status with GET /v1/registration and check the recipient with GET /v1/opt-outs/:phone. Campaigns, drip campaigns and automations wait up to 24 hours for the window; a one-to-one or API text is held only if the window opens within 6 hours, otherwise it is refused with 409 QUIET_HOURS_BLOCK and must be resent; a one-to-one reply to someone who texted you in the last 24 hours goes out at any hour.

How do I stop my Python script from sending duplicate texts?

Send an Idempotency-Key header and reuse the same key on every retry of the same message. Generate the key when you create the message record and store it, so a crashed worker that restarts uses it again. A new key per attempt is what causes duplicates.

Do I need 10DLC registration to send SMS from code?

Yes, for any real volume in the US. On Ready, you can send up to 500 texts a month before registering, and after that registration is required. Most approvals take 2 to 7 days.