Send text, images, video and documents through the Mac's Messages app using iMessage, SMS, or an existing group conversation. Version 1.1 added enrolled-recipient inbound events, STOP suppression, signed webhooks, and observed delivery/read/error states. Version 1.2 adds outbound images, video, PDFs and HTML, downloadable inbound attachments, and tracking of threaded replies and reactions on incoming messages. This is a server-to-server API. Store its key in your backend's secret manager.
Base URL: https://imessage.velroi.com. Local origin: http://127.0.0.1:3001. Documentation hosting on Railway is separate from the Mac-hosted messaging API.
Authentication
Send Authorization: Bearer YOUR_API_KEY or X-API-Key: YOUR_API_KEY. Keys in URLs are not accepted. Every endpoint except GET /health requires authentication. The service key authorizes sending, group discovery, enrolled-recipient inbound events, opt-out administration, receipt lookup, and webhook administration. Treat it as a single-tenant administrative secret. It cannot browse historical personal conversations. All clients using this key share recipients, suppressions, and events; this is not tenant-isolated. The API intentionally does not enable browser CORS.
Cloudflare currently rejects Python urllib's default user agent with HTTP 403 / code 1010. Python clients should send an application user agent, such as User-Agent: velroi-api-client/1.0. Standard curl and Node clients passed external checks.
Quick start: dry run
Set IMESSAGE_API_URL to your API base URL and IMESSAGE_API_KEY through your secret manager. The following request only resolves a target. It does not send a message or prove delivery capability for that recipient.
curl --fail-with-body "$IMESSAGE_API_URL/v1/messages" \
-H "Authorization: Bearer $IMESSAGE_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"to":"+12025550123","text":"Your appointment is confirmed.","service":"iMessage","consent":true,"dryRun":true}'
Send a message
POST /v1/messages (POST /send is an alias on the new API).
| Field | Type | Meaning |
|---|---|---|
to | string | E.164 phone number, email address, or existing group chat ID. Use either to or chatId. |
chatId | string | Alternative to to, always interpreted as an existing group. |
text | string | Nonblank, 1–10,000 characters, no NUL. Required unless attachments is present. |
attachments | string[] | Up to 5 IDs from POST /v1/attachments: images, video, PDF or HTML. Local paths and URLs are rejected. |
service | string | iMessage, SMS, or auto. Default: iMessage for direct messages, auto for groups. |
consent | boolean | Required literal true: your client asserts it verified current opt-in for every recipient. For a group, this covers every current member. |
dryRun | boolean | Default false. If true, resolve the target without sending. |
group | boolean | Optional compatibility flag for a group target. Full group IDs are detected automatically. |
purpose | string | Only opt_out_confirmation is supported as a special purpose. See STOP handling below. |
optOutEventId | string | Required only for that fixed confirmation; identifies the current opt-out event. |
Unknown fields are rejected. This API supports text and images, not recipient arrays or bulk sends. JSON request bodies are limited to 64 KiB; image bytes go to the separate upload endpoint below. SMS requires a phone number and the paired iPhone's forwarding setup. auto lets Messages resolve a direct participant using its default routing. It does not retry failed iMessage sends as SMS. Choose an explicit service when you need control.
Every live send requires an Idempotency-Key header: 8–128 characters drawn from letters, digits, ., _, :, and -. A UUID works well. Generate one per logical message and reuse it for every retry of that message.
curl --fail-with-body "$IMESSAGE_API_URL/v1/messages" \
-H "Authorization: Bearer $IMESSAGE_API_KEY" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: appointment-123-confirmation-v1' \
-d '{"to":"+12025550123","text":"Your appointment is confirmed.","service":"SMS","consent":true}'
Successful submission returns HTTP 202:
{"status":"submitted","service":"SMS","requestId":"a-server-generated-uuid","providerMessageId":"a-server-generated-uuid","submittedAt":"2026-09-08T23:00:00.000Z"}
submitted means Messages accepted the automation command. It does not establish carrier acceptance, recipient delivery, or a read receipt. Use request status or message.status events for separately observed outcomes. A 202 response alone must never mark a message delivered.
Images, video and documents
Sending an attachment is two steps: upload the bytes, then reference the returned ID in a normal send.
ATTACHMENT=$(curl --fail-with-body -X POST "$IMESSAGE_API_URL/v1/attachments?filename=receipt.png" \
-H "Authorization: Bearer $IMESSAGE_API_KEY" \
-H 'Content-Type: image/png' --data-binary @receipt.png | jq -r .attachmentId)
curl --fail-with-body "$IMESSAGE_API_URL/v1/messages" \
-H "Authorization: Bearer $IMESSAGE_API_KEY" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: receipt-8891-v1' \
-d "{\"to\":\"+12025550123\",\"text\":\"Here is your receipt.\",\"consent\":true,\"attachments\":[\"$ATTACHMENT\"]}"
POST /v1/attachments takes raw bytes, not multipart or base64. The Content-Type header is required, the optional ?filename= query sets the name the recipient sees, and the extension is always corrected to match the bytes. Accepted:
| Type | Accepted as | Limit | Verified by |
|---|---|---|---|
| Images | JPEG, PNG, GIF, WebP, HEIC/HEIF, TIFF, BMP | 16 MiB | Magic bytes |
| Video | MP4, MOV, M4V | 64 MiB | ISO container brand (isom, qt␣␣, M4V␣) |
| Documents | 16 MiB | Magic bytes (%PDF-) | |
| Web pages | HTML | 16 MiB | Valid UTF-8, no NUL, and the content must begin with <!doctype html> or <html |
Video is limited to the containers iMessage plays inline. WebM, Matroska, AVI and MPEG-TS are refused rather than delivered as a file the recipient cannot open. HEIC images and MP4 video share the same ISO container, so the brand inside the ftyp box decides which is which; an unrecognised brand is refused rather than guessed.
The declared type must match the actual bytes. Anything else is rejected with 415, so a renamed script or archive can never be handed to Messages. HTML is the one format with no magic number, so it is proven structurally instead; that check is deliberately narrow, and plain text, JSON, XML, SVG, and shell scripts are all refused.
HTML is active content. Every other accepted type is inert data. An HTML file you send may run scripts, render forms, or imitate another site when the recipient opens it, and it arrives from a trusted contact. Send HTML only when you control its contents. Capabilities list it under
attachmentActiveContentTypesso a client can gate or warn on it. Where a rendered page is the goal, a hosted URL is usually the better choice.
{"attachmentId":"att_...","filename":"receipt.png","mimeType":"image/png","size":43306,"expiresAt":"2026-09-12T22:36:31.571Z"}
An upload is a staged file, not a message. It expires 24 hours after upload, and one hour after its most recent send, so the same ID can go to several recipients. GET /v1/attachments/{id} returns the bytes again and DELETE /v1/attachments/{id} discards it early. At most 5 attachments per message and 100 staged uploads at a time. Images, video, PDFs and HTML can be mixed in one message. A large video takes several seconds to hand over, so allow a longer client timeout on sends that carry one.
text becomes optional when attachments is present, so an image can be sent with no caption. Messages has no captioned-attachment concept: each image and the text are separate messages, sent in order, image first. The response reports each part.
{"status":"submitted","service":"iMessage","requestId":"...","providerMessageId":"...","submittedAt":"...","attachments":[{"id":"att_...","status":"submitted"}]}
Delivery tracking follows the text when there is one, and otherwise the first image. If an image fails, the remaining parts are not attempted, the response is 502 send_outcome_unconfirmed with a per-part breakdown, and that idempotency key never sends again — inspect Messages before issuing a new one. A partially sent message cannot be recalled.
{"requestId":"...","status":"unknown","error":"send_outcome_unconfirmed","attachments":[{"id":"att_a","status":"unknown"},{"id":"att_b","status":"not_attempted"}],"textStatus":"not_attempted"}
Consent, STOP suppression, group membership checks, rate limits, and dry runs govern image sends exactly as they govern text. A dry run validates the target and the attachment IDs without sending. Outbound attachments are limited to the table above by design; there is no general file transfer.
Incoming attachments are fetched from the same path: GET /v1/attachments/{id} using the id from a message.received event. Only attachments on messages this relay has already surfaced as inbound events are readable; anything else returns 404, and 409 means Messages has no local copy yet. The response streams the bytes with the stored MIME type and a Content-Disposition filename.
Link previews
A URL sent through this API is delivered as plain text, not as a rich link card. Messages builds the preview payload (com.apple.messages.URLBalloonProvider) in its own compose UI, and the automation interface provides no way to attach one: a link sent through the API is recorded with no balloon bundle and an empty payload. Whether the recipient's device draws its own preview is up to that device, and is outside this API's control.
If a card-like result matters, send the page's preview image as an attachment alongside the link text. That renders as an image followed by the URL, which is achievable today and needs no special support.
Existing groups
- Call
GET /v1/groupsto obtain{ "groups": [{ "chatId": "any;+;example-group-id", "displayName": "Project team" }] }. - Verify consent for every current group member in your own application.
- Send with the returned
chatId, text, consent, and a unique idempotency key. Omitserviceor useauto.
curl --fail-with-body "$IMESSAGE_API_URL/v1/groups" \
-H "Authorization: Bearer $IMESSAGE_API_KEY"
{"chatId":"PASTE_EXACT_ID_FROM_GROUPS","text":"The meeting starts in 15 minutes.","consent":true}
Never invent a chat ID. Full modern any;+;... IDs, legacy iMessage;+;... IDs, and unambiguous complete bare identifiers are resolved against existing groups. Partial IDs are rejected. A group send stays in that conversation and uses its transport; it does not create individual SMS messages. Creating groups, adding/removing members, renaming groups, and reactions are not available. Images can be sent to an existing group. The relay checks the actual current member list before sending, enrolls those members for future inbound monitoring on a live attempt, and blocks the whole group if any member is suppressed. It fails closed when membership cannot be verified. Consent remains the caller's responsibility for every current member.
Retries and request status
GET /v1/requests/{requestId} returns the original submission outcome in status (sending, submitted, or unknown) plus a separate delivery object. Reading status never sends a message.
- Repeat the same payload and idempotency key after a network error. Completed requests replay the original response with
Idempotency-Replayed: trueand do not send again. - A changed payload under an existing key returns HTTP 409.
- HTTP 429 means wait for
Retry-After, then retry the same request and key. The API allows one automation operation at a time, up to 30 live attempts per minute and 120 authenticated requests per minute. Dry runs count toward the request limit, not the live-send limit. - HTTP 502 with
send_outcome_unconfirmed, or HTTP 409 with stateunknown, means a send may or may not have occurred. The server will never resend that key. Inspect Messages before deciding to issue a new logical request. - Restarting preserves completed and uncertain requests. Keys have no automatic expiry. Do not delete the state database to retry uncertain sends.
The client must verify current opt-in and its own suppression list before every new send. The relay independently blocks known opt-outs and scans the inbox immediately before dispatch. A completed idempotency replay may return its original 202 after a later STOP; that replay sends nothing. STOP cannot recall a message already handed to Messages.
Enroll for inbound messages
Register an opted-in recipient before expecting the first inbound message, including verification-code replies:
POST /v1/recipients
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{"recipient":"+12025550123","consent":true}
A live outbound attempt also enrolls its recipient(s). Enrollment is idempotent and never clears an opt-out. Dry runs do not enroll. Only future messages from enrolled phone/email addresses are exposed, including their replies in group chats; unrelated senders and historical personal messages are excluded. Phone and email handles are separate identities; the application must link and suppress related identities when needed.
Poll GET /v1/events?after=0&limit=100 for all events, or /v1/inbound for incoming messages only. Persist nextCursor after durably processing a page. Use the all-events stream for STOP, reaction and status handling, and ignore event types you do not recognize. limit is 1–200. Cursors and stable event IDs survive restarts; deduplicate using event.id. Webhooks provide the same events.
{
"events": [{
"sequence": 12,
"id": "inbound:NATIVE-MESSAGE-GUID",
"type": "message.received",
"createdAt": "2026-09-09T12:00:02.000Z",
"data": {
"providerMessageId": "NATIVE-MESSAGE-GUID",
"sender": "+12025550123",
"body": "STOP",
"inReplyTo": null,
"attachments": [{"id": "at_0_NATIVE-GUID", "name": "IMG_0421.HEIC", "mimeType": "image/heic", "size": 1840112, "downloadPath": "/v1/attachments/at_0_NATIVE-GUID"}],
"receivedAt": "2026-09-09T12:00:01.000Z",
"chatId": "iMessage;-;+12025550123",
"isGroup": false,
"service": "iMessage",
"keyword": "STOP",
"optOut": true,
"textDecoded": true
}
}],
"nextCursor": 12
}
The poller reads the local Messages database every two seconds while running and catches up after downtime. This is not a two-second end-to-end delivery SLA: Apple, connectivity, permissions, and backlogs can delay arrival. Tapbacks and system records are excluded. Incoming attachments carry an ID, name, MIME type, size, and a downloadPath; fetch the bytes from that path. Local filesystem paths are never exposed. A photo sent without a caption arrives with body: null and a populated attachments array. An undecodable message without attachments suppresses that sender pending review. Text edits to previously ingested rows are not separate events.
Threaded replies
Messages supports inline replies, where a message is attached to a specific earlier message rather than to the conversation. This API reports them and cannot send them.
Every message.received event carries inReplyTo. It is null for an ordinary message, and otherwise identifies the message being replied to:
{
"inReplyTo": {
"nativeMessageId": "NATIVE-GUID-OF-THE-MESSAGE-REPLIED-TO",
"part": "0:0:140",
"providerMessageId": "your-request-uuid-or-null"
}
}
Events are immutable, so message.received events recorded before the v1.2 upgrade omit inReplyTo entirely rather than setting it to null. Treat an absent field as null when replaying an existing cursor; every event recorded since the upgrade always includes it.
providerMessageId is the requestId of your own send when the reply answers a message this API sent, and null when it answers anything else — another of the sender's messages, or a message sent from the Mac outside this API. nativeMessageId is always present, so a client tracking its own native IDs can correlate either way. part is Messages' opaque position within the original message; pass it through rather than parsing it.
GET /v1/requests/{requestId} answers the direct question — did anyone reply to this? — with a replies array:
{
"requestId": "...", "status": "submitted", "delivery": { "status": "read" },
"replies": [{"nativeMessageId": "...", "sender": "+12025550123", "part": "0:0:30", "eventSequence": 412, "receivedAt": "2026-09-11T22:40:03.000Z"}]
}
Replies are matched through the native message ID, resolved at query time. A reply that arrives before receipt correlation finishes is attributed as soon as correlation catches up, so poll the request rather than treating an empty replies array as final. Use eventSequence to fetch the full message body from GET /v1/events. Only replies from enrolled recipients appear, on the same terms as any other inbound message. Tapbacks on a threaded message are reactions, not replies, and are excluded.
Sending a threaded reply is not supported, and this is a platform limit, not a roadmap gap. The Messages automation interface exposes exactly one send command, addressed to a participant or a chat; it has no reply parameter, and it does not expose individual messages as objects at all, so there is nothing to attach a reply to. Passing replyTo, inReplyTo, threadOriginator, or replyToMessageId returns 501 threaded_replies_unsupported rather than silently sending an unthreaded message. Send a normal message and, where context matters, quote it in the text. Capabilities report inboundThreadTracking: true and outboundThreadedReplies: false.
Reactions
Tapbacks are reported and cannot be sent, on the same terms as threaded replies.
A received tapback produces a message.reaction event rather than a message.received one. It carries no body, so it is never text and can never be read as a STOP:
{
"sequence": 318,
"id": "reaction:NATIVE-GUID-OF-THE-TAPBACK",
"type": "message.reaction",
"data": {
"providerMessageId": "NATIVE-GUID-OF-THE-TAPBACK",
"sender": "+12025550123",
"action": "added",
"kind": "loved",
"emoji": null,
"target": {"nativeMessageId": "NATIVE-GUID-REACTED-TO", "part": "0", "providerMessageId": "your-request-uuid-or-null"},
"chatId": "iMessage;-;+12025550123", "isGroup": false, "service": "iMessage",
"receivedAt": "2026-09-11T22:48:10.000Z"
}
}
kind is one of loved, liked, disliked, laughed, emphasized, questioned, or emoji. Only emoji populates emoji, with the character the sender chose. action is added or removed; removing a tapback emits its own event. target.providerMessageId resolves to your requestId when the tapback is on a message this API sent, and is null otherwise. target.part identifies which part of a multi-part message was reacted to.
GET /v1/requests/{requestId} reports the reactions currently standing on that message:
{"requestId": "...", "reactions": [{"sender": "+12025550123", "kind": "loved", "emoji": null, "part": "0", "receivedAt": "..."}]}
This is current state, not history: one entry per sender, a new tapback from that sender replaces their previous one, and a removal clears it. Replay the message.reaction events if you need the full history. Messages itself does not keep that history: replacing a tapback deletes the previous row from its database and writes a new one, and removing a tapback deletes the row and writes a removal record. The event stream therefore retains a sequence that the Mac no longer holds. Reactions are matched through the native message ID at query time, exactly like replies, so an empty array is not final while receipt correlation is still pending.
Reactions appear only from enrolled recipients, and message.reaction is a new event type on an existing stream. Consumers of GET /v1/events and of webhooks must ignore event types they do not recognize; GET /v1/inbound is unaffected, as it returns message.received only. Stickers and message edits are not tapbacks and are not reported.
Sending a tapback is not supported. The limitation is identical to threaded replies: Messages' automation interface sends text or a file to a participant or a chat and exposes no message object to react to. Passing reaction, tapback, reactTo, or reactToMessageId returns 501 reactions_unsupported rather than sending the text of a reaction as a normal message — which is what a non-Apple device would display, and would be a visible, permanent message in the conversation rather than a tapback. Capabilities report inboundReactionTracking: true and outboundReactions: false.
STOP handling
STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, REVOKE, and OPTOUT are recognized case-insensitively with whitespace/punctuation tolerance. PARAR, ALTO, 停止 and several clear natural-language requests (such as “please stop texting me” and “don't text me”) are also recognized. This is a conservative text matcher, not a complete revocation classifier. The application must interpret other reasonable requests and call the suppression endpoint.
For a recognized request, the relay atomically records message.received, suppresses the sender, and emits recipient.opted_out with recipient, reason, inboundMessageId, and acknowledgmentRequired. Subsequent direct sends and groups containing that address return 403 recipient_opted_out. consent:true, another idempotency key, and re-enrollment cannot bypass it. The application must also cancel queued jobs and suppress linked identities.
A STOP acknowledgment is caller-triggered, never automatic. When acknowledgmentRequired:true, the caller may attempt exactly one fixed confirmation for that opt-out event. Queue it promptly under the application's applicable timing rules; the relay does not guarantee acknowledgment latency. Normal rate/concurrency limits still apply. Use Idempotency-Key: optout-EVENT_UUID and the exact text returned by capabilities:
{
"to": "+12025550123",
"text": "Your request to stop messages has been recorded. You will receive no further automated texts.",
"service": "iMessage",
"purpose": "opt_out_confirmation",
"optOutEventId": "UUID_FROM_RECIPIENT_OPTED_OUT_EVENT"
}
Only this fixed direct confirmation can omit consent:true and pass the opt-out block. It permits no promotional text, arbitrary purpose, or group target. The attempt is recorded before dispatch, including uncertain failures; retry the original key instead of creating another. Repeated STOP while already suppressed does not create more confirmation allowances. Undecodable-input suppression does not permit a confirmation.
GET /v1/recipients/{recipient} reports monitoring and suppression state. URL-encode the recipient path. To record another revocation:
POST /v1/recipients/%2B12025550123/suppress
Content-Type: application/json
{"reason":"customer_requested_stop_in_support"}
START, HELP, HUMAN, REPORT, PAUSE, YES, NO, and CHANGE are exposed as keyword hints. They are not automatic workflows. START never restores consent. After the application captures fresh opt-in evidence, it may explicitly restore using POST /v1/recipients/{recipient}/restore with {"consent":true,"reoptInReference":"consent-record-42"}. The reference is retained and a recipient.restored event is emitted. The API trusts the caller's evidence assertion.
Delivery and read status
Outbound providerMessageId is the stable API requestId. delivery.nativeMessageId is the separate Messages GUID, available only after correlation. Incoming providerMessageId is the native GUID. Keep these namespaces distinct.
{
"requestId": "API-REQUEST-UUID",
"providerMessageId": "API-REQUEST-UUID",
"status": "submitted",
"result": {"status":"submitted"},
"delivery": {
"status": "delivered",
"nativeMessageId": "NATIVE-MESSAGE-GUID",
"observed": true,
"service": "iMessage",
"errorCode": null,
"deliveredAt": "2026-09-09T12:00:02.000Z",
"readAt": null
}
}
| Delivery status | Meaning |
|---|---|
pending | Tracking created; no native message has been correlated yet. |
accepted | Matching native row observed; no sent/delivered/read marker yet. |
sent | Messages records sent; recipient delivery remains unconfirmed. |
delivered | Messages records delivered. This is not proof of reading. |
read | Messages exposes a read timestamp. Not available for every recipient. |
failed | Messages exposes a nonzero native error code. |
unconfirmed | No unique matching row after 60 seconds. No automatic resend. |
ambiguous | Multiple matching native rows; no receipt asserted. |
unavailable | This request predates v1.1 tracking. |
Correlation uses the exact text hash, target, database position, and dispatch time window. Observed status changes emit message.status with requestId, providerMessageId, and the delivery fields. Checks continue across restarts, and a request-status lookup refreshes that request. A database outage preserves the last observed state rather than inventing failure or delivery.
These are observations from this Mac, not carrier-signed receipts or guaranteed provider callbacks. SMS commonly has no delivery/read receipt; it may remain sent. Group markers describe the conversation message, not per-member receipts. No automatic SMS fallback occurs. If your product requires guaranteed carrier delivery callbacks or individual group receipts, use a transport that supplies them.
Signed webhooks
Register a receiver with POST /v1/webhooks:
{
"url": "https://your-service.example/webhooks/velroi",
"secret": "A_SEPARATE_RANDOM_SIGNING_SECRET_AT_LEAST_32_CHARACTERS",
"afterCursor": 0
}
Generate a cryptographically random signing secret (32–256 characters), store it in your receiver's secret manager, and send it only over authenticated HTTPS. Do not reuse the service API key. afterCursor is optional: omitted means future events; zero replays retained events. At most five subscriptions are supported. Receivers must use public HTTPS on port 443. Private/local destinations, credentials in URLs, and redirects are rejected; DNS results are validated and pinned for each attempt.
Each POST body is one event envelope, signed using the exact raw bytes:
X-Velroi-Event-Id: EVENT_ID
X-Velroi-Timestamp: UNIX_SECONDS
X-Velroi-Signature: v1=HEX_HMAC_SHA256
HMAC input = timestamp + "." + raw_request_body
Example receiver verification (Node/Bun):
import { createHmac, timingSafeEqual } from "node:crypto";
export async function verifyWebhook(request: Request, secret: string) {
const raw = Buffer.from(await request.arrayBuffer()); // Before any JSON middleware.
const timestamp = request.headers.get("x-velroi-timestamp") ?? "";
const supplied = request.headers.get("x-velroi-signature") ?? "";
if (!/^\d{10}$/.test(timestamp)
|| Math.abs(Date.now() / 1000 - Number(timestamp)) > 300
|| !/^v1=[0-9a-f]{64}$/.test(supplied)) throw new Error("Invalid webhook");
const expected = createHmac("sha256", secret)
.update(timestamp + ".").update(raw).digest();
if (!timingSafeEqual(expected, Buffer.from(supplied.slice(3), "hex")))
throw new Error("Invalid webhook");
const event = JSON.parse(raw.toString("utf8"));
if (event.id !== request.headers.get("x-velroi-event-id"))
throw new Error("Invalid webhook");
return event; // Validate the event schema, then atomically deduplicate and enqueue.
}
Return 2xx only after durable acceptance. Delivery is at least once, ordered per subscription; deduplicate by event.id, including replay via polling. Non-2xx responses/timeouts retry with exponential backoff from 2 seconds up to 15 minutes. The body and ID stay stable; retries receive a fresh signed timestamp. A failing event blocks later events for that subscription; monitor backlog and use the polling stream for recovery. Receiver failure never delays local STOP suppression.
GET /v1/webhooks shows IDs, URLs, acknowledged cursors, retry attempts, next attempt time (Unix milliseconds), and last HTTP status, without secrets. DELETE /v1/webhooks/{id} stops future attempts; an already in-flight request may finish. To rotate a signing secret, replace the subscription using its last acknowledged cursor and deduplicate any overlap. No subscriptions are created automatically; configure your own receiver.
Readiness and application controls
GET /v1/readiness returns {"ready":true,"lastScanAt":"..."} when inbound scanning succeeded within ten seconds; otherwise HTTP 503 with ready:false. New sends scan the inbox again and fail closed with inbound_unavailable if STOP processing is unavailable or catching up. Liveness at /health alone does not establish readiness or delivery capability.
The relay supplies transport mechanisms. The calling product still owns identity verification, consent evidence, approved channels, age/guardian rules, disclosures, quiet hours, a durable queue, cancellation of queued jobs, natural-language revocation review, HELP/HUMAN/REPORT handling, retention, and its acceptance tests. A capability response does not establish legal compliance or authorize a product's consumer rollout. See the provider integration handoff.
Data and migration
On first v1.1 startup, the inbound cursor starts at the current Messages database edge; old inbox content is not exported. Enroll recipients before expecting replies. Outbound records created before the upgrade retain retry protection but have delivery status unavailable.
Events (including enrolled inbound text), recipient addresses, opt-out evidence references, delivery metadata, and webhook signing secrets are stored in the Mac's owner-only SQLite state directory on its FileVault-protected disk. Staged outbound images are owner-only files held outside that database and removed on expiry; received attachments stay in the Messages store under Apple's own retention. Logs omit message bodies and keys. There is currently no automatic event/state expiration. Establish an application retention policy and an operator-reviewed archive/purge procedure; deleting suppression or idempotency state can cause unwanted messages or duplicates. Native Messages has its own storage and retention.
If the Messages database is rewound below the saved cursor, sending fails closed for operator review; the service must not skip unread revocations to recover. Ordinary reconnects and process restarts resume from the saved cursor.
Capabilities
GET /v1/capabilities reports what this deployment can do, so a client can branch on behaviour instead of hard-coding it. The keys worth checking:
| Key | Meaning |
|---|---|
apiVersion | Currently 1.2.0. |
outboundImages, outboundVideo, outboundDocuments | Whether each attachment class can be sent. |
attachmentMediaTypes | Every media type the upload endpoint accepts. |
attachmentActiveContentTypes | The subset the recipient's device may execute rather than display — currently ["text/html"]. Gate or warn on these. |
maxAttachmentBytes, maxVideoBytes | Size ceilings: 16 MiB for images and documents, 64 MiB for video. |
maxAttachmentsPerMessage | Currently 5. |
attachmentsSendAsSeparateMessages | Always true: each attachment is its own message, sent before the text. |
inboundAttachmentDownload | Whether received attachment bytes can be fetched. |
inboundThreadTracking / outboundThreadedReplies | true / false: threaded replies are reported, never sent. |
inboundReactionTracking / outboundReactions | true / false: tapbacks are reported, never sent. |
richLinkPreviews | false: URLs are delivered as plain text. |
deliveryReceipts, readReceipts | Observed from Messages when available, never synthesised. |
smsDeliveryGuarantee, automaticSmsFallback | Both false. A failed iMessage is never retried as SMS. |
maxSendsPerMinute, maxTextCharacters | Rate and length limits. |
optOutConfirmation | The exact fixed confirmation text, and that it is caller-triggered and one attempt per opt-out. |
The three false capabilities are platform limits, not configuration: Messages' automation interface cannot thread a reply, send a tapback, or attach a link-preview payload. They will not become true on this deployment.
Endpoint reference
| Method | Path | Response |
|---|---|---|
| GET | /health | Unauthenticated process liveness, { "status": "ok" }. Does not test Messages or SMS forwarding. |
| GET | /v1/capabilities | Supported features, limits, and submission semantics. |
| GET | /v1/groups | Existing group IDs and display names; no message bodies. |
| POST | /v1/messages | Submit or dry-run one message, with optional images. |
| POST | /v1/attachments | Stage image bytes for sending; returns an attachment ID. |
| GET | /v1/attachments/{id} | Download a staged upload or a received attachment. |
| DELETE | /v1/attachments/{id} | Discard a staged upload before it is sent. |
| POST | /send | Alias of /v1/messages on this service. |
| GET | /v1/requests/{requestId} | Submission, observed delivery/read/error status, threaded replies, and standing reactions. |
| GET | /v1/readiness | Inbound/STOP scan freshness; 503 when unavailable. |
| POST | /v1/recipients | Enroll a consented recipient for future inbound monitoring. |
| GET | /v1/recipients/{recipient} | Monitoring and opt-out state. |
| POST | /v1/recipients/{recipient}/suppress | Record a revocation from another source. |
| POST | /v1/recipients/{recipient}/restore | Restore after fresh, referenced opt-in evidence. |
| GET | /v1/events | Durable event stream with cursor pagination. |
| GET | /v1/inbound | Incoming-message events only. |
| GET / POST | /v1/webhooks | List / register signed event subscriptions. |
| DELETE | /v1/webhooks/{id} | Remove a subscription. |
Errors
Every code the API can return, grouped by status. Treat the code as the contract; message wording may change.
| HTTP | Code | Action |
|---|---|---|
| 400 | consent_required | Assert consent: true after your own opt-in check. |
| 400 | invalid_target, sms_phone_required, group_service | Fix the recipient: E.164 phone, email, or an exact group chatId. SMS needs a phone; groups take only auto. |
| 400 | invalid_text, invalid_json, invalid_body, invalid_boolean, invalid_service, unknown_field | Fix request data; unknown fields are rejected outright. |
| 400 | invalid_attachments, too_many_attachments, invalid_filename, empty_attachment, invalid_attachment_id | Use IDs from POST /v1/attachments, at most 5 per message, with a bare filename. |
| 400 | idempotency_key_required | Send an Idempotency-Key of 8–128 permitted characters on every live send. |
| 400 | invalid_purpose, invalid_opt_out_acknowledgment | Only opt_out_confirmation is a special purpose, and it needs the exact fixed text and event ID. |
| 400 | invalid_cursor | after must be a nonnegative integer and limit 1–200. |
| 400 | reason_required, reopt_in_required | Suppression needs a reason; restoring needs consent: true and a reference to fresh evidence. |
| 400 | invalid_webhook_url, invalid_webhook_secret | Public HTTPS on port 443, and a signing secret distinct from the API key. |
| 401 | unauthorized | Supply the correct service key. |
| 403 | recipient_opted_out | Cancel the job. Never bypass suppression; consent: true does not override STOP. |
| 403 | invalid_opt_out_acknowledgment | Only the fixed confirmation, to the currently opted-out recipient, is permitted. |
| 404 | group_not_found, not_found | Fetch current groups or check the path. |
| 404 | attachment_not_found | The upload is unknown or expired; upload it again. |
| 404 | recipient_not_found | Enroll the recipient before restoring consent. |
| 409 | idempotency_conflict | That key was used for a different payload. Do not reuse keys across messages. |
| 409 | send_outcome_unconfirmed | The earlier attempt's outcome is unknown and will not be resent. Inspect Messages before issuing a new key. |
| 409 | acknowledgment_already_attempted | One confirmation per opt-out. Retry its original key; never send another. |
| 409 | attachment_unavailable | Messages has no local copy yet; it may still be downloading. |
| 409 | webhook_exists, webhook_limit | That URL is already subscribed, or five subscriptions already exist. |
| 413 | body_too_large | Keep JSON request bodies below 64 KiB. |
| 413 | attachment_too_large | Video is capped at 64 MiB, every other attachment at 16 MiB. |
| 415 | content_type | Use application/json for sends, and a declared media type for uploads. |
| 415 | unsupported_media_type, media_type_mismatch | The bytes are not an accepted type, or do not match the type you declared. |
| 429 | rate_limit, send_rate_limit, busy | Follow Retry-After and retry the same payload and key. |
| 501 | threaded_replies_unsupported, reactions_unsupported | Messages cannot thread a reply or send a tapback. Send a normal message. |
| 501 | attachments_unavailable | This deployment has attachment sending disabled. |
| 502 | send_outcome_unconfirmed | Messages did not confirm submission. The outcome is uncertain and the key will not send again. |
| 503 | backend_unavailable, inbound_unavailable, group_members_unavailable, opt_out_unavailable | Keep jobs queued; resolve Mac availability, permissions, and STOP-readiness before retrying. |
| 507 | attachment_storage_full | Send or delete staged uploads before retrying. |
TypeScript client
// Persist this key with your job. Do not regenerate it on each retry.
const idempotencyKey = job.messageId;
const response = await fetch(`${process.env.IMESSAGE_API_URL}/v1/messages`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.IMESSAGE_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
to: job.recipient,
text: job.text,
service: "iMessage",
consent: true, // Only after your current opt-in/suppression check.
...(job.attachmentIds?.length ? { attachments: job.attachmentIds } : {}),
}),
// Each attachment is a separate send; a large video can take ten seconds on its own.
signal: AbortSignal.timeout(120_000),
});
const result = await response.json();
if (response.status === 429) {
// Requeue the SAME payload/key after Retry-After seconds.
} else if (!response.ok) {
// Handle result.error; uncertain outcomes require reconciliation.
} else {
// Store result.providerMessageId / requestId; reconcile delivery separately.
}
Stage an image first, then pass its ID above:
const upload = await fetch(`${process.env.IMESSAGE_API_URL}/v1/attachments?filename=receipt.png`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.IMESSAGE_API_KEY}`,
"Content-Type": "image/png", // or video/mp4, application/pdf, text/html
},
body: fileBytes, // Raw bytes: no multipart, no base64.
});
// 415 here means the bytes are not an accepted type, or disagree with the type you declared.
const { attachmentId } = await upload.json();
Automatic recovery
The API and tunnel restart automatically if their processes crash. Cloudflare Tunnel reconnects when internet access returns. A watchdog runs every 60 seconds and checks the local API, tunnel readiness, and public HTTPS endpoint. It waits for three consecutive failures before restarting a stalled component, applies a five-minute cooldown, and avoids repeatedly restarting the tunnel while the Mac is offline.
When the Mac is unavailable, the caller must keep jobs in a durable queue. Retry connection failures and temporary gateway errors with exponential backoff and the same payload and Idempotency-Key. A request cannot be accepted while the Mac is powered off or disconnected. Previously accepted request records, inbound cursors, opt-outs, and webhook retries survive process restarts and power interruptions; uncertain sends are never automatically duplicated. Webhooks resume from their last acknowledged cursor. Incoming events resume from the saved native database position.
This Mac has FileVault enabled. After a full shutdown or cold reboot, a person must unlock/log in once before Messages can operate; then the API, tunnel, and watchdog start automatically. The watchdog cannot power on a turned-off computer or unlock FileVault. See Apple's automatic login requirements.
Mac availability
The Mac must be powered, connected, awake, and logged in to its Messages account. The API and tunnel start at user login and restart after a crash. A powered-off Mac, closed laptop lid, logged-out session, or disabled forwarding can interrupt service. SMS depends on the paired iPhone being available and Text Message Forwarding enabled. See Apple's forwarding instructions.
The basic Photon iMessage kit supplies chat discovery. A local adapter handles this Mac's modern group IDs and explicit SMS routing. No advanced Photon subscription is required for the implemented endpoints.