data field
varies by event type.
Globally-unique event id. Same id surfaces on the events-stream pull.
Per-identity monotonic cursor. Strictly increasing within one identity;
not comparable across identities.
Event type. See the table below.
ISO 8601 timestamp.
Type-specific payload.
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.
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) → flipdo_not_contact = 1immediately. - Soft (
4.x.x— mailbox full, server unreachable, greylisted) → increment a counter on the recipient row. Only escalate todo_not_contactaftersoftBounceThresholdconsecutive 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.
| 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. |
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.