Webhooks
A webhook tells your system that something happened in your workspace, so you do not have to poll for it. Cirrux POSTs a small signed JSON body to an HTTPS URL you own, within seconds of the event.
Payloads are deliberately thin. They carry identifiers and nothing else, never a subject line or a message body. To read the content you fetch it back through the API with a token of your own, so the webhook itself can never become a way around who is allowed to read a mailbox.
Set up a destination
In the admin app
Webhooks are configured by a workspace admin at admin.cirrux.co/webhooks. Add a destination, give it a URL and a description, then open it and pick the mailboxes it should receive events for. A destination with no mailboxes selected receives nothing.
The destination page is also where you check on it later: the last seven days of deliveries and response times, the most recent attempts with their status codes, and a pause switch.
What a URL has to be
Cirrux only delivers to destinations that are reachable from the public internet:
httpsonly, on port 443, with a valid certificate.- The hostname must resolve to a public address. Private, loopback and link-local addresses are rejected.
This is checked when you save the URL and again on every delivery, so a destination whose DNS later points inward is disabled rather than retried. If you are developing locally, put a tunnel in front of your app and register the tunnel URL.
Events
One event type is available today. It is subscribed per mailbox, so subscribing three mailboxes to one destination is three separate subscriptions.
| Event | Description |
|---|---|
| email.received | An inbound email was delivered to the inbox of a subscribed mailbox. It fires once the message is stored, threaded and labelled, not when it first hits the server, so the labels you read back are final by the time you receive the call. Mail that does not reach the inbox does not fire it: anything classified as spam, and anything a filter rule deletes outright. Neither does mail that was never received, such as a draft you save over IMAP or the API, or a message copied in by an import. |
The request
Headers and body
Every delivery is a POST with a JSON body and these headers:
| Header | Description |
|---|---|
| X-Cirrux-Signature | HMAC-SHA256 of the body, as t={timestamp},v1={signature}. See below. |
| X-Cirrux-Event | The event type, for example email.received. |
| X-Cirrux-Delivery-ID | Identifies this attempt. A retry of the same event has a different value. |
| X-Cirrux-Attempt | Attempt number, from 1 to 5. |
{
"id": "whevt_9f2c1b40-5d3e-4a71-9c88-2f6b0d1e77aa",
"type": "email.received",
"workspace_uuid": "b3f1c2a4-3333-4a2b-8c3d-000000000002",
"created_at": "2026-09-07T09:12:44+00:00",
"data": {
"email_uuid": "7c5a91de-0f44-4d2b-9a10-3e8c5b21d004"
}
}id identifies the event and stays the same across every retry of it, so it is the value to deduplicate on. Reply with any 2xx status within 10 seconds. Anything else, including a timeout, counts as a failure.
Verify the signature
Sign the string {timestamp}.{raw body} with your destination’s signing secret and compare it to the v1 value. The secret is on the destination page in the admin app, behind a reveal toggle.
Use the raw request body, exactly as received. Parsing the JSON and serialising it again will change the bytes and the signature will not match.
import crypto from 'node:crypto'
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((part) => {
const i = part.indexOf('=')
return [part.slice(0, i), part.slice(i + 1)]
}),
)
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex')
if (expected.length !== (parts.v1 ?? '').length) return false
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}Compare with a constant-time function, as above. A plain === leaks the secret over enough requests.
Read the email
The payload gives you email_uuid. Fetch the message itself from the API, which re-checks that your token is allowed to read that mailbox:
curl https://api.cirrux.co/public_api/v1/emails/$EMAIL_UUID \ -H "Authorization: Bearer $CIRRUX_TOKEN"
This endpoint needs a user-scoped OAuth token with the email.read scope, belonging to someone with access to that mailbox. A workspace API key is rejected. Being an admin of the workspace is what lets you subscribe a mailbox; it is not what lets you read its mail.
Failures
Retries
A delivery that does not return 2xx is retried four times, after 1 minute, 5 minutes, 30 minutes and 2 hours. After the fifth attempt the event is given up on.
Retries are not ordered. A later event can arrive while an earlier one is still working through its retries, which is another reason to treat the payload as a pointer and read current state from the API rather than assuming the order you were called in is the order things happened.
Auto-disabling and pausing
After 50 consecutive events that exhaust all their retries, the destination is disabled and stops receiving anything. A single successful delivery resets the counter, so a destination with the occasional bad day is never disabled.
A disabled destination shows why on its page in the admin app, and you turn it back on from the same place. You can also pause one yourself while you are working on the receiving end. Events that happen while a destination is paused or disabled are not queued up and are not replayed when it comes back.
History
Delivery history is kept for 30 days and then removed. The charts on the destination page cover the last seven days of that.