Skip to main content
Every webhook delivery has the same outer shape. The data field varies by event type.
{
  "id":        "evt_01HF6YJZ4N0K3W2X9CPM7B8DG2",
  "seq":       43,
  "type":      "email.replied",
  "createdAt": "2026-04-27T11:55:29.000Z",
  "data": { /* type-specific */ }
}
id
string
Globally-unique event id. Same id surfaces on the events-stream pull.
seq
number
Per-identity monotonic cursor. Strictly increasing within one identity; not comparable across identities.
type
string
Event type. See the table below.
createdAt
string
ISO 8601 timestamp.
data
object
Type-specific payload.

Event types

TypeFires when
email.queuedA POST /send accepted a row and added it to the dispatch queue. Emitted with a pre-minted convId.
email.sentAn outbound message was dispatched (left the queue and reached the provider).
email.cancelledA queued send was cancelled (admin action / recipient flipped to do-not-contact / no eligible mailbox).
email.receivedAn inbound message landed in a new conversation (first contact).
email.repliedAn inbound message landed in an existing conversation (the reply).
email.no_replyThe no-reply timer fired without a reply within the configured window.
email.bouncedAn outbound bounced. Body classifies it as soft (4.x.x) or hard (5.x.x) per RFC 3464.
email.send_failed_permanentlyA queued send was dropped after exhausting retries OR a non-retryable error.
mailbox.replacedA 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.
{
  "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.
{
  "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.).
{
  "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.
{
  "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.
{
  "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.
{
  "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.
{
  "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
  }
}
FieldNotes
bounceKind"hard" / "soft" / "unknown". Drives the recipient lifecycle.
bounceStatusRFC 3464 enhanced status code (e.g. "5.1.1"). Null when not present.
softBounceCountRecipient’s running soft-bounce counter at the moment of this bounce. Null on hard/unknown.
escalatedToDoNotContacttrue if this bounce flipped do_not_contact (hard, or soft crossing threshold).
convIdConversation 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).
originalRecipientAddress 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.
{
  "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.
{
  "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.