# messages.dev > messages.dev is the iMessage API for agents. Send and receive iMessages, attachments, audio messages, reactions, typing indicators, and read receipts. The recommended integration is the TypeScript SDK (`@messages-dev/sdk`); a plain REST API is available for any other language; and a `messages-dev` CLI is the fastest path for local development, scripting, and live event forwarding into a local handler. Outbound messages follow a contact-first model: the recipient (or group chat) must message your line before you can send to them. The sandbox line is exempt once activated. This file inlines auth, every endpoint, and every webhook event payload so an integrator can skip the multi-fetch dance. For deeper guides see [/llms-full.txt](https://messages.dev/llms-full.txt) (auto-generated, full content) or the human-readable docs at https://messages.dev/docs. ## Recommended: TypeScript SDK ```bash npm install @messages-dev/sdk ``` ```typescript import { createClient } from "@messages-dev/sdk"; const client = createClient(); // reads MESSAGES_API_KEY await client.sendMessage({ from: "+15551234567", to: "+15559876543", text: "Hello!", }); ``` Every endpoint below has a typed wrapper. Pagination returns `{ data, hasMore, nextCursor }` and supports `for await…of`. Errors throw typed exceptions (`AuthenticationError`, `AuthorizationError`, `InvalidRequestError`, `NotFoundError`, `RateLimitError`). ## CLI: `messages-dev` A single self-contained binary built from the same SDK. It is the recommended path for local development, scripting, and ad-hoc debugging — especially for receiving messages without setting up a public URL or ngrok. ### Install ```bash # Standalone binary (macOS arm64/x64, Linux arm64/x64) curl -fsSL https://www.messages.dev/install.sh | sh # Or via a Node package manager npm install -g @messages-dev/cli ``` Pin a version with `MESSAGES_CLI_VERSION=v0.2.0`. Change the install location with `MESSAGES_INSTALL=/opt/messages`. ### Authenticate ```bash messages-dev login # interactive; stores credentials in ~/.messages/config.json export MESSAGES_API_KEY=sk_live_… # headless / CI; takes precedence over the config file ``` ### Common commands ```bash messages-dev send +14155551234 "hi from the terminal" # text (or stdin if [text] omitted) echo "deploy done" | messages-dev send +14155551234 # pipe stdin into a message messages-dev react love # tapback (love|like|dislike|laugh|emphasize|question) messages-dev typing +14155551234 # typing indicator (--off to clear) messages-dev read +14155551234 # mark chat as read messages-dev lines list # list lines on your account messages-dev chats list --line +14155551234 # list chats on a line messages-dev messages list --line +14155551234 --chat +14150000000 messages-dev messages get --line --chat messages-dev reactions list --message messages-dev receipts list --line --chat messages-dev outbox get # inspect an outbound item messages-dev files upload ./photo.jpg # prints the file_… id messages-dev files get --download # stream bytes messages-dev webhooks list / create / delete # manage webhook subscriptions messages-dev listen # stream events as NDJSON to stdout messages-dev listen --forward-to http://localhost:3000/webhooks # POST signed deliveries to a local URL ``` If your account has exactly one active line, `--from` is optional on `send`. Otherwise pass `--from `. ### Local webhook development with `listen --forward-to` `messages-dev listen --forward-to ` subscribes to your account's event stream and POSTs each event at a local URL with the same HMAC headers production webhooks use (`X-Webhook-Signature`, `X-Webhook-Timestamp`, `X-Webhook-Delivery-Id`). Your `verifyWebhook()` code path runs unchanged because the signature is genuine. This is the recommended local-dev path because it removes the need for ngrok or a public URL, but it isn't required — exposing your local server with ngrok / Cloudflare Tunnel / Tailscale Funnel and registering a regular webhook works too, and exercises the production delivery path end-to-end. ```bash messages-dev listen --forward-to http://localhost:3000/webhooks # prints a per-session HMAC secret on first run; pin one with MESSAGES_LISTEN_SECRET=… ``` Filter to specific events or a single line: ```bash messages-dev listen --forward-to http://localhost:3000/webhooks \ --event message.received --event message.failed \ --line +14155551234 ``` ### Output and exit codes By default every command renders human-readable output. Pass `--json` (or set `MESSAGES_OUTPUT=json` for the whole shell) to switch to structured output on stdout. Errors always go to stderr; with `--json` they take the shape `{"error":{"code":"…","message":"…"}}`. | Exit code | Meaning | |-----------|---------| | `0` | OK | | `1` | Generic failure | | `2` | Usage error (bad flags, missing args) | | `3` | Not authenticated | | `4` | Not found | | `5` | Validation error | ### Environment variables - `MESSAGES_API_KEY` — bearer token; takes precedence over `~/.messages/config.json`. - `MESSAGES_OUTPUT=json` — global JSON output. - `MESSAGES_LISTEN_SECRET` — pin the HMAC secret used by `listen --forward-to`. - `MESSAGES_CLI_VERSION`, `MESSAGES_INSTALL` — install-script knobs. ## Auth ``` Authorization: Bearer sk_live_... ``` Keys are created in the dashboard. Each key carries a set of scopes that gate which endpoints it can call: - `messages:read` — `GET /v1/messages` - `messages:write` — `POST /v1/messages`, `POST /v1/audio-messages` - `chats:read` — `GET /v1/chats` - `lines:read` — `GET /v1/lines` - `reactions:read` — `GET /v1/reactions` - `reactions:write` — `POST /v1/reactions` - `typing:read` / `typing:write` — `GET /v1/typing`, `POST /v1/typing` - `receipts:read` / `receipts:write` — `GET /v1/receipts`, `POST /v1/receipts` - `webhooks:read` / `webhooks:write` — `GET /v1/webhooks`, `POST /v1/webhooks`, `DELETE /v1/webhooks` - `outbox:read` — `GET /v1/outbox` - `files:read` / `files:write` — `GET /v1/files`, `POST /v1/files` A key can also be restricted to specific lines (only requests with `from` matching one of those lines are authorized). ## Conventions - Base URL: `https://api.messages.dev` - All endpoints are prefixed with `/v1` and return JSON. - Wire format is `snake_case`. The TypeScript SDK is `camelCase`. - IDs include a type prefix: `msg_`, `cht_`, `ln_`, `rxn_`, `obx_`, `wh_`, `ind_`, `rcp_`, `file_`, `dlv_`. - Timestamps are Unix milliseconds (UTC). - Every response includes `request_id`; include it when reporting issues. ### Async writes `POST /messages`, `POST /audio-messages`, `POST /reactions`, `POST /typing`, and `POST /receipts` are asynchronous. They return `{ id: "obx_...", status: "pending", request_id }`. Track delivery via webhooks (`message.sent`) or by polling `GET /v1/outbox?id=obx_...`. ### Pagination List endpoints accept `limit` (default 50, max 100) and `cursor`. Responses include `has_more` and `next_cursor`. Pass `next_cursor` as the next request's `cursor` until `has_more` is false. ### Contact-first restriction Outside the sandbox, the recipient (or group chat) must message your line first. Sending to a cold contact returns `403 contact_has_not_messaged`. ### Rate limits Per-API-key hourly limit (default 1000/hour). When exceeded the API returns `429` with a `Retry-After` header. Per-line iMessage volume guidance lives in https://messages.dev/docs/scaling/limits. ## Endpoints ### `GET /v1/lines` List your lines. No params. Response: `{ data: Line[], has_more, next_cursor, request_id }` where `Line = { id: ln_..., handle, label?, service: "imessage"|"sms"|"auto", is_active }`. ### `GET /v1/chats?from=&limit=&cursor=` List chats on a line, ordered by most recent activity. `Chat = { id: cht_..., line_id, identifier, service, name?, is_group?, participants?, last_message_at? }`. ### `GET /v1/messages?from=&to=&limit=&cursor=` List messages in a chat. `to` accepts a phone number, Apple ID, or `cht_...` chat ID. `Message = { id: msg_..., line_id, chat_id, guid, sender, text|null, attachments: Attachment[], service|null, is_from_me, is_audio_message|null, sent_at, synced_at, outbox_id|null, reply_to_guid|null }` `Attachment = { filename|null, mime_type|null, size|null, url|null, transcription|null }` — `transcription` is Apple's on-device transcription on inbound voice memos. ### `POST /v1/messages` Body: `{ from: , to: , text?, attachments?: [file_...], reply_to?: msg_... | }` At least one of `text` or `attachments` is required. `attachments` is at most one file ID. Send to a `cht_...` chat ID for group chats. Returns `201 { id: obx_..., status: "pending", request_id }`. Errors include `400 missing_required_parameter`, `403 contact_has_not_messaged`, `404 line_not_found`, `404 chat_not_found`, `404 file_not_found`, `404 message_not_found`. ### `POST /v1/audio-messages` Body: `{ from, to, audio_message: file_..., reply_to? }` — sends a native iMessage waveform balloon. Audio formats: m4a, mp3, wav, caf, aiff (transcoded server-side). iMessage only — SMS lines rejected. Some lines without advanced features return `400 advanced_features_required`. ### `POST /v1/reactions` Body: `{ from, to, message_id: msg_... | , type: "love"|"like"|"dislike"|"laugh"|"emphasize"|"question" }`. Returns the same outbox shape. ### `GET /v1/reactions?message_id=msg_...` List reactions on a message. `Reaction = { id: rxn_..., message_id, type, sender, is_from_me, added, sent_at, synced_at }`. ### `POST /v1/typing` Body: `{ from, to, state?: "on" | "off" }` (default `on`). ### `GET /v1/typing?from=&to=` `TypingIndicator = { id: ind_..., chat_id, handle, is_typing, updated_at }`. ### `POST /v1/receipts` Body: `{ from, to }` — marks the chat as read. ### `GET /v1/receipts?from=&to=` `ReadReceipt = { id: rcp_..., chat_id, handle, last_read_at, synced_at }`. ### `GET /v1/outbox?id=obx_...` `OutboxItem = { id, line_id, status: "pending"|"claimed"|"sent"|"failed", payload, completed_at|null, error|null, attempts, max_attempts, created_at, request_id }`. ### `POST /v1/files` Raw bytes as the body. Headers: `Content-Type: `, optional `X-Filename`. Returns `201 { id: file_..., url|null, filename|null, mime_type, size, request_id }`. ### `GET /v1/files?id=file_...` `302` redirect to the storage URL. ### `POST /v1/webhooks` Body: `{ from: , url, events: [event_name, ...] }`. Returns `201 { id: wh_..., line_ids, url, events, secret, is_active, request_id }` — the `secret` is shown once. Allowed events: `message.received`, `message.sent`, `reaction.added`, `reaction.removed`. (`typing.*` and `receipt.read` are not delivered today; subscribing to them is rejected.) ### `GET /v1/webhooks?from=` / `DELETE /v1/webhooks` (body `{ id: wh_... }`) Standard list/delete. Webhook IDs are passed in the **request body** for DELETE, not the URL. ## Recipes (capabilities that compose existing endpoints) | Capability | Mechanism | SDK | |---|---|---| | Send a contact card | `POST /v1/files` (`Content-Type: text/vcard`) → `POST /v1/messages` with the file ID in `attachments`. The SDK helper builds the vCard for you (name, phones, emails, org, photo, etc.). | `client.sendContactCard({ from, to, firstName, lastName, phones, emails, ... })` | | Send a native audio message | `POST /v1/files` (audio mime) → `POST /v1/audio-messages` with `audio_message: file_...` | `client.sendAudioMessage({ from, to, audioMessage })` | | Reply to a specific message | `reply_to: "msg_..."` (or a raw iMessage GUID) on `POST /v1/messages` | `replyTo` parameter on every send method | | Send into a group chat | Pass a `cht_...` chat ID as `to`. Discover group chats with `GET /v1/chats` (`is_group: true`). Apple does not let third-party software create new group chats — you can only send into chats that already exist on your line. | Same `to` parameter | | Test webhooks locally | `buildWebhookDelivery(event, data, secret)` returns a real signed `{ body, headers }` you can POST at your handler — `verifyWebhook` accepts it without a test-mode flag. | `buildWebhookDelivery`, `signWebhook` | ## Webhooks ### Delivery format Every event arrives as `POST ` with these headers: - `X-Webhook-Signature` — lowercase hex HMAC-SHA256 of `${timestamp}.${rawBody}` using the webhook secret - `X-Webhook-Timestamp` — Unix ms of dispatch; reject deliveries more than 5 minutes off (replay protection) - `X-Webhook-Delivery-Id` — `dlv_...` ID for idempotency - `Content-Type: application/json` Body: ```json { "event": "", "data": { /* see per-event shape below */ }, "timestamp": 1710000000123, "delivery_id": "dlv_..." } ``` The SDK helper `verifyWebhook(rawBody, headers["x-webhook-signature"], secret)` verifies the signature, performs replay-protection on the timestamp, and returns a typed discriminated-union event. ### Event payload shapes Every event includes `line_handle` (the receiving line's phone number / Apple ID) so you don't need to side-channel it. Reactions also include `chat_id` so you can route to a thread without an extra `getMessage` call. #### `message.received` and `message.sent` `data` is a `Message` plus `line_handle: string`: ```json { "id": "msg_...", "line_id": "ln_...", "line_handle": "+15551234567", "chat_id": "cht_...", "guid": "", "sender": "+15559876543", "text": "Hey!", "attachments": [], "service": "imessage", "is_from_me": false, "is_audio_message": false, "sent_at": 1710000000000, "synced_at": 1710000000001, "outbox_id": null, "reply_to_guid": null } ``` For inbound voice memos: `is_audio_message: true`, `text: null`, and `attachments[0]` carries `mime_type: "audio/x-caf"`, the audio bytes via `url`, and `transcription` (may be `null` on the first delivery if Apple hasn't finished on-device transcription yet). #### `reaction.added` and `reaction.removed` `data` is a `Reaction` plus `chat_id` and `line_handle`: ```json { "id": "rxn_...", "message_id": "msg_...", "chat_id": "cht_...", "line_handle": "+15551234567", "type": "love", "sender": "+15559876543", "is_from_me": false, "added": true, "sent_at": 1710000000000, "synced_at": 1710000000001 } ``` `added: true` for `reaction.added`, `false` for `reaction.removed`. `type` is one of `love`, `like`, `dislike`, `laugh`, `emphasize`, `question`. ## Error envelope ```json { "error": { "type": "invalid_request_error", "code": "missing_required_parameter", "message": "...", "param": "from" }, "request_id": "req_..." } ``` Error types: `authentication_error` (401), `authorization_error` (403), `invalid_request_error` (400 typically; `contact_has_not_messaged` is 403), `not_found_error` (404), `rate_limit_error` (429). Common codes: `missing_api_key`, `invalid_api_key`, `insufficient_scope`, `line_not_accessible`, `missing_required_parameter`, `invalid_parameter_value`, `invalid_json`, `empty_file`, `advanced_features_required`, `contact_has_not_messaged`, `sandbox_not_activated`, `sandbox_contact_mismatch`, `sandbox_quota_exceeded`, `sandbox_requires_owner`, `line_not_found`, `chat_not_found`, `message_not_found`, `outbox_item_not_found`, `webhook_not_found`, `file_not_found`, `rate_limit_exceeded`.