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.
Two runs from the same journey, both real:
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 — ask a workspace admin
for the webhooks and HTTP-credentials permissions, 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.
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:
Nullable fields on
outcome, so your parser doesn’t assume otherwise:
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.
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
Leave your journey-event destination alone
It keeps its environment pin and its per-environment credential. 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.