Skip to main content
A webhook tells your systems what happened in Boom without you polling for it. When a journey run starts, when it ends, when someone couldn’t be enrolled, or when the AI hands a conversation to a person, Boom sends a signed 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 two journey_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.
Webhooks are not a source of truth — reconcile. Delivery is at-least-once (so dedupe), but emission is at-most-once: if we fail to emit, the run is permanently unreported and no retry recovers it. A subscription can also be skipped at delivery time — an unresolvable credential or {{env.*}} token drops the event outright. If your system must be exactly consistent with ours, treat webhooks as a fast path and reconcile periodically, not as a ledger.

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://; plain http is 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 than localhost.
  • Events — tick the ones you want. One destination can take all four, though see Environments do not apply here for why conversation.escalated is 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).
A passing ping does not prove conversation.escalated works. The ping tests each environment the destination fires in, resolving that environment’s URL and credential. Escalations don’t work that way — they always resolve your default environment. If those differ, the ping can pass while every real escalation fails. See If your default environment isn’t where escalations should authenticate.

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.
Delivery is at-least-once. You must dedupe on id. A retry after a timeout can deliver an event your server already processed successfully — if the response never reached us, we try again. Store id and ignore repeats.
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.
personId is frequently null. It is populated on the CDP-event enrollment path, but manual, CSV and API enrollment currently leave it unset — the same run object will carry personExternalId regardless. Join on personExternalId, or you will silently match nothing for those runs.
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 with POST /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:
externalId can be null. Runs that were already in flight before this field shipped report null. The key is always present, so branch on the value.
  • 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. Use trigger.externalId plus run.engagementId as the run key.
  • Mint a fresh one per event — never re-post one. externalId is 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.
Runs that didn’t come from an event have no 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.
The nine values, so you can write an exhaustive 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 initiativeId and workflowId as 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. trigger describes 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 inboxUrl to 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

personExternalIdsyour 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.
This is an array on purpose — treat it as candidates, not an identity. A CDP Person is identified by externalId; phone and email are not unique, so one number can belong to several People (shared family lines, a clinic front desk, a business number on many records). If you need one id, take personExternalIds[0] — it is the enrolled person whenever the conversation came from a journey run — but handle the case where more than one comes back.
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.
This is a nudge, not an alarm. Emission is at-most-once, like every event here — if we fail to emit, no retry finds this conversation again. Good enough to stop watching the inbox all day; not something to put an SLA on. Keep the inbox as the source of truth for what needs a human.

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.escalated receives it. Ticking production will 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.
So you cannot point escalations at a non-default environment by ticking boxes — there’s no environment for the event to belong to. If you need them at a specific host, use a literal URL (no {{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.
Recommended: give conversation.escalated a destination of its own, with a literal URL. Not because the shared one breaks — it won’t — but because a handoff goes to whoever staffs your inbox, which is rarely the same system that consumes run lifecycle. Splitting them lets you point escalations at an on-call webhook or a support queue without touching the integration that tracks runs, and a literal URL means the host is exactly what you typed.
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 is staging 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.
The fix is a credential key that only ever has one value:
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.
A global-only key resolves identically in every environment, so escalations authenticate correctly no matter which environment carries the default flag — and you can keep 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.
Leave Environments empty and the subscription receives events from runs in every environment. Tick specific ones and it only receives those — useful when your staging systems shouldn’t hear about production runs.

Per-environment URLs and credentials

Your URL can reference an environment variable with {{env.KEY}}:
Each environment substitutes its own value, so one subscription can point at staging from staging runs and production from production runs. The 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.
If a subscription fires in an environment where its credential key has no row — and no global row exists — those deliveries are dropped, not sent unauthenticated. The same applies to a {{env.KEY}} an environment doesn’t define, or one whose value can’t build a valid https:// URL. Adding a new environment is the usual cause: see Troubleshooting.

Retries and what to return

Return any 2xx 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.
A slow or failing endpoint does hold up your other subscriptions. Deliveries for one event are attempted in sequence, and a retryable failure interrupts the pass — subscriptions after it are retried along with it, and if its retries exhaust, they may not be delivered at all. Only a permanent 4xx lets the rest continue immediately. If you have several subscriptions, keep every endpoint fast and always-answering; return 2xx on receipt and process afterwards.

Troubleshooting

I created the subscription and nothing arrives. Work down this list:
  1. 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.
  2. Is the journey actually live? Webhooks report runs; no runs, no events.
  3. Does the event type match? journey_run.ended fires at the end of a run, which for a journey with waits can be hours or days after enrollment.
  4. Is the subscription enabled? Check Settings → Webhooks.
  5. Is it scoped to the wrong environment? A subscription pinned to production receives nothing from staging runs.
  6. Can your endpoint be reached from the internet? localhost and private addresses are blocked by design. For local testing, put a tunnel in front of it.
  7. Does the credential resolve for that environment? This is the one that catches people out — see below.
Escalations aren’t arriving, but journey events are. The two resolve environments differently, so check these — in this order:
  1. Is the destination subscribed to conversation.escalated? It’s a separate tick from the journey events.
  2. 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.
  3. 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.
  4. 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.
  5. Was the conversation a test run? Escalations on conversations belonging to a journey run in your test environment are never delivered.
Note that the Environments field is not on this list: it is ignored for escalations, so ticking or unticking it changes nothing. See Environments do not apply here. It worked, then stopped after I added an environment. That’s the most common cause of silent loss. A subscription with no environments ticked fires in every environment, including ones created after it was saved. If its credential key has no row in the new environment and no global row, every delivery from that environment is dropped. Open Settings → Webhooks: each destination lists one row per environment, showing the URL it resolves to there and whether it is delivering. Fix it by adding a credential row for that environment, or by ticking specific environments on the subscription so it stops firing in the new one. (The same facts for a single environment are in that environment’s Webhooks panel under Settings → Environments.) A variable is defined, but the environment still shows as not delivering. Check the variable’s value, not just that it exists. {{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.