DocsWebhooks

Developers

.md

Webhooks

Updated 17 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

  1. Add the endpoint. A workspace admin opens Settings → Developers, goes to Webhooks, enters the URL and ticks the events it wants.
  2. Copy the signing secret. Open Signing secret & test on the endpoint. Keep the secret in an environment variable or a secret manager.
  3. 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.

EventWhen it fires
message.inboundNew mail was filed into one of your mailboxes. A retry of the same message by the sending server does not fire it twice.
message.outboundA message was sent from one of your mailboxes: from the web app, a mail app over SMTP, or the API.
message.deliveredThe recipient's mail server accepted a message you sent.
message.bouncedA recipient's server refused a message you sent, as our own mail server saw it. A hard bounce also adds the address to your suppression list. A bounce email another server sends back later doesn't fire it (anyone who got your message could write one): the mailbox gets a notice instead.
message.complaintA recipient reported a message as spam, through their provider's feedback loop. The address is suppressed.
message.heldMail to an agent inbox was held back from the agent (its sender failed authentication, is blocked, or isn't on the inbox's allowlist). People still see it; the agent does once someone releases it, and message.inbound fires then.
agent.approval_requestedAn agent asked to send an email that waits for a person: a new recipient, approval mode, or a daily cap.
agent.approval_decidedA person approved (and so sent) or rejected an email an agent asked to send.
agent.pausedAn agent inbox was paused on its own (spam complaints or bounces on what it sent, or a person paused it). Its sending stops; the rest of the workspace keeps sending.
domain.verifiedA domain's DNS records all checked out, so mail can be sent from it. It fires the first time, and again if the domain recovers after a problem.
domain.unverifiedA verified domain failed its DNS check twice in a row, so sending from it stops until it's fixed. A DNS lookup that times out never counts. problems says what to fix.

What a delivery looks like

Every delivery is an HTTP POST with a JSON body and these headers:

HeaderMeaning
X-SuperMail-EventThe event name, such as message.inbound.
X-SuperMail-DeliveryThe event id, the same as id in the body. Retries and resends reuse it, so you can dedupe on it.
X-SuperMail-Attempt1 for the first try, then 2, 3 and so on for each retry.
X-SuperMail-TimestampUnix seconds when this attempt was signed. Each retry is signed afresh.
X-SuperMail-Signaturet=<timestamp>,v1=<hex>. See verifying the signature.
Request
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…c41a

The body is always id, event, createdAt and a data object that depends on the event. Here is message.inbound:

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

NameTypeDescription
emailIdstringInternal message id — GET /api/v1/messages/{emailId}.
threadIdstring
mailboxIdstring
mailboxstring (email)
messageIdstringRFC 5322 Message-ID.
inReplyTostringRFC In-Reply-To, when present.
fromEmailAddress
toEmailAddress[]
ccEmailAddress[]
subjectstring
snippetstring
hasAttachmentsboolean
attachmentCountinteger
isSpambooleanFiled to Spam on arrival.
receivedAtstring (date-time)

message.outbound

NameTypeDescription
threadIdstring
messageIdstringRFC 5322 Message-ID.
mailboxstring (email)
fromEmailAddress
toEmailAddress[]
subjectstring

message.delivered

NameTypeDescription
threadIdstring
messageIdstring
mailboxstring (email)
toEmailAddress[]
subjectstring
deliveredAtstring (date-time)

message.bounced

NameTypeDescription
mailboxstring (email)
recipientstring (email)
hardboolean
detailstring

message.complaint

NameTypeDescription
mailboxstring (email)
recipientstring (email)

message.held

NameTypeDescription
holdIdstring
mailboxIdstring
mailboxstring (email)
threadIdstring
emailIdstring
fromstring (email)
reasonauth_failed | blocked_sender | not_allowlisted | unverified_sender
reasonTextstring
verdictverified | unverified | failed
heldAtstring (date-time)

agent.approval_requested

NameTypeDescription
approvalIdstring
mailboxIdstring
mailboxstring (email)
kindsend | reply
threadIdstring
tostring (email)[]
subjectstring
reasonsstring[]Why it waits. Bcc recipients are counted, never named.
statusstring
expiresAtstring (date-time)
decisionapproved | rejectedagent.approval_decided only.
messageIdstringOnce sent.
errorstringApproved but couldn't be sent.

agent.approval_decided

NameTypeDescription
approvalIdstring
mailboxIdstring
mailboxstring (email)
kindsend | reply
threadIdstring
tostring (email)[]
subjectstring
reasonsstring[]Why it waits. Bcc recipients are counted, never named.
statusstring
expiresAtstring (date-time)
decisionapproved | rejectedagent.approval_decided only.
messageIdstringOnce sent.
errorstringApproved but couldn't be sent.

agent.paused

NameTypeDescription
mailboxIdstring
mailboxstring (email)
reasonstring
bysystem | person
pausedUntilstring (date-time)

domain.verified

NameTypeDescription
domainIdstring
domainstring
statuspending | verifying | verified | failed
previousStatuspending | verifying | verified | failed
verifiedAtstring (date-time)
checkedAtstring (date-time)
problemsstring[]What to fix (empty once verified).

domain.unverified

NameTypeDescription
domainIdstring
domainstring
statuspending | verifying | verified | failed
previousStatuspending | verifying | verified | failed
verifiedAtstring (date-time)
checkedAtstring (date-time)
problemsstring[]What to fix (empty once verified).

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:

Signed string
HMAC_SHA256(secret, "<t>." + rawBody)  →  v1
  1. Read the raw body before any JSON parsing. A re-serialized body will not match.
  2. Split X-SuperMail-Signature into t and v1.
  3. Reject the request if t is more than 5 minutes from your clock. This stops a captured request being replayed later.
  4. Compute the HMAC and compare it to v1 in constant time.
Node.js
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);
}
Node.js · Express handler
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}
  }
});
Python
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 createdAt or 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?