Skip to main content

joinsimplesms (Python)

Official Python client for the SimpleSMS API: SMS, phone verification, and phone numbers. No dependencies, Python 3.8+.

pip install joinsimplesms

Get a free key (no card) at the console.

Send an SMS

from joinsimplesms import SimpleSMS

client = SimpleSMS()  # reads SIMPLESMS_API_KEY

message = client.messages.send(to="+15005550006", text="Hello from SimpleSMS")
print(message["id"])  # msg_...

from_ is optional while your account has one number (a sandbox account does): the client looks it up once and reuses it. With several numbers, pass from_="+1..." or set SIMPLESMS_FROM. text and body are the same thing.

Retries are safe. An Idempotency-Key is generated for every send, so a network blip can never double-send. Pass your own to control it:

client.messages.send(to=to, body=body, idempotency_key=f"order:{order_id}")

Phone verification

client.verify.send(to="+14155550132")
result = client.verify.check(to="+14155550132", code=code)
if result["verified"]:
    ...

You don't store codes or own a number, and you're billed only when verified comes back true.

By email instead, or when the text doesn't arrive:

client.verify.send("ada@example.com", channel="email")

# Same verification, same single charge; the emailed code replaces the texted one.
client.verify.fallback(v["id"], email="ada@example.com")
# The emailed code proves the address, not the phone: check it by email.
client.verify.check(to="ada@example.com", code=code)

Everything else

client.messages.get("msg_...")
client.messages.list(limit=25)                       # one page
for m in client.messages.list_all():                 # every page, lazily
    ...
client.messages.schedule(to, "Reminder", send_at=datetime(...))

client.numbers.available(area_code="415")
client.numbers.buy("+14155550132")
client.numbers.list()
client.numbers.release("+14155550132")

client.lookup.phone("+14155550132")                  # carrier, line type

client.events.list(limit=25)
for e in client.events.list_all(type="message.delivered"):
    ...

# Automations: send events from your app; a flow sends, waits, and branches.
# The first call for a user links user_id to their phone number.
client.events.track("trial_started", user_id="123", phone="+14155550132")
client.events.track("subscription_created", user_id="123")
flow = client.automations.create(
    name="Trial reminder",
    from_="+15005550100",
    trigger={"event": "trial_started"},
    steps=[
        {"type": "wait", "seconds": 3600},
        {"type": "send_sms", "body": "Your trial is live. Reply STOP to opt out."},
    ],
)
client.automations.activate(flow["id"])
client.automations.runs(flow["id"])                  # each run, with its timeline

client.consent.get("+14155550132")                   # status + history
client.consent.opt_out("+14155550132", note="CRM sync")
client.consent.opt_in("+14155550132")
# For one registration (what marketing from its numbers looks for); proof is
# required when the registration is marketing or mixed.
client.consent.opt_in(
    "+14155550132",
    campaign_id="reg_a1B2c3D4e5F6",
    proof={"source": "web_form", "collected_at": "2026-10-01T15:04:05Z", "page_url": "https://acme.example/texts"},
)
client.consent.import_(["+14155550132", "+14155550133"])
csv_text = client.consent.export()

Recurring schedules

One message to one number on a repeating local time. Each occurrence is a normal send: opt-outs, quota and your spend limit are checked when it fires.

schedule = client.schedules.create(
    to="+14155550132",
    body="Good morning. You have got this.",
    repeat={"frequency": "daily", "time": "08:00", "timezone": "America/New_York"},
)
schedule["next_run_at"]                      # the next 08:00 in New York, in UTC
client.schedules.pause(schedule["id"])       # resume(id) starts again from now
client.messages.list(schedule_id=schedule["id"])
client.schedules.delete(schedule["id"])

frequency is daily, weekly (with days_of_week=["mon", "fri"] inside repeat) or monthly (with day_of_month). The time is a wall-clock time and stays put across daylight saving.

Contacts

One contact per phone number. Tags double as broadcast audiences.

client.contacts.upsert("+14155550132", name="Ada", tags=["vip"])

