> ## Documentation Index
> Fetch the complete documentation index at: https://docs.useboom.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Get a POST to your endpoint when a journey run starts, ends, an enrollment is rejected, or a conversation is handed to a human.

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:

| Event | Fires when |
| - | - |
| `journey_run.started` | Someone was enrolled and their run began. |
| `journey_run.ended` | A run reached a terminal state, however it ended. |
| `enrollment.rejected` | A **CDP event** matched a journey trigger, but the run did not start. |
| `conversation.escalated` | The AI stepped off a conversation and handed it to a human. |

<Note>
  `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"`.
</Note>

## 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.

Three runs from the same journey, all real:

| | Conversed | Never replied | Never received it |
| - | - | - | - |
| `reason` | `completed` | `no_reply` | `delivery_failed` |
| `lastSignal` | `CLOSED` | `TIMEOUT` | `null` |
| `closedVia` | `agent-end-conversation` | `null` | `null` |
| `undelivered` | `null` | `null` | `{ … "errorCode": "63024" }` |
| `messagesSent` / `messagesReceived` | 3 / 2 | 1 / 0 | 1 / 0 |
| `durationMs` | 109,713 (\~2 min) | \~5 min (the wait) | \~4s (no wait at all) |

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`.

<Warning>
  **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.
</Warning>

## Set one up

<Note>
  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.
</Note>

<Steps>
  <Step title="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.
  </Step>

  <Step title="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](#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](#environments) below.
  </Step>

  <Step title="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](#troubleshooting)).

    <Warning>
      **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](#if-your-default-environment-isnt-where-escalations-should-authenticate).
    </Warning>
  </Step>
</Steps>

## The envelope

Every delivery has the same outer shape:

```json theme={null}
{
  "id": "evt_cm7x…",
  "type": "journey_run.ended",
  "occurredAt": "2026-07-29T19:26:00.000Z",
  "organizationId": "org_…",
  "data": {}
}
```

`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.

<Warning>
  **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.
</Warning>

Requests are `POST`, `Content-Type: application/json`, and time out after 10
seconds.

<Note>
  **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.
</Note>

## `journey_run.started`

```json theme={null}
"data": {
  "run": {
    "engagementId": "…", "initiativeId": "…", "initiativeName": "Winback",
    "workflowId": "…", "customerId": "…", "personId": "…",
    "personExternalId": "acct-4417"
  },
  "trigger": {
    "kind": "cdp_event",
    "eventName": "checkout_abandoned",
    "eventId": "…",
    "externalId": "your-ref-99182"
  },
  "startedAt": "2026-07-29T19:26:00.000Z"
}
```

`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`.

<Warning>
  **`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.
</Warning>

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):

| `kind` | Extra fields | Means |
| - | - | - |
| `cdp_event` | `eventName`, `eventId`, `externalId` | An ingested event matched a trigger. |
| `segment` | `segmentId` | The person entered a segment. |
| `manual` | — | Enrolled by hand, by CSV, or through the API. |

### 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:

<Warning>
  **`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.
</Warning>

* **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`

```json theme={null}
"data": {
  "run": { "…same ids as above…" },
  "trigger": { "…same object as on started…" },
  "outcome": {
    "status": "CONVERSATION_COMPLETE",
    "reason": "no_reply",
    "lastSignal": "TIMEOUT",
    "closedVia": null,
    "terminalNode": { "id": "exit_no_response", "kind": "EXIT" },
    "error": null,
    "undelivered": null,
    "startedAt": "…", "endedAt": "…", "durationMs": 43200000,
    "messagesSent": 1, "messagesReceived": 0
  }
}
```

<Note>
  **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.
</Note>

The ten values, so you can write an exhaustive `switch`:

| `reason` | Means | `lastSignal` |
| - | - | - |
| `completed` | The run reached its end normally. Includes a journey that simply had nothing to wait for — it does **not** guarantee the customer engaged; check `messagesReceived` for that. | `REPLIED`, `CLOSED`, or `null` |
| `no_reply` | Outreach went out; the customer never replied. | `TIMEOUT` |
| `abandoned` | The customer replied at least once, then stopped. | `STALE` |
| `delivery_failed` | A message this run sent could not reach the person **at all** — the number is not a WhatsApp account, does not exist, or replied STOP. The run ends immediately rather than waiting for a reply that cannot come. `outcome.undelivered` carries the code. Note this is narrower than "the send failed": see below. | `null` |
| `failed_node_error` | A step threw. `outcome.error` is set. | — |
| `failed_engine` | The run failed without a specific failing step. `outcome.error` is always `null`. | — |
| `canceled_operator` | An operator stopped the run, the engine halted it, or one of the journey's stop-condition events arrived. | — |
| `canceled_initiative` | The whole initiative was canceled. | — |
| `canceled_participant_stop` | This one participant was stopped. | — |
| `expired` | Reserved. **Cannot currently occur on `journey_run.ended`** — only non-journey outreach expires, and those runs are never reported here. Handle it for exhaustiveness; don't build behaviour on it. | — |

<Note>
  **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`.
</Note>

Nullable fields on `outcome`, so your parser doesn't assume otherwise:

| Field | Shape |
| - | - |
| `error` | `{ "errorName": "TypeError" }` or `null` — an object, not a string |
| `undelivered` | `{ nodeId, errorCode, failedAt }` or `null` — see below |
| `terminalNode` | `{ id, kind }` or `null` when the run recorded no steps |
| `lastSignal` | one of the signals above, or `null` |
| `closedVia` | one of the four close triggers below, or `null` |

### `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.

