Skip to content
Docs
FA
Sign in
Start here
  • Overview
  • Quickstart
  • Concepts
Collect data
  • Designing events
  • Event dictionary
  • Placing events
  • Identity
  • Web SDK
  • Android SDK
  • Devices and push
  • Server to server
  • Product catalogue
  • Webhooks
Engage customers
  • Segments
  • Journeys
  • Transactional
  • Consent and caps
  • In-app and inbox
Analyze and export
  • Reports and exports
Developer reference
  • API reference
    • Ingest endpoints
    • Management API
  • Errors
  • Limits
  • OpenAPI
Developer tools
  • MCP server
  • Working with an agent
Privacy and changes
  • Personal data
  • Versioning

Building a journey

The graph each user walks alone: entry, waits, branches and exit.

A campaign sends one message to a list. A journey is a graph that each person walks alone, at their own pace, from the moment something about them became true. Two people who entered the same journey an hour apart are at different nodes, waiting on different timers, and one of them may already have left.

The engine that walks the graph performs no I/O and mutates nothing. Given a graph, a node and a snapshot of the person, it returns what should happen next as data. That is what makes every branching rule testable without a database, which matters because a bug in it sends the wrong message to millions of people.

#What a journey is

ENTRY
Segment entryAudience change
Event triggerCustomer action
ScheduleTime-based entry
SEGMENTICCondition nodeEvaluate customer data at the moment of action
NEXT STEP
YESSend messageEligible route
YESCall APIYour service
NOWait or exitStop this path
A Segmentic journey from entry triggers through a condition to messages, API calls or exit

A journey has one entry node, some number of nodes after it, and no other way in. Each person who enters gets an instance: their position in one version of the graph, plus the bookkeeping that goes with it.

JSON
{
  "tenant_id": 7,
  "journey_id": 12,
  "version": 3,
  "user_id": "u_9137",
  "current_node": "wait",
  "status": "waiting",
  "entered_at": "2026-08-01T09:00:00Z",
  "updated_at": "2026-08-01T09:00:00Z",
  "wake_at": "2026-08-01T10:00:00Z",
  "entry_count": 1,
  "variant": "",
  "is_holdout": false
}

Instance statuses are active, waiting, completed and exited. Exit reasons are completed, exit_criteria, max_duration, goal_reached and no_next_node.

#The endpoints

Every journey route is on the control plane, which is the API the panel talks to. There is no journey route on the management host.

The control plane is not routed from the internet. In the reference deployment, api.segmentic.net serves the management API on its own listener and the control plane's listener is deliberately not published. The only public path to the routes in this table is the panel's own server-side proxy at https://app.segmentic.net/api/proxy/v1/..., which authenticates with the signed-in user's session cookie and answers 401 without one. An sk_seg_ key does not reach it. Everything on this page is therefore something a person does in the panel, including entering people from an API.

RoutePermissionWhat it does
GET /v1/journeysjourney.readlist, with live counts
GET /v1/journeys/{id}journey.readthe published graph plus per-node statistics. ?version=N for an older one.
POST /v1/journeysjourney.writesave a draft
GET /v1/journeys/{id}/draftjourney.readthe working copy, its problems and its warnings
POST /v1/journeys/validatejourney.readcompile a graph without storing it
POST /v1/journeys/{id}/simulatejourney.readdry run against one real person
POST /v1/journeys/{id}/publishjourney.publishfreeze a version and activate it
POST /v1/journeys/{id}/enterjourney.publishpush named people in
POST /v1/journeys/{id}/{action}journey.writepause, resume or archive
DELETE /v1/journeys/{id}journey.writemove it to the recycle bin

journey.publish is carried by owner, admin and marketer only. It is separate from journey.write because editing a canvas and starting real messages to real people are different acts.

The read routes exist only when the journey reader is configured; the write routes only when the editor is; enter additionally needs the ingest path.

GET /v1/capabilities on the management host reports "journeys": true when journeys are wired. No journey route is registered on that host. Do not read that flag as "journeys are reachable over the management API". They are not, in any form, including read.

GET /v1/journeys returns {"journeys": [...]}, always an array and never null, each row being {id, name, status, version, active, waiting}.

