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.
Add a webhook
Section titled “Add a webhook”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.
Events
Section titled “Events”| Event | Fires when | Payload |
|---|---|---|
worker.paired | A device or agent is added to the account | workerId, kind (device or agent) |
worker.connected | A device reported disconnected is active again | workerId |
worker.disconnected | A device has not been active for two hours. Checked hourly. | workerId |
worker.removed | A device is removed | workerId |
inbox.created | An email address is created | inboxId |
inbox.deleted | An email address is deleted | inboxId |
inbox.assigned | An address is given to a device | workerId, inboxId |
inbox.removed | An address is taken off a device, including when the device is removed | workerId, inboxId |
message.received | Mail arrives at an address | messageId, inboxId |
message.sent | Mail is sent from an address | messageId (the copy in Sent, or null), inboxId, via (app, api, device, agent, or member for a team member sending from a shared address) |
subscription.updated | The 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 Play | planId; pendingPlanId for a scheduled change; renewing when the store’s renewal changed |
webhook.test | You send a test | webhookId |
Mail held during a missed payment fires message.received when it is released, not when it reached the server.
What arrives
Section titled “What arrives”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:
| Header | Value |
|---|---|
Mailcycle-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
Mailcycle-Event-Id | The event id. The same on every attempt and redelivery. |
Mailcycle-Event-Type | The event type. |
Mailcycle-Delivery-Id | This delivery, whd_…. |
User-Agent | Mailcycle-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.
Check the signature
Section titled “Check the signature”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.
Retries
Section titled “Retries”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.