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

# Journeys

> The versioned workflow behind an initiative. Build it, validate it, and publish it, all over the API.

A journey is the step-by-step workflow a person moves through inside an initiative: a message, a wait, an AI-led conversation, a branch, all the way to an exit. It is fully authorable over the API and MCP, not read-only. You build a draft node by node, wire the connections, set how people enter, then validate and publish.

## Journey, initiative, run

Every journey belongs to exactly one initiative. A new initiative arrives without one, so building the first draft is your first step after creating it, and an initiative cannot launch until that draft exists and validates. From there the initiative can carry several journey versions over its life:

```
Initiative "Renewal reminder"
  Journey v1  STOPPED     (superseded by v2; its runs ran to completion)
  Journey v2  PUBLISHED   (live, one run per enrolled person)
  Journey v3  DRAFT       (being edited)
```

Each enrollment creates its own run against the version that was live at the moment the person entered. `journeys_list` and `journeys_get` return the read-only summary of a journey (its trigger and ordered steps, in plain language). `journeys_get_definition` returns the full editable graph, the shape you build with and save back.

## Node kinds

Discover these from `journeys_authoring_catalog` rather than hardcoding them: it returns every kind's inputs, its output handles (the signals it can emit), and the connection rules, scoped to what your organization actually has enabled.