GET /v1/journeys/{id} returns the graph and the counts together:

JSON
{
  "graph": { "journey_id": 12, "version": 3, "entry_id": "trigger", "nodes": [] },
  "stats": {
    "trigger": { "entered": 4210, "exited": 0, "suppressed": 0, "waiting": 0 },
    "send":    { "entered": 3902, "exited": 0, "suppressed": 391, "waiting": 0 }
  }
}

They travel together deliberately: fetching statistics separately would show a graph with empty nodes for a beat, and seeing where the funnel leaks without leaving the page is the whole point. A statistics failure yields an empty map rather than an error page. A journey that exists but has never been published answers 404 with this journey has no published version yet, which is a different fact from "no such journey" and is worded differently on purpose.

#The graph

FieldJSONNotes
Journey idjourney_id
Versionversionimmutable once published
Nodesnodes
Entryentry_idthe id of the one node people enter at
Re-entry ruleentry_ruleonce, every_time or max_n. Empty means once.
Entry ceilingmax_entrieswith max_n
Minimum gapcooldown_hoursin hours, from the last entry
Re-entry windowreentry_windowday means once a day in the account's own day. Empty means cooldown_hours.
Exit conditionexit_criteriaa segment definition. Removes a person the moment it matches, wherever they are.
Maximum staymax_duration_daysbounds how long anyone may remain

The tenant id is never part of the stored document. The loader sets it.

#Node types

Every node carries id, kind, an optional label, an optional next, and exactly one configuration object matching its kind.

JSON
{
  "id": "wait",
  "kind": "wait",
  "label": "one hour",
  "next": "send",
  "wait": { "kind": "duration", "amount": 1, "unit": "hour" }
}

x and y may also be present. They are where the editor drew the node and the engine never reads them. A graph without them is not less valid; the canvas computes a layout.

An empty next is legal and ends the journey there. A next naming a node that does not exist is a dangling edge and refuses to compile.

The eight kinds are trigger, wait, condition, switch, split, action, goal and exit.

#trigger

The way in. TriggerConfig:

FieldMeaning
kindevent, segment_enter, segment_exit, segment_periodic, attribute_changed, date, api
eventthe event name, for event
definitionan inline segment definition, for the segment kinds
segment_ida saved segment id
trait, valuefor attribute_changed and date. An empty value means any non-empty write to that trait.
filterproperty comparisons on an event trigger: gt, gte, lt, lte, eq, neq
offset_daysfor date: how many days ahead of the date it fires. 0 to 365.
hoursfor segment_periodic and date: which hours of the day it sweeps. 1 to 6 of them.

The segment rule is stored inline, not as a reference to a saved segment. A published version is immutable, and a reference would break that: editing the saved segment would silently change who enters a journey that was reviewed and approved with different rules.

offset_days only ever runs *before* the date, never after. "Three days after their birthday" is a wait node, and offering both here would put the same delay in two places that disagree about quiet hours.

hours are hours of the day rather than an interval, because "every six hours" drifts against the clock the audience lives by: a sweep started at 09:00 is a 03:00 sweep a week later, and the messages it produces are then held by quiet hours until morning for reasons nobody can see from the schedule.

#wait

kindFieldsBehaviour
durationamount, unit (minute, hour, day, week)wakes at now plus that span
until_timehour 0 to 23, minutethe next occurrence of that wall-clock time in the recipient's own timezone
until_best_timefallback_hour 0 to 23the hour this person has historically engaged at, or the fallback when there is not enough history
for_eventevent, timeout_amount, timeout_unit, on_timeoutparks until the event arrives or the timeout fires

fallback_hour is required on a best-time wait rather than defaulted, because a best time computed from three opens is a coin flip wearing a statistic's clothes, and silently defaulting to midnight would send at 3am to exactly the people the platform knows least about.

A for_event wait resumes down next when the event arrives, and down on_timeout when the timer fires first. An empty on_timeout falls back to next, so both paths converge.

#condition and switch

A condition is yes or no:

JSON
{
  "id": "bought",
  "kind": "condition",
  "condition": {
    "definition": { "root": {} },
    "on_true": "thanks",
    "on_false": "remind"
  }
}

