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

# Webhook payload

> Reference for the payload we POST and the four event types.

Every webhook delivery has the same outer shape. The `data` field
varies by event type.

```json theme={null}
{
  "id":        "evt_01HF6YJZ4N0K3W2X9CPM7B8DG2",
  "seq":       43,
  "type":      "email.replied",
  "createdAt": "2026-04-27T11:55:29.000Z",
  "data": { /* type-specific */ }
}
```

<ResponseField name="id" type="string">
  Globally-unique event id. Same id surfaces on the events-stream pull.
</ResponseField>

<ResponseField name="seq" type="number">
  Per-identity monotonic cursor. Strictly increasing within one identity;
  not comparable across identities.
</ResponseField>

<ResponseField name="type" type="string">
  Event type. See the table below.
</ResponseField>

<ResponseField name="createdAt" type="string">
  ISO 8601 timestamp.
</ResponseField>

<ResponseField name="data" type="object">
  Type-specific payload.
</ResponseField>

## Event types

| Type                            | Fires when                                                                                              |
| ------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `email.queued`                  | A `POST /send` accepted a row and added it to the dispatch queue. Emitted with a pre-minted `convId`.   |
| `email.sent`                    | An outbound message was dispatched (left the queue and reached the provider).                           |
| `email.cancelled`               | A queued send was cancelled (admin action / recipient flipped to do-not-contact / no eligible mailbox). |
| `email.received`                | An inbound message landed in a **new** conversation (first contact).                                    |
| `email.replied`                 | An inbound message landed in an **existing** conversation (the reply).                                  |
| `email.no_reply`                | The no-reply timer fired without a reply within the configured window.                                  |
| `email.bounced`                 | An outbound bounced. Body classifies it as **soft** (4.x.x) or **hard** (5.x.x) per RFC 3464.           |
| `email.send_failed_permanently` | A queued send was dropped after exhausting retries OR a non-retryable error.                            |
| `mailbox.replaced`              | A backing mailbox died; we repinned recipients and (when supplier is wired) provisioned a replacement.  |

## `email.queued`

Fires synchronously from `POST /v1/identities/:handle/send` once the row
is accepted into the dispatch queue. The conversation id is pre-minted
at queue time, so you can persist it alongside your sequencer state
immediately — no need to wait for `email.sent`.

```json theme={null}
{
  "type": "email.queued",
  "data": {
    "pendingId":        "psn_cc1...",
    "convId":           "conv_dd6e7130674645d3",
    "identity":            "alice.acme@inboxbase.ai",
    "recipient":        "morgan@northwindrobotics.com",
    "sendClass":        "cold_first_contact",
    "pinnedAccountId":  null,
    "dispatchAt":       1777200000000,
    "queuedAt":         1777199999987,
    "subject":          "Quick intro — fleet rotation",
    "snippet":          "Hi Morgan, ..."
  }
}
```

`sendClass` is `"cold_first_contact"` / `"cold_followup"` / `"warm"`.
Cold sends respect the identity's working-hours window; `dispatchAt`
is a best-effort estimate that the dispatcher may refine.

A queue rejection does **not** emit `email.queued` — those are
returned synchronously in the `POST /send` response with
`status: "rejected"` and a reason (`do_not_contact` /
`no_eligible_pool` / `inactive` / `suspended` / `invalid_recipient`).
For `invalid_recipient`, the response also carries a `verification`
discriminator: `"invalid_format"`, `"invalid_no_mx"`, `"disposable"`,
or `"suppressed"` (org-level suppression list match).

## `email.sent`

Fires when an outbound is accepted by the underlying provider. The
message is on the wire from this point forward.

```json theme={null}
{
  "type": "email.sent",
  "data": {
    "convId": "conv_dd6e7130674645d3",
    "identity":  "alice.acme@inboxbase.ai",
    "message": {
      "id":              "<provider-message-id>",
      "messageIdHeader": "<...@tryacme.com>",
      "from":            "alice.chen@tryacme.com",
      "to":              "morgan@northwindrobotics.com",
      "subject":         "Quick intro — fleet rotation",
      "snippet":         "Hi Morgan, ...",
      "ts":              1777200000000
    },
    "account": { "id": "acc_...", "email": "alice.chen@tryacme.com" }
  }
}
```

