velroi/Developers
INTEGRATION GUIDE API VERSION 1.2

Messages, from
your service.

Send and receive text, images, video and documents through Messages on your Mac. Durable events, STOP suppression, reply and reaction tracking, and observed delivery status.

iMessage & SMSImages, video, PDF & HTMLReply & reaction trackingExisting group chatsSigned inbound events
API BASE URLhttps://imessage.velroi.com

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

FieldTypeMeaning
tostringE.164 phone number, email address, or existing group chat ID. Use either to or chatId.
chatIdstringAlternative to to, always interpreted as an existing group.
textstringNonblank, 1–10,000 characters, no NUL. Required unless attachments is present.
attachmentsstring[]Up to 5 IDs from POST /v1/attachments: images, video, PDF or HTML. Local paths and URLs are rejected.
servicestringiMessage, SMS, or auto. Default: iMessage for direct messages, auto for groups.
consentbooleanRequired literal true: your client asserts it verified current opt-in for every recipient. For a group, this covers every current member.
dryRunbooleanDefault false. If true, resolve the target without sending.
groupbooleanOptional compatibility flag for a group target. Full group IDs are detected automatically.
purposestringOnly opt_out_confirmation is supported as a special purpose. See STOP handling below.
optOutEventIdstringRequired 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:

TypeAccepted asLimitVerified by
ImagesJPEG, PNG, GIF, WebP, HEIC/HEIF, TIFF, BMP16 MiBMagic bytes
VideoMP4, MOV, M4V64 MiBISO container brand (isom, qt␣␣, M4V␣)
DocumentsPDF16 MiBMagic bytes (%PDF-)
Web pagesHTML16 MiBValid 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 attachmentActiveContentTypes so 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.

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

  1. Call GET /v1/groups to obtain { "groups": [{ "chatId": "any;+;example-group-id", "displayName": "Project team" }] }.
  2. Verify consent for every current group member in your own application.
  3. Send with the returned chatId, text, consent, and a unique idempotency key. Omit service or use auto.
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: true and 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 state unknown, 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 statusMeaning
pendingTracking created; no native message has been correlated yet.
acceptedMatching native row observed; no sent/delivered/read marker yet.
sentMessages records sent; recipient delivery remains unconfirmed.
deliveredMessages records delivered. This is not proof of reading.
readMessages exposes a read timestamp. Not available for every recipient.
failedMessages exposes a nonzero native error code.
unconfirmedNo unique matching row after 60 seconds. No automatic resend.
ambiguousMultiple matching native rows; no receipt asserted.
unavailableThis 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:

KeyMeaning
apiVersionCurrently 1.2.0.
outboundImages, outboundVideo, outboundDocumentsWhether each attachment class can be sent.
attachmentMediaTypesEvery media type the upload endpoint accepts.
attachmentActiveContentTypesThe subset the recipient's device may execute rather than display — currently ["text/html"]. Gate or warn on these.
maxAttachmentBytes, maxVideoBytesSize ceilings: 16 MiB for images and documents, 64 MiB for video.
maxAttachmentsPerMessageCurrently 5.
attachmentsSendAsSeparateMessagesAlways true: each attachment is its own message, sent before the text.
inboundAttachmentDownloadWhether received attachment bytes can be fetched.
inboundThreadTracking / outboundThreadedRepliestrue / false: threaded replies are reported, never sent.
inboundReactionTracking / outboundReactionstrue / false: tapbacks are reported, never sent.
richLinkPreviewsfalse: URLs are delivered as plain text.
deliveryReceipts, readReceiptsObserved from Messages when available, never synthesised.
smsDeliveryGuarantee, automaticSmsFallbackBoth false. A failed iMessage is never retried as SMS.
maxSendsPerMinute, maxTextCharactersRate and length limits.
optOutConfirmationThe 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