A switch is many ways, evaluated in order, first match wins:

JSON
{
  "id": "plan",
  "kind": "switch",
  "switch": {
    "cases": [
      { "label": "premium", "definition": { "root": {} }, "next": "vip" },
      { "label": "paid",    "definition": { "root": {} }, "next": "standard" }
    ],
    "default": "free"
  }
}

default is required. Without it anybody who matches no case leaves the journey at the switch, which looks identical to a bug and is invisible until an audience report comes back short.

Ordered rather than collecting every match, because overlapping rules are the normal case: people on the premium plan are also people who have bought, and an ordered list is the only reading in which the author decides which one counts.

Both use the same segment definition language as a saved audience. See Segments.

When no segment evaluator is available, a condition takes on_false and a switch takes default. That is the conservative reading, and it is what happens rather than an error.

#split

JSON
{
  "id": "test",
  "kind": "split",
  "split": {
    "branches": [
      { "weight": 1, "next": "arm_a", "variant": "A" },
      { "weight": 1, "next": "arm_b", "variant": "B" }
    ],
    "holdout_percent": 10,
    "on_holdout": "end"
  }
}

weight is a share, not a percentage. The engine normalises, so a marketer editing one arm cannot leave the total at 97. holdout_percent is 0 to 100.

Allocation is a hash, not a draw: the holdout uses (user_id, node_id) and the arm uses (user_id, node_id + ":branch"), so the two are independent and both are stable across a retry. A user who flipped between arms on a retry would make the result meaningless.

A holdout user with no on_holdout path follows the first branch with the sends suppressed. See Holdouts.

#action

Five kinds:

kindRequiredWhat it does
sendchannel or channels, and a template for eachdelivers a message
update_traittrait, valuewrites to the person's profile
webhookurlcalls your endpoint
add_to_segmentsegment_idadds the person to a list
enter_journeytarget_journey_idhands them to another journey

A send node may name one channel (channel and template_id) or several (channels, an array of {channel, template_id}), with channel_mode empty for fallback or "all" for broadcast. Fallback tries each in order and stops at the first that reaches the person. Only a channel-shaped refusal moves on: no device, no phone number, this medium switched off. A refusal about the person rather than the medium, an unsubscribe, a frequency cap, quiet hours, stops the node for them entirely, because trying the next channel would be looking for a way around the answer.

priority and budget sit on the node rather than on the journey, because one flow contains both kinds of message: "your order is out for delivery" and "you might also like" are the same journey and are not the same claim on somebody's attention.

enter_journey may not target its own journey.

#goal and exit

A goal node carries {"event": "...", "window_days": N}. It is a conversion checkpoint; window_days bounds attribution, because a purchase six months later is not this journey's doing.

An exit node is terminal and carries no configuration.

#A complete definition

The abandoned-cart flow, in the JSON POST /v1/journeys accepts:

JSON
{
  "name": "سبد رها شده",
  "graph": {
    "journey_id": 1,
    "entry_id": "trigger",
    "nodes": [
      {
        "id": "trigger", "kind": "trigger", "label": "added to cart", "next": "wait",
        "trigger": { "kind": "event", "event": "product_added_to_cart" }
      },
      {
        "id": "wait", "kind": "wait", "label": "one hour", "next": "send",
        "wait": { "kind": "duration", "amount": 1, "unit": "hour" }
      },
      {
        "id": "send", "kind": "action", "label": "reminder", "next": "end",
        "action": { "kind": "send", "channel": "push", "template_id": 3 }
      },
      { "id": "end", "kind": "exit" }
    ]
  }
}

A periodic sweep with pacing and an exit rule. This combination, sweep twice a day, message no one person more than weekly, and stop chasing anybody who signs up, is the shape that produces no warnings at all:

JSON
{
  "journey_id": 1,
  "entry_id": "trigger",
  "entry_rule": "every_time",
  "cooldown_hours": 168,
  "exit_criteria": { "root": {} },
  "nodes": [
    {
      "id": "trigger", "kind": "trigger", "label": "sweep", "next": "send",
      "trigger": {
        "kind": "segment_periodic",
        "definition": { "root": {} },
        "hours": [11, 18]
      }
    },
    {
      "id": "send", "kind": "action", "label": "reminder", "next": "end",
      "action": { "kind": "send", "channel": "push", "template_id": 3 }
    },
    { "id": "end", "kind": "exit" }
  ]
}

