> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ruber.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Get told when mail arrives, sends, bounces or is marked as spam — signed, retried, and deduplicated.

The API so far is something you call. Webhooks are the other direction: Ruber
calls **you**, as things happen, so an integration does not have to poll a
mailbox to find out a message arrived.

## Events

| Event                                   | When                                                        |
| --------------------------------------- | ----------------------------------------------------------- |
| `message.received`                      | Mail was delivered to one of your Smart mailboxes.          |
| `message.sent`                          | A message left, from the API, the app, or a scheduled send. |
| `message.delivered`                     | The receiving server accepted it.                           |
| `message.bounced`                       | It could not be delivered.                                  |
| `message.complained`                    | A recipient marked it as spam.                              |
| `mailbox.created` · `mailbox.suspended` | *Defined, not yet emitted.*                                 |
| `domain.verified` · `domain.failed`     | *Defined, not yet emitted.*                                 |
| `storage.limit_warning`                 | *Defined, not yet emitted.*                                 |

The last four are in the catalogue so you can subscribe to them now without a
breaking change later; nothing raises them yet, and this page will say so until
something does.

<Warning>
  **Private mailboxes never raise `message.received`.**

  Not a limitation to lift later. The event payload carries a subject and a
  sender, and a payload is a row in our database — so emitting one for a Private
  mailbox would put exactly the plaintext we promise never to hold at rest into
  storage we can read.

  A stripped payload with no headers was the obvious compromise and is also
  wrong: it would leak who is corresponding and how often, and the event would be
  useless anyway, since every API endpoint refuses to read a Private mailbox.
</Warning>

## Registering an endpoint

**Settings → Webhooks** in the dashboard. Give it an HTTPS URL and tick the
events you want.

You get a signing secret beginning `whsec_`, shown once. Unlike an API key it
is stored intact — we need it to sign — but it is still only displayed at
creation.

Your URL must be **HTTPS** and must resolve to a public address. Endpoints
resolving to loopback, private ranges, or cloud metadata addresses are refused
on every attempt, checked at delivery time rather than only at registration, so
a name repointed later is caught too.

## The envelope

Every delivery is a `POST` with this body:

```json theme={null}
{
  "id": "8f14e45f-ce0a-4e2a-9b7f-1d2c3b4a5e6f",
  "event": "message.received",
  "createdAt": "2026-09-07T11:53:59.267Z",
  "organizationId": "3d1b...",
  "data": {
    "mailbox": "billing@yourdomain.com",
    "from": "Accounts <accounts@supplier.com>",
    "envelope_from": "bounce+123@supplier.com",
    "to": "billing@yourdomain.com",
    "subject": "Invoice 42",
    "message_id": "abc123@supplier.com",
    "date": "Mon, 7 Sep 2026 11:53:00 +0000",
    "size_bytes": 48213
  }
}
```

`id` is the **delivery** id. It is the same across every retry of that
delivery, which is what makes it usable for deduplication — see below.

<Note>
  **`from` and `envelope_from` are different things and both are given.**
  `from` is the header a person sees; `envelope_from` is the address that
  actually delivered and is where a bounce would go. They differ for mailing
  lists and forwarders, and code that checks only one will eventually be
  surprised.

  There is no IMAP `uid` in `message.received`. The event fires the moment the
  mail server accepts the message, which is before the mailbox has assigned one.
  Use `message_id`, which is the sender's and never changes.
</Note>

### Bounce payloads

```json theme={null}
{
  "event": "message.bounced",
  "data": {
    "from": "you@yourdomain.com",
    "recipients": ["nobody@example.com"],
    "message_id": "<2f8c...@yourdomain.com>",
    "ses_message_id": "0100018f...",
    "subject": "Your order has shipped",
    "bounce_type": "Permanent",
    "bounce_subtype": "General",
    "permanent": true,
    "diagnostic": "smtp; 550 5.1.1 user unknown"
  }
}
```