## `email.cancelled`

Fires when a queued send is cancelled before dispatch — either via
the dashboard "Cancel pending" action, the bulk-action API, or
automatically (recipient flipped to do-not-contact between queue and
dispatch, no eligible mailbox left, etc.).

```json theme={null}
{
  "type": "email.cancelled",
  "data": {
    "pendingId":  "psn_cc1...",
    "convId":     "conv_dd6e7130674645d3",
    "identity":      "alice.acme@inboxbase.ai",
    "recipient":  "morgan@northwindrobotics.com",
    "reason":     "user",
    "ts":         1777200005000
  }
}
```

`reason` values: `"user"` (manual cancel), `"do_not_contact"`
(recipient flagged between queue and dispatch),
`"no_eligible_pool"` (every pinned mailbox died before this row drained),
or any other free-text the cancel caller passed.

## `email.received`

Inbound landed in a **new** conversation — i.e. nobody on this identity
has corresponded with `from` before.

```json theme={null}
{
  "type": "email.received",
  "data": {
    "convId": "conv_5860bd251d0a46f6",
    "identity":  "alice.acme@inboxbase.ai",
    "message": {
      "id":              "<msg-id>",
      "messageIdHeader": "<...@northwindrobotics.com>",
      "from":            "morgan@northwindrobotics.com",
      "to":              "alice.chen@tryacme.com",
      "subject":         "Question about your tool",
      "snippet":         "Hey, saw your post about ...",
      "ts":              1777278929000
    }
  }
}
```

## `email.replied`

Inbound landed in an **existing** conversation. This is the event you
listen for to halt a sequence.

We also classify the reply asynchronously (cheap LLM pass) and store
the result on the conversation as `latest_inbound_sentiment`:
`positive`, `soft_no`, `hard_no`, or `system`. A `hard_no` flips the
recipient's `do_not_contact` automatically; future scheduleSend calls
to that address reject. The sentiment isn't included on this event
(classification can finish a beat after the event fires); read the
conversation detail or label-stats rollup to see it.

```json theme={null}
{
  "type": "email.replied",
  "data": {
    "convId": "conv_dd6e7130674645d3",
    "identity":  "alice.acme@inboxbase.ai",
    "message": {
      "id":              "<msg-id>",
      "messageIdHeader": "<...@northwindrobotics.com>",
      "from":            "morgan@northwindrobotics.com",
      "to":              "alice.chen@tryacme.com",
      "subject":         "Re: Quick intro — fleet rotation",
      "snippet":         "Friday 10 works...",
      "ts":              1777278929000
    }
  }
}
```

## `email.no_reply`

Fires once the no-reply timer elapses without an inbound landing on
the conversation. The window is set per-send via `noReplyEventAfter`
on `POST /send`.

```json theme={null}
{
  "type": "email.no_reply",
  "data": {
    "convId":         "conv_dd6e7130674645d3",
    "identity":          "alice.acme@inboxbase.ai",
    "afterMessageId": "<msg-id>",
    "waitedMs":       14401234
  }
}
```

`waitedMs` is the actual elapsed time. It can be a few seconds longer
than the requested window because alarms aren't preempted to the
millisecond.

## `email.bounced`

Fires when an inbound poll surfaces a bounce notification — typically
from `mailer-daemon@…` or `postmaster@…`, or with a "Delivery Status
Notification" / "Mail Delivery Failed" subject.

We parse the RFC 3464 `Status:` header to classify the bounce, then
update the recipient row accordingly:

* **Hard** (`5.x.x` — address doesn't exist, mailbox closed,
  policy reject) → flip `do_not_contact = 1` immediately.
* **Soft** (`4.x.x` — mailbox full, server unreachable, greylisted)
  → increment a counter on the recipient row. Only escalate to
  `do_not_contact` after `softBounceThreshold` consecutive soft
  bounces (default 3,
  tunable per identity via the scheduling endpoint). A live reply
  resets the counter.