POST /v1/journeys answers {"id": 12, "version": 4}. A draft is allowed to be incomplete. A single node with no edges saves without complaint, because a marketer builds a flow over several sittings and refusing to save half a graph loses their work. Publishing is where the graph has to be sound.

Failures: 400 malformed JSON, 400 name is required, 400 graph is required, 503 could not save journey.

#How people get in

The re-entry rule decides who may start again:

entry_ruleBehaviour
once (and empty)never again, whatever happened the first time
every_timeagain, subject to the minimum gap
max_nuntil entry_count reaches max_entries

Somebody currently active or waiting is never re-entered, under any rule. Starting them again would send them the whole flow twice in parallel. cooldown_hours is measured from the prior instance's last update, not from when it started.

#The gap has two readings

cooldown_hours is a duration: nobody enters again until N hours have passed since their last entry. reentry_window: "day" is a calendar period: at most one entry per person per day, and the counter resets at midnight rather than 24 hours after the previous entry. The calendar window is measured from the prior instance's entry rather than its last update, so the gap does not depend on how long the flow itself runs.

They are not the same rule. A sweep at 10:00 and 18:00 with a cooldown_hours of 24 enrolled 2,110 people at 19:00 one evening, and both of the next day's slots then fell inside those 24 hours, so 15 people entered against a baseline of 2,195. The cohort locks to the hour it first entered and misses whole days from then on. A daily reminder means "once a day", and no duration expresses that.

The day is the account's, in Tehran, the same day a day frequency cap counts over. The two fields are alternatives rather than a pair: a graph carrying both has two answers, so POST /v1/journeys/validate reports it as a problem and the panel will not publish it. If one is somehow stored anyway, the window is what the engine obeys and the hours beside it do nothing.

How the trigger actually enrols somebody differs by kind, and the differences are the part that catches people out.

event is immediate. The event arrives on the bus, the worker checks every published graph, and a match enters the person. filter comparisons apply here. A journey listening for an ordinary event can never be entered by the reserved enrolment event described below.

attribute_changed matches only an identify call that actually wrote the trait, with an optional exact value match. It is evaluated from the identify event itself, so it reacts immediately and never mistakes an old profile value for a change.

segment_enter, segment_exit, segment_periodic and date are found by a scanner, not by an event. Nobody emits "left the lapsed-customer segment"; it becomes true because a purchase three weeks ago aged past a window, and the only way to notice is to look twice and compare.

A segment trigger is not instant and is not advertised as such. The scanner re-reads membership every 5 minutes by default. The delay is bounded by that interval. A marketer who needs "the moment they buy" wants an event trigger.

Three properties of the scanner are load-bearing:

  • The first scan enrols nobody. It records who is in the segment and stops. Without that interlock, publishing a win-back aimed at four hundred thousand lapsed customers messages all of them inside one scan interval, and the graph looks entirely correct afterwards.
  • Enrolment goes through the same path an enter_journey jump uses, so the entry rule, the cooldown and the exit criteria are applied by the code that already applies them.
  • Truncation is recorded, never silent. A diff is bounded at 250000 members and a sweep at 5000000, both read one page of 5000 at a time. A segment larger than the scan may read is a journey that has quietly stopped noticing anybody past the limit.

Sweep hours are read in Tehran, because "sweep at 11 and 18" is a sentence about the audience's day.

A date trigger compiles "three days before their birthday" into "days until their birthday is exactly three", ANDed with the trigger's own definition when it has one.

Every non-event enrolment, including the API one, travels as the reserved event journey_enter_requested carrying a numeric property journey_id. That is why an API entry inherits de-duplication, the re-entry rules, the exit criteria and the instance bookkeeping instead of getting a second enrolment path that agrees with the first until it does not.

The published-graph set is reloaded every 30 seconds, so a publish, a pause or a resume reaches the entry path within half a minute.

