Developers
.mdWebhooks
Updated 9 Oct 2026
A webhook is a signed POST that SuperMailOS sends to a URL you choose the moment something happens in a mailbox, so your app can react without polling. Each delivery is signed with a secret only you and we hold, is retried if your server is down, and carries metadata only: never the message body, and never who was Bcc’d.
Set up an endpoint
- Add the endpoint. A workspace admin opens Settings → Developers, goes to Webhooks, enters the URL and ticks the events it wants.
- Copy the signing secret. Open Signing secret & test on the endpoint. Keep the secret in an environment variable or a secret manager.
- Send a test. The same panel has Send a test, which delivers a signed sample event to your URL right now and shows what your server answered.
The URL must be reachable from the public internet. Addresses on private networks, loopback and cloud metadata ranges are refused, and so are redirects: a 3xx answer counts as a failure. Use https.
Events
An endpoint can subscribe to any of these. Each one arrives in the same envelope.
| Event | When it fires |
|---|---|
| message.inbound | New mail was filed into one of your mailboxes. A retry of the same message by the sending server does not fire it twice. |
| message.outbound | A message was sent from one of your mailboxes: from the web app, a mail app over SMTP, or the API. |
| message.delivered | The recipient's mail server accepted a message you sent. |
| message.bounced | A recipient's server refused a message you sent. A hard bounce also adds the address to your suppression list. |
| message.complaint | A recipient reported a message as spam, through their provider's feedback loop. The address is suppressed. |
What a delivery looks like
Every delivery is an HTTP POST with a JSON body and these headers:
| Header | Meaning |
|---|---|
| X-SuperMail-Event | The event name, such as message.inbound. |
| X-SuperMail-Delivery | The event id, the same as id in the body. Retries and resends reuse it, so you can dedupe on it. |
| X-SuperMail-Attempt | 1 for the first try, then 2, 3 and so on for each retry. |
| X-SuperMail-Timestamp | Unix seconds when this attempt was signed. Each retry is signed afresh. |
| X-SuperMail-Signature | t=<timestamp>,v1=<hex>. See verifying the signature. |
POST /webhooks/supermailos HTTP/1.1
Content-Type: application/json
User-Agent: SuperMailOS-Webhooks/1.0
X-SuperMail-Event: message.inbound
X-SuperMail-Delivery: evt_8c1f0b52a9d3
X-SuperMail-Attempt: 1
X-SuperMail-Timestamp: 1791538867
X-SuperMail-Signature: t=1791538867,v1=5b0e…c41aThe body is always id, event, createdAt and a data object that depends on the event. Here is message.inbound:
{
"id": "evt_8c1f0b52a9d3",
"event": "message.inbound",
"createdAt": "2026-10-09T09:41:07.412Z",
"data": {
"emailId": "msg_5d2e7a90c1",
"threadId": "thr_31d9a47be2",
"mailboxId": "mbx_90ab12cd34",
"mailbox": "orders@shop.example",
"messageId": "<CAF3x9-7q@mail.example.net>",
"inReplyTo": null,
"from": { "email": "hannah@example.net", "name": "Hannah Lee" },
"to": [{ "email": "orders@shop.example" }],
"cc": [],
"subject": "Where's my order?",
"snippet": "Hi, I ordered on Monday and haven't had a shipping email yet…",
"hasAttachments": false,
"attachmentCount": 0,
"isSpam": false,
"receivedAt": "2026-10-09T09:41:07.001Z"
}
}We may add fields to data over time; existing fields are not renamed or removed. Ignore the ones you do not know.
Event data
message.inbound
| Name | Type | Description |
|---|---|---|
| emailId | string | Internal message id — GET /api/v1/messages/{emailId}. |
| threadId | string | |
| mailboxId | string | |
| mailbox | string (email) | |
| messageId | string | RFC 5322 Message-ID. |
| inReplyTo | string | RFC In-Reply-To, when present. |
| from | EmailAddress | |
| to | EmailAddress[] | |
| cc | EmailAddress[] | |
| subject | string | |
| snippet | string | |
| hasAttachments | boolean | |
| attachmentCount | integer | |
| isSpam | boolean | Filed to Spam on arrival. |
| receivedAt | string (date-time) |
message.outbound
| Name | Type | Description |
|---|---|---|
| threadId | string | |
| messageId | string | RFC 5322 Message-ID. |
| mailbox | string (email) | |
| from | EmailAddress | |
| to | EmailAddress[] | |
| subject | string |
message.delivered
| Name | Type | Description |
|---|---|---|
| threadId | string | |
| messageId | string | |
| mailbox | string (email) | |
| to | EmailAddress[] | |
| subject | string | |
| deliveredAt | string (date-time) |
message.bounced
| Name | Type | Description |
|---|---|---|
| mailbox | string (email) | |
| recipient | string (email) | |
| hard | boolean | |
| detail | string |
message.complaint
| Name | Type | Description |
|---|---|---|
| mailbox | string (email) | |
| recipient | string (email) |
message.inbound deliberately carries only a short snippet. To read the message, call GET /api/v1/messages/{emailId} with an API key (see the API reference). Bcc recipients are never included in any event.
Verify the signature
Anyone can POST to your URL, so check the signature before you trust a delivery. The signature is an HMAC-SHA256, in hex, of the timestamp, a dot, and the raw request body, keyed with your endpoint’s secret:
HMAC_SHA256(secret, "<t>." + rawBody) → v1- Read the raw body before any JSON parsing. A re-serialized body will not match.
- Split
X-SuperMail-Signatureintotandv1. - Reject the request if
tis more than 5 minutes from your clock. This stops a captured request being replayed later. - Compute the HMAC and compare it to
v1in constant time.
import crypto from "node:crypto";
// secret: the endpoint's signing secret (Settings → Developers → Webhooks)
// header: the X-SuperMail-Signature request header
// rawBody: the body exactly as received (string or Buffer), not re-serialized JSON
export function verifySuperMail(secret, header, rawBody, toleranceSec = 300) {
const parts = Object.fromEntries(
String(header ?? "")
.split(",")
.map((p) => p.trim().split("=")),
);
const t = Number(parts.t);
if (!parts.v1 || !Number.isFinite(t)) return false;
// An old timestamp means a captured request being replayed.
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import express from "express";
import { verifySuperMail } from "./verify.js";
const app = express();
// express.raw keeps the exact bytes the signature was computed over.
app.post("/webhooks/supermailos", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifySuperMail(
process.env.SUPERMAILOS_WEBHOOK_SECRET,
req.get("X-SuperMail-Signature"),
req.body,
);
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
// event.id is the same on every retry and resend: skip ids you have already handled.
res.sendStatus(200); // acknowledge first, do the work after
if (event.event === "message.inbound") {
// Metadata only. Fetch the body when you need it:
// GET https://supermailos.com/api/v1/messages/{event.data.emailId}
}
});import hashlib
import hmac
import time
def verify_supermail(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
"""secret: the endpoint's signing secret. header: X-SuperMail-Signature.
raw_body: the body exactly as received (request.get_data() in Flask)."""
parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or not t.isdigit():
return False
# An old timestamp means a captured request being replayed.
if abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)Respond, retries and ordering
Answer with any 2xx within 10 seconds. Acknowledge first and do the work afterwards; the response body is ignored except for the first few kilobytes, which we keep in the delivery log to help you debug.
Anything else is a failure: a timeout, a connection error, a non-2xx answer or a redirect. We try again on a backoff, 7 attempts in total over about 21 hours. After the first attempt, the waits are 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours. A destination we refuse to call (a private address, say) fails at once without retries.
- Delivery is at least once. A retry can land twice if your first answer was lost. Dedupe on
id. - Order is not guaranteed. A retried event can arrive after a newer one. Use
createdAtor fetch the current state from the API when order matters.
When an endpoint keeps failing
If 15 deliveries in a row run out of attempts, which takes about a day each, we switch the endpoint off so we stop calling a dead server, and email the workspace admins once. Any successful delivery resets the count. Fix your server, then press Re-enable on the endpoint under Settings → Developers.
Delivery log and resend
Each delivery is listed under Settings → Developers with its state, the response code, the number of attempts, when the next retry is due and the first part of what your server answered. Finished deliveries are kept for 30 days. Resend replays a delivery once, immediately, with the same payload and the same event id, so a handler that dedupes on id sees it as the repeat it is.
Next: the REST API reference to fetch the message an event points at, or connect your AI over MCP.
Was this page helpful?