Skip to main content

SendiMessage SDK for Python

Version License: MIT 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

  • 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

MIT

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)

Source distribution for sendimessage 0.1.0
File Size Uploaded Provenance
sendimessage-0.1.0.tar.gz 13.0 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for sendimessage 0.1.0
File Interpreter ABI Platform
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.

Release history Release notifications | RSS feed

This release

0.1.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