Skip to content

Pairing devices

A device (a “worker” in the API, ids starting wkr_) is paired by an account that is already signed in. Pairing follows the OAuth device flow: a device with no account shows a code, and the account claims it.

Device Mailcycle API Account
│ │ │
├─ POST /workers/pairing-session ► │
│◄─ { token, code } ─────────────┤ │
├─ show QR(token) and code │ │
├─ GET …/pairing-session/:token ─► │
│◄─ state: waiting ──────────────┤ │
│ │◄─ POST /workers/pair/lookup ───┤
│◄─ state: operator_detected ────┤ │
│ │◄─ POST /workers/pair ──────────┤
│◄─ awaiting_inbox + credential ─┤ (credential sent once) │
│ │◄─ POST /workers/:id/inboxes ───┤
│ │◄─ POST /workers/:id/keys ──────┤
│◄─ state: inbox_assigned ───────┤ │
└─ open mail │ │
  1. POST /workers/pairing-session with a platform (ios, android, web, unknown, or agent for an AI agent), a deviceName, a deviceKey: an X25519 public key, base64url, that the account can seal a secret to, and optionally an installId, a random id the device keeps across pairings. No token needed. The response carries a pairing token and an 8-digit code. Show the token as a QR code, and the code for typing.
  2. Poll GET /workers/pairing-session/:token. The state moves through waiting, operator_detected, awaiting_inbox and inbox_assigned. The device credential is returned once, on the first poll after the account claims the device. The response also carries the sealed keyBundle once the account has handed over keys, and, on the typed-code path, the sealed wrapSeal.
  3. To give up, DELETE /workers/pairing-session/:token. Do this whenever the pairing screen closes, so an account holding the code stops waiting for this device.
  4. To unpair later, DELETE /workers/self with the device credential, then forget the credential. The account removes the device and hears it at once.

Looking up a code, claiming a device and handing it keys all need a signed-in session. An API key gets 403 session_required.

Each SDK does the steps below for you, for example mc.devices.pair() and mc.devices.assign() in TypeScript.

  1. POST /workers/pair/lookup with the 8-digit code finds the waiting session, and returns the device’s deviceKey if it sent one.
  2. POST /workers/pair claims it, with the sessionId, a client-generated workerId (wkr_…) and the sealed profile. The claim must come from the account that looked the code up; otherwise it returns 409 lookup_required. On Free, a second device returns worker_limit_reached. A device that sent an installId this account has paired before replaces its old entry: the old one is removed, its credential revoked and its addresses left unassigned, so pairing the same phone again never adds a second device.
  3. Assign it an address with POST /workers/:id/inboxes, then hand it the sealed address keys with POST /workers/:id/keys and a keyBundle. Do the same each time you assign it another address.

Nothing here is per device. A stack of devices can be read first and claimed afterwards: look every code up, hold the previews (and, on the QR path, each device’s wrapping secret) in memory, then claim them one after another and assign addresses at the end.

Two things make that safe. Sessions have no timer, so a code read ten minutes ago is still good. And only failed lookups count against the rate limit, so a batch of correct codes is never refused for its size.

A device can leave while you hold its code. Listen on the event stream: a sync frame with the scope pairing means a code you looked up was given up, and worker.removed means a device you claimed has gone. Either way, ask POST /workers/pair/status about the codes you hold and drop every one that comes back gone. Ask again after the stream reconnects.

Claim one at a time rather than in parallel: each claim mints a credential, and the address keys that follow are handed over as one bundle per device. Watch for worker_limit_reached on the first claim that hits the plan’s device cap, since every claim after it fails the same way.

The address keys are sealed on the account’s side under a wrapping secret the device holds, and the server relays a blob it cannot open.

  • QR code. The device puts its wrapping secret in the QR code, and the account reads it with the camera. The secret never crosses the network. This is keyTransport: optical.
  • Typed code. The 8 digits carry no secret. Instead the account mints a wrapping secret, seals it to the device’s deviceKey, and sends it with the claim as wrapSeal. The device opens it with its own private key. The server cannot read it by watching, but it relays the deviceKey, so it could in principle substitute its own at pairing. This is keyTransport: relayed. Scan the QR code where you can.

Don’t claim a device by typed code if it sent no deviceKey: it could never open its mail.

  • Pairing tokens are random, generated by the server and stored only as a hash.
  • The 8-digit code is a lookup key onto the token, not the secret itself.
  • Sessions have no timer, so a device can wait as long as it needs. Each is used once. Sessions nobody claims are removed after 14 days.
  • The device credential is minted only after the account confirms, is scoped to that device, and can be revoked on its own.
  • Code lookups are limited: after ten failed lookups in five minutes, lookups return 429.
  • The server keeps only a hash of the installId together with the account id, so the same device cannot be matched across accounts. The id is dropped from the pairing session when it is claimed.
  • A device reads, marks, stars, moves and deletes the mail at its own addresses with its credential, and opens it with the keys it was handed. See Reading mail.
  • On Personal and above it can send from those addresses. See Sending mail.
  • Every request a device makes stamps its lastActiveAt, from the server’s clock, rounded down to five minutes. POST /workers/heartbeat also reports activity. A device is online if it was active in the last 15 minutes.
  • An open device can hold a live connection, GET /workers/live, to hear at once when it is removed, an address is assigned to it or unassigned, or mail arrives at one of its addresses. See Devices. Without it, a device finds out at its next request.

GET /workers lists devices, PATCH /workers/:id replaces the sealed profile or sets status to disabled, and DELETE /workers/:id removes a device. Its addresses stay on the account, unassigned. Changing or removing a device needs a signed-in session; an API key can only list and read them. See Devices.

© Northlab Studios Ltd, trading as Mailcycle. Registered in England and Wales, company no. 17462734. Registered office: 167-169 Great Portland Street, London W1W 5PF.