Campaigns
A campaign is a multi-step automated subscriber journey: entry on a trigger, then a sequence of nodes (messages, delays, conditions, experiments) that can stretch over days or weeks. It is not the same thing as a broadcast or an email campaign — those send one content document in one wave; a campaign is a branching graph that reads and changes subscriber data at every step.
Versions and the draft
A campaign always has exactly one editable draft, and, once it has ever been
published, a history of immutable versions: draft → published → retired.
The draft carries the trigger, audience, settings, and node graph. Editing the
draft goes through PUT /draft with an expected_revision: if someone else
already saved the draft first, the request is rejected as a conflict instead
of silently overwriting their edit.
Publishing (POST /publish) freezes the current draft as a new published
version and immediately opens the next draft as its copy. A subscriber who
entered while version N was published walks their whole path on version N,
even after version N+1 is published later — the trigger, audience, settings,
and graph an already-running journey is executing never change under it.
The node graph can only be edited while the campaign is stopped — saving a graph while the campaign is Running or Paused is refused: a subscriber may be sitting on exactly that node by its id, and the executable graph must not change under a live subscriber.
Separate from versions, there is cloning: any existing version — a draft, a published one, or a retired one — can be opened as a new draft, replacing whichever draft is currently open. A campaign can also be archived (taken off the active list without deleting its history), but only while stopped.
Entry triggers
- Manual — no configuration; the editor's "send a test to myself" button takes the same path and runs the entry node immediately, without waiting for the background sweep.
- Site event — the same client SDK/widget event as in reactions, with the same condition editor.
- Schedule — once, at a fixed instant, or recurring. A recurring schedule
is a local time of day (
HH:MM) in an explicit IANA timezone — "Local" is refused — an optional weekday filter, and mandatorystart_at/end_atno more than two years apart: a recurring schedule must have an end, or a forgotten campaign would sweep the audience forever. A wall-clock moment that does not exist because of a spring-forward transition is simply absent from the schedule by default; the "next valid instant" policy moves it to the end of the gap instead of skipping it. - Label changed — one specific label added or removed, never both at once — those are different journeys with different content.
- Property changed — a transition of one approved profile property from one state to another. The "previous" and "new" conditions are independent and both optional; re-saving the same value is not a transition and never triggers entry.
- Segment entered — a membership transition from "not a member" to "member" of a ready segment. An ordinary recompute that merely reconfirms existing membership does not count as entry.
Graph nodes
The graph is nodes and edges, and an edge carries a branch label (for example
yes/no). Exactly one node has no incoming edges — the entry point; terminal
nodes ("Goal", "Exit") have no outgoing edges, and every node must have a path
to at least one terminal — a graph with an unreachable node or a node with no
path to a finish will not save. One node's configuration is capped at 32 KiB;
a campaign as a whole is capped at 200 nodes and 400 edges.
- Message — text/photo/file/delay blocks, run as an ordinary reaction against the same subscriber. AI blocks, a nested reaction, "expected answer" state, and parameter writes with a side effect are not supported here — retrying the node after a failure has to stay safe.
- Content — unlike "Message", sends one specific published, version- pinned content document on one explicitly named channel (Telegram, VK, WhatsApp, or web chat), and never switches channel by itself: an unreachable recipient either takes a separate "when unavailable" branch, if one is wired, or the node fails visibly.
- Delay — waits a fixed number of seconds (up to 180 days).
- Wait for event — waits for a client event by name (up to 64 characters,
no reserved
$prefix) with a timeout of up to 180 days; the node has exactly two branches — the default one (the event arrived) andtimeout. - Condition — branches
yes/noon subscriber parameters, time periods, time since last seen, or message count — the same condition vocabulary as reactions. - Split — a weighted A/B branch: a subscriber lands in the same branch deterministically, by a hash of the node and their own id, and that does not change on re-evaluation or restart.
- Experiment — branches on a shared experiment's variant, with a pinned experiment id and version; two to sixteen variants, and every declared variant is a mandatory branch — an unwired variant would silently strand whoever the experiment assigns to it.
- Channel available — branches
yes/noon whether the subscriber can actually be reached right now on one explicitly named channel (email and phone do not qualify — neither is a delivery channel). It exists so an author decides the fallback channel question explicitly, rather than relying on a send silently switching channel on its own. - Action — change labels; unset parameters (setting, appending, and
incrementing are not supported here — the same retry-safety principle as
"Message"); a synchronous web request (no async mode, no nested reaction on
success/error, the
Idempotency-Keyheader is reserved); sync to a connected CRM; assign the support dialog to an agent or a group; emit a customer event (the same as a widgettrack()call — segments, funnels, and a matching trigger will see it); start a lead bot. - Goal and Exit — terminal nodes with no configuration: the first is a successful finish, the second is leaving without one.
Preflight and publishing
POST /preflight checks the current draft's shape (the same graph validator
that runs on every save) and its cross-entity references — do the named
segments, content documents, and experiments actually exist — and sends
nothing. The response is ready, the version number, its checksum, and a
list of errors with a code (invalid_graph, invalid_reference) and a
message. Preflight does not estimate audience size, channel eligibility, or
consent — that is out of scope for it.
POST /publish re-runs the same reference checks again, in one transaction: a
client that skipped preflight, or whose world changed since, still cannot
publish a campaign pointing at a deleted segment or document. Publishing
requires the draft's current revision — a stale revision or an already-
published draft returns a conflict with no partial result.
Per-version reports and the journey trace
The report (GET /report) is computed per version, not for the campaign
as a whole: version N and version N+1 carry different counters, and
publishing a new version never shifts an old one's numbers. The report has a
journey funnel (entered, active, waiting, on retry, done, exited, failed) and,
per node, entered/completed/failed counts. A report is sealed once its
version can no longer take a new entrant and no journey on it can move any
further — those numbers are stored as final and never recomputed; a live
published version's report is always recomputed on read.
One journey's trace (GET /journeys/{subscriberID}/{runNo}) is a bounded
event log: entering a node, an effect firing, parking on a wait, leaving a
wait, a retry being scheduled, giving up after exhausting attempts, finishing.
The trace carries ids, branches, attempt counts, and error codes — never a
message body or a provider response: it is an audit of the path taken, not a
copy of what was sent. A truncated flag shows a journey exceeded the event
cap and part of its history was not kept. A separate list
(GET /failures) shows who is stuck, and on which node, after the runner
exhausted its attempts; a stuck journey can be retried
(POST /journeys/{subscriberID}/{runNo}/retry).
Quiet hours
A version has a free-form settings blob, seeded by default as
{"allow_reentry": false, "timezone_mode": "bot", "quiet_hours": {"enabled": false}}.
Re-entry (allow_reentry) is a separate, genuinely enforced field
on the campaign itself (not on version settings): it decides whether one
subscriber may enter the campaign a second time. timezone_mode and
quiet_hours inside settings, however, are currently a reserved
configuration shape only — the campaign runner does not read or apply them.
That is what sets them apart from the same-named feature in
Web Push, where quiet hours genuinely defer delivery. Do not
assume a campaign will wait for a "convenient" time on its own — model a
pause with a "Delay" node or a time-period condition instead.
Permissions
Personal tokens and roles see campaigns through three separate scopes:
campaigns:read, campaigns:write, and campaigns:publish — publishing a
version and retrying a stuck journey are kept apart from ordinary draft
editing. All three require the "Reactions" bot right: without it, the
campaigns section is neither visible nor reachable for reading or writing.
What's next
- Broadcasts — a one-off send of a single document, with no graph and no versions.
- Email campaigns — a similar one-off scenario for email.
- Experiments and attribution — the shared experiment an "Experiment" node can reference.
- People (CRM) — segments and labels for audience and triggers.
- Content Studio — documents for the "Content" node.
- REST API and Tokens — campaign routes and their scopes.