Metadata-Version: 2.4
Name: sendimessage
Version: 0.1.0
Summary: SendiMessage SDK — send iMessage/SMS through the SendiMessage API
License: MIT
Project-URL: Homepage, https://sendimessage.com
Project-URL: Repository, https://github.com/sendimessage/sendimessage-python
Keywords: imessage,sms,messaging,sendimessage
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# SendiMessage SDK for Python

[![Version](https://img.shields.io/github/v/tag/sendimessage/sendimessage-python?label=version&color=blue)](https://github.com/sendimessage/sendimessage-python/tags)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
[![Python](https://img.shields.io/badge/python-%E2%89%A53.9-brightgreen)](https://www.python.org)

The official Python client for the [SendiMessage API](https://sendimessage.com) —
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](#requirements)
- [Installation](#installation)
- [Authentication](#authentication)
- [Quick start](#quick-start)
- [Usage](#usage)
  - [Sending messages](#sending-messages)
  - [Sending media](#sending-media)
  - [Scheduling and status callbacks](#scheduling-and-status-callbacks)
  - [Message status and history](#message-status-and-history)
  - [iMessage lookup](#imessage-lookup)
  - [Contacts and opt-out](#contacts-and-opt-out)
  - [Lines and call forwarding](#lines-and-call-forwarding)
  - [Conversations](#conversations)
  - [Webhooks](#webhooks)
- [Error handling](#error-handling)
- [Configuration](#configuration)
- [API reference](#api-reference)
- [License](#license)

## Requirements

- Python ≥ 3.9 (standard library only)
- A SendiMessage account with an API key pair

## Installation

```bash
pip install sendimessage
```

Or straight from GitHub:

```bash
pip install git+https://github.com/sendimessage/sendimessage-python.git
```

## Authentication

Create a key pair in the [account portal](https://account.sendimessage.com)
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.

```python
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

```python
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

```python
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`:

```python
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.

```python
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

```python
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](#webhooks) / `status_callback`:

```python
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.

```python
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`.

```python
one = client.get_message(handle)  # single history message
```

### iMessage lookup

Check whether a number is reachable over iMessage before sending:

```python
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.

```python
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:

```python
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.

```python
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:

```python
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:

```python
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 |

```python
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:

```python
# 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).

```python
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

```python
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](https://sendimessage.com/postman_collection.json).

## License

[MIT](LICENSE)
