Skip to main content
Every webhook delivery has the same outer shape. The data field varies by event type.
string
Globally-unique event id. Same id surfaces on the events-stream pull.
number
Per-identity monotonic cursor. Strictly increasing within one identity; not comparable across identities.
string
Event type. See the table below.
string
ISO 8601 timestamp.
object
Type-specific payload.

Event types

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

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

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.

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