MethodPathResponse
GET/healthUnauthenticated process liveness, { "status": "ok" }. Does not test Messages or SMS forwarding.
GET/v1/capabilitiesSupported features, limits, and submission semantics.
GET/v1/groupsExisting group IDs and display names; no message bodies.
POST/v1/messagesSubmit or dry-run one message, with optional images.
POST/v1/attachmentsStage 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/sendAlias of /v1/messages on this service.
GET/v1/requests/{requestId}Submission, observed delivery/read/error status, threaded replies, and standing reactions.
GET/v1/readinessInbound/STOP scan freshness; 503 when unavailable.
POST/v1/recipientsEnroll a consented recipient for future inbound monitoring.
GET/v1/recipients/{recipient}Monitoring and opt-out state.
POST/v1/recipients/{recipient}/suppressRecord a revocation from another source.
POST/v1/recipients/{recipient}/restoreRestore after fresh, referenced opt-in evidence.
GET/v1/eventsDurable event stream with cursor pagination.
GET/v1/inboundIncoming-message events only.
GET / POST/v1/webhooksList / 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.

HTTPCodeAction
400consent_requiredAssert consent: true after your own opt-in check.
400invalid_target, sms_phone_required, group_serviceFix the recipient: E.164 phone, email, or an exact group chatId. SMS needs a phone; groups take only auto.
400invalid_text, invalid_json, invalid_body, invalid_boolean, invalid_service, unknown_fieldFix request data; unknown fields are rejected outright.
400invalid_attachments, too_many_attachments, invalid_filename, empty_attachment, invalid_attachment_idUse IDs from POST /v1/attachments, at most 5 per message, with a bare filename.
400idempotency_key_requiredSend an Idempotency-Key of 8–128 permitted characters on every live send.
400invalid_purpose, invalid_opt_out_acknowledgmentOnly opt_out_confirmation is a special purpose, and it needs the exact fixed text and event ID.
400invalid_cursorafter must be a nonnegative integer and limit 1–200.
400reason_required, reopt_in_requiredSuppression needs a reason; restoring needs consent: true and a reference to fresh evidence.
400invalid_webhook_url, invalid_webhook_secretPublic HTTPS on port 443, and a signing secret distinct from the API key.
401unauthorizedSupply the correct service key.
403recipient_opted_outCancel the job. Never bypass suppression; consent: true does not override STOP.
403invalid_opt_out_acknowledgmentOnly the fixed confirmation, to the currently opted-out recipient, is permitted.
404group_not_found, not_foundFetch current groups or check the path.
404attachment_not_foundThe upload is unknown or expired; upload it again.
404recipient_not_foundEnroll the recipient before restoring consent.
409idempotency_conflictThat key was used for a different payload. Do not reuse keys across messages.
409send_outcome_unconfirmedThe earlier attempt's outcome is unknown and will not be resent. Inspect Messages before issuing a new key.
409acknowledgment_already_attemptedOne confirmation per opt-out. Retry its original key; never send another.
409attachment_unavailableMessages has no local copy yet; it may still be downloading.
409webhook_exists, webhook_limitThat URL is already subscribed, or five subscriptions already exist.
413body_too_largeKeep JSON request bodies below 64 KiB.
413attachment_too_largeVideo is capped at 64 MiB, every other attachment at 16 MiB.
415content_typeUse application/json for sends, and a declared media type for uploads.
415unsupported_media_type, media_type_mismatchThe bytes are not an accepted type, or do not match the type you declared.
429rate_limit, send_rate_limit, busyFollow Retry-After and retry the same payload and key.
501threaded_replies_unsupported, reactions_unsupportedMessages cannot thread a reply or send a tapback. Send a normal message.
501attachments_unavailableThis deployment has attachment sending disabled.
502send_outcome_unconfirmedMessages did not confirm submission. The outcome is uncertain and the key will not send again.
503backend_unavailable, inbound_unavailable, group_members_unavailable, opt_out_unavailableKeep jobs queued; resolve Mac availability, permissions, and STOP-readiness before retrying.
507attachment_storage_fullSend 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.