[SuperMailOS](/)

[Product](/#product)[Customers](/#customers)[Agents](/#agents)[Pricing](/pricing)[Compare](/compare)[Tools](/tools)[Docs](/docs)

[Log in](/login)[Start free](/signup)

Docs/Webhooks

Getting started

-   [Overview](/docs)
-   [Set up your domain’s DNS](/docs/dns-setup)
-   [Connect a mail app (IMAP/SMTP)](/docs/connect-mail-app)
-   [Migrate your existing mail](/docs/migrate)

Running your mail

-   [Deliverability & inbox placement](/docs/deliverability)
-   [Security: 2FA & sessions](/docs/security)

Developers

-   [API reference](/docs/api)
-   [Webhooks](/docs/webhooks)
-   [Connect your AI (MCP)](/docs/mcp)

Resources

-   [Guides](/learn)
-   [Email checker](/tools/email-checker)
-   [System status](/status)
-   [Changelog](/changelog)

Getting started

-   [Overview](/docs)
-   [Set up your domain’s DNS](/docs/dns-setup)
-   [Connect a mail app (IMAP/SMTP)](/docs/connect-mail-app)
-   [Migrate your existing mail](/docs/migrate)

Running your mail

-   [Deliverability & inbox placement](/docs/deliverability)
-   [Security: 2FA & sessions](/docs/security)

Developers

-   [API reference](/docs/api)
-   [Webhooks](/docs/webhooks)
-   [Connect your AI (MCP)](/docs/mcp)

Resources

-   [Guides](/learn)
-   [Email checker](/tools/email-checker)
-   [System status](/status)
-   [Changelog](/changelog)

[Home](/)/[Docs](/docs)/Webhooks

Developers

Copy page[.md](/docs/webhooks.md "Open this page as Markdown")

# Webhooks

Updated 9 Oct 2026

On this page 8 sections

-   [Set up an endpoint](#set-up-an-endpoint)
-   [Events](#events)
-   [What a delivery looks like](#what-a-delivery-looks-like)
-   [Event data](#event-data)
-   [message.inbound](#message-inbound)
-   [message.outbound](#message-outbound)
-   [message.delivered](#message-delivered)
-   [message.bounced](#message-bounced)
-   [message.complaint](#message-complaint)
-   [Verify the signature](#verify-the-signature)
-   [Respond, retries and ordering](#respond-retries-and-ordering)
-   [When an endpoint keeps failing](#when-an-endpoint-keeps-failing)
-   [Delivery log and resend](#delivery-log-and-resend)

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.

| 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. |

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

| 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](/docs/api)). 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](/docs/api) to fetch the message an event points at, or [connect your AI over MCP](/docs/mcp).

Was this page helpful?

YesNo

[← PreviousAPI reference](/docs/api)[Next →Connect your AI (MCP)](/docs/mcp)

Stuck?

Write to [hello@supermailos.com](mailto:hello@supermailos.com). A person who can fix it reads every message.

[SuperMailOS](/)

Every domain you own, in one beautiful inbox. From $3 a mailbox.

Product

-   [Tour](/#product)
-   [Pricing](/pricing)
-   [Features](/features)
-   [iPhone app (soon)](/#iphone)
-   [Agents & API](/#agents)
-   [Changelog](/changelog)

Compare

-   [Google Workspace](/compare/google-workspace)
-   [Microsoft 365](/alternatives/microsoft-365-alternative)
-   [Zoho Mail](/compare/zoho)
-   [Fastmail](/compare/fastmail)
-   [All comparisons](/compare)

Resources

-   [Docs](/docs)
-   [Developers](/docs/api)
-   [Learn](/learn)
-   [Use cases](/use-cases)
-   [Free tools](/tools)
-   [Glossary](/glossary)
-   [Status](/status)

Company

-   [About](/about)
-   [Contact](/contact)
-   [Brand](/brand)
-   [Privacy](/privacy)
-   [Terms](/terms)
-   [Refunds](/refund)
-   [Security](/security-policy)

© 2026 SuperMailOSEvery domain · one inbox