Skip to content
Mailcycle
Get started
Developers

The same API the console runs on.

Programmable email address creation, device provisioning and mail retrieval over one public HTTP API. Mailcycle's own apps are built entirely on it. Parts that are not built yet are labelled planned.

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/challenge for a nonce, then POST /accounts/verify with 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.
curl https://api.mailcycle.email/workers \
  -H "Authorization: Bearer $MAILCYCLE_TOKEN"

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/json on 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: 9900 is $99.00.
  • GET /workers and GET /inboxes/:id/messages are cursor paginated. Pass ?limit= (default 50, at most 200) and ?cursor= from the previous response's nextCursor; a null nextCursor means the end.

Endpoints

Account

POST /accountsRegister 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/challengeSend 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/verifySend 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/meThe authenticated account and its plan
POST /accounts/signoutRevoke the current session
GET /accounts/sessionsThe account's live sessions, with the current one marked
DELETE /accounts/sessionsRevoke every session except the current one
DELETE /accounts/sessions/:idRevoke one session
DELETE /accounts/meErase the account and everything in it, immediately. Its addresses are retired, never reissued
GET /overviewAccount counters for the dashboard
GET /activityRecent account activity, up to limit entries (default 20, at most 200)

Devices

GET /workersList 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-sessionCalled 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/:tokenCalled 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/:tokenCalled by the device to cancel a session that has not been claimed
POST /workers/pair/lookupLook up a waiting session by its 8-digit code
POST /workers/pairClaim the device for the account. Takes the sessionId, a client-generated workerId (wkr_…) and the sealed profile
POST /workers/:id/keysHand a paired device its sealed address keys
GET /workers/:idOne device
PATCH /workers/:idReplace the sealed profile (name, tags, notes), or set status to disabled, which revokes the device credential
DELETE /workers/:idRemove the device and its credential. Its email addresses stay on the account, unassigned
POST /workers/heartbeatReport that the device is active. Worker credential only

Email addresses

GET /domainsDomains this account can create addresses on
POST /domains/customAdd a domain you own (Scale and up). Returns the TXT record that proves ownership
GET /domains/customYour own domains and where each one is in setup
POST /domains/custom/:domain/checkCheck the TXT record and nameservers again
DELETE /domains/custom/:domainRemove one of your own domains
GET /inboxesEvery email address on the account
POST /inboxesCreate 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/:idUpdate the sealed label (meta) or retentionDays. A shorter window applies to mail already stored
DELETE /inboxes/:idDelete an address with its mail and attachments, freeing its place on the plan. The address is retired and never reissued
POST /workers/:id/inboxesAssign an email address to a device by inboxId. On Free a device holds one
DELETE /workers/:id/inboxes/:inboxIdUnassign an email address from a device. It stays on the account and still counts toward the plan
GET /inboxes/allocationHow many email addresses the plan allows, how many are assigned, and how many more you can create

Messages

GET /inboxes/:id/messagesMail for one email address, newest first, paginated with limit and cursor
GET /workers/messagesThe newest mail across the calling device's email addresses, up to 200. Worker credential only
GET /messages/:idOne sealed message
GET /messages/:id/attachments/:indexOne sealed attachment
POST /messages/:id/readMark as read, or unread with { "read": false }
POST /messages/sendSend 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/preferencesWhich push notifications the account receives
PATCH /notifications/preferencesTurn notification kinds on or off
PATCH /notifications/inboxes/:idMute or unmute new-mail notifications for one address
POST /notifications/devicesRegister a phone's push token
DELETE /notifications/devices/:tokenRemove a push token

Billing

GET /plansAvailable plans and their limits
GET /subscriptionThe current subscription
POST /subscription/planMove 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/providersPayment methods this deployment takes
POST /billing/checkoutOpen 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/:idOne payment and its status. The plan changes when it is paid

Abuse

POST /abuse/reportsReport 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.

Worker                          Backend                       Operator
  │                                │                              │
  ├─ POST /workers/pairing-session ►                              │
  │                                ├─ mint token + 8-digit code   │
  │◄─ { token, code } ─────────────┤   no timer; single use       │
  │                                │                              │
  ├─ render QR(token) + code       │                              │
  ├─ GET …/pairing-session/:token ─►                              │
  │◄─ state: waiting ──────────────┤                              │
  │                                │◄─ POST /workers/pair/lookup ─┤
  │◄─ state: operator_detected ────┤                              │
  │                                │◄─ POST /workers/pair ────────┤
  │                                ├─ consume session (once)      │
  │                                ├─ create Worker + credential  │
  │◄─ awaiting_inbox + credential ─┤   (credential sent once)     │
  │                                │◄─ POST /workers/:id/inboxes ─┤
  │                                │◄─ POST /workers/:id/keys ────┤
  │◄─ state: inbox_assigned ───────┤                              │
  └─ open mail interface           │                              │

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

PlannedWebhooks and the public event stream

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.

EventFires when
worker.pairedA device is added to the account
worker.connectedA device comes online
worker.disconnectedA device goes offline
worker.removedA device is removed from the account
inbox.createdAn email address is created
inbox.assignedAn email address is assigned to a device
inbox.removedAn email address is unassigned from a device
message.receivedMail arrives for an email address on the account
subscription.updatedThe account's plan changes
{
  "type": "message.received",
  "payload": {
    "messageId": "msg_91be",
    "inboxId": "ibx_4c7a"
  }
}

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.

{
  "code": "downgrade_blocked",
  "message": "You have 340 email addresses, which is more than Operator allows. Delete 90 first."
}
StatusMeaning
400Malformed request or invalid parameter, e.g. invalid_json or invalid_field
401Missing, expired or revoked token, e.g. unauthenticated or session_expired
402The plan does not allow it, e.g. allocation_exhausted, worker_limit_reached or payment_required
403Not permitted for this identity or plan, e.g. plan_required, not_your_address or account_suspended
404No such record, or not yours
409Conflicts with current state, e.g. downgrade_blocked, already_used or address_taken
429rate_limited; see Retry-After
5xxA 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.

Build on Mailcycle.

Create an account in the app, run the handshake and create an email address over the API.