| `closedVia` | Means |
| - | - |
| `agent-end-conversation` | The agent decided the conversation was done and ended it itself. |
| `operator` | A human closed it from the Shared Inbox. Usually means someone took over. |
| `inactivity-timeout` | The conversation node's own inactivity timer fired — the customer stopped replying mid-conversation. Never reported for a conversation the agent handed to a human: the timer leaves those open. |
| `system` | Closed by the platform rather than by the agent or a person: the global inactivity sweep, a participant being stopped, or a close we did not observe directly. |

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.

<Note>
  `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.
</Note>

`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.

```json theme={null}
"undelivered": {
  "nodeId": "send_bienvenida",
  "errorCode": "63024",
  "failedAt": "2026-09-08T14:58:41.260Z"
}
```

| Field | Shape |
| - | - |
| `nodeId` | The journey step that sent the message, or `null` when the failed message was an AI reply inside a conversation rather than a step's own send |
| `errorCode` | The provider's failure code, always a **string** (e.g. `"63024"` — not a WhatsApp user), or `null` if the provider gave none |
| `failedAt` | ISO instant we recorded the failure |

It is non-null **exactly when** something this run sent was permanently rejected,
and `null` otherwise — which is the overwhelming majority of runs.

<Warning>
  **`undelivered` is set far more often than `reason` is `delivery_failed`, and
  that is deliberate.** Roughly half of permanent send failures are *not* an
  unreachable person: the most common single one is WhatsApp declining a
  **marketing template** to an account that is perfectly live and may well reply
  to you tomorrow. Ending that run would be wrong, so we don't — it keeps running
  and ends on its own terms, usually `no_reply`.

  So the two fields answer two different questions, and you want both:

  * **`reason`** — why the run ended.
  * **`undelivered`** — whether your message actually landed.

  A run that reports `reason: "no_reply"` **and** a non-null `undelivered` did not
  get ignored by the customer; it never reached them. Treating `reason` alone as
  your delivery signal will read those as disinterest.

  Only branch "this contact is bad, stop trying" off `delivery_failed`. For any
  other `undelivered` code, retrying later — or on a different template category
  — is usually the right move.
</Warning>

<Note>
  **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.
</Note>

Two limits to code against:

* **`null` does 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 ships `null` and 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_EMAIL` step reports `null` and its run still ends
  on its wait's timeout as `no_reply`. Until that ships, don't read `null` as
  "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_failed` where they previously arrived as `no_reply`. Keep your
  `reason` switch exhaustive and don't assume a code's mapping is frozen.

## `enrollment.rejected`

A trigger matched but no run started.

```json theme={null}
"data": {
  "reason": "active_run",
  "detail": { "existingEngagementId": "…" },
  "initiativeId": "…", "workflowId": "…",
  "trigger": {
    "kind": "cdp_event",
    "eventName": "checkout_abandoned",
    "eventId": "…",
    "externalId": "your-ref-99182"
  },
  "eventName": "checkout_abandoned", "eventId": "…",
  "personId": "…", "personExternalId": "acct-4417", "customerId": "…"
}
```

<Note>
  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.
</Note>

| `reason` | Means | `detail` |
| - | - | - |
| `no_channel` | No reachable phone or email for this person. | — |
| `active_run` | They're already in a live run of this journey. | `existingEngagementId` |
| `frequency_cap` | They hit the initiative's enrollment cap. | `count`, `window` |
| `cooldown` | Another initiative messaged them inside your Smart Sending cooldown, so this one may not enroll them. Enrollments the person caused — an inbound message, a comment reply, a handoff — are exempt, as is an initiative set to always send. | `cooldownDays`, `blockedUntil`, `blockedByInitiativeId` (null if the blocking send had no initiative) |
| `no_workflow` | The journey stopped being published mid-flight. | — |
| `internal_error` | Enrollment failed after retries. | `errorName` |

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.

```json theme={null}
"data": {
  "conversationId": "cmsxfhb8d000r04jv9x43ufjl",
  "inboxUrl": "https://app.useboom.ai/org/your-workspace/inbox?id=cmsxfhb8d000r04jv9x43ufjl",
  "personExternalIds": ["acct-4417"]
}
```

`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

**`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.

<Warning>
  **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.
</Warning>

`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.

<Note>
  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.
</Note>

<Warning>
  **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.
</Warning>

### 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.

<Tip>
  **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. If you are splitting
  an existing destination, untick the event there — a destination you leave
  subscribed keeps receiving its own copy of every handoff.
</Tip>

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.

<Note>
  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.
</Note>

The fix is a credential key that only ever has one value:

<Steps>
  <Step title="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.
  </Step>

  <Step title="Give escalations their own destination">
    **Settings → Webhooks → Add destination**: a **literal** URL (no
    `{{env.*}}`), only `conversation.escalated` ticked, that credential, and
    **Environments left empty**.
  </Step>

  <Step title="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.
  </Step>
</Steps>

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.

<Note>
  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](/authentication#hosted-environments), and they do not scope
  API keys: one key works across all of your organization's environments.
</Note>

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}}`:

```
https://{{env.WEBHOOK_HOST}}/boom/events
```

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.

<Warning>
  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](#troubleshooting).
</Warning>

## 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.

| Your response | What we do |
| - | - |
| `2xx` | Delivered. Done. |
| `5xx`, `408`, `429` | Retried up to 3 times — **4 delivery attempts** in total. |
| No response (timeout, DNS, connection reset) | Retried up to 3 times. |
| Any other `4xx` | **Permanent.** Not retried. |

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.

<Warning>
  **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.
</Warning>

## 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](#if-your-default-environment-isnt-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](#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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.