Skip to main content
Each delivery carries the same five-field envelope; only data changes. Envelope, headers, signing and retry behaviour are in Webhooks Overview. This page is the field-level reference.
campaign_id is null for events that are not about a campaign — most often a reply to a direct contact email, which has no campaign.
Six of the sixteen event types the API accepts do not currently deliver anything. The subscription validator is more permissive than the delivery pipeline behind it. Read the status table below before building against a type. It is not a placeholder caution — campaign_started in particular has never been emitted at all.

Status at a Glance

A subscription naming any of these validates with a 201. The last three groups simply never produce a delivery.

Replies

reply_received

Category replies. Fires when an inbound email is matched to a conversation.
Two fields here are weaker than they look, and it depends which ingestion path handled the mail:
  • inbox_email is always null on the Stalwart sync path. It is populated on the workspace-email path. Treat it as optional and fall back to looking the thread up.
  • is_auto_reply is hardcoded false on two of the three emit sites. Auto-replies are not reliably flagged, so do not use this field to filter out-of-office responses — inspect reply_text instead.
This is the event that fires branch entry triggers. See Subsequences.

Delivery

email_sent

Category delivery. Fires once per outbound email accepted by the sending provider.
message_id here is the Azure Communication Services identifier, not the RFC Message-ID header. It will not match the <direct_…@domain> value returned by POST /contacts/{id}/send-email, and it is not what reply threading keys on. Do not use it to correlate a send with a reply.

email_delivered

Category delivery. Fires on the provider’s delivery confirmation. The provider’s own message ID is recorded internally but is not exposed in data.

email_bounced

Category delivery. Fires on a hard or soft bounce.
This payload drops the useful half of the bounce classification. The emitter records smtp_status_code, bounce_category and bounce_reason_tag — the structured fields you would actually branch on — and the public mapper does not forward any of them. You get bounce_type and a free-text status_message only.sequence_step is declared in the payload but never emitted, so it is always null.If you need reliable bounce classification today, parse status_message for the SMTP code, or read the contact’s suppression state via GET /contacts/{id} — a bounce sets is_suppressed: true with suppression_reason: "bounced".

Engagement

email_opened

Category engagement. Fires when the tracking pixel loads.
Neither of the two open-tracking paths emits sequence_step, so it is always null. You cannot tell from this event which email in the sequence was opened — only that some email to that address was. To attribute an open to a step, correlate on lead_email against your own record of the email_sent events you received.Mail clients that pre-fetch images produce opens that no human caused. Do not treat a single open as intent.
Category engagement. Fires per tracked link.
Clicks are deduplicated per email and link index, so the same lead clicking the same link twice produces one event.

Lifecycle

lead_status_changed

Category lifecycle. Fires on every lead status transition. Statuses: PENDING, QUEUED, SENT, OPENED, REPLIED, FAILED, BOUNCED, UNSUBSCRIBED, EXTERNAL, BLOCKED.
Deduplication is keyed on lead + target status, so a lead produces at most one event per to_status, ever. A normal progression PENDING → SENT → OPENED → REPLIED yields three events. But if a lead ever returns to a status it has already held, that second transition is silently suppressed. Do not treat this stream as a complete audit log of every write — it is a log of every status a lead first reached.from_status is null on the initial transition into PENDING.

lead_unsubscribed

Category lifecycle. Fires once per lead.
Deduplicated on lead alone — one event per lead, permanently. Unsubscribing also sets the contact’s suppression state, so subsequent direct emails to that address fail with 409.

subsequence_entered

Category lifecycle. New. Fires when a lead enters a flow branch, either by an automatic trigger or a manual move.
trigger_reason has two formats depending on how the lead got there:
entry_node_id is the value GET /campaigns/{id}/leads/{lead_id}/journey reports as the branch’s node, and the value move-to-subsequence takes as target_subsequence_node_id. It is not the subsequence_id used by PUT and DELETE /flow/subsequences/{id} — that is the entry edge id. The internal entry_edge_id is recorded but deliberately not exposed, since the public flow API addresses branches by node.
Deduplicated on lead_id + entry_node_id, mirroring the uniqueness the enrollment table already enforces. One enrollment produces exactly one event, and a re-delivered trigger message cannot double-emit.

subsequence_completed

Category lifecycle. New. Fires when a lead reaches the end of a branch.
entry_node_id identifies which branch completed — a lead can be enrolled in more than one. Same dedupe shape as subsequence_entered, so the two events pair up one-to-one per lead per branch.
A lead that exits a branch early — because the campaign was paused, or the branch was deleted — does not produce this event. It fires only when the lead walks off the end of the branch’s last step. Absence of subsequence_completed is not evidence of a bug.

campaign_completed — reserved, does not fire

Category lifecycle. The API accepts this name in event_types, and the dispatcher knows how to map it, but nothing writes it to the event stream. Campaign completion is currently surfaced only as an in-app notification inside the ColdSend UI. A subscription naming it is created successfully and then never delivers. It is listed here so the silence is expected rather than mysterious.
To detect campaign completion today, poll GET /campaigns/{id} and watch for status: "COMPLETED", or infer it from the absence of further email_sent events. Note that a campaign with subsequences_enabled: true can reach COMPLETED and still send branch emails afterwards, so completion is not the end of activity.

Accepted but Not Delivered

These five validate at subscription time and never arrive. Listed so you do not debug a handler that was never going to be called.
The system category consists entirely of inbox_paused and domain_limit_reached — neither is deliverable. A subscription created with event_categories: ["system"] will therefore receive nothing at all, ever. Do not use it.

Categories

Subscribing by category is an alternative to naming types. The mapping is:
event_types and event_categories AND together rather than OR. If you set both, an event must appear in event_types and belong to one of the listed categories. Set one or the other.Categories also include the silent members above, so lifecycle looks broader than it behaves.

Ordering and Guarantees

  • At-least-once. Retries and manual redeliveries can produce the same event_id more than once. Deduplicate on it.
  • Not ordered. email_delivered can arrive before email_sent, and subsequence_entered before the reply_received that caused it. Never assume sequence from arrival order — use occurred_at.
  • Best-effort emission. Every emit site is wrapped so that a failure to record an event never fails the business operation it describes. Events can be missing. For anything you must not lose, reconcile against the REST API.
  • Same transaction as the write. The subsequence events are written inside the enrollment’s own transaction, so they never survive a rollback of the change they describe.
Build handlers to be idempotent and order-independent from the start. Both properties are cheap to add on day one and expensive to retrofit after a duplicate has corrupted state.

Next Steps

Webhooks Overview

Signing, retries and the delivery log.

Subsequences

What causes the two branch events.

Direct Email

Sends that produce campaign_id: null.

API Scopes

The webhooks:* family.