POST to an
HTTPS endpoint you own.
Four event types today:
enrollment.rejected fires only on the CDP-event enrollment path. Segment,
manual, CSV and API enrollment do not emit it — which is why its trigger is
always kind: "cdp_event".The run lifecycle
The twojourney_run.* events bracket a run, and understanding the gap between
them is most of what you need to consume them correctly.
journey_run.started fires at enrollment — the moment the person enters the
journey, before the first message is sent. It is not a delivery receipt. A run
whose first send later fails still emitted started.
journey_run.ended fires when the run reaches a terminal state, whatever the
ending. Between the two, the journey does its work: sends, waits for a reply,
converses, branches. A journey with a multi-day wait leaves the run open for
days, so do not assume the two events arrive close together. durationMs
tells you the real gap.
Three runs from the same journey, all real:
The third column is the one worth reading twice.
no_reply and
delivery_failed look alike on every count — one message out, none back — and
mean opposite things: the customer ignored you, versus the customer never had
anything to ignore.
messagesSent and messagesReceived count the whole run, so they are only final
on ended.
Set one up
Webhooks are on for every workspace, and you need both Settings → Webhooks
and Settings → HTTP Credentials. If either page isn’t there, it is almost
always your user’s permissions rather than the feature — both pages come with
the Owner, Admin and Developer roles, so ask your organization’s Owner for one
of those, or ask us to check.
1
Create a credential
Go to Settings → HTTP Credentials and add one. Boom sends its value on every
delivery so your endpoint can prove the request came from us.A credential is required, and it has to actually authenticate. Your URL
is not a secret: anything that learns it could forge events, and without a
credential your receiver has no way to tell our
POST from theirs.BEARER, API_KEY (in a header or a query parameter) and BASIC all work.
Most people want a bearer token. A credential set to No authentication
can’t be attached to a webhook — it’s greyed out in the picker, rejected on
save, and refused at delivery rather than sent unsigned.2
Add the subscription
Go to Settings → Webhooks → Add destination and fill in:
- URL — must start with a literal
https://; plainhttpis rejected when you save. A private or loopback address saves fine but is blocked at delivery time, so use a public host (or a tunnel) rather thanlocalhost. - Events — tick the ones you want. One destination can take all four,
though see Environments do not apply here for
why
conversation.escalatedis better off on its own. - Credential — the one you just created.
- Environments — leave empty to receive events from every environment, or tick specific ones. See Environments below.
3
Send a test delivery
Click the send icon on the subscription. Boom delivers a synthetic event
—
type: "ping" — through exactly the same path a real event takes, and
reports the result per environment.It’s the same path deliberately: a check that just curled your URL would
prove the endpoint is reachable while saying nothing about whether the
credential resolves or the {{env.*}} tokens fill in, which is where
subscriptions actually break.Until you’ve seen a delivery land, assume it isn’t wired up — a subscription
that saves cleanly can still drop every event (see
Troubleshooting).The envelope
Every delivery has the same outer shape:id is also sent as the Idempotency-Key header.
type is one of the four events above, plus ping — the synthetic test
delivery. Nothing subscribes to ping; it only arrives when someone clicks the
test button. Handle it as a no-op (return 2xx and ignore it) so a colleague
testing the endpoint never shows up as a real run in your system.
Requests are POST, Content-Type: application/json, and time out after 10
seconds.
We add fields to these payloads. Ignore the ones you don’t recognise. New
keys appear additively — an existing field won’t change meaning or vanish
without a new event type — so a parser that rejects unknown keys will start
failing on a release that changed nothing you actually read. If your language
deserializes strictly (a JSON Schema with
additionalProperties: false, Go’s
DisallowUnknownFields, a sealed record type), allow unknown keys.journey_run.started
personExternalId is your id for the person, so you can join straight back
to your own database without storing Boom’s ids. Prefer it over personId.
The same run object appears on both journey_run.* events. initiativeName is
the operator-facing name and can be renamed at any time; engagementId is the
stable identifier for one person’s pass through one journey.
trigger is a discriminated union on kind, and the same object appears on
both journey_run.* events and on enrollment.rejected (not on
conversation.escalated, which has no run behind it):
Correlating a run back to your own record
When you trigger journeys yourself withPOST /api/v1/cdp/events,
trigger.externalId is the externalId you sent on that call, echoed back
verbatim. It is the key to correlate on: no need to store Boom ids or keep a
translation table. (Events we ingest on your behalf carry an id we generated
instead — see the end of this section.)
Why not the others? engagementId doesn’t exist yet when you fire the event — the
participant is created asynchronously, seconds after the POST returns.
personExternalId identifies the person, not the run, so it can’t tell two runs
for the same customer apart.
Three things to handle:
- It is not unique per run. One event enrolls the person into every
published journey that triggers on it, so two journeys means two runs sharing
one
externalId. Usetrigger.externalIdplusrun.engagementIdas the run key. - Mint a fresh one per event — never re-post one.
externalIdis our idempotency key for the event record: a repeat value stores no second event. It does not make the call a no-op. Re-posting still re-evaluates enrollment, and while a live run blocks a second one, a value whose earlier run has already finished starts a new run and messages the customer again. That retry behaviour is deliberate (it’s how a checkout that gains a phone number later still enrols), so treat the endpoint as “fire once per event”, not as something to call again to check status.
externalId to give — segment and
manual triggers simply don’t carry one. Events that Boom ingested on your behalf
(a Shopify integration, or one journey dispatching an event to chain into another)
carry that integration’s id rather than one you chose — still a stable
correlation handle, just not yours.
journey_run.ended
Branch on
reason, not status. Three materially different endings — the
customer conversed, the customer replied then went quiet, the customer never
replied at all — all land on status: CONVERSATION_COMPLETE. Status carries
almost no information on its own.switch:
A send that a reviewer rejected (a journey step set to Draft for approval) ends the run as
reason: completed when nothing is connected after the rejection, with terminalNode.kind set to
the send step’s kind (SEND_MESSAGE or SEND_EMAIL). To tell a rejection from a normal finish, read
the participant’s step history: the send step’s emittedSignal is REJECTED.outcome, so your parser doesn’t assume otherwise:
closedVia — who ended the conversation
lastSignal: "CLOSED" tells you a conversation ended. It does not tell you
whether your agent finished the job, a teammate stepped in, or the customer
went quiet — three endings you would report very differently. closedVia does.
It is non-null exactly when
lastSignal is CLOSED, and null for every
other ending — so the two fields never disagree and there’s no third state to
handle.
system is the catch-all. It deliberately covers “closed, but not by a path
we can name” as well as the platform’s own sweeps, so don’t read a precise
cause into it. If you need to distinguish those, the per-step detail is
available on the participant’s step history via the API.terminalNode.id is whatever the journey author named that exit node. It’s
useful for a human reading a log; don’t build logic on it, because renaming a
node in the builder changes it.
outcome.error never contains an error message or stack — only a class name.
Provider and database errors routinely echo the offending record, so we don’t
forward them.
undelivered — a message that never arrived
A send completes when the messaging provider accepts the message. Whether it
actually reached the phone is a separate verdict that lands seconds later. So
“the run finished” and “the customer got the message” are two different facts,
and undelivered is the one that carries the second.
It is non-null exactly when something this run sent was permanently rejected,
and
null otherwise — which is the overwhelming majority of runs.
Read it on every ending, not just
delivery_failed. When we learn about the
failure in time, we end the run right away and reason is delivery_failed.
But a run whose wait had already timed out, or that an operator had already
stopped, is already over when the verdict arrives — those endings keep their
honest no_reply / canceled_* reason, and undelivered is the only field
telling you the customer never saw the message. Branch on reason for the
ending; check undelivered for whether your outreach landed.nulldoes not prove delivery. It means nothing came back as a permanent failure. A run that ended in the few seconds before the provider’s verdict arrived shipsnulland is never re-announced — emission is at-most-once. If delivery matters to your reconciliation, treat this as a strong signal, not a ledger.- Runs that ended before 2026-09-08 have no field at all, and email is not
covered yet: a bounced
SEND_EMAILstep reportsnulland its run still ends on its wait’s timeout asno_reply. Until that ships, don’t readnullas “the email arrived”. - The set of codes that ends a run may widen. It starts deliberately narrow —
only failures that mean the person cannot receive at all. If we later learn that
another code is also hopeless, runs carrying it will start arriving as
delivery_failedwhere they previously arrived asno_reply. Keep yourreasonswitch exhaustive and don’t assume a code’s mapping is frozen.
enrollment.rejected
A trigger matched but no run started.
The top-level
eventName and eventId are deprecated. They still ship, so
nothing breaks — but read them from trigger instead. They will be removed in
the next breaking revision of this contract.
Two things to know:
- “No trigger matched” does not emit. Most ingested events match no journey at all. Emitting for those would bury the rejections that mean something.
- Treat
initiativeIdandworkflowIdas nullable. A failure raised before a specific journey is in hand has no journey to name.
conversation.escalated
The AI decided a person should take over — the customer asked for a human, raised
something the agent can’t resolve, or a journey routed the conversation to your
team. It fires the moment the conversation lands in the unassigned inbox, so it is
your cue to go look, not a record of anyone having replied.
occurredAt on the envelope is when the handoff happened.
- It fires once per handoff, not once per attempt. The agent may decide to escalate several times while a burst of messages arrives; you get one event.
- No run correlation, and no
trigger.triggerdescribes what enrolled a run; a conversation can belong to no journey or to several, so there is nothing non-arbitrary to name. Most escalated conversations have no run at all. Correlate on the identity fields below instead. - Why the conversation was escalated is not on the wire. The agent writes a
short handoff note for whoever picks the thread up, and it stays in the inbox —
it can quote what the customer said. Open
inboxUrlto read it. - An opt-out is not an escalation. When a customer asks not to be contacted the AI also steps off the conversation, but that is the opposite situation and deliberately emits nothing.
Matching an escalation to your own record
personExternalIds — your ids for the person, from your CDP. Best first:
when the conversation belongs to a journey run, the id that run was enrolled for
comes first and is exact; after it come matches on the contact’s phone and email.
We deliberately do not send Boom’s own internal id for the contact: you have no way
to resolve it, so it would look like a correlation key while being useless as one.
personExternalIds can also be []: the contact reached you on a channel and
was never synced to your CDP, so nothing matches. The key is always present, so
branch on the length rather than on its shape, and fall back to opening
inboxUrl. Syncing your people to the CDP (POST $BASE/cdp/people) is what makes
this field reliable — a contact we have never seen in your CDP cannot be matched.
We look the ids up when the escalation is dispatched, so they reflect your CDP at
that moment. Sync a person after the handoff and the event you already
received will not gain their id retroactively.
Environments do not apply here
A conversation isn’t a journey run, so it has no environment. Two things follow, and they’re the opposite of what you might expect from the other events:- The Environments field on a destination is ignored for escalations. Whether
you leave it empty or tick specific environments, every destination subscribed
to
conversation.escalatedreceives it. Tickingproductionwill not stop escalations arriving. {{env.*}}in the URL resolves against your default environment, since there’s no run to say which one applies. Same for the credential: your default environment’s key signs the request, falling back to a global one.
{{env.*}}), which takes environment
resolution out of the picture entirely.
One exception, and it runs the other way: an escalation on a conversation whose
journey run sits in your test environment is not delivered. Test traffic never
reaches your endpoint, for this event or any other. That is not the Environments
field doing the filtering — it applies however that field is set, and a
conversation with no journey run behind it (most of them) is always delivered.
Whichever you choose, make sure the destination’s credential resolves — either a
global credential, or one defined in your default environment. If your default
environment is not the one your escalation endpoint belongs to, read the next
section before you tick the event on.
If your default environment isn’t where escalations should authenticate
Escalations resolve your default environment’s credential row — even when the destination is pinned to another environment, and even when its URL points at another host. So if your default environment isstaging and your escalation endpoint is
production, a credential key that has a staging row will resolve, and will
sign with the staging secret. Your endpoint sees a key it doesn’t recognise and
returns 401; because any 4xx is permanent we don’t retry, so that escalation
is gone. This is the one credential problem that doesn’t announce itself as one —
nothing is reported as unresolvable, and the destination looks correctly
configured.
Adding a global row to that same key does not fix it: an environment-specific
row always wins over the global one, so the
staging row keeps being chosen.1
Create a global-only credential
Settings → HTTP Credentials → add a key (e.g.
escalation_api_key) with
no environment — a global row — holding the key your escalation endpoint
expects.2
Give escalations their own destination
Settings → Webhooks → Add destination: a literal URL (no
{{env.*}}), only conversation.escalated ticked, that credential, and
Environments left empty.3
Untick the escalation on your journey-event destination
Both destinations stay subscribed otherwise, so every handoff is sent twice —
once signed with the global key, and once with your default environment’s,
which is the
401 you started with. Everything else on that destination stays
as it is: it keeps its environment pin and its per-environment credential, and
journey events still resolve against the environment each run belongs to.staging as your default.
Prefer to change the default instead? Making production your default environment
also fixes this, but it changes every other default-environment fallback in your
workspace — most visibly, journeys published without an explicit environment
start resolving production’s variables. Check those before you switch.
Environments
If you use environments to separate staging from production, a subscription can be scoped to them.These are environments inside your organization — a label on journey runs,
webhook subscriptions, and credentials. They are not the two hosted
environments (
www and dev) described in
Authentication, and they do not scope
API keys: one key works across all of your organization’s environments.Per-environment URLs and credentials
Your URL can reference an environment variable with{{env.KEY}}:
https://
prefix itself must be literal — it can’t come from a variable.
Credentials work the same way. A credential key can have one global row plus
per-environment overrides; each delivery resolves the key against the
environment its run belongs to, preferring that environment’s own row and
falling back to the global one. That way one subscription authenticates
correctly everywhere without sharing a production secret with staging.
Retries and what to return
Return any2xx as soon as you’ve stored the event. Do your processing after
responding — we time out at 10 seconds.
A
4xx means “your request is wrong”, and repeating an identical request won’t
fix that — so we don’t retry it. That includes 401 and 403, so if you rotate
the credential on your side, update it in Boom in the same change or events sent
in between are lost.
Troubleshooting
I created the subscription and nothing arrives. Work down this list:- Can you see Settings → Webhooks and Settings → HTTP Credentials at all? Webhooks are on for every workspace, so a missing page is a permissions problem, not a disabled feature.
- Is the journey actually live? Webhooks report runs; no runs, no events.
- Does the event type match?
journey_run.endedfires at the end of a run, which for a journey with waits can be hours or days after enrollment. - Is the subscription enabled? Check Settings → Webhooks.
- Is it scoped to the wrong environment? A subscription pinned to
productionreceives nothing from staging runs. - Can your endpoint be reached from the internet?
localhostand private addresses are blocked by design. For local testing, put a tunnel in front of it. - Does the credential resolve for that environment? This is the one that catches people out — see below.
- Is the destination subscribed to
conversation.escalated? It’s a separate tick from the journey events. - Does the credential resolve for your default environment? Escalations sign with your default environment’s key, or a global one. A key defined only in a non-default environment cannot sign them.
- Is your endpoint rejecting the key? If the key has a row in your default
environment and in the environment your endpoint belongs to, escalations are
signed with the default one — check your own logs for
401/403, which we never retry. Fix: If your default environment isn’t where escalations should authenticate. - Does the URL use
{{env.*}}? It resolves against your default environment’s value, which may not be the host you intended. A literal URL avoids this. - Was the conversation a test run? Escalations on conversations belonging to a journey run in your test environment are never delivered.
{{env.KEY}} is spliced
into the URL as-is, so a value that already includes the scheme —
https://api.example.com in a https://{{env.WEBHOOK_HOST}}/hooks template —
builds https://https://api.example.com/hooks. That is not a valid destination,
so those deliveries are dropped rather than sent somewhere unintended. Store the
host (and path, if any) without the scheme: api.example.com.
I’m getting the same event twice.
Expected — delivery is at-least-once. Dedupe on id, as above.
I’m getting several enrollment.rejected for one person.
Also expected if more than one journey triggers on the same event. Each journey
is a separate enrollment attempt, so each one reports its own outcome.