# 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. Success metrics and the analytics views are configured and read in the Boom app. They are not on the REST API or MCP today. The raw material behind them is: [extracted values](/extraction), transcripts and participant records all come back over the API, 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. ## 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. 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 The typed fields a conversation yields, and the read paths behind these numbers. The flow whose nodes these per step metrics describe. Record the events a success metric can watch for. 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. # 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; free-form attributes are fully replaced. # 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. # 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. Free-form attributes are fully replaced on each write. # 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. # 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 initiative. WARNING: each person added receives a real outbound WhatsApp message immediately. Requires a published journey. 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 Archive a completed or canceled initiative, hiding it from the default list. Reversible with unarchive. # 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. Requires an org admin. # 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. 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. # 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. A WhatsApp initiative needs an approved template first; its journey is published as part of the launch (fails with the journey's issues if it cannot be). Requires an org admin. # 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. Both are the same record type, split by `isRecurring` — pass that parameter to narrow to one. 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. Filter by personExternalId to find one person's runs — including the live one, to stop it before starting another. # 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 Stop all messaging to one participant and close their conversation. Idempotent and safe — it never sends. Their answers and transcript stay readable. # Unarchive initiative Source: https://docs.useboom.ai/api-reference/initiatives/unarchive-initiative /api-reference/openapi.json post /api/v1/initiatives/{id}/unarchive Unarchive an initiative, restoring it to the default list. Its status is unchanged. # Update initiative Source: https://docs.useboom.ai/api-reference/initiatives/update-initiative /api-reference/openapi.json patch /api/v1/initiatives/{id} Edit a draft initiative. Only draft initiatives are editable. # 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 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 WhatsApp channels a SEND_MESSAGE node can send from. Use a channel id as channelId on the node and to look up its approved templates. # 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.*. 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). 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. 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), with an optional frequency cap. 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). Use it to iterate a draft to valid before publishing in the app. # 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 ``` 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 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. 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. The workflow behind an initiative. Author it end to end: open a draft, add and connect nodes, set the trigger, validate, publish. The typed fields each conversation should yield, and how to read them back per participant or in aggregate. 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`). When the initiative is set to WhatsApp, launching publishes its [journey](/journeys) for you, 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. 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, and computed variables — with the token and operators to use for each. Read this first when building a filter. # 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). 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. Use them when creating templates. # 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. # 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`. 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. 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 Make your first authenticated calls end to end. Per-key limits, headers, and the shared error shape. The same capabilities over REST and MCP. # 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 ``` 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 Individuals, keyed by your own `externalId`, with optional `firstName`, `lastName`, `email`, and `phoneNumber` (E.164), plus a free-form `attributes` object. Typed records (an `order`, a `loan`, …) keyed by `(type, externalId)`. Types are defined per organization. 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. Append-only behavioral records (a `payment_made`, a `checkout_started`) tied to a person or object. Record one at a time or in bulk. Links between a person and an object, or between two objects: a customer who `placed` an order, an order that `has_line_item`. 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. `attributes` are **fully replaced** on update: always send the complete set. * **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 How events are shaped, recorded, and read back. The catalog of allowed link shapes and how to register one. Sync people and objects straight from PostgreSQL or MySQL: no API integration. # 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. 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: A least-privilege database user Boom connects as. Enter host, credentials, and TLS, then test the connection. Write the query and map columns to customer data. Decode the most common connection errors. 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 **PostgreSQL** or **MySQL**. Connecting and testing works for both today; automated syncing currently runs for **PostgreSQL** (see [Current limitations](#current-limitations)). Boom only ever reads. Create a dedicated user with `SELECT`-only grants: [snippets below](#step-1-create-a-read-only-user). 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. The connection is encrypted in transit. `Require` is the default; use `Verify full` to also validate the server certificate against a CA. **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; ``` 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 In the dashboard sidebar, open **Integrations**, then **Add integration**. Pick **PostgreSQL** or **MySQL**: the default port fills in automatically (`5432` for PostgreSQL, `3306` for MySQL). | 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. | Under **Security**, pick how strictly Boom verifies the server's certificate. TLS is always used; this controls verification: | Mode | Behavior | When to use | | --------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | **Require** | TLS required; the server certificate is **not** verified. | Default. Good for most managed databases (Neon, RDS, Cloud SQL). | | **Verify full** | TLS required **and** the server certificate verified against a CA certificate you supply. | Highest assurance (see the note below). | | **Disable** | No TLS. | **Not recommended**, only for local development. | Avoid **Disable** for any database holding real customer data: the connection would be unencrypted in transit. **Verify full** requires a CA certificate, and uploading one isn't available in the connection form yet. Use **Require** (which still encrypts the connection) unless you specifically need certificate verification; if you do, contact [support](mailto:support@useboom.ai) to set it up. See [Current limitations](#current-limitations). **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). Click **Test connection**. Boom opens a short, read-only connection, checks TLS, and reads the database version. On success you'll see the verified version (e.g. *Connection verified · PostgreSQL 16.2*). On failure you get a plain-language message and a hint. See [Troubleshooting](#troubleshooting). Click **Save**. Boom re-runs the test automatically as it saves, so the connection lands with the right status without a separate click: an **Active** badge if it passed, or **Error** (with the message) if it didn't. A failed connection is still saved, so you can fix the details and retest from its page. ### 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. 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. **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. Open the **Data sources** area for your connection to build them. ### 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: pick a table Boom discovered and choose **Draft as person** or **Draft as custom object**. Boom writes a starter `SELECT` for you, which you can then refine. ### 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; ``` 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. 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: 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. 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. 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. 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. Use the **Enabled** toggle on a source to pause 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. 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'@'%'`. Boom connected but can't read a table. Grant `SELECT` on the tables (or schema) you're syncing. See the read-only snippets above. 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). 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. 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. The encrypted handshake didn't complete. Check the **SSL mode**: if you chose **Verify full**, the server's certificate must be valid and match a trusted CA. Try **Require** to confirm the rest of the connection works. 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). 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. 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. * **`Verify full` needs a CA certificate that the form can't yet accept.** The connection form doesn't expose a CA-certificate field, so `Verify full` isn't selectable end-to-end yet. Use **Require** (still encrypted) for now, or contact support if you need full certificate verification. ## Related Understand how links between records are modeled across the CDP. Prefer to push data over the API? Start here instead. 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. # 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. | 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. ## 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 Record your first event alongside people and objects. 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. Extraction is scoped to one initiative. Each initiative has its own schema, and there's no shared, cross-initiative field catalog today. 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 `extraction_schema_set`: author or replace the schema in one call. `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. Setting a schema reshapes how every conversation on the initiative gets analyzed, so it is not a casual change. Over MCP it requires the signed-in user to be an org admin. 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 `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. `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`. `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). 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 Bind `engagement.extracted.*` into a WhatsApp template placeholder. How the same capability is exposed identically on REST and MCP. Connect an AI tool to read and set extraction schemas conversationally. Every Initiatives, Journeys, and Segments endpoint. # 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 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. No journey is involved. A journey exists to carry a conversation you started, so it has a trigger, a first message, and a path. An inbound conversation has none of those. It starts because the customer decided to write. ## 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: 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. ## 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. | `/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 that answers, how the knowledge base briefs it, and when it hands off to a person. The other direction: a flow you author to start a conversation. Why outreach needs an approved opener and inbound does not. 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`. | | `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. | | `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. | | `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`. **Only a draft is editable through the API**, so get the configuration right before launching, and treat a launched initiative as frozen. ## Lifecycle ``` DRAFT ──launch──► ACTIVE ──► COMPLETED │ │ └── editable └── cancel, archive ``` 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. | When the initiative is set to WhatsApp, launching **publishes its journey for you**, 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. 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. **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. Enrolling sends a real message. People on your Do Not Contact list are skipped by the platform rather than by whoever wrote the flow, so a suppressed contact enrolled by mistake is not contacted. ## 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 The flow an initiative runs, and how to shape it when the default is not what you want. The typed fields every conversation should yield. Set this before launching. Define who the initiative reaches, and remember to evaluate it. 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. # 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, or CDP event, with an optional frequency cap | Exactly one per journey | | `SEND_MESSAGE` | Sends an approved WhatsApp template from a chosen channel | The template must belong to the same WhatsApp account as the channel, or publishing is blocked | | `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 inactivity timeout closes the conversation after a window of silence | | `CONVERSATION_BLOCK` | Legacy combined wait-and-converse step | Kept so journeys published before the split keep running. Build new journeys with `WAIT_FOR_REPLY` + `MANAGE_CONVERSATION` instead | | `DISPATCH_EVENT` | Records a CDP event for the person | Lets one journey enroll people into another: the dispatched event can be a different journey's trigger | | `DELAY` | Pauses the run for a duration, until a date, or until a weekday window | A pure wait. It does not race an incoming reply | | `DECISION` | Two-way branch, combined by AND/OR, over workflow data, a reserved event, or a live CDP attribute | Both `YES` and `NO` must be wired before publishing | | `CASE` | Switches on a single person attribute, up to 10 branches | Every branch handle, plus the default handle, must be wired | | `HTTP_REQUEST` | Calls an external endpoint, optionally with a stored credential | Emits `SUCCESS` or `FAILED`. Behind a feature flag your organization may not have yet | | `EXIT` | Ends the journey for the person | At least one is required | The visual builder also has an email-send step, outbound only, with no reply of its own. It is not yet in the authoring catalog above, so `journeys_add_node` cannot add one today. A journey already built with one in the app still runs and can still be read back; the rule below under Troubleshooting still applies to it. It also needs a PUBLISHED email template, and templates are WhatsApp only on the API and MCP surface (`whatsapp_numbers_list`, `templates_list`, `templates_get`, `templates_create`). There is no email-template capability. An email template is created and published in the Boom app, after the organization has a verified sending domain and from address set up there too. ## Build a journey Call `journeys_authoring_catalog` for the node kinds, and the reference catalogs for the ids and paths a node needs: `journeys_message_channels` and `journeys_message_templates` for a `SEND_MESSAGE` node, `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. `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`. 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_set_trigger` configures the ENTRY node: `manual`, `segment` (needs a `segmentId`), or `cdp_event` (needs an `eventName`), with an optional frequency cap. 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. `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. `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. * 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. 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 three 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. 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. ## 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. **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 Connect an AI tool to author and run journeys conversationally. The full variable catalog a SEND\_MESSAGE binding can reference, and what resolves per trigger. Record the CDP events that trigger a journey or feed a DECISION condition. Why every journey tool here has a matching REST endpoint. # 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. This documentation is generated from the same schemas the API validates against. The reference pages cannot drift from what the platform actually does. ## 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 (destructive/outreach tools need an org admin) | | **Rate limits** | [Per key](/rate-limits-and-errors) | Per user | | **Errors** | `{ "error": { "code", "message" } }` + HTTP status | Tool error with the same `code: message` | ## Related Connect Claude, Cursor, or any AI tool to Boom. The same capabilities over REST, end to end. # Quickstart Source: https://docs.useboom.ai/quickstart Create a person, an object type, an object, a relationship type, and a link. 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" ``` ```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`. 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"}' ``` ```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"}}' ``` ```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. ```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. 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. Run any upsert twice: the first returns `created: true`, the second `created: false`. That's the upsert working as intended. 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 Create an API key and authenticate your requests. Record behavioral events and read them back. Register the link shapes you need before creating relationships. 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": "Rate limit exceeded" } ``` 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 share your key but 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). ## 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`), 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`) or a wrong lifecycle state | | `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` | A list query timed out (`query_timeout`). Narrow the filters or `limit`, then retry | 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. 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 How API keys and the `401` responses work. 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. 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: Sent as `Authorization: Bearer boom_org_…`. The organization is derived from the key. See [Authentication](/authentication). 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. 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`. | 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 } ``` 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 `POST /custom-objects/types` (or via your dashboard / data sync). The public API never auto-creates object types. `POST /relationship-types` with cardinality + description. **Keep the `relationshipTypeId` from the response**: you'll need it to create relationships. `POST /people` and `POST /custom-objects`. `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 Register a type and create a relationship in context. Record what your linked people and objects did. # 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. ## 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. * **`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` | `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. `GET /api/v1/segments/catalog` (`segments_catalog`). See what's filterable before writing anything. `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":[...] } }' ``` `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. `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":[...] } }' ``` `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. `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. 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. 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 `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 requires organization admin over MCP.** `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. ## Related The people, objects, and events a segment's filter reads. How the events an `event` predicate matches against get recorded. Set a segment as a journey's trigger to turn membership into outreach. 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). 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. 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:`. 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 | `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. 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. These skills can launch initiatives and add participants, which send **real WhatsApp messages 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 Connect Boom to Claude, Cursor, or any AI tool. API keys for scripts and server-to-server calls. # 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 What answers your customers, and how it improves from your corrections. One mission: audience, objective, and what it comes back with. Point Claude or Cursor at Boom and sign in. No API key. 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 sends an approved WhatsApp template. 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" } } ``` 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). **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` | | `engagement.extracted.*` | The conversation: extraction outputs | `engagement.extracted.churn_reason` | | `engagement.nodeOutputs..*` | An earlier HTTP request node's response | `….status`, `….body.result` | | `engagement.documents` | Files the participant sent in the conversation (HTTP node body only) | `engagement.documents` | 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. * **`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`. `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. ## 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" } } ``` ## 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 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. 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. ## 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. 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. 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 The workflow behind an initiative, and how you author and publish one. The typed fields the agent pulls out of a conversation, and how you read them back. The people, objects, and events behind the data schemas in the knowledge base. Connect an AI tool to the same organization and capabilities. # 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. Copy this link: ``` https://www.useboom.ai/mcp ``` Then add it the way your tool expects: Run this in your terminal: ```bash theme={null} claude mcp add --transport http boom https://www.useboom.ai/mcp ``` Add this to `.cursor/mcp.json`, then open **Settings → MCP** and click **Boom**: ```json theme={null} { "mcpServers": { "boom": { "url": "https://www.useboom.ai/mcp" } } } ``` 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" } } } ``` Go to **Settings → Connectors → Add custom connector**, paste the link, and click **Connect**. Most tools use a file like this: ```json theme={null} { "mcpServers": { "boom": { "url": "https://www.useboom.ai/mcp" } } } ``` 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**. * **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). One gap worth knowing: the email send step is not in the authoring catalog yet, so an agent cannot add one. Build that step in the Boom app. A journey that already has one still runs and still reads back normally. * **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. Adding participants to an initiative (and launching one) sends **real WhatsApp messages 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. Destructive and outreach actions (deleting records, launching or cancelling initiatives, adding or stopping participants) require your Boom login to be an **organization admin**. Read tools work for every member. ## Related How MCP tools map one-to-one onto REST endpoints. Work with the same data over the REST API. API keys for scripts and server-to-server calls. Sync people and objects straight from your database. Limits and the shared error shape. # 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. | `enrollment.rejected` fires **only** on the CDP-event enrollment path. Segment, manual, CSV and API enrollment do not emit it — which is why its `trigger` is always `kind: "cdp_event"`. ## The run lifecycle The two `journey_run.*` events bracket a run, and understanding the gap between them is most of what you need to consume them correctly. **`journey_run.started` fires at enrollment** — the moment the person enters the journey, *before* the first message is sent. It is not a delivery receipt. A run whose first send later fails still emitted `started`. **`journey_run.ended` fires when the run reaches a terminal state**, whatever the ending. Between the two, the journey does its work: sends, waits for a reply, converses, branches. A journey with a multi-day wait leaves the run open for days, so **do not assume the two events arrive close together**. `durationMs` tells you the real gap. Two runs from the same journey, both real: | | Conversed | Never replied | | ----------------------------------- | ----------------- | ------------------ | | `reason` | `completed` | `no_reply` | | `lastSignal` | `CLOSED` | `TIMEOUT` | | `messagesSent` / `messagesReceived` | 3 / 2 | 1 / 0 | | `durationMs` | 109,713 (\~2 min) | \~5 min (the wait) | `messagesSent` and `messagesReceived` count the whole run, so they are only final on `ended`. **Webhooks are not a source of truth — reconcile.** Delivery is at-least-once (so dedupe), but **emission is at-most-once**: if we fail to emit, the run is permanently unreported and no retry recovers it. A subscription can also be skipped at delivery time — an unresolvable credential or `{{env.*}}` token drops the event outright. If your system must be exactly consistent with ours, treat webhooks as a fast path and reconcile periodically, not as a ledger. ## Set one up Webhooks are on for every workspace, and you need both **Settings → Webhooks** and **Settings → HTTP Credentials**. If either page isn't there, it is almost always your user's permissions rather than the feature — ask a workspace admin for the webhooks and HTTP-credentials permissions, or ask us to check. 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. 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. 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)). **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. **Delivery is at-least-once. You must dedupe on `id`.** A retry after a timeout can deliver an event your server already processed successfully — if the response never reached us, we try again. Store `id` and ignore repeats. Requests are `POST`, `Content-Type: application/json`, and time out after 10 seconds. ## `journey_run.started` ```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`. **`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: **`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", "terminalNode": { "id": "exit_no_response", "kind": "EXIT" }, "error": null, "startedAt": "…", "endedAt": "…", "durationMs": 43200000, "messagesSent": 1, "messagesReceived": 0 } } ``` **Branch on `reason`, not `status`.** Three materially different endings — the customer conversed, the customer replied then went quiet, the customer never replied at all — all land on `status: CONVERSATION_COMPLETE`. Status carries almost no information on its own. The nine values, so you can write an exhaustive `switch`: | `reason` | Means | `lastSignal` | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | | `completed` | The run reached its end normally. Includes a journey that simply had nothing to wait for — it does **not** guarantee the customer engaged; check `messagesReceived` for that. | `REPLIED`, `CLOSED`, or `null` | | `no_reply` | Outreach went out; the customer never replied. | `TIMEOUT` | | `abandoned` | The customer replied at least once, then stopped. | `STALE` | | `failed_node_error` | A step threw. `outcome.error` is set. | — | | `failed_engine` | The run failed without a specific failing step. `outcome.error` is always `null`. | — | | `canceled_operator` | An operator stopped the run, or the engine halted it. | — | | `canceled_initiative` | The whole initiative was canceled. | — | | `canceled_participant_stop` | This one participant was stopped. | — | | `expired` | Reserved. **Cannot currently occur on `journey_run.ended`** — only non-journey outreach expires, and those runs are never reported here. Handle it for exhaustiveness; don't build behaviour on it. | — | Nullable fields on `outcome`, so your parser doesn't assume otherwise: | Field | Shape | | -------------- | ------------------------------------------------------------------ | | `error` | `{ "errorName": "TypeError" }` or `null` — an object, not a string | | `terminalNode` | `{ id, kind }` or `null` when the run recorded no steps | | `lastSignal` | one of the signals above, or `null` | `terminalNode.id` is whatever the journey author named that exit node. It's useful for a human reading a log; don't build logic on it, because renaming a node in the builder changes it. `outcome.error` never contains an error message or stack — only a class name. Provider and database errors routinely echo the offending record, so we don't forward them. ## `enrollment.rejected` A trigger matched but no run started. ```json theme={null} "data": { "reason": "active_run", "detail": { "existingEngagementId": "…" }, "initiativeId": "…", "workflowId": "…", "trigger": { "kind": "cdp_event", "eventName": "checkout_abandoned", "eventId": "…", "externalId": "your-ref-99182" }, "eventName": "checkout_abandoned", "eventId": "…", "personId": "…", "personExternalId": "acct-4417", "customerId": "…" } ``` 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` | | `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. **This is an array on purpose — treat it as candidates, not an identity.** A CDP Person is identified by `externalId`; phone and email are **not** unique, so one number can belong to several People (shared family lines, a clinic front desk, a business number on many records). If you need one id, take `personExternalIds[0]` — it is the enrolled person whenever the conversation came from a journey run — but handle the case where more than one comes back. `personExternalIds` can also be **`[]`**: the contact reached you on a channel and was never synced to your CDP, so nothing matches. The key is always present, so branch on the length rather than on its shape, and fall back to opening `inboxUrl`. Syncing your people to the CDP (`POST $BASE/cdp/people`) is what makes this field reliable — a contact we have never seen in your CDP cannot be matched. We look the ids up when the escalation is dispatched, so they reflect your CDP at that moment. Sync a person **after** the handoff and the event you already received will not gain their id retroactively. **This is a nudge, not an alarm.** Emission is at-most-once, like every event here — if we fail to emit, no retry finds this conversation again. Good enough to stop watching the inbox all day; not something to put an SLA on. Keep the inbox as the source of truth for what needs a human. ### Environments do not apply here A conversation isn't a journey run, so it has no environment. Two things follow, and they're the opposite of what you might expect from the other events: * **The Environments field on a destination is ignored for escalations.** Whether you leave it empty or tick specific environments, every destination subscribed to `conversation.escalated` receives it. Ticking `production` will not stop escalations arriving. * **`{{env.*}}` in the URL resolves against your *default* environment**, since there's no run to say which one applies. Same for the credential: your default environment's key signs the request, falling back to a global one. So you cannot point escalations at a non-default environment by ticking boxes — there's no environment for the event to belong to. **If you need them at a specific host, use a literal URL** (no `{{env.*}}`), which takes environment resolution out of the picture entirely. One exception, and it runs the other way: **an escalation on a conversation whose journey run sits in your test environment is not delivered.** Test traffic never reaches your endpoint, for this event or any other. That is not the Environments field doing the filtering — it applies however that field is set, and a conversation with no journey run behind it (most of them) is always delivered. **Recommended: give `conversation.escalated` a destination of its own, with a literal URL.** Not because the shared one breaks — it won't — but because a handoff goes to whoever staffs your inbox, which is rarely the same system that consumes run lifecycle. Splitting them lets you point escalations at an on-call webhook or a support queue without touching the integration that tracks runs, and a literal URL means the host is exactly what you typed. Whichever you choose, make sure the destination's credential resolves — either a global credential, or one defined in your default environment. If your default environment is not the one your escalation endpoint belongs to, read the next section before you tick the event on. ### If your default environment isn't where escalations should authenticate Escalations resolve your **default** environment's credential row — even when the destination is pinned to another environment, and even when its URL points at another host. So if your default environment is `staging` and your escalation endpoint is production, a credential key that has a `staging` row **will resolve, and will sign with the staging secret**. Your endpoint sees a key it doesn't recognise and returns `401`; because any `4xx` is permanent we don't retry, so that escalation is gone. This is the one credential problem that doesn't announce itself as one — nothing is reported as unresolvable, and the destination looks correctly configured. Adding a *global* row to that same key does not fix it: an environment-specific row always wins over the global one, so the `staging` row keeps being chosen. The fix is a credential key that only ever has one value: **Settings → HTTP Credentials** → add a key (e.g. `escalation_api_key`) with **no environment** — a global row — holding the key your escalation endpoint expects. **Settings → Webhooks → Add destination**: a **literal** URL (no `{{env.*}}`), only `conversation.escalated` ticked, that credential, and **Environments left empty**. It keeps its environment pin and its per-environment credential. Journey events still resolve against the environment each run belongs to. A global-only key resolves identically in every environment, so escalations authenticate correctly no matter which environment carries the default flag — and you can keep `staging` as your default. Prefer to change the default instead? Making production your default environment also fixes this, but it changes every other default-environment fallback in your workspace — most visibly, journeys published without an explicit environment start resolving production's variables. Check those before you switch. ## Environments If you use environments to separate staging from production, a subscription can be scoped to them. These are environments **inside your organization** — a label on journey runs, webhook subscriptions, and credentials. They are not the two hosted environments (`www` and `dev`) described in [Authentication](/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. 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. **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.