#Waits, and how durable they are

Timers are durable rows in Postgres, not in-memory schedules. A worker restart, a deploy, a crash: the timer is still there and still fires.

  • Timers are bucketed by the minute, which is what makes the poller cheap. With ten million people parked on a "wait 3 days" node it reads one small partition per tick instead of scanning an index over every future timer.
  • The poll runs every 1 second with a batch of 1000, even though the buckets are minutes, because somebody who set "wait 5 minutes" expects roughly five minutes and a minute of quantisation on top would be visible.
  • Scheduling replaces any existing timer for the same person in the same journey. A person cannot be waiting in two places at once.
  • Claiming is atomic. A claimed timer is not handed to a second worker.
  • A failure while running one timer leaves it claimed and unprocessed, and the next pass picks it up. One person failing does not abort the batch: the rest are unrelated people whose messages are also due right now.
  • State is persisted before effects are dispatched. A crash between the two re-sends a message the delivery layer de-duplicates, rather than losing it. Losing it is the failure that has no evidence afterwards.

A wall-clock wait uses the recipient's own timezone, so "wait until 9am" means their 9am. A best-time wait reads the person's engaged hour at the moment it is needed; a lookup failure falls back to fallback_hour silently.

Pausing a journey does not stop the people already inside it. Pause removes the journey from the published set, so nobody new enters. Timers are resolved against the stored version by id, without checking the journey's status, so somebody parked on a three-day wait still wakes up and still receives the message. If you need the sends to stop, switch off the channel or the account, which is what the kill switches are for. See Consent and caps.

#Leaving

Three ways out, checked in this order every time an instance advances:

  1. exit_criteria wins over everything, wherever the person is. This is what stops an abandoned-cart flow nagging somebody who has already bought.
  2. max_duration_days, which bounds how long anyone may stay. Somebody parked behind a for-event wait would otherwise sit in the journey indefinitely.
  3. running out of graph: an exit node, an empty next, or the natural end.

Then the engine walks nodes, at most 100 steps per call. Beyond that it returns a step-limit error. The compiler already refuses a loop with no wait in it; this is the runtime backstop, because an engine that looped here would send the same message thousands of times before anyone noticed.

The exit reason is recorded on the instance, so a marketer can see whether a journey converted people or merely timed them out.

#Versions

A published version is immutable, and a person is pinned to the version they entered on. Somebody who entered on version 3 finishes on version 3 even after version 4 is published, because re-pointing a half-finished person at a changed graph would drop them onto a node that no longer means what it did when they arrived.

The timer carries the version too, so a wake-up resolves the same graph the person entered on.

GET /v1/journeys/{id}?version=N reads an old version. Version 0, or no parameter, means whatever is published now.

#Validating before you publish

POST /v1/journeys/validate takes {"graph": {...}} and answers 200 with:

JSON
{ "valid": true, "problems": [], "warnings": [] }

400 only when the body will not parse or the graph is null. Everything else is a 200 with a list.

problems block a publish. warnings do not. They are kept apart rather than folded together because they ask for opposite responses. A problem is "this cannot go out". A warning is "this will go out, and here is what it will do". Refusing to publish a warned graph would block the shapes that are occasionally right, and mixing them into one list would teach a marketer that the list is advisory, which is the reading that gets a dangling edge published.

Every problem is listed, not only the first, because a marketer fixing one dangling edge at a time with a round trip between each gives up.

