What is built
What the API offers today, and what is planned.
| REST API over accounts, devices, email addresses, messages and billingThe same surface the console runs on. | Available now |
| Session tokens from the recovery-phrase handshakeChallenge, verify, receive a bearer token that lasts 30 days. | Available now |
| Device pairing and scoped Worker credentialsMinted during pairing, independently revocable. | Available now |
| Long-lived API keysLets a script skip the handshake. Operator plan and up. Not issuable today. | Planned |
| WebhooksSigned deliveries for the events below. | Planned |
| Hosted event stream and SDKsThere is no public socket to subscribe to yet. | Planned |
Authentication
All requests are made over HTTPS to https://api.mailcycle.email and carry a bearer token. There are two kinds of identity.
- Operator sessions represent your account. They can manage devices, email addresses and billing, and read any mail on the account. You obtain one by running the recovery-phrase handshake:
POST /accounts/challengefor a nonce, thenPOST /accounts/verifywith a proof computed from the challenge. - Worker credentials represent one device. They are minted during pairing, scoped to that device, and can read only its own mail. They are never issued by hand.
There are no API keys yet. Operator tokens come from the handshake above and expire after 30 days, so an unattended script has to hold the recovery phrase to renew one. Long-lived, separately revocable keys are next on this surface; the console cannot issue them yet. When they ship, keys will need the Operator plan or higher.
The API returns ciphertext. Message content, address labels and device names are stored sealed and returned as sealed blobs. Decryption happens in your client with a key derived from the recovery phrase. So does search: there is no server-side query over message bodies and no ?q= parameter.
A device ID is not authorization. Every device-scoped and address-scoped call is checked against the caller's account. Requesting a record you do not own returns 404, not 403, so the API does not confirm that another account's identifiers exist.
Conventions
- JSON in, JSON out.
Content-Type: application/jsonon every request with a body. - Identifiers are prefixed and opaque:
wkr_devices,ibx_email addresses,msg_messages,acct_accounts. - Timestamps are ISO 8601 in UTC.
- Monetary amounts are integer minor units:
9900is $99.00. GET /workersandGET /inboxes/:id/messagesare cursor paginated. Pass?limit=(default 50, at most 200) and?cursor=from the previous response'snextCursor; a nullnextCursormeans the end.
Endpoints
Account
POST /accounts | Register an account id derived on the device from a recovery phrase. Takes an accountId, an authVerifier and an authPublicKey, and returns the account with a session token and expiresAt. The phrase is never sent |
POST /accounts/challenge | Send an accountId. Returns a single-use nonce, its expiresAt two minutes out, and an ephemeral X25519 challengeKey. The answer is the same whether or not the account exists |
POST /accounts/verify | Send accountId, nonce and a proof: an HMAC-SHA256 over the nonce and account id, keyed by the X25519 shared secret between the account key and the challenge key. Returns the account with a session token and expiresAt. Nothing secret crosses the wire |
GET /accounts/me | The authenticated account and its plan |
POST /accounts/signout | Revoke the current session |
GET /accounts/sessions | The account's live sessions, with the current one marked |
DELETE /accounts/sessions | Revoke every session except the current one |
DELETE /accounts/sessions/:id | Revoke one session |
DELETE /accounts/me | Erase the account and everything in it, immediately. Its addresses are retired, never reissued |
GET /overview | Account counters for the dashboard |
GET /activity | Recent account activity, up to limit entries (default 20, at most 200) |
Devices
GET /workers | List devices, paginated with limit (default 50, at most 200) and cursor. Names and tags are sealed, so search and filtering happen in your client |
POST /workers/pairing-session | Called by the device, unauthenticated. Takes a platform (ios, android, web or unknown) and a deviceName. Returns the session with its token and 8-digit code |
GET /workers/pairing-session/:token | Called by the device to poll the session's state. Returns the device credential once, on the first poll after the claim |
DELETE /workers/pairing-session/:token | Called by the device to cancel a session that has not been claimed |
POST /workers/pair/lookup | Look up a waiting session by its 8-digit code |
POST /workers/pair | Claim the device for the account. Takes the sessionId, a client-generated workerId (wkr_…) and the sealed profile |
POST /workers/:id/keys | Hand a paired device its sealed address keys |
GET /workers/:id | One device |
PATCH /workers/:id | Replace the sealed profile (name, tags, notes), or set status to disabled, which revokes the device credential |
DELETE /workers/:id | Remove the device and its credential. Its email addresses stay on the account, unassigned |
POST /workers/heartbeat | Report that the device is active. Worker credential only |
Email addresses
GET /domains | Domains this account can create addresses on |
POST /domains/custom | Add a domain you own (Scale and up). Returns the TXT record that proves ownership |
GET /domains/custom | Your own domains and where each one is in setup |
POST /domains/custom/:domain/check | Check the TXT record and nameservers again |
DELETE /domains/custom/:domain | Remove one of your own domains |
GET /inboxes | Every email address on the account |
POST /inboxes | Create an email address. Send a client-generated id (ibx_…), a domain and optionally the address's publicKey and retentionDays (1 to 90, default 7). Send a prefix and the server adds a random ending, or send localPart for an exact name (Scale and up). Every address counts toward the plan limit, assigned or not |
PATCH /inboxes/:id | Update the sealed label (meta) or retentionDays. A shorter window applies to mail already stored |
DELETE /inboxes/:id | Delete an address with its mail and attachments, freeing its place on the plan. The address is retired and never reissued |
POST /workers/:id/inboxes | Assign an email address to a device by inboxId. On Free a device holds one |
DELETE /workers/:id/inboxes/:inboxId | Unassign an email address from a device. It stays on the account and still counts toward the plan |
GET /inboxes/allocation | How many email addresses the plan allows, how many are assigned, and how many more you can create |
Messages
GET /inboxes/:id/messages | Mail for one email address, newest first, paginated with limit and cursor |
GET /workers/messages | The newest mail across the calling device's email addresses, up to 200. Worker credential only |
GET /messages/:id | One sealed message |
GET /messages/:id/attachments/:index | One sealed attachment |
POST /messages/:id/read | Mark as read, or unread with { "read": false } |
POST /messages/send | Send mail from an address you own. Takes from, to, subject, text and optionally html. Returns sent and a messageId. Account sessions only |
Notifications
GET /notifications/preferences | Which push notifications the account receives |
PATCH /notifications/preferences | Turn notification kinds on or off |
PATCH /notifications/inboxes/:id | Mute or unmute new-mail notifications for one address |
POST /notifications/devices | Register a phone's push token |
DELETE /notifications/devices/:token | Remove a push token |
Billing
GET /plans | Available plans and their limits |
GET /subscription | The current subscription |
POST /subscription/plan | Move to a plan priced the same or lower. Refused with downgrade_blocked if the account holds more addresses than the new plan allows, or more than one device when moving to Free. Upgrades return payment_required and go through checkout |
GET /billing/providers | Payment methods this deployment takes |
POST /billing/checkout | Open a hosted checkout for a planId with a provider: polar for card or nowpayments for crypto. Returns the checkout url and the pending payment. A provider this deployment has no keys for, or one an operator has switched off, returns provider_unavailable |
GET /billing/payments/:id | One payment and its status. The plan changes when it is paid |
Abuse
POST /abuse/reports | Report an address or message. Unauthenticated |
Pairing flow
Pairing follows the OAuth device flow: a device with no keyboard and no account is claimed by an authenticated one.
A session moves through four states: waiting, operator_detected, awaiting_inbox and inbox_assigned. A device that gives up can cancel its session with DELETE /workers/pairing-session/:token.
The guarantees the flow depends on:
- Pairing tokens are cryptographically random and server-generated.
- The 8-digit code is a lookup key onto the token, not the secret itself.
- Sessions do not expire on a timer, so a device can wait as long as it needs to. Each is consumable exactly once, and an unclaimed session can be cancelled. Sessions nobody claims are removed after 14 days.
- Claiming requires a valid Operator bearer token. On Free, claiming a second device returns
worker_limit_reached. - The device credential is minted only after the Operator confirms, is scoped to that one device, and is independently revocable.
- Code lookups are limited per account and per IP: after ten failed lookups in five minutes, further lookups return
429.
Events
Your code cannot receive events yet: there is no socket to subscribe to and no webhook to register. The names below are the events Mailcycle's own apps already pass between screens, and the contract for the public stream.
Each frame is { "type": …, "payload": … }. Payloads carry ids, never content: to show a message, fetch it by id and decrypt it in your client.
| Event | Fires when |
|---|---|
worker.paired | A device is added to the account |
worker.connected | A device comes online |
worker.disconnected | A device goes offline |
worker.removed | A device is removed from the account |
inbox.created | An email address is created |
inbox.assigned | An email address is assigned to a device |
inbox.removed | An email address is unassigned from a device |
message.received | Mail arrives for an email address on the account |
subscription.updated | The account's plan changes |
Webhook signing and delivery guarantees will be documented here when webhooks ship.
Errors
Errors carry an HTTP status and a stable machine-readable code alongside a human message. Match on code, never on the message text.
| Status | Meaning |
|---|---|
| 400 | Malformed request or invalid parameter, e.g. invalid_json or invalid_field |
| 401 | Missing, expired or revoked token, e.g. unauthenticated or session_expired |
| 402 | The plan does not allow it, e.g. allocation_exhausted, worker_limit_reached or payment_required |
| 403 | Not permitted for this identity or plan, e.g. plan_required, not_your_address or account_suspended |
| 404 | No such record, or not yours |
| 409 | Conflicts with current state, e.g. downgrade_blocked, already_used or address_taken |
| 429 | rate_limited; see Retry-After |
| 5xx | A failure on our side or at a provider, e.g. internal or send_failed. Retry with backoff. |
Rate limits
Requests are limited per account and per IP in two layers: short-term limits at Cloudflare's edge, and hourly and 30-day quotas behind them. A limited request returns 429 with a Retry-After header in seconds. The edge limits absorb floods and are not published as fixed numbers.
The quota you are most likely to meet is your plan's message allowance, from 3,000 a month on Free to 20,000,000 on Fleet (see pricing). Sending and reading mail back have hourly limits, and pairing-code lookups are limited most tightly.
If you are planning a bulk import or a migration that may hit these limits, tell us first.
Roadmap
What we are building next, in the order we expect to ship it. Nothing here is available today.
- API keys. Long-lived, scoped and individually revocable, issued from the console, so automation does not need a recovery phrase.
- Webhooks. Signed deliveries for inbound mail and device lifecycle events, with retries and a delivery log.
- Event stream. The socket described above, open to customer code.
- SDKs. Thin typed clients over the REST surface, including client-side decryption, which the API cannot do.
- A Developer section in the console. Keys, usage, event logs and developer settings in one place.
There are no dates. When something on this list ships it is announced in the changelog and the badge above it changes.