# Sync from your own database, up to 500 per call.
client.contacts.import_([{"phone_number": u.phone, "name": u.name} for u in users])

for c in client.contacts.list_all(tag="vip"):
    print(c["phone_number"])

Customers (for platforms)

If you send for other businesses, label the traffic so usage, messages, and webhooks come back per customer. Entirely optional.

acme = client.customers.create(name="Acme Dental", external_id="acct_42")
client.numbers.buy("+14155550132", customer_id=acme["id"])  # or numbers.update(...)

client.messages.send(to=to, text="Your cleaning is tomorrow", from_="+14155550132")
# -> message["customer_id"] == acme["id"], inherited from the number

client.messages.list(customer_id=acme["id"])
client.customers.usage(acme["id"], start="2026-10-01", end="2026-10-31")

Registrations (business texting)

A US local number texts the public only once its registration is approved. The first submit for a business carries an attestation signed by someone authorized to act for it; we verify the business, then file. Every submit carries fees_acknowledged=True: carrier registration fees are billed at cost, a campaign carries a 3-month minimum, and they are non-refundable even if the registration is rejected.

reg = client.registrations.create(
    business_name="Acme Plumbing LLC",
    website="https://acmeplumbing.com",
    use_case="customer_care",
    opt_in_url="https://acmeplumbing.com/text-updates",
    sample_messages=["Acme Plumbing: Hi Sam, your plumber Dana arrives tomorrow between 9 and 11 AM. Reply STOP to opt out.",
                     "Acme Plumbing: Your job #4821 is done. Reply here with any questions."],
    brand={"legal_name": "Acme Plumbing LLC", "ein": "12-3456789", "entity_type": "PRIVATE_PROFIT",
           "street": "12 Main St", "city": "Springfield", "state": "IL", "postal_code": "62701", "vertical": "CONSTRUCTION",
           "contact_name": "Dana Smith", "contact_title": "Owner", "contact_email": "dana@acmeplumbing.com", "contact_phone": "217-555-0100"},
)
if reg["status"] == "ready":
    client.registrations.submit(reg["id"], attestation={"signer_name": "Dana Smith", "signer_title": "Owner", "accepted": True}, fees_acknowledged=True)

Webhooks

from joinsimplesms import verify_webhook, SimpleSMSError

@app.post("/webhooks/simplesms")
def hook(request):
    try:
        event = verify_webhook(
            request.body,                          # raw bytes, not parsed JSON
            request.headers["simplesms-signature"],
            os.environ["SIMPLESMS_WEBHOOK_SECRET"],
        )
    except SimpleSMSError:
        return 400
    ...

Signatures older than 5 minutes are rejected (tolerance=300) so a captured request can't be replayed. client.webhooks.verify(...) is the same function.

The signature is also sent as dsms-signature and resms-signature (the header names from before the rename). Pass request.headers itself as the second argument and the client reads whichever one arrived.

Endpoints can be managed from code too:

endpoint = client.webhooks.create("https://example.com/webhooks/simplesms", events=["message.received"])
endpoint["secret"]  # whsec_... returned once, here. Store it.

client.webhooks.list()
client.webhooks.update(endpoint["id"], active=False)  # pause
client.webhooks.delete(endpoint["id"])

To receive webhooks on localhost without a tunnel, use the CLI that ships with the Node package: npx joinsimplesms login, then npx joinsimplesms listen --forward-to http://localhost:8000/webhooks. It prints a signing secret; verify_webhook accepts its requests unchanged.

Errors

Every failure raises SimpleSMSError:

from joinsimplesms import SimpleSMSError

try:
    client.verify.send(to=phone)
except SimpleSMSError as err:
    err.code         # "verification_blocked", "rate_limited", ...
    err.status       # HTTP status (0 when the API was unreachable)
    err.param        # which field was wrong, when known
    err.retry_after  # seconds, on rate limits
    err.retryable    # whether trying again could work

Codes you will want to handle:

Code Status Meaning
insufficient_credits 402 Your prepaid balance cannot cover the call. Nothing was sent or charged. err.balance_micro_usd, err.required_micro_usd and err.top_up_url say how short you are and where to add credits. Not retried: top up, then send again.
spend_limit_reached 403 Your own monthly spend limit is reached.
recipient_opted_out 403 The recipient unsubscribed. Do not retry.
sender_not_registered 403 The sending number is not registered yet; it can text your verified numbers only.
quiet_hours 403 A marketing message outside 8:00am to 9:00pm in the recipient's local time. err.send_after is the next allowed time; err.recipient_local_time and err.timezone say what we read. Send it then, or pass quiet_hours="defer" to have it held. Mark marketing sends with category="marketing".
quota_exceeded 429 A free-tier or daily limit was reached.
rate_limited 429 Slow down; see err.retry_after.
client.balance.get()["balance_usd"]  # prepaid credit balance

429s, 5xx, timeouts, and connection errors are retried with exponential backoff, honoring Retry-After. Two things are deliberately not retried: a POST without an idempotency key (a verification send, a number purchase), and a verification cooldown, which is policy, not a blip.

Testing

Test keys (ssms_sk_test_...) cost nothing and simulate: the message reaches delivered with "simulated": True and no text is sent, except to your own verified phone. client.is_test_mode tells you which you have. Magic numbers and codes are in the sandbox docs.

Options

SimpleSMS(
    api_key,                      # default: SIMPLESMS_API_KEY
    default_from="+14155550100",  # default: SIMPLESMS_FROM, else your only number
    base_url="https://api.joinsimplesms.com",
    max_retries=2,
    timeout=30.0,
    transport=my_transport,       # (method, url, headers, body, timeout) -> Response
)

Renamed from Delivered

SimpleSMS was called Delivered until October 2026. Delivered and DeliveredError are still exported as aliases of SimpleSMS and SimpleSMSError; DELIVERED_API_KEY, DELIVERED_BASE_URL and DELIVERED_FROM are still read when the SIMPLESMS_* names are unset; dsms_sk_... keys still authenticate. The old host api.deliveredsms.com is retired (410); this SDK defaults to api.joinsimplesms.com.

Why no dependencies

The client uses only the standard library (urllib, json, hmac). The API is plain JSON over HTTPS and every call is synchronous, so a third-party HTTP stack would add install weight and version conflicts (the classic requests/urllib3 pin fight) without adding anything you'd notice. It installs in any environment, including Lambda layers and locked-down CI. If you want connection pooling, proxies, or tracing, pass a transport.

MIT © Truelabel Inc

Metadata

Release files for joinsimplesms 2.8.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for joinsimplesms 2.8.0
File Size Uploaded Provenance Actions
joinsimplesms-2.8.0.tar.gz 29.4 kB Without provenance Details for joinsimplesms-2.8.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for joinsimplesms 2.8.0
File Interpreter ABI Platform Actions
joinsimplesms-2.8.0-py3-none-any.whl Python 3 none any Details for joinsimplesms-2.8.0-py3-none-any.whl

Total release size: 56.1 kB

Release files / joinsimplesms-2.8.0.tar.gz

Download URL joinsimplesms-2.8.0.tar.gz
Size 29.4 kB
Tags Source
SHA-256 checksum
How to use checksums
07d34744034a5928798cf696e6651b550f05fc0d0c0142c100cf6771a9add0d3
BLAKE2b-256 checksum
How to use checksums
6f38f6bbe935a7ec24ae399d0fc61cab817fe366e06d17bfc95f52e5045d589c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / joinsimplesms-2.8.0-py3-none-any.whl

Download URL joinsimplesms-2.8.0-py3-none-any.whl
Size 26.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ff51165fcb972a07b038a62ebb871416ecb3e3489254dcc61e0205c167eaa79d
BLAKE2b-256 checksum
How to use checksums
c4d5063946a21be21fb578f4c9a0b99e30e8a2354252c3b23cc5d6766e1fd9cf
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Security for joinsimplesms 2.8.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

2.8.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page