ConditionMessage
no nodesThis journey has no nodes at all
a node with no idOne of the nodes has no identifier
a duplicated node idThe node "%s" is duplicated
no entry setNo starting point is set
entry points at nothingThe starting point points at a node that does not exist
an edge to a deleted nodeThe output "%s" of the node "%s" goes to a node that has been deleted
no action node anywhereThis journey does nothing: add a send node
event trigger with no eventThe start node "%s" has no entry event
segment trigger with no definitionThe start node "%s" has no segment to check
sweep with no hoursThe start node "%s" has no sweep hour; without one it never runs
more than six sweep hoursThe start node "%s" sweeps more than %s times a day
date trigger with no date traitThe start node "%s" does not say which date it runs on
date offset outside 0 to 365The start node "%s" has an invalid offset from that date
switch with no casesThe multi-way node "%s" has no paths
switch with no defaultThe multi-way node "%s" has no default path: anyone who matches none of the conditions drops out of the journey right here
action not configuredThe node "%s" is not configured
enter_journey with no targetThe node "%s" has nowhere to jump to
send with no channelThe send node "%s" has no channel
send with an empty channel entryThe send node "%s" has an empty channel
send with no templateThe send node "%s" has no text to send
multi-channel send missing one templateThe send node "%s" has no text for channel %s
wait not configuredThe wait node "%s" has no duration
for-event wait with no timeoutThe wait node "%s" has no timeout: anyone who never performs that event stays in the journey forever
split with no branchesThe split node "%s" has no branches
condition not configuredThe condition node "%s" has no condition

Anything the list misses is caught by the compiler, above all a cycle with no wait node in it, which is refused. A wait breaks a cycle because time has to pass.

The compiler also enforces, beyond the list above: duration needs a positive amount and a recognised unit; until_time needs an hour in 0 to 23; until_best_time needs a fallback_hour in 0 to 23, refused rather than clamped; every switch case definition has to be a valid segment; a split needs at least one branch, no negative weights, a positive total, and holdout_percent in 0 to 100; update_trait needs a trait; webhook needs a url; add_to_segment needs a segment_id; a goal needs an event.

Note one asymmetry: the compiler does not require a send node to have a template. The editor's problems list does. So a graph can compile and still be unpublishable.

Warnings apply only to segment_periodic and date triggers:

ConditionWarning
date trigger with entry rule onceThis journey admits each person once, so it runs for one year per person. For a yearly repeat, set the re-entry rule to "every time"
date trigger, every_time, gap under 90 daysThe minimum gap is short for a yearly occasion; something around 90 days or more is safer
sweep with entry rule onceThis journey admits each person once, so a periodic sweep only enrols people who have not entered before
sweep, every_time, no cooldownWith the re-entry rule set to "every time" and no minimum gap, every sweep messages every member of the segment
sweep with no exit_criteriaThis journey has no exit condition; somebody who does the thing you wanted stays a target of the sweep until they leave the segment
another running journey has the same entry signatureThe journey "%s" has exactly this entry condition; one person gets a message from both

The overlap warning compares a fingerprint of what the trigger asks at the door and deliberately ignores everything after it. A journey never warns about itself, and a lookup failure is silent rather than fatal.

#Publishing, pausing, deleting

POST /v1/journeys/{id}/publish re-validates the stored draft rather than trusting the client's last check. The browser's opinion is a convenience; this is the gate that decides whether messages start going out.

400 when the draft is not ready
{
  "error": "This journey is not ready to publish yet",
  "problems": ["The send node \"send\" has no text to send"]
}

Success is 200 {"version": 4}. 404 when the draft cannot be read, 503 when publishing itself fails.

POST /v1/journeys/{id}/pause, /resume and /archive set the status to paused, active and archived, and answer {"status": "paused"}. Any other action is 400 unknown action.

DELETE /v1/journeys/{id} is a soft delete into the recycle bin, kept for 30 days, restorable with POST /v1/recycle/journey/{id}/restore. A journey that is running or scheduled answers 409 and tells you to stop it first. An already deleted or non-existent journey answers 404, because from here "already deleted" and "never existed" are the same answer: the caller is looking at a stale screen.

#Entering people from your own backend

There is an endpoint built for exactly this, and in the reference deployment your backend cannot reach it. Both halves are true and the second one is the one that costs an afternoon, so here it is first.

POST /v1/journeys/{id}/enter lives on the control plane. The control plane's listener is not published to the internet: api.segmentic.net carries the management API only, and no journey route is registered there. The only public path is the panel's server-side proxy, which requires the signed-in user's session cookie and refuses an sk_seg_ key. So today this is a panel action and an on-network integration, not a public API.

The endpoint itself:

HTTP
POST /v1/journeys/12/enter
Content-Type: application/json

