# Analytics and attribution Source: https://docs.useboom.ai/analytics Define what success means for an initiative, then measure against it. Boom does not decide what a good outcome is for you. You define it, per initiative, and everything else is measured against that definition: a rate, a value, a funnel, and the list of individual conversations that counted. > [!NOTE] > > Success metrics can be configured from the app or [over the API and > MCP](#configuring-metrics-over-the-api). The analytics **views** — the funnel, > the per node numbers, the charts — are read in the app only. The raw material > behind them is on the API: [extracted values](/extraction), transcripts and > participant records all come back, so you can compute your own numbers if you > would rather. ## Defining success An initiative can carry more than one named success metric. Each one says what signal counts as success, and how to aggregate it. Three kinds of signal: | Signal | Counts success when | Use it for | | - | - | - | | A **CDP event** | A named event lands for that person inside the attribution window. The value can come from a property on the event, or from an object the event points at, such as the total on the order it references. | A signup, a payment, a booking, an order. | | A **CDP object** | An object reached from the person exists, dated inside the window by one of its own attributes rather than by when Boom saw it. | An order, a subscription, an application. | | An **extracted variable** | The conversation produced a value for a field in your [extraction schema](/extraction). | Outcomes that only exist because someone said them, like agreeing to come back. | Each metric aggregates as a **count**, a **sum**, an **average** or a **max**, so "how many came back" and "how much revenue came back" are the same mechanism with a different aggregation. ### The attribution window A conversation's window opens when Boom sends its first outbound message and closes 48 hours after the journey ends. That grace period is fixed and not configurable today, so a purchase two weeks later is not attributed, by design. A person Boom never messaged has no window at all, and is excluded from both the numerator and the denominator rather than counted as a failure. ## Configuring metrics over the API The same definitions the app's dialog writes are readable and settable programmatically, on an initiative you already have the id for. **MCP** * `success_metrics_catalog` — what a metric can point at * `success_metrics_list` — the metrics on an initiative * `success_metrics_upsert` — add one, or replace one by `rootId` * `success_metrics_delete` — retire one **REST** * `GET /api/v1/initiatives/{id}/success-metrics/catalog` * `GET /api/v1/initiatives/{id}/success-metrics` * `POST /api/v1/initiatives/{id}/success-metrics` * `DELETE /api/v1/initiatives/{id}/success-metrics/{rootId}` ### Start with the catalog An event name, object type or variable that does not exist is **accepted and then matches nothing**, so the metric quietly reports zero rather than failing. Ask what is available first: ```bash theme={null} curl "https://www.useboom.ai/api/v1/initiatives/init_123/success-metrics/catalog" \ -H "Authorization: Bearer $BOOM_KEY" ``` You get `events`, `objectTypes` and `variables` for this initiative (`variables` leaves out archived guiding questions, which collect no new answers). Add `?eventName=order_completed` for that event's properties, or `?objectTypeId=...` for a type's attributes and the paths that link it to a person; both come back `null` when you did not ask for them. ### Setting a metric ```bash theme={null} curl -X POST "https://www.useboom.ai/api/v1/initiatives/init_123/success-metrics" \ -H "Authorization: Bearer $BOOM_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Revenue recovered", "signalType": "CDP_EVENT", "eventName": "order_completed", "aggregation": "SUM", "valueProperty": "total" }' ``` ```json theme={null} { "rootId": "sdr_abc123", "version": 1, "status": "ACTIVE", "name": "Revenue recovered", "signalType": "CDP_EVENT", "eventName": "order_completed", "aggregation": "SUM", "valueProperty": "total", "valueSource": "EVENT_PROPERTY", "roiEnabled": false, "variableId": null, "objectTypeId": null, "windowHours": 48, "createdAt": "2026-09-18T10:00:00.000Z" } ``` Abridged: the response always carries every configuration field, `null` for the ones this signal type does not use. Omit `rootId` to add a metric; pass an existing metric's `rootId` to replace it, which files a new `version` under the same `rootId`. `rootId` is the id to hold on to — `version` counts *the initiative's* definition history, not this metric's, so a brand new metric on an initiative that already has one starts above `1`. **Replacing is not a patch** — send the metric's complete configuration, because fields you leave out are cleared rather than kept. Which fields are required depends on `signalType` and `aggregation`: a `COUNT` needs no value field, while `SUM`, `AVERAGE` and `MAX` each need one, and a missing one is rejected with a `validation_failed` error naming the field. `GET /api/v1/initiatives/{id}/success-metrics` returns the metrics in the exact shape `POST` accepts, so you can read one, change a field and send it back. It returns the ACTIVE ones by default; `?status=all` adds the superseded versions, newest first, which is the edit history. An initiative with nothing configured returns an empty list, not a 404. ### Retiring a metric ```bash theme={null} curl -X DELETE "https://www.useboom.ai/api/v1/initiatives/init_123/success-metrics/sdr_abc123" \ -H "Authorization: Bearer $BOOM_KEY" ``` The metric leaves the dashboard immediately. Its configuration is kept for the audit trail — `?status=all` still lists it as `SUPERSEDED` — and posting the same `rootId` again brings it back. Removing the last metric returns the initiative to having no success measure, which is a normal state. > [!WARNING] > > Both writes are retroactive. Successes are recomputed on every read against > the current definition, so editing a metric changes what past periods report > and deleting one removes the numbers it reported for earlier conversations > too. Nothing is pinned per period. > [!NOTE] > > Over MCP, setting and deleting require a role that can edit initiatives > (Owner, Admin or Member); listing and the catalog work for any role that can > see initiatives. Over REST all four take a valid organization > API key, and any valid key can make any of these calls: treat a key as full > access to your organization and scope who holds it accordingly. An initiative is capped at ten ACTIVE metrics over this surface. Past that, `POST` is refused until you retire one or pass its `rootId` to replace it. ## What you get **Per node**, from the messages themselves with nothing to configure: sent, delivered, read and replied counts, how each branch split, and median wait times. Useful for finding the step where people fall out. **A funnel** that starts at Sent rather than at enrolled, with each stage showing its share of the previous stage and of the first, ending in a shared succeeded stage. **A success rate** whose denominator is the people Boom actually messaged, not everyone who entered. Worth knowing before comparing it to a number from another tool, which may well count differently. **Successes over time**, bucketed by day, switchable between count and value when the metric aggregates a value. **The evidence behind the number**: the individual successes that matched, each linking to that participant and to the run that produced it. This is the part people ask for when they do not believe the dashboard. **ROI, if you turn it on for the metric** (`roiEnabled`): the attributed sales over what the journey's messages cost, in US dollars. The cost side is a fixed average per message — not your invoice — so read it as an order of magnitude, not as billing. It is off by default, only offered on a metric that sums a money value, and sales reported in pesos are converted at a fixed rate. > [!WARNING] > > Metrics are recomputed on read against the current definition, and editing a > definition creates a new version rather than pinning history. So changing what > counts as success also changes what past periods show. Decide the definition > before you need to defend a number with it. ## Reading the raw data instead Everything the dashboard builds on is available over the API and MCP, covered in [extraction](/extraction): * the aggregate summary for an initiative, with coverage and distributions per field * each participant's extracted values, including the quote the value came from and a confidence * full transcripts, in batches, so you can pull every conversation without looping one at a time * the step by step run timeline for a participant, when you need to know what the engine did rather than what was said CSV and XLSX exports live in the app, including one row per participant with one column per extracted field. ## What this is not There is no experiment primitive: no randomized allocation, no significance testing. You can compare variants by branching a journey on an attribute and reading each path's success metric, which answers most questions, but the split is deterministic and the statistics are yours to do. There is no warehouse sync. Bulk reads are polling. Outbound webhooks do exist for journey-run lifecycle events, and are the exception to "reads are polling": Boom can POST to an endpoint you own when a run starts, when a run ends (with the reason it ended), and when someone matched a trigger but was not enrolled. They are enabled per organization, so talk to us if you want them switched on. ## Related > **[Extraction](/extraction)** > > The typed fields a conversation yields, and the read paths behind these numbers. > **[Journeys](/journeys)** > > The flow whose nodes these per step metrics describe. > **[Events](/events)** > > Record the events a success metric can watch for. > **[Initiatives](/initiatives)** > > The mission a success metric belongs to. # Batch upsert objects Source: https://docs.useboom.ai/api-reference/cdp-custom-objects/batch-upsert-objects /api-reference/openapi.json post /api/v1/cdp/custom-objects/batch Upsert up to 1000 custom objects in one request. Idempotent and safely retryable. By DEFAULT each item REPLACES that object's entire `attributes` blob, so an item carrying only the fields you want to change DELETES every other attribute on that object. To add or change a subset across many objects, pass `mode: 'merge'` — it overlays the keys you send and leaves the rest untouched, with no need to read each object first. Pass `createMissing: false` to update only objects that already exist; anything unmatched comes back in `skipped` rather than being created. # Create object type Source: https://docs.useboom.ai/api-reference/cdp-custom-objects/create-object-type /api-reference/openapi.json post /api/v1/cdp/custom-objects/types Create a custom object type — required before you can upsert objects of that kind. # Delete object Source: https://docs.useboom.ai/api-reference/cdp-custom-objects/delete-object /api-reference/openapi.json delete /api/v1/cdp/custom-objects/{type}/{externalId} Soft-delete a custom object and its links. Idempotent. # Get object Source: https://docs.useboom.ai/api-reference/cdp-custom-objects/get-object /api-reference/openapi.json get /api/v1/cdp/custom-objects/{type}/{externalId} Read one custom object by type and external id. # Get object type Source: https://docs.useboom.ai/api-reference/cdp-custom-objects/get-object-type /api-reference/openapi.json get /api/v1/cdp/custom-objects/types/{type} Read one custom object type by name. # List object types Source: https://docs.useboom.ai/api-reference/cdp-custom-objects/list-object-types /api-reference/openapi.json get /api/v1/cdp/custom-objects/types List the organization's custom object types. # List objects Source: https://docs.useboom.ai/api-reference/cdp-custom-objects/list-objects /api-reference/openapi.json get /api/v1/cdp/custom-objects List custom objects of one type, newest first. # Upsert object Source: https://docs.useboom.ai/api-reference/cdp-custom-objects/upsert-object /api-reference/openapi.json post /api/v1/cdp/custom-objects Create or update a custom object by type and external id. The type must already exist. By default `attributes` is REPLACED wholesale — pass `mode: 'merge'` to add or change a subset without deleting the rest, and `createMissing: false` to update only when the object already exists. # Batch record events Source: https://docs.useboom.ai/api-reference/cdp-events/batch-record-events /api-reference/openapi.json post /api/v1/cdp/events/batch Ingest up to 1000 events in one request, for bulk or historical loads. Unlike the single endpoint, this does not trigger journey enrollment. # Get event Source: https://docs.useboom.ai/api-reference/cdp-events/get-event /api-reference/openapi.json get /api/v1/cdp/events/{externalId} Read one event by its external id. # List events Source: https://docs.useboom.ai/api-reference/cdp-events/list-events /api-reference/openapi.json get /api/v1/cdp/events List events, newest first, with optional filters by name, subject, and time range. # Record event Source: https://docs.useboom.ai/api-reference/cdp-events/record-event /api-reference/openapi.json post /api/v1/cdp/events Record one behavioral event for a person and/or custom object. This is the real-time path — it triggers journey enrollment. Unknown subjects still ingest and are linked later. # Batch upsert people Source: https://docs.useboom.ai/api-reference/cdp-people/batch-upsert-people /api-reference/openapi.json post /api/v1/cdp/people/batch Upsert up to 1000 people in one request. Idempotent and safely retryable. Attributes merge by default; pass mode:'replace' to swap each person's whole set. # Delete person Source: https://docs.useboom.ai/api-reference/cdp-people/delete-person /api-reference/openapi.json delete /api/v1/cdp/people/{externalId} Soft-delete a person and their outgoing links. Idempotent; behavioral events are kept. # Get person Source: https://docs.useboom.ai/api-reference/cdp-people/get-person /api-reference/openapi.json get /api/v1/cdp/people/{externalId} Read one person by their external id. # List people Source: https://docs.useboom.ai/api-reference/cdp-people/list-people /api-reference/openapi.json get /api/v1/cdp/people List the organization's people, newest first. # Search people Source: https://docs.useboom.ai/api-reference/cdp-people/search-people /api-reference/openapi.json get /api/v1/cdp/people/search Search the organization's people by name, or a partial email / phone / externalId. Returns a relevance-ranked shortlist. # Upsert person Source: https://docs.useboom.ai/api-reference/cdp-people/upsert-person /api-reference/openapi.json post /api/v1/cdp/people Create or update a person by external id. Attributes are merged into the stored ones by default (omitted keys kept, null removes a key); pass mode:'replace' to swap the whole set. # Batch link / unlink Source: https://docs.useboom.ai/api-reference/cdp-relationships/batch-link-unlink /api-reference/openapi.json post /api/v1/cdp/relationships/batch Link or unlink up to 1000 relationships in one request. # Get relationship type Source: https://docs.useboom.ai/api-reference/cdp-relationships/get-relationship-type /api-reference/openapi.json get /api/v1/cdp/relationship-types/{relationshipTypeId} Read one relationship type by id. # Link relationship Source: https://docs.useboom.ai/api-reference/cdp-relationships/link-relationship /api-reference/openapi.json post /api/v1/cdp/relationships Link a person to a custom object, or one custom object to another. Idempotent. # List relationship types Source: https://docs.useboom.ai/api-reference/cdp-relationships/list-relationship-types /api-reference/openapi.json get /api/v1/cdp/relationship-types List the organization's relationship types — use it to discover valid link shapes before linking. # List relationships Source: https://docs.useboom.ai/api-reference/cdp-relationships/list-relationships /api-reference/openapi.json get /api/v1/cdp/relationships List relationship edges of one kind, anchored on a person or custom object, newest first. # Register relationship type Source: https://docs.useboom.ai/api-reference/cdp-relationships/register-relationship-type /api-reference/openapi.json post /api/v1/cdp/relationship-types Register a relationship type and its metadata. Re-registering an existing type updates it in place. # Unlink relationship Source: https://docs.useboom.ai/api-reference/cdp-relationships/unlink-relationship /api-reference/openapi.json delete /api/v1/cdp/relationships Unlink (soft-delete) a relationship. Idempotent. # List sensitive fields Source: https://docs.useboom.ai/api-reference/cdp-sensitive-fields/list-sensitive-fields /api-reference/openapi.json get /api/v1/cdp/sensitive-fields List the fields your organization marked sensitive: contact fields, People attributes and custom object attributes. A sensitive value is hidden from AI agents and from teammates without access (it reads `[redacted]`); it stays stored and keeps working in segments and messages. Returns field names only, never values. # Mark fields sensitive Source: https://docs.useboom.ai/api-reference/cdp-sensitive-fields/mark-fields-sensitive /api-reference/openapi.json post /api/v1/cdp/sensitive-fields Mark fields sensitive (or, over the REST API only, visible again). A sensitive value is hidden from AI agents and from teammates without access; it stays stored and keeps working in segments and messages. Applies every change or none: an unknown field fails the whole call. Keys must already exist in the attribute catalog. Over MCP only `isSensitive: true` is accepted — making a field visible again is refused with `sensitive_unmark_forbidden`; use Settings › Sensitive data or the REST API. # Create email template Source: https://docs.useboom.ai/api-reference/email-templates/create-email-template /api-reference/openapi.json post /api/v1/email-templates Create an email template as a DRAFT, from either a block `document` (like the drag-and-drop builder) or raw `html`. The deliverable HTML and plain text are produced server-side; {{path}} variables in the subject and body resolve at send. Publish it with email_templates_update (status PUBLISHED) before a journey can send it. Minimal blocks example: `{ "name": "Recordatorio", "subject": "Hola {{customer.name}}", "document": { "blocks": [ { "type": "heading", "text": "Tu pago vence pronto" }, { "type": "text", "text": "Hola {{customer.name}}, ..." }, { "type": "button", "text": "Pagar", "href": "https://..." }, { "type": "footer", "text": "Empresa, dirección" } ] } }`. # Get email template Source: https://docs.useboom.ai/api-reference/email-templates/get-email-template /api-reference/openapi.json get /api/v1/email-templates/{templateId} Get one email template in full: its block document (BLOCKS) or HTML source (CODE), the rendered HTML and text, variables and sender picks. Edit what you read and send it back with email_templates_update. # List email templates Source: https://docs.useboom.ai/api-reference/email-templates/list-email-templates /api-reference/openapi.json get /api/v1/email-templates List the organization's email templates — drafts and published — with their status, authoring mode and variables. A journey's SEND_EMAIL node can only use a PUBLISHED one (journeys_email_templates lists just those, plus sender readiness). # Update email template Source: https://docs.useboom.ai/api-reference/email-templates/update-email-template /api-reference/openapi.json patch /api/v1/email-templates/{templateId} Update an email template: rename, change the subject or senders, replace its content, or publish/unpublish it (status). Content is re-rendered server-side. Sending `html` to a block template converts it to an HTML template, one way — an HTML template cannot take a `document` again. A PUBLISHED template is live: journeys that send it use the change from their next send, so any update to one requires confirm: true (call without it first to see the refusal). Omitted fields are unchanged. # Add or replace a success metric Source: https://docs.useboom.ai/api-reference/initiatives/add-or-replace-a-success-metric /api-reference/openapi.json post /api/v1/initiatives/{id}/success-metrics Configure what counts as success for an initiative: a customer-data event, a record on a custom object, or a value extracted from the conversation, measured within 48 hours of the first message. Omit `rootId` to add a metric; pass an existing metric's `rootId` to replace it with a new version. This is not a patch — send the metric's complete configuration, because omitted fields are cleared. Successes are recalculated on every read, so replacing a metric also changes what the dashboard reports for past conversations. Use success_metrics_catalog to find the event, object type or variable to point at. # Add participants Source: https://docs.useboom.ai/api-reference/initiatives/add-participants /api-reference/openapi.json post /api/v1/initiatives/{id}/participants Add people to an active WhatsApp or email initiative. WARNING: each person added receives a real outbound message immediately. Identify each person by `phoneNumber` on a WhatsApp initiative and by `email` on an email one. Requires a published journey (launching publishes it). Do-Not-Contact people are skipped. # Archive initiative Source: https://docs.useboom.ai/api-reference/initiatives/archive-initiative /api-reference/openapi.json post /api/v1/initiatives/{id}/archive Hide an initiative from the default list. Takes a draft, completed or canceled one — a draft created by mistake does not have to be canceled first — but not a running (active or paused) one. Nothing else about the initiative changes, and unarchive reverses it. # Cancel initiative Source: https://docs.useboom.ai/api-reference/initiatives/cancel-initiative /api-reference/openapi.json post /api/v1/initiatives/{id}/cancel Cancel an initiative and stop its conversations. Terminal. Over MCP, requires the org:initiatives:launch permission (Owner and Admin roles). # Create initiative Source: https://docs.useboom.ai/api-reference/initiatives/create-initiative /api-reference/openapi.json post /api/v1/initiatives Create a draft initiative or campaign — only a name is required. `isRecurring` decides which: omit it or pass `false` for a campaign (one-time outreach, lives under Campaigns), `true` for an initiative (ongoing work, lives under Initiatives). No UI control changes this afterwards, so set it deliberately. The third kind is a Transactional: pass `transactional` instead (it cannot be combined with `isRecurring: true`). To send ONE message to an audience ONCE, pass `campaign`: its journey is built for you and nobody enrolls on their own. For a WhatsApp send, link an approved template before launching. # Data summary Source: https://docs.useboom.ai/api-reference/initiatives/data-summary /api-reference/openapi.json get /api/v1/initiatives/{id}/data/summary The initiative's data summary — participant count and, per captured variable, its coverage and rollup. # Delete a success metric Source: https://docs.useboom.ai/api-reference/initiatives/delete-a-success-metric /api-reference/openapi.json delete /api/v1/initiatives/{id}/success-metrics/{rootId} Retire a success metric. It disappears from the dashboard immediately — including from past periods, because successes are recalculated on every read, so the numbers this metric reported for earlier conversations go with it. The configuration is kept for the audit trail and success_metrics_list can still show it with `status: all`; adding the metric back with the same rootId restores it. Deleting the last metric returns the initiative to having no success measure, which is a normal state, not an error. # Get initiative Source: https://docs.useboom.ai/api-reference/initiatives/get-initiative /api-reference/openapi.json get /api/v1/initiatives/{id} Get one initiative by id. # Get participant Source: https://docs.useboom.ai/api-reference/initiatives/get-participant /api-reference/openapi.json get /api/v1/initiatives/{id}/participants/{participantId} Get one participant's status and captured answers. # Get templates Source: https://docs.useboom.ai/api-reference/initiatives/get-templates /api-reference/openapi.json get /api/v1/initiatives/{id}/templates List which WhatsApp template each outreach round sends. Round 1 must be linked before a WhatsApp initiative can launch. # Launch initiative Source: https://docs.useboom.ai/api-reference/initiatives/launch-initiative /api-reference/openapi.json post /api/v1/initiatives/{id}/launch Launch a draft initiative — it goes active and Boom starts reaching out. Its journey is published as part of the launch, on every channel (fails with the journey's issues if it cannot be), so no separate journeys_publish is needed. A WhatsApp initiative also needs an approved template first. Over MCP, requires the org:initiatives:launch permission (Owner and Admin roles). # List initiatives Source: https://docs.useboom.ai/api-reference/initiatives/list-initiatives /api-reference/openapi.json get /api/v1/initiatives List the organization's initiatives and campaigns, newest first. All three kinds are the same record type: `isRecurring` splits campaigns from initiatives, and `isTransactional` selects Transactionals (event-triggered sends, kept out of the other two lists unless you pass it). Archived rows are excluded. # List participants Source: https://docs.useboom.ai/api-reference/initiatives/list-participants /api-reference/openapi.json get /api/v1/initiatives/{id}/participants List an initiative's participants and the values Boom captured from each, newest first. Each row carries both contact identifiers (`phoneNumber`, `email`) — on an email initiative the phone is null for everyone, so read the one the initiative actually sends to. Filter by personExternalId to find one person's runs — including the live one, to stop it before starting another. # List success metrics Source: https://docs.useboom.ai/api-reference/initiatives/list-success-metrics /api-reference/openapi.json get /api/v1/initiatives/{id}/success-metrics Read the success metrics configured on an initiative — what counts as a win, and how it is measured. Returns them in the exact shape success_metrics_upsert accepts, so you can read one, edit it and send it back. An initiative with no metric configured returns an empty list. Editing a metric changes what the dashboard reports for past conversations too, because successes are recalculated on every read. # List transcripts Source: https://docs.useboom.ai/api-reference/initiatives/list-transcripts /api-reference/openapi.json get /api/v1/initiatives/{id}/transcripts Full conversation transcripts for a page of participants, newest participant first. Use this to read or export ALL conversations of an initiative — one call per page (up to 100 participants) instead of looping the per-participant messages endpoint. # Participant messages Source: https://docs.useboom.ai/api-reference/initiatives/participant-messages /api-reference/openapi.json get /api/v1/initiatives/{id}/participants/{participantId}/messages The participant's conversation transcript, in order. Defaults to this initiative only; pass scope="customer" for everything this person ever said to you across initiatives — use that whenever you need to know whether someone answered at all, since a reply lands on whichever initiative was live at the time and is invisible to the others. # Pause initiative Source: https://docs.useboom.ai/api-reference/initiatives/pause-initiative /api-reference/openapi.json post /api/v1/initiatives/{id}/pause Stop enrolling new people into this initiative. Anyone already enrolled keeps their run, and conversations in progress continue normally. Reversible with resume. # Resume initiative Source: https://docs.useboom.ai/api-reference/initiatives/resume-initiative /api-reference/openapi.json post /api/v1/initiatives/{id}/resume Resume a paused initiative so it starts enrolling people again. # Set template Source: https://docs.useboom.ai/api-reference/initiatives/set-template /api-reference/openapi.json post /api/v1/initiatives/{id}/templates Link an approved WhatsApp template as a round's outreach message — round 1 is the opening message. Required before a WhatsApp initiative can launch. This writes the initiative's journey DRAFT: customers keep receiving the previous template until the journey is published. # Stop participant Source: https://docs.useboom.ai/api-reference/initiatives/stop-participant /api-reference/openapi.json post /api/v1/initiatives/{id}/participants/{participantId}/stop End one participant’s run: no further scheduled messages are sent and their session is closed. Idempotent and safe — it never sends. Their answers and transcript stay readable, and the conversation keeps whoever it was assigned to. # Success-metric catalog Source: https://docs.useboom.ai/api-reference/initiatives/success-metric-catalog /api-reference/openapi.json get /api/v1/initiatives/{id}/success-metrics/catalog List everything a success metric can point at on this initiative: the events this organization records, its custom-object types, and the variables extracted from this initiative's conversations. Pass `eventName` to also get that event's properties, or `objectTypeId` to get that type's attributes and the ways it links to a person. Call this before success_metrics_upsert: an unrecognised event, object type or variable is accepted but never matches anything, so the metric silently reports zero. # Unarchive initiative Source: https://docs.useboom.ai/api-reference/initiatives/unarchive-initiative /api-reference/openapi.json post /api/v1/initiatives/{id}/unarchive Restore an archived initiative to the default list. Its status is unchanged, and any archived initiative can be restored regardless of status. # Update initiative Source: https://docs.useboom.ai/api-reference/initiatives/update-initiative /api-reference/openapi.json patch /api/v1/initiatives/{id} Edit an initiative. A launched one still takes its agent-facing content (name, objective, context, guidingQuestions, flagCondition, identityDeflection) and its smartSendingMode and serves the edit on the next turn; the structural fields are draft-only, and a completed or canceled initiative is frozen. Send only the fields you change; `context` and `objective` replace the whole value, so read the current value first and send it back with your addition. # Add node Source: https://docs.useboom.ai/api-reference/journeys/add-node /api-reference/openapi.json post /api/v1/journeys/{journeyId}/nodes Add one node to a draft journey. Positions are optional (auto-laid-out). Wire it up separately with journeys_connect_nodes. # Condition catalog Source: https://docs.useboom.ai/api-reference/journeys/condition-catalog /api-reference/openapi.json get /api/v1/journeys/condition-catalog List the person attributes and computed attributes available for DECISION and CASE CONDITIONS, plus the custom object types. Attribute tokens are ready to use as condition selection paths (e.g. attributes.plan, computed.ltv). NOTE: these `attributes.*` tokens are ONLY for DECISION/CASE conditions — SEND_MESSAGE template bindings use a different syntax (`person.`); call journeys_message_variables for those. Also describes the cdp_predicate DECISION condition term shape: which `op` values are legal per attribute `type` (operatorsByType), and which `value` shape each `op` expects (cdpPredicateValueShapes). # Connect nodes Source: https://docs.useboom.ai/api-reference/journeys/connect-nodes /api-reference/openapi.json post /api/v1/journeys/{journeyId}/edges Wire an edge from one node to another on a given output handle (the emitted signal, e.g. SENT, REPLIED, YES, case:). A handle can wire to only one node. # Create draft Source: https://docs.useboom.ai/api-reference/journeys/create-draft /api-reference/openapi.json post /api/v1/journeys Create a new draft journey on an initiative from a full graph. The draft is editable and does not run until published (which is done in the app). Omit node positions and the server lays the graph out automatically. # Delete node Source: https://docs.useboom.ai/api-reference/journeys/delete-node /api-reference/openapi.json delete /api/v1/journeys/{journeyId}/nodes/{nodeId} Remove one node and every edge connected to it from a draft journey. # Disconnect nodes Source: https://docs.useboom.ai/api-reference/journeys/disconnect-nodes /api-reference/openapi.json delete /api/v1/journeys/{journeyId}/edges Remove edges leaving a node. Narrow by target and/or handle; with neither, all outgoing edges from the node are removed. # Event catalog Source: https://docs.useboom.ai/api-reference/journeys/event-catalog /api-reference/openapi.json get /api/v1/journeys/event-catalog List the CDP event names seen for your organization. Use these for a cdp_event trigger, a DISPATCH_EVENT node, or an event-based DECISION condition. # Fork journey Source: https://docs.useboom.ai/api-reference/journeys/fork-journey /api-reference/openapi.json post /api/v1/journeys/{journeyId}/fork Copy a PUBLISHED journey into a fresh editable draft. The live version keeps running until the new draft is published. Use this to edit a live journey safely. # Get journey Source: https://docs.useboom.ai/api-reference/journeys/get-journey /api-reference/openapi.json get /api/v1/journeys/{journeyId} Get one journey by id — its trigger and the ordered steps people move through. Read-only, for inspecting setup. # Get journey definition Source: https://docs.useboom.ai/api-reference/journeys/get-journey-definition /api-reference/openapi.json get /api/v1/journeys/{journeyId}/definition Get a journey's full editable graph (all nodes, their config, and connections) so it can be modified and saved back. Unlike journeys_get, this is the complete authoring shape, not the sanitized public summary. # List email templates Source: https://docs.useboom.ai/api-reference/journeys/list-email-templates /api-reference/openapi.json get /api/v1/journeys/email-templates Everything a SEND_EMAIL node needs: the PUBLISHED email templates it can use, the org's email readiness, and the senders available as From / Reply-To overrides. Email has no channelId — it sends from the org's verified domain — which is why it is not in journeys_message_channels. Publish refuses a SEND_EMAIL node unless readiness.verified and readiness.hasSender are both true (or the node sets a fromSenderIdOverride whose sender is usableAsFrom). # List journeys Source: https://docs.useboom.ai/api-reference/journeys/list-journeys /api-reference/openapi.json get /api/v1/journeys List the organization's journeys, newest first. A journey is the step sequence people move through, attached to an initiative. Read-only. # List message channels Source: https://docs.useboom.ai/api-reference/journeys/list-message-channels /api-reference/openapi.json get /api/v1/journeys/message-channels List the channels a journey send node can send from, across every channel that has one: WhatsApp (SEND_MESSAGE), SMS (SEND_SMS), Instagram (SEND_INSTAGRAM) and Messenger (SEND_MESSENGER). Use a row's id as the node's channelId — `sendNodeKind` says which node kind takes it. Approved templates are a WhatsApp-only concept; look those up with journeys_message_templates. Email has no channel — a SEND_EMAIL node takes its template and sender from journeys_email_templates. A WhatsApp row with `callingEnabled: true` can also be the channelId of REQUEST_CALL_PERMISSION / PLACE_CALL. # List message templates Source: https://docs.useboom.ai/api-reference/journeys/list-message-templates /api-reference/openapi.json get /api/v1/journeys/message-templates List the approved WhatsApp templates a SEND_MESSAGE node can use from a given channel. A template belongs to one channel account, so pass the same channelId you pin on the node. # Message variable catalog Source: https://docs.useboom.ai/api-reference/journeys/message-variable-catalog /api-reference/openapi.json get /api/v1/journeys/{journeyId}/message-variables List the variables a SEND_MESSAGE template binding can reference for a journey — the same set the builder offers, and the authority on what resolves for THIS journey (when docs and this catalog disagree, trust the catalog). customer.* and person.* are offered for every trigger type (person.* is read live at send time from the identity the enrollment named — personExternalId, which event triggers always carry — and only falls back to a phone/email lookup, blank on zero or several matches, for contacts enrolled without a CDP person). The trigger-aware part is participant data: event-triggered journeys get the event payload under engagement.workflowState.* and no engagement.context.*, segment-triggered journeys get segment output columns under engagement.workflowState.*, and manual/CSV enrollment populates engagement.context.*. `event.` is an alias of `engagement.workflowState.` (e.g. event.due_date), valid in a binding and inside email Liquid tags ({% if event.amount > 0 %}, {% for i in event.items %}); the catalog returns the engagement.workflowState.* spelling. Bind a template placeholder to one of the returned `path` values (custom person attributes are `person.`, NOT `attributes.` — that is the DECISION/CASE condition syntax). A binding may append ONE formatting helper after a pipe: `currency:MXN` (code required), `date` or `date:long`, `sum` / `sum:field`, `count`, `fixed:N` — e.g. "engagement.nodeOutputs.fetch.body.total | currency:MXN". No chaining. `currency`/`date` are localized to the org language; `sum`/`count`/`fixed` are deliberately NOT, so they stay parseable by a DECISION comparison and valid inside a JSON body. Applied to a missing value the helper is skipped entirely, so a gap renders blank rather than "NaN" or "Invalid Date". `count` is the one to reach for before indexing an array: binding `body.buyers.0.account` on a plural array picks an arbitrary element, so gate a DECISION on `count eq 1` instead of guessing. Pass a journey id or its initiative id. # Node catalog Source: https://docs.useboom.ai/api-reference/journeys/node-catalog /api-reference/openapi.json get /api/v1/journeys/catalog Describes every journey node kind — its inputs, its output handles (the signals it can emit), and the rules for connecting nodes. Read this before building or editing a journey. # Publish journey Source: https://docs.useboom.ai/api-reference/journeys/publish-journey /api-reference/openapi.json post /api/v1/journeys/{journeyId}/publish Publish a draft journey so it goes live and starts enrolling people (this begins real outreach). Validates first and requires confirm: true. Errors block the publish; any advisory warnings the journey still carries come back in `warnings`. Call it WITHOUT confirm first to preview: nothing is published, and the refusal itself lists those warnings, so you can reconsider before confirming. The trigger is taken from the ENTRY node. Any previously live version is retired. # Set trigger Source: https://docs.useboom.ai/api-reference/journeys/set-trigger /api-reference/openapi.json patch /api/v1/journeys/{journeyId}/trigger Configure how people enter the journey by updating its ENTRY node: manual, segment (needs segmentId), cdp_event (needs eventName), or inbound (needs inboundAction and inboundChannelIds, optionally narrowed to an inboundKeyword), with an optional frequency cap, or an optional parallelRunsBy on cdp_event to give one person a concurrent run per event-property value. Fields belonging to the other trigger types are cleared. # Stop journey Source: https://docs.useboom.ai/api-reference/journeys/stop-journey /api-reference/openapi.json post /api/v1/journeys/{journeyId}/stop Retire a live journey: no one else is enrolled, and anyone already enrolled who has not been messaged yet is dropped before their message goes out. Conversations already in progress continue normally. Requires confirm: true. This cannot be undone — re-running the campaign means publishing again. # Update draft Source: https://docs.useboom.ai/api-reference/journeys/update-draft /api-reference/openapi.json patch /api/v1/journeys/{journeyId} Replace a draft journey's whole graph. Journey-level metadata is merged, not replaced — keys the payload omits (e.g. the environment pin) keep their stored values. Only drafts can be edited; published versions are frozen (edit a live journey in the app to fork a new draft). Omit node positions and the server lays the graph out automatically. # Update node Source: https://docs.useboom.ai/api-reference/journeys/update-node /api-reference/openapi.json patch /api/v1/journeys/{journeyId}/nodes/{nodeId} Update one node's name, position, and/or config (inputs shallow-merge). Use journeys_authoring_catalog for a node kind's input fields. # Validate journey Source: https://docs.useboom.ai/api-reference/journeys/validate-journey /api-reference/openapi.json post /api/v1/journeys/validate Dry-run the publish checks against a journey graph without saving. Pass a `journeyId` to validate the stored draft, or a `definition` to validate a graph as sent (exactly one of the two). Returns whether it is valid and the list of issues (errors block publishing; warnings are advisory). A node that sends a field the org marked hidden (sensitive) in a message, an HTTP request or a route-to-initiative mapping gets a SENSITIVE_FIELD_SENT, SENSITIVE_FIELD_SENT_EXTERNAL or SENSITIVE_FIELD_MAPPED warning naming the field; it never blocks. Use it to iterate a draft to valid before publishing in the app. # Create a Lead Capture pop-up Source: https://docs.useboom.ai/api-reference/lead-capture/create-a-lead-capture-pop-up /api-reference/openapi.json post /api/v1/lead-capture/forms Create a pop-up, PAUSED, optionally from a template. Then add prizes and codes with lead_capture_forms_update, and set it ACTIVE there once lead_capture_forms_get shows no liveIssues. Visitors see nothing until it is ACTIVE and the install tag is on an allowed site. # Create an embedded form Source: https://docs.useboom.ai/api-reference/lead-capture/create-an-embedded-form /api-reference/openapi.json post /api/v1/lead-capture/embedded-forms Create an embedded form, PAUSED and unpublished. Edit its draft with lead_capture_embedded_forms_update, publish it with lead_capture_embedded_forms_publish, then set it ACTIVE. Visitors see nothing until it is published, ACTIVE, and embedded on an allowed site. # Get a Lead Capture pop-up Source: https://docs.useboom.ai/api-reference/lead-capture/get-a-lead-capture-pop-up /api-reference/openapi.json get /api/v1/lead-capture/forms/{formId} Get one pop-up in full: its whole config (design, copy, fields, trigger, game, prizes and codes), the install tag, and anything still stopping it from going live. Edit any part of the config and send it back with lead_capture_forms_update. # Get an embedded form Source: https://docs.useboom.ai/api-reference/lead-capture/get-an-embedded-form /api-reference/openapi.json get /api/v1/lead-capture/embedded-forms/{formId} Get one embedded form: its editable draft, the embed tags, whether the draft has unpublished changes, and anything stopping it from being published. # List an embedded form’s responses Source: https://docs.useboom.ai/api-reference/lead-capture/list-an-embedded-form’s-responses /api-reference/openapi.json get /api/v1/lead-capture/embedded-forms/{formId}/responses The submissions one embedded form collected, newest first: contact details, answers by question key, score, result and hidden values. Pass `status: PARTIAL` for what visitors answered before leaving (forms with `partials.enabled`). Agents never see sensitive answers; on a sensitive form the score and result are hidden from them too. # List embedded forms Source: https://docs.useboom.ai/api-reference/lead-capture/list-embedded-forms /api-reference/openapi.json get /api/v1/lead-capture/embedded-forms List the organization's embedded forms (forms placed inside a page, built from typed questions) with their status and published version. Pop-ups are listed by lead_capture_forms_list. # List Lead Capture pop-ups Source: https://docs.useboom.ai/api-reference/lead-capture/list-lead-capture-pop-ups /api-reference/openapi.json get /api/v1/lead-capture/forms List the organization's Lead Capture pop-ups with their status, game, allowed sites and install key. Archived pop-ups are not included. # List the leads a pop-up collected Source: https://docs.useboom.ai/api-reference/lead-capture/list-the-leads-a-pop-up-collected /api-reference/openapi.json get /api/v1/lead-capture/forms/{formId}/leads The entries one pop-up collected, newest first: contact details, consent, quiz answers, and the prize and code each visitor won. Each lead is also a person in People and a `lead_captured` event. # Publish an embedded form Source: https://docs.useboom.ai/api-reference/lead-capture/publish-an-embedded-form /api-reference/openapi.json post /api/v1/lead-capture/embedded-forms/{formId}/publish Freeze the draft as the next version visitors get (within about five minutes on live pages). Refused with the reasons when the draft is not ready or changes something published (a key, a type, an answer value). Returns the journeys and segments still reading anything this version stops producing. # Update a Lead Capture pop-up Source: https://docs.useboom.ai/api-reference/lead-capture/update-a-lead-capture-pop-up /api-reference/openapi.json patch /api/v1/lead-capture/forms/{formId} Change a pop-up: restyle it with a template, edit any part of its config (design, copy, fields, trigger, game, prizes and codes), replace its allowed sites, or set it ACTIVE or PAUSED. A live pop-up must stay ready, so a change that would break it is refused with the reasons. Visitors see a change within about five minutes. # Update an embedded form Source: https://docs.useboom.ai/api-reference/lead-capture/update-an-embedded-form /api-reference/openapi.json patch /api/v1/lead-capture/embedded-forms/{formId} Edit an embedded form’s draft, replace its allowed sites, or set it ACTIVE or PAUSED. Draft edits reach visitors only when published. # List message delivery logs Source: https://docs.useboom.ai/api-reference/message-logs/list-message-delivery-logs /api-reference/openapi.json get /api/v1/message-logs List the messages your organization sent or received and what happened to each one — delivered, failed, or blocked — with the recipient and the failure reason. The same rows as Settings → Logs, so you can review delivery in bulk instead of opening conversations one at a time. Statuses are a lifecycle, not a set of outcomes. SENT, DELIVERED and READ all mean the message went out; they differ only in how far it got. SENT means it left Boom and no receipt has come back yet. To count what you sent, ask for all three — asking for SENT alone returns only the ones still unconfirmed. How far a message can get depends on the channel. WhatsApp reaches READ. Email never does: there is no read receipt, so an email resting at DELIVERED is fully settled. PENDING on an inbound message is normal and permanent — nobody sends a receipt for a message a customer wrote to you. This is a log of messages, not a delivery-rate report. A few send failures never create a row at all, so an absent message is not proof that nothing was attempted. # API Source: https://docs.useboom.ai/api-reference/overview The REST API for acting on your customer data: build audiences, run initiatives, author the flow behind them, and read back what the conversations produced. The Boom REST API turns the customer data in your [CDP](/cdp/overview) into conversations: define who you want to reach, create the initiative that reaches them, author the [journey](/journeys) that carries it out, and read back the structured data those conversations produced. What each initiative is for is up to you. The same endpoints run a win-back, a support line, a research round, an onboarding nudge, lead qualification, or a document collection. Every endpoint here is also available as an [MCP tool](/use-mcp) with the same name and schema, see [one uniform surface](/one-surface). ## Base URL All endpoints live under `/api/v1`: ``` https://www.useboom.ai/api/v1 ``` > [!NOTE] > > Every request is authenticated with an **organization API key** sent as a > Bearer token. The organization is derived from the key. You never pass an > org id. See [Authentication](/authentication). ## What you can do > **Segments** > > Saved audiences built from your people and their data. Discover what's > filterable, validate and preview a filter, create and update segments, and > page through their members. > **Initiatives** > > One outreach mission: an audience, an objective, and the flow that carries > it out. Create one, link its templates, launch it, manage participants, and > read back a summary of what it collected. > **[Journeys](/journeys)** > > The workflow behind an initiative. Author it end to end: open a draft, add > and connect nodes, set the trigger, validate, publish. > **[Extracted data](/extraction)** > > The typed fields each conversation should yield, and how to read them back > per participant or in aggregate. > **WhatsApp templates** > > The pre-approved openers a conversation starts with. List your WhatsApp > numbers, then list, read, and create templates. ## Common use cases * **Reach a group end to end.** Build a segment of the people you want (`POST /api/v1/segments`), then **evaluate it** (`POST /api/v1/segments/{slug}/evaluate`), because a new segment has no members until you do. Create an initiative (`POST /api/v1/initiatives`), link the opening template (`POST /api/v1/initiatives/{id}/templates`), and set the [extraction schema](/extraction) now rather than later, since it does not apply to conversations that already ran. Then launch (`POST /api/v1/initiatives/{id}/launch`). Launching publishes the initiative's [journey](/journeys) for you, on WhatsApp and email alike, so you do not need a separate publish call. You do have to build that journey first, since a new initiative does not come with one. If it does not validate, the launch fails with `journey_not_ready` and lists what to fix. A launch that fails for any reason leaves the journey unpublished. Then enroll your participants, which only works once the initiative is live. The step people skip is the evaluate. A segment has no members until it runs once, so an initiative launched behind a fresh segment reaches nobody. * **Read back what was said, as data.** Poll an initiative's data summary (`GET /api/v1/initiatives/{id}/data/summary`) and read individual participant transcripts (`GET /api/v1/initiatives/{id}/participants/{participantId}/messages`). See [extraction](/extraction) for the typed fields behind that summary. * **Automate it from your own systems.** Drive audiences and initiatives from your backend, on a schedule or in response to events. ## Conventions * **One record or many.** Most write endpoints process one record; the `/batch` variants accept up to 1000 at once. * **Pagination.** List endpoints return a `nextCursor`; pass it back as `cursor` to page forward. * **Errors.** Every error responds with `{ "error": { "code", "message" } }` and an HTTP status. See [Rate limits & errors](/rate-limits-and-errors). ## Getting data in Acting on data assumes the data is already in Boom. To push or sync people, objects, events, and relationships, see the [CDP](/cdp/overview). # Create a segment Source: https://docs.useboom.ai/api-reference/segments/create-a-segment /api-reference/openapi.json post /api/v1/segments Create a segment from a filter expression. It starts empty until evaluated. Validate and preview the filter first. # Delete a segment Source: https://docs.useboom.ai/api-reference/segments/delete-a-segment /api-reference/openapi.json delete /api/v1/segments/{slug} Remove a segment. It disappears from all lists and its journey triggers are disconnected. Idempotent. # Evaluate a segment now Source: https://docs.useboom.ai/api-reference/segments/evaluate-a-segment-now /api-reference/openapi.json post /api/v1/segments/{slug}/evaluate Re-evaluate a segment's membership now instead of waiting for its cadence. Runs synchronously; can take a while for large organizations. # Get a segment Source: https://docs.useboom.ai/api-reference/segments/get-a-segment /api-reference/openapi.json get /api/v1/segments/{slug} Get one segment by slug, with a live member count. # Get the segment filter catalog Source: https://docs.useboom.ai/api-reference/segments/get-the-segment-filter-catalog /api-reference/openapi.json get /api/v1/segments/catalog Everything filterable in your organization — person attributes, related data, computed variables, and the events people can be filtered on — with the token and operators to use for each. Read this first when building a filter. The `events` section is what makes behavioural filters possible: an `event` predicate matches on whether a person performed an event, optionally narrowed by its payload (`propertyFilters`) and when it happened (`window`). # List segment members Source: https://docs.useboom.ai/api-reference/segments/list-segment-members /api-reference/openapi.json get /api/v1/segments/{slug}/members List a segment's active members, newest first. # List segments Source: https://docs.useboom.ai/api-reference/segments/list-segments /api-reference/openapi.json get /api/v1/segments List the organization's active segments, newest first. Archived segments are excluded. # Preview a segment filter Source: https://docs.useboom.ai/api-reference/segments/preview-a-segment-filter /api-reference/openapi.json post /api/v1/segments/preview Count how many people currently match a filter, without saving. Use it to check the audience before saving a segment. # Update a segment Source: https://docs.useboom.ai/api-reference/segments/update-a-segment /api-reference/openapi.json patch /api/v1/segments/{slug} Update a segment in place; the slug is permanent. Changing the filter doesn't re-evaluate membership until the next evaluation. # Validate a segment filter Source: https://docs.useboom.ai/api-reference/segments/validate-a-segment-filter /api-reference/openapi.json post /api/v1/segments/validate Dry-run a filter expression against your live catalog — nothing is saved. Returns whether it's valid, or the field to fix. # Create template Source: https://docs.useboom.ai/api-reference/whatsapp-templates/create-template /api-reference/openapi.json post /api/v1/templates Create a WhatsApp template and submit it for WhatsApp approval, which is asynchronous (~24–48h). A template belongs to one WhatsApp account (WABA) and can only be sent from that account's numbers; if the organization has several accounts, choose one with `whatsappAccountId`: its WABA ID or the name the team gave it, e.g. "Soporte" (see whatsapp_numbers_list). Example (TEXT): `{ "name": "order_shipped", "language": "es", "category": "UTILITY", "contentType": "TEXT", "content": { "body": "Hola {{1}}, tu pedido ya salió." }, "variables": { "1": "Ana" } }`. Example with buttons (CALL_TO_ACTION): `{ "name": "order_shipped_cta", "language": "es", "category": "UTILITY", "contentType": "CALL_TO_ACTION", "content": { "body": "Hola {{1}}, tu pedido ya salió.", "actions": [{ "type": "URL", "title": "Ver pedido", "url": "https://example.com/orders/{{2}}" }] }, "variables": { "1": "Ana", "2": "12345" } }`. Example with reply buttons (QUICK_REPLY): `{ "name": "order_feedback", "language": "es", "category": "UTILITY", "contentType": "QUICK_REPLY", "content": { "body": "¿Te gustó tu compra?", "actions": [{ "title": "Sí", "id": "yes" }, { "title": "No", "id": "no" }] } }`. Example with a header and image (CARD): `{ "name": "order_shipped_card", "language": "es", "category": "UTILITY", "contentType": "CARD", "content": { "headerType": "media", "mediaUrl": "https://example.com/banner.jpg", "body": "Hola {{1}}, tu pedido ya salió.", "footer": "Gracias por tu compra", "actions": [{ "type": "URL", "title": "Ver pedido", "url": "https://example.com/orders/{{2}}" }] }, "variables": { "1": "Ana", "2": "12345" } }`. Example that is only an image (MEDIA): the image URL goes in `content.media` (an array), NOT in a variable — `{ "name": "promo_verano", "language": "es", "category": "MARKETING", "contentType": "MEDIA", "content": { "media": ["https://example.com/promo.png"], "body": "¡Hola {{1}}! Aprovecha nuestra promo de verano." }, "variables": { "1": "Ana" } }`. # Get template Source: https://docs.useboom.ai/api-reference/whatsapp-templates/get-template /api-reference/openapi.json get /api/v1/templates/{id} Get one WhatsApp template by id, including its approval status and any rejection reason. # List numbers Source: https://docs.useboom.ai/api-reference/whatsapp-templates/list-numbers /api-reference/openapi.json get /api/v1/whatsapp-numbers List the WhatsApp numbers connected to your organization, each with the WhatsApp account (WABA) it belongs to. Templates are per account: a template created on an account can be sent from every number on it and from no other. Use `whatsappAccount.id`, its `alias` (the name the team gave it, e.g. "Soporte") or any of its numbers to choose where to create a template. # List templates Source: https://docs.useboom.ai/api-reference/whatsapp-templates/list-templates /api-reference/openapi.json get /api/v1/templates List your WhatsApp templates and each one's approval status. Reviews are async (~24–48h) — re-read to see updates. # Update template tracking Source: https://docs.useboom.ai/api-reference/whatsapp-templates/update-template-tracking /api-reference/openapi.json patch /api/v1/templates/{id}/tracking Change the UTM tags a WhatsApp template adds to its URL buttons, e.g. `{ "id": "…", "tracking": { "utm": { "campaign": "black_friday" } } }`, or `{ "utm": false }` to stop tagging. Works on approved templates with no new review: tags are added when each message is sent. Tracking can only be added to a template that has not been sent for review yet; for an approved template without it, create a new one with `tracking`. # Authentication Source: https://docs.useboom.ai/authentication Authenticate with an organization API key. All requests are made to the base URL `https://www.useboom.ai` and require an organization API key, sent as a Bearer token: ```http theme={null} Authorization: Bearer boom_org_xxxxxxxxxxxxxxxxxxxx ``` * The key identifies your organization. **You never pass an organization id**: it's derived from the key, and every request is scoped to it. * Keys are prefixed `boom_org_` and are shown **once** at creation. Store the key securely; Boom only retains a hash. * Revoking a key in the dashboard immediately rejects further requests with `401`. > [!WARNING] > > Treat API keys like passwords. Never embed them in client-side code, > mobile apps, or public repositories. Use server-to-server calls only. ## Hosted environments Boom exposes two hosted environments. Each has its own organizations and its own API keys. A production key is not valid against the development host, and vice versa. | Environment | Base URL | Use | | - | - | - | | **Production** | `https://www.useboom.ai` | Live data and real journeys | | **Development** | `https://dev.useboom.ai` | A sandbox for integration testing | In the interactive API reference, switch the target with the **server dropdown** at the top of each endpoint. In your own code, set the base URL once (e.g. `export BASE="https://dev.useboom.ai/api/v1/cdp"`) so you can point the same integration at either environment. > [!NOTE] > > Not to be confused with the **environments you create inside an > organization** to separate staging from production runs (see > [Webhooks](/webhooks#environments)). Those are a journey-level concept: they > scope runs, webhook subscriptions, and credentials — **not** API keys. A key > is scoped to its organization and works across all of that organization's > environments. ## Errors A `401` is returned with one of two messages depending on the failure mode: | Status | `error` message | When | | - | - | - | | `401` | `Missing or malformed Authorization header` | The header is absent or not in `Bearer ` format | | `401` | `Invalid or revoked API key` | The key failed verification (wrong key, or revoked) | ```json theme={null} { "error": "Invalid or revoked API key" } ``` ## Related > **[Quickstart](/quickstart)** > > Make your first authenticated calls end to end. > **[Rate limits & errors](/rate-limits-and-errors)** > > Per-key limits, headers, and the shared error shape. > **[One uniform surface](/one-surface)** > > The same capabilities over REST and MCP. # Send a campaign Source: https://docs.useboom.ai/campaigns Send one email or WhatsApp message to a group of people, once, now or at a time you pick. Boom builds the flow for you and closes the campaign when it is done. A **Campaign** sends one message per channel to an audience, once: a launch announcement, a seasonal promotion, a reminder to everyone who left a cart. You pick who gets it, the message and when it goes out. Boom builds the flow, sends it, lets the agent answer WhatsApp replies for 24 hours, and closes the campaign when every conversation has ended. > [!NOTE] > > Use a **Transactional** to send a message every time an event happens, and an > **Initiative** for ongoing work that keeps enrolling people, with waits, > reminders or branches. See [transactional messages](/transactional-messages) > and [initiatives](/initiatives). ## How it works 1. You choose the audience: a segment, or people you add by hand or from a CSV. 2. You choose the messages: an email template, a WhatsApp template, or both. The email goes out first, then the WhatsApp. 3. You choose when: now, or a date and time. 4. At that time each person gets the message once. People who reply on WhatsApp talk to the agent for 24 hours. 5. When the last conversation ends, the campaign completes and its report is generated. A campaign never keeps enrolling people who join the segment later. That is the difference from an initiative. ### When the segment is read * **At send time** (the default): Boom reads the segment when the message goes out. Someone who leaves it before then, for example by finishing a purchase, does not get it. * **Now**: Boom reads the segment when you schedule the campaign, and those people get it even if they leave the segment before it sends. You can add more people until the campaign closes. Anyone added after the send time gets the message right away. Each person gets the campaign once, even if you add them again. Once the audience is in, you can add people but not swap it for another segment. ## Before you start * **A channel.** A verified email domain, a connected WhatsApp number, or both. * **A template per channel.** A published email template, or a WhatsApp template approved by Meta. Every template variable needs a value (a field of the customer's profile, or fixed text). * **An audience.** A segment, or the people you want to reach. ## Set it up in Boom **1. Create the campaign** Open **Campaigns** in the sidebar and click **New**. Give it a name. **2. Add the message** Click **Add a message**, pick the channel and its template. Fill every variable. Click **Preview** to see the message as a real person from your audience would get it. **3. Choose who and when** Pick the audience, and whether the segment is read at send time or now. Then pick **Send now** or a date and time. **4. Brief the agent (optional)** With a WhatsApp message, replies go to the agent. **Brief the agent** lets you give it an objective, context, files and guiding questions. Everything there is optional. **5. Schedule** Click **Schedule**. Nothing sends before the time you picked. To check every message first, turn on **Review before sending** under Options: each one then waits in [Drafts](/drafts) until someone approves it. > [!NOTE] > > If your organization has [Smart Sending](/smart-sending) limits, people inside > them are skipped for this campaign. To reach everyone in the audience anyway, > turn on **Always send** under **Options › Smart Sending**. > [!WARNING] > > Once people are enrolled and waiting, the send time cannot move. To change it, > cancel the campaign and schedule a new one. ## Pause, cancel and close * **Pause** stops anyone new from being enrolled. People already enrolled keep their message and conversations. **Resume** picks up where it left off, even if the send time passed while it was paused. * **Cancel** stops the campaign for good, including anyone not yet enrolled. * The campaign **closes** on its own once everyone is enrolled, the send time has passed, and every conversation has ended. Its report is generated then. ## Set it up over the API or MCP Create the whole campaign in one call with `initiatives_create` and a `campaign` object, check it, then schedule it: ```json theme={null} { "name": "Night routine launch", "campaign": { "audience": { "kind": "segment", "segmentId": "SEGMENT_ID", "readAtSend": true }, "sendAt": "2026-10-01T09:15:00-06:00", "email": { "templateId": "EMAIL_TEMPLATE_ID" } } } ``` * `initiatives_campaign_get` returns the setup, how many people are in the audience and how many have no email or phone, and `missing`: anything that still blocks scheduling. * `initiatives_campaign_configure` changes it. Only the fields you pass change. * `initiatives_launch` schedules it. Over REST these are `POST /api/v1/initiatives`, `GET /api/v1/initiatives/{id}/campaign`, `PATCH /api/v1/initiatives/{id}/campaign` and `POST /api/v1/initiatives/{id}/launch`. See the [API reference](/api-reference/overview) for every field. > [!NOTE] > > The campaign form in the app is rolling out by organization. If **New** still > opens the older step-by-step setup, ask your Boom contact. # Customer Data Platform Source: https://docs.useboom.ai/cdp/overview The REST API for getting customer data into Boom: people, custom objects, events, and the relationships between them. The Boom CDP is how you get customer data **into** the platform: the people you serve, the typed records that describe your business (orders, loans, …), the things those people do, and the relationships between them. Once the data is in, you act on it with the [REST API](/api-reference/overview). Every endpoint here is also an [MCP tool](/use-mcp) with the same name and schema. ## Base URL All CDP endpoints live under `/api/v1/cdp`: ``` https://www.useboom.ai/api/v1/cdp ``` > [!NOTE] > > Every request is authenticated with an **organization API key** sent as a > Bearer token. The organization is derived from the key. See > [Authentication](/authentication). ## Building blocks > **People** > > Individuals, keyed by your own `externalId`, with optional `firstName`, > `lastName`, `email`, and `phoneNumber` (E.164), plus a free-form > `attributes` object. > **Custom objects** > > Typed records (an `order`, a `loan`, …) keyed by `(type, externalId)`. > Types are defined per organization. > **Attributes** > > Free-form key/value data on people and objects. New keys are auto-registered > in your attribute catalog, so they show up as filters in Segments. > **Events** > > Append-only behavioral records (a `payment_made`, a `checkout_started`) > tied to a person or object. Record one at a time or in bulk. > **Relationships** > > Links between a person and an object, or between two objects: a customer > who `placed` an order, an order that `has_line_item`. > **Types** > > The catalogs behind the data: custom-object types and relationship types. > Register these to get the ids you reference elsewhere. ## Common use cases * **Identify a person.** Upsert a person by your own `externalId` with their contact fields and attributes. The same call updates rather than duplicates. * **Record behavior.** Send events as they happen, one at a time or via the `/batch` endpoint (up to 1000). * **Model your domain.** Register custom-object and relationship types, upsert objects, and link them to people. * **Bulk import.** Backfill history through the `/batch` endpoints instead of one request per record. ## Conventions * **Writes are upserts.** The same `externalId` twice updates rather than duplicates. * **People merge; custom objects replace.** A person update **merges**: keys you leave out of `attributes` are kept, a key sent as `null` is removed, and a contact field (`firstName`, `lastName`, `email`, `phoneNumber`) you omit or send empty keeps its stored value. Pass `mode: "replace"` to swap the whole profile instead: anything you leave out is cleared, which is also how you clear a contact field. A custom object update **replaces** its `attributes` by default, so an update that omits a stored attribute deletes it; pass `mode: "merge"` to overlay only the keys you send. * **One record or many.** The `/batch` variants accept up to 1000 records. * **Errors.** `{ "error": { "code", "message" } }` + HTTP status. See [Rate limits & errors](/rate-limits-and-errors). ## Learn more > **[Events](/events)** > > How events are shaped, recorded, and read back. > **[Relationship types](/relationship-types)** > > The catalog of allowed link shapes and how to register one. > **[Connect your database](/connect-your-database)** > > Sync people and objects straight from PostgreSQL or MySQL: no API > integration. # Sensitive fields Source: https://docs.useboom.ai/cdp/sensitive-fields Hide customer data fields from AI agents and from teammates without access. Some customer data shouldn't reach an AI agent or every teammate: a tax ID, a card's last digits, a home address. Mark those fields **sensitive** and Boom hides their values. Nothing is sensitive until you mark it. This page is the reference. For a step-by-step guide to hiding fields in the dashboard, see [Hide sensitive customer data](/hide-sensitive-data). A hidden value is **still stored** and keeps working: segments still filter on it and messages can still use it. What changes is who can **read** it. | Who reads | What they see | | - | - | | AI agents (in conversations, the in-app assistant, MCP tools) | `[redacted]` in place of the value, always | | Teammates whose role can see sensitive data (Owner, Admin, Developer) | The value | | Everyone else in your organization | A **Hidden** marker instead of the value, except the screens listed below | **Exports.** A segment CSV export by someone who can't see sensitive data is refused with `hidden_fields` while any contact field is hidden. Initiative participant exports write `[redacted]` in place of a hidden name or phone. **Inbox.** The conversation list, header and customer panel follow the same rules. One difference: a hidden phone shows its last four digits in the conversation list and header (`•••• 0100`), so teammates can tell conversations apart. See [What your teammates see](/hide-sensitive-data#in-the-inbox). **Not covered yet for teammates.** These screens still show the real value to teammates who can open them: * An initiative's **Contacts** tab. * Pending enrollment requests in the agent tester's **Run as** picker. * Computed segment columns whose formula reads a hidden field. * The inbox's **New message** contact picker. * The caller's name when an incoming WhatsApp call rings. * A customer's Instagram @username in the inbox customer panel. * Message text, in either direction: messages stay as written, including a template variable filled in from a hidden field. ## What you can mark * **Contact fields** every person has: email, phone number, first name and last name. The external ID can't be hidden, because Boom uses it to match people across your systems. First and last name are hidden separately: hide only the last name and agents see `Ana [redacted]`, teammates see "Ana" with a **Hidden** marker. A stored full name we can't split safely, such as a WhatsApp profile name, is hidden whole. * **People attributes**: any key in your people attribute catalog. * **Custom object attributes**: any key in a custom object type's catalog. A hidden attribute with nested data (an object or a list) is hidden whole. Marking a contact field doesn't mark an attribute that copies it (an `email` attribute beside the built-in email). Settings suggests those copies so you can hide them too. A new attribute from a sync starts visible, and a sync never changes what you've hidden. ## Where to mark fields * **Settings › Sensitive data** in the dashboard. Owners and Admins can change it; other roles with access to settings see it read-only. * The [REST API](/api-reference/overview): `GET` and `POST /api/v1/cdp/sensitive-fields`. Up to 100 changes per call, applied all or nothing: an unknown field fails the whole call. * [MCP](/use-mcp): the same tools, `cdp_sensitive_fields_get` and `cdp_sensitive_fields_set`. A connected AI tool can only **hide** a field. Asking it to make one visible again returns `sensitive_unmark_forbidden`; do that in Settings or with the REST API. ```json theme={null} POST /api/v1/cdp/sensitive-fields { "changes": [ { "scope": "built_in", "field": "PHONE_NUMBER", "isSensitive": true }, { "scope": "people", "key": "rfc", "isSensitive": true }, { "scope": "custom_object", "type": "order", "key": "card_last4", "isSensitive": true } ] } ``` ## Known limitations * An agent that reads a segment or journey definition sees its filters as they were written. If a teammate built a filter on a hidden field, the value typed into that filter is visible to the agent. * External IDs can't be hidden, and agents can look people up by part of an external ID. > [!NOTE] > > Sensitive data is turned on per workspace. Ask Boom to enable it for yours. > Until then, marking fields returns `sensitive_data_unavailable`; reading the > list always works. # Connect your database Source: https://docs.useboom.ai/connect-your-database Sync customers into the Boom CDP straight from your own PostgreSQL or MySQL database. > [!NOTE] > > This page is for whoever administers your database. Setting up the sync needs > someone who can create a database user and knows your host, port and TLS > setup. If that is not you, send them this page: the rest of the site does not > depend on it. Boom can read customers directly from your operational database and keep them in sync with the CDP: no CSV exports, no custom ETL. You point Boom at a read-only user on your database, write a `SELECT` that returns the records you care about, and map the columns to people, custom objects, and relationships. This guide walks an admin through the whole setup: > **[1. Create a read-only user](#step-1-create-a-read-only-user)** > > A least-privilege database user Boom connects as. > **[2. Connect your database](#step-2-connect-your-database)** > > Enter host, credentials, and TLS, then test the connection. > **[3. Define what to sync](#step-3-define-what-to-sync)** > > Write the query and map columns to customer data. > **[Troubleshooting](#troubleshooting)** > > Decode the most common connection errors. > [!NOTE] > > You connect through the Boom dashboard, not the API. Open > [app.useboom.ai](https://app.useboom.ai) and go to **Integrations** in the > sidebar. The whole flow needs an admin who can create a database user and > knows the database's host, port, and TLS setup. ## Before you begin > **A supported database** > > **PostgreSQL** or **MySQL**. Connecting and testing works for both today; > automated syncing currently runs for **PostgreSQL** (see > [Current limitations](#current-limitations)). > **A read-only user** > > Boom only ever reads. Create a dedicated user with `SELECT`-only grants: > [snippets below](#step-1-create-a-read-only-user). > **Network reachability** > > Boom must be able to reach the database: either a **publicly reachable > host**, or a **SSH bastion** (jump host) in front of a private database. > **TLS** > > The connection is encrypted in transit. `Require` is the default; use > `Verify full` to also validate the server certificate against a CA. > [!NOTE] > > **What Boom stores and how.** Boom stores the connection details and > credentials encrypted at rest (AES-256-GCM). The password is never returned > to the browser after you save it, and credentials never appear in logs. Boom > opens short-lived, read-only connections. It does not write to your database. ## Step 1: Create a read-only user Give Boom a dedicated user with read-only access to the tables you want to sync. This keeps the blast radius small and makes the connection easy to audit and revoke. The same snippets are available in-product, next to the connection form. ```sql PostgreSQL theme={null} CREATE USER boom_readonly WITH PASSWORD 'your-strong-password'; GRANT CONNECT ON DATABASE your_db_name TO boom_readonly; GRANT USAGE ON SCHEMA public TO boom_readonly; GRANT SELECT ON ALL TABLES IN SCHEMA public TO boom_readonly; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO boom_readonly; ``` ```sql MySQL theme={null} CREATE USER 'boom_readonly'@'%' IDENTIFIED BY 'your-strong-password'; GRANT SELECT ON your_db_name.* TO 'boom_readonly'@'%'; FLUSH PRIVILEGES; ``` > [!TIP] > > Replace `your_db_name` and `your-strong-password` with real values. The > final `ALTER DEFAULT PRIVILEGES` line (PostgreSQL) makes sure Boom can also > read tables created **after** you grant access, handy when your schema grows. > To scope Boom to specific tables instead of the whole schema, grant `SELECT` > on just those tables. ## Step 2: Connect your database **1. Open Integrations → Directory** In the dashboard sidebar, open **Integrations**, then the **Directory** tab (on an empty workspace, **Browse integrations** takes you there; once you have a connection, **Add integration** does). Pick the **PostgreSQL** or **MySQL** card: the default port fills in automatically (`5432` for PostgreSQL, `3306` for MySQL). **2. Fill in the connection details** | Field | What to enter | | - | - | | **Name** | A label you'll recognize later, e.g. `Production Postgres`. | | **Database type** | PostgreSQL or MySQL. | | **Host** | The database's DNS name or public IP, e.g. `db.example.com`. Must be publicly reachable in **Direct** mode (see [SSH tunnel](#connecting-through-an-ssh-bastion) for private databases). | | **Port** | Defaults to the database's standard port; change it if yours differs. | | **Database name** | The database to connect to, e.g. `acme_production`. | | **Username** | The read-only user from [Step 1](#step-1-create-a-read-only-user). | | **Password** | The user's password. Stored encrypted; never shown again after saving. | **3. Pick a connection method** **Direct** (default) connects straight to the host you entered. If your database is locked inside a private network, choose **SSH Tunnel (bastion)** instead. See [below](#connecting-through-an-ssh-bastion). **4. Save** Click **Save**. Boom tests the connection as it saves: it opens a short, read-only connection over TLS and reads the database version. The connection lands on its own page with an **Active** badge if the test passed, or **Error** (with a plain-language message) if it didn't. A failed connection is still saved, so you can fix the details with **Edit** and retest with **Test connection** on that page. See [Troubleshooting](#troubleshooting). > [!NOTE] > > Every connection uses TLS (SSL mode **Require**: encrypted, server > certificate not verified), which suits managed databases such as Neon, RDS > and Cloud SQL. The form has no SSL setting. If you need the server > certificate verified against your own CA, contact > [support](mailto:support@useboom.ai) to set it up. See > [Current limitations](#current-limitations). ### Connecting through an SSH bastion If your database has no public endpoint, Boom can reach it through an **SSH bastion** (a jump host that *is* publicly reachable and can connect onward to the database). Choose **SSH Tunnel (bastion)** as the connection method and fill in the bastion fields: | Field | What to enter | | - | - | | **Bastion host** | The jump host's public address, e.g. `bastion.example.com`. | | **Bastion port** | SSH port, usually `22`. | | **Bastion username** | The SSH user on the bastion. | | **SSH private key** | A private key Boom authenticates with. Use a **dedicated, least-privilege key** that can only forward ports. | | **Key passphrase** | Optional, only if your key is passphrase-protected. | | **Pinned host key** | Optional, paste the bastion's `SHA256:…` fingerprint to pin it, or pin it after the first successful test. | In tunnel mode you still enter the **database** host and port as the bastion sees them (often a private address like `10.0.x.x` or an internal DNS name). Boom dials the bastion, then forwards onward to the database. > [!NOTE] > > If the test fails with a *could not reach the database host/port* error, the > bastion is likely refusing TCP port forwarding, or it can't reach the > database from its own network. Confirm the bastion's SSH server allows > forwarding (set `AllowTcpForwarding yes` in `/etc/ssh/sshd_config` and reload > `sshd`) and that the bastion can itself connect to the database host and port. > [!TIP] > > **Host-key pinning** protects against a man-in-the-middle on the bastion. > Leave it blank to trust the key on first connect, then pin the fingerprint > Boom shows you. Once pinned, a changed key is refused until you re-pin it. ## Step 3: Define what to sync A connection on its own doesn't move any data. It just proves Boom can reach the database. To actually sync, you add one or more **data sources**. A source is a `SELECT` query plus a mapping that tells Boom how to turn each row into a person, a custom object, or a relationship. Boom switches syncing on for your connection once it's saved (ask [support](mailto:support@useboom.ai) if you don't see it yet). It then appears under **Integrations → Syncs**. Open it and click **+ Add data** to build a source in four steps: **What it becomes**, **Choose a table**, **Query & mapping**, and **Preview & go live**. ### Resource kinds Each source produces one **kind** of record: | Kind | Use it for | Lands in | | - | - | - | | **Person** | Humans: customers, leads, users. | People, keyed by your `external_id`, with email/phone for outreach. | | **Custom object** | Everything else: orders, loans, products, subscriptions. | Typed custom objects. | | **Relationship** | A link between two records (typically a many-to-many join table). | An edge only, no record of its own. | The fastest way to start: choose what the data becomes (**People**, **Objects** or **Link**), then pick a table Boom discovered and click **Use this table**. Boom writes a starter `SELECT` for you, which you can refine and check with **Run preview** before **Create sync**. ### Required columns Every source query **must** project three columns, whatever else it returns. They're how Boom keeps the sync correct and incremental: | Column | Purpose | | - | - | | `external_id` | A stable, unique id for the row (usually the primary key). Boom uses it verbatim to match rows on every sync, so the same record updates instead of duplicating. | | `source_updated_at` | A last-modified timestamp. Boom remembers the latest value it has seen and, on each run, pulls only rows changed since then. That's what makes ongoing syncs cheap. | | `source_deleted` | A boolean. When `true`, Boom marks the synced record as removed while keeping its last-known data. | ```sql theme={null} SELECT id AS external_id, updated_at AS source_updated_at, (deleted_at IS NOT NULL) AS source_deleted, email, phone, plan_tier, signup_date FROM public.users; ``` > [!WARNING] > > If you project `source_deleted` from a soft-delete column, **don't also filter > those rows out** (e.g. `WHERE deleted_at IS NULL`): the deleted rows would > never reach Boom, so `source_deleted` would always be `false` and deletions > would never sync. Either include them (as above) so Boom sees the flag flip, > or, if you only ever want live rows, drop `source_deleted` from the projection > and let Boom's daily reconciliation detect removals. > [!NOTE] > > Boom runs your query in a **read-only transaction** and previews only a small > number of rows while you're editing, so it's safe to iterate. Make sure > `external_id` is unique per row: for a join table without a single-column > key, project one (e.g. `left_id || ':' || right_id AS external_id`). ### Map your columns Once the query runs, map the returned columns: **1. Identity (Person sources)** Map at least one of **email** or **phone** so Boom can reach the customer. These populate the person's contact channels, which is what lets event-triggered journeys message them. **2. Attributes** Map any other columns to **attributes** and give each a type (String, Number, Date, Boolean). Attributes become filterable fields in Segments, so the right type matters: a `Date` compares chronologically, a `Number` numerically. **3. Descriptions (optional but recommended)** Add a short description per column to build a data dictionary. **Describe with AI** can draft these for you. It only ever sees column **names and types**, never row values. Descriptions are saved to your attribute catalog and survive future syncs. ### Link records with relationships Relationships connect a person to an object, or one object to another (a customer who `placed` an order; an order that `has_line_item`). On a Person or Custom object source, add a relationship by pointing a **foreign-key column** at another source: | Field | Meaning | | - | - | | **FK column** | The column on *this* row that holds the other record's id. | | **Kind** | `person_to_co` (person → object) or `co_to_co` (object → object). | | **Target resource** | The other source the id refers to. | | **Role** | A label for the link, e.g. `placed`, `has_line_item`. | For a **many-to-many** join table, add it as its own **Relationship** source. Its query projects `left_external_id` and `right_external_id` (plus the three required columns) and emits one edge per row, useful when the link itself carries data (a quantity, a redeemed-at timestamp), which becomes the edge's attributes. > [!TIP] > > Sync the target of a relationship **before** the source that points at it > (e.g. orders before line items). If an edge references a record Boom hasn't > seen yet, Boom tolerates it and re-links automatically on a later run, so > ordering is a convenience, not a hard requirement. ### How syncing runs Once a source is **enabled**, Boom keeps it in sync automatically (roughly every 15 minutes) using `source_updated_at` to pull only what changed since the last run: * **First sync** backfills all matching rows. Large tables backfill in batches across several runs; you don't need to do anything. * **Ongoing syncs** are incremental and usually pull a handful of rows (or none). * **Deletions** are reconciled by a daily check, and rows flagged with `source_deleted = true` are marked removed as they sync. A new source starts syncing when you click **Go live**. Use **Pause** on a source to stop syncing without deleting your mapping. ## Troubleshooting When a test or sync fails, Boom shows a plain-language message. Here's what the common ones mean. > **Authentication failed. Check username and password.** > > The username or password is wrong, or the user isn't allowed to connect > from Boom's network. Re-run the [Step 1](#step-1-create-a-read-only-user) > grants, confirm the password, and (MySQL) make sure the user is created > with a host pattern that allows remote connections, e.g. `'boom_readonly'@'%'`. > **Authentication succeeded but the user lacks permission.** > > Boom connected but can't read a table. Grant `SELECT` on the tables (or > schema) you're syncing. See the read-only snippets above. > **Host not found. Check the hostname spelling.** > > The hostname didn't resolve. Verify the host, and that it's a public DNS > name or IP (in Direct mode). For private databases, use an > [SSH bastion](#connecting-through-an-ssh-bastion). > **Connection timed out.** > > Boom reached the network but got no response in time, usually a firewall > or security group blocking the port. Allow inbound access to the database > port from Boom, or front the database with a bastion. > **Connection refused.** > > The host is reachable but nothing is listening on that port. Check the port > number and that the database is running and accepting TCP connections. > **TLS handshake failed.** > > The encrypted handshake didn't complete. Boom always connects over TLS, so > the database must accept TLS connections. Check that SSL is enabled on the > server and that nothing between Boom and the database strips it. > **Cannot connect to private, loopback, or link-local addresses.** > > In **Direct** mode Boom refuses private (`10.x`, `172.16–31.x`, > `192.168.x`), loopback (`127.x`), and link-local (`169.254.x`) addresses > for security. Use a publicly reachable host, or connect through an > [SSH bastion](#connecting-through-an-ssh-bastion). > **Database '…' does not exist on this server.** > > The **Database name** is wrong, or the read-only user can't see it. Confirm > the exact name and that the user has `CONNECT` on it. > **Could not reach the database host/port (SSH tunnel).** > > The bastion connected but couldn't forward to the database. Make sure the > bastion allows TCP forwarding and can itself reach the database host and > port. Double-check the database host/port as the **bastion** sees them. ## Current limitations The database-source feature is live, with a few capabilities still on the way. Plan around these: * **No static egress IP to allowlist (yet).** Boom currently connects from a shared, variable IP range, so it can't give you a single fixed IP to add to your firewall. If your database can't be exposed publicly, use an [SSH bastion](#connecting-through-an-ssh-bastion). Static egress IPs are planned. * **No AWS PrivateLink.** Private connectivity is via SSH bastion today; PrivateLink is not available yet. * **No customer-managed keys (BYOK).** Credentials are encrypted with a Boom-managed key. Bring-your-own-key is not available yet. * **Automated sync is PostgreSQL-first.** You can create and test **MySQL** connections today, but scheduled syncing currently runs for **PostgreSQL** sources. MySQL syncing is on the roadmap. * **No SSL setting in the form.** Every connection uses **Require** (encrypted, certificate not verified). Contact support if you need the server certificate verified against your own CA. * **Syncing is switched on by Boom.** Saving a connection doesn't create its sync yet; Boom enables it, after which you build sources yourself from **Integrations → Syncs**. ## Related > **[Relationship types](/relationship-types)** > > Understand how links between records are modeled across the CDP. > **[Quickstart](/quickstart)** > > Prefer to push data over the API? Start here instead. > [!NOTE] > > Prefer to push data instead of pulling it? You can also send people, objects, > events, and relationships over the [CDP API](/introduction). The two > approaches coexist. # Drafts Source: https://docs.useboom.ai/drafts Hold a journey's sends for a person to review, then approve or reject each one before it reaches the customer. A **draft** is a journey send that waits for approval. Boom prepares the message exactly as it would send it, with the recipient, the sender and every variable filled in, and holds it. Nothing reaches the customer until someone on your team approves it. Use drafts when a person should see a message before it goes out: * **The first sends to a new audience.** Check a few real messages before you let the rest go. * **Sensitive content.** Collections, account changes, or anything where a wrong variable would hurt. * **Compliance review.** When your process needs a named person to sign off on each outbound message. Drafting is set per send step, so one journey can draft its first message and send the follow-ups on its own. Each drafted step either holds the person's run until someone decides (**Draft and wait**) or queues the message and lets the run carry on (**Draft and continue**, see [below](#draft-and-continue)). A journey's **Send reward** step always waits in Drafts: see [Gift card rewards](#gift-card-rewards). ## Turn it on **1. Open the send step** In the journey builder, select a **Send message** step (WhatsApp, template mode) or a **Send email** step. **2. Pick a draft mode** Under **Delivery**, change the select from **Send now** to **Draft and wait** or **Draft and continue**. Each option shows a one-line description of what happens to the customer. With **Draft and wait** the step gains a third exit, **Rejected**, next to **Sent** and **Capped, not sent**. **Draft and continue** adds no exit: the run always carries on through **Sent**. **3. Wire the Rejected exit (optional, Draft and wait only)** Connect **Rejected** to whatever should happen when a reviewer says no: a different message, a wait and another try, or an exit. Leave it unconnected and the person's run ends quietly. **4. Publish** Publish the journey as usual. From then on, every send from that step waits for review. ## Draft and continue **Draft and continue** queues each message in Drafts and lets the person's run move on right away, as if the message had gone out. Use it to check a sample of a journey's messages without holding anyone back, for example while you move campaigns from another tool: send events to Boom, review what the journeys would send for a few days, delete the queue, then switch the steps to **Send now**. * **Send** sends that one message, exactly as previewed. The person's journey does not change: they already moved on. * **Delete** discards the message. Nothing is sent and nothing else changes. * Queued drafts show a **Queued** label in the list, and their run history says "Queued for review", then whether the message was sent, deleted or expired. * Reports count a queued message as sent only once it actually goes out. * A **Wait for reply** right after a queued WhatsApp message starts its timer before the message is sent, so publishing shows a warning. Replies still reach the run; give the wait a timeout longer than your review takes. * The step's send window still applies: an approved message outside it shows "Sends at …" and goes out when the window opens. ## Expiring drafts Under the **Delivery** select of a draft step, tick **Discard drafts nobody reviews in** and set the number of days (1 to 30, 3 by default) to expire drafts nobody decided in time. An expired draft is never sent. With **Draft and wait**, the run then continues through **Rejected**; with **Draft and continue**, nothing else changes. Off by default: a draft waits until someone decides it. ## Review drafts Drafts are reviewed inside the campaign or initiative they belong to. Open it and choose **Drafts** in its sidebar, under **Operational**, after **Executions**. The sidebar shows how many drafts are pending (up to "99+"). The initiative page shows an **N drafts waiting** button, and the journey builder toolbar shows the same count as a badge. Both open the Drafts tab. ### By template The tab opens **By template**: pending drafts grouped by what you are actually approving, one row per template version at each step. A row shows the template (and, if the template has changed since, which version these drafts were made from), the step, how many are pending, and how many have waited more than 24 hours. Click a group, or move with **J** and **K**, to see one example message from it, rendered exactly as it would be sent, plus a warning if the template was edited after the drafts were created. **Approve N…** and **Reject N…** decide the whole group. The confirmation, just above the example, gives the count, how many have waited more than 24 hours, and that drafts arriving later are not included. The same 5 second undo applies. The group then shows how many were sent and, if any could not be, how many, with a link that opens them in **One by one**. **Approve all…** in this view lists each group and its count before you confirm. ### Odd values Some messages would go out wrong: a name that came through empty, or an amount of zero. For a WhatsApp group, **Variables in this group** lists each template variable with how many distinct values it takes and how many drafts have it empty or at zero (a value such as `$0.00` or `0,00 MXN`). The **To review** column in the group list shows how many drafts in each group have at least one. These drafts are left out of **Approve N…** and **Approve all…** by default. The confirmation says how many stay behind for review, and **Include the N with odd values** adds them back. Click a count, or **Review them one by one**, to open just those drafts in **One by one** (the **To review** chip; remove it to see the rest). In the preview, an odd value is shaded amber with a solid underline, an empty one reads **Empty** in the variables list, and a notice names each odd variable. Odd values are checked for WhatsApp only. Email drafts do not store their variables yet, so for email, check the example. ### One by one Switch to **One by one** to review drafts individually. It can be narrowed to a single group (the **Template: …** chip; remove it to see every draft again). The list shows every draft with its recipient, step, status and age, plus the first line of its message (the WhatsApp text or the email subject). A pending draft older than 24 hours is marked in amber, because its content was fixed when it was drafted. Use **Filter** to narrow it by status, channel, journey version, step or when it was created, and **Refresh** to pick up new ones. Click a row to see the preview: the exact message that will be sent, with the template, the variables, and for email the From, Reply-To and subject. In a WhatsApp preview, the values filled into the template are lightly shaded with a dotted underline, so you can check each one. On a narrower screen the preview opens as a drawer from the bottom, only when you tap a row. The preview also shows how old the draft is, and warns **Template edited since drafted** if the template changed after the draft was made. For email, **Show full email** opens the whole message. A draft that has been decided shows who approved or rejected it, and when. You can decide: * **One draft**, with **Approve** or **Reject** in its preview. There is no confirmation step, and the preview moves to the next pending draft right away. * **A selection**, by ticking rows and choosing **Approve selected** or **Reject selected**. Approving, or rejecting more than one, asks you to confirm the count first, just above the list. * **Everything matching the current filter**, with **Approve all…**, or **Reject all pending…** in the **⋯** menu. Drafts created after you opened the page are not included, so you never approve something you have not had the chance to see. The confirmation shows how many messages will go out and how many have waited more than 24 hours. Approving leaves out WhatsApp drafts with [odd values](#odd-values) unless you tick them in. Rejecting everything confirms in red and says that nothing will be sent. ### Five seconds to undo Every decision waits 5 seconds before Boom acts on it. The preview, and a notice at the bottom of the screen, count down (**Sending to Ana in 5s**) with an **Undo** button. Undo inside those 5 seconds and nothing happens: the draft stays pending and nothing was sent. You can keep reviewing other drafts while one counts down. If you leave the page during the countdown, the decision is dropped and the draft stays pending. After the countdown, an approved draft shows **Sending…**, then **Sent**, or **Could not send** with what happened and what to do, for example that Meta no longer approves the template. A draft that could not be sent stays in the list with that reason. If someone else decided the draft first, the preview says who and when instead. Once you click into the list or a preview, the keyboard works too: the arrow keys move through the list, **Space** ticks a row and **Enter** opens it. **J** and **K** move between drafts, **A** approves, **R** rejects, and **Esc** closes the preview. Holding a key down does nothing extra, and **A** is ignored for a moment after the preview moves on, so a double press cannot approve two people. Screen readers hear the open draft's name and its position in the list. When you reject, you can add an optional reason. It is kept on the draft as the reviewer's note. ## What happens next **Approve** sends the message you previewed, exactly as it is. Boom does not render it again, so a template edit made after the draft was created does not change what goes out. The journey's send window and your channel's rate limits still apply, so a large approval goes out at the same pace as live traffic. The run then continues on **Sent**. **Reject** sends nothing. The run takes the **Rejected** exit, or ends if nothing is connected to it. A rejected draft costs no credits: credits are only used when a message is actually sent. For a **Draft and continue** step the run never waited, so **Send** only sends the message and **Delete** only discards it. ### Checked again at delivery Some things can change while a draft waits. These are checked again when an approved draft is delivered, and the draft is not sent if any fails. The **Rejected** exit is only for a reviewer's rejection, so it never catches these. Each one takes the path below: * **Do Not Contact.** A person who opted out in the meantime is skipped. On WhatsApp the run ends. On email the run continues on **Sent**, the same as an email step that skips an opted-out person without drafts. * **The template.** A WhatsApp template no longer approved by Meta, or an email template no longer published, stops the send and the run ends. * **The sender.** A WhatsApp number or email sender that is no longer available stops the send and the run ends. * **The journey.** If the journey was stopped, the draft is not sent and the run ends. * **Smart Sending.** The [message limit](/smart-sending) is checked at delivery, not when the draft is created. An approved draft that would go over the limit is not sent: the run takes **Capped, not sent**, or ends if that path is not connected. Re-enroll the person later if the message is still needed. * **A reply (WhatsApp only).** If the person replies while an approved WhatsApp draft is waiting for its send window, the draft is skipped and the run continues on **Sent**. Email drafts have no send window, so this never applies to them. Each draft keeps its final status (Sent, Skipped, Failed, Canceled, Expired) and the reason, so the tab shows what happened to it. ## Gift card rewards > [!NOTE] > > Reward steps in journeys are in early access. Contact Boom support to turn them > on for your organization. A journey's **Send reward** step gives a customer a gift card, and every one waits in Drafts first: nothing is ordered, and no money is spent, until someone approves it. When a run reaches the step, Boom checks the step's **Who qualifies** rule against the conversation. A customer who qualifies (or everyone, when the rule is empty) gets a reward draft; one who does not takes the step's **Not eligible** exit and no draft is made. When an initiative has rewards waiting, the Drafts tab shows **All**, **Messages** and **Rewards**, each with its pending count. A reward row shows the customer, the amount and a one-line reason. Its preview, titled for example **MX\$50 to Ana**, shows: * how the gift card reaches the customer: a link sent on WhatsApp with your reward template, or an email; * **Who qualifies**: the step's rule, a **Qualifies** mark and a short reason, in Boom's words rather than a quote from the customer; * the last few messages of the conversation, with **Open in inbox**. These need access to the inbox; without it the preview says so. **Approve and send MX\$50** orders the gift card for the amount, delivery and address in the preview, records you as the person who approved it, and the run continues on **Sent**. The same 5 second undo applies. If the order cannot be placed (your rewards account is not connected, it refused the order, there is no approved reward template, or the customer has no email or WhatsApp number), the draft shows **Could not send** with the reason, nothing is charged, and the run takes the step's **Failed** exit. **Reject** orders nothing. The run takes the step's **Rejected** exit, or ends if nothing is connected to it. **By template** groups reward drafts by step and amount, so a whole step's rewards can be approved at once; the confirmation says the gift cards will be ordered and that this spends money. **Approve all…** on the **All** view says when it includes both messages and gift cards. If the journey is stopped while a reward waits, the draft is canceled and nothing is ordered. ### The Rewards page and the budget Each campaign or initiative with a reward step has a **Rewards** page in its sidebar, under **Extra**. Before the journey has a reward step it explains how rewards work and offers **Add a reward step**, which opens the journey builder with a new **Send reward** step placed and open (on a published journey it first offers to start a draft). Once there is a step, the page shows: * **Sent**: what the initiative has paid in gift cards, and how many. * **Waiting for approval**: rewards pending in Drafts, with a link to them. * **Budget**: an optional spending cap for the initiative, with how much of it is used. Set or change it with **Set** or **Edit** (this needs permission to send rewards). The budget is in one currency, the same as the reward steps. * The journey's reward steps, each with **Edit in journey**. * **Sent rewards**: every reward with its status (**Delivered**, **Processing**, **Not delivered**, **Not sent** or **Rejected in review**), who approved it and when. If your rewards account is not connected, the page says so: approvals fail until it is connected again in **Settings › Rewards**. **In Drafts, with a budget.** Under **Approve** and **Reject** a reward shows a short budget line, for example **MX$50 · MX$1,150 of MX\$5,000 used**. When approving it would pass the budget, the line says so and the approval is refused: nothing is approved or ordered, and the reward keeps waiting. Raise the budget on the Rewards page, or approve fewer. A bulk approval is checked as a whole and refused whole. If someone else is approving rewards for the same initiative at that moment, you may see **Another approval is in progress**; try again a moment later. **Resend link.** When a reward was paid but its WhatsApp message failed, the **Sent rewards** list shows **Not delivered** and offers **Resend link**. It sends the same redemption link again and never orders a second gift card. It needs permission to send rewards. Check the customer's WhatsApp number and your reward template if it fails again. ## Limits * **Supported steps.** WhatsApp template messages and email. Free-text WhatsApp, SMS, Instagram and Messenger sends cannot be drafted. * **Transactional messages: all or nothing.** A [transactional message](/transactional-messages) holds every message or none, with **Review before sending** under its **Options**, not per step. While one waits, that customer's next event sends nothing unless the Transactional sends **One per field value**. * **Several runs at once.** On a journey with [several runs per person](/journeys#several-runs-at-once), a send that waits for a send window cannot be drafted. * **Drafts only expire when the step says so.** Unless **Discard drafts nobody reviews in** is ticked on the step, a draft waits until someone decides it, however long that takes. * **A waiting draft keeps the person in the journey.** While their **Draft and wait** draft is pending, the same person cannot be enrolled again in that journey. Approve or reject it first. A **Draft and continue** draft does not hold the run. * **Transactional messages and campaigns hold their runs.** Their **Review before sending** works like **Draft and wait**. * **Stopping the journey cancels its drafts.** Pending drafts move to Canceled and those runs end. * **Drafts can be turned off for an organization.** When they are, the **Delivery** option is gone from the builder, and a journey with a drafted step cannot be published. Drafts already waiting stay on the Drafts tab and can still be approved or rejected. A waiting draft is never sent without approval. Contact Boom support to turn drafts off or back on. ## Permissions Two permissions control drafts, in the app and over MCP alike. Approving a reward also takes a third, because it spends money. | Permission | Lets someone | Roles that have it | | - | - | - | | `org:drafts:read` | See the Drafts tab, its count and the previews. | Owner, Admin, Member, Viewer | | `org:drafts:update` | Approve and reject drafts. | Owner, Admin | | `org:rewards:send` | Together with `org:drafts:update`, approve a gift card reward. | Owner, Admin | Someone who can approve drafts but lacks `org:rewards:send` can still reject a reward, but sees no approve button on it, and **Approve all…** approves only the messages. Over MCP, approving a reward without it is refused and nothing is decided. Someone with read but not update sees the list and previews, without checkboxes or approve and reject buttons. Over MCP, `drafts_list`, `drafts_get` and `drafts_count` need read and `drafts_decide` needs update, so a Member can review what is waiting but only an Owner or Admin can send it. See [roles and permissions](/use-mcp#roles-and-permissions). ## Over MCP An AI agent connected over [MCP](/use-mcp) can review drafts too: * `drafts_list` lists drafts, with a preview of each, filtered by initiative, status and more. It returns `asOf`, the server time of the read. * `drafts_get` returns one draft by id: its full preview, and who decided it, when and why. * `drafts_count` counts the pending drafts a filter matches. Pass the `asOf` from `drafts_list` as `createdTo` to get the exact number a filter decide would change. * `drafts_decide` approves or rejects drafts by id, or everything matching a filter. Approving sends **real messages**. A filter decide needs the `asOf` from `drafts_list` and an `expectedCount` from `drafts_count`. If the number of matching drafts has changed, it is refused and nothing is decided. To decide one template version at one step, the same group the Drafts tab shows, filter by `workflowId`, `nodeId`, `templateId` and `templateUpdatedAt`, taking the last two from a `drafts_list` row. * Every filter takes `kind`: `"message"` for WhatsApp and email sends, `"reward"` for gift cards (or `channel: "REWARD"`). A reward's preview has the amount, currency, delivery, recipient, the step's rule and the reason the customer qualifies. Approving a reward with `drafts_decide` orders a real gift card. * If the initiative has a rewards budget, `drafts_decide` checks rewards against it before approving anything, and refuses the **whole** request when it can't approve all of it. Nothing is decided and the drafts stay pending: * `reward_budget_exceeded`: approving would take the initiative past its budget. Raise the budget on the initiative's Rewards page, or approve fewer. * `reward_budget_currency_mismatch`: a reward pays in another currency than the budget. * `reward_draft_unreadable`: a reward under a budget can't be read, so its amount can't be checked. Reject that draft. * `reward_budget_unverifiable`: some of the initiative's past spend can't be counted in the budget's currency. Contact support, or remove the budget. * `reward_budget_batch_too_large`: too many rewards to check at once. Approve them in smaller groups. * `reward_budget_busy`: another approval for the same initiative is in progress. Try again in a moment. * Every filter takes `odd`: `"only"` for WhatsApp drafts with an empty or zero variable value, `"exclude"` for all the others (to approve a group without them, as the Drafts tab does). When authoring a journey over MCP or the API, set `delivery: "draft"` on a `SEND_MESSAGE` (template mode) or `SEND_EMAIL` node. See [journeys](/journeys). There is no REST endpoint for deciding drafts. Approving a send has to name the person who approved it, and an organization API key identifies the integration, not a person. See [one uniform surface](/one-surface). If you use [webhooks](/webhooks), a run that ended because its draft was rejected reports `journey_run.ended` with `reason: completed`. To tell it apart, read the participant's step history: the send step's `emittedSignal` is `REJECTED` (also for a draft that expired, with `output.decision: "expired"`). A **Draft and continue** step records `QUEUED`; once its draft is decided, the step's `output.draftResolution.status` says whether it was sent. ## Related > **[Journeys](/journeys)** > > Build the workflow whose send steps you draft. > **[Smart Sending](/smart-sending)** > > The message limit an approved draft is checked against. > **[Use MCP](/use-mcp)** > > Review and decide drafts from an AI tool. > **[Webhooks](/webhooks)** > > How a rejected draft shows up on `journey_run.ended`. # Email templates Source: https://docs.useboom.ai/email-templates Read, create and update email templates over MCP or the API — as builder blocks or as HTML. Email templates are what a journey's `SEND_EMAIL` node sends. You can author them in the app's builder, or from an agent or script with five tools (each is also a REST endpoint): | Tool | Endpoint | Does | | - | - | - | | `email_templates_list` | `GET $BASE/email-templates` | Every template, draft and published, with its status, mode and variables | | `email_templates_get` | `GET $BASE/email-templates/{templateId}` | One template in full — edit it and send it back | | `email_templates_create` | `POST $BASE/email-templates` | Create a template as a draft | | `email_templates_update` | `PATCH $BASE/email-templates/{templateId}` | Rename, change subject/senders/content, publish or unpublish | | `email_templates_preview` | `POST $BASE/email-templates/{templateId}/preview` | Render it with a real event's data and read back what a recipient would get. Nothing is sent | A template is written one of two ways, and you send exactly one: * **`document`** — the builder's blocks: `heading`, `text`, `button`, `image`, `divider`, `spacer`, `html`, `social`, `footer`, plus `container` (a framed card) and `columns` (side by side). Styling is optional; what you leave out takes the builder's defaults. * **`html`** — your own markup. It is sanitized on save: scripts, event handlers and external stylesheets are removed, and the response lists what was removed in `removed`. Boom renders the deliverable HTML and plain text itself. Variables use the `{{path}}` grammar in the subject and the body — `{{customer.name}}`, `{{person.}}`, `{{engagement.chatLink}}` — and resolve at send; see [Template variables](/template-variables) for what a path can be. A template's `variables` lists each path once, even when it is written both `{{x}}` and `{{ x }}`; both spellings render. ```json theme={null} { "name": "Recordatorio de pago", "subject": "Hola {{customer.name}}, tu pago vence pronto", "document": { "body": { "language": "es", "preheader": "Evita recargos" }, "blocks": [ { "type": "heading", "text": "Tu pago vence el viernes" }, { "type": "text", "text": "Hola {{customer.name}}, aún tienes un saldo pendiente." }, { "type": "button", "text": "Pagar ahora", "href": "https://example.com/pay" }, { "type": "footer", "text": "Empresa S.A. de C.V., Ciudad de México" } ] } } ``` The same template as HTML is `{ "name": …, "subject": …, "html": "…
" }`. ## Sending one A template sends through a journey. Build it on an email initiative with a `SEND_EMAIL` step that points at the PUBLISHED template (`journeys_email_templates` lists those, along with whether your domain and sender are ready), then launch the initiative, which publishes the journey. Then enroll people: * **Directly:** `initiatives_participants_add` with an `email` for each person. * **On behavior:** give the journey a `cdp_event` trigger, and every time that event is recorded for a person with an email (`cdp_events_record`), they are enrolled. People on your Do Not Contact list are skipped either way. ## Rules worth knowing * **New templates are drafts.** A journey can only send a `PUBLISHED` template; publish with `email_templates_update` and `"status": "PUBLISHED"`. * **Changing a published template needs `"confirm": true`.** Journeys reference the template itself, so an edit changes what they send from their next send, and setting it back to `DRAFT` makes those sends fail. Call without `confirm` first to see the refusal. * **Unknown fields are rejected, not ignored**, so a typo such as `background` for a button's `bg` fails with a validation error instead of quietly taking the default. * **Blocks → HTML is one way.** Sending `html` to a block template converts it; an HTML template does not accept a `document` again. * A footer block goes at the top level of the email, not inside a container or a column. Omit `unsubscribeLabel` for the document language's default link text, or send `""` for no unsubscribe link (transactional email). * **Colors and images can read a variable**, for example each merchant's brand color and logo. A color field takes `{{ brand.primary_color | color | default: '#111827' }}` (the `color` filter turns anything that isn't a color into the fallback), and an image is `{{ brand.logo_url | default: '' }}`, written in an `html` block since an `image` block's `src` must be a literal URL. See [White-label email](/white-label-email). * Not available here yet: test sends, image upload (use an image URL you host, or one from the builder's library), and deleting a template. ## Preview with a real event `email_templates_preview` renders a template the way a journey triggered by an event would send it, without sending anything. Name the event one way: * `latestEventName` — the most recent event with that name, e.g. `"payment_due"`; add `personExternalId` for that person's most recent one. * `eventExternalId` — the id your backend sent the event with. * `eventId` — Boom's id, from `cdp_events_list`. ```json theme={null} { "latestEventName": "payment_due", "personExternalId": "cus_123" } ``` The event's properties fill `event.*` (and `engagement.workflowState.*`), including inside Liquid tags, so loops and conditions render as they will in the real email. When the event names a person, `customer.*` and `person.*` come from that person, as in the email the journey would send; fields your organization marked sensitive stay hidden from an assistant. The response has the `subject`, `html` and `text`, plus: * `unresolved` — the paths of variables that came out blank. A blank `event.*` variable usually means the event doesn't carry that property. * `liquidError` — set when the Liquid failed to render. * `redactedKeys` — event properties your organization marked sensitive. An assistant (MCP) always sees them masked; an API key sees real values. * `event` — which event was used (`null` without one, when sample values are used instead). `values` overrides any path with fixed text (`{ "customer.name": "Ana" }`), and `bindings` previews the email as a journey **Send email** step that remaps its variables. # Email open and click tracking Source: https://docs.useboom.ai/email-tracking Measure who opens your emails and clicks their links, with one DNS record on your sending domain. Boom can tell you which of your emails were opened. It does this through your email provider's tracking, which only works once your sending domain has a **tracking subdomain** with its own DNS record. Until then, the open count on a journey reads as not measured rather than as zero. > [!NOTE] > > Tracking is set up once per organization, in **Settings → Email**, on the > **Open and click tracking** card. It applies to every email Boom sends from > your domain after it becomes active. ## What each one measures | | How it works | Turn it on when | | - | - | - | | **Opens** | A tiny invisible image is added to each email. When the recipient's mail app loads it, the email counts as opened. | You want an open rate on your email journeys. | | **Clicks** | Every link in the email is routed through your tracking subdomain, which records the click and forwards the person to the original address. | You are ready for click reporting in Boom (coming soon). | The two are independent: you can turn on opens, clicks, or both. > [!NOTE] > > Open rates appear in a journey's analytics. Click counts are not shown in Boom > yet, so turning clicks on today rewrites your links without adding a number to > your dashboard. ## Set it up **1. Choose a tracking subdomain** On the **Open and click tracking** card, pick a short name such as `links`. Boom combines it with your sending domain, so `links` on `example.com` becomes `links.example.com`. Use letters, numbers and hyphens only. **2. Turn on opens, clicks, or both** Flip the switches you want and press **Save**. Boom registers the subdomain with the email provider and shows the DNS record to add. **3. Add the CNAME record** At your DNS provider (Cloudflare, GoDaddy, Squarespace and so on), add the **CNAME** record exactly as shown on the card: the same name and the same value. **4. Check the record** DNS changes can take from a few minutes to a few hours. Press **Check again** on the card until the badge reads **Active**. The badge on the card tells you where you are: | Badge | Meaning | | - | - | | **Off** | No tracking subdomain yet, or both switches are off. | | **DNS pending** | Tracking is configured, but the CNAME record isn't verified yet. Nothing is tracked until it is. | | **Active** | The record is verified. New emails are tracked. | > [!WARNING] > > Once you set a tracking subdomain, you can change it later but not remove it. > Changing it needs a new CNAME record, and tracking stays pending until that > one is verified. ## Reading the numbers Open rates are a useful signal, not an exact count: * **Some mail apps open everything.** Apple Mail's privacy protection loads the tracking image for every email it receives, whether or not the person reads it, which pushes open rates up for Apple Mail users. * **Some readers block images.** A person who never loads images is never counted as opening, even if they read the email or reply to it. On a journey's analytics, the **Email** view tells you when its open count can't be trusted, and shows `—` instead of a percentage in that case: | You see | What it means | | - | - | | Open tracking isn't turned on | Your domain has no active tracking, so opens are not measured. | | The tracking DNS record isn't verified yet | Tracking is configured but still pending (see the table above). | | *N* of *M* emails were sent without tracking | Tracking is active now, but some emails in the selected period went out before it was. | ## Good to know * **Only new emails are tracked.** Emails sent before tracking became active can never report opens or clicks, even after you turn it on. * **Tracking never affects sending.** A pending or failed tracking record does not stop your domain from sending email. * **Your links still work.** With clicks on, the link a person sees in the email points at your tracking subdomain first, then forwards to the original address, unsubscribe links included. # Embedded forms Source: https://docs.useboom.ai/embedded-forms A form inside your own page — tests with a score and a result, sign-ups, lead forms — that saves every answer in Boom and starts a journey. An embedded form sits **inside** a page on your site, where you place it: a test with a score and a result ("test de estrés"), a webinar sign-up, a lead form on a landing page. Each answer is saved in Boom, the person becomes a lead in your [CDP](/cdp/overview) people, and the form records an [event](/events) a [journey](/journeys) can start from, within seconds. > [!NOTE] > > Embedded forms are part of the Lead Capture add-on. If you don't see > **Lead capture → Forms** in the left sidebar, ask your account manager to turn them > on for your organization. ## Build a form In the left sidebar, open **Lead capture → Forms**, choose **New form** and start from a template (*Lead form*, *Webinar registration*, *Short test*) or blank. The form's page is a live preview with the settings beside it. The left sidebar lists its sections, grouped under Build (Questions, Results), Look (Design), Share (Install, Settings) and Data (Responses, Initiatives); pick one and its settings open next to the preview. The version you have published, the **Publish** button and the language you are editing in sit at the top of the settings. A blue dot on a section means it has unsaved changes, and an orange dot means something there needs fixing. Switching sections keeps your unsaved changes, and **Save draft** saves them all at once. On a phone the settings come first. Tap **Preview** at the top of the settings to see the form, and **Back to settings** to return. * **Questions**, grouped in **steps**: one question per step feels like Typeform; several make a classic form. Types: short and long text, email, phone, number, single choice, multiple choice, dropdown, scale (0–10 or 1–5) and plain text with no question. * Each question has a **key** (how it is stored, e.g. `sleep`) and each answer a **value** (e.g. `badly`). Labels can change any time and in any language; keys and values are what your segments and journeys read, so they freeze once the form is published. * **Scoring**: give answers points (a scale scores the number picked) and add **results** with a score range — "0–3: low stress", "4–8: high stress" — plus one result for anything else. The visitor sees the result that matches; Boom picks it, never the page. * **Hidden fields** carry values from the page's address (`?campaign=octubre`) onto the response; `utm_*` tags are kept automatically. **Constants** put a fixed value on every response, such as a webinar's date. A form needs a required email or phone question, so every response belongs to a person journeys can reach. ## Languages Write the form in your own language: set it under **Settings → Language** (new forms start in the language your dashboard is in). Publishing checks the required texts in that language only. Translations are optional. A visitor sees the form in their browser's language when its questions, answers, results and consent are all translated into it, and in yours otherwise. Templates come translated: if you change the text in your language, update the translations too (the Language card lists them), or pin the form to your language. Turn on **Always show it in this language** to show yours to everyone. ## Publish, then go live You edit a **draft**; visitors only ever see a **published version**. Publish when the draft is ready: Boom refuses with the reasons if something is missing, and lists the journeys and segments still reading a question or answer you removed. Then set the form **Active**. Pages already open keep working for an hour after you publish a new version, so nobody loses their answers mid-form. ## Put it on your site Paste both tags where the form should appear (the **Install** section has them with your key): ```html theme={null}
``` Add every site that shows it under **Allowed sites** — matching is exact, so `https://example.com` does not cover `https://www.example.com`. The form fits the width of its container and grows to fit each question; turn on **Fixed height** under **Design** to keep it one size. After a submission the page receives a `boom:form-submitted` event (no answers, no contact details) for your own analytics; turn on **Push to dataLayer** to send `boom_form_submitted` to Google Tag Manager for your ad pixels. ## Make it look like yours Visitors see one question at a time, with the question as the headline. They can answer with the keyboard: a letter picks an answer, a number picks a point on a scale, and Enter moves on. A question with a single answer moves on by itself. Under **Design**, start from a look (Paper, Graphite, Sand, Forest, Ocean, Plum, Mono, or Brand, which is built from your accent colour), then adjust it: accent and text colours, font, answer style, corners, button shape, alignment, question size, a background image, or no background at all so the form sits on your page. Fonts are served by Boom, so your visitors' browsers never contact a font company. Under **Questions**, turn on the **Welcome screen** to open with a title, a line and a Start button before the first question. ## Share a link instead For ads, your bio or an email, turn on **Hosted page** in the form's settings: the form gets its own page at `www.useboom.ai/f//
`, with your title and description for link previews. Search engines are kept out unless you allow them. Turning the page off takes it down with the next publish, within a few minutes. A hosted form cannot ask for passwords, card numbers, PINs or one-time codes, and its result buttons and redirects can only go to your allowed sites. ## What each response creates * A **lead** in People (`source: lead_capture`), never merged into an existing customer by what someone typed. Answers are saved on the person under `_`, the result as `_result` and the score as `_score`, so "everyone who got *high stress* on the stress test" is a segment with no setup. * An **event** named after the form (for example `test_estres_submitted`) with every answer by key, the score and result, hidden fields and UTMs. Build a journey on it in the journey builder: the event and all its properties are there as soon as the form is published. * A **response** in the form's **Responses** section, with a CSV export. The form's **Initiatives** section lists the initiatives, campaigns and transactional messages whose journey starts on its submitted or abandoned event, published or in draft, with links to each one and to its journey (needs access to Initiatives). Journeys only start for people who ticked the marketing box (or forms that show none). With a phone question, the WhatsApp opt-in box is required. To protect your sender, a form enrolls at most **50 people an hour** in journeys by default, and one visitor's connection at most 10. Responses past the limit are still saved, just not enrolled. Expecting more (a launch, a webinar ad)? Raise **Journey enrollments per hour** under **Settings**, up to 500 (also the limit across all of an organization's forms). Someone on Do Not Contact (for example because you withdrew their consent) stays out of journeys even if they fill a form or pop-up again. ## Partial responses Turn on **Allow partial responses** under **Settings** to keep what a visitor answered when they leave before submitting (they close the tab, go back, or switch apps). It is off by default and goes live with your next publish. * While it is on, a short line shows under the form so visitors know: "We save your answers as you type." by default, in each language. You can reword it. * What they answered appears under **Responses → Partial**, and the funnel shows views, partial responses and completed responses. The CSV export follows the tab you are on and has a `status` column (`accepted`, `partial` or `spam`). * If they gave an email or phone, they become a lead in People, and an event named `_abandoned` (for example `test_estres_abandoned`) is recorded once, with the answers so far and `partial: true`. Build a journey on it to follow up, starting with a short wait: a visitor who only switched tabs may still finish the form. Names are not taken from a partial, and neither is a phone number whose WhatsApp opt-in box wasn't ticked. * **A partial response never messages anyone without consent.** It never turns on a WhatsApp or email opt-in, and its event never enrolls anyone in a journey, unless the visitor ticked the marketing box before leaving. People on Do Not Contact stay out, as always. The lead it creates is like any other lead in People: an audience you build yourself can include them, so filter on the opt-ins when you message a segment. * If the same visitor comes back and submits, the partial becomes their response: one row, not two. The `_abandoned` event stays in their history, so give a follow-up journey an exit on `_submitted`. If they untick the marketing box before submitting, or correct their email, the opt-in the partial turned on is turned back off (and the event leaves a mistyped address). * A partial with no email or phone keeps the answers only, with no lead. * On a form with sensitive questions, a sensitive answer is kept only if the visitor already ticked the consent to process it, and is stored encrypted. * Partial responses are deleted after 90 days (sooner if the form's retention is shorter) unless the visitor submits, and are erased with the person like full ones. ## Sensitive questions (health and similar) Mark a question **sensitive** when its answer is health data or as private. Then: * the answer is stored encrypted and saved on the person as a [sensitive field](/hide-sensitive-data): agents never see it, and only people with access to sensitive data do; * the answer, the score and the result are left off the event; * the form must ask for consent to process the answers, with a link to your privacy notice, before it can be published; * responses are deleted after a retention period (a year by default). Once published, a sensitive question stays sensitive. ## When someone asks you to stop or to delete their answers From a response, an admin can act on a person's privacy request: * **Withdraw consent** adds them to Do Not Contact (in People), so nothing is sent to them on any channel, turns their WhatsApp and email opt-ins off, and stops the journeys still running for them. If a journey's Shopify customer step subscribed them in your store, Boom also tries to unsubscribe them there, only for what Boom subscribed (if that doesn't work, Boom warns you to unsubscribe them in Shopify yourself). Remove them from Do Not Contact to message them again. * **Erase** deletes every form response of theirs, the events those responses created and the answers saved on their profile, right away. The person stays in People with their contact details; delete them there too if they asked for that. ## Use it from your tools Everything above is available on the [REST API and MCP](/use-mcp): `lead_capture_embedded_forms_list`, `…_get`, `…_create`, `…_update` (edit the draft, sites and status), `…_publish`, and `lead_capture_form_responses_list`. Agents always see sensitive answers as `[redacted]`. An organization **API key** reads them in full, like the rest of the REST API, so give keys only to systems that need the data. ## Keep another tool in sync Moving off another email tool gradually? Build a journey on the form's event with an **HTTP request** step that posts the response to the other tool's API. # Events Source: https://docs.useboom.ai/events Record behavioral events, then read and list them back. An **event** is something a person or object *did* at a point in time: a `payment_made`, a `checkout_started`, a `loan_disbursed`. Unlike people and custom objects (which you upsert to a current state), events are an append-only log: each one is recorded once and read back later, individually or as a filtered, paginated stream. ## Anatomy of an event | Field | Required | Notes | | - | - | - | | `name` | ✅ | Letters, numbers, and underscores only, no spaces or symbols. | | `externalId` | ✅ | Your stable id for this event. It's the **idempotency key**: re-sending the same one returns `created: false` instead of duplicating. | | `timestamp` | Optional | ISO 8601 event time. Defaults to now() when omitted. | | `personExternalId` | Optional | The person subject. | | `customObjectType` + `customObjectExternalId` | Optional | The object subject (sent together). | | `properties` | Optional | Free-form JSON describing the event. | > [!NOTE] > > At least one subject is required: `personExternalId`, **or** the > `customObjectType` + `customObjectExternalId` pair. You may set both (a > person event about an object). Subjects are referenced by **your** external ids. They're stored as-is and Boom resolves the internal link best-effort: recording an event for a person who hasn't synced yet still succeeds, and the link is back-stitched later. A missing subject never fails the request. ## Record an event ```bash theme={null} curl -X POST "$BASE/events" \ -H "Authorization: Bearer $BOOM_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"payment_made","externalId":"evt_123","personExternalId":"user_42","properties":{"amount":1200}}' ``` Returns `{ "created": true, "eventId": "evt_db_1", "externalId": "evt_123" }`. `externalId` is your own id echoed back. Keep it: it's the key the [journey webhooks](/webhooks) report as `trigger.externalId`, so you can match an arriving `journey_run.started` / `journey_run.ended` to the record that caused it without storing `eventId`. For historical loads, **Record events in bulk** accepts up to 1000 per request. Bulk ingest is treated as backfill: it does **not** fire journey enrollment, so use the single endpoint for real-time, journey-triggering events. ## Read events back Fetch one by the `externalId` you supplied: ```bash theme={null} curl "$BASE/events/evt_123" -H "Authorization: Bearer $BOOM_KEY" ``` Or **list** them, newest first, with optional filters (combined with AND): ```bash theme={null} # every payment_made for one person, in June curl "$BASE/events?name=payment_made&personExternalId=user_42&start=2026-06-01T00:00:00Z&end=2026-07-01T00:00:00Z" \ -H "Authorization: Bearer $BOOM_KEY" ``` Filters match the external references you supplied at ingest, so events recorded before their subject synced are still returned. ## Count events To answer "how many", ask for a count rather than paging the list and tallying it — a page is capped, so counting one gives you a number that looks right and is wrong. ```bash theme={null} # how many payment_made events, broken down by the payload's "bank" curl "$BASE/events/aggregate?name=payment_made&group_by=bank" \ -H "Authorization: Bearer $BOOM_KEY" ``` ```json theme={null} { "total": 716, "unique_people": 402, "groups": [ { "value": "BBVA", "count": 430, "unique_people": 250 }, { "value": null, "count": 286, "unique_people": 152 } ] } ``` `total` counts **events**; `unique_people` counts **people**. They differ whenever someone fires the same event more than once, so don't report one as the other. `group_by` buckets by a payload property, and events that don't carry it land under `"value": null`. You can narrow first with `start`/`end`, `personExternalId`, or a `filter` on the payload (`&filter={"currency":"MXN"}`). If more distinct values exist than we return, `groups_truncated` is `true` — the bucket counts then don't add up to `total`. One caveat worth reading twice: the **`unique_people` inside each bucket never add up to the top-level `unique_people`**. Somebody who paid once with BBVA and once with HSBC is a real person in both buckets. The `count`s split your events into groups; the `unique_people` don't split your people. This counts **one** event name. To relate two different events — how many people who did X went on to do Y — build a segment with `event` predicates instead. ## Pagination Every list endpoint (events, people, custom objects) is **cursor-paginated**. A response carries `next_cursor`; pass it back as `?cursor=` to get the next page, and loop until `next_cursor` is `null`: ``` GET /events?limit=1000 → { data:[1000], next_cursor:"c1" } GET /events?limit=1000&cursor=c1 → { data:[1000], next_cursor:"c2" } GET /events?limit=1000&cursor=c2 → { data:[42], next_cursor:null } # done ``` `limit` defaults to 100 (max 1000). The cursor is opaque and stable under concurrent writes: no skipped or duplicated rows. ## Related > **[Quickstart](/quickstart)** > > Record your first event alongside people and objects. > **[Relationship types](/relationship-types)** > > Model the links between the people and objects your events reference. # Extraction Source: https://docs.useboom.ai/extraction Define the typed fields a conversation should yield, then read the structured data back. A conversation is unstructured: turns of text back and forth. Extraction is how you turn it into fields you can count, filter, and export. You declare a **schema** on an initiative: the typed fields Boom should pull out of every conversation it runs there. Boom fills that schema for each participant once their conversation has enough signal to work with. This works for any conversation, not only research. A support initiative can declare `resolved` (was the issue fixed) and `issue_category` (which kind of issue it was); a sales initiative can declare `objection` and `next_step`; an onboarding initiative can declare `blocked_on`. Every closed conversation then writes back the same fields, so you get one row per participant instead of a transcript you'd have to read one at a time. > [!NOTE] > > Extraction is scoped to one initiative. Each initiative has its own schema, > and there's no shared, cross-initiative field catalog today. > [!WARNING] > > Set the schema before you launch. A conversation is extracted against > whichever schema was current when it ran, and the API cannot go back and > re-extract a closed conversation under new fields. Add a field an hour after > launching and the conversations from that first hour never carry it. ## Defining a schema A schema is a map of field slugs to field definitions: | Type | What it captures | When to use it | | - | - | - | | `bool` | Yes/no. | A binary flag, e.g. whether the issue was resolved. | | `int` | A whole number, optionally bounded by `min`/`max`. | A count or amount mentioned in the conversation. | | `enum` | One value from a fixed list you declare. | A single category the conversation falls into. | | `enum[]` | Zero or more values from that same fixed list. | More than one label can legitimately apply. | | `string` | Free text, optionally capped at `maxLength`. | A summary, reason, or quote in the customer's words. | | `string[]` | Zero or more free-text items, with no predefined list. | An open set the conversation surfaces (products mentioned, competitors named) that you don't want to constrain to a fixed catalog. | Every field also carries: | Property | Required | Notes | | - | - | - | | `label` | ✅ | Display name, 1–80 characters. | | `description` | ✅ | 1–500 characters. This is not just documentation: it's the instruction the model reads to decide what to extract for this field, so write it like you're briefing the model, not labeling a column. | | `values` | Only for `enum` / `enum[]` | 1–64 characters per value, at least one value, no duplicates. | | `min`, `max` | Optional, `int` only | Integer bounds. | | `maxLength` | Optional, `string` only | Positive integer. | The field slug (the map key) must be lowercase, start with a letter, and contain only letters, digits, and underscores, up to 40 characters. `topic`, `problem`, and `competitor` are reserved: Boom already writes its own org-wide signals under those keys, so a custom schema can't declare them: `extraction_schema_set` rejects the request with a `reserved_field` error. ### Applying a schema **MCP** `extraction_schema_set`: author or replace the schema in one call. **REST** `POST /api/v1/initiatives/{id}/extraction-schema` ```bash theme={null} curl -X POST "https://www.useboom.ai/api/v1/initiatives/init_123/extraction-schema" \ -H "Authorization: Bearer $BOOM_KEY" \ -H "Content-Type: application/json" \ -d '{ "schema": { "fields": { "resolved": { "type": "bool", "label": "Resolved", "description": "True if the customer confirmed their issue was fixed by the end of the conversation, false otherwise." }, "issue_category": { "type": "enum", "label": "Issue category", "description": "The category that best matches the customer'\''s issue.", "values": ["billing", "bug", "how_to", "cancellation", "other"] }, "summary": { "type": "string", "label": "Summary", "description": "One sentence summarizing what the customer needed, in their own words.", "maxLength": 280 } } }, "notes": "v1: support triage fields" }' ``` This replaces the initiative's schema wholesale, so always submit the complete set of fields, not a diff. The response tells you whether it actually changed anything: ```json theme={null} { "initiativeId": "init_123", "version": 3, "changed": true, "fieldCount": 3, "notes": "v1: support triage fields", "createdAt": "2026-07-24T10:00:00.000Z" } ``` `changed: false` means the schema you submitted was identical to what was already current: no new version was created, and the `notes` you sent were not recorded (the response returns the prior version's notes instead). Read the current schema back with `extraction_schema_get` / `GET /api/v1/initiatives/{id}/extraction-schema`. It returns the schema in the same shape `extraction_schema_set` accepts, so you can fetch it, edit a field, and set it back. `hasSchema: false` and `schema: null` mean the initiative has none configured yet. > [!NOTE] > > Setting a schema reshapes how every conversation on the initiative gets > analyzed, so it is not a casual change. Over MCP it requires a role that can > edit initiatives (Owner, Admin or Member), the same as the Schema Builder in > the app. Over REST it takes a valid organization API key, and > any valid key can make this call: treat a key as full access to your > organization and scope who holds it accordingly. ## Reading the data back > **Per participant** > > `GET /api/v1/initiatives/{id}/participants/{participantId}` returns a > `values` array: one entry per captured field, each with `label`, > `valueKind`, a display `value`, and (when available) the `sourceQuote` > and `confidence` behind it. > **Listed across participants** > > `GET /api/v1/initiatives/{id}/participants` (paginated) returns each > participant's `values` as an object keyed by field slug, each value an > array of display strings, plus `capturedAt`. Each row also carries both > contact identifiers — `phoneNumber` and `email` — either of which can be > null depending on how the initiative reaches people. A participant enrolled > as a person in your People data shows that person's `name`, `email` and > `phoneNumber` — the `email` is the address its emails go to. > **In aggregate** > > `GET /api/v1/initiatives/{id}/data/summary` (`initiatives_summary`) > returns `participantCount` plus one entry per variable: `coverage` > (`answered`/`eligible`), and depending on type, an `average` (numeric > fields), a `distribution` (categorical fields), or `samples` (text > fields). `archived: true` marks a guiding question removed from a live > initiative: its past answers are still reported, but no new ones are > collected. > [!WARNING] > > These three endpoints return **every** variable captured on the > initiative, not only the fields in your schema. Boom's own default > signals and, where configured, guiding-question answers come back > alongside them. Match on the field slugs (and, in the aggregate summary, > the `source` field) to isolate what you defined. ## Using extracted values later Once a schema is set (that's what "publishes" it), its fields become addressable in journey message templates as `engagement.extracted.`, for example `engagement.extracted.issue_category`. This only resolves once the upstream conversation step that produces it has actually run; before that, the placeholder goes out blank. See [Template variables](/template-variables) for the full binding syntax and the trigger-by-trigger resolution matrix. ## Rules and limits * **A schema needs at least one field.** An empty `fields` map is rejected. * **Naming is the only structural limit.** No cap on how many fields a schema declares, but the slug rules above are enforced. * **Changing a schema doesn't retroactively update past extractions.** Engagements already extracted keep the values produced by whichever schema version was current when they ran. The new version only applies to conversations extracted after the change. Re-running extraction against old, already-closed conversations under a new schema is a dashboard/CLI operation today, not something the public API or MCP exposes. * **Extraction only runs on conversations with enough signal**: at least 3 total messages and at least 1 inbound message. Below that bar, a conversation is skipped rather than filled in with empty values. Plan for this when the audience is people who already went quiet: whoever never replies produces no fields at all, so coverage on a win-back reflects who answered, not who you reached. * **Repeat triggers don't repeat the LLM call.** Boom hashes the transcript together with the schema; if neither changed since the last extraction, it skips the call. Editing the schema changes the hash, so the next time that conversation closes (or a manual re-extract fires), it re-runs against the new fields. * **Scalar fields (`bool`, `int`, `enum`, `string`) always get a value.** Every field in the schema is required in the model's output. There's no built-in "unknown" or null state for these types; the model does its best with what the conversation gave it, confirming or updating any previously extracted value. List fields (`enum[]`, `string[]`) are the exception: the model is instructed to emit an empty array when the conversation gives no signal. ## Related > **[Template variables](/template-variables)** > > Bind `engagement.extracted.*` into a WhatsApp template placeholder. > **[One uniform surface](/one-surface)** > > How the same capability is exposed identically on REST and MCP. > **[Use MCP](/use-mcp)** > > Connect an AI tool to read and set extraction schemas conversationally. > **[API reference](/api-reference/overview)** > > Every Initiatives, Journeys, and Segments endpoint. # Hide sensitive customer data Source: https://docs.useboom.ai/hide-sensitive-data Choose which customer fields your AI agents and teammates without access can't read, from Settings › Sensitive data. Some customer data shouldn't reach an AI agent or every person on your team: a tax ID, the last digits of a card, a home address. In Boom you mark those fields **Hidden**. The value stays stored and keeps working, but agents read `[redacted]` in its place and teammates without access see a **Hidden** lock. Nothing is hidden until you hide it. > [!NOTE] > > Sensitive data is turned on per workspace. Ask Boom to enable it for yours. > Until then, the settings page doesn't appear and the API answers > `sensitive_data_unavailable` when you try to hide a field. > [!TIP] > > Looking for how to keep parts of **your own app** out of what the web > widget's in-app expert reads? That's a different feature: > [Protecting sensitive data](/protecting-sensitive-data). ## Hide a field 1. Go to **Settings › Sensitive data**. 2. Find the field. The page has three sections: * **Built-in fields**: email, phone number, first name and last name, the contact fields every person has. * **People attributes**: extra fields from your integrations and imports. Search by key, or filter by **Hidden** or **Looks personal**. * **Custom objects**: fields on records like orders, policies or accounts. 3. Turn on its switch. The field is hidden right away; there's nothing to save. To show a field again, turn its switch off. Boom asks you to confirm, because agents and teammates without access can read it again from their next message. **External ID can't be hidden.** Boom uses it to match people across your systems. ### Hide the copies too Hiding a built-in field doesn't hide an attribute that holds the same data, such as an `email` attribute next to the built-in email. When you hide a built-in, Boom lists attributes that look like copies of it. Choose **Hide these too** to hide them in one step, or **Not now** to leave them visible. New attributes from a sync start visible, and a sync never changes what you've hidden. ### Who can change it Only **Owners** and **Admins** can hide or show fields. Other roles that can open settings see the page read-only. ## What your agents see Every AI agent in Boom (the agent in your conversations, voice calls, the in-app assistant, and AI tools connected over MCP) gets `[redacted]` in place of a hidden value. The agent knows the field exists, so it never asks the customer to repeat it and never tries to guess it. If it needs the value, it escalates to your team. One exception: in a conversation, the agent still knows the name of the person it's talking to, so it can greet them. Hiding names hides them everywhere else, including when an agent looks up or reads about other customers. An agent also can't work out a hidden value indirectly. If it tries to filter, group or list the distinct values of a hidden field, Boom refuses. First and last name are hidden separately. Hide only the last name and agents see `Ana [redacted]`. A stored full name Boom can't split safely, such as a WhatsApp profile name, is hidden whole. ## What your teammates see | Role | What they see | | - | - | | Owner, Admin, Developer | The value | | Everyone else | A **Hidden** lock instead of the value | You can see the full list on **Settings › Organization › Roles**, in the **Sensitive data (hidden fields)** row. With only the last name hidden, teammates without access see "Ana" with a **Hidden** lock next to it. This includes the drawer for a single run: its header, the run's stored data on the **Data** tab (including computed attributes built from a hidden field) and the details of each step on the **Activity** tab. A customer reply quoted on the **Activity** tab is part of the conversation and stays visible, like the conversation itself. ### In the inbox The same rules apply to the conversation list, the conversation header, and the customer panel for teammates without access: * **Name**: a hidden last name shows "Ana" with the lock. A fully hidden name shows the customer's phone or email instead, if those are visible, or the lock. * **Phone** in the conversation list and header: only the last four digits are shown, for example `•••• 0100`, so your team can still tell conversations apart. In the customer panel, a hidden phone shows the lock. * **Email** in the customer panel: the lock. * **Extra contact info** added to a customer (a second phone or email) follows the same setting as the customer's own phone or email. * **Search** still finds a conversation by a hidden name or number, but the results show it hidden. Because search still matches, a teammate can confirm a guess: typing a number they suspect and seeing the conversation come up, or comparing it with the last four digits shown, tells them they were right. * **Notifications** for new messages show the hidden name the same way. * **Settings › Logs** and **Agent Studio** show message recipients and customers the same way. * A customer name that is really an email address or phone number (for example from a guest checkout) is hidden along with that email or phone. **Message content stays as written.** Hiding a field never changes the text of a message: neither what customers write nor what is sent to them (AI replies, templates, and your teammates' messages). If a message contains a hidden value, teammates can read it in the conversation. Replying and sending templates keep working. If a template variable is filled in from a hidden field, the inbox shows it as hidden while you prepare the message, and Boom sends the real value. The sent message then shows what was sent, like any other message text. ## What keeps working Hiding a field changes who can **read** it, not how Boom uses it. * **Segments** you build in the dashboard can still filter on hidden fields. * **Journeys** can still send a hidden field in a message. The builder warns you, because sending it puts the value somewhere new: * `SENSITIVE_FIELD_SENT`: a message (WhatsApp, SMS, email, Instagram, Messenger) puts the value in the conversation, where the agent and inbox users can read it. * `SENSITIVE_FIELD_SENT_EXTERNAL`: an HTTP request sends it to an external URL. * `SENSITIVE_FIELD_MAPPED`: a route-to-initiative step copies it into another initiative's context. These are warnings only. They never block publishing. ## Exports * A **segment CSV export** by someone who can't see hidden values is refused with `hidden_fields` while any built-in field is hidden. * **Initiative participant exports** write `[redacted]` in place of a hidden name or phone for people without access. ## Hide fields with the API or MCP You can also hide fields with the REST API (`GET` and `POST /api/v1/cdp/sensitive-fields`) or from an AI tool connected over [MCP](/use-mcp). An AI tool can only **hide** a field, never show one again. See [Sensitive fields](/cdp/sensitive-fields) for the request format and the full reference. ## Not covered yet A few screens still show the real value to teammates who can open them: * An initiative's **Contacts** tab. * Pending enrollment requests in the agent tester's **Run as** picker. * Computed segment columns whose formula reads a hidden field. * The inbox's **New message** contact picker. * The caller's name when an incoming WhatsApp call rings. * A customer's **Instagram @username** in the inbox customer panel. * Message text, in either direction: messages stay as written, including a template variable filled in from a hidden field. For what an agent can still see, such as the values typed into an existing segment filter, see [Known limitations](/cdp/sensitive-fields#known-limitations). # Inbound conversations Source: https://docs.useboom.ai/inbound When a customer writes first, the same agent answers, with the same context it uses for outreach. Everything else in these docs starts with you reaching out: an audience, an initiative, a journey that sends the first message. Inbound is the other direction, and it needs none of that. When a customer messages your WhatsApp number, your SMS number or your Instagram account without being enrolled in anything, the conversation is answered by **the same agent** that runs your outreach. Same knowledge base, same policies, same voice. It is not a separate support bot with its own configuration to keep in sync, which is the point: an agent that already knows your business, your product and your rules can handle someone asking a question just as well as someone answering one. By default no journey is involved: the agent simply answers, using everything it knows about your business. But a journey can also *start* from an inbound message — see [Routing by keyword](#routing-by-keyword) — which is how you put a specific flow in front of a specific question. Those are not the only ways in. The [web widget](/web-widget) puts the same agent on your own website, and everything on this page applies to it — the answering settings, the escalation path, and the inbox. ## Where it goes The agent answers, and can [hand off to a person](/the-agent) at any point in the conversation, not only at a step someone designed in advance. Escalated conversations land in the shared inbox, where a person replies with the whole history in front of them. That is the difference worth internalizing. In outreach, escalation is a node you place. Inbound, escalation is a judgment the agent makes whenever the conversation calls for it. ## What has to be configured Answering inbound is a setting, not a default. Each channel resolves its own answering behavior, falling back to the organization when the channel says nothing: whether the agent replies at all, and which agent replies. You set both in the Boom app, not through this API. A channel can be set to reply even when the organization's default is not to. The behavior when this is unset is deliberate, and it is worth knowing before you go looking for a bug: > [!NOTE] > > If no agent is configured to answer a channel, or the configured agent is > disabled, an inbound message is received and stored, and nothing replies. It > waits in the inbox for a person. Boom does not pick an agent on your behalf, > because guessing which agent should speak for you is worse than staying quiet. So "we pointed our number at Boom and the agent never answered" is almost always this: the channel has no answering agent set, or the one it has is off. ## Routing by keyword A journey's entry step can listen for *"a message arrives on these channels"*, optionally narrowed to messages that say something in particular. When one arrives, the sender is enrolled in that journey **before the agent replies**, so the reply already comes from inside the journey's instructions rather than from the default agent. Four kinds of channel can start a journey this way: a **WhatsApp** number, an **SMS** number, a native **Instagram** account, and a **Facebook Messenger** Page connected in Boom. The authoring is identical for all four: the picker lists whichever you have connected. You configure this on the entry step in the journey builder, in three steps: pick the channels, then choose what the journey **answers** — every message on them, or only messages that say a particular word — then test a message to see who answers it. > [!NOTE] > > **Instagram story tags.** When someone tags your account in their Instagram > story, that arrives as a message with no text. By default the agent does not > reply to one, because a story tag is not a question addressed to you. If a > journey listens on that account, the journey decides instead: branch on the > **Kind of customer's last message** field being `story_mention` to handle or > ignore it explicitly. Choosing *Keyword* gives you a list of words. Type one and press Enter — or a comma — to add it; each becomes its own rule, and any one of them enrols the sender. Once added, the word appears in the form it will actually be compared in, so you can see the folding. | How it has to match | What it means | | - | - | | Only this word | The whole message has to be just the keyword. | | This exact phrase | The keyword's words appear together, in order, anywhere in the message. | | All of these words | Every word appears somewhere, in any order. | **Several journeys can listen on one number, as long as their keywords differ.** Exactly one journey answers any given message, and the most specific rule wins: a keyword beats a journey that answers everything, a narrower match mode beats a broader one, and a longer keyword beats a shorter one. The third step shows you which journey wins a message you type, against the real rules, before publishing. Two journeys cannot claim the *same* number and the *same* keyword — the second one could never fire, so publishing it is refused rather than silently accepted. **Two journeys both set to answer *every* message on one number is the same case**, and the most common one: the builder warns you as soon as you pick the number, and the way out is to give one of them a word. > [!NOTE] > > Keywords are compared loosely on purpose: capitals, accents and punctuation are > ignored, so `¿PRECIO?` and `precio` are the same keyword. Accent folding also > treats `ñ` as `n`, so a keyword that has to tell `año` from `ano` is not > expressible. The builder shows you the compared form once a word is added. Two limits worth knowing before you rely on this: * **A photo or file with no caption has no text**, so it can only reach a journey that answers every message on the number. If every rule on a number carries a keyword, an image enrols nobody and the agent answers normally. * **Someone already in a journey with you stays there.** A keyword does not pull a customer out of a conversation they are already having — they continue in their current journey, and the agent answers them. "Only this word" is the strictest option and rarely what you want: real customers write "Hola, quería saber el precio" far more often than "precio" on its own. ## What you can read back Inbound conversations are readable the same way any conversation is, through the transcripts and messages endpoints on the [API](/api-reference/overview) and over [MCP](/use-mcp). One asymmetry to plan around: [extraction schemas](/extraction) are defined on an initiative, so structured fields come from conversations that belong to one. A customer who writes in while enrolled in an active initiative is answered inside that conversation and is covered by its schema. A customer who writes in cold belongs to no initiative, so there are no typed fields for that conversation, only the transcript and whatever your team records from the inbox. ## Testing with your own number While you are integrating, the same conversation accumulates every edge case you try, and the agent's replies get worse as it grows. Two commands reset it from WhatsApp itself. | Command | What it does | | - | - | | `/new` | Starts a new session. The agent forgets the live conversation; earlier sessions stay as background. | | `/reset` | Deletes the conversation, your engagements and the agent's memory. Journey re-enrollment is unblocked and you look like a new contact. | > [!WARNING] > > `/reset` is permanent. It deletes the conversation, every engagement for that > customer in the organization, and the answers extracted from them. There is no > undo. The message has to be exactly the command. `/reset now` or `please /reset` is an ordinary message and reaches the agent normally. `/reset` is not a full wipe. Your CDP person, its event history and your initiative participation all survive, so a journey that branches on CDP data still sees a returning customer. Both commands only work from a number on your organization's test-number list. Add yours under **Settings → Test numbers**. Entries expire — renew or remove them there — and a number that is not on the list gets a normal agent reply instead. ## Related > **[The agent](/the-agent)** > > The agent that answers, how the knowledge base briefs it, and when it hands > off to a person. > **[Journeys](/journeys)** > > The other direction: a flow you author to start a conversation. > **[Web widget](/web-widget)** > > The same answering behavior, reached from your own website. > **[WhatsApp templates](/template-variables)** > > Why outreach needs an approved opener and inbound does not. > **[Use MCP](/use-mcp)** > > Read conversations and transcripts from an AI tool. # Initiatives Source: https://docs.useboom.ai/initiatives One outreach mission: who you reach, what the agent is trying to accomplish, and what it comes back with. An initiative is one mission you send the agent on. It holds the objective, the briefing the agent works from, the people it reaches, the template it opens with, and the [fields it should extract](/extraction) from every conversation. Almost everything else in these docs hangs off one. What the mission is, is your call. A win-back, a support follow-up, an onboarding nudge, a research round, a qualification pass. Boom does not ask you to pick a category, and nothing in the API changes based on the job you have in mind. The objective and the briefing are what make it that job. ## The fields that decide quality Only `name` is required. In practice three fields decide whether the conversations are any good, because they go straight into the agent's prompt: | Field | What it does | | - | - | | `objective` | What the agent is trying to accomplish. One goal, stated plainly, not a topic and not a question list. | | `context` | A Markdown briefing, up to 20,000 characters: who these people are, what happened before this conversation, what is true about your business that the agent needs. | | `guidingQuestions[]` | What every conversation should come back having covered. The agent decides how and when to ask, and follows up on its own. | The rest tune behavior: | Field | What it does | | - | - | | `language` | Defaults to `es`. | | `channel` | Read-only in practice: **what an initiative sends on is decided by its [journey](/journeys)**, not by this field. Responses report `channel` as the identifier people are reached by — `EMAIL` when the journey only mails, `WHATSAPP` when anything phone-shaped sends — and `sendChannels` as the full list (including SMS and the Meta DMs, which `channel` cannot express). The value you pass at creation is only used until the initiative has a journey. | | `maxAttempts` | Outreach attempts per participant when someone does not answer, 1 to 5, default 3. Size the [journey](/journeys) you build to match it, with one approved follow-up template per extra round. | | `isRecurring` | Keeps processing participants you add later, on a schedule, instead of ending after one pass. Default false. | | `transactional` | Creates a Transactional instead of a campaign or initiative: one event sends a WhatsApp and/or email template: one notification per event, or one per value of `parallelRunsBy` when you set it. Pass `whatsapp` (`templateId` and `channelId`), `email` (`templateId`), or both, and optionally the rest of the setup in the same object: `eventName`, `parallelRunsBy`, the variable bindings (`whatsapp.templateBindings`, `email.bindings`) and `reviewBeforeSending` (hold every message in Drafts for approval). See [Send transactional messages](/transactional-messages#do-it-from-code-or-an-agent). You cannot combine it with `isRecurring: true`; that request is refused with `400 transactional_not_recurring`. A request that cannot be set up, such as an unapproved template, is refused with `422 transactional_not_ready`. `GET /api/v1/initiatives/{id}/transactional` returns its setup and what still blocks launch; `PATCH` the same path changes it. | | `campaign` | Creates a one-time campaign: one email and/or WhatsApp message to an audience, once, at `sendAt`. Pass `audience` (`{ "kind": "manual" }` for people you add, or `{ "kind": "segment", "segmentId", "readAtSend" }`), `sendAt` (omit or `null` to send when scheduled), `email` and/or `whatsapp`, and optionally `reviewBeforeSending`. The journey is built for you. You cannot combine it with `isRecurring: true` (`400 campaign_not_recurring`) or with `transactional` (`400 campaign_and_transactional`). A campaign that cannot be set up, such as a send time in the past, is refused with `422 campaign_not_ready`. Campaigns are being enabled organization by organization; until yours has them, the request is refused with `400 campaigns_unavailable`. `GET /api/v1/initiatives/{id}/campaign` returns its setup and what still blocks scheduling; `PATCH` the same path changes it. See [Campaigns](/campaigns). | | `flagCondition` | A condition in plain language. When a conversation matches it, the conversation is flagged for your team to look at. | | `identityDeflection` | How the agent answers when someone asks whether it is a person. | | `smartSendingMode` | What happens to this initiative's messages when your organization caps how often one person is messaged. `FORCE` always sends, `DROP` skips a message that would go over the cap, and `DEFER` (the default) currently behaves like `DROP`. See [Smart Sending](/smart-sending). | | `contextSchema` | Declares the per-participant variables you will pass when enrolling, so the agent can personalize. Participant `context` keys are validated against it. Set through the CSV upload in the app, not over the API. | An initiative is created as `DRAFT`. A draft is fully editable. **After launch, what the agent says stays editable** — `name`, `objective`, `context`, `guidingQuestions`, `flagCondition` and `identityDeflection` — and the edit reaches the next turn of every conversation already open, with no publish step. That is the intended way to fix wording mid-run. `smartSendingMode` also stays editable, and applies to the next message the initiative sends. Responses include `isTransactional`, true for a Transactional. `GET /api/v1/initiatives` returns every kind when you pass neither `isRecurring` nor `isTransactional`. Passing `isRecurring` alone leaves Transactionals out, and `isTransactional=true` returns only Transactionals. A launch of a Transactional whose setup is incomplete is refused with `journey_not_ready`. A Transactional starts only from its event, so send each event with the single-event endpoint (`POST /api/v1/cdp/events`). Batch event recording stores the events but does not trigger enrollment. You cannot add people to a Transactional by hand; that is refused with `422 transactional_event_only`. Everything else — `channel`, `language`, `maxAttempts`, `isRecurring`, `contextSchema` and the voice and reward settings — is draft-only, because it cannot change under conversations already in flight. A `PATCH` that names one of them on a launched initiative fails with `409 initiative_not_draft` and lists the fields it refused, changing nothing. A `COMPLETED` or `CANCELED` initiative is frozen outright. One edit a launched initiative refuses even though the field is editable: **clearing `context` or `objective`**. Both are required to launch, so emptying one would leave the agent with no brief on conversations already open. Sending `""` (or only whitespace) fails and changes nothing; send the new text instead. **Dropping a guiding question** from a launched initiative that already collected answers for it **archives** the question rather than deleting it. The agent stops asking it from the next turn and no new answers are extracted, but the answers already collected are kept: they still appear in insights, the data view, CSV exports (column marked `(archived)`) and the data summary (`archived: true`). A question with no answers yet, or any question on a draft, is deleted outright. An archived question can't be restored or edited; sending its old `id` again creates a new question. `initiatives_update` takes `guidingQuestions` as the **complete set**, not a patch. Omit the field to leave the questions alone. When you send it, carry each existing question's `id` (from `initiatives_get`) to edit it in place — a question you leave out counts as dropping it, which on a launched initiative archives it (see above). So the round-trip is: read, edit the array keeping every `id` you mean to keep, write. Within a question you send, a field you leave out keeps its current value. That applies to `answerType`, `scaleMin`, `scaleMax` and `options`: an edit that names only `id` and `questionText` re-words the question and changes nothing else, so a multiple-choice question keeps its type and its answer options. To change one of them, send it — including `options: []` to clear the options, and a new `answerType` to retype the question, which discards options that no longer apply. `OPEN` is the default only for a question you are adding. ## Lifecycle ``` DRAFT ──launch──► ACTIVE ──► COMPLETED │ │ │ ├── content editable │ └── cancel, archive ├── fully editable └── archive ``` Archiving only hides an initiative from the default list — it changes nothing else, and unarchiving restores it. A draft created by mistake can be archived directly; it does not have to be canceled first. A running (ACTIVE or PAUSED) initiative cannot be archived: pause, complete or cancel it first. Launching an archived draft un-archives it, so a live initiative is never hidden from the list. Launching starts real outreach to real people. It is the one call here you cannot take back, and it is gated accordingly. ### What "ready to launch" means A launch that is not ready fails with a specific code rather than a generic error, which tells you exactly what to fix: | Code | What it means | | - | - | | `409 initiative_not_draft` | It already launched, or it is cancelled or archived. Only a draft launches. | | `422 no_outreach_template` | Round one has no approved, active WhatsApp template linked. An initiative cannot open a conversation without one. | | `422 journey_not_ready` | The journey behind it failed validation at publish. The response lists the issues, and you fix them with the journey tools before launching again. | | `422 initiative_not_ready` | A required field is missing, or rewards are not set up. The message names what is missing. | > [!NOTE] > > Launching **publishes the initiative's journey for you**, on WhatsApp and > email alike, so you do not need a separate publish call. You do have to have > built that journey first: a new initiative does not come with one, and a > launch without one is refused. On WhatsApp, a missing channel > on the opening message is filled in automatically when there is only one > sensible answer: the initiative's own channel, otherwise the organization's > primary, otherwise its only sendable one. > [!WARNING] > > **Launch before you enroll.** Adding participants requires an initiative that > is already `ACTIVE` with a published journey, so enrolling first fails with > `initiative_not_active`. Launching with nobody enrolled sends nothing, which > makes it safe to do first. Every person you add after that receives a real > message right away, so add one test contact and read what arrives before you > add the rest. ## Participants Participants exist only inside an initiative, addressed under `/initiatives/{id}/participants`. There is no global participant list, and there is no delete: stopping a participant halts their outreach and keeps the data. A `participantId` is one person's single pass through the journey, so a person enrolled twice has two of them. It is the same id [`journey_run.*` webhooks](/webhooks) report as `run.engagementId` — capture it there to stop a specific run later without listing participants at all. Each person needs the identifier of the initiative's channel: `phoneNumber` in E.164 format on WhatsApp, `email` on an email initiative. The other one is optional. A row without the channel's identifier comes back in `errors` as `missing_phone_number` or `missing_email` and is not enrolled; every other row in the request still goes through. Each added row echoes the `phoneNumber` and `email` you sent for it, `null` when you sent none. Enrolling sends a real message. People on your Do Not Contact list are skipped by the platform rather than by whoever wrote the flow, on both channels, so a suppressed contact enrolled by mistake is not contacted and comes back as `contact_suppressed`. ## Reading results Three read paths, all covered in [extraction](/extraction): the aggregate summary for the whole initiative, the per participant record with its extracted values, and the full transcripts. ## Related > **[Journeys](/journeys)** > > The flow an initiative runs, and how to shape it when the default is not what > you want. > **[Extraction](/extraction)** > > The typed fields every conversation should yield. Set this before launching. > **[Segments](/segments)** > > Define who the initiative reaches, and remember to evaluate it. > **[Inbound conversations](/inbound)** > > The other direction, where no initiative is involved. # Documentation Source: https://docs.useboom.ai/introduction Bring your customer data into Boom and turn it into AI conversations with your customers. One platform, two matching surfaces: REST and MCP. Documentation Build with Boom Guides, videos and the API reference for Boom. [Start hereWhat Boom is and how it works](/start-here) [QuickstartYour first API calls](/quickstart) [TutorialsShort videos of the app](/tutorials/overview) [Connect your databaseSync from PostgreSQL or MySQL](/connect-your-database) [Use MCPConnect any AI agent](/use-mcp) [API & CDP referenceEvery endpoint, REST and MCP](/api-reference/overview) # Journeys Source: https://docs.useboom.ai/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 Mexican, Argentine or Brazilian mobile number matches whether it is stored with or without the mobile prefix, such as +52 and +521). A new customer is created with the person's email, phone and name, and is marked as created by Boom so a retried step never counts it as a customer the store already had; 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; the step never unsubscribes anyone, and a customer who unsubscribed in Shopify stays unsubscribed. If the person later withdraws consent in Boom, the customers this step subscribed are unsubscribed in Shopify for the same channels. 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 | | `LOAD_OBJECT` | **Look up record**: loads one record from your custom objects (a merchant's brand, an order) and saves it under a name you choose, like `brand` or `order` | Later steps use its fields directly, e.g. `{{brand.logo_url}}` and `{{brand.primary_color}}` in an email, or `{{order.total}}` in a WhatsApp message — one template works for every merchant. Find the record by an ID the run carries (for example `{{event.order_id}}`) or by how it is linked to the person, up to three links away (person → merchant → merchant brand). Pick the fields to load; publishing checks that every field a later step uses is loaded. The run always continues: when nothing is found, the fields are empty and your templates use their fallback values (`{{brand.found}}` tells you which). The record is read when the run reaches the step, so place it right before the step that uses it. 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, a live CDP attribute, or whether the person did any of a set of events since entering the journey | Both `YES` and `NO` must be wired before publishing. The *since entering* check (in the builder: **Customer did … since entering**) counts only events after the one that started the run, so the trigger itself never counts; when the run's person can't be identified it reads as false whichever way it is set, so put the send on the branch that needs a definite answer | | `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). ## Build a journey **1. 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. **2. 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`. **3. 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:`, 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. **4. 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. **5. 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. **6. Publish** `journeys_publish` with `confirm: true`. This is the one action here that starts real outreach. ## 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. A connected Shopify store also produces events Boom records for you. **Checkout started** and **Cart created** come from the store's own notifications. **Visited the store** (`shopify_page_viewed`) and **Viewed a product** (`shopify_product_viewed`) come from the store's Boom pixel, once **Storefront tracking** is turned on for the store (on its page under Integrations; early access, ask your Boom contact). Pixel events are recorded for logged-in shoppers, and for visitors who agreed to hear from you in a Boom pop-up or form on that browser (within seconds of submitting; earlier browsing is not recovered). A run starts for a logged-in shopper only when their email matches the person Boom holds, and for a pop-up or form visitor on the browser they submitted from. Each Shopify event appears in the trigger picker once your organization has recorded one. Abandonment flows pair one of these triggers with a delay and a *since entering* `DECISION`: "viewed a product, then did not add to cart within an hour". * **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.`, not `attributes.`, 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 > **[Use MCP](/use-mcp)** > > Connect an AI tool to author and run journeys conversationally. > **[Template variables](/template-variables)** > > The full variable catalog a SEND\_MESSAGE binding can reference, and what resolves per trigger. > **[Events](/events)** > > Record the CDP events that trigger a journey or feed a DECISION condition. > **[One uniform surface](/one-surface)** > > Why every journey tool here has a matching REST endpoint. # Lead Capture Source: https://docs.useboom.ai/lead-capture A pop-up for your own site that collects emails and WhatsApp numbers, hands out a promo code through a small game, and starts a journey. Lead Capture puts a pop-up on your website that asks visitors for their email and WhatsApp number and gives them a promo code in return. You can show the code straight away, or make it a game: **pick 1 of 3** cards, **spin the wheel**, drop a ball down a **Plinko** board, **scratch** a card, pull a **slot machine**, or open **1 of 3 gift boxes**. Every submission becomes a lead in your [CDP](/cdp/overview) people, marked `source: lead_capture`, and a `lead_captured` [event](/events), so a [journey](/journeys) can welcome the lead on WhatsApp or email within seconds, while they still remember you. > [!NOTE] > > Lead Capture is an add-on. If you don't see **Lead capture → Pop-ups** in > the Boom app's left sidebar, ask your account manager to turn it on for your > organization. ## Create a pop-up In the left sidebar, open **Lead capture → Pop-ups** and choose **New pop-up**, pick a template, and give it a name and the site it will run on. Templates are finished pop-ups (a big coupon, a newsletter sign-up, a full-screen offer, the wheel and the other games) with their design and text already in English, Spanish and Portuguese; choose **Blank** to start plain. Either way you only add your prizes and codes. New pop-ups start **Paused**, so you can design one without it showing to anyone. The pop-up's page is a live preview with the settings beside it. The left sidebar lists its sections, grouped under Setup, Look, Engage and Results; pick one and its settings open next to the preview. A blue dot on a section means it has unsaved changes, and an orange dot means something there needs fixing. Switching sections keeps your unsaved changes, and **Save** saves them all at once. On a phone the settings come first. Tap **Preview** at the top of the settings to see the pop-up, and **Back to settings** to return. | Section | What you set | | - | - | | **Install** | Status, how to install it (Shopify, script tag or your own button), and the sites allowed to load it. A checklist shows what is still missing before it can go live. | | **Design** | **Browse templates** (replaces the design and text, keeps your prizes, sites and timing), a one-click **look**, then fine-tuning: layout (centered, split, corner card or full screen), where the image goes (inside, top banner or background), your brand colors, one of twelve fonts, button shape and corner roundness. **On phones** it opens from the bottom: as tall as each step needs, half the screen, or the whole screen. | | **Content** | Every piece of text, in English, Spanish and Portuguese — including the big **offer** line ("\$250", "15% OFF"). Wrap words in `*asterisks*` to highlight them. Anything left blank uses the English text. Lines that start with `- ` in a body become a bulleted list. Optional small lines: one above the headline (shown on the quiz and the first form step too), one above the prize, and a label on the side photo ("SAVE UP TO 15%"). Under **Design → Highlighted words**, choose coloured text or a marker behind the words. At the end, **Under the code** adds a small table below the code, for example a volume offer ("−10%, 2 products", "−15%, 3 or more"). | | **Form** | Which fields to ask for, the phone field's default country, an optional **ask first** screen (one "yes" before the form), and what happens after: show the code (optionally with a button that applies it in your store) or send it by message. | | **Trigger** | When it opens: after a delay, after scrolling part of the page, whichever of those two comes first (e.g. 8 seconds or 40% of the page), when the visitor is about to leave, or only when they click a button you add. Whether it shows on phones, and how many days to wait before showing it again: counted from when someone closes it, or from any time it was shown ("once every 14 days"). | | **Quiz** | Optional: up to three questions before the form, one tap or several picks each. See [Quiz](#quiz). | | **Prizes** | The game, the **order of screens** (form first, or play first — see [Play first](#play-first)), and the prizes it can land on, each with its promo code and odds. | | **Leads** | How many people it has collected, how many in the last 7 days compared with the 7 days before, and how many opted in. **See leads in People** opens your People list filtered to this pop-up. | ## See it live before installing Above the preview, **See it live** opens the pop-up in a new tab on a sample store page, working exactly as it will on your site: it opens on its trigger (after the delay, on scroll, on exit), the tab shows, and every step runs, the game included, through to the code. It works while the pop-up is **Paused**, before you install anything and whatever sites you allowed. The bar at the top of that page has: * **Open now**: open the pop-up without waiting for its trigger; * **Reset**: forget that it was seen, closed or filled in, so it plays again from the start; * **Desktop / Mobile**: see it at a phone's width. **Nothing entered there is saved.** No lead, person or event is created, no journey starts, and your live pop-up's limits are untouched. The codes it shows are your real codes, as a visitor would see them. It shows the **saved** version: save your changes first to see them. **Copy link** copies a link to the same page that you can send to someone without a Boom login, such as a client approving the design. It works for **7 days**. Rotating the pop-up's key (Install) turns off every link shared before. ## Install The **Install** section has a tab for each way to install the pop-up. If your Shopify store is connected to Boom, the **Shopify** tab comes first; otherwise you start on **Script tag**. **Rotate key** sits below the tabs and changes the key in all of them; the confirmation explains what stops working. ### Shopify Stores connected to Boom through their own Boom Shopify app do not need to edit any code. The **Shopify** tab has two steps: 1. **Copy your key** (the `blc_live_…` value). 2. **Open theme editor.** The button opens your live theme on **App embeds** with **Boom Chat Widget** already switched on. Paste the key in **Lead Capture install key**, then click **Save**. If you don't see a Lead Capture setting, open **Don't see Lead Capture?** in the tab: * **Turn the embed on yourself:** App embeds → **Boom Chat Widget**. In a Shopify admin in Spanish, that's **Tienda online → Temas → Personalizar → Incrustaciones de aplicaciones**. * **No Lead Capture setting at all?** Your app needs updating; ask Boom. * **The same embed has Enable AI Chat** (on by default) **and Enable WhatsApp.** Leave both off unless you use Boom's chat on your store, or turning the embed on also shows a chat bubble. Your store's domain still has to be in **Allowed sites** (see below). ### Script tag Copy the tag from the **Script tag** tab and paste it at the end of the page body on every page that should show the pop-up: ```html theme={null} ``` It is separate from the [web widget](/web-widget)'s tag; you can use either or both. Like the widget's, the install key is public and identifies the pop-up — it does not authorize anything. Then add every site that hosts it under **Allowed sites**. Matching is exact: `https://example.com` does not cover `https://www.example.com`. On any other site the pop-up does not open, and your browser's console says why. ### Open it from your own button The **Your own button** tab has a ready-made sample. Any button or link with the pop-up's install key in `data-boom-lead-capture-open` opens it when clicked, whatever its trigger: ```html theme={null} ``` The tag above must still be on the page. A button opens the pop-up even for a visitor who closed it or already signed up, because they asked for it. Every entry records a `lead_captured` event, a repeat one included (`repeat` is `true`), so give a welcome journey a frequency cap or branch on `repeat` to avoid welcoming the same person twice. Set the trigger to **On click** if it should only ever open this way. That is also the choice for a page where search ranking matters: a pop-up that covers a page as soon as it loads on a phone can cost it. ### Single-page apps On a site that changes pages without reloading (React, Next.js, Vue), the pop-up stays mounted across those changes, tab included. To show it on some pages only, take it down when the visitor leaves them and bring it back when they return: ```js theme={null} // Leaving the page: removes the tab, any open pop-up and its trigger. window.__BOOM_LEAD_CAPTURE_DESTROY__?.('blc_live_YOUR_INSTALL_KEY'); // Back on the page: mounts it again. The tag does not need to load twice. window.__BOOM_LEAD_CAPTURE_RUN__?.('blc_live_YOUR_INSTALL_KEY'); ``` Taking it down is not a dismissal, so it does not start a cooldown counted from dismissal. Bringing it back re-arms its trigger, so a delay or scroll pop-up can open again on each return; **On click** never does. Leave out the key to take down every pop-up loaded so far; bring each back by its key. ## Apply the code in your store Under **Form → After the form**, add an apply-code link and the prize screen gets a button that opens your store with the code already applied. Write `{code}` where the code goes. For Shopify: ``` https://your-store.com/discount/{code} ``` ## Going live **Set Active** is refused until the pop-up can keep its promise: * at least one allowed site; * it asks for an email or a WhatsApp number; * a game has at least one prize; * if it asks for a WhatsApp number, it has a consent sentence; * if it plays first, it has a game. While it is Active, changes that would break one of these are refused too — pause it first. ## Prizes and games Each visitor gets **one** prize, drawn on Boom's side using the odds you set. The game only shows the result: whichever card or box the visitor picks, the wheel, the ball and the reels land on the prize already drawn, and the scratch card hides it. Nothing on your page can change what a visitor wins, and the promo codes are never sent to the page until a prize is drawn. Someone who enters again — same WhatsApp number, or the same email inbox, even with a `+tag` or Gmail dots — gets the prize they got the first time. > [!TIP] > > Wheel slices, Plinko slots, reels and gift tags are small. Lead with the value > in each prize's label — "10% off", "Free shipping" — or give it a short label. ### Play first By default the visitor leaves their details first and then plays to reveal the prize. Set **Order of screens** to **Play first** (in **Prizes**, once a game is picked) to turn it around: the visitor plays straight away, sees what they won ("You won! 10% off"), and then leaves their details to get the code. The **ask first** screen and the quiz, when you use them, still come before the game. The line over the form is the **Claim message** in **Content**; `{prize}` in it becomes the prize's name, e.g. `Leave your details to get your {prize}.` The prize is still drawn on Boom's side, when the game starts, and the code still reaches the page only after the visitor has left their details. After the form they see the code, or the message that it is on its way if you send it by message. Someone who entered before still gets their first prize, even if the game showed them another one, and the screen after the form shows the prize they actually get. If the prize cannot be drawn when the game starts (a network problem, for example), the pop-up falls back to the form first. ## Quiz Turn on the quiz to ask up to three quick questions before the form, such as "What would you like to work on?" with answers like Stress, Sleep or Energy. Each question has two to twelve answers, one question per screen. **One tap or several picks.** With **Answers they can pick** at 1, the visitor taps one answer and moves on. Set it higher, say 3 for "pick up to 3", and the answers show as tiles two to a row, with a counter ("2 of 3 chosen") and a **Continue** button that works from the first pick. Every answer is saved on the person under the question's **Saved as** name (filled in from the question; you can rename it), and on the `lead_captured` event, so a segment or a journey can branch on it. Answers are saved as written in English, the base language, whatever language the visitor saw, so a condition like `interest = "Sleep"` matches everyone. If the person already answered before, their first answer is kept. **Saved values.** Give an answer a **saved value**, a short code such as `sleep` or `hormones` (lowercase letters, digits, `_` and `-`), and that code is stored instead of the English text. A code survives rewording the answer, and a journey can build a link from it, e.g. `/collections/need-{{need_primary}}`. **Several picks are saved as a list**, in the order the visitor picked them, e.g. `need = ["sleep", "stress"]`, and the first pick also as `_primary` (`need_primary = "sleep"`). Segments can match any pick in the list ("need includes sleep"). Journey branches compare single values, so branch on the `_primary` one. The `lead_captured` event also carries `_labels`, the picks as the visitor read them, in their language, ready for a message: `{{event.need_labels}}` gives "sleep and stress" (or "sueño y estrés"). A question already live as one tap can't become multi-pick under the same name, since its saved answers are text: give it a new name. An answer can also have a **link**, for example the collection that matches it. The last screen then shows a button to it, with the words you choose for that answer, and the link reaches your journey as `link_url`. Use `{code}` in the link to apply the code as well, e.g. `https://your-store.com/discount/{code}?redirect=/collections/sleep`. When the code is sent by message, a link that contains `{code}` is left off the screen and only reaches the journey. > [!TIP] > > Keep it short: each extra question costs some visitors. One question that > changes what you send is worth more than three that don't. ## Consent When the pop-up asks for a WhatsApp number, it shows your consent sentence beside an unticked box, and the visitor has to tick it to continue. Boom stores, with each lead, whether they ticked it, the exact sentence they saw, the language it was in, and when. A visitor who leaves the box unticked (on a form that asks only for email) is still saved as a lead, but is **not** enrolled in journeys. The same goes for someone on your Do Not Contact list (in People), even if they tick the box: they are saved, but not opted in and not enrolled, and their event says `consent: false`. A form that asks only for email can show the consent sentence as a **notice** instead of a checkbox — submitting the form is then the agreement. Forms that ask for a WhatsApp number always use the checkbox. Each lead records which of the two it was. **Progress bar.** Turn on **Form → Progress bar** to show a thin bar on top with one segment per screen (each quiz question, each form step, the game, the code), filled up to where the visitor is. **A form in steps.** Under **Form → Steps** you can split the form into a few screens, for example your email first and then an optional WhatsApp step with a link like "I'd rather just use email". Each step has its own headline, button and consent sentence. The visitor's details are sent once, at the end; someone who skips the WhatsApp step, or closes the pop-up on it, is still saved with the email they gave. Consent is kept per channel: the email step's sentence (often a notice) opts them in to email, and the WhatsApp step always has a box to tick, which opts them in to WhatsApp. When both were agreed, the lead records both sentences. To link your terms and conditions, add the page under **Form → Terms and conditions link** and mark the words that should link with asterisks in the consent sentence (**Content**), for example `I accept the *terms and conditions*`. Those words open your page in a new tab, and each lead records the link as `consent_terms_url`. A pop-up with a terms link cannot go live until the sentence has marked words in every language you wrote it in. ## Use the lead in a journey Start a journey with the **`lead_captured`** event. It appears in the journey builder as soon as a pop-up is first set Active. Its properties are available as message variables: | Property | | | - | - | | `email`, `phone`, `displayName` | What the visitor entered | | `promo_code`, `prize_label`, `prize_id` | The prize they won | | `form_id`, `form_name` | Which pop-up | | `consent` | Whether they ticked the consent box | | `consent_method` | `checkbox` (they ticked it) or `notice` (submitting was agreeing) | | `consent_text` | The exact sentence they agreed to — your record of the opt-in | | `consent_terms_url` | The terms page the sentence linked to, when you set one | | `locale` | The language the pop-up was shown in | | `repeat` | `true` when this person had already entered | | `page_url` | The page they submitted from | | `link_url` | The link of the quiz answer they tapped, with the code filled in | | Your quiz names | Each quiz answer, e.g. `interest`; a multi-pick question gives a list plus `_primary` and `_labels` (the picks in words) | A typical welcome sends the promo code again on WhatsApp, then follows up if no order arrives. ### Before you publish * **Check for a WhatsApp number first.** `email` and `phone` are only there when the visitor gave them, so some leads arrive with an email and no number. Start the journey with a condition on `phone` **is set**: send WhatsApp on *Yes* and email on *No*. A WhatsApp step for someone with no number ends their journey there; an email step for someone with no email is skipped. * **One journey hears every pop-up.** The trigger is the event name, so a `lead_captured` journey receives leads from all your pop-ups. To treat them differently, add a condition on `form_name` (or `form_id`) right after the start. * **Publishing is going live.** The journey starts enrolling new leads the moment you publish it, so test the messages first. * Visitors who left the consent box unticked are saved as leads but never enter a journey. Anyone can type any email or number into a public form, so a submission never changes a customer you already have — not their details, opt-ins or journeys. Each visitor is a **lead** of their own (its ID starts with `lead_`, and it carries `source: lead_capture`), even when the email or number matches a customer. The same visitor coming back, on any of your pop-ups, is the same lead, and gets the prize they were first given. To target leads in a segment, use `source` is `lead_capture`. In the **Leads** section each person counts once, however many times they submit, and counts as opted in if any of their submissions was. The People list it opens is filtered by the `leadCaptureForm` attribute, which holds the name of the last pop-up or form the person used. Someone who later signs up through another one is listed under that one, and renaming a pop-up leaves its earlier leads under the old name. Seeing the list needs access to People; the counts do not. ### See which initiatives a pop-up starts The pop-up's **Initiatives** section (under Results) lists every initiative, campaign or transactional message whose journey starts on `lead_captured`, with its journey's status (Published, Draft or Stopped) and links to the initiative and to its journey. A journey starts for leads from **every** pop-up unless its first step is a condition on `form_id` (or `form_name`) whose "no" side ends the journey: then it is listed only under the pop-ups that condition lets through. A journey that names this pop-up in a condition is listed as this pop-up's; one that names none is marked **All pop-ups**. The Pop-ups list shows the same count next to each pop-up. Seeing the section needs access to Initiatives. ## From the API or an AI tool Everything above can also be done over [MCP](/use-mcp) or the REST API, with five tools: | Tool | Endpoint | Does | | - | - | - | | `lead_capture_forms_list` | `GET $BASE/lead-capture/forms` | Every pop-up with its status, game, allowed sites and install key | | `lead_capture_forms_get` | `GET $BASE/lead-capture/forms/{formId}` | One pop-up in full: its config, the install tag and button tag, and what still stops it from going live | | `lead_capture_forms_create` | `POST $BASE/lead-capture/forms` | Create a pop-up, paused, optionally from a template | | `lead_capture_forms_update` | `PATCH $BASE/lead-capture/forms/{formId}` | Restyle with a template, edit any part of the config, replace the allowed sites, set it Active or Paused | | `lead_capture_leads_list` | `GET $BASE/lead-capture/forms/{formId}/leads` | The leads one pop-up collected, newest first, with consent, prize and quiz answers | `config` on an update is merged into the current one: objects merge key by key, while a list (such as `prizes`) or a plain value replaces what was there. So `{ "copy": { "headline": { "en": "Spin to win" } } }` changes only the English headline, and sending `prizes` replaces the whole prize list. A field the config does not have, such as a misspelled one, is refused with its path instead of being ignored. The same rules as the settings page apply. Setting a pop-up **Active** is refused, with the reasons, until it is ready, and a change that would leave a live pop-up unable to deliver what it promises is refused the same way. Rotating the install key and archiving stay in the app. Creating and changing pop-ups takes the **Owner** or **Admin** role, as they are organization settings. Listing leads also needs access to customer data. The tools answer `lead_capture_unavailable` until Lead Capture is switched on for your organization. ## Your own analytics After a successful submission the page receives a `boom:lead-captured` event on `window`, carrying the install key and the prize id (no contact details): ```js theme={null} window.addEventListener('boom:lead-captured', (event) => { analytics.track('Lead captured', event.detail); }); ``` # Message logs Source: https://docs.useboom.ai/message-logs List what your organization sent and what happened to each message — delivered, failed, or blocked — over MCP or the API. Every message Boom sends or receives leaves a row with its delivery status and, when something went wrong, the reason. It is the same log the app shows under **Settings → Logs**, available to an agent or a script so you can review delivery in bulk instead of opening conversations one at a time. | Tool | Endpoint | Does | | - | - | - | | `message_logs_list` | `GET $BASE/message-logs` | List messages with their status, recipient and failure reason | Typical questions it answers: which numbers never received this week's send, who has blocked us, and whether a specific customer got their message. On an email, `recipient.email` is the address the email was sent to. A journey email goes to the person's own address, which can differ from an older address on the contact it is threaded under; when it does, `recipient.name` and `recipient.phone` are empty rather than that contact's. `search` matches the address too. ## Statuses are a lifecycle, not a verdict `SENT`, `DELIVERED` and `READ` all mean the message went out. They differ only in how far it got: | Status | Means | | - | - | | `PENDING` | Not yet handed to the provider | | `SENT` | It left Boom; no receipt has come back yet | | `DELIVERED` | It reached the recipient's device | | `READ` | The recipient opened it | | `FAILED` | It could not be delivered — see `errorCode` / `errorMessage` | > [!NOTE] > > To count what you sent, ask for `SENT`, `DELIVERED` and `READ` together. > Asking for `SENT` alone returns only the ones still unconfirmed, which on a > healthy account is a small fraction of what actually went out. **How far a message can get depends on the channel.** WhatsApp reaches `READ`. Email never does — there is no read receipt, so an email resting at `DELIVERED` is fully settled. And `PENDING` on an inbound message is normal and permanent: nobody sends a receipt for a message a customer wrote to you. ## Why a message failed On email, `errorMessage` carries the reason in words. On WhatsApp you usually get a numeric `errorCode` instead: | Code | Means | | - | - | | `63024` | The number is not reachable on WhatsApp — wrong number, or it never had an account | | `63049` | The recipient's provider chose not to deliver a marketing message | | `63032` | The recipient has blocked this business | | `63050` | The business is not allowed to message this recipient | Any other code is passed through unchanged. Rows sent before codes were recorded have neither field; that is expected, not a data error. ## Filtering ```bash theme={null} # What failed this week (the default window) curl "$BASE/message-logs?status=FAILED" -H "Authorization: Bearer $BOOM_API_KEY" # Everything sent since August, one page at a time curl "$BASE/message-logs?status=SENT,DELIVERED,READ&createdAfter=2026-08-01" \ -H "Authorization: Bearer $BOOM_API_KEY" # One customer's history, any channel curl "$BASE/message-logs?search=+5215500000001&channel=WHATSAPP,SMS" \ -H "Authorization: Bearer $BOOM_API_KEY" ``` > [!WARNING] > > To filter on **several** statuses or channels over HTTP, pass them as one > comma-separated value (`?status=SENT,DELIVERED,READ`). Repeating the > parameter (`?status=SENT&status=DELIVERED`) keeps only the last one and > answers `200` with a narrower filter than you asked for. Over MCP, a recipient's phone, email or name reads `[redacted]` when your organization marked that contact field sensitive, and `search` is refused with `400 sensitive_field` while any of them is hidden. The REST API returns them in full. `direction` defaults to `OUTBOUND` — a delivery status only means something for a message you sent. Pass `direction=INBOUND` for what customers sent you. ## The window, and how to widen it **With no dates, the read covers the last seven days** — a rolling window from the moment you ask, so the edge is a timestamp rather than a midnight. Every response says which window it used: ```json theme={null} { "range": { "from": "2026-09-22T14:31:07.221Z", "to": null }, "data": [ … ], "next_cursor": null } ``` Quote that range alongside any count you report — "48 failures" means something different over a week than over a year. The default is not a ceiling. Name `createdAfter`, `createdBefore`, or both, and that window is honoured however wide it is. Naming either one turns the default off entirely, so `createdBefore=2026-08-01` really does mean everything before August. **A bare date means the whole day, in UTC.** `createdBefore=2026-09-30` includes everything that happened on the 30th, not just the instant it began — so `createdAfter=2026-09-01&createdBefore=2026-09-30` is the whole of that range, with no missing final day. Pass a full timestamp (`2026-09-30T06:00:00Z`) when you want an exact instant; it is used as given. Once you start paging, the window is fixed. A `next_cursor` carries the range its first page resolved, so `range` is identical on every page of one result set even though the default floor is relative to "now". > [!NOTE] > > There is no total. Results page through `next_cursor`, and a page comes back > without counting the rows behind it — counting an unbounded log is far more > expensive than reading a page of it. To total something up, page to the end. ## What this log does not cover It lists messages, so it is not a delivery-rate report. A few send failures never create a row at all — an absent message is not proof that nothing was attempted. Message text is never returned; the log answers what happened to a message, not what it said. ## Related * [One uniform surface](/one-surface) — the same capabilities over REST and MCP * [Use MCP](/use-mcp) — connect an AI tool to your Boom organization # One uniform surface Source: https://docs.useboom.ai/one-surface Every capability is defined once and exposed identically over REST and MCP: same names, same inputs, same errors. Boom's public platform is a single set of **capabilities**. Each one (say, *create a WhatsApp template*) is defined once, with one input schema, one output shape, and one set of error codes, and then exposed everywhere: * **REST**: as an endpoint under `/api/v1`, authenticated with an [organization API key](/authentication). * **MCP**: as a tool with the same name and schema, authenticated with your Boom login ([set up in two steps](/use-mcp)). Whatever you learn on one surface transfers to the other: the field names, the validation rules, the pagination cursors, and the [error codes](/rate-limits-and-errors) are identical. An integration you prototype by chatting with an AI tool over MCP behaves the same when you port it to server-side REST calls. > [!NOTE] > > This documentation is generated from the same schemas the API validates > against. The reference pages cannot drift from what the platform > actually does. > [!NOTE] > > Two exceptions, both in the MCP direction. **Authoring your agent's own tools** > (`org_tool_list`, `org_tool_upsert`, and the call history > `org_tool_calls_list`, `org_tool_calls_stats`) has no REST endpoint: those calls > change, or describe the wiring of, what your customer-facing agent can do. Neither do **send drafts** > (`drafts_list`, `drafts_get`, `drafts_count`, `drafts_decide`), which approve or reject journey sends a > reviewer is holding. Both are deliberately limited to a surface where a named > person is signed in — an organization API key identifies the integration, not > the person, and "who approved this send" has to name someone. Everything else > on this page is available on both. ## How names map MCP tool names are the REST resource and action joined with underscores. A few representative rows per domain (the **API** and **CDP** tabs list every endpoint): | Domain | REST endpoint | MCP tool | | - | - | - | | CDP: People | `POST /api/v1/cdp/people` | `cdp_people_upsert` | | CDP: People | `GET /api/v1/cdp/people/{externalId}` | `cdp_people_get` | | CDP: Events | `POST /api/v1/cdp/events` | `cdp_events_record` | | CDP: Custom objects | `POST /api/v1/cdp/custom-objects` | `cdp_custom_objects_upsert` | | CDP: Relationships | `POST /api/v1/cdp/relationships` | `cdp_relationships_link` | | Segments | `GET /api/v1/segments` | `segments_list` | | Segments | `POST /api/v1/segments/preview` | `segments_preview` | | Segments | `POST /api/v1/segments/{slug}/evaluate` | `segments_evaluate` | | Initiatives | `POST /api/v1/initiatives` | `initiatives_create` | | Initiatives | `POST /api/v1/initiatives/{id}/launch` | `initiatives_launch` | | Initiatives | `GET /api/v1/initiatives/{id}/data/summary` | `initiatives_summary` | | Participants | `POST /api/v1/initiatives/{id}/participants` | `initiatives_participants_add` | | Participants | `GET /api/v1/initiatives/{id}/participants/{participantId}/messages` | `initiatives_participant_messages_list` | | Participants | `GET /api/v1/initiatives/{id}/transcripts` | `initiatives_transcripts_list` | | Participants | `POST /api/v1/initiatives/{id}/participants/{participantId}/stop` | `initiatives_participants_stop` | | Journeys | `GET /api/v1/journeys` | `journeys_list` | | WhatsApp templates | `GET /api/v1/whatsapp-numbers` | `whatsapp_numbers_list` | | WhatsApp templates | `POST /api/v1/templates` | `templates_create` | ## What differs per surface Only the transport-level concerns: | | REST | MCP | | - | - | - | | **Auth** | Organization API key (Bearer token) | Your Boom login (OAuth) | | **Access control** | The organization API key | Your role: each tool needs the permission your role grants for the same action in the app ([roles](/use-mcp#roles-and-permissions)) | | **Rate limits** | [Per key](/rate-limits-and-errors) | Per organization, per tool | | **Errors** | `{ "error": { "code", "message" } }` + HTTP status | Tool error with the same `code: message` | ## Related > **[Use MCP](/use-mcp)** > > Connect Claude, Cursor, or any AI tool to Boom. > **[Quickstart](/quickstart)** > > The same capabilities over REST, end to end. # Protecting sensitive data Source: https://docs.useboom.ai/protecting-sensitive-data Keep parts of your app out of what the in-app expert reads: tags, classes, selectors and the automatic rules. The in-app expert (the [web widget's](/web-widget#proactive-help-copilot) proactive help) reads the **structure** of the screen a visitor is on: button and link names, form labels, headings and short labels. It never reads what anyone types into a field, or anything inside lists and tables. This page covers how to keep more of your app out of what it reads, and what it masks on its own. Everything here happens **in the visitor's browser**, before anything is sent to Boom. Protected content never leaves the page. To hide **customer data fields** (a tax ID, an email) from Boom's AI agents and from teammates, see [Hide sensitive customer data](/hide-sensitive-data) instead. ## Two ways to protect a part of the page | | Hide completely | Keep layout, hide text | | - | - | - | | Attribute | `data-boom-private` | `data-boom-pii` | | Class | `boom-private` | `boom-pii` | | What the expert gets | Nothing. For the expert, the region does not exist. | That a control is there (for example "a text box, filled") but never its label or any text. Every label and text inside reads `[redacted]`. | | Can the expert point at it? | No | No. It never rings or guides to a control in this region. | Use **hide completely** for regions with nothing the expert needs: account details, balances, a profile card. Use **keep layout, hide text** for a form or panel where knowing that the controls exist helps the expert understand the screen, but their labels or contents would identify someone (for example, a contact list whose buttons are named after people). ```html theme={null}

Maria Lopez

Balance: $1,200.00

``` The class works the same as the attribute, for apps whose components let you set a `class` but not arbitrary attributes. Either one protects the element and everything inside it. You can also mark just a part of something. In `

Welcome, Maria

`, the expert reads the heading as "Welcome, \[redacted]"; the same goes for buttons, links and form labels. With `boom-private` the part is left out entirely. ## Selectors, when you can't change the markup If you can't edit the HTML (a third-party component, a template you don't own), add CSS selectors in Boom instead: in the widget's settings, open the **In-app expert** tab and use the **Protected content** card. Each selector is either **Hide completely** or **Keep layout, hide text**, exactly like the attribute. * Up to 50 selectors, each up to 200 characters. * Any selector the browser understands: `.account-balance`, `#profile-card`, `[data-testid="user-name"]`, `table.users td:nth-child(2)`. * Boom refuses a selector that would cover the whole page (`body`, `html`, `:root`, `*`). To stop the expert on a site, switch it off instead. * If a visitor's browser can't parse one of your selectors, that one selector is ignored and the rest still apply. Changes reach your site with the widget's next load (the loader is cached for a few minutes). ## What is protected automatically You don't need to mark these: * **Anything typed into a field.** Only whether a field is empty or filled is read, and not even that for passwords. * **Fields for personal data.** An input of type `password`, `email` or `tel`, and any field whose `autocomplete` names personal data (`name`, `given-name`, `family-name`, `email`, `tel`, `street-address`, `address-line1`, `postal-code`, `cc-number` and the other `cc-` values, `bday`, `one-time-code`, `current-password`, `new-password`, `username`, `sex`), are treated as **keep layout, hide text**: their labels read `[redacted]` and the expert never points at them. Fields for a company name or a country are not included. * **Lists and tables.** Nothing inside list items, table rows and cells, or cards marked as `article` is read, except navigation menus and tab lists. * **Hidden content.** Anything under `aria-hidden="true"`. * **Patterns in any text it does read**, masked before anything is sent: | Pattern | Becomes | | - | - | | Email address | `[email]` | | Card number (13-19 digits that pass the card checksum) | `[card]` | | IBAN | `[iban]` | | Mexican CURP and RFC, Brazilian CPF and CNPJ, US Social Security number | `[id]` | | Phone numbers and other long numbers | `[number]` | | Ids in the page address (`/orders/48213/edit`) | `/orders/:id/edit` | The masks recognize the usual written forms (a CPF as `123.456.789-09`, an SSN as `123-45-6789`). A number written some other way may only be masked as `[number]`, or not at all if it is short. Patterns are a safety net, not a substitute for marking a region that shows personal data. ## What Boom keeps The screen structure is used to decide whether to offer help and is not stored. Boom's logs keep the page's route shape (ids removed) and the name of the control the expert pointed to. Because a protected control is never pointed to, its name never reaches those logs either. # Guiding questions and variables Source: https://docs.useboom.ai/questions-and-variables What the agent asks, what Boom extracts without asking, and how to decide which one a piece of information belongs in. Every conversation an initiative runs turns into data. Some of that data comes from things the agent **asks**. Some of it Boom **notices** on its own, by reading the conversation once it's over. Knowing which is which is most of what it takes to design a study that comes back with numbers you can use. | | Guiding questions | Variables | | - | - | - | | **Who sees it** | The agent. It's part of the brief it works from. | Only Boom's extraction. The agent's brief doesn't include your variables. | | **During the conversation** | The agent asks it, in order, and follows up as deep as you set. | Nothing. The agent never asks for a variable. | | **After the conversation** | Its answer is extracted as a variable. | Boom reads the whole conversation and fills it. | | **Good for** | What you need the person to tell you. | What shows up on its own: a classification, a flag, a list in the person's words. | ## Guiding questions: asked, then extracted Guiding questions are the topics every conversation should cover. You write them on the initiative, in the order you want them asked, and the agent decides how to phrase each one and when to bring it up. **Follow-up depth** sets how far it explores once it has an answer, from taking the first answer to digging deeper. Each guiding question **also becomes a variable**. When the conversation closes, Boom extracts the answer, typed by the question's answer type: | Answer type | What gets stored | | - | - | | **Open** | The answer, as text. | | **Yes / No** | Yes or no. | | **Multiple choice** | One of the options you listed. | | **Scale** | A number within the range you set. | So you don't need to repeat a guiding question as a variable. Its answer is already in the data. ## Variables: never asked, still captured A variable is a field Boom fills by reading the conversation **after it ends**. The agent doesn't know your variables exist, so it never asks for them and never steers the conversation toward them. The person talks about their experience; Boom notices what they said. That makes variables the right place for: * **A classification across the whole conversation.** Whether someone tried a feature once, keeps using it, or stopped. Nobody answers that in one message, but it's clear by the end. * **A flag.** Whether they mentioned a liquidation, an outage, a competitor, or asked to talk to a person. * **A list in the person's own words.** Features they asked for, bugs they reported, other tools they use. * **Something you shouldn't ask directly.** Which costs are on someone's radar is more honest when they name them unprompted than when the agent lists them. Variables come in the types described in [Extraction](/extraction): yes/no, a whole number, one value from a list, several values from a list, text, or a list of free text. ## Where variables come from On an initiative's **Data** step, under **What to extract**, Boom groups every variable by where it comes from: | Group | What it is | | - | - | | **Included by default** | Signals Boom adds to every initiative: sentiment, an alert flag, a summary, a key quote, and activity and quality levels. A reward-eligibility flag joins them when the initiative rewards participants. | | **Shared across your organization** | Fields your organization extracts on every initiative, such as topic, problem and competitor. | | **From your guiding questions** | One per guiding question, as above. | | **Your variables** | The ones you add with **Add custom variable**, specific to this initiative. | ## Variables you pass in are a third thing Don't confuse extracted variables with the **context** you send for each participant when you enroll them, such as a customer segment, a plan, or the date of their last purchase. That context goes to the agent before the conversation starts so it can adapt: talk differently to someone who used a feature once than to someone who uses it every week. Declare those fields on the initiative's context schema (set through the CSV upload in the app; see [Initiatives](/initiatives)). A useful pattern is to pass a segment in **and** extract the same dimension out. The extracted version records what the person actually said, so you can see where your segment and their story disagree. ## Writing variables that fill A variable is only as good as the share of conversations that fill it. Four rules make the difference: 1. **Anchor every variable to the study's objective.** Fields about side topics rarely come up, so they come back empty and add noise to the report. 2. **The description is the instruction.** Boom reads it to decide what to extract, so write it like a brief: what counts, what doesn't, and what to do when the conversation doesn't say. 3. **Give single-value fields an honest way out.** A yes/no, number, single choice or text field always gets a value. Add an option like "Not clear" or "Not mentioned" to a list so the model can abstain instead of guessing. Multi-value fields come back empty when there's no signal. 4. **Mirror what the agent offers.** If your guiding question tells the agent to suggest a few options when someone is stuck, use the same options as the variable's values, so the chart reflects what people were actually offered. ## A worked example A team wants to know why people try a new feature once and don't come back. **Guiding questions**, which the agent asks: 1. What made you try it? 2. Tell me about the first time you used it. 3. If you've used it again, what brings you back? If not, what stopped you? **Variables**, which the agent never asks: | Variable | Type | Values | | - | - | - | | `usage_pattern` | One value | Tried once / Uses it regularly / Used it and stopped / Not clear | | `stop_reasons` | Several values | The same options the agent offers in question 3 | | `costs_mentioned` | Several values | Fees / Time it takes / Risk / Other (only costs the person names) | | `features_requested` | Free-text list | In the person's words | | `requested_contact` | Yes / No | Asked for someone from the team to follow up | Someone replies *"I tried it once in March, the fees surprised me and I haven't been back."* Question 3 gets its answer, and without anyone asking, `usage_pattern` comes back "Tried once" and `costs_mentioned` comes back "Fees". ## When the data appears * **After the conversation closes.** Extraction runs when a conversation ends, for conversations with at least 3 messages and at least one reply. Someone who never answers produces no data. * **Set your variables before you launch.** A conversation is extracted against the variables that existed when it ran. You can re-run extraction on past conversations from the initiative's data view after a change. * **Where to read it.** The initiative's data view (**Answers**), insights, CSV exports, and the API, described in [Extraction](/extraction#reading-the-data-back). ## Related > **[Extraction](/extraction)** > > Variable types, limits, and setting a schema over the API or MCP. > **[Initiatives](/initiatives)** > > Objective, context, guiding questions and the context schema. # Quickstart Source: https://docs.useboom.ai/quickstart Create a person, an object type, an object, a relationship type, and a link. > [!NOTE] > > This walkthrough is for developers, and it runs in a terminal. If you are here > to understand what Boom does rather than to integrate it, read > [start here](/start-here) instead. Set your key and base URL: ```bash theme={null} export BOOM_KEY="boom_org_YOUR_KEY_HERE" export BASE="https://www.useboom.ai/api/v1/cdp" ``` **1. Upsert a person** ```bash theme={null} curl -X POST "$BASE/people" \ -H "Authorization: Bearer $BOOM_KEY" \ -H "Content-Type: application/json" \ -d '{"externalId":"user_123","email":"a@b.com","phoneNumber":"+523333333333","attributes":{"plan":"pro","mrr":49}}' ``` Returns `{ "externalId": "user_123", "created": true }`. Send a reachable channel, `email` and/or `phoneNumber` (E.164 format), as top-level fields. They populate the person's typed contact columns, which is what lets event-triggered journeys reach them. Anything else goes in `attributes`. Calling it again for the same `externalId` updates the person and only changes what you send: fields you leave out keep their value, and an attribute sent as `null` is removed. See [Conventions](/cdp/overview#conventions) for `"mode": "replace"`. **2. Make sure the object type exists** List your types, and create one if you need to. Objects can only be upserted against a type that already exists. ```bash theme={null} curl "$BASE/custom-objects/types" -H "Authorization: Bearer $BOOM_KEY" # create one if it's missing: curl -X POST "$BASE/custom-objects/types" \ -H "Authorization: Bearer $BOOM_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"order","displayName":"Order"}' ``` **3. Upsert a custom object** ```bash theme={null} curl -X POST "$BASE/custom-objects" \ -H "Authorization: Bearer $BOOM_KEY" \ -H "Content-Type: application/json" \ -d '{"type":"order","externalId":"ord_998","displayName":"Order #998","attributes":{"total":120.5,"status":"paid"}}' ``` **4. Register a relationship type** ```bash theme={null} curl -X POST "$BASE/relationship-types" \ -H "Authorization: Bearer $BOOM_KEY" \ -H "Content-Type: application/json" \ -d '{"kind":"person_to_co","role":"placed","customObjectType":"order","cardinality":"ONE_TO_MANY"}' ``` Returns the type with a `relationshipTypeId` (e.g. `rt_2hk9`). Keep it for the next step. See [relationship types](/relationship-types) for the full field and cardinality catalog. **5. Link them** ```bash theme={null} curl -X POST "$BASE/relationships" \ -H "Authorization: Bearer $BOOM_KEY" \ -H "Content-Type: application/json" \ -d '{"personExternalId":"user_123","customObjectExternalId":"ord_998","relationshipTypeId":"rt_2hk9"}' ``` Returns `{ "created": true, "relationshipTypeId": "rt_2hk9" }`. The `relationshipTypeId` is **required**: it fixes the role and object type, so you don't repeat them here. **6. Record an event** Log a behavioral event for the person you created: ```bash theme={null} curl -X POST "$BASE/events" \ -H "Authorization: Bearer $BOOM_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"payment_made","externalId":"evt_1","personExternalId":"user_123","properties":{"amount":120.5}}' ``` Read it back with `GET /events/evt_1`, or list a person's events with `GET /events?personExternalId=user_123`. See [Events](/events) for the full model. > [!TIP] > > Run any upsert twice: the first returns `created: true`, the second > `created: false`. That's the upsert working as intended. > [!NOTE] > > Already have this data in a PostgreSQL or MySQL database? Skip the API and > [connect your database](/connect-your-database). Boom syncs people, objects, > and relationships from a read-only query on a schedule. ## Related > **[Authentication](/authentication)** > > Create an API key and authenticate your requests. > **[Events](/events)** > > Record behavioral events and read them back. > **[Relationship types](/relationship-types)** > > Register the link shapes you need before creating relationships. > **[Connect your database](/connect-your-database)** > > Sync the same data from your database instead of pushing it. # Rate limits & errors Source: https://docs.useboom.ai/rate-limits-and-errors Limits, headers, and the error shape used across all endpoints. ## Rate limits Requests are limited to **1000 per minute per API key** (sliding window). Every response carries the current state: | Header | Meaning | | - | - | | `X-RateLimit-Limit` | Max requests in the window | | `X-RateLimit-Remaining` | Requests left in the current window | | `X-RateLimit-Reset` | When the window resets (epoch ms) | | `Retry-After` | Seconds to wait (only on `429`) | When exceeded you get `429`: ```json theme={null} { "error": { "code": "rate_limited", "message": "Rate limit exceeded. Retry after the indicated delay." } } ``` Wait for `Retry-After` seconds (or until `X-RateLimit-Reset`) and retry. Rate-limited requests are rejected before any work happens, so they are always safe to resend. ### Batch endpoints The `/batch` endpoints use a **lower limit of 20 requests per minute**. A single batch performs up to 1000 operations, so the per-operation ceiling stays comparable. A batch body is capped at **1000 items** (`400` over that). This is a **separate budget**: batch requests are counted on their own, so ordinary traffic on your key never consumes your batch allowance and vice versa. `X-RateLimit-Limit` reflects the ceiling of whichever budget handled the request — `1000` on standard endpoints, `20` on `/batch` ones. ## Error shape Every error response is JSON with an `error` object carrying a stable, machine-readable `code` (snake\_case) and a human `message`: ```json theme={null} { "error": { "code": "initiative_not_found", "message": "No initiative with that id exists in your organization." } } ``` Some errors carry extra fields alongside `code` and `message`: * **`issues`**: per-field breakdown on validation failures (`code: "validation_failed"`). * **`suggestions`**: "did you mean" candidates on unknown-type `404`s: ```json theme={null} { "error": { "code": "unknown_object_type", "message": "Unknown custom object type: ordr", "suggestions": ["order"] } } ``` Every response uses this shape, including authentication failures (`unauthorized`) and rate limits (`rate_limited`). ## Status codes | Status | When | | - | - | | `200` / `201` | Success | | `400` | Wrong `Content-Type` (`unsupported_content_type`), malformed JSON (`invalid_json`; the `message` gives the parse error and, when known, its character position in the body), schema validation failure (`validation_failed`, including an empty batch or one over the 1000-item cap), a bad pagination token (`invalid_cursor`), or a relationships list without an anchor (`unanchored_query`) | | `401` | Missing or invalid API key | | `404` | The resource (or one it references) doesn't exist in your organization. Codes follow the `*_not_found` pattern: `person_not_found`, `custom_object_not_found`, `event_not_found`, `segment_not_found`, `initiative_not_found`, `participant_not_found`, `journey_not_found`, `template_not_found`, and so on. Unknown custom object types return `unknown_object_type` with `suggestions` | | `409` | Conflicts with current state: duplicates (`duplicate_type_name`, `segment_slug_taken`, `template_name_taken`, `initiative_name_taken`) or a wrong lifecycle state — including an action that would be a no-op, e.g. `initiative_already_archived` / `initiative_not_archived` | | `422` | Syntactically valid but rejected semantically, e.g. launching an initiative that isn't ready (`initiative_not_ready`) | | `429` | Rate limit exceeded | | `500` | Unexpected server error (`internal_error`, internals are never leaked) | | `503` | Transient, safe to retry: a list query timed out (`query_timeout`; narrow the filters or `limit`), or a single upsert did not complete (`write_failed`; retry as is) | > [!NOTE] > > Batch and participant endpoints report **per-item errors** inside a `2xx` > response body (e.g. `contact_suppressed` when someone on your Do Not > Contact list is skipped). The request succeeds while individual items > fail. Always check the per-item `errors` array. > [!NOTE] > > Server errors return a generic message by design: they never echo stack > traces or payload contents. Every response includes an `X-Request-Id` > header; include it when contacting support about a failed request. ## Related > **[Authentication](/authentication)** > > How API keys and the `401` responses work. > **[Quickstart](/quickstart)** > > See the request and response shapes in action. # Relationship types Source: https://docs.useboom.ai/relationship-types Register the catalog of link shapes and the data you need to do it correctly. A **relationship type** is the *shape* of a link, not a link itself. Every relationship (e.g. person `user_123` → order `ord_998`) points at a relationship type that defines its `role`, the object types involved, and metadata like cardinality. Think of it as the schema for your edges. Creating a relationship requires a `relationshipTypeId`, so registering the type (or finding an existing one with `GET /relationship-types`) is a **prerequisite for linking** over the API. Registering also lets you attach `cardinality`, `description`, and `bidirectional` so your graph is self-describing and queryable in Segments. > [!NOTE] > > When you **register** a type, you reference custom object types by > **name** (`order`, `line_item`). When you **create a relationship**, you > reference the type by its **`relationshipTypeId`** (returned here), so > register or list your types first. See [the FAQ below](#do-i-send-the-relationshiptypeid-when-creating-a-relationship). ## What you need before you register Registration validates against your existing catalog, so have these ready first, in this order: **1. An organization API key** Sent as `Authorization: Bearer boom_org_…`. The organization is derived from the key. See [Authentication](/authentication). **2. The custom object type(s), already created** Both endpoints reference object types **by name**, and those types must already exist for your org. An unknown name returns `404` with fuzzy `suggestions`. Confirm with: ```bash theme={null} curl "$BASE/custom-objects/types" -H "Authorization: Bearer $BOOM_KEY" ``` For `person_to_co` you need one type; for `co_to_co` you need both the parent and the child type. **3. A role and (recommended) the metadata** Decide the `role` label and, ideally, the `cardinality` and a short `description`. These are the fields documented in the catalog below. ## Field catalog Required vs. optional fields per `kind`. `role`, `cardinality`, `description`, and `bidirectional` are common to both. ### `kind: "person_to_co"` | Field | Required | Type | Notes | | - | - | - | - | | `kind` | ✅ | `"person_to_co"` | Discriminator. | | `role` | ✅ | string (1–255) | Label for the link, e.g. `placed`, `returned`. | | `customObjectType` | ✅ | string (1–255) | Name of an **existing** custom object type. | | `cardinality` | Optional | enum | One of the [cardinality values](#cardinality-catalog). | | `description` | Optional | string (1–1000) | Human-readable explanation. | | `bidirectional` | Optional | boolean | Defaults to `false`. | ### `kind: "co_to_co"` | Field | Required | Type | Notes | | - | - | - | - | | `kind` | ✅ | `"co_to_co"` | Discriminator. | | `role` | ✅ | string (1–255) | Label for the link, e.g. `contains`. | | `parentCustomObjectType` | ✅ | string (1–255) | Name of an **existing** type (parent side). | | `childCustomObjectType` | ✅ | string (1–255) | Name of an **existing** type (child side). | | `cardinality` | Optional | enum | One of the [cardinality values](#cardinality-catalog). | | `description` | Optional | string (1–1000) | Human-readable explanation. | | `bidirectional` | Optional | boolean | Defaults to `false`. | > [!NOTE] > > Both `person_to_co` and `co_to_co` relationships can be created over the API. > To link two objects, call **Link a relationship** with `kind: "co_to_co"` > and `parentExternalId` / `childExternalId` (instead of `personExternalId` / > `customObjectExternalId`). ## Cardinality catalog `cardinality` is optional but recommended. For `person_to_co`, read it as person→object; for `co_to_co`, read it as parent→child. | Value | Meaning | Example (`person_to_co`) | | - | - | - | | `ONE_TO_ONE` | One on each side. | A person has exactly one `profile`. | | `ONE_TO_MANY` | One subject, many objects. | A person `placed` many `order`s. | | `MANY_TO_ONE` | Many subjects, one object. | Many people belong to one `household`. | | `MANY_TO_MANY` | Many on both sides. | People `favorited` many `product`s. | Any other value is rejected with `400`. ## Register a type ```bash person_to_co theme={null} curl -X POST "$BASE/relationship-types" \ -H "Authorization: Bearer $BOOM_KEY" \ -H "Content-Type: application/json" \ -d '{ "kind": "person_to_co", "role": "placed", "customObjectType": "order", "cardinality": "ONE_TO_MANY", "description": "A person who placed an order" }' ``` ```bash co_to_co theme={null} curl -X POST "$BASE/relationship-types" \ -H "Authorization: Bearer $BOOM_KEY" \ -H "Content-Type: application/json" \ -d '{ "kind": "co_to_co", "role": "contains", "parentCustomObjectType": "order", "childCustomObjectType": "line_item", "cardinality": "ONE_TO_MANY" }' ``` The response echoes the resolved type, including `created` (`false` when the type already existed and was refined in place): ```json theme={null} { "kind": "person_to_co", "relationshipTypeId": "rt_2hk9", "role": "placed", "customObjectType": "order", "cardinality": "ONE_TO_MANY", "description": "A person who placed an order", "bidirectional": false, "created": true } ``` > [!TIP] > > Registration is an **upsert**: re-post the same `(kind, role, type)` to > refine metadata. Omitted optional fields are preserved, so you can patch > just the `description` without clearing a previously-set `cardinality`. ## List your types ```bash theme={null} curl "$BASE/relationship-types" -H "Authorization: Bearer $BOOM_KEY" ``` ```json theme={null} { "data": [ { "kind": "person_to_co", "relationshipTypeId": "rt_2hk9", "role": "placed", "customObjectType": "order", "cardinality": "ONE_TO_MANY", "description": "A person who placed an order", "bidirectional": false }, { "kind": "co_to_co", "relationshipTypeId": "rt_8fa1", "role": "contains", "parentCustomObjectType": "order", "childCustomObjectType": "line_item", "cardinality": "ONE_TO_MANY", "description": null, "bidirectional": false } ], "next_cursor": null } ``` ## Recommended order of operations **1. Create custom object types** `POST /custom-objects/types` (or via your dashboard / data sync). The public API never auto-creates object types. **2. Register relationship types** `POST /relationship-types` with cardinality + description. **Keep the `relationshipTypeId` from the response**: you'll need it to create relationships. **3. Upsert people and custom objects** `POST /people` and `POST /custom-objects`. **4. Create relationships** `POST /relationships`, passing the `relationshipTypeId` from step 2. ## FAQ ### Do I send the `relationshipTypeId` when creating a relationship? **Yes, it's required.** `POST /relationships` takes the `relationshipTypeId`, which fixes the link's `role` and which custom object type it targets, so you don't pass those separately. Get the id from the response of `POST /relationship-types` or from `GET /relationship-types`. ```jsonc theme={null} // POST /relationships, request { "personExternalId": "user_123", "customObjectExternalId": "ord_998", "relationshipTypeId": "rt_2hk9" } // response { "created": true, "relationshipTypeId": "rt_2hk9" } ``` ### What happens if I skip registration? You can't. `POST /relationships` needs a `relationshipTypeId`, and the only way to get one is to register a type (or list a type that already exists from a prior data sync). So registering is a prerequisite for linking over the API. ### Can I change a type's cardinality later? Yes, re-`POST` the same type with the new value. It's an upsert and only the fields you send are updated. ## Related > **[Quickstart](/quickstart)** > > Register a type and create a relationship in context. > **[Events](/events)** > > Record what your linked people and objects did. # Reviews Source: https://docs.useboom.ai/reviews Collect product reviews on WhatsApp, moderate them, and show them on your store — Shopify or any website. Reviews asks your buyers for a review of what they bought, over WhatsApp, a few days after it arrives. Boom's agent asks about each product, saves the rating, their words and any photos, and the reviews go live on your store once they pass your publishing rules. Low ratings land in your team's inbox. > [!NOTE] > > Reviews is turned on per organization. If you don't see **Reviews** in the > Boom app, ask your account manager. ## How it works 1. **Boom learns your orders.** From your connected Shopify store, or from the products and orders your store sends through Boom's API (below). 2. **Boom plans the asks.** Every hour, it picks the orders due for an ask (when they were placed, shipped or delivered, plus the days you choose), merges orders shipped close together, and keeps the ask to your most valuable products. Turn on **Shadow** first to see who would be asked without sending anything. 3. **The agent collects the reviews.** **Set up asks** creates a "Product reviews" initiative with a ready journey: the ask, a reminder, and the conversation. Pick your WhatsApp templates, launch it, and switch asks to **Live**. 4. **Your rules decide what's public.** Publish automatically unless the AI check flags something, only from a minimum rating, or always by hand. Photos can wait for a person even when the text is published. ## Show reviews on Shopify In **Reviews → Install**, choose **Connect**. Then, in your theme editor, turn on the **Boom Reviews** app embed and add the **Boom product reviews** and **Boom star rating** blocks to your product template. One more block goes on product pages: | Block | Where | Shows | | - | - | - | | **Boom review highlights** | Product page | A card for the buy box: the AI summary and its topics (when you show it), your best quote and a link down to the reviews section. Hidden on products without reviews. | These blocks show reviews from across your store: | Block | Where | Shows | | - | - | - | | **Boom reviews carousel** | Any page | Reviews side by side: the ones you featured (or your 5-star reviews while you haven't featured any), 5-star reviews, those with photos, or the newest. Cards with a photo or quote only, or a wall of quotes. 12 by default; your featured reviews show at most 12. | | **Boom review wall** | Any page | A masonry wall of reviews and their photos, with an "All / With photos" filter and "Load more reviews". | | **Boom testimonials** | Any page | One large quote at a time with its author and product, arrows and dots. It can move on its own. | | **Boom photo gallery** | Any page | Your shoppers' photos, newest first, each with its rating. A photo opens with its review and a link to the product. | | **Boom rating banner** | Any page | Your average rating and review count, as a band with up to three short quotes or as one line that links to your reviews page. | | **Boom all reviews** | A page template | Every published review with your store's rating summary, filters and paging. | Each has a heading you can change or hide. Left empty, the heading appears in your store's language. In the theme editor you also choose which products a block covers (all, a collection's first 50 products, or products you pick) and, for the carousel and testimonials, how many reviews it shows (up to 24; featured reviews stop at 12); the banner takes the page its one-line style links to. How a block looks and which reviews it picks are set in Boom (see below). If you connect more than one Shopify store to Boom, each store's blocks show only that store's products. While you edit your theme, an empty block says why it's empty; your shoppers see nothing until there is something to show. Moving widgets (the carousel and testimonials, when set to move on their own) stop while a shopper hovers or is inside them, have a pause button, and stay still for shoppers who ask their device for reduced motion. ### Choose the look **Reviews → Widgets** shows each widget on sample reviews, at desktop or phone width and on a light or dark theme, while you change it. Your changes reach your store when you choose **Publish to store**, within a few minutes. Start with the **store look**, which every widget follows: * **Style:** start from a preset (Editorial, Minimal, Soft or Bold), then pick the star color (or your theme's text color), corners, how reviews are framed (lines, outlines or filled cards) and whether headings use your theme's heading font. * **Content:** turn the AI summary, the row of customer photos, photos in reviews, the verified-buyer mark, reviewer initials, review dates and your replies on or off. * **Settings:** the default sort, and whether products without reviews show the section at all. Then pick a widget to give it a **custom look** (its own star color, corners, cards and headings) or to set what it does: * **Product reviews** and **All reviews:** the layout (summary beside the list, cards, or one column). * **Star rating:** three badge styles. * **Carousel:** which reviews, the card (photo on top, quote only, or a wall), cards per view on desktop, whether it moves on its own, and the product. * **Review wall:** which reviews, and the photo filter. * **Testimonials:** which reviews, whether it moves on its own, and the product. * **Rating banner:** a band with quotes, or one line. * **Review highlights:** the AI summary and the best quote. Widgets use your theme's fonts and colors; Boom loads no fonts. A review the buyer got something for always says so, whatever the look. Boom also keeps Shopify's standard product rating up to date on every product that has reviews, so theme product cards and Google Shopping can show stars, and adds the rating to each product page's structured data for Google search results. Turn the embed's **Star ratings in Google results** setting off if your theme already outputs ratings. ## Show reviews on any website In **Reviews → Install → Install on any website**, copy the code into your product pages: ```html theme={null}
``` The code in the app already carries your store's key. `data-product-ref` is the product's id: the same id you send Boom as the product's `externalId`. The script loads once however many elements a page has, so a list of products can show stars for each. The same script shows reviews from across your store anywhere on your site. These elements need no product id: ```html theme={null}
``` On a product page, `
` adds the highlights card. Add `data-heading="Your title"` to change a heading, or `data-heading=""` to hide it. On the carousel, wall, testimonials, gallery and banner, add `data-refs="ID1,ID2"` (up to 50 product ids) to show reviews of those products only. On the carousel and testimonials, add `data-max="8"` to show at most that many (up to 24; featured reviews stop at 12). The all-reviews element always covers your whole store. `data-link` takes a web address (`https://…`) or a path on your site. Elements show nothing until there is something to show. On a website, these elements show reviews from every store you connect to Boom. The script looks for these elements once, when the page loads. Elements your site adds later, such as a single-page app rendering a new page without a full reload, aren't picked up. ## Send products and orders through the API A store that isn't on Shopify sends its catalog, buyers and orders to Boom's [CDP](/cdp/overview) with an [API key](/authentication). First, in **Reviews → Store data**, choose **Set up the commerce API**: it creates the two types below and the link from a person to their orders. | Type | `externalId` | Attributes | | - | - | - | | `commerce_product` | your product id (`data-product-ref`) | `title` (required), `url`, `imageUrl`, `handle`, `productType`, `tags` (list), `status`: `ACTIVE` or `ARCHIVED` | | `commerce_order` | your order id | `placedAt` (date-time, required), `name` (e.g. `#1001`), `fulfilledAt`, `deliveredAt`, `status`: `OPEN`, `PARTIAL`, `FULFILLED`, `CANCELLED` or `REFUNDED`, `currency`, `lineItems` (required list) | Each line item is `{ "id", "productId", "title", "variantTitle", "unitPrice" }`, where `id` and `productId` are required and `productId` is a `commerce_product` `externalId`. Boom asks about each line once, ever. * **Dates** are ISO 8601 date-times with a timezone, like `2026-10-01T15:00:00Z` or `2026-10-01T10:00:00-05:00`. A date alone (`2026-10-01`) isn't read, and an order without a readable `placedAt` is never asked about. * **Shipped time** is `fulfilledAt`. If you only know when the order arrived, `deliveredAt` alone counts as shipped too. * **Ids** (`externalId`, line `id`, `productId`) are strings. Keep them the same every time you send the order: a changed id reads as a new line, and Boom may ask about it again. * A line without a `title` uses the product's `title` in the ask. **1. Send your products** ```bash theme={null} export BASE="https://www.useboom.ai/api/v1/cdp" curl -X POST "$BASE/custom-objects" \ -H "Authorization: Bearer $BOOM_KEY" -H "Content-Type: application/json" \ -d '{ "type": "commerce_product", "externalId": "kit-viaje", "attributes": { "title": "Kit de viaje", "url": "https://tienda.example/kit-viaje", "imageUrl": "https://cdn.example/kit-viaje.jpg", "tags": ["viaje"] } }' ``` **2. Send the buyer** Boom asks on WhatsApp, so the person needs a phone number. ```bash theme={null} curl -X POST "$BASE/people" \ -H "Authorization: Bearer $BOOM_KEY" -H "Content-Type: application/json" \ -d '{ "externalId": "cliente-55", "firstName": "Lucía", "phoneNumber": "+5215512345678" }' ``` **3. Send the order** ```bash theme={null} curl -X POST "$BASE/custom-objects" \ -H "Authorization: Bearer $BOOM_KEY" -H "Content-Type: application/json" \ -d '{ "type": "commerce_order", "externalId": "web-1001", "attributes": { "name": "#1001", "placedAt": "2026-10-01T15:00:00Z", "fulfilledAt": "2026-10-03T12:00:00Z", "status": "FULFILLED", "currency": "MXN", "lineItems": [ { "id": "1", "productId": "kit-viaje", "title": "Kit de viaje", "unitPrice": 499 } ] } }' ``` Send it again whenever it changes (shipped, delivered, cancelled): the `externalId` makes the call an update. An update replaces the order's attributes, so send the whole order each time, or add `"mode": "merge"` to send only what changed: ```bash theme={null} curl -X POST "$BASE/custom-objects" \ -H "Authorization: Bearer $BOOM_KEY" -H "Content-Type: application/json" \ -d '{ "type": "commerce_order", "externalId": "web-1001", "mode": "merge", "attributes": { "status": "CANCELLED" } }' ``` **4. Link the buyer to the order** Find the `placed` link once with `GET $BASE/relationship-types`: the entry with `role: "placed"` and `customObjectType: "commerce_order"` carries the `relationshipTypeId` to use. Then: ```bash theme={null} curl -X POST "$BASE/relationships" \ -H "Authorization: Bearer $BOOM_KEY" -H "Content-Type: application/json" \ -d '{ "relationshipTypeId": "PLACED_TYPE_ID", "personExternalId": "cliente-55", "customObjectExternalId": "web-1001" }' ``` When asks are timed from shipping or delivery, an order that is partly shipped (`PARTIAL`) waits until it's fully shipped; if it is still partial 30 days after it first shipped, Boom doesn't ask about it. Asks timed from the order date don't wait for shipping. A cancelled or refunded order is never asked about, and an ask whose orders were cancelled after it was planned is dropped before it's sent. A record Boom can't read (no readable `placedAt`, `lineItems` not a list) is skipped, every hour until it's fixed, and the rest are planned. ## The AI summary on product pages Turn on **AI summary** in **Reviews → Widgets → Content** to add what customers say to each product with 5 or more published reviews: a short summary and the topics shoppers mention, labelled as an AI summary of the reviews it read (the newest 60 at most). Boom writes it from the reviews' ratings and words, never their authors' names, writes it again after 5 more reviews or when reviews are removed, and takes it down when fewer than 5 published reviews remain. In **Reviews**, open a product to read its summary, keep it off your product pages, or have it written again. ## Reach shoppers by what they rated Boom keeps two numbers on each reviewed product in your CDP: `boom_reviews_rating` (the published average, one decimal, empty until the product has a published review) and `boom_reviews_count`. They update when a review changes, and your Shopify product syncs leave them in place. In **Segments**, they work like any product attribute: for a Shopify store, "people who bought a product rated below 3" is a person who **placed** an **order** whose **line item** references a **product** with `boom_reviews_rating` less than 3. For reviews Boom collects over WhatsApp, the person also gets a `review_submitted` event when they leave the review, with its `rating`, so "reviewed a product with 2 stars or less" works for any store. Imported and manually added reviews don't create that event. ## Thank photo reviews with a gift card In **Reviews → Follow-up**, turn on **Thank photo reviews with a gift card** and choose the amount. It needs a connected Tremendous account (**Settings → Rewards**). Then choose **Add to the journey**: Boom adds the reward step to a draft of your reviews journey. Publish the draft to start offering it; until then, buyers aren't offered anything. Sending gift cards in WhatsApp also needs your approved reward-delivery template, and the journey won't publish without it. Once the journey with the reward is published: * Boom's agent tells buyers that the store sends one gift card per order when they add a photo or video to a review, whatever their rating. It never offers anything else. * Each gift card waits for your approval in **Drafts** before it's sent. Approve every photo review's gift card whatever its rating: approving only happy buyers would make the reward depend on what they said, which consumer-protection rules forbid. * Reviews written with the reward show as **Incentivized review** on your store and in Boom. * To stop offering it, publish your journey without the reward step. ## When a buyer is unhappy Boom hands a review conversation to your team when the buyer's lowest rating is at or below your threshold (2 stars by default; choose 1, 2 or 3 stars in **Reviews → Follow-up**). Your team finds it in the inbox with a note of what was rated low, in the buyer's words, and the instructions you wrote in **What your team should do**. Boom's agent never offers anything itself. With **Notify the team in Boom** on, everyone in your organization sees a notification that opens the conversation. ## Bring reviews from another app **Reviews → Import** takes a CSV export from Judge.me or Revie, or any CSV with a column mapping. A dry run shows how many rows are ready, which products weren't found and which rows are duplicates before anything is saved. Imported reviews keep their date, rating and photos and publish straight away. ## Related > **[CDP overview](/cdp/overview)** > > People, custom objects and relationships: what the commerce API writes to. > **[Relationship types](/relationship-types)** > > How links like `placed` are defined and found by id. > **[Journeys](/journeys)** > > The journey Set up asks creates, and how to adjust it. > **[Authentication](/authentication)** > > Create the API key the calls above use. # Segments Source: https://docs.useboom.ai/segments Define an audience with a filter, keep it fresh, and use it to trigger outreach. A **segment** is a named audience: a filter over your people, the custom objects related to them, and the events they generated, saved once and reused. Instead of re-describing "premium customers who haven't paid in 30 days" every time you launch an initiative, you define it as a segment and point a journey's trigger at it. Segments are how you decide who a conversation reaches. A win-back journey, an NPS follow-up, an onboarding nudge: each one starts from a segment, not from a hand-picked list of people. > [!NOTE] > > Deleted people are never matched. A person whose `status` is `REMOVED` is a > soft-deleted record, and no segment returns them, so a deleted customer or a > removed team member cannot end up in an audience you message. People whose > `status` is `INACTIVE` **are** matched — filter on `status` explicitly if you > want only active people. ## Discover what you can filter on You cannot guess your own attribute names, so start by reading the catalog instead of writing a filter blind: ```bash theme={null} curl "$BASE/segments/catalog" -H "Authorization: Bearer $BOOM_KEY" ``` The response lists everything filterable in your organization, each with the exact `attr` token to use and the operators valid for it: * **`personAttributes`**: typed columns on the person (`email`, `status`, `createdAt`) plus every custom `attributes.` your org has ingested. * **`computedVariables`**: virtual `computed.` values (an aggregate or expression over related data) resolved at filter time, with no stored value of their own. * **`customObjectTypes`**: each type's own filterable attributes, addressed as `object.attributes.` once you reach that type through a relationship. * **`reachableObjects`**: the custom-object types reachable from a person, and every relationship path that gets you there, direct or through one or two intermediate objects. * **`events`**: every event your organization has recorded, with the payload keys seen on it. These drive `event` predicates — "did this person do X", optionally narrowed by the payload and by when it happened. Each key carries the token to use in `propertyFilters` and the operators valid for its type. An event with no payload keys recorded yet lists `properties: []`. * **`eventWindowOperators`**: what an `event` predicate's `window` accepts. Past-oriented only — a future window over history means nothing. * **`operatorReference`**: every operator's expected `value` shape (`scalar`, `list`, `date`, `dateRange`, `relativeWindow`, `dateOffset`, `none`), so you don't have to guess whether an operator wants a string, a list, or a window. The catalog is generated from the same registry the compiler enforces at save time, so it never lists an attribute or operator combination that would actually be rejected. ## Build a filter A segment's filter is a `FilterExpression`: a tree of predicates joined by `AND`/`OR`. Each predicate is one of a few kinds: | Kind | Matches on | Example use | | - | - | - | | `person_group` | Person columns and attributes | `plan_tier = premium` | | `has_relationship` | A related custom object, optionally several hops away | Has an order with `status = refunded` | | `event` | Whether an event did or did not happen, with property filters and a time window | Did not do `payment_made` in the last 30 days | A segment for premium customers who have gone quiet on payments. Note the `event` predicate: events reach Boom through the API, not through the [database sync](/connect-your-database), which syncs people, custom objects and relationships only. If your payment history arrives by database sync, filter on a custom object's date attribute with `has_relationship` instead. ```json theme={null} { "kind": "person", "op": "AND", "predicates": [ { "kind": "person_group", "filters": [ { "attr": "attributes.plan_tier", "type": "STRING", "op": "eq", "value": "premium" } ] }, { "kind": "event", "eventName": "payment_made", "occurred": false, "window": { "op": "in_last_n", "value": { "amount": 30, "unit": "days" } } } ] } ``` Operators worth knowing: | Type | Operators | | - | - | | STRING | `eq`, `neq`, `in`, `not_in`, `contains`, `not_contains`, `starts_with`, `ends_with`, `is_null`, `is_not_null` | | NUMBER | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `is_null`, `is_not_null` | | BOOLEAN | `eq`, `is_null`, `is_not_null` | | DATE (relative) | `in_last_n`, `in_next_n`, `more_than_n_ago`, `more_than_n_from_now`, `exactly_n_from_today` | | DATE (specific) | `on`, `before`, `after`, `between` | > [!NOTE] > > `contains` and `not_contains` accept a single substring or a list. A list > means "any of these": `contains` matches a row with any needle, > `not_contains` keeps a row with none of them. To exclude several values, > pass one `not_contains` with all of them, not a comma-joined string. ## Author a segment end to end The catalog, validate, preview, create, evaluate, and members endpoints form one loop. An agent (or a script) can walk it without ever opening the dashboard. **1. Read the catalog** `GET /api/v1/segments/catalog` (`segments_catalog`). See what's filterable before writing anything. **2. Validate the filter** `POST /api/v1/segments/validate` (`segments_validate`) with a `filterExpression`. This is a dry-run compile against your live catalog. Nothing is saved; a bad attribute or operator fails here with the exact field to fix. ```bash theme={null} curl -X POST "$BASE/segments/validate" \ -H "Authorization: Bearer $BOOM_KEY" \ -H "Content-Type: application/json" \ -d '{"filterExpression": { "kind":"person", "op":"AND", "predicates":[...] } }' ``` **3. Preview the match count** `POST /api/v1/segments/preview` (`segments_preview`) runs the same filter and returns `{ "count": N }`, the number of people it currently matches. No segment is created. **4. Create the segment** `POST /api/v1/segments` (`segments_create`) with `name`, `slug`, and the `filterExpression`. Creating a segment compiles and stores the filter, it does not evaluate it: the response comes back with `memberCount: 0` regardless of how many people actually match. ```bash theme={null} curl -X POST "$BASE/segments" \ -H "Authorization: Bearer $BOOM_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Quiet premium customers","slug":"quiet-premium", "filterExpression": { "kind":"person", "op":"AND", "predicates":[...] } }' ``` **5. Evaluate it** `POST /api/v1/segments/{slug}/evaluate` (`segments_evaluate`) runs the filter now and writes the membership rows, returning `{ "entrants", "leavers", "total" }`. Nothing is a member until an evaluation runs at least once. **6. Read the members** `GET /api/v1/segments/{slug}/members` (`segments_members_list`) lists the active members, newest first: `name`, `phoneNumber`, `externalId`, `email`, `enteredAt`. Keyset-paginated with `next_cursor`, same pattern as every other list endpoint. `PATCH /api/v1/segments/{slug}` (`segments_update`) updates any field except the slug, which is permanent. Changing `filterExpression` recompiles the filter but does not re-evaluate it, so the member set stays exactly as it was until the next evaluate call. ## Keeping a segment fresh A segment's membership is a set of rows, not a live query, so it only changes when an evaluation runs. How often that happens is `evaluationCadence`: * **`REACTIVE_ONLY`** (the default). The segment re-evaluates the specific people a CDP write touches, usually within seconds of that write, plus a daily backstop sweep to catch anything a reactive update missed. Nothing else triggers it, so an explicit `segments_evaluate` call is how you force a full refresh. * **`HOURLY`** or **`DAILY`**. A scheduled sweep evaluates the whole segment on that cadence, independent of any particular write. Useful for filters that depend on the passage of time itself, like "hasn't paid in 30 days," which can flip with no new data at all. Whichever cadence you choose, a freshly created or just-edited segment shows a stale or empty member set until the next evaluation actually runs. > [!WARNING] > > Set the cadence explicitly when you create a segment over the API. The default > for API-created segments is `REACTIVE_ONLY`, and this page's own example is the > case it handles worst: "no payment in 30 days" turns true from the passage of > time, not from a write, so nothing reactive fires when a customer crosses the > line. Ask for `HOURLY` or `DAILY` on any filter whose answer changes with the > calendar, or evaluate on your own schedule. ### How a segment reaches outreach A segment does not attach to an initiative directly. It becomes the **trigger** on the journey behind that initiative: the journey's entry node is configured with `triggerType: segment` and a `segmentId`. Once that journey is published, every evaluation that adds someone to the segment enrolls them, carrying along whatever data the segment projects for that person. > [!NOTE] > > Wiring that trigger is an app job today. It needs the segment's internal id, > and this API identifies segments by `slug`, so set a segment trigger in the > Boom app rather than over the API. ## Limits and gotchas > [!WARNING] > > `segments_evaluate` runs synchronously over the API and MCP, unlike the > dashboard's "Evaluate now" button, which queues the evaluation in the > background and returns immediately. On a large organization, a synchronous > evaluate call can take a while and holds the request open the whole time. > If you just need the segment defined and don't need its membership > refreshed right away, skip the evaluate call and let its cadence run it. * **Create and update never evaluate.** `memberCount` is `0` right after `segments_create`, and a filter change from `segments_update` leaves the existing member set untouched until you call `segments_evaluate`. * **Weekend freeze.** A segment with `skipWeekendEvaluation` set rejects an explicit evaluate on Saturday or Sunday (in your organization's timezone) with `400 weekend_evaluation_skipped`. * **Deleting over MCP requires a role that can delete segments** (Owner, Admin or Member). `segments_delete` is a soft delete: it disappears from every list immediately, and its journey triggers are removed so no journey keeps enrolling from it. It is idempotent, `{ "removed": false }` on a segment that is already gone. * **Pagination.** `segments_list` and `segments_members_list` are keyset-paginated, defaulting to 100 rows and capped at 1000; loop on `next_cursor` until it comes back `null`. * **Preview returns a count only.** It does not return a sample of matching people over the API or MCP, only the total. * **Slugs are permanent.** Pick one deliberately; `segments_update` cannot rename it. * **Hidden fields can't be filtered over MCP.** If your organization marked a field sensitive, an AI tool connected over MCP gets `400 sensitive_field` from `segments_validate`, `segments_preview`, `segments_create`, `segments_update` and `segments_evaluate` when the filter uses that field: a count over a hidden value would let it guess the value. Build those segments in the dashboard's segment builder; the REST API is not affected. ## Related > **[Customer Data Platform](/cdp/overview)** > > The people, objects, and events a segment's filter reads. > **[Events](/events)** > > How the events an `event` predicate matches against get recorded. > **[Journeys](/journeys)** > > Set a segment as a journey's trigger to turn membership into outreach. > **[Use MCP](/use-mcp)** > > Author segments conversationally with the same tools documented here. # Skills Source: https://docs.useboom.ai/skills Install Boom's Agent Skills so Claude can run your customer conversations end-to-end over Boom. Boom publishes a set of **Agent Skills** that teach Claude how to use Boom's [MCP tools](/use-mcp) for real work: connecting your customer data, building the audiences you want to reach, designing the [journey](/journeys) that carries a conversation, launching it over WhatsApp, and reading back [what customers said as structured data](/extraction). The job is yours to pick. The same pieces cover winning back customers who dropped off, support, product research, onboarding, qualifying leads, collecting documents, and NPS follow-up. The skills themselves are open source, in [BOOM-TML/skills](https://github.com/BOOM-TML/skills). > [!NOTE] > > The skills run on Boom's [MCP tools](/use-mcp), and the plugin below sets that > server up for you at `https://www.useboom.ai/mcp`. You sign in with your normal > Boom login the first time Claude connects, so there's no API key to create or > store. Already connected Boom by hand? Keep that same URL, and Claude Code > treats the two as one server rather than loading Boom's tools twice. > [!NOTE] > > Options 1 and 2 below need a terminal or Claude Code. Option 3 works from the > Claude app with no terminal at all. ## Install Pick the path that matches how you use Claude. ### 1. One command (recommended) ```bash theme={null} npx @useboom/skills-setup ``` It asks who the skills are for, then sets up everything: Boom's marketplace, the plugin, Boom's MCP server, and **auto-update**, so new skills and fixes reach you without reinstalling. Installed Boom's skills before, with `npx skills add`? The installer finds those older copies, which never update, and moves them into a backup folder, so you don't end up running each skill twice. Then start Claude Code. The first time, it asks you to confirm installing the marketplace and the plugin, and to approve Boom's MCP server. Sign in to Boom when the window opens, and the skills show up as `boom:`. > **Prefer to set it up by hand?** > > The installer only writes these two keys, so you can paste them yourself: > > ```json theme={null} > { > "extraKnownMarketplaces": { > "boom": { > "source": { "source": "github", "repo": "BOOM-TML/skills" }, > "autoUpdate": true > } > }, > "enabledPlugins": { "boom@boom": true } > } > ``` > > Use whichever settings file fits, the syntax is the same in all: > > | Where | File | Scope | > | - | - | - | > | Just you | `~/.claude/settings.json` | Your machine, every project | > | A repo / team | `.claude/settings.json` (committed) | Everyone in that repo | > | Whole org | `managed-settings.json` | Enforced for all members | > [!NOTE] > > `autoUpdate` is the line that matters. Marketplaces other than Anthropic's own > arrive with auto-update **off**, so setting it is what keeps you current. Claude > Code then refreshes the marketplace in the background shortly after each session > starts, and either offers you `/reload-plugins` or picks up the new version on > your next launch. ### 2. From the `/plugin` menu, no files to edit Prefer menus? Everything below happens inside Claude Code (terminal, desktop app, VS Code, or JetBrains): 1. Type **`/plugin`** in the chat and press Enter to open the plugin manager. 2. Go to **Marketplaces → Add marketplace** and enter `BOOM-TML/skills`. 3. Go to **Discover**, find the Boom plugin, and click **Install** (choose "just me" or "this project"). 4. Back under **Marketplaces**, open **boom** and choose **Enable auto-update**, so new skills reach you on their own. If a settings file already sets it, the menu shows it as fixed there. 5. Run **`/reload-plugins`** (or restart). The skills appear as `boom:`. ### 3. The Claude app (claude.ai, desktop, mobile) The Claude app doesn't read plugin marketplaces, so there's no auto-update here. You upload the skills you want, one at a time, and Claude can do most of the work: 1. Ask Claude, in a chat with code execution available: > Get the `launch-initiative` skill from github.com/BOOM-TML/skills and give > me a ZIP I can upload as a skill. The ZIP's root must be the skill folder > itself. 2. Download the ZIP it hands back. 3. Open **Customize → Skills → Add** and upload it. Swap the skill name for whichever ones you want from [the list below](#the-skills). You can also skip Claude entirely: download the folder from the repo and zip it yourself, as long as the skill folder sits at the ZIP's root. > [!NOTE] > > Uploaded skills are private to your account, and the feature needs **code > execution** enabled. To hand them to a whole team at once, an admin provisions > them under organization settings (Team and Enterprise). > > Connect the [MCP tools](/use-mcp) too, or the skills will have nothing to call. > [!WARNING] > > These skills can launch initiatives and add participants, which send **real > WhatsApp messages and emails to real customers**. Review what an agent is about to do > before letting it run unattended. People on your Do Not Contact list are > always skipped. ## The skills Ten of them, and you don't type their names: Claude picks the right one from what you ask. | Skill | Use it to | | - | - | | `boom-overview` | Get oriented: Boom's object model, lifecycle, and which skill fits what you're doing | | `onboarding` | A guided first run: build your first journey, send a Hello World, learn the mental model | | `build-knowledge-base` | Prepare the context your agent works from: brand identity, plus a brief per use case | | `connect-your-data` | Sync Postgres, MySQL, or Shopify data into Boom's CDP (read-only user, SSH tunnel, mapping SQL) | | `cdp-and-segments` | Query the CDP and build the segments you'll target | | `design-journey` | Design or debug the [journey](/journeys) behind an initiative: follow-up rounds, branching, timing, external calls | | `whatsapp-templates` | Write WhatsApp openers that pass Meta review and earn replies | | `launch-initiative` | Create and launch an initiative end to end, whatever the job, using the context formula from Boom's best performers | | `manage-participants` | Add, monitor, and stop the people in an initiative | | `analyze-results` | Turn a running initiative's [extracted data](/extraction) and transcripts into insight | ## Related > **[Use MCP](/use-mcp)** > > Connect Boom to Claude, Cursor, or any AI tool. > **[Authentication](/authentication)** > > API keys for scripts and server-to-server calls. # Smart Sending Source: https://docs.useboom.ai/smart-sending Limit how often one person hears from you, across every initiative in your organization. Smart Sending keeps your initiatives from piling up on the same person. Without it, two journeys can message someone minutes apart, and a busy week can reach one person a dozen times. That annoys people, and on WhatsApp it leads to spam reports that lower your number's quality rating and can get it restricted. Smart Sending is off until you turn it on, in **Settings › Smart Sending**. ## How it works There are two rules. Each has its own on/off switch, and you can use either, both, or neither. | Rule | What it limits | Checked when | | - | - | - | | **Message limit** | At most **N** messages to one person on one channel within a window, for example 1 per day on WhatsApp. | A journey is about to send a message. | | **Cooldown** | After any initiative messages someone, no **other** initiative can start with them for **D** days. | Someone is added to an initiative. | **What counts, and what is limited:** proactive messages sent by a journey step. That is WhatsApp templates, SMS and email. **What never counts and is never limited:** * The agent's replies inside a conversation. * Messages your team sends from the inbox. * Instagram and Messenger messages, which only go out inside a conversation the person started. * Anything in your test environment, and WhatsApp messages to your organization's test numbers. A few things that are true for both rules: * **Each channel is counted on its own.** An email never uses up someone's WhatsApp allowance. * **The window is rolling, not a calendar day.** "1 every day" means 24 hours after the last message, not "once today". * **First come, first served.** Every message counts toward the limit, whichever initiative sent it, and the first one to reach the person takes the slot. There is no priority between initiatives. Use [Exceptions](#exceptions) for the ones that must always go out. * **A change applies at once, to everyone.** Raise, lower or turn off a rule and the next message is checked against the new setting. ## Set it up **1. Review your exceptions first** Open **Settings › Smart Sending** and look at **Exceptions**. Add every initiative whose messages people need, like document requests, payment links, renewals or order updates. Those always send. **2. Turn on the message limit** Flip **Message limit**. A dialog asks how many messages and how often (for example "At most 1 message per person every day") and on which channels. WhatsApp is ticked by default; tick SMS or Email only if you want them limited too. The dialog also lists any initiative that would lose messages, each with a one-click **Add to exceptions**. Nothing changes until you click **Turn on**. **3. Optionally, turn on the cooldown** Flip **Cooldown between initiatives** and pick how long: 7, 14, 30, 60 or 90 days. **4. Check an initiative** Each initiative's page says, under its details, whether it **Always sends** or **Follows Smart Sending**, and which rule applies. > [!WARNING] > > Do step 1 before step 2. An initiative that sends one person several messages a > day, like a document collection flow, would otherwise lose most of them. The > settings page flags every initiative that sent one person more than one message > in a day over the last two weeks. ## The message limit Pick **1, 2 or 3 messages** per person, **every day, every 2 days, or every week**, on the channels you tick. The card then shows the limit with a checkbox per channel, so you can extend it to another channel or narrow it later. To give each channel its own numbers, open **Customize per channel**. There you can set up to 10 messages in a window of up to 7 days (168 hours) per channel. A send held for approval as a [draft](/drafts) is checked when it is delivered, after someone approves it, not when the draft is created. ## The cooldown The cooldown is the longer rule: "don't contact this person from another campaign for 30 days". It is checked when someone is added, so a person inside their cooldown is never added in the first place, rather than added and then stopped halfway. It counts messages on any channel from **other** initiatives. An initiative's own messages never block it. These are always let through: * Someone who messaged you first, or commented on your post. * A conversation being handed from one initiative to another. * An initiative in [Exceptions](#exceptions). The settings page offers 7 to 90 days. Through the [MCP tools](#set-it-up-with-claude) it can be up to 365 days. If you use [webhooks](/webhooks), a person turned away by the cooldown shows up as `enrollment.rejected` with `reason: "cooldown"`. When you start a journey for several people at once, the confirmation says how many were skipped because of the cooldown. ## Exceptions Every initiative follows the limit and the cooldown, unless it is an exception. An exception **always sends**: its messages go out even over the limit, and it ignores the cooldown. * **Add them** in **Settings › Smart Sending › Exceptions** with **Add initiative**, which opens a searchable list showing each initiative's status. You can tick several and add them in one go. * **Or on the initiative itself:** turn on **Always send** under **Smart Sending** in its **Agent** step. On a [campaign](/campaigns), it is under **Options › Smart Sending**. * [Transactional messages](/transactional-messages) start as exceptions, since a notification has to go out. You can change that with **Always send**, under **Options** on the Transactional's page. **Exceptions still count toward the limit.** A payment reminder at 10:00 means a promo at 11:00 is skipped. That is on purpose: on WhatsApp, two messages close together is what gets reported as spam, whatever they were for. ## What happens to a skipped message A message over the limit is **skipped, not sent later**, and not resent. * **In the journey,** the send step takes its **Capped, not sent** exit instead of **Sent**. Leave that exit unconnected and the person's run ends there. Connect it to do something else: wait a day and try again, try another channel, or end on purpose. * **In Executions,** the run is labelled **Skipped** rather than Completed. Hovering the label names the rule, for example "Over the limit: 1 message every day on WhatsApp". The **Skipped by Smart Sending** filter shows only those runs. * **On the person's timeline,** the step reads **Skipped by Smart Sending**. * **In totals,** the initiative's overview counts runs skipped by Smart Sending and leaves them out of **Completed**, since they were stopped, not finished. Settings › Smart Sending shows how many messages were skipped in the last 14 days. Sent counts in analytics only include messages that went out. The person can be added again once the window clears. ## Recommended setups | If you… | Try | | - | - | | Run promos and flows on WhatsApp, and want every email to go out (e-commerce) | Message limit **1 every day, WhatsApp only**. Order, delivery and subscription notices as exceptions. No cooldown. | | Don't want to contact someone again from another campaign for weeks or months (fintech, subscriptions) | **Cooldown** of 30 to 90 days, plus a WhatsApp limit of 1 every day. Account and payment notices as exceptions. | | Collect documents or payments, with several messages a day by design | Put those initiatives in **Exceptions** first, then any limit you like for the rest. | ## Coming from Klaviyo? If you used Klaviyo's Smart Sending, most of it maps directly. The differences: | | Klaviyo | Boom | | - | - | - | | Where you turn it off | Per campaign and per flow message | Per initiative (**Exceptions** or **Always send**) | | The limit | 1 message per window, per channel | 1 to 10 messages per window, per channel | | Messages that always send | Transactional messages, which also **don't count** toward the limit | Exceptions, which **still count** toward the limit | | "Don't contact for X months" | Built with segments | Built in, as the **cooldown** | | A skipped message | Skipped, not rescheduled | Skipped, not rescheduled; the journey can route it through **Capped, not sent** | | Changing the window | People already inside the old window stay on it | Applies to everyone immediately | ## Set it up with Claude Everything on the settings page is also available through [Boom's MCP tools](/use-mcp), for admins of your organization: | Tool | What it does | | - | - | | `smart_sending_policy_get` | Reads the limits per channel and the cooldown. | | `smart_sending_policy_set` | Sets one channel's limit, e.g. `{ "channel": "WHATSAPP", "maxMessages": 1, "windowHours": 24 }`, or turns it off with `{ "channel": "WHATSAPP", "off": true }`. | | `smart_sending_cooldown_set` | Sets the cooldown in days (1 to 365), or turns it off. | | `initiatives_update` | Makes an initiative an exception with `smartSendingMode: "FORCE"`. `DEFER`, the default, follows the rules. | ## Good to know * **Nothing is limited until you turn a rule on.** Your organization still records every send, so the day you turn a limit on it already knows who was messaged recently. * **If Smart Sending can't check, the message goes out.** An outage on our side never silently stops your messages. * **To turn a rule off,** flip its switch. It stops immediately. * **Holding a skipped message and sending it once the window clears** is coming. # Start here Source: https://docs.useboom.ai/start-here What Boom is, and which part of this site is yours depending on what you do. Boom puts an AI agent in conversation with your customers over WhatsApp. One agent per organization, briefed once from your knowledge base, sent on whatever mission you need: winning back customers who left, answering support, onboarding someone who stalled, qualifying a lead, collecting a document, asking why people churn. The four ideas the rest of this site is built on: * **[The agent](/the-agent)** does the talking, in your voice, and hands off to a person when it should not handle something. * **[An initiative](/initiatives)** is one mission: who you reach and what the agent is trying to accomplish. * **[A journey](/journeys)** is the flow that carries a mission, the messages and waits and branches. * **[Extraction](/extraction)** turns each conversation into typed fields you can count and export, instead of transcripts nobody reads. If a customer writes to you first, no mission is needed. See [inbound conversations](/inbound). ## Which part of this site is yours **If you run the campaigns and not the code**, read this page and the five above it. They are prose, no code required, and they will tell you what Boom can do and what the words mean when someone on your team says "the segment hasn't been evaluated". The day to day work of creating and launching happens in the Boom app. This site documents the API underneath it. **If you are integrating Boom from your own systems**, start with the [API overview](/api-reference/overview), then the quickstart and authentication pages in that same tab. [One uniform surface](/one-surface) explains why every REST endpoint has an identically named MCP tool. **If you want an AI agent to operate Boom for you**, [connect over MCP](/use-mcp) and install the [skills](/skills). That is the fastest path from nothing to a running initiative, because the agent reads the same documentation you would and already knows the order of operations. ## Two things worth knowing before you plan around them WhatsApp is the conversational channel. Email is outbound only right now: a journey can send one, and there is no reply loop back to the agent. The dashboard, the API and MCP are not three copies of the same surface. A few things live only in the app, and this site says so where it matters: creating an email template, connecting a database, re-running extraction over conversations that already closed. ## Related > **[The agent](/the-agent)** > > What answers your customers, and how it improves from your corrections. > **[Initiatives](/initiatives)** > > One mission: audience, objective, and what it comes back with. > **[Use MCP](/use-mcp)** > > Point Claude or Cursor at Boom and sign in. No API key. > **[API overview](/api-reference/overview)** > > Every capability over REST, with the same names over MCP. # Template variables Source: https://docs.useboom.ai/template-variables How to bind a WhatsApp template's placeholders to live data in a journey. A `SEND_MESSAGE` node in its default content mode sends an approved WhatsApp template. (In `mode: "free_text"` it sends text you wrote, which binds by name through `bindings` — the same grammar as `SEND_SMS` in the note below.) Templates have positional placeholders (`{{1}}`, `{{2}}`, …). You fill each one by setting **`templateBindings`** on the node: a map from a placeholder to the **path** of the value it should carry: ```json theme={null} { "templateBindings": { "1": "customer.name", "2": "engagement.workflowState.plan" } } ``` For a value that is the same for every recipient (a discount code, a fixed URL path), bind fixed text instead of a path by prefixing it with `literal:`: `"3": "literal:BIENVENIDA20"`. In the visual builder, switch the placeholder's row from **Variable** to **Text** and type it. If a placeholder has no binding, or its path doesn't resolve, the message goes out with that slot blank. So bind every placeholder, and use a path that actually resolves. **Which paths resolve depends on how the journey is triggered** (see the matrix below). > [!NOTE] > > **`SEND_SMS` binds the same paths, but by name and with a stricter rule.** SMS > bodies are free text, so placeholders are named rather than positional — > `{{customer.name}}`, not `{{1}}` — and the map is `bindings`, not > `templateBindings`. The visual builder writes each path as its own name; an > alias works too, as long as the body uses the alias. > > Two differences matter. Publishing is **refused** if the body names a > placeholder `bindings` does not supply, rather than sending a blank slot. And > an unresolved placeholder is left visible at send time instead of blanked, so > a binding to a path that does not resolve texts the customer the literal > braces. `engagement.chatLink` is the one path to avoid here: it resolves only > on the email step. > [!NOTE] > > **The catalog is the source of truth.** The variable catalog (`GET > /api/v1/journeys/{journeyId}/message-variables`, the > `journeys_message_variables` MCP tool, or the visual builder's picker) is > authoritative for a specific journey. It already accounts for the journey's > trigger, segment, and schemas. If this page and the catalog ever disagree, > trust the catalog. ## Where the values come from Each namespace is supplied by a different actor: | Namespace | Supplied by | Examples | | - | - | - | | `customer.*` | Boom: the contact's messaging identity. Always resolves. | `customer.name`, `customer.lastName`, `customer.phoneNumber`, `customer.email` | | `person.*` | Your CDP: **live person attributes**, resolved when the message is sent | `person.plan`, `person.city` | | `engagement.context.*` | You: participant data supplied at enrollment (CSV import, manual add, participants API) | `engagement.context.cohort` | | `engagement.workflowState.*` | The trigger: **event/segment data**, frozen at enrollment | `engagement.workflowState.plan` | | `event.*` | Short alias of `engagement.workflowState.*` | `event.plan` | | `engagement.extracted.*` | The conversation: extraction outputs | `engagement.extracted.churn_reason` | | `engagement.nodeOutputs..*` | An earlier HTTP request node's response | `….status`, `….body.result` | | `.*` | A record a **Look up record** step saved under that name | `brand.logo_url`, `order.total` | | `engagement.documents` | Files the participant sent in the conversation (HTTP node body only) | `engagement.documents` | | `engagement.conversation.*` | The live conversation: what the participant has sent — how long ago, how many documents, and the text of their last message — for Decision conditions, plus its id and inbox link (HTTP node only) | `engagement.conversation.lastInboundText`, `engagement.conversation.documentCount` | A journey email goes to the email on the person the journey runs for. When that differs from the contact's email (for example, a WhatsApp contact who signed up again with a new address), the email uses the person's details for `customer.email` and the `customer.*` name fields, while other `customer.*` fields and `engagement.conversation.*` render empty. Namespace notes: * **`person.*`** re-resolves at send time from the CDP person the enrollment named, and is **offered for every trigger, including event-triggered journeys**. The phone or email lookup is only a fallback, used for contacts enrolled without a CDP person, and that fallback needs **exactly one** match: if several people share the phone or email, the value resolves blank. Full resolution order below. * **`engagement.context.*`** is validated against the initiative's context schema and only exists when you enroll the participants yourself (CSV, manual add, API). Event- and segment-triggered enrollment never populates it. * **`engagement.workflowState.*`** carries what the trigger delivered (the event's properties, the segment's output columns, or an optional payload on manual enrollment), frozen at enrollment. `_event` is a reserved key. * **`event.*`** is the same data under a shorter name: `event.due_date` and `engagement.workflowState.due_date` read the same value. Templates migrated from Customer.io that use `{{event.}}` work as written, including inside Liquid tags like `{% if event.amount > 0 %}` and `{% for item in event.items %}`. * **`engagement.extracted.*`** requires a published extraction schema and an upstream conversation block that has already run. A custom attribute you set on a person (for example `plan`) is referenced as **`person.plan`**, not `attributes.plan`. > [!WARNING] > > `attributes.` is the syntax for **DECISION / CASE conditions**, a > different part of a journey. It does **not** work in a message binding. In a > `SEND_MESSAGE` template binding, a custom person attribute is always > `person.`. ## What resolves, per trigger For the **first message** of a journey: | Trigger | `customer.*` | `person.*` | `engagement.context.*` | `engagement.workflowState.*` | | - | - | - | - | - | | Manual / CSV / API add | ✅ | ✅ when a CDP person is linked | ✅ | Only if you pass a payload | | Segment | ✅ | ✅ when a CDP person is linked | No | ✅ segment output columns | | CDP event | ✅ | ✅ when a CDP person is linked | No | ✅ event properties | `person.*` values are read **live at send time**, not frozen at enrollment, so they behave the same for every trigger. Which CDP person they are read from is decided in this order: 1. **The person the enrollment named.** If you enrolled the contact by `personExternalId`, which an event-triggered journey always does, that identity is used directly. It is authoritative: if that person has since been deleted or removed, `person.*` resolves blank rather than falling back. 2. **Otherwise, a lookup by phone or email.** For contacts enrolled without a CDP person (manual add, CSV import), Boom matches the contact back to a person. This needs **exactly one** match: zero matches, or several people sharing that phone number, resolve blank. So duplicate CDP person rows only blank `person.*` on path 2. If your events carry `personExternalId`, duplicates don't affect you. `engagement.extracted.*`, `engagement.nodeOutputs.*`, and `engagement.documents` are produced **during** the run, so they resolve later in the journey for every trigger type, once the step that produces them has run. * **`engagement.documents`** carries every file type the participant sent — PDFs, but also the photos and screenshots people send over WhatsApp. If your endpoint accepts only some of those, set `documentMimeTypes` on the HTTP request node (e.g. `["application/pdf"]`) and Boom withholds the rest instead of posting them for you to reject. Each entry is checked at publish against the types a customer's file can actually arrive with, so a misspelling is refused there rather than quietly withholding every document. Two things to handle either way: the array **can arrive empty** — a participant who sent only photos to a PDF-only node leaves nothing to send — and a file whose type Boom never recorded is withheld too, so accept an empty list rather than treating it as an error. ### Linking to the conversation from an HTTP request To put a link to the conversation in a Slack or webhook notification, use: ``` {{engagement.conversation.inboxUrl}} an absolute link to the conversation {{engagement.conversation.id}} just the conversation id ``` Both are offered in the HTTP request node's variable picker, under **Conversation link**, and resolve **only in an HTTP request node**. They are refused when you publish a message, an email, a dispatch, or a Decision condition that binds them — an id is not something to show a customer, and you cannot know it when you are writing the journey, so a Decision that checks it just fires every time. They fill in once a conversation node in the journey has **closed**, which is where the extracted values arrive too — so a notification placed after the AI conversation gets the link, and one placed before it gets an empty string. Validation warns you about that placement rather than letting it ship silently. If several conversation nodes converge on the same request node, you get whichever one actually ran; you never have to name it. > [!NOTE] > > There is currently no way to get the link at the moment a customer **replies**, > before the conversation ends — the value exists only once a conversation node > closes. Tell us if you need it. > [!NOTE] > > This namespace is a closed set, and where each value works depends on the key: > > * **Decision conditions only** — `hoursSinceLastInbound`, `lastInboundText`, > `lastInboundKind`, `newDocumentCount`. > * **Decision conditions, and also messages, emails and dispatches** — > `documentCount`. > * **HTTP request nodes only** — `id`, `inboxUrl`. > > Any other key under `engagement.conversation.`, and any of these used outside > the place it works, is refused at publish rather than sent as an empty string. > To route on `lastInboundText`, use the **Matches keyword** operator rather than **Contains**: it is the same matcher your inbound trigger keywords use, so it folds accents and case and matches whole words (Contains is a raw substring test, so `precio` also fires on `precioso`). ## Worked examples **Event-triggered journey**: the event's properties are the participant data. An event like `loan_approved` with a `plan` property binds as: ```json theme={null} { "templateBindings": { "1": "customer.name", "2": "engagement.workflowState.plan" } } ``` **Segment- or manually-triggered journey**: custom CDP person attributes are available as `person.`: ```json theme={null} { "templateBindings": { "1": "customer.name", "2": "person.plan" } } ``` ## Formatting a value A binding can append one formatting helper after a pipe. This is how you turn a raw API value into something a customer can read, without changing the API: ```json theme={null} { "templateBindings": { "1": "customer.name", "2": "engagement.nodeOutputs.fetch.body.total | currency:MXN", "3": "engagement.nodeOutputs.fetch.body.dueDate | date" } } ``` | Helper | Example | Notes | | - | - | - | | `currency:CODE` | `total \| currency:MXN` | The code is required. Localized to your organization's language. | | `date`, `date:long` | `dueDate \| date` | Accepts an ISO string, epoch milliseconds, or a date. Localized. | | `sum`, `sum:field` | `lines \| sum:amount` | Totals a numeric array, or one field across an array of objects. | | `count` | `buyers \| count` | Number of elements in an array. | | `fixed:N` | `rate \| fixed:2` | Fixed decimal places, 0–20. | Three things to know: * **One helper per binding. Chaining is not supported** and is rejected rather than partly applied, so `total | sum | currency:MXN` is an error at publish, not a silent half-format. * **`sum`, `count` and `fixed` are not localized**, on purpose: they stay a plain number so they remain usable in a condition comparison and valid inside a JSON request body. Only `currency` and `date` follow your organization's language. * **A helper applied to a value that is missing is skipped**, so an absent field renders blank rather than `NaN` or `Invalid Date`. Helpers also work in an HTTP Request node's URL and body — where a *formatting* helper is usually the wrong choice, because `currency:MXN` sends `"MX$1,234.50"` to an API that expects `1234.5`. Use **Test request** on the node to see the resolved body exactly as it will be sent before you publish. > [!TIP] > > Reaching for an array index like `body.buyers.0.account` is worth a second look: > if the response can contain more than one element, position `0` is arbitrary and > you may send the wrong record's data. Branch on `body.buyers | count` first and > handle the "more than one" case explicitly. ## Liquid in email templates Email templates can use [Liquid](https://shopify.github.io/liquid/), the same template language as Customer.io, Braze and Klaviyo, so a template you migrate keeps its logic. Liquid is enabled per organization — ask your Boom contact to turn it on. ```liquid theme={null} Hola{% if customer.first_name != blank %}, {{ customer.first_name | titlecase }}{% endif %}, Tu pago de {{ event.amount | currency: "MXN" | default: "tu saldo" }} vence el {{ event.due_date | date: "%d/%m/%Y" }}. {% for factura in event.invoices %} - {{ factura.folio }} {% endfor %} ``` A path read only inside a Liquid tag (`if`, `for`, `case`) can't be rebound on a **Send email** step, so write event data there as `event.*` (or `engagement.workflowState.*`) — not as a name the step would have mapped. * Everything in core Liquid works: `if`/`unless`/`case`, `for`, `assign`, `blank`/`empty`, and the standard filters, chained. * Names the template defines itself (`item` in `{% for item in order.items %}`, `forloop.index`, an `assign`) are not template variables: they don't appear in the template's `variables` or under a **Send email** step, since there is nothing to bind them to. * The helpers above work as filters too, plus `titlecase`. `date` takes `long`, or any strftime format. * Existing templates render exactly as before. * A template that is not valid Liquid cannot be published. ### A default when a value is empty With or without Liquid, every email variable can carry a fallback. In a journey's **Send email** step, type the text under a variable in the **If empty, use…** field. It overrides the template's own fallback for that step, and the **Preview** under the variables shows the email with them applied. ## Discover the valid variables Rather than guess, ask the API (or MCP) for the exact set a given journey can bind: the same list the visual builder shows: ```http theme={null} GET /api/v1/journeys/{journeyId}/message-variables ``` It returns the variables grouped by source, each with a ready-to-use `path`. Copy a `path` straight into `templateBindings`. For event-triggered journeys the response includes a `notes` field explaining which groups are unavailable and why. ## Validation When you save or validate a journey, a binding whose path can't resolve (for example `attributes.plan` or `customer.attributes.plan`) is reported as an error and blocks publishing, so a broken binding never ships silently. Fix it by switching to a path from the catalog above (custom attributes → `person.`). The same applies to a formatting helper: an unknown helper name, or one that needs an argument and has none (`total | currency` with no code), blocks publishing. Binding a field your org marked **hidden** (sensitive), such as `customer.email` or `person.`, is allowed, because the person receives their own value. It returns a `SENSITIVE_FIELD_SENT` warning naming the field, since the value then sits in the conversation where the agent and inbox users can read it. Warnings never block publishing. # The Agent Source: https://docs.useboom.ai/the-agent One agent per organization, briefed by a knowledge base, that improves from the corrections your team gives it. Boom runs **one agent per organization**, not a separate bot per use case. The agent that runs a win-back campaign is the same agent that staffs a support line, runs a research round, handles onboarding, qualifies a lead, or collects a document from a customer. Whatever the mission, it carries your organization's voice and what it knows about your business into the conversation. Treat it like a teammate you brief, not a workflow you have to feed a prompt to on every step. You set your refund policy, your tone, and the facts about your business once, and the agent applies them in every conversation it runs, regardless of which mission that conversation belongs to. ## How you brief it The **knowledge base** is how you brief the agent. Each organization has its own, organized into areas: * **Brand voice**: how the agent should sound. * **Policies**: what it can promise, refund, or has to escalate. * **Data schemas**: the people, objects, and events it can reason about. * **Company information**: the facts about your business it can draw on. The agent reads from this knowledge base to answer in your voice and stay inside your rules, no matter which initiative the conversation is part of. You don't edit the knowledge base through this API. You prepare its content and hand it to Boom, and the [`build-knowledge-base` skill](/skills) runs that as a guided interview: it asks what it needs, researches your own site, and hands you a structured bundle to pass along. On top of the knowledge base, each initiative carries its own **per-initiative context and guiding questions**, the specifics of that one mission. The knowledge base is the constant across every conversation; the per-initiative layer is what changes from mission to mission. ## What it knows about the person it is talking to An initiative's `context` is a briefing, not a template. Nothing in it is interpolated, so writing a placeholder there gets you the literal text and no value. Facts about the individual reach the conversation two different ways, and the difference matters when you are designing around them. **Pushed in, on every turn.** Whatever you supplied when you enrolled that person, plus anything the trigger carried, is part of the agent's context from the first message onward. This is the dependable path: no lookup, no decision, nothing to enable. Use it for anything that should shape how the conversation opens. **Fetched, if the agent decides to.** The agent can also read live data from your [CDP](/cdp/overview) mid-conversation, which is what makes it useful for questions that only come up because the conversation went there. Three things gate it, so do not design an opener around it: the CDP read tools are entitled per organization and are **not on by default**, only the first few person attributes are listed for the agent up front, and naming a field in your briefing does not compel the agent to go look it up. > [!TIP] > > If a value matters to every conversation, pass it at enrollment. If you do want > a live lookup, write the instruction as an imperative that names the tool and > the field, rather than mentioning the field and hoping the agent infers it. **Fetched from your own systems.** Your CDP is not the only live source. You can give the agent a tool that calls one of your own HTTP endpoints — an order lookup, an account status — and it will call it mid-conversation when the question calls for it. You define the endpoint, the arguments the agent may fill in, and, importantly, **exactly which fields of the response the agent is allowed to see**: everything else is discarded before it reads the answer, so a record carrying an email and a phone number does not put them in front of the agent because you needed a status. Authentication belongs in a stored credential rather than a header you type: a credential is encrypted, resolved per environment when the tool runs, and its value is never shown back to you or to the agent. The same screen runs the tool against your real endpoint before any customer sees it, and lets you save a canned response for testing — including a failure response, which is the one worth writing. It is how you check the agent says it could not look something up instead of inventing an answer. ### Fields that must ride along with a search hit A CDP search answers *which* object matched — id, display name, type, relevance — and nothing about it. That is usually right: the agent reads the object when it needs details. But some attributes are ones an answer cannot be assembled without, and the classic case is a URL slug. Given a product's name and no slug, a model can produce a link that looks plausible and resolves to the wrong page, or to nothing. Name those attributes and every search hit carries them: ```bash theme={null} curl -X POST "$BASE/custom-objects/types/product/search-fields" \ -H "Authorization: Bearer $BOOM_KEY" \ -H "Content-Type: application/json" \ -d '{"attributeKeys":["name_slug","is_active"]}' ``` Each hit then includes an `attributes` object with just those keys. The selection is replaced on each call, and `{"attributeKeys":[]}` clears it. Keep the list to two or three. Every named key is fetched for every result, so this is a way to close a specific gap — not a projection API for pulling a whole record through search. Keys must already exist in the type's attribute catalog, which is built from data you have actually sent; a key you have never upserted is rejected rather than silently ignored. > [!NOTE] > > This changes what the agent is *given*, not what it is *told*. It is the more > reliable of the two: an instruction to look a field up can be skipped, whereas a > field present in the result cannot be missed. ## How it improves from feedback When someone on your team gives a thumbs-down to an agent message and says what was wrong, an internal agent reviews the reasoning behind that message, patches the knowledge base, and writes a new evaluation case that reproduces the failed conversation. > [!NOTE] > > The new version of the agent only ships if it passes that new evaluation > case, alongside every evaluation case written before it. A correction that > regresses a past fix never reaches your organization. Each correction your team makes is locked in as a permanent test, not a one-time patch. The agent you have after a month of use is meaningfully better than the one you started with, because every mistake anyone corrected along the way is now a case it has to keep passing. ## When it hands off to a person The agent handles a conversation end to end when it can, and escalates the ones it shouldn't handle. Escalated conversations land in a shared inbox where a person can take one over: reply, leave internal notes, and send or receive files. The customer's attributes sit alongside the conversation, so whoever picks it up has context without asking the customer to repeat themselves. ## Guardrails while it talks Before the agent responds, the conversation is checked for prompt injection. Before a response goes out, it's reviewed to stop it from sharing personal data it shouldn't. > [!WARNING] > > If a customer asks to stop, that's respected: the contact is not messaged > again, even if someone re-enrolls them into a later initiative. Do Not > Contact is enforced by the platform, not by whoever built a given flow. ## Reliability Messages are queued and retried with spacing rather than sent in a burst. If the primary model provider goes down, Boom falls back to another one so conversations don't stall. ## Related > **[Journeys](/journeys)** > > The workflow behind an initiative, and how you author and publish one. > **[Extraction](/extraction)** > > The typed fields the agent pulls out of a conversation, and how you read > them back. > **[Customer Data Platform](/cdp/overview)** > > The people, objects, and events behind the data schemas in the > knowledge base. > **[Use MCP](/use-mcp)** > > Connect an AI tool to the same organization and capabilities. # Send transactional messages Source: https://docs.useboom.ai/transactional-messages Send one WhatsApp or email message every time something happens in your system, like a payment link or an order confirmation. A **Transactional** sends a message every time an event happens in your system: a payment link is created, an order is received, an appointment is booked. Your system sends Boom the event, and Boom sends the customer a WhatsApp template, an email template, or both. It is the simplest kind of initiative. There is no conversation to design, no audience to pick and no schedule: one event, one message per channel. > [!NOTE] > > Use a [Campaign](/campaigns) to message a list of people once, and an **Initiative** > when you need waits, reminders or a conversation with the agent. See > [initiatives](/initiatives). ## How it works 1. Your system records an event, for example `payment_link_created`, for a person. 2. Boom starts one run for that event and sends each message you set up: the email first, then the WhatsApp. 3. The run ends. Every event starts its own run, so two payment links created at the same time send two messages. Each run is identified by one field of the event that you choose, like `paymentLinkId`. **Each value sends once, ever, per customer**: if an event with the same `paymentLinkId` arrives again, no second message goes out. That is what makes retries from your side safe. ## Before you start * **Transactional turned on for your organization.** Boom enables it per organization. If you don't see **Transactional** in the sidebar, or creating one over the API returns `transactional_not_ready`, ask your Boom contact. * **A channel.** A connected WhatsApp number, a verified email domain, or both. * **A template per channel.** A WhatsApp template approved by Meta, or a published email template. Its variables (like `{{1}}` or `{{customer_name}}`) are filled from the event or from the customer's profile. * **An API key** for your organization, to send events. See [authentication](/authentication). ## Set it up **1. Create the Transactional** In Boom, open **Transactional** in the sidebar and create a new one. Give it a **Name** (picking the event names it for you until you change it). Everything below is on the same screen. **2. Pick the messages** Under **Message**, click **Add a message**, then pick a channel and its template. Add a second message to send on both channels. On an email, the **From** chip picks the sender; leave it to use the template's. **3. Pick the event** Under **When it sends**, click **Choose the event that sends it** and pick the event. If your system has not sent it yet, type its name. Event names use letters, numbers and underscores only: `payment_link_created`, not `payment_link.created`. **4. Fill the template** For each template variable, choose where its value comes from: a field of the event (like `customerName` or `paymentUrl`) or a field of the customer's profile (like their first name). A field the event has not sent yet can be typed as a new field. **Preview** on each message shows it as the customer gets it. Every variable must be filled. A message with an empty variable would reach the customer with the raw `{{1}}` in it, so Boom refuses to launch until each one is set. **5. Choose how many notifications per customer** **One per event** (the default) sends the notification every time the event arrives. **One per field value** sends once per value of a field you pick, usually an id such as `orderId`: use it when a customer can have several at once, or when a retry must never send twice. See [how many notifications a customer gets](/transactional-sends). **6. Review before sending (optional)** Under **Options**, turn on **Review before sending** to hold every message in the Transactional's **Drafts** tab until someone approves it. Rejecting the email still lets the WhatsApp message go. While a message waits, that customer's next event sends nothing, so pick **One per field value** if events can repeat. See [drafts](/drafts). **7. Launch** Click **Launch**. From now on, every matching event sends a message. Not ready yet? **Save draft** keeps it, and the line next to the buttons says what is still missing. **API request**, next to the event, shows the exact request your system has to send. ## Send the event Make sure the customer exists, with the phone number and email Boom should use: ```bash theme={null} export BOOM_KEY="boom_org_YOUR_KEY_HERE" export BASE="https://www.useboom.ai/api/v1/cdp" curl -X POST "$BASE/people" \ -H "Authorization: Bearer $BOOM_KEY" \ -H "Content-Type: application/json" \ -d '{"externalId":"cus_42","email":"ana@example.com","phoneNumber":"+5215512345678"}' ``` > [!NOTE] > > Saving a person **merges** into what is stored: a field you leave out keeps > its value, so a save that sends only the email leaves the phone number alone. > To remove an attribute, send it as `null`. Pass `"mode": "replace"` only if > you always send the complete profile; with it, anything you leave out is > cleared, including `phoneNumber`. Then record the event each time it happens: ```bash theme={null} curl -X POST "$BASE/events" \ -H "Authorization: Bearer $BOOM_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "payment_link_created", "externalId": "evt_001", "personExternalId": "cus_42", "properties": { "paymentLinkId": "pl_9", "customerName": "Ana", "paymentUrl": "https://pay.example.com/pl_9" } }' ``` * `name` is the event you picked in step 2. * `externalId` is your id for this event. Recording the same `externalId` twice stores it once, but with **one per event** a retry still sends again. With **one per field value**, a value that already sent never sends again. * `personExternalId` is the customer's id in your system. Their phone and email come from their profile, not from the event. * `properties` carries the fields your messages use, plus the field you picked for **one per field value**, if you did. Use the single-event endpoint. Batch recording stores events but does not send messages. See [events](/events) for the full reference. > [!TIP] > > **API request**, next to the event on the Transactional's page, shows this > request with your own event name and fields, and marks what each field is used > for. Copy it from there. ## See how it's doing The Transactional's **Analytics** page shows how its messages arrive. Its **Detail** page shows only the setup. * **Runs**, one per event, and how many people they reached. * **Delivery rate**: delivered out of every message sent, failed ones included. * **Open or read rate**: emails opened and WhatsApp messages read, out of those delivered. Email opens are counted only once open tracking is set up in **Settings › Email**; until then the page says so and shows no email open rate. Some inboxes block the tracking pixel, so email opens still run low. * **Reply rate**: WhatsApp messages the customer answered within 24 hours, before any other message reached them in that chat. Email replies are not tracked. * **Why messages didn't arrive**: failures by reason (an address that bounced, a number that isn't on WhatsApp) kept apart from messages that were never sent (no email address, no WhatsApp number, Smart Sending). Pick a range (today, 7 or 30 days, or your own dates) and a channel. Each number is compared with the same length of time just before. Until your system sends the first event, the page shows the request to send instead. ## Do it from code or an agent Everything above can also be done over the [API or MCP](/use-mcp), in three calls: 1. **Create it with the whole setup**: `initiatives_create` (`POST /api/v1/initiatives`) with a `transactional` object. 2. **Check it is ready**: `initiatives_transactional_get` (`GET /api/v1/initiatives/{id}/transactional`) returns `readyToLaunch` and a `missing` list, each item with how to fix it. 3. **Launch**: `initiatives_launch` (`POST /api/v1/initiatives/{id}/launch`). ```bash theme={null} curl -X POST "https://www.useboom.ai/api/v1/initiatives" \ -H "Authorization: Bearer $BOOM_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Payment link", "transactional": { "eventName": "payment_link_created", "parallelRunsBy": "paymentLinkId", "whatsapp": { "templateId": "tpl_123", "channelId": "ch_456", "templateBindings": { "1": "person.firstName", "2": "engagement.workflowState.paymentUrl" } }, "email": { "templateId": "etpl_789" } } }' ``` A binding names where a template variable's value comes from: `engagement.workflowState.` (or its short form `event.`) for a property of the event, `person.` for a field of the customer's profile, or `literal:` for fixed text. Every WhatsApp placeholder needs one. Email variables without one use the template's own value. To change it later, send only what changes to `initiatives_transactional_configure` (`PATCH /api/v1/initiatives/{id}/transactional`); `null` removes a field. A launched Transactional starts using the change right away. Pass `reviewBeforeSending: true` to hold every message in Drafts for approval; it is refused with `drafts_disabled` while drafts are turned off for your organization. Find the ids with `journeys_message_channels` (WhatsApp numbers), `journeys_message_templates` (approved WhatsApp templates for a number) and `journeys_email_templates` (published email templates). | Error | What it means | | - | - | | `422 transactional_not_ready` | The Transactional cannot be created as asked, for example because a template is not approved. | | `422 journey_not_ready` | Launch was refused because the setup is incomplete, for example a variable nothing fills. The message says what to fix. | | `422 transactional_event_only` | People cannot be added by hand. A Transactional starts only from its event. | | `400 transactional_not_recurring` | A Transactional cannot also be recurring. | | `400 not_transactional` | A Transactional tool was called on another kind of initiative. | ## Troubleshooting **Nothing was sent.** Check that the Transactional is launched, that the event name matches exactly, and that you used the single-event endpoint. Then open **Executions** on the Transactional to see each event it received. **The customer did not get the WhatsApp.** Their profile needs a phone number in E.164 format (`+52...`). If it has one, Meta may have declined to deliver the template. The Transactional then counts it as **failed**, not sent, and the inbox shows why. The most common reason is error `63049`: Meta limits how many template messages one person receives. The limit is **per recipient**, not only per burst. A number that received several templates it didn't answer can have every later template dropped for hours, even a single one. What helps: * File order and payment messages under the **Utility** category, not Marketing. Meta limits Marketing templates much more. * Don't send the same person several templates within seconds. Combine them into one message when you can. * Expect some drops, and send the email too for anything the customer must receive. **The email did not go out.** The email domain must be verified in **Settings › Email**, and the customer needs an email on their profile. **The email went to spam.** A new sending domain has no reputation yet. Make sure SPF, DKIM and DMARC all pass for the domain you send from. Send from a real address like `pagos@yourcompany.com` rather than `no-reply@`, and start with a low volume that you raise over a few days. **A tool or field from the docs is missing in your agent.** MCP clients keep the tool list they loaded when they connected. After a Boom release, reconnect the Boom MCP server so the client picks up new tools and fields. **A second event sent nothing.** Its identifying field had a value that was already sent to that customer. Each value sends once, ever. See [how each send is identified](/transactional-sends). **Launch is disabled.** The line next to the buttons, starting with "Before launching", names what is still missing, including any template variable nothing fills. # How many notifications a customer gets Source: https://docs.useboom.ai/transactional-sends One per event, or one per value of a field: how a Transactional counts its sends, and what happens when an event arrives twice. Every Transactional asks one question besides the event and the messages: **how many notifications per customer?** There are two answers. ## One per event (the default) Every time the event arrives, the customer gets the notification. Use it for notifications where each event is its own thing, like a password reset or a shipping update. * Each event sends once. * A customer gets one at a time: if two events for the same customer arrive at the same moment, only the first one sends. * A retry of the same event sends again, even with the same `externalId`. If your system retries, or must never send the same thing twice, use **one per field value**. ## One per field value Each different value of a field you pick gets its own notification, and a value that comes again is skipped. Use it when a customer can have several of something at once, like orders or appointments, and must never get the same one twice. Pick the field that is **different on every event**, usually an id: `orderId`, `appointmentId`, `invoiceId`. * Each distinct value sends **once, ever**, per customer. * The same value arriving again for that customer sends **nothing**, so a retry never sends twice. * Two different values for the same customer send **two** notifications, even at the same moment. ### An example Your system records an `order_shipped` event each time an order ships, and the Transactional sends one per `orderId`. | Event received | `orderId` | What Boom does | | - | - | - | | Ana's first order | `ord_1` | Sends the notification | | Ana's second order, at the same moment | `ord_2` | Sends it again, for this order | | A retry of Ana's first order | `ord_1` | Nothing: `ord_1` already sent | With **one per event**, the second order would be skipped (it arrived at the same moment as the first), and the retry would send again. ### Choosing the field * **Use an id that your system creates once per thing**, the same one you would use to look it up later. * **Do not use a field shared by many events**, like the customer id, a status or an amount. A customer id would send only the first notification the customer ever gets, and never again. * **Do not use a field that changes on a retry**, like a timestamp or a request id. Every retry would send again. When the event has exactly one field ending in `Id`, choosing **One per field value** starts from it. If your event has not arrived yet, type the field's name; it only has to match what your system will send. ### Sending the field Put the field in the event's `properties`: ```json theme={null} { "name": "order_shipped", "externalId": "evt_001", "personExternalId": "cus_42", "properties": { "orderId": "ord_1", "trackingUrl": "https://track.example.com/ord_1" } } ``` An event that arrives **without** the field, or with an empty one, sends nothing, because Boom cannot tell which order it belongs to. So does a value longer than 255 characters. See [Send transactional messages](/transactional-messages) for the full set-up, and [events](/events) for the event API. # Agent Studio Source: https://docs.useboom.ai/tutorials/agent-studio Shape how your agent talks: describe a change in plain words, try it in a simulated conversation, and publish when it sounds right. [Agent Studio](https://www.youtube-nocookie.com/embed/wSiFFUAh1ck) ## Related > **[The Agent](/the-agent)** > > What answers your customers, and how it improves from your corrections. > **[Initiatives](/initiatives)** > > One mission: who you reach and what the agent is trying to accomplish. # Analytics and success metrics Source: https://docs.useboom.ai/tutorials/analytics Track an initiative's success metrics over time and see how people moved through its journey, from sent and delivered to read and replied. [Analytics and success metrics](https://www.youtube-nocookie.com/embed/GGynUx9Y3-s) ## Related > **[Analytics and attribution](/analytics)** > > How Boom counts what each initiative achieved. > **[Initiatives](/initiatives)** > > One mission: who you reach and what the agent is trying to accomplish. # Boom overview Source: https://docs.useboom.ai/tutorials/boom-overview A tour of the Boom app: where your customers and their data live, how the agent talks to them, and where the results show up. [Boom overview](https://www.youtube-nocookie.com/embed/DbaosucQhcM) ## Related > **[Start here](/start-here)** > > What Boom is, and which part of this site is yours. > **[Initiatives, end to end](/tutorials/initiatives)** > > The next video: how an initiative runs from start to finish. # Customer data (CDP) Source: https://docs.useboom.ai/tutorials/cdp Where your customer data lives in Boom: people, events and custom objects, and the relationships that tie them together. [Customer data (CDP)](https://www.youtube-nocookie.com/embed/C8Cxr7xm2Kw) ## Related > **[Customer Data Platform](/cdp/overview)** > > The REST API for getting customer data into Boom. > **[Segments](/segments)** > > Turn that data into an audience you can reach. # Boom in Claude (MCP) Source: https://docs.useboom.ai/tutorials/claude-mcp Add Boom to Claude Code with one command and sign in, so Claude can work with your Boom data. [Boom in Claude (MCP)](https://www.youtube-nocookie.com/embed/XrXc9nkAkR0) ## Related > **[Use MCP](/use-mcp)** > > Connect Boom to Claude, Cursor, or any AI tool that supports MCP. > **[Skills](/skills)** > > Teach Claude how to run your customer conversations over Boom. # Data extraction, part 1 Source: https://docs.useboom.ai/tutorials/data-extraction-1 Define the fields every conversation should yield, then publish them so the agent starts collecting them. [Data extraction, part 1](https://www.youtube-nocookie.com/embed/0gAY1dQ-X8Q) ## Related > **[Extraction](/extraction)** > > Typed fields a conversation should yield, and how to read them back. > **[Data extraction, part 2](/tutorials/data-extraction-2)** > > The next video: extraction inside an initiative's journey. # Data extraction, part 2 Source: https://docs.useboom.ai/tutorials/data-extraction-2 Extraction inside an initiative's journey: what a conversation step collects, and where the answers land. [Data extraction, part 2](https://www.youtube-nocookie.com/embed/nTtquhiGk8o) ## Related > **[Extraction](/extraction)** > > Typed fields a conversation should yield, and how to read them back. > **[Journeys](/journeys)** > > The flow that carries a mission: messages, waits, and branches. # Initiatives, end to end Source: https://docs.useboom.ai/tutorials/initiatives How an initiative runs from start to finish: who it reaches, the journey that carries it, and what comes back from each conversation. [Initiatives, end to end](https://www.youtube-nocookie.com/embed/5lSt8iN0rGE) ## Related > **[Initiatives](/initiatives)** > > One mission: who you reach and what the agent is trying to accomplish. > **[Journeys](/journeys)** > > The flow that carries a mission: messages, waits, and branches. # Tutorials Source: https://docs.useboom.ai/tutorials/overview Short video walkthroughs of the Boom app. Pick one to start watching. > **[Boom overview](/tutorials/boom-overview)** > > A tour of the Boom app: where your customers and their data live, how the agent talks to them, and where the results show up. > **[Initiatives, end to end](/tutorials/initiatives)** > > How an initiative runs from start to finish: who it reaches, the journey that carries it, and what comes back from each conversation. > **[Shared inbox](/tutorials/shared-inbox)** > > Every conversation in one place, across WhatsApp, Instagram, Messenger and web chat: filter by status, assign teammates, and see what the agent already resolved. > **[Analytics and success metrics](/tutorials/analytics)** > > Track an initiative's success metrics over time and see how people moved through its journey, from sent and delivered to read and replied. > **[Customer data (CDP)](/tutorials/cdp)** > > Where your customer data lives in Boom: people, events and custom objects, and the relationships that tie them together. > **[Agent Studio](/tutorials/agent-studio)** > > Shape how your agent talks: describe a change in plain words, try it in a simulated conversation, and publish when it sounds right. > **[Boom in Claude (MCP)](/tutorials/claude-mcp)** > > Add Boom to Claude Code with one command and sign in, so Claude can work with your Boom data. > **[Data extraction, part 1](/tutorials/data-extraction-1)** > > Define the fields every conversation should yield, then publish them so the agent starts collecting them. > **[Data extraction, part 2](/tutorials/data-extraction-2)** > > Extraction inside an initiative's journey: what a conversation step collects, and where the answers land. # Shared inbox Source: https://docs.useboom.ai/tutorials/shared-inbox Every conversation in one place, across WhatsApp, Instagram, Messenger and web chat: filter by status, assign teammates, and see what the agent already resolved. [Shared inbox](https://www.youtube-nocookie.com/embed/kaAvhasExzY) ## Related > **[Inbound conversations](/inbound)** > > When a customer writes to you first. > **[The agent](/the-agent)** > > What answers your customers, and when it hands off to a person. # Use MCP Source: https://docs.useboom.ai/use-mcp Connect Boom to Claude, Cursor, or any AI tool. Add one link, sign in: no API key. Boom works with any AI tool that supports **MCP** (Model Context Protocol), like Claude, Cursor, and VS Code. Once it's connected, the tool can look up and update your customer data for you. It takes two steps, and there's no API key to set up. **1. Add Boom to your tool** Copy this link: ``` https://www.useboom.ai/mcp ``` Then add it the way your tool expects: **Claude Code** Run this in your terminal: ```bash theme={null} claude mcp add --transport http boom https://www.useboom.ai/mcp ``` **Cursor** Add this to `.cursor/mcp.json`, then open **Settings → MCP** and click **Boom**: ```json theme={null} { "mcpServers": { "boom": { "url": "https://www.useboom.ai/mcp" } } } ``` **VS Code** Add this to `.vscode/mcp.json`, then start the server from the little button above it: ```json theme={null} { "servers": { "boom": { "type": "http", "url": "https://www.useboom.ai/mcp" } } } ``` **Claude Desktop / Claude.ai** Go to **Settings → Connectors → Add custom connector**, paste the link, and click **Connect**. **Other tools** Most tools use a file like this: ```json theme={null} { "mcpServers": { "boom": { "url": "https://www.useboom.ai/mcp" } } } ``` **2. Sign in** The first time you use it, a window opens to sign in to Boom, the same login you use for the dashboard. Approve it, and you're connected. * Everything stays inside **your organization**. * In more than one organization? Pick which one when you sign in. * There's no key to copy or keep safe. Access follows your login. ## What you can do Once connected, the AI tool gets the **same capabilities as the REST API** (see [one uniform surface](/one-surface)): * **Customer data**: find, add, and update **people**; record and read **events** (like a purchase or a sign-up); manage **custom objects**, things like orders or loans; and link records with **relationships**. * **Sensitive fields**: see which fields are hidden from AI agents, and mark more of them. A connected tool can only hide a field, never make one visible again: do that in **Settings › Sensitive data** or with the REST API. See [sensitive fields](/cdp/sensitive-fields). * **Segments**: the full authoring loop: discover what's filterable, validate and preview a filter, create or update an audience, trigger an evaluation, and page through who's in it. * **Initiatives**: create and configure an initiative, whatever the job it runs, link the templates it sends, drive the lifecycle (**launch**, cancel, archive, unarchive), and read a summary of the data collected. * **Participants**: add people to an active initiative, list them, check one participant's status, read conversation transcripts (one participant at a time, or every conversation in pages of up to 100), and stop their outreach. * **Journeys**: the full authoring loop, not just reading: open a draft, add and connect nodes, set the trigger, validate, and publish. Catalogs tell an agent which nodes, conditions, events, templates and variables exist, so it can build a flow without guessing. See [journeys](/journeys). That includes the email send step (`SEND_EMAIL`), with the templates it sends authored through the email template tools below. * **Drafts**: list the journey sends waiting for approval, with a preview of each, and approve or reject them one by one, by id, or everything matching a filter. Approving sends **real messages**, so it takes the **Owner** or **Admin** role. MCP only, with no REST equivalent: a send held for review is waiting on a named person's judgement, and an organization API key identifies the integration, not the person. * **Extracted data**: define the typed fields a conversation should yield, then read them back per participant or in aggregate. See [extraction](/extraction). * **WhatsApp templates**: see your WhatsApp numbers, then list, read, and create message templates. * **Email templates**: list, read, create and update email templates, as builder blocks or as your own HTML. See [email templates](/email-templates). * **Lead Capture**: list, read, create and update pop-ups (design, copy, prizes and codes, allowed sites, going live), and list the leads one collected. See [Lead Capture](/lead-capture#from-the-api-or-an-ai-tool). * **Message logs**: list what you sent and what happened to each message — delivered, failed, or blocked — with the recipient and the failure reason, so you can clean a list or chase a non-delivery without opening conversations one at a time. See [message logs](/message-logs). * **Your agent's own tools**: list the HTTP tools your agent can call mid-conversation, and create or update one — the same tools you author in Agent Studio's **Herramientas** section. Reading the credentials and environments they reference works too, so a tool can be built end to end without leaving the chat. Secrets are never returned; a tool references one by name. Requires the **Owner** or **Admin** role, and only for organizations with Agent Studio. See [the agent](/the-agent). > [!WARNING] > > Adding participants to an initiative (and launching one) sends **real > WhatsApp messages and emails to real customers**. Any connected AI tool can do it, so > check what a tool is doing before you let it run on its own. People on > your Do Not Contact list are always skipped. > [!NOTE] > > What a connected tool can do follows **your role** in the organization: it > can do exactly what you could do in the dashboard, and nothing more. Starting > or stopping real outreach (launching, pausing, resuming or cancelling an > initiative, adding or stopping participants, publishing or stopping a > journey, approving drafts) takes the **Owner** or **Admin** role. See > [roles and permissions](#roles-and-permissions) below. ## Roles and permissions Every MCP tool checks the same permission as the matching action in the app, using the role you hold in the organization you signed in to. A tool your role can't use returns a permission error; nothing else about the connection changes. Your organization's Owner assigns each member's role. | Role | Over MCP, this role can | | - | - | | **Owner** | Everything. | | **Admin** | Everything, the same as the Owner. | | **Member** | Read initiatives, journeys, segments, templates, drafts and customer data. Create and edit initiatives, journeys, segments, WhatsApp and email templates, extraction schemas and success metrics. Can't launch, pause, resume or cancel an initiative, add or stop participants, publish or stop a journey, approve drafts, change customer data, or change organization settings and your agent's tools. | | **Developer** | Read and write customer data: people, events, custom objects and relationships, and see your data sources. Read initiatives, journeys, segments and templates, credentials and environments. Can't edit initiatives, journeys or segments, or start any outreach. | | **Inbox Operator** | Read people, templates, initiatives and their conversations, and create WhatsApp templates. Can't change initiatives, journeys, segments or customer data. | | **Billing Manager** | Read organization settings only. | | **Viewer** | Read initiatives, journeys, drafts, segments, templates and customer data. Can't change anything. | Reading or changing your agent's own tools, changing organization context, sending limits or Lead Capture pop-ups, and marking fields sensitive takes the **Owner** or **Admin** role. A tool definition carries the internal hosts your agent calls, so even reading the list is limited to the people who can change it. > [!NOTE] > > [API keys](/authentication) work differently: a key is not tied to a person, so any valid key can make any > call for your organization. Treat a key as full access and scope who holds it > accordingly. ## Related > **[One uniform surface](/one-surface)** > > How MCP tools map one-to-one onto REST endpoints. > **[Quickstart](/quickstart)** > > Work with the same data over the REST API. > **[Authentication](/authentication)** > > API keys for scripts and server-to-server calls. > **[Connect your database](/connect-your-database)** > > Sync people and objects straight from your database. > **[Rate limits & errors](/rate-limits-and-errors)** > > Limits and the shared error shape. # Web widget Source: https://docs.useboom.ai/web-widget One script tag puts the same agent that answers WhatsApp on your own website. The web widget is a chat launcher you add to your site with a single script tag. A visitor clicks it, types, and is answered by **the same agent** that runs your outreach and answers [inbound WhatsApp](/inbound) — same knowledge base, same policies, same voice, and the same shared inbox when it hands off to a person. Nothing about it is a separate support bot with its own configuration to keep in sync. What is different from WhatsApp is who the visitor is: nobody signs in, so there is no phone number and no email address until the conversation produces one. ## Install Copy the snippet from the Boom app — **Settings → Channels → Web widget →** your widget — and paste it before the closing `` tag of every page that should show the widget. It looks like this: ```html theme={null} ``` Copy it rather than typing it. The origin in the snippet is pinned by the deployment you are looking at, so a hand-written one will point at the wrong environment. The install key in that URL is **public** — it ships in your page source, and it has to. It identifies the widget; it does not authorize anything. What controls where your widget may be embedded is the origin list below. > [!NOTE] > > `async` is deliberate. The script does not block your page, and the launcher > button appears a moment after the rest of the page has painted. ## Allow your site's origin A widget only renders on origins you list, and the list starts empty. Add every origin that will host it under **Allowed origins** on the same settings page. Matching is **exact string equality**, on the origin a browser actually sends: | Add | Not | | - | - | | `https://example.com` | `example.com` (no scheme) | | `https://www.example.com` — separately, if you serve both | `*.example.com` (no wildcards) | | `https://staging.example.com` | `https://example.com/` (no paths) | So `https://example.com` does **not** cover `https://www.example.com`, and a staging domain needs its own entry. > [!WARNING] > > A widget with an empty origin list installs cleanly and never opens. The > launcher button appears — the script that draws it does not know which site > loaded it — and clicking it produces an empty panel. If that is what you are > seeing, this list is the first thing to check. ## If your site sets a Content-Security-Policy Two directives need our origin. Add them to whatever your site already sends: ``` script-src https://www.useboom.ai frame-src https://www.useboom.ai ``` `script-src` covers both the snippet above and the launcher bundle it loads. `frame-src` covers the chat panel, which is an iframe served from our origin. You do **not** need a `connect-src` entry. The panel makes its own network requests, but it makes them from inside that iframe, so they are governed by our policy rather than your page's. The launcher itself — the button and the greeting bubble, which are the only parts living in your document — makes no requests at all. Serve the page over HTTPS. The widget's session cookie is `Secure`, so a visitor on an `http://` page will not keep a conversation across reloads. ## Customize how it looks The settings page is a live canvas with the widget drawn on a stand-in for your own site, and the settings beside it in four groups. Switch the canvas between desktop and mobile, and between the open panel and the collapsed launcher, to see what a visitor gets. You pick one accent colour and Boom derives the rest, so the text drawn on it stays legible whatever you choose. **Launcher** — the button visitors click: | Setting | What it changes | | - | - | | Icon | The glyph in the launcher circle: a speech bubble, a question mark, a sparkle, sparkles, or a robot | | Launcher image | Your own image in the circle instead of a glyph. An `https://` URL; square reads best, since it is cropped to a circle. If it cannot be loaded the icon is used instead | | Launcher label | The words beside the icon — your own capitalisation is kept. Keep it to a few, since the button truncates rather than growing across your page. Leave it blank for the icon on its own | | Position and distance | Which corner the launcher sits in, and how far off the edges — room for a cookie banner or a sticky nav bar | **Panel** — the window that opens: | Setting | What it changes | | - | - | | Accent colour | The launcher, the panel header, and the visitor's own messages | | Theme | `Light` or `Dark` | | Panel height | `Full height` fills the visitor's window, less the distance you set. `Compact` keeps the panel to a card about 560px tall, and shrinks it further on a short window so it never runs off the bottom. On a phone both cover the screen | | Logo | Shown in the panel header and on the avatar beside each reply. An `https://` URL | **Words** — everything a visitor reads: the panel title, the greeting, the composer's hint text, and up to four one-tap suggested questions shown on an empty panel. Every piece of copy is per-language — English, Spanish, and Portuguese — and the visitor gets the language their browser asks for, falling back to English for anything you leave blank. A suggested question left blank in one language simply does not appear for those visitors. > [!NOTE] > > A suggested question **is** the message. Tapping one sends that exact text as the > visitor's first message, so write them the way a customer would ask, and expect the > agent to answer them the way it answers anything else — they are not scripted > replies. Below the composer the panel shows a **"Powered by Boom"** link. That line is not configurable. > [!NOTE] > > The panel does not tell visitors on its own that they are talking to an AI. Where you > need that disclosure — the EU AI Act asks for it wherever it is not otherwise obvious — > put it somewhere you control. The greeting is the natural place: it is the first thing > a visitor reads, and it is yours to write in all three languages. ## What a visitor's session is A visitor is anonymous. The only thing tying somebody who comes back to the transcript they left is a cookie on our origin, set the moment they send a first message and good for **30 days**. That cookie is `Partitioned` (CHIPS), which has two consequences worth knowing: * It survives in browsers that block ordinary third-party cookies, which is now most of them. * It is scoped to **your** site. The same person visiting two different customers of ours is two unrelated visitors, and the same widget embedded on two of your own domains keeps two separate conversations. The widget is not a cross-site identity. > [!NOTE] > > A visitor who blocks cookies for our origin can still use the widget, but every > message starts a new conversation, and the panel is empty again after a reload. > There is no login to fall back on. ## Sending images A visitor can attach an image — PNG, JPEG, GIF or WebP, up to **4 MB**, four per message. An image on its own is a message; no caption is required. The agent can read it. It receives a written description of the image plus any text found in it, which is what lets it answer about a screenshot of an error, a photo of a receipt, or a picture of the wrong item that arrived. It does not see the picture itself, so an image whose meaning depends on fine detail is better described in words as well. Your agent can send images back, from the assets you have given it — the same library it draws on for WhatsApp. So can a person replying from the shared inbox. > [!NOTE] > > Images only, in both directions. A PDF, a video or an audio file is refused > rather than sent: the panel has no way to show one, and a reply that arrived > with the file silently missing would be worse than a refusal you can see. On > the inbox side the paperclip only offers image files for a widget > conversation, and a reply that somehow carries anything else is marked as > failed rather than delivered without it. > [!WARNING] > > Anyone who can open your site can upload to the widget — there is no login. It > is rate limited per visitor and per widget, every file is checked to be a real > image regardless of what it claims to be, and nothing larger than 4 MB is > stored. Treat what arrives as untrusted, exactly as you would a message. ## Controlling the widget from your page The snippet installs one global. It works from the moment the tag runs, so you can call it from a button in your own header without waiting for anything to load: ```js theme={null} Boom('open'); // also 'close' and 'toggle' ``` To start a conversation from your own UI, for example an "Ask about this order" button, send a first message on the visitor's behalf: ```js theme={null} button.addEventListener('click', () => { Boom('send', '¿Dónde está mi pedido #1234?'); }); ``` `send` opens the chat and posts the text as the visitor. It only works from a real click or keypress: called from a timer or on page load it is ignored, so no script on your page can write to the chat without the visitor asking. Re-running the snippet is safe. A single-page app that re-executes it on a route change gets the widget that is already there, not a second button. ## Proactive help (copilot) > [!NOTE] > > Proactive help is in early access. Contact us to have it turned on for your > organization; until then, the **In-app expert** tab does not appear. The copilot notices when a visitor seems stuck in your app and offers help: after 30 seconds without a click, or right after they click the same button three times without anything changing, it shows a short message next to the chat button, labelled as an AI assistant, and can draw a ring around the button that helps. Clicking that button works exactly as before. It can also walk a visitor step by step toward a goal, one control at a time. The message is one you write: the widget's **Playbooks** page holds them, each describing a situation ("the library has no papers yet") and what to say. You switch it on and choose how it shows up in the widget's settings, on the **In-app expert** tab: * **Corner notice** (the default): the message appears as a card next to the chat button. * **Island**: a small dark tab docked on the edge of the screen, separate from the chat button. Visitors can drag it to any edge. It picks the quietest way to say each message, from the playbook and what is on screen: a single line that comes out of the tab when there is nothing to point at, a ring on the control when the control says it all, a short card when it can guide the visitor, a choice of two or three when it can't tell which you meant, and only a dot while the visitor is typing. A situation you mark as a `warning` stays until acted on. Tapping the tab opens a card with the current message or step and a question box; sending a question opens the chat with it, or, with the [expert agent](#asking-the-expert) on, the expert answers it in the island. With nothing pending, the card also offers up to three things to do on that screen: the situations you gave a short `label` (and a goal), and picking one starts its guide. Its cards can be **Light** (the default) or **Dark**, and you can add a small "How it works" link to your own help page. Its **Presence** is **Always there** (the default) or **Only when it can help**: then the tab stays out of sight until it has something to say (a suggestion, a guide step or an answer) and goes again once that is dealt with, so nothing rests on your page. In both styles, a guide's steps appear in a card beside the control they refer to. It runs only when you switch it on for a widget and it has at least one playbook. Switching it off stops it, including a guide a visitor is in the middle of. Playbooks are written per page: each one lists the paths it applies to (`/events/**`, `/settings`) and the situations it watches for there. The chat and the expert are separate: in the widget's settings you can switch the chat off and keep only the expert. The island then has no question box, since there is no chat to hand a question to. With the chat off, the chat panel and its message and upload endpoints refuse every request, including ones made directly rather than through the snippet. What it reads, from the screen the visitor is on: * the buttons, links, tabs and form labels, and whether they are disabled * headings and short labels * the last few pages visited and controls clicked on this tab, kept only in the tab's session storage and sent only when the copilot decides whether to offer help What it never reads: * anything anyone types into a field (it only notes whether a field is empty or filled, and not even that for passwords) * content inside lists and tables, where your users' own data usually lives * anything inside an element you mark with `data-boom-private` (or the `boom-private` class) * the labels and text inside an element you mark with `data-boom-pii` (or the `boom-pii` class), and of fields for personal data such as email, phone, address, card or password fields: the expert sees only that the controls are there, reads them as `[redacted]`, and never points at them Emails, card numbers, IBANs, national ids (CURP, RFC, CPF, CNPJ, SSN) and phone-like numbers are masked before anything leaves the browser. Boom keeps only the page's route shape (ids removed) and the name of the control the expert pointed to, never field values, rows, messages or anything typed. To keep a region out entirely: ```html theme={null}
``` If you can't change the markup, add CSS selectors on the **In-app expert** tab's **Protected content** card instead. [Protecting sensitive data](/protecting-sensitive-data) has every rule, the automatic ones included. The copilot only answers pages on your widget's allowed origins, the same list the chat uses. ### Asking the expert > [!NOTE] > > The expert agent is in early access. Contact us to have it turned on for your > organization. To keep it off on a page that loads the widget, set > `` before the widget script. With the agent on, a question typed into the island is answered there, by an AI that knows your playbooks and can see the same redacted screen the copilot sees. It answers one step at a time, rings the control it is talking about, and can start one of your guides. Turn it on in the widget's settings, on the **In-app expert** tab's **Agent** card: * **Languages**: which of English, Spanish and Portuguese it answers in. It answers in the page's language when that one is on, otherwise the first one on. * **Persona**: an optional few lines on its name and tone. * **Voice**: lets visitors talk to it. A microphone button appears in the island. Until a visitor's first call, one short line under it says that starting a call accepts the terms, linked to Boom's Terms; starting the call accepts them. Each language has its own voice. Talking is kept short on purpose: after about 18 seconds of quiet, or two minutes at most, it says it will continue on screen and the conversation carries on in text, in the same thread. Typing a question also ends the spoken part. * **Actions**: whether it may act for the visitor. * **Off** (the default): it only guides. * **Navigate**: it may open a page by following a link on screen. * **Confirmed**: it may also click and fill fields. Every action asks the visitor first, in the island ("Click “Import CSV”?", "Fill “Name” with “Q4 churn”?", with **Do it** and **No**). Whatever the visitor or the AI asks, it never clicks anything that deletes, cancels, pays, signs out or disconnects; never follows a link off your site; and never types into passwords, payment fields, one-time codes or anything under `data-boom-private`. #### References The agent never writes a link itself. It can only put a link it was given on screen, as a small chip under its answer that opens in a new tab, and any web address typed into its answer is removed before it shows. Every expert has three links built in: how data is handled (`https://www.useboom.ai/privacy`), who processes data (`https://www.useboom.ai/subprocessors`) and Boom's terms (`https://www.useboom.ai/terms`), which it shares when a visitor asks about privacy, the AI providers behind it, or the terms. Add your own (docs, examples, a pricing page) to the playbook YAML, up to 10, each with a note on when to share it. A situation can name the ones that belong to it: ```yaml theme={null} references: - id: pricing-plans # lowercase slug; not privacy, subprocessors or terms title: { en: Pricing, es: Precios } # or a plain string url: https://example.com/pricing # https only note: When they ask what a plan costs playbooks: - id: billing name: Billing situations: - id: upgrade references: [pricing-plans] # up to 3, each listed above or built in # ... ``` References are saved with the playbooks, so each version keeps its own, and the playbooks page lists the current ones. Their titles and addresses are public: they are sent to the visitor's browser with the widget. What the agent sends and keeps, on top of what the copilot reads: * the visitor's question, and the AI's answer; * the name of each control it pointed at or acted on, and whether the visitor said yes; never what was typed, except the value the AI itself proposed in a confirmed fill. Conversations are kept encrypted for 30 days after the last message, then deleted. With voice on, the visitor's audio goes to ElevenLabs to be turned into text and back into speech; ElevenLabs does not keep a recording, and the conversation's text is the one Boom keeps. #### Reporting something confusing Where the agent is on, the island's card has a small flag, **Report something confusing**. A visitor taps it, then taps the part of your page they find confusing (that tap selects it instead of using it; **Esc** or **Cancel** backs out), adds an optional one-line note and sends it. The agent also hears about it on the visitor's next question, so it can help with that control. A report carries the element's role and name (a button called "Sync", say), the same redacted page structure and recent steps a question carries, the page's path with ids masked, the screen size, the note, and the conversation it belongs to. It takes **no screenshot or recording**, and never a field's value: inside a field, anything under `data-boom-private`, or a list or table row, only the element's role is sent, as "protected". Reports are kept encrypted for 30 days. A visitor can send 10 a day. ### Reading the conversations To see what visitors asked and where they got stuck, open the widget's **Playbooks** page and switch to **Conversations** (the **In-app expert** tab links there too). Each row is one conversation: when it started, the visitor's first question with the agent's last answer under it, the language, the number of turns and an estimated cost. Opening one shows the whole exchange, with each thing the agent did on the page as one short line ("Highlighted “Export”", "Filled “Name” (visitor said yes)"); values are never shown, not even the one the AI proposed in a fill. Conversations appear once the agent is on, and go when they are deleted after 30 days. Reports are on the same page, under **Reports**: newest first, with the element, the visitor's note, the page and a link to the conversation. Opening one shows the steps before it and the page's structure, and **Copy as Markdown** puts it all on your clipboard for an issue or a coding agent. A conversation shows its reports above the exchange. Reading them needs the permission to read the inbox (Owner, Admin, Member, Inbox Operator and Viewer have it), since they hold what your visitors typed. Every time someone opens a conversation or a report, or lists reports, Boom records who did it. ## When nothing answers Answering is a setting, not a default, and it resolves exactly the way [inbound](/inbound) does: per channel, falling back to your organization. > [!NOTE] > > If no agent is configured to answer the widget, or the configured agent is > disabled, the visitor's message is still received and stored — it waits in the > shared inbox for a person — and the panel stops waiting and offers a retry > rather than spinning. Boom does not pick an agent on your behalf, because > guessing which agent should speak for you is worse than staying quiet. The agent that answers is assigned to the conversation, so the inbox always shows who is replying. Unassigning the agent from a conversation hands it to your team: the agent stops answering that visitor, and their messages wait in the inbox. It picks the conversation back up only after 7 days with no one on your team touching it, or as soon as someone sends it back to the AI. Replies a person sends from the inbox arrive in the visitor's open panel in realtime, minutes later if that is when they are written. ## Rate limits Per visitor, **30 messages a minute** and **10 image uploads a minute**. Per widget, **120 first-messages** and **40 uploads** a minute from visitors who have no session yet — set far above a real site's arrival rate, so they throttle a loop rather than a launch. A throttled request is refused before anything is stored. Uploads have their own allowance rather than sharing the message one, so a visitor who has just sent several images can still write about them. The [expert agent](#asking-the-expert) has its own: per visitor, **20 questions a minute** and **300 a day** (counting each step it takes), and per widget **120 a minute**. Voice adds **15 minutes a day** per visitor and **10 conversations at once** per widget. Each organization also has a daily spending cap; past it, the agent says it can't answer questions today, and the copilot's guides keep working. Confusion reports: **10 a day** per visitor and **30 a minute** per widget. ## Rotating the install key **Rotate** on the settings page mints a new key and invalidates the old one immediately. The snippet on your site then points at a key that no longer resolves, and the widget stops appearing until you paste the new one — so rotate when you are ready to deploy the change, not before. Rotating does not touch conversations, transcripts, or appearance. ## Troubleshooting | What you see | Almost always | | - | - | | No launcher button at all | The snippet is not on the page, or the install key was rotated and the page still has the old one. | | Launcher appears, panel opens empty | Your origin is not in the allowed-origins list — check scheme, `www`, and port. | | Panel opens, visitor's message stays put | Your CSP is missing `frame-src`, or a `script-src` gap kept the launcher's own bundle out. | | Transcript gone after a reload | Cookies for our origin are blocked, or the page is served over `http://`. | | Message sent, nothing ever replies | No answering agent is configured for the channel, or the one configured is off. If it is one conversation only, someone on your team unassigned it in the last 7 days. | | "Only images can be attached" when picking a file | The file is not a PNG, JPEG, GIF or WebP — or it claims to be one and is not. | | An image the visitor sent, and the agent answers as if it saw nothing | Reading the image failed on our side. The agent is told a file arrived but not what is in it, so it will ask rather than guess. | ## Related > **[Inbound conversations](/inbound)** > > The same answering behavior, reached from WhatsApp instead of your site. > **[The agent](/the-agent)** > > What briefs the agent that answers, and when it hands off to a person. > **[Extraction](/extraction)** > > Why a cold widget conversation has a transcript but no typed fields. > **[Use MCP](/use-mcp)** > > Read widget conversations from an AI tool. # Webhooks Source: https://docs.useboom.ai/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"`. ## 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. ## 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. **1. Create a credential** Go to **Settings → HTTP Credentials** and add one. Boom sends its value on every delivery so your endpoint can prove the request came from us. A credential is **required**, and it has to actually authenticate. Your URL is not a secret: anything that learns it could forge events, and without a credential your receiver has no way to tell our `POST` from theirs. `BEARER`, `API_KEY` (in a header or a query parameter) and `BASIC` all work. Most people want a bearer token. A credential set to **No authentication** can't be attached to a webhook — it's greyed out in the picker, rejected on save, and refused at delivery rather than sent unsigned. **2. Add the subscription** Go to **Settings → Webhooks → Add destination** and fill in: * **URL** — must start with a literal `https://`; plain `http` is rejected when you save. A private or loopback address saves fine but is blocked at delivery time, so use a public host (or a tunnel) rather than `localhost`. * **Events** — tick the ones you want. One destination can take all four, though see [Environments do not apply here](#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. **3. Send a test delivery** Click the **send** icon on the subscription. Boom delivers a synthetic event — `type: "ping"` — through exactly the same path a real event takes, and reports the result **per environment**. It's the same path deliberately: a check that just curled your URL would prove the endpoint is reachable while saying nothing about whether the credential resolves or the `{{env.*}}` tokens fill in, which is where subscriptions actually break. Until you've seen a delivery land, assume it isn't wired up — a subscription that saves cleanly can still drop every event (see [Troubleshooting](#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). ## 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. 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. ## `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. 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. * **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. 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 and wait*) 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`. 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. `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. > [!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. 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. | `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. `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. > [!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. ### 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. 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. The fix is a credential key that only ever has one value: **1. Create a global-only credential** **Settings → HTTP Credentials** → add a key (e.g. `escalation_api_key`) with **no environment** — a global row — holding the key your escalation endpoint expects. **2. Give escalations their own destination** **Settings → Webhooks → Add destination**: a **literal** URL (no `{{env.*}}`), only `conversation.escalated` ticked, that credential, and **Environments left empty**. **3. 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. 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. 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). ## 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. ## 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. # WhatsApp link tracking Source: https://docs.useboom.ai/whatsapp-link-tracking Add UTM tags to the buttons of your WhatsApp templates, so campaign traffic shows up in Google Analytics and Shopify. Your WhatsApp templates can add **UTM tags** to their button links. When a customer taps the button, your analytics (Google Analytics, Shopify, or any tool that reads UTM tags) sees the visit as coming from Boom, from WhatsApp, and from that template. You type your links as usual. Boom adds the tags when each message is sent. > [!NOTE] > > Links the agent sends inside a conversation are tagged too, and they use the same > `utm_source=boom`. That way all Boom traffic shows up as one source in your reports. ## Turn it on **1. Create a template with a link button** In **Templates → WhatsApp → New template**, choose **Call to action** or **Card** and add a button of type **URL**. Use your normal link, for example `https://yourstore.com/promo`, or a link that ends in a variable, such as an abandoned-checkout link. **2. Leave 'Tag links for analytics' ticked** Right under the buttons, **Tag links for analytics** is ticked by default, next to the campaign name the links will carry. Under each URL field you'll see the tags that link gets. Use **Customize** to change the source, medium or campaign (empty fields use the defaults) or to turn off **See who tapped each button**. Then create the template as usual. ## The tags | Tag | Default | Example | | - | - | - | | `utm_source` | `boom` | `boom` | | `utm_medium` | `whatsapp` | `whatsapp` | | `utm_campaign` | The template's name, lowercased with spaces as `_` | `black_friday_mx` | A button link of `https://yourstore.com/promo` opens as `https://yourstore.com/promo?utm_source=boom&utm_medium=whatsapp&utm_campaign=black_friday_mx`. If your link already has a query (`?ref=ig`), the tags are added after it. ## Change the tags later Open the template and use **Edit** on its **Link tracking** card. Because the tags are added when each message is sent, a change applies to the next messages right away, **without a new WhatsApp review**. Tracking can only be turned **on** when you create a template, because it changes the button link that WhatsApp reviews. To track an older template, create a new one with tracking on. ## When a link is not tagged * **The link already has its own UTM tags.** Yours are kept as they are, and Boom adds nothing. * **The link has a `#` section** (`https://yourstore.com/page#reviews`). Tags after a `#` never reach your site, so the link is sent unchanged. The line under such a link's URL field says it isn't tagged. ## What your analytics will show * **Visits open inside WhatsApp.** WhatsApp opens business links in its own in-app browser, which shows only your domain. The tags are still in the address; the customer can see them with **⋯ → Copy link**. That browser keeps its own cookies, so a customer logged in to your store in Safari or Chrome arrives logged out. * **Cookie consent comes first.** If your site asks visitors to accept cookies, tools such as Google Analytics record the visit, and its UTM tags, only after the visitor accepts. Some taps will therefore not appear in those reports, as with any other campaign link. ## From the API or MCP Pass `tracking` when you create a template: ```json theme={null} { "name": "black_friday_mx", "language": "es", "category": "MARKETING", "contentType": "CALL_TO_ACTION", "content": { "body": "Hola {{1}}, ya empezó el Black Friday.", "actions": [{ "type": "URL", "title": "Ver ofertas", "url": "https://yourstore.com/promo" }] }, "variables": { "1": "Ana" }, "tracking": { "utm": {} } } ``` `{ "utm": {} }` uses the defaults. Set any of `source`, `medium` or `campaign` to override them. Without `tracking`, the template is created without tags. Change the tags later with `PATCH /api/v1/templates/{id}/tracking` (MCP: `templates_tracking_update`), for example `{ "tracking": { "utm": { "campaign": "cyber_monday" } } }`, or `{ "tracking": { "utm": false } }` to stop tagging. ## See who tapped each button With **See who tapped each button** (under **Customize**, ticked by default when it's available for your organization), Boom counts each customer's taps on the buttons, not just total visits. The template's page shows how many people tapped, out of how many sends. Your customers still land on your link; Boom records the tap on the way. This can only be chosen when the template is created. From the API, pass `"tracking": { "utm": {}, "clicks": true }`. > [!NOTE] > > Tap counting is being rolled out per organization. Where it isn't enabled yet, > templates are created with UTM tags only. # White-label email Source: https://docs.useboom.ai/white-label-email Send every merchant's email in their own logo and colors from one journey and one template, with a Look up record step. If you send email on behalf of many merchants, each one wants their customers to see **their** brand. Instead of one template per merchant, keep each merchant's branding as a record in your CDP and let the journey load it: a **Look up record** step finds the right record for each run, and the email's colors, logo and copy read from it. A merchant with no record gets your default branding, so a new merchant never blocks a send. > [!TIP] > > Look up record loads any custom object, not just a brand. The same steps > send an order total, a plan name or a due date the triggering event doesn't > carry. ## 1. Store each merchant's brand Create a custom object type for the branding, for example `merchant_brand`, and one record per merchant. Use the merchant's ID as the record's **external ID**, and give it the fields your email needs: | Field | Example | | - | - | | `name` | `Marea` | | `logo_url` | `https://cdn.example.com/marea/logo.png` | | `primary_color` | `#1D5FD1` | Records come in through the API, MCP or a sync; there's no screen for creating them. With the API, create the type once ([`POST /api/v1/cdp/custom-objects/types`](/api-reference/cdp-custom-objects/create-object-type)), then send the records ([`POST /api/v1/cdp/custom-objects/batch`](/api-reference/cdp-custom-objects/batch-upsert-objects), up to 1,000 per call). From an AI tool connected over [MCP](/use-mcp), you can ask for the same thing in plain words, for example "create a merchant\_brand type and load the brands in this CSV". A color is a hex (`#1D5FD1`), an `rgb()` or `hsl()` with plain numbers, or a CSS color name. Anything else is treated as empty, and the email uses its fallback. Logos must be public `https` URLs. ### Already have brand records? Add the missing fields If your records exist but don't have a logo or color yet, add only those fields with `"mode": "merge"`: ```bash theme={null} curl -X POST https://www.useboom.ai/api/v1/cdp/custom-objects/batch \ -H "Authorization: Bearer $BOOM_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "merge", "createMissing": false, "items": [ { "type": "merchant_brand", "externalId": "brand-marea", "attributes": { "logo_url": "https://cdn.example.com/marea/logo.png", "primary_color": "#1D5FD1" } } ] }' ``` > [!WARNING] > > Without `"mode": "merge"`, an upsert **replaces** all of a record's fields: > every field you leave out is deleted. `"createMissing": false` makes sure > a typo in an external ID is reported back instead of creating a new, > empty record. If the records come from a sync, add the two columns to the sync instead, so the next refresh doesn't drop them. ### Check your records Open **Objects** in your CDP and pick the type. Check that the fields your email needs are there, with real values, for the merchants you're about to send for. ## 2. Add a Look up record step In the journey, add **Look up record** right before the email step, and set: * **Record type**: `Merchant Brand`. * **Save as**: the name later steps use, for example `brand`. It becomes `{{brand.logo_url}}`, `{{brand.primary_color}}` and so on. * **How to find it**: * **By an ID this run carries**: insert the variable that holds the merchant's ID, such as `event.merchant_id` on the event that started the journey, a person attribute, or a field of a record an earlier step loaded. * **Linked to the person**: pick how the person is linked, up to three hops (person → merchant → merchant brand). * **Fields to load**: tick every field the email uses (up to 30). Only these are available to later steps. * **If several match** (linked to the person): pick the field that decides which record wins, and whether the highest or lowest value wins. Without it, several matches load nothing. The record is read when the run reaches the step, so place it right before the step that uses it, not before a long wait. ### By an ID, or linked to the person? * Use **By an ID this run carries** only when the event (or the person) holds the brand record's **external ID** exactly. A merchant ID that isn't the brand record's external ID finds nothing. * Otherwise use **Linked to the person**. The list shows every way the person can reach the type through your relationships, for example person → merchant → merchant brand, or person → buyer → merchant → merchant brand. If it says *no link to this type*, people aren't linked to it yet: see [Relationship types](/relationship-types). * If a person can be linked to more than one merchant (a manager of several stores, say), set **If several match**, or those runs load nothing and get your default branding. ## 3. Bind the email to the record Open the email template in the editor: * **Colors**: next to any color (text, heading, button, divider, background), choose `{ }` (*Use a variable for…*) and type `brand.primary_color`. The swatches you pick become the fallback, used when the value is empty or isn't a color. * **Logo and images**: on an image, choose `{ }` and type `brand.logo_url`. The image you already had becomes the **Fallback image URL**. Always keep a fallback image: without one, a merchant with no logo gets an empty image. * **Copy**: use the record anywhere you write text, for example `Your order with {{brand.name | default: 'us'}}` in the subject. In the journey, the email step's preview runs the lookup on a real triggering event, so you see the email in that merchant's brand. The editor itself shows the fallback colors. The step's variables are split into **Content** (text) and **Style** (colors and images). ## What happens at send time * **The run always continues.** If no record is found, the step records **No record found**, the fields are empty and the email uses its fallbacks. * **Unsafe values are removed.** Before an email goes out, Boom checks the values that came from your data. A value that would add a script, an event handler or a `javascript:` link is removed. * **Each run shows what it found.** The run's timeline says **Record found: Marea**, or why nothing was found (no match, an empty ID, several matches and no rule). ## What publishing checks Every `brand.` the journey reads, **including inside its email templates**, must be loaded by a Look up record step saved as `brand` that runs before it. If the email reads `brand.primary_color` and the step doesn't load it, publishing is refused and names the field to add. ## Test before you send 1. In the journey, open the email step. Its preview has a **Preview with** picker: choose a recent event for a merchant you know. The preview runs the lookup, so you should see that merchant's logo and colors. Try one merchant without a brand record too, to see the fallback. 2. Send one real run to yourself, for example by recording the triggering event for a test person you own. 3. Open that run's timeline. The Look up record step says **Record found:** and the merchant, or why nothing was found. ## Troubleshooting | What you see | Why | What to do | | - | - | - | | Every email has the fallback colors and logo | The records don't have those fields, or the step doesn't load them | Check the record in **Objects**, then tick the fields under **Fields to load** | | The timeline says **Nothing matched** | The ID isn't the brand record's external ID, or the person isn't linked to it | Switch to **Linked to the person**, or send the right ID in the event | | The timeline says **The ID variable was empty for this run** | The event or person didn't carry the variable you picked | Check the event's properties, or pick another variable | | The timeline says several records matched | The person is linked to more than one merchant | Set **If several match** | | One merchant's color is ignored | The value isn't a color, for example `blue-ish` or an empty string | Store a hex such as `#1D5FD1` | | The logo is a broken image | The URL isn't public, or the image has no fallback and the value is empty | Use a public `https` URL and keep a **Fallback image URL** | | Publishing is refused, naming `brand.primary_color` | The email reads a field the step doesn't load | Add it to **Fields to load** | ## Other channels and uses Saved records work in emails, WhatsApp, SMS, Instagram and Messenger messages, HTTP requests and Dispatch event steps. Decision and Case steps can't read `brand.*` directly; to branch on whether a record was found, use the step's **Found** output. ## With the API and MCP Journeys built over the API or MCP use the same step (`LOAD_OBJECT`, with `objectType`, `saveAs`, `lookup` and `attributes`) and the same publish checks. See [Journeys](/journeys) for the node reference and [Use Boom from MCP](/use-mcp) to connect an AI agent.