Devices
See Pairing devices for the flow, and AI agents for agents, which are devices with platform: "agent". For every event on the account, use the event stream.
| Endpoint | What it does |
|---|---|
GET /workers | List devices, paginated with limit and cursor. Filter with kind (device or agent) and status (online, offline, disabled or all, the default), group (a device group’s id), and order with sort: id (the default), recent (last active first) or created (newest first). Returns workers, nextCursor and total, the count matching the filters. Names and tags are sealed, so search in your client. |
GET /workers/:id | One device. |
PATCH /workers/:id | Replace the sealed profile (name, tags, notes), set status to disabled, which refuses the device with 403 device_disabled until status is set back to offline, or set an agent’s sendMode (off, reply or full). Session only. |
DELETE /workers/:id | Remove the device and its credential. Its addresses stay on the account, unassigned. An open device is told at once over its live connection. Session only. |
POST /workers/bulk | Apply one action (disable, enable or remove) to up to 200 devices, as workerIds, or to a device group’s members, as groupId. Remove works as DELETE /workers/:id. Returns affected, how many of them were this account’s and changed. Operator and up; 402 plan_capability_required below. Session only. |
GET /device-groups | The account’s device groups: id, sealed meta (the name), createdAt and workerIds. Fleet and up. |
POST /device-groups | Create a group from a client-generated id (grp_…) and a sealed meta, bound to the id as a device name is. Up to 200 groups. Session only. |
PATCH /device-groups/:id | Replace the sealed meta. Session only. |
DELETE /device-groups/:id | Delete the group. Its devices are not affected. Session only. |
POST /device-groups/:id/members | Set the group’s members to exactly workerIds, up to 5,000 of the account’s own devices; an id that is not one is 404. A device can be in any number of groups. Session only. |
POST /workers/pairing-session | Called by the device, without a token. Takes a platform (ios, android, web, unknown or agent), a deviceName and, optionally, a deviceKey (an X25519 public key, base64url) for the typed-code key handover and an installId (16 to 64 base64url characters) that the device keeps across pairings. Returns the session’s id, token and 8-digit code. Codes are unique among live sessions. If no free code can be drawn, it is 503 code_unavailable; ask again. At most 120 an hour per IP. |
GET /workers/pairing-session/:token | Called by the device to poll the session. Returns the session, the device credential once, on the first poll after the claim, and the sealed keyBundle and wrapSeal when there are any. An unknown token is 404 session_not_found. |
DELETE /workers/pairing-session/:token | Called by the device when it leaves its pairing screen. A session nobody has claimed is deleted, and an account that looked it up hears a sync frame with the scope pairing on its event stream. A device claimed but that has not yet collected its credential is removed, as with DELETE /workers/:id. A device that has collected its credential is left alone. |
POST /workers/pair/lookup | Find a waiting session by its 8-digit code. Returns the sessionId, platform, deviceName, and the device’s deviceKey if it sent one. A code nobody is waiting with is 404 session_not_found, and one already claimed is 409 already_used. After 10 wrong codes in 5 minutes, per account and per IP, lookups return 429. Session only. |
POST /workers/pair/status | Where collected codes stand. Takes up to 90 sessionIds, as returned by lookup, and returns sessions, each a sessionId and a state: waiting (still on the device’s screen), claimed (a device on this account) or gone (the device left, showed a new code or was removed). Session only. |
POST /workers/pair | Claim the device. Takes the sessionId, a client-generated workerId (wkr_…) and the sealed profile, and optionally keyTransport (optical or relayed, the default), a sealed keyBundle and, on the typed-code path, a sealed wrapSeal. Claiming an agent needs Scale or above. If the session carried an installId this account has paired before, the old device is removed first, as with DELETE /workers/:id, and does not count toward the limit. Only the account that looked the code up can claim it (409 lookup_required otherwise), and a claim with a wrapSeal must be relayed (400 invalid_field otherwise). On Free, a second device is 402 worker_limit_reached. Returns the worker. Session only. |
POST /workers/:id/keys | Hand a paired device its sealed address keys, as keyBundle, through its pairing session. Session only. |
POST /workers/heartbeat | Report that the device is active. Device credential only. |
DELETE /workers/self | Unpair, from the device. Removes it from the account as DELETE /workers/:id does: its credential is revoked, its addresses go back to the account unassigned, and the account hears worker.removed. Device credential only. |
GET /workers/self | The device itself, including its sendMode, the addresses it holds, each with its unreadCount, unreadCount across all of them, and planSends, whether the account’s plan sends mail at all. Device credential only. |
GET /workers/messages | The device’s mail, newest first. Filter with inboxId, inboxIds (comma-separated, up to 500), id or folder; page back with before, the previous response’s olderCursor; and fetch only what arrived after a message you have with newer, as on GET /messages. Returns total and unread for the whole folder, counts with the same per address, and more. With after (a message id) or since (a time), what arrived in the inbox since, oldest first, and wait (seconds, up to 25) holds the request open until something does; nextCursor is then the id to pass as after next. Without after or since, nextCursor is always null. Device credential only. |
GET /workers/live | A WebSocket for live signals. Device credential only. See below. |
A device returns id, operatorId, platform, kind (device or agent), sendMode, status (offline or disabled), online, createdAt, lastActiveAt, pausedAt and the sealed profile. GET /workers and GET /workers/:id also return unreadCount, the unread mail in the inbox folder across the device’s addresses.
“Session only” endpoints answer 403 session_required to an API key.
lastActiveAt is set by the server on every request the device makes, rounded down to five minutes. online is true when the device is not disabled and was active in the last 15 minutes, by the server’s clock, so every client agrees.
Live connection
Section titled “Live connection”GET /workers/live upgrades to a WebSocket. Put the credential in the subprotocol list: offer mailcycle.v1 first and the device credential second. The server answers with mailcycle.v1.
const socket = new WebSocket('wss://api.mailcycle.email/workers/live', ['mailcycle.v1', credential]);The connection is receive-only. Frames are JSON:
type | Sent when | payload |
|---|---|---|
revoked | The device was removed. The server closes the socket with code 4001. | None |
inbox.assigned | An address was assigned to the device. Fetch GET /workers/self. Its keys arrive in the keyBundle on the device’s pairing session once the account hands them over. | workerId, inboxId |
inbox.removed | An address was unassigned from the device. | workerId, inboxId |
keys.updated | The account handed the device a new set of address keys. Fetch them from the keyBundle on its pairing session. | None |
message.received | Mail arrived at one of the device’s addresses. List it with GET /workers/messages. | messageId, inboxId |
sync | Something on the account changed in scope (workers, inboxes, messages or admin). Refetch what you show. | scope, and origin, the X-Mailcycle-Client header of the client that made the change, if it sent one |
Every frame except sync also carries "origin": "server". Frames carry ids, never content. Disabling a device and deleting an address are signalled only by a sync frame, and take effect at the device’s next request. List mail with GET /workers/messages. A device whose credential no longer works gets one revoked frame and a close. Send ping to keep the connection alive through NATs; the server answers pong. A device may hold up to eight sockets at once, and the oldest is closed with 4000 when a ninth opens. A request that is not a WebSocket upgrade returns 426 upgrade_required.
The socket is a shortcut, not a guarantee. Keep checking in, which catches anything missed while it was down.
© Northlab Studios Ltd, trading as Mailcycle. Registered in England and Wales, company no. 17462734. Registered office: 167-169 Great Portland Street, London W1W 5PF.