{ "user_ids": ["u_9137", "u_4410"] }
202
{
  "accepted": 2,
  "status": "queued",
  "note": "Entry happens once the event has been processed; the re-entry rules and the exit condition still apply."
}
  • The journey's entry node must be an api trigger. Anything else is 409. Without that check the call succeeds, the event is published, the worker declines to match it, and the caller is told "accepted" about something that will never happen.
  • Permission journey.publish, not journey.write. This does not edit a graph; it causes real messages to be sent to a named person.
  • Accepts {"user_id": "u1"} or {"user_ids": ["u1","u2"]}, and both together. Blanks and repeats are dropped, and a repeat inside one call is one person.
  • At most 500 people per call. More is 400 at most 500 people per call. Enrolling is a per-person decision, and a request that could name a hundred thousand people is a campaign wearing an API's clothes, without a campaign's audience preview or coverage report.
  • Body cap 1 MiB, and unknown fields are refused.

accepted counts what the event bus took, not how many people started a journey. The worker still applies the re-entry rule, the cooldown and the exit criteria, so some of those people will not start one. The field is named for what this endpoint can actually promise.

Each person becomes one track envelope with a deterministic message id of the form jenter-{journeyID}-{userID}-{unixSeconds}, so a retry inside the same second de-duplicates at the collector.

Failures: 400 bad journey id, malformed JSON, unknown field, or an empty user list; 404 when the draft cannot be read; 409 when the entry is not an API trigger; 503 could not queue the entry.

#Simulating

POST /v1/journeys/{id}/simulate runs the draft against one real person and reports every decision. Nothing is sent and nothing is stored.

request
{ "user_id": "u_9137" }

An empty user_id makes the server pick a recent profile. Supplying a graph tests what is on the canvas rather than what was last saved.

response
{
  "steps": [
    { "node_id": "trigger", "label": "added to cart", "kind": "visited", "detail": "..." },
    { "node_id": "wait", "label": "one hour", "kind": "wait", "detail": "..." },
    { "node_id": "send", "label": "reminder", "kind": "effect", "detail": "...",
      "effect": { "kind": "send", "channel": "push", "template_id": 3, "node_id": "send" } }
  ],
  "reached": true,
  "outcome": "...",
  "sends": 1,
  "simulated_waits": 1,
  "problems": [],
  "user_id": "u_9137"
}

Step kinds are visited, branch, effect, wait, end and error. A step may also carry a suggestion when something is wrong with it.

What it does that a structural check cannot:

  • Waits are accelerated, and simulated_waits says how many. A "wait two days" node would otherwise make the test button unusable on exactly the journeys that most need checking. A for_event wait takes the event-arrived path.
  • Exit criteria are checked first, exactly as the engine does, because a journey whose exit rule already matches every entrant is the most common "why did nobody receive this": the flow is correct and every entrant leaves at the door.
  • Conditions are evaluated against the real person, through the same matcher the worker uses. A matcher failure is reported as "branch not taken" rather than aborting the trace.
  • problems is the same list the publish button uses, so a clean walk down one branch does not read as publishable.
  • Bounded at 100 steps.

Permission is journey.read. Gating a dry run behind publish would mean the person who cannot publish also cannot check their own work.

Failures: 400 bad id or malformed JSON; 409 no profile is available for a test run yet when the account has no profile to pick; 503 when the test-user lookup fails; 404 when neither a supplied graph nor a stored draft exists.

#Holdouts inside a journey

A holdout person walks the whole graph and receives nothing. That is what makes the journey's effect measurable, and it is also where the interesting rule is: the two arms must differ only in what was sent.

EffectFor a holdout
sendrecorded and suppressed, at exactly the same point a real send would be
update_traitapplied
add_to_segmentapplied
webhookskipped
enter_journeyskipped

A trait and a segment membership are state. Skipping them would make the control group differ from the treatment group in a second way, and the uplift figure would then be measuring both differences at once. A webhook and a jump are outbound effects: firing them enrols the person in a loyalty tier, opens a ticket, or starts the next journey, which is exactly the treatment the holdout exists to withhold.

#Sends from a journey

