Skip to main content

opensms (Python)

Official Python client for opensms: prepaid SMS for Africa.

Pure standard library: no runtime dependencies. Python 3.8+. Fully typed (py.typed). See the monorepo README for the other SDKs and spec/SURFACE.md for the full API surface.

Install

pip install opensms

Not yet published to PyPI: until it is, install from this repo with pip install ./packages/python (run from the sdks directory), or pip install -e ./packages/python for an editable install.

Usage

from opensms import Opensms

client = Opensms(api_key="sk_test_...")

message = client.messages.send(to="+254712345678", text="Your order has shipped")
print(message["id"], message["status"], message["price"])   # price is a decimal string

A key starting sk_test_ selects the sandbox, sk_live_ the live environment; client.environment reports which one is active, and a malformed key raises ValueError before any request is made. Other constructor options: base_url (default https://api.opensms.io), timeout (seconds per attempt, default 30), max_retries (default 2), and transport (swap the network layer for tests).

More

One short example per resource, in the order they're listed on client.

messages

m = client.messages.send(to="+254712345678", text="Hello", sender_id="ACME")
client.messages.get(m["id"])
client.messages.cancel(m["id"])                       # only queued or scheduled messages
page = client.messages.list(limit=50, status="delivered", country="KE")

batches

b = client.batches.create(items=[
    {"to": "+254712345678", "text": "Hi Ada"},
])
client.batches.start(b["id"])                         # batches are created "ready"
client.batches.get(b["id"])

otp

otp = client.otp.send(to="+254712345678", length=6, ttl_seconds=300)
result = client.otp.verify(otp_id=otp["otp_id"], code="123456")
print(result["valid"], result["attempts_left"])

lookups

lk = client.lookups.create(to="+254712345678")
client.lookups.get(lk["id"])

contacts

ct = client.contacts.create(e164="+254712345678", name="Ada")
client.contacts.update(ct["id"], name="Ada L")        # PATCH: other fields kept
client.contacts.list(limit=200)

contact_groups

g = client.contact_groups.create(name="VIP", contact_ids=[ct["id"]])
client.contact_groups.send(g["id"], text="Sale starts today")

templates

tpl = client.templates.create(name="welcome", body="Hi {{name}}", traffic_type="transactional")
client.templates.update(tpl["id"], body="Hello {{name}}")

webhooks

wh = client.webhooks.create(url="https://example.com/opensms", events=["message.delivered"])
secret = wh["secret"]                                 # whsec_..., shown only once
client.webhooks.update(wh["id"], url=wh["url"], events=wh["events"], enabled=True)  # full replacement

inbound

client.inbound.list()
client.inbound.reply(inbound_id, text="Thanks!")      # live keys only

numbers

n = client.numbers.assign(country="KE", kind="long_code")   # live keys only, charges the wallet
client.numbers.create_rule(n["id"], match="keyword", pattern="STOP", action="webhook", target="https://example.com/in")

sender_ids

draft = client.sender_ids.create_draft(source="application", value="ACME", kind="alphanumeric", countries=["KE"])
sid = client.sender_ids.create(value="ACME", kind="alphanumeric", countries=["KE"],
                                documents=[], use_case="transactional")  # may charge fees; never auto-retried

suppressions

s = client.suppressions.create(e164="+254712345678", reason="manual")
client.suppressions.import_([{"e164": "+254712345679", "reason": "complaint"}])

import is a Python keyword, so the method is import_ (getattr(client.suppressions, "import") also works).

compliance

client.compliance.list_countries()
client.compliance.get_country("KE")

wallet

client.wallet.balances()
client.wallet.ledger(limit=50)                        # not cursor based; page with before=<smallest id seen>

pricing

client.pricing.get(product="sms", country="KE")

analytics

client.analytics.overview(range="7d")
client.analytics.by_country(from_="2026-09-01", to="2026-09-24")   # from is a keyword, so from_

sandbox

client.sandbox.list_messages(limit=10)                # rendered texts of sandbox sends, including OTP codes

countries

client.countries.list()
client.countries.carriers("KE")

Pagination

for message in client.paginate(client.messages.list, limit=50):
    print(message["id"])

client.paginate(list_method, *args, **params) feeds each page's next_cursor back as cursor until it is None. Bare-array endpoints and wallet.ledger are not paginated by it.

Errors and retries

Every non-2xx response, and any network failure or timeout that survives all retries, raises OpensmsError:

Field Notes
status HTTP status; 0 means a network error, timeout, or webhook signature failure
message detail, else title, else a generic message
type problem type URI, usually about:blank
title problem title, e.g. "Bad Request"
detail the specific human readable reason
code machine readable code; absent on most errors
trace_id present when the server sets one
errors field validation errors, {field: [messages]}, when present
request_id X-Request-ID; set on message and OTP admission rejections
retry_after seconds, from Retry-After, when the server sent it
body raw decoded body (dict), or text when the body was not JSON

Most errors carry no code, so branch on status and show detail. Insufficient scope is 401 on messages and otp but 403 everywhere else, so branch on status, not code, there too.

The client retries 429, 500, 502, 503, 504, and network errors or timeouts, up to max_retries (default 2, so 3 attempts), only when the request is safe to repeat. GET, PUT, PATCH and DELETE are always retried. A POST is retried only when it carries an Idempotency-Key: every method that supports one generates a UUIDv4 per call (or uses your idempotency_key=) and sends the same key on every retry, so the server replays instead of sending twice. messages.cancel, otp.verify, sender_ids.create, sender_ids.create_draft, suppressions.create, suppressions.import_, and any other 4xx are never retried. The client honours Retry-After (seconds or an HTTP date); when that asks for more than 60s it raises instead, with retry_after set. Otherwise it waits a random delay up to min(8s, 0.5s * 2^(n-1)) (exponential backoff with full jitter).

from opensms import OpensmsError

try:
    client.messages.send(to="+254712345679", text="hi")
except OpensmsError as e:
    print(e.status, e.detail)

Webhooks

Deliveries carry X-OpenSMS-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 of "<t>.<raw body>" keyed with the endpoint's whsec_... secret used verbatim. Always verify the raw body before parsing it; no API key is needed:

from opensms import construct_event, verify_signature, OpensmsError

# e.g. in a Flask view
raw = request.get_data()                              # exact bytes received
header = request.headers.get("X-OpenSMS-Signature")
try:
    event = construct_event(raw, header, secret)       # secret = "whsec_..." from webhooks.create
except OpensmsError as e:
    abort(400)                                          # e.code: invalid_signature | expired_signature
print(event["type"], event["data"])

ok = verify_signature(raw, header, secret, tolerance_seconds=300)   # boolean form

The default tolerance is 300 seconds; both functions accept now to inject a fixed clock in tests.

Testing

pip install -e ".[dev]"
pytest tests/test_unit.py tests/test_urllib_transport.py

The unit tests need no network. The live suite (tests/test_live.py, marked live) runs against a sandbox when OPENSMS_BASE_URL and OPENSMS_API_KEY are set, and is skipped otherwise:

OPENSMS_BASE_URL=... OPENSMS_API_KEY=... pytest tests/test_live.py -v

License

MIT (c) OpenSMS

Release files for opensms 0.1.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 opensms 0.1.0
File Size Uploaded
opensms-0.1.0.tar.gz 35.4 kB Details

Built distribution (wheel)

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

Total release size: 70.9 kB

Release files / opensms-0.1.0.tar.gz

Download URL opensms-0.1.0.tar.gz
Size 35.4 kB
Tags Source
SHA-256 checksum
How to use checksums
c6f02189ded4ebea50170f1332c3b34f705a92eb90ad724060ab048266525034
BLAKE2b-256 checksum
How to use checksums
b40599b09405a7043daaeae25836a3b9091865c2d1e76a3aef9b34af22c49ec6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release files / opensms-0.1.0-py3-none-any.whl

Download URL opensms-0.1.0-py3-none-any.whl
Size 35.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b5fab85dfc47f4c394c578c479ebcf7e978245d67f512908b8a58596a5e38e41
BLAKE2b-256 checksum
How to use checksums
727cb242402192dd535009ceb4e7912839c98a57446d95f51e200caccb9d3818
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release history Release notifications | RSS feed

0.1.1

2 release files

This release

0.1.0 This release

2 release files

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