**`permanent: true` means stop sending to that address.** It is surfaced as its
own boolean so you do not have to learn AWS's vocabulary to act on the one
distinction that matters. Continuing to send to addresses that hard-bounce is
the fastest way to lose a domain's sending reputation.

## Verifying the signature

Two headers arrive with every delivery:

| Header                |                                                   |
| --------------------- | ------------------------------------------------- |
| `x-mailbox-signature` | `v1=<hex>` — HMAC-SHA256, keyed with your secret. |
| `x-mailbox-timestamp` | Unix seconds, and part of what is signed.         |

The signed string is `${timestamp}.${rawBody}`.

```js Node theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

export function verify(rawBody, headers, secret) {
  const header = headers["x-mailbox-signature"] ?? "";
  const timestamp = headers["x-mailbox-timestamp"] ?? "";

  // Tolerate future versions sent alongside v1.
  const digest = header
    .split(",")
    .map((part) => part.trim().split("="))
    .find(([version]) => version === "v1")?.[1];
  if (!digest || !timestamp) return false;

  // Reject replays of a genuinely signed, genuinely old delivery.
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(digest, "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

<Warning>
  **Verify against the raw request body, never a re-serialised object.**

  This is the single most common way a webhook integration is got wrong. Parsing
  the JSON and re-stringifying it can change insignificant whitespace, how a
  number is written, or how a non-ASCII character is escaped — and any of those
  produce a different string and a failed verification for a message nobody
  tampered with.

  In Express, that means `express.raw({ type: "application/json" })` on this
  route, not `express.json()`. In Next.js, `await request.text()` before any
  parsing.
</Warning>

The timestamp is inside the signed string rather than merely sent beside it.
That is what makes the freshness check meaningful: a captured delivery cannot
be given a new timestamp without invalidating the signature.

## Responding

**Answer `2xx` quickly.** Anything else — including a `3xx` — counts as a
failure and is retried. We do not follow redirects, so a redirecting endpoint
never receives anything.

We wait **10 seconds**. If your handler needs longer, acknowledge immediately
and do the work afterwards; that is what the retry schedule assumes.

### Retries

Six attempts, then we stop:

| Attempt | Waits      |
| ------- | ---------- |
| 2nd     | 1 minute   |
| 3rd     | 5 minutes  |
| 4th     | 25 minutes |
| 5th     | \~2 hours  |
| 6th     | \~10 hours |

About half a day in total — long enough that an endpoint broken over lunch
still receives its events once fixed, short enough that a permanently dead one
stops costing anything within a day.

Disabling an endpoint stops its queued deliveries; they are not retried at you
later.

### Deduplicate on `id`

Deliveries are at-least-once. A network failure after your server processed the
request but before we saw the response is indistinguishable, from our side,
from one that never arrived — so we retry, and you may see the same `id` twice.

Store the `id` and ignore one you have already handled. We also deduplicate on
our side, so a message the mail server retried does not become two separate
events, but that protects against a different failure than this one.

## Ordering

**Events are not ordered.** Deliveries run in parallel so one slow endpoint
does not delay everybody else's events, and a retried event arrives after
events raised later. Use `createdAt` if order matters to you.

## Errors

Delivery attempts, their response codes and their failure reasons are recorded
against each endpoint. An endpoint that never receives anything is usually one
of:

| Symptom                                  | Cause                                                                          |
| ---------------------------------------- | ------------------------------------------------------------------------------ |
| Nothing at all, no attempts              | The endpoint is disabled, or not subscribed to that event.                     |
| `endpoint must be https`                 | The URL is `http://`.                                                          |
| `endpoint resolves to a private address` | It points inside a private network — including a name that now resolves there. |
| `endpoint answered 3xx`                  | A redirect. Point the endpoint at its final URL.                               |
| `no response within 10000ms`             | Acknowledge first, work afterwards.                                            |
| Signature never verifies                 | Almost always a re-serialised body. See the warning above.                     |