A send node hands the message to the same delivery path a campaign uses, so every rule on Consent and caps applies.

  • The default category for a journey send is marketing. The template's own category overrides it, and that is the field to set for an order-shaped message inside a journey.
  • The message id is j{journey_id}.v{version}.{node_id}.{user_id}.e{entry_count}, with the channel appended for the second and later channels of a multi-channel node. Two channels reaching one person are two messages, and sharing an id would have the second taken for a retry of the first and silently dropped.
  • A send that governance defers is queued as a deferred send, not re-armed on the journey timer. The journey moves on; the message is released later.
  • Without a deferral store configured, a deferred journey message is dropped with a warning. That is worth checking on an installation you did not set up yourself.

A send node authored on the webhook channel fails for every person who reaches it. The compiler checks only that the channel string is non-empty, so "channel": "webhook" validates, publishes and runs. There is no webhook sender anywhere in the delivery layer: the value is in the authorable channel vocabulary and in nothing that delivers. Each arrival comes back failed with "this channel is not configured", one per person, and the graph looks correct. The webhook action kind in the table above is a different thing and it works: it calls your endpoint instead of sending a message. Only push, sms, email, webpush, inapp, bale, eitaa and rubika have senders.

#Personalisation from the trigger

A journey send renders from the three sources a transactional send does, and from one more: the properties of the event that caused the step, under the event. namespace.

{{event.rival}} از تو جلو زد، الان رتبه {{event.rank}} هستی

The event is the one that matched the trigger when the person entered, or the one that ended a wait-for-event when it woke them. Whichever caused this step.

The namespace never merges with anything, and that is the point. A stored trait rank and a property rank are two different facts and a message often wants both: {{rank}} is the trait and {{event.rank}} is the property, and neither can shadow the other. event. is also the one prefix the renderer never strips, so an unfilled {{event.rank}} is a missing variable rather than the trait quietly answering for it.

A step with no event has none of these variables. A wait that elapses, a date trigger and a segment sweep all resume with no event, which includes every send that sits after a wait node. A template naming one of these then has a variable with no value, so the send is suppressed with missing_personalisation, and the missing keys are named in the worker log. The alternative is a push that reads " از تو جلو زد", which cannot be recalled. Give the placeholder a fallback if the sentence survives without the value, and put the send before the wait if it does not.

The event is not written to the instance. It is read at the step and thrown away, so a journey cannot present Monday's rank as today's. The one place these values are stored is a send that quiet hours held until morning: that message keeps the values it was built with, because the event it came from cannot be read again.

At most 32 properties travel, and only those of at most 512 bytes. The 32 are the first in sort order, which is the same 32 on a retry. A longer value is dropped rather than cut short, so it becomes a missing variable instead of a sentence that stops halfway.

Only properties travel. The event's name, its timestamp and its device context are not variables.

#What journeys do not do

  • No journey routes on the management host at all, including read, despite the capabilities flag. And the control plane that does carry them is not routed from the internet. See The endpoints.
  • No route answers "where is this person in this journey". The graph endpoint returns per-node aggregates. There is no per-instance read.
  • No route removes one person from a journey. Exit criteria and max_duration_days are the mechanisms; there is no "eject this user" call.
  • No pause that stops people already inside. See Waits.
  • No approval flow for a journey you built yourself. One you wrote by hand publishes on journey.publish and nothing else. A journey that came from the library or from a suggestion is different: it carries review_required, and publishing it without a review returns an error. Review takes a fingerprint of the behaviour, so editing the journey after it was reviewed and publishing the edit is refused too, the same way a campaign's approval works.
  • No recurring schedule of its own. A sweep trigger with hours is the closest thing, and it reads its hours in Tehran.
  • No check that a send node's channel can be delivered on. See Sends from a journey.
  • No offset_days after a date. Use a wait node.
PreviousSegmentsNextTransactional

On this page

  • What a journey is
  • The endpoints
  • The graph
  • Node types
  • A complete definition
  • How people get in
  • Waits, and how durable they are
  • Leaving
  • Versions
  • Validating before you publish
  • Publishing, pausing, deleting
  • Entering people from your own backend
  • Simulating
  • Holdouts inside a journey
  • Sends from a journey
  • Personalisation from the trigger
  • What journeys do not do

Segmentic

This page is written from the code