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

Two runs from the same journey, both real:

|                                     | Conversed         | Never replied      |
| ----------------------------------- | ----------------- | ------------------ |
| `reason`                            | `completed`       | `no_reply`         |
| `lastSignal`                        | `CLOSED`          | `TIMEOUT`          |
| `messagesSent` / `messagesReceived` | 3 / 2             | 1 / 0              |
| `durationMs`                        | 109,713 (\~2 min) | \~5 min (the wait) |

`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 — ask a workspace admin
  for the webhooks and HTTP-credentials permissions, 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.

## `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",
    "terminalNode": { "id": "exit_no_response", "kind": "EXIT" },
    "error": 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 nine 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`                        |
| `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, or the engine halted it.                                                                                                                                               | —                              |
| `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. | —                              |

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

| Field          | Shape                                                              |
| -------------- | ------------------------------------------------------------------ |
| `error`        | `{ "errorName": "TypeError" }` or `null` — an object, not a string |
| `terminalNode` | `{ id, kind }` or `null` when the run recorded no steps            |
| `lastSignal`   | one of the signals above, or `null`                                |

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

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