Skip to content
iMessage APIs
Operations11 min read

Sending iMessage from Python

The Python packages you will find on PyPI mostly read your own Mac's message database. That is a different problem from sending. Here is the one that sends.

Search PyPI for iMessage and you get two categories of package: readers that open the local chat.db on macOS, and thin wrappers around AppleScript. Both require a Mac you own, both send from your personal Apple ID, and neither is what you want behind a product. There is no widely-trusted Python library that sends through a hosted provider, because none is needed — the popular providers are plain REST, and httpx is the library.

What you actually want is an HTTP client against a provider, and Python makes that pleasant. This guide uses httpx for the client half and FastAPI for the webhook half.

The client

python
import os
import httpx
from dataclasses import dataclass

BASE_URL = "https://api.sendblue.co/api"

_client = httpx.Client(
    base_url=BASE_URL,
    timeout=httpx.Timeout(10.0, connect=5.0),
    headers={
        "sb-api-key-id": os.environ["IMESSAGE_KEY_ID"],
        "sb-api-secret-key": os.environ["IMESSAGE_SECRET"],
    },
)


class ImessageError(Exception):
    def __init__(self, message: str, status: int, retryable: bool):
        super().__init__(message)
        self.status = status
        self.retryable = retryable


@dataclass(frozen=True)
class SendResult:
    handle: str
    status: str


def send_message(
    to: str,
    body: str,
    status_callback: str | None = None,
) -> SendResult:
    response = _client.post(
        "/send-message",
        json={
            "number": to,
            "content": body,
            "status_callback": status_callback,
        },
    )

    if response.is_error:
        raise ImessageError(
            f"send failed {response.status_code}: {response.text[:300]}",
            response.status_code,
            retryable=response.status_code == 429 or response.status_code >= 500,
        )

    payload = response.json()
    return SendResult(handle=payload["message_handle"], status=payload["status"])

imessage.py — a synchronous client with a shared connection pool and a meaningful exception type.

A module-level httpx.Client rather than a fresh client per call is not a micro-optimisation — it reuses the TLS connection, and against a provider you are calling thousands of times a day the handshake cost is real.

Retries

python
from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential_jitter,
    retry_if_exception,
)


def _is_retryable(error: BaseException) -> bool:
    if isinstance(error, ImessageError):
        return error.retryable
    # Network-level failures are always worth one more go.
    return isinstance(error, httpx.TransportError)


@retry(
    stop=stop_after_attempt(4),
    wait=wait_exponential_jitter(initial=0.5, max=8),
    retry=retry_if_exception(_is_retryable),
    reraise=True,
)
def send_with_retry(**kwargs) -> SendResult:
    return send_message(**kwargs)

Using tenacity, which is worth the dependency for the readable retry predicate. The equivalent hand-rolled loop is fifteen lines.

Do not retry a 422

A 422 usually means the recipient is not reachable on iMessage — a landline, an Android handset, a number that does not exist. No amount of retrying changes that. Route those to your SMS fallback (or drop them) and keep your retry budget for the failures that are genuinely transient.

The webhook

python
import hmac
import hashlib
import os
from fastapi import FastAPI, Request, Response, BackgroundTasks

app = FastAPI()
SECRET = os.environ["IMESSAGE_WEBHOOK_SECRET"].encode()


def _valid(raw: bytes, signature: str | None) -> bool:
    if not signature:
        return False
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


@app.post("/webhooks/imessage")
async def imessage_webhook(
    request: Request,
    background: BackgroundTasks,
) -> Response:
    raw = await request.body()

    if not _valid(raw, request.headers.get("x-signature")):
        return Response(status_code=401)

    event = await request.json()

    # 200 immediately; the provider retries anything slow, and a retry
    # storm triggered by your own ORM is entirely self-inflicted.
    background.add_task(handle_event, event)
    return Response(status_code=200)


def handle_event(event: dict) -> None:
    event_id = event.get("id")
    if already_processed(event_id):
        return  # At-least-once delivery means this will happen.
    ...

FastAPI. Read the raw body — verifying a signature against a re-serialized dict will fail, and the failure looks like a broken secret rather than a broken approach.

Pacing an async send

python
import asyncio
import random

async def send_batch(
    jobs: list[dict],
    per_minute: int = 6,
    concurrency: int = 2,
) -> list[str]:
    spacing = 60 / per_minute
    limit = asyncio.Semaphore(concurrency)
    sent: list[str] = []

    async def one(job: dict, index: int) -> None:
        # Stagger the start, then jitter so the cadence is not machine-regular.
        await asyncio.sleep(index * spacing + random.uniform(0, spacing * 0.3))
        async with limit:
            try:
                result = await asyncio.to_thread(send_with_retry, **job)
                sent.append(result.handle)
            except Exception as error:
                print("send failed", job.get("to"), error)

    await asyncio.gather(*(one(job, i) for i, job in enumerate(jobs)))
    return sent

A semaphore bounds concurrency; the sleep bounds rate. You need both — concurrency alone still lets a hundred messages leave in one second.

The jitter is deliberate. Perfectly regular intervals are one of the cheapest automation signals there is, and the whole value of this channel rests on messages that read as though a person sent them. More on that in the bulk sending guide.

Where Python-specific advice actually differs

  • Django. Put the send behind transaction.on_commit. Sending inside the transaction means a rollback still delivers the message, and you cannot unsend an iMessage.
  • Celery. Set acks_late=True with a bounded max_retries, and make the task idempotent on a natural key — customer plus template plus day — because a worker that dies mid-send will replay.
  • Serverless. A module-level httpx.Client survives warm invocations and is the right call; just make sure something closes it on shutdown so you are not leaking sockets across cold starts.
  • Pydantic. Model the webhook payload. Providers add fields without announcement, and a model with extra="ignore" turns that from an outage into a non-event.

What it costs to run this

Worth knowing before you build, because it changes which provider you write the client against. The affordable end of the market starts around $20 a month for a shared sender and $60–$100 for a dedicated number; the enterprise end reaches $250 per line. Billing is per line rather than per message almost everywhere, so a Python service sending 300 messages a month and one sending 3,000 usually pay the same. The full pricing comparison.

The corollary matters for architecture: your ceiling is the line's safe daily volume, not your budget. If a scheduled job needs to send more than a line can carry, you are buying lines, and that is the number to put in your capacity planning.

The macOS packages, and when they are right

The chat.db readers are genuinely useful for one job: analysing message history on a Mac you own. If you want to know how many customers replied to something you sent manually last quarter, that is the tool.

They are the wrong tool for sending, for anything multi-user, and for anything that has to keep running when your laptop lid is shut. The open-source landscape article covers what each project is genuinely good at.

Before this goes live

  • Credentials in the environment, not the repository, and not prefixed for client exposure.
  • Webhook endpoint verifying signatures and returning 200 in under a second.
  • Event handling idempotent on the provider's event ID.
  • Retries distinguishing 429 and 5xx from 4xx.
  • Send rate paced and jittered, with a daily cap for the first two weeks.
  • An opt-out path that stops sends immediately — the compliance rules are here.
pythondeveloperscode