# Velroi Messages API v1

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.

```sh
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.

```sh
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**:

```json
{"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`.

```sh
curl --fail-with-body "$IMESSAGE_API_URL/v1/groups" \
  -H "Authorization: Bearer $IMESSAGE_API_KEY"
```

```json
{"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

| 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

```ts
// 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](https://support.apple.com/en-us/102316).

## 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](https://support.apple.com/en-us/102545).

The basic [Photon iMessage kit](https://github.com/photon-hq/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.
