Skip to content

Webhooks

A webhook sends your account’s events to an HTTPS URL as they happen: mail arriving, devices pairing, addresses changing. Webhooks are on Operator and above.

Events carry ids, never content. To show a message, fetch it by id and decrypt it in your client. An event says no more than GET /activity already does. The same events arrive over the event stream for code that is running and connected.

In the app, open API and add one on the Webhooks tab, or call POST /webhook-endpoints with a signed-in session:

{
"id": "whk_k3v9x2mq7a8b",
"url": "https://example.com/mailcycle",
"events": ["message.received", "worker.removed"],
"meta": { "…": "sealed" }
}

events is a list of the types below, or ["*"] for all of them. The response carries the webhook and its signing secret, which starts whsec_ and is shown once. See Webhooks for every endpoint.

The URL must use HTTPS and a hostname. IP addresses, local names such as localhost or *.local, and Mailcycle’s own domains are refused. Redirects are not followed.

An account can hold 10 webhooks. Adding, changing and removing one needs a signed-in session. An API key can list them, read deliveries, send a test and send an event again, but cannot add a URL that would outlive the key.

EventFires whenPayload
worker.pairedA device or agent is added to the accountworkerId, kind (device or agent)
worker.connectedA device reported disconnected is active againworkerId
worker.disconnectedA device has not been active for two hours. Checked hourly.workerId
worker.removedA device is removedworkerId
inbox.createdAn email address is createdinboxId
inbox.deletedAn email address is deletedinboxId
inbox.assignedAn address is given to a deviceworkerId, inboxId
inbox.removedAn address is taken off a device, including when the device is removedworkerId, inboxId
message.receivedMail arrives at an addressmessageId, inboxId
message.sentMail is sent from an addressmessageId (the copy in Sent, or null), inboxId, via (app, api, device, agent, or member for a team member sending from a shared address)
subscription.updatedThe plan changes, a change is scheduled for the end of the paid month, or renewal is turned off or back on in the App Store or Google PlayplanId; pendingPlanId for a scheduled change; renewing when the store’s renewal changed
webhook.testYou send a testwebhookId

Mail held during a missed payment fires message.received when it is released, not when it reached the server.

A POST with a JSON body:

{
"id": "evt_4k2m9x7q1b8c3v6n0z5a",
"type": "message.received",
"createdAt": "2026-09-19T09:14:03.000Z",
"payload": { "messageId": "msg_91be02c4a7d1", "inboxId": "ibx_k3v9x2mq7a" }
}

And these headers:

HeaderValue
Mailcycle-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
Mailcycle-Event-IdThe event id. The same on every attempt and redelivery.
Mailcycle-Event-TypeThe event type.
Mailcycle-Delivery-IdThis delivery, whd_….
User-AgentMailcycle-Webhooks/1

Answer with any 2xx within 10 seconds. Anything else, a redirect included, counts as a failure. Do slow work after you answer.

Events can arrive more than once and out of order. Deduplicate on Mailcycle-Event-Id, and use createdAt to order them.

The signature is an HMAC-SHA256, in hex, of the timestamp, a full stop and the raw body, keyed with the webhook’s secret. Check it against the raw bytes before parsing, and refuse a timestamp more than five minutes old.

import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret, rawBody, header) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const given = Buffer.from(parts.v1 ?? '', 'utf8');
return given.length === expected.length && timingSafeEqual(given, Buffer.from(expected, 'utf8'));
}

Each SDK has this check built in, for example verifyWebhookSignature in TypeScript and verify_webhook_signature in Python.

To change the secret, call POST /webhook-endpoints/:id/rotate-secret or use New secret in the app. The old secret stops working at once.

A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 12 hours: eight attempts over about a day and a half. Each delivery is in the log for 30 days, with its status, attempts and the last response code. Any delivery that has finished can be sent again from the log.

A webhook whose every attempt has failed for three days is switched off, and the account gets a push notification if the Webhooks notification is on. Switch it back on in the app or with PATCH /webhook-endpoints/:id and { "status": "active" }. Events that happened while it was off are not sent.

While the account is on a plan without webhooks, after a downgrade or a missed payment, nothing is sent and pending deliveries are marked failed. They start again when the plan includes webhooks.

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