| Node | What it does | Notable rule |
| - | - | - |
| `ENTRY` | Where people start: manual, segment, CDP event, or an inbound message, with an optional frequency cap | Exactly one per journey |
| `SEND_MESSAGE` | Sends a WhatsApp message from a chosen channel, in one of two content modes: an approved template (`mode: "template"`, and what you get when you omit `mode`), or text you write yourself (`mode: "free_text"`) | In template mode the template must belong to the same WhatsApp account as the channel, or publishing is blocked, and the node emits `SENT` plus `SKIPPED` (your organization's Smart Sending cap refused the send). Wiring `SKIPPED` is optional: leave it and the run simply ends, or wire it to retry later or try another channel. Free text emits **two** handles, `SENT` and `WINDOW_CLOSED`, and both must be wired: Meta accepts free-form content only within 24 hours of the person's last message, so wire `WINDOW_CLOSED` to an approved template as the fallback. Free text needs `body` plus a `bindings` entry per `{{placeholder}}` instead of `templateId`. Set `delivery: "draft"` (template mode only) to hold each send for approval in the campaign's **Drafts** tab (see [drafts](/drafts)); it adds a third handle, `REJECTED`, which can stay unconnected to end the run. An approved draft the cap refuses at send time still takes `SKIPPED`. `delivery: "draft_continue"` queues the same draft but the run leaves through `SENT` at once (its step records `QUEUED`), with no `REJECTED` handle; approving later only sends the message. Either draft mode takes `draftTtlDays` (1-30) to expire drafts nobody reviewed. If drafts are turned off for your organization, publishing a drafted send is refused with `drafts_disabled` |
| `SEND_WHATSAPP_TEXT` | The free-text WhatsApp send under its older name | Deprecated, and still executable — journeys already built with it keep running and can still be read back. Build new ones as a `SEND_MESSAGE` with `mode: "free_text"`, which is the same send |
| `SEND_SMS` | Sends a plain-text SMS from a chosen number | Emits `SENT` plus the optional `SKIPPED` (Smart Sending's cap refused the send), like a template `SEND_MESSAGE`. Every `{{placeholder}}` in `body` needs a matching entry in `bindings`, or publishing is blocked — an unbound one would be sent literally, braces and all. Behind a feature flag your organization may not have yet |
| `SEND_INSTAGRAM` | Sends a free-text Instagram message from a chosen native account | No template concept, so every message is author-written prose. Emits **two** handles, `SENT` and `WINDOW_CLOSED`, and both must be wired: Meta accepts free-form content only within 24 hours of the person's last message. Instagram has no template fallback, so `WINDOW_CLOSED` usually leads to an `EXIT` rather than another send. In a run an `INSTAGRAM_COMMENT` trigger started, a shut window sends one private reply to that comment instead (Meta allows one per comment, within 7 days). Behind a feature flag your organization may not have yet |
| `SEND_EMAIL` | Sends a PUBLISHED email template to the person's email address | Emits `SENT` plus the optional `SKIPPED` (Smart Sending's cap refused the send). The next step cannot be `WAIT_FOR_REPLY`, `MANAGE_CONVERSATION` or `CONVERSATION_BLOCK`, since email has no reply of its own. Publishing needs the template PUBLISHED and a From address that resolves (a verified domain with a default sender, or a valid `fromSenderIdOverride`); `journeys_email_templates` shows both. Also takes `delivery: "draft"` or `"draft_continue"` ([drafts](/drafts)), same `REJECTED` handle on `draft`; `REJECTED` and `SKIPPED` follow the same successor rule as `SENT` |
| `SEND_MESSENGER` | Sends a free-text Facebook Messenger message from a chosen Page | Same rules as `SEND_INSTAGRAM`: author-written prose, **two** handles, `SENT` and `WINDOW_CLOSED`, both wired, because Meta accepts it only within 24 hours of the person's last message. In a run a `FACEBOOK_COMMENT` trigger started, a shut window sends one private reply to that Page comment instead, as for Instagram. Up to 2000 characters. Behind a feature flag your organization may not have yet |
| `REQUEST_CALL_PERMISSION` | Asks the person, on WhatsApp, for permission to call them, then waits for their answer | Emits `GRANTED` (also at once when a permission is already live), `REJECTED`, `TIMEOUT`, `WINDOW_CLOSED` or `SKIPPED`. `GRANTED`, `TIMEOUT` and `WINDOW_CLOSED` must be wired; `REJECTED` and `SKIPPED` end the run when left unwired. Within 24 hours of the person's last message the prompt goes out as is. Outside that window it goes out as a call-permission template that Boom creates for your number and submits to Meta when calling is enabled or a journey with this step is published (Meta usually approves it within minutes to hours, and bills it as a utility template); until it is approved, the node takes `WINDOW_CLOSED` and sends nothing. A `WAIT_FOR_REPLY` or `MANAGE_CONVERSATION` before it asks inside the window, with no template needed. Meta allows one request per 24 hours and two per week (`SKIPPED`), and a permission lasts 7 days. The channel must be a WhatsApp number with calling enabled, and Meta does not allow business calls from numbers in the US and Canada (+1), Egypt (+20), Nigeria (+234), Turkey (+90) or Vietnam (+84), so publishing is blocked for those. Early access: ask Boom to enable WhatsApp calling for your organization |
| `PLACE_CALL` | Calls the person on WhatsApp; your initiative's voice agent holds the conversation | Needs a live permission, so it usually follows `REQUEST_CALL_PERMISSION`'s `GRANTED`. Emits `ANSWERED`, `MISSED` (no answer or declined), `FAILED`, `NOT_ALLOWED` (nothing rang: no permission, Meta's limit of 5 calls a day, no voice agent ready, or calling turned off) or `TIMEOUT` (no outcome within `maxWait`, at most 24 hours). All five must be wired. The call's result (status, duration, summary and extracted data) is available to later steps. Same channel and country rules as `REQUEST_CALL_PERMISSION`. Early access: ask Boom to enable WhatsApp calling for your organization |
| `SEND_REWARD` | Gives the person a gift card (`amount` in USD or MXN) through your connected Tremendous account | When `criterion` (who qualifies, in plain words) is set, it is judged against the person's conversation first. Emits `SENT` (a reward was ordered), `NOT_ELIGIBLE` (the criterion was not met; nothing was ordered), `FAILED` (nothing was ordered: no connection, no email, no template, or the provider refused) or `REJECTED` (declined in review; ends the run when unwired). Every reward waits for approval in Drafts before anything is ordered. `SENT`, `NOT_ELIGIBLE` and `FAILED` must be wired. `delivery: "conversation"` sends the redemption link on WhatsApp and needs an approved reward template in your organization's language on the sending number's WhatsApp account; `delivery: "email"` lets Tremendous email it (to the person's email, from their profile or CDP record; people with none take `FAILED`, and validation warns about it). At most one reward per person per enrollment: if they already have one (from your team, another reward step, or an earlier pass through a loop), the step takes `NOT_ELIGIBLE` and nothing is ordered; validation warns about journeys with several reward steps or a loop through one. If the initiative has a rewards budget, the step must pay in the budget's currency, and approvals that would pass the budget are refused (the reward keeps waiting in Drafts). Validation warns (`SEND_MESSAGE_REWARD_NOT_MENTIONED`) when the journey's first message never mentions the reward. Early access: ask Boom to enable rewards in journeys for your organization |
| `SHOPIFY_UPSERT_CUSTOMER` | **Create or update Shopify customer**: creates or updates the person as a Customer in your connected Shopify store. Its exits read *Saved to Shopify*, *Not sent* and *Shopify error* in the builder | Typical use: a Lead Capture pop-up or embedded form as the trigger, then this step, so new leads land in Shopify. Matches an existing customer by email, then phone. A new customer is created with the person's email, phone and name; an existing one only gains fields that are blank in Shopify, plus the step's tags (added, never replacing the store's tags). Tags and the customer note can include variables, such as the form's name, a pop-up prize code, data the agent extracted in an earlier conversation, or any CDP field; a tag that comes out empty is skipped — names, emails and phones already in Shopify are never overwritten. When the triggering pop-up or form recorded a marketing opt-in, the customer is subscribed in Shopify for the email and phone given in that submission; nobody is ever unsubscribed, and a customer who unsubscribed in Shopify stays unsubscribed. Emits `SUCCESS`, `FAILED` (store disconnected, the Shopify app lacks permission to write customers, or Shopify refused) or `SKIPPED` (no email or phone, Do Not Contact, or your organization's hourly limit; ends the run when unwired). `SUCCESS` and `FAILED` must be wired. Erasing a person in Boom does not delete their Shopify customer. Only offered once a Shopify store is connected. Early access: ask Boom to enable it for your organization |
| `WAIT_FOR_REPLY` | Waits for the person to reply | Emits `REPLIED` or `TIMEOUT`. Pair it with `MANAGE_CONVERSATION` |
| `MANAGE_CONVERSATION` | Runs the AI-led conversation, or hands it to a human (`mode: ESCALATE`) | Emits `CLOSED` or `STALE`. An optional `goal` gives the agent instructions for this step only, on top of the initiative objective. An optional inactivity timeout closes the conversation after a window of silence, unless the agent has handed it to a human |
| `CONVERSATION_BLOCK` | Legacy combined wait-and-converse step | Kept so journeys published before the split keep running. Build new journeys with `WAIT_FOR_REPLY` + `MANAGE_CONVERSATION` instead |
| `DISPATCH_EVENT` | Records a CDP event for the person | Lets one journey enroll people into another: the dispatched event can be a different journey's trigger |
| `DELAY` | Pauses the run for a duration, until a date, or until a weekday window | A pure wait. It does not race an incoming reply |
| `DECISION` | Two-way branch, combined by AND/OR, over workflow data, a reserved event, or a live CDP attribute | Both `YES` and `NO` must be wired before publishing |
| `CASE` | Switches on a single person attribute, up to 10 branches | Every branch handle, plus the default handle, must be wired |
| `HTTP_REQUEST` | Calls an external endpoint, optionally with a stored credential | Emits `SUCCESS` or `FAILED`. The url, headers and body interpolate `{{variable}}` tokens from a different set than a message binding uses — see the node's own inputs in `journeys_authoring_catalog`. Behind a feature flag your organization may not have yet |
| `ROUTE_TO_INITIATIVE` | Ends the journey for the person and enrolls them into a different initiative, moving their live conversation onto it | Terminal, so it cannot have an outgoing connection. The target must be another live initiative in the same organization, and it should open by sending: the message that caused the routing belongs to the old conversation, so the target cannot reply to it |
| `EXIT` | Ends the journey for the person | At least one end point is required — an `EXIT` or a `ROUTE_TO_INITIATIVE` |

<Note>
  `SEND_EMAIL` sends a PUBLISHED email template and emits `SENT`, plus the optional `SKIPPED` when Smart Sending's cap refuses the send. It never emits a reply signal, so the step after it cannot wait for a reply or run a conversation (see Troubleshooting). Pick its template and check sender readiness with `journeys_email_templates`. Email has no `channelId`: it sends from the organization's verified domain. Author the templates themselves with the [email template tools](/email-templates).
</Note>

## Build a journey

<Steps>
  <Step title="Discover what you can build">
    Call `journeys_authoring_catalog` for the node kinds, and the reference catalogs for the ids and paths a node needs: `journeys_message_channels` for any send node's `channelId` (WhatsApp, SMS, Instagram and Messenger — each row carries `type` and the `sendNodeKind` that takes it; a WhatsApp row with `callingEnabled: true` can also be the `channelId` of a `REQUEST_CALL_PERMISSION` or `PLACE_CALL` node), `journeys_message_templates` for a `SEND_MESSAGE` node's approved template, `journeys_email_templates` for a `SEND_EMAIL` node's published email template, sender and email readiness (email has no `channelId`), `journeys_event_catalog` for event names, `journeys_condition_catalog` for `DECISION`/`CASE` attribute paths, and `journeys_message_variables` for what a template placeholder can bind to.
  </Step>

  <Step title="Start a draft">
    `journeys_create_draft` with an `initiativeId` and a definition (name, nodes, edges). Omit node positions and the server lays the graph out for you. To keep editing an initiative's existing draft instead, load it with `journeys_get_definition`.
  </Step>

  <Step title="Add and wire nodes">
    Shape the graph with `journeys_add_node`, `journeys_update_node`, and `journeys_delete_node`, then connect them with `journeys_connect_nodes`, naming the source node's output handle (`SENT`, `REPLIED`, `YES`, `case:<id>`, and so on). A handle wires to at most one node. `journeys_disconnect_nodes` removes an edge.

    `journeys_update_node` merges the `inputs` you send into the ones the node already holds, and checks the *merged* node against the same per-kind rules `journeys_add_node` applies. So a patch that is fine on its own is refused when it contradicts what the node already stores, such as sending a `templateId` to a node whose `mode` is `free_text`. A half-configured node is never refused for being incomplete — that is what `journeys_validate` is for.
  </Step>

  <Step title="Set the trigger">
    `journeys_set_trigger` configures the ENTRY node: `manual`, `segment` (needs a `segmentId`), `cdp_event` (needs an `eventName`), or `inbound` (needs an `inboundAction` and `inboundChannelIds`), with an optional frequency cap.

    <Warning>
      A segment trigger needs the segment's internal id, and the public API
      identifies segments by `slug` instead, so you cannot wire one from the API
      or MCP today. Passing a slug fails with a not-found error. Set a segment
      trigger in the Boom app. Manual and event triggers work fully from here.
    </Warning>
  </Step>

  <Step title="Validate">
    `journeys_validate` dry-runs the publish checks against a stored draft or an arbitrary definition, without saving anything. It returns whether the graph is valid and the full list of issues, errors block publishing, warnings are advisory.
  </Step>

  <Step title="Publish">
    `journeys_publish` with `confirm: true`. This is the one action here that starts real outreach.
  </Step>
</Steps>

## Publishing and versioning

Publishing is deliberately guarded:

* It validates first. Any error-severity issue blocks the publish, the same checks `journeys_validate` runs.
* Warning-severity issues do not block it, and come back on the response as `warnings`, an array in the same shape `journeys_validate` returns. It is empty on a clean publish. Read it: some warnings cover conditions the validator cannot decide on its own, such as an inbound-triggered journey whose first step is a free-text message on a channel whose AI agent may also answer, which would send the person two replies.
* A node that sends a field your org marked **hidden** (sensitive) gets a warning naming the field: `SENSITIVE_FIELD_SENT` for a message (WhatsApp, SMS, email, Instagram, Messenger), `SENSITIVE_FIELD_SENT_EXTERNAL` for an HTTP request, `SENSITIVE_FIELD_MAPPED` for a route-to-initiative mapping. Sending a hidden field is allowed, since the recipient is the person, so it never blocks the publish. It tells you the value will now sit somewhere new: the conversation, where the agent and inbox users can read it, an external URL, or another initiative's context.
* You do not have to publish to see them. Calling `journeys_publish` **without** `confirm: true` publishes nothing and refuses, and that refusal lists the warnings the draft carries — in the error message, and in `issues` on the error body. So the safe two-step call, preview then confirm, shows you what a clean publish would not. `journeys_validate` returns the same warnings if you would rather ask before you are ready to publish at all.
* It requires an explicit `confirm: true`. There is no accidental publish, because publishing enrolls real people and sends them real messages.
* It is atomic. The previous PUBLISHED version is retired in the same operation that promotes the new one, never a moment where two versions are both live.
* People already mid-journey are unaffected. Each run is pinned to the version it enrolled under, so publishing a new version never changes someone's path partway through.

Published journeys are frozen: there is no in-place edit. To change anything, even a small fix, fork it first with `journeys_create_draft_from_published`, which copies the live version into a new editable draft while the original keeps running untouched. Edit the fork, validate it, and publish it, which then supersedes the version you forked from.

A journey reaches STOPPED two different ways, and they do not mean the same thing. Publishing a replacement supersedes the old version, which keeps running out the people already on it. Stopping it retires it outright. Both show `status: STOPPED`; only the second sets `stoppedAt`.

## Stopping a journey

Publishing a replacement is not the only way to take a campaign down. `journeys_stop` retires the live version without putting anything in its place. It takes either a journey id or the initiative id, and needs `confirm: true`.

* **Nobody else enrolls.** Both the trigger and the initiative's live-journey pointer are closed, so a segment backfill still in flight stops producing enrollments too.
* **Anyone enrolled but not yet messaged is dropped** at their send instead of receiving it. That includes people queued behind a large batch still draining.
* **Conversations already under way continue normally.** Stopping is not cancelling — it closes the front door and leaves the people inside alone.
* **Sends waiting for approval are canceled.** Drafts from the stopped version move to Canceled and those runs end, so the person can be enrolled again.

Stopping leaves the initiative with no PUBLISHED journey at all, so it does not undo itself and launching the initiative will not revive it. To run the campaign again, fork the version you want with `journeys_create_draft_from_published` and publish it.

## Triggers

A journey enters people one of four ways, set on its ENTRY node:

* **Manual**: an operator or an API call adds people directly.
* **Segment**: anyone who enters the segment enrolls. Pass `includeExisting: true` on publish to also backfill current members once. Backfill takes whoever is a member at that moment, so a segment that has never been evaluated has no members and backfills nobody, with no error to tell you.
* **CDP event**: a person enrolls the moment a matching event arrives and resolves to a known person.
* **Inbound**: a person enrolls the moment they message one of the journey's channel instances (WhatsApp, SMS, or Instagram), rather than something reaching out to them first. Set with `journeys_set_trigger`'s `inboundAction`, `inboundChannelIds`, and an optional `inboundKeyword` (matched by `inboundMatchMode`: `EXACT`, `CONTAINS`, or `ALL_WORDS`). Leaving the keyword empty makes the trigger a catch-all for every inbound message on those channels. Several journeys may listen on the same (organization, channel, action) — a keyword tells them apart, and resolution picks one winner per message.

An optional frequency cap (`maxEnrollments` plus `enrollmentWindow`, set together or not at all) limits how often the same person can re-enter within a rolling window, useful for a journey that can otherwise re-trigger on repeat events or segment membership. The cap is per person, never a campaign-wide total: `maxEnrollments: 1` stops one person entering twice, it does not stop the journey enrolling new people indefinitely. A journey using **several runs at once** (below) cannot also carry a cap — publish refuses the combination, because that journey already refuses a repeat of the same value and a per-person cap on top would drop the later runs.

## Several runs at once

By default a person holds one run of a journey at a time, and a second triggering event while that run is live is dropped. That is right for a conversation and wrong for a transaction: someone who takes two credits in a day needs two payment links, not one.

A journey triggered by a **CDP event** can set `parallelRunsBy` on its ENTRY node to the name of a property on that event — `paymentLinkId`, `orderId` — and then each distinct value of that property starts its own run for the same person, carrying its own event's data into its messages.

In the builder, open the trigger, pick the event and type the property under **One run per**. Leave it blank for the default one run per person. Over the API or MCP, pass `parallelRunsBy` to `journeys_set_trigger`. Several runs at once is enabled per organization: if the builder doesn't show **One run per**, ask your Boom contact.

The property has to be a top-level field of the event's `properties` (`payment_pkey`, not `payment.pkey` inside a nested object), and event names use letters, numbers and underscores only.

What that changes:

* **A repeat of the same value never starts a second run.** A value is good for one run ever, whatever happened to it, so a retried or replayed event cannot restart a sequence that already finished. The flip side: a run that failed cannot be re-run for that same value.
* **An event missing the property is refused**, not enrolled. Enrolling it would send a message with blank placeholders.
* **Only the triggering event can start a run.** Adding a person to the journey by hand, or through the API, is refused for the same reason — there is no event to take the property from. Don't point a segment at a journey like this either.
* **The journey can only send.** No Wait for reply, no Handle conversation, no legacy business-hours delay on a send. A customer's reply arrives on the person, not on one run, so a single reply would release every run at once — one "ok gracias" about the first credit would silently drop the pending reminders for the others.
* **A stop event stops only its own run.** The stop event has to carry the same property with the same value. That is the point — one link being paid must not stop the reminders for the person's other links.
* **A stop event that does not carry the property stops nothing.** This looks like a bug and is not: the alternative is stopping every run the person has.

Because a stop event and a triggering event are processed independently, a stop event can arrive before the run it would have stopped exists, in which case the run still starts. Put a **DECISION** before each send in a reminder loop — "is this link still pending?" — rather than relying on the stop event landing first.

Everything about a journey that does not set `parallelRunsBy` is unchanged, including the one-run-at-a-time rule.

## Troubleshooting

**A template placeholder sends blank instead of erroring.** It is usually bound to a path that does not resolve for this journey's trigger. Call `journeys_message_variables` for the exact set of paths this journey can use. A custom person attribute binds as `person.<key>`, not `attributes.<key>`, that second form is the syntax for `DECISION`/`CASE` conditions, not message bindings.

**A `{{variable}}` in an HTTP request sends blank instead of erroring.** Same symptom, different answer: `journeys_message_variables` describes what a *message* can bind and does not list the HTTP-only groups, so it will not tell you why an HTTP body came out empty. The roots an HTTP node resolves are documented on the node's own `url`, `headers` and `body` inputs in `journeys_authoring_catalog`, and validation now reports an unresolvable token as `UNRESOLVABLE_HTTP_TOKEN` when you validate or publish.

It is a **warning, not an error**, so it never blocks a publish that would have worked: the built-in customer fields are guidance rather than a closed list, and a request may legitimately reference a column the catalog does not know about. Read the warnings on every publish rather than only the errors. The most common cause is a near-miss on a built-in field — `customer.phone` instead of `customer.phoneNumber` — which substitutes as an empty string and sends a request that looks successful.

**Publish fails because a conversational node follows an email send.** An email-send step has no reply lifecycle: only a delay, an exit, or another non-conversational node may come after it. Route any reply-driven path around it instead of through it.

**Publish fails with validation errors you did not expect.** `journeys_publish` always validates before publishing and refuses on any error-severity issue, it does not publish "mostly working" journeys. Run `journeys_validate` while you iterate so you see the same issues before you attempt to go live.

**A time-limited campaign keeps enrolling people after its date has passed.** Nothing stops it on its own: a published journey stays live until something takes it down, a frequency cap only limits one person's re-entry, and a `DELAY` set to a date that has already gone by resolves immediately rather than holding people back. Take it down with `journeys_stop`.

## Related

<CardGroup cols={2}>
  <Card title="Use MCP" icon="plug" href="/use-mcp">
    Connect an AI tool to author and run journeys conversationally.
  </Card>

  <Card title="Template variables" icon="message-square" href="/template-variables">
    The full variable catalog a SEND\_MESSAGE binding can reference, and what resolves per trigger.
  </Card>

  <Card title="Events" icon="bolt" href="/events">
    Record the CDP events that trigger a journey or feed a DECISION condition.
  </Card>

  <Card title="One uniform surface" icon="layers" href="/one-surface">
    Why every journey tool here has a matching REST endpoint.
  </Card>
</CardGroup>


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