velroi/Developers
INTEGRATION GUIDE API VERSION 1.0

Messages, from
your service.

A straightforward API for sending through Messages on your Mac. One key, explicit consent, and predictable retries.

iMessage & SMSExisting group chatsIdempotent requests
API BASE URLhttps://imessage.velroi.com

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

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.
textstringRequired, nonblank, 1–10,000 characters, no NUL.
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.

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

  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, 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: 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 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

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 text message.
POST/sendAlias of /v1/messages on this service.
GET/v1/requests/{requestId}Stored submission outcome.

Errors

HTTPCommon codeAction
400consent_required, invalid_target, invalid_text, invalid_service, idempotency_key_required, invalid_json, unknown_fieldFix request data.
401unauthorizedSupply the correct service key.
404group_not_found, not_foundFetch current groups or check the path.
409idempotency_conflict, send_outcome_unconfirmedDo not blindly generate a new key.
413body_too_largeReduce payload below 64 KiB.
415content_typeUse application/json.
429busy, send_rate_limit, rate_limitFollow Retry-After; retain the original key.
502send_outcome_unconfirmedInspect Messages; the outcome is uncertain.
503backend_unavailableCheck 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.