SendiMessage SDK for Python
The official Python client for the SendiMessage API — send and receive iMessage and SMS programmatically. Pure standard library, no dependencies to install.
Server-side only. The API key pair grants full messaging access to your account — never embed it in client-side or distributed code.
Contents
- Requirements
- Installation
- Authentication
- Quick start
- Usage
- Error handling
- Configuration
- API reference
- License
Requirements
- Python ≥ 3.9 (standard library only)
- A SendiMessage account with an API key pair
Installation
pip install sendimessage
Or straight from GitHub:
pip install git+https://github.com/sendimessage/sendimessage-python.git
Authentication
Create a key pair in the account portal
under API credentials. You get a key_id and a secret — the secret is
shown once, store it securely (e.g. in your secret manager or environment
variables). Every request the SDK makes carries them as the X-API-Key /
X-API-Secret headers.
import os
from sendimessage import Client
client = Client(
api_key=os.environ["SENDIMESSAGE_KEY"],
api_secret=os.environ["SENDIMESSAGE_SECRET"],
)
You can verify a key pair at any time with GET /me (curl example in the
account portal).
Quick start
from sendimessage import Client
client = Client(api_key="...", api_secret="...")
msg = client.send_message(number="+15551234567", content="Hello from SendiMessage!")
print(msg["message_handle"], msg["status"]) # "…" "QUEUED"
The API delivers over iMessage first and falls back to SMS when the recipient is not reachable over iMessage.
Usage
Sending messages
msg = client.send_message(
number="+15551234567", # E.164 phone number, or an email for iMessage
content="Your order has shipped 🎉",
)
If your account has more than one line (sending number), route via a specific
one with line_handle:
lines = client.list_lines()
client.send_message(
number="+15551234567",
content="Hi!",
line_handle=lines["lines"][0]["line_handle"],
)
Sending media
Upload the file first — upload_media returns a public URL you pass as
media_url. Images, video, audio and PDF are supported, up to 50 MiB.
url = client.upload_media("./photo.jpg") # path
# or: client.upload_media(data, filename="photo.jpg") # raw bytes + filename
client.send_message(number="+15551234567", media_url=url)
content is optional when media_url is set; provide both to send a caption
with the attachment. Any public http(s) URL also works as media_url.
Scheduling and status callbacks
client.send_message(
number="+15551234567",
content="Reminder: appointment at 3pm",
scheduled_at="2026-08-10T14:30:00Z", # ISO 8601, deliver later
status_callback="https://example.com/hooks/status", # POSTed the final status
)
Message status and history
Every send returns a message_handle. Poll it — or better, use a
webhook / status_callback:
status = client.get_status(msg["message_handle"])
print(status["status"]) # QUEUED | SENT | ERROR
History is cursor-paginated: pass next_before from one page as before on
the next. next_before is None on the last page.
before = None
while True:
page = client.list_messages(number="+15551234567", limit=50, before=before)
for m in page["messages"]:
print(m["date_sent"], "→" if m["is_outbound"] else "←", m["content"])
before = page["next_before"]
if not before:
break
Filters: number, direction ("in" / "out"), line_handle,
conversation_handle, service, since, until, limit.
one = client.get_message(handle) # single history message
iMessage lookup
Check whether a number is reachable over iMessage before sending:
result = client.lookup("+15551234567")
print(result["service"]) # "iMessage" | "SMS"
print(result["cached"]) # True when answered from the capability cache
Lookups are answered by a live Apple device, so the API is cache-first: a
fresh cached answer returns immediately, otherwise the question is queued and
the call waits briefly for a device. If no answer arrives in time you get a
pending response — retry shortly; a 503 means no device is currently
online for your account.
Contacts and opt-out
Contacts are your address book: names shown in conversations, the detected service (iMessage/SMS) and the opt-out flag.
client.create_contact(number="+15551234567", first_name="Jane", last_name="Doe")
client.update_contact("+15551234567", company_name="Acme Inc.")
contact = client.get_contact("+15551234567") # number or contact_handle
all_contacts = client.list_contacts(limit=100)
client.delete_contact("+15551234567")
client.recheck_contact("+15551234567") # re-run the iMessage/SMS check now
Opt-out is enforced on every send path — a send to an opted-out number
fails with HTTP 422. Inbound STOP / UNSUBSCRIBE / CANCEL / END /
QUIT opts the sender out automatically; START / UNSTOP / YES opts
back in. You can also manage it explicitly:
client.set_opt_out("+15551234567", True) # opt out
client.set_opt_out("+15551234567", False) # opt back in
Lines and call forwarding
A line is a sending identity (phone number or iMessage address) on your account.
lines = client.list_lines()
client.update_line(line_handle, label="Support line", is_active=True)
Calls to your line's number can be forwarded to any number you choose.
Forwarding is configured by the SendiMessage operator, so changes are
asynchronous — the API answers 202 and the request shows as pending until
fulfilled:
client.set_call_forwarding(line_handle, "+15559876543") # request forwarding
client.set_call_forwarding(line_handle, None) # request turning it off
state = client.get_call_forwarding(line_handle) # current + pending
Conversations
Conversations group the message history per contact:
convos = client.list_conversations(limit=20)
msgs = client.list_conversation_messages(conversation_handle, limit=50)
client.mark_conversation_read(conversation_handle)
Webhooks
Get pushed events instead of polling. Events:
| Event | Fired when |
|---|---|
receive |
An inbound message arrives |
outbound |
An outbound message reaches its final status (sent/failed) |
line_blocked |
A line was blocked by Apple (sends answer 422 until unblocked) |
line_unblocked |
The block was lifted |
client.create_webhook(
url="https://example.com/hooks/sendimessage",
events=["receive", "outbound"], # default: every event
secret=os.environ["WEBHOOK_SECRET"], # enables the signature header
)
hooks = client.list_webhooks()
client.replace_webhooks([...]) # swap the whole set atomically
client.delete_webhook("https://example.com/hooks/sendimessage")
When the webhook has a secret, every delivery carries an
X-SMSBridge-Signature header — the hex HMAC-SHA256 of the raw request
body. Always verify it:
# Flask example. The raw body is required — re-serialized JSON differs
# byte-for-byte.
import json
from flask import Flask, request, abort
from sendimessage import verify_webhook_signature
app = Flask(__name__)
@app.post("/hooks/sendimessage")
def sendimessage_hook():
ok = verify_webhook_signature(
secret=os.environ["WEBHOOK_SECRET"],
raw_body=request.get_data(),
signature=request.headers.get("X-SMSBridge-Signature", ""),
)
if not ok:
abort(401)
event = json.loads(request.get_data())
# ... handle event
return "", 200
Error handling
Every non-2xx response raises ApiError with .status (HTTP code), the API
error text as the message, and .body (the parsed response).
from sendimessage import ApiError
try:
client.send_message(number="+15551234567", content="hi")
except ApiError as e:
print(e.status, e)
Common statuses:
| Status | Meaning |
|---|---|
401 |
Bad or missing key pair |
404 |
Unknown handle (message, contact, line, conversation) |
422 |
Validation failed — including sends to an opted-out contact or via a blocked line |
429 |
Rate limited — back off and retry |
503 |
Lookup: no device online for your account |
Configuration
client = Client(
api_key="...",
api_secret="...",
base_url="https://api.sendimessage.com/v1", # default; override for testing
timeout=30, # seconds, per request
)
API reference
| Method | Endpoint |
|---|---|
send_message(number, content=None, media_url=None, line_handle=None, status_callback=None, scheduled_at=None) |
POST /send-message |
get_status(message_handle) |
GET /status |
list_messages(**filters) |
GET /v2/messages |
get_message(message_handle) |
GET /v2/messages/{handle} |
upload_media(file, filename=None) |
POST /media |
lookup(number) |
GET /lookup |
list_contacts(**params) |
GET /contacts |
create_contact(**props) |
POST /contacts |
get_contact(number) |
GET /contacts/{number} |
update_contact(number, **props) |
PUT /contacts/{number} |
delete_contact(number) |
DELETE /contacts/{number} |
set_opt_out(number, opted_out=True) |
POST /contacts/opt-out |
recheck_contact(number) |
POST /contacts/{number}/lookup |
list_lines() |
GET /lines |
update_line(line_handle, label=None, is_active=None) |
PUT /lines/{handle} |
get_call_forwarding(line_handle) |
GET /lines/{handle}/call-forwarding |
set_call_forwarding(line_handle, forwarding_number) |
PUT /lines/{handle}/call-forwarding |
list_conversations(**params) |
GET /conversations |
list_conversation_messages(handle, **params) |
GET /conversations/{handle}/messages |
mark_conversation_read(handle) |
POST /conversations/{handle}/read |
list_webhooks() |
GET /account/webhooks |
create_webhook(url, events=None, secret=None) |
POST /account/webhooks |
replace_webhooks(webhooks) |
PUT /account/webhooks |
delete_webhook(url) |
DELETE /account/webhooks |
Full HTTP-level reference: the Postman collection.
License
Metadata
Release files for sendimessage 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| sendimessage-0.1.0.tar.gz | 13.0 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sendimessage-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 23.0 kB
Release files / sendimessage-0.1.0.tar.gz
| Download URL | sendimessage-0.1.0.tar.gz |
|---|---|
| Size | 13.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f80114c06a50582f99775da54972744f5d226a812f02f49c4458863adcfc1df9
|
|
BLAKE2b-256 checksum How to use checksums |
99ab0c46a9c8159a9beafd90cba75d97de5dc9b86f507c8f4108790e3b2fca29
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.13
|
Release files / sendimessage-0.1.0-py3-none-any.whl
| Download URL | sendimessage-0.1.0-py3-none-any.whl |
|---|---|
| Size | 10.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1b51922cd1ecfcd1efae7f6adef0f51fb0407224e4014a07bd5cf2e4475e165d
|
|
BLAKE2b-256 checksum How to use checksums |
b7a9e353acacc22a913543a1767ebce80681970319cdfe67b957555fb8bf1c12
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.13
|
Security for sendimessage 0.1.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.