* **Unknown** (no parseable status) → conservative fallback,
  treated as hard.

```json theme={null}
{
  "type": "email.bounced",
  "data": {
    "convId":            "conv_dd6e7130674645d3",
    "linkedVia":         "message_id",
    "identity":             "alice.acme@inboxbase.ai",
    "originalMessageId": "<originaloutbound@tryacme.com>",
    "originalRecipient": "morgan@northwindrobotics.com",
    "bounceKind":        "soft",
    "bounceStatus":      "4.2.2",
    "softBounceCount":          2,
    "escalatedToDoNotContact":  false,
    "from":              "Mail Delivery Subsystem <mailer-daemon@tryacme.com>",
    "subject":           "Delivery Status Notification (Failure)",
    "snippet":           "mailbox full",
    "ts":                1777278929000
  }
}
```

| Field                     | Notes                                                                                                                |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `bounceKind`              | `"hard"` / `"soft"` / `"unknown"`. Drives the recipient lifecycle.                                                   |
| `bounceStatus`            | RFC 3464 enhanced status code (e.g. `"5.1.1"`). Null when not present.                                               |
| `softBounceCount`         | Recipient's running soft-bounce counter at the moment of this bounce. Null on hard/unknown.                          |
| `escalatedToDoNotContact` | `true` if this bounce flipped `do_not_contact` (hard, or soft crossing threshold).                                   |
| `convId`                  | Conversation the bounced send belongs to. `linkedVia` indicates how we matched.                                      |
| `linkedVia`               | `"message_id"` (most precise) / `"recipient"` (fallback) / `null` (parse failed; we log `dispatch.bounce_unlinked`). |
| `originalRecipient`       | Address that bounced. Null when the body's RFC 3464 fields don't parse.                                              |

Soft bounces with `escalatedToDoNotContact: false` mean the recipient is still
fair game — handlers running compliant resend logic should respect
the count and back off, not retry immediately.

## `email.send_failed_permanently`

Fires when a queued send is dropped — either after exhausting transient
retries (8 attempts × exponential backoff capped at 1h ≈ 4h wall time)
or on a non-retryable failure (auth dead, bounce, recipient marked
do-not-contact). Use this to surface delivery problems on agent
dashboards and to release sequencer state for the failed step.

```json theme={null}
{
  "type": "email.send_failed_permanently",
  "data": {
    "pendingId":  "psn_4c2d99e1...",
    "recipient":  "morgan@northwindrobotics.com",
    "convId":     null,
    "sendClass":  "cold_first_contact",
    "attempts":   1,
    "error":      "bounced"
  }
}
```

`convId` is null when the send never reached `recordSend` (i.e. failed
before a conversation was minted). For replies and follow-ups it's the
existing conversation id.

## `mailbox.replaced`

Fires when a backing mailbox transitions into a permanent-dead state
(`auth_failed`, `suspended`, deprovisioned, or health drops to/under
the floor). We repin every recipient pinned to the dead mailbox to a
healthy peer; pending queue rows pinned to it follow the new affinity.

```json theme={null}
{
  "type": "mailbox.replaced",
  "data": {
    "deadMailbox":      { "id": "acc_...", "email": "alice.chen@old.com" },
    "newMailbox":       null,
    "reason":           "auth_failed",
    "recipientsRepinned": 23,
    "rowsRepointed":    4,
    "rowsUnpinned":     0
  }
}
```

`newMailbox` is null while the supplier integration isn't wired in;
when it is, the field carries the freshly-provisioned replacement so
your dashboard can surface "we rotated to a new sender for you" UI.
`rowsRepointed` is the number of pending sends that followed the
repin; `rowsUnpinned` is rows whose recipient had no surviving peer
and got downgraded to first-contact for the next dequeue race.

## What we don't emit yet

* **`email.opened`, `email.clicked`** — not in the stream. Most
  senders run their own pixel + link rewriter; inject them into the
  HTML you pass to `/send`.
