Send text through the Mac's Messages app using iMessage, SMS, or an existing group conversation. 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 and listing group IDs/names; it cannot read message history or administer webhooks. 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 | Required, nonblank, 1–10,000 characters, no NUL. |
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. |
Unknown fields are rejected. This API supports text, not attachments, recipient arrays, or bulk sends. Request bodies are limited to 64 KiB. 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","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. There are no delivery receipts or inbound-message endpoints in this API.
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, reactions, and attachments are not available in v1.
Retries and request status
GET /v1/requests/{requestId} returns the stored submission outcome, not a delivery receipt. States are sending, submitted, or unknown.
- 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 manage opt-in, revocations, and suppression lists before every new send. The server records an assertion; it does not independently verify consent or consume inbound STOP messages.
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 text message. |
| POST | /send | Alias of /v1/messages on this service. |
| GET | /v1/requests/{requestId} | Stored submission outcome. |
Errors
| HTTP | Common code | Action |
|---|---|---|
| 400 | consent_required, invalid_target, invalid_text, invalid_service, idempotency_key_required, invalid_json, unknown_field | Fix request data. |
| 401 | unauthorized | Supply the correct service key. |
| 404 | group_not_found, not_found | Fetch current groups or check the path. |
| 409 | idempotency_conflict, send_outcome_unconfirmed | Do not blindly generate a new key. |
| 413 | body_too_large | Reduce payload below 64 KiB. |
| 415 | content_type | Use application/json. |
| 429 | busy, send_rate_limit, rate_limit | Follow Retry-After; retain the original key. |
| 502 | send_outcome_unconfirmed | Inspect Messages; the outcome is uncertain. |
| 503 | backend_unavailable | Check the Mac session and automation permissions. |
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.
}),
signal: AbortSignal.timeout(35_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.requestId. This is submission, not delivery confirmation.
}
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 survive process restarts and power interruptions; uncertain sends are never automatically duplicated.
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.