Skip to main content

Canvas Nodes

Canvas flows are built using different types of nodes that define how users progress through your automation. This guide covers all available node types and their behavior.

Trigger Nodes​

Trigger nodes define how users enter a canvas.

Available Triggers​

Trigger TypeDescription
EventUser enters when they perform a specific event
Entity ChangeUser enters when a related entity record changes
APIUser enters via API call
ScheduleUsers enter on a recurring schedule
WhatsApp MessageUser enters when they send a WhatsApp message
Inbound SMSA known contact enters when they text in (filter by SMS account, message type, and Message Text / Message Type conditions)

Segment Entry is not a trigger. For entries caused by something the person did, use an Event trigger with a filter; for entries caused by time passing, use a recurring journey over the segment.

Repeat check - the same thing twice​

An Event trigger can require the arriving event to be a repeat: at least N occurrences of this event by this person, in the last D days, with the same values of the properties you pick - counting the event that just arrived. The person enters exactly on the Nth matching occurrence; occurrences with other values never enrol them.

The example this was built for: someone who looked at the same hotel for the same dates twice.

SettingValue
Eventhotel_viewed
Repeatat least 2 times in the last 2 days
with the samehotel, check_in

A second look at Casa Azul for 3-5 October starts the journey. A first look at Casa Azul, or a look at any other hotel, does not - the count is pinned to the arriving event's own hotel and check_in.

Put this check on the trigger, not in a Condition step. A journey that entered on every hotel_viewed and asked "twice?" inside would enrol the person on the first view; with re-entry blocked, the second view could never start it. The trigger asks the question before the entry is spent.

How it is evaluated: when the event arrives, the trigger counts this person's stored hotel_viewed events in the window whose hotel and check_in equal the arriving event's, excludes the arriving event's own id from that count and adds one for the event itself - so the answer is the same whether or not the event has reached the analytics store yet. Under the hood the check is the sameProperties event filter described under Branch nodes, with each property compared to the triggering event's value.

Message Nodes​

Message nodes send communications to users through various channels.

Supported Channels​

  • Email - Send personalized emails
  • SMS - Send text messages (on a Delivyr account, pick an approved template and fill its variables - see Delivyr accounts)
  • Push Notifications - Send mobile push notifications
  • WhatsApp - Send WhatsApp template messages or replies
  • Webhook - Call an external API with a Liquid-templated body
In-app is delivered, not sent

In-app is a channel you pick inside the Message node, alongside email, SMS, push, WhatsApp and Viber. It behaves differently from the others: the message is queued and shown when the person next opens your app, rather than pushed out to them, so it has no "sent" or "delivered" receipt of its own - see In-App Message Nodes.

You pick the channel from the Message node itself - Webhook lives in the same channel picker as Email and SMS, with its own request builder (method, headers, body, auth, retries, and an optional response-mapping wait). Use Send test to fire a sample call, then click any field in the response to map it to a journey variable - or paste a sample response to author the mapping without a live call. When response-mapping is on, you can also enable an on-error output: a second port that the journey follows when the call fails or times out (leave it unwired to continue on the normal output - fail-open).

Send test renders the request for the Test contact you pick. A real contact (Random user or Select existing user) is loaded from your workspace and rendered exactly as a live send would render it - {{ user.id }} is that contact's internal ID, and every other field and attribute comes from their profile. A Custom user is made up, so its blank fields fall back to sample values. After the test, switch the result between Response received and Request sent to see the exact method, URL, headers and body that went out; credentials (Authorization, API-key and signature headers) are masked.

Which gates apply to webhooks

The Webhook channel calls an external service rather than a person's inbox, so subscription consent is not managed for webhooks - there is no webhook opt-in, and the audience's subscription preference doesn't gate them. Everything else applies by default:

  • Quiet hours and frequency caps gate webhooks by default, just like messages - a webhook that represents a customer touch (e.g. it triggers a message in another system) shouldn't fire during someone's quiet window. If your webhook is a pure system-to-system call (sync a CRM, kick off a backend job), tick System call - skip quiet hours & frequency caps on the node to opt out of both.
  • Suppression lists always apply - a suppressed contact never fires the webhook. The System call checkbox does not change this.
  • Billing - contacts a webhook fires about count as billable engaged profiles, the same as message recipients. See How usage is counted.

Message Advancement Behavior​

Important

Users advance to the next step immediately after the message is queued for sending. The canvas does not wait for the message to be delivered to the user's inbox.

This is the industry-standard approach. It enables:

  • Scalability - Process millions of users without blocking
  • Performance - Canvas execution is not slowed by message delivery times
  • Reliability - Message delivery is handled by dedicated workers with retries

What "message sent" means:

  1. Message is personalized with user data
  2. Message is added to the sending queue
  3. User advances to next canvas step
  4. Dedicated worker processes the queue and delivers messages

Message delivery guarantees:

  • Messages are retried up to 3 times with exponential backoff
  • Failed messages are logged for investigation
  • Delivery tracking (opens, clicks, bounces) is handled separately

Message nodes enforce subscription consent at send time, per channel - the same gate campaigns apply, so a journey and a campaign treat the same contact identically. The canvas audience's subscription preference governs how strict the gate is:

  • Subscribed (default) - sends only to contacts who have consented on that channel (subscribed or opted_in). Explicitly-unsubscribed contacts and contacts with no subscription record at all are skipped.
  • Confirmed - only double-opt-in confirmed subscribers.
  • All - sends regardless of subscription state (the explicit "send even to unsubscribed" / transactional override).

Per-channel specifics:

  • Email additionally skips hard-bounced addresses.
  • Push is lenient: because push permission is granted on the device rather than through an opt-in record, push is sent unless the contact has explicitly unsubscribed - a contact with no record still receives push.

A skipped send does not remove the contact from the journey: only the message is skipped, and the contact advances to the next step (webhook, Update User, branch, etc. still run for everyone). Consent is re-checked on every send, so a subscription change made earlier in the journey (e.g. an Update User step) or through the preference center is honored on the next message - nothing is frozen at entry.

Suppression lists are enforced too, across all channels: a contact who is a member of an active suppression list's segment is skipped (and stays in the journey), unless that list's exception tags exempt this canvas. Journeys use the contact's materialized segment membership, refreshed on a schedule - so suppression tracks within a short delay (campaigns evaluate it live at send).

Per-number subscription groups (SMS/WhatsApp)​

For SMS and WhatsApp, journeys enforce the same per-number / per-WABA subscription-group gate that campaigns do. A subscription list bound to a specific sender becomes that sender's group: SMS groups are per sending number, WhatsApp groups are per WABA. A contact who opted out of the sending number's (or WABA's) group is skipped for that message - but, as with all skips, they still advance to the next step; only the message is dropped.

Which group's opt-out is checked is decided by the sender you choose on the message node: the SMS number for an SMS message, the WABA for a WhatsApp message. If you don't pick one, the workspace's default sender (SMS) or default WABA (WhatsApp) is used, and that default's group is the one checked.

Quiet Time​

Message nodes respect the workspace's quiet-time settings, evaluated in the contact's timezone at send time. When a contact reaches a message node during a quiet window (or a configured holiday), behavior follows the org's per-channel strictness:

  • Skip: the message is not sent and the contact advances to the next step.
  • Delay: the message node is deferred until the window closes, then re-runs and sends.

This mirrors campaigns (e.g. email may delay overnight while SMS skips), so quiet hours apply consistently whether a contact is reached by a campaign or a journey.

Send a Test Message​

Each message node has a Send test message button in its configuration modal. Use it to verify personalization and provider connectivity before publishing.

Per-channel coverage:

  • Email - opens a test-send modal with the same user picker (random / existing / custom) and a recipient email override
  • SMS - opens a test-send modal with the same user picker (random / existing / custom) and a phone number override
  • WhatsApp - same as SMS, plus the selected template must be in approved state

The picker resolves Liquid variables against the chosen user's attributes, then sends directly via the workspace's provider - bypassing the canvas queue, audience targeting, and quiet-time rules. Suppression is still enforced: a test send to a suppressed address or number is refused, exactly like a live send. Test sends don't count toward execution stats, don't trigger webhooks, and are always free.

Test renders match live sends

Test sends render through the same pipeline as live sends - personalization tokens, custom attributes, and content blocks resolve identically, so what you receive in a test is exactly what your audience will receive.

Templates in a message step​

  • Email "From template" and In-app "From template" link the step to a saved template. Change template picks another one and the step follows it from then on - nothing is copied into the step, so nothing is lost.
  • Push and Webhook steps copy a template in through Start from template. Picking another template over content you edited asks before replacing it; content still exactly as the last template left it is replaced without asking.
  • Save as template (email with Drag & drop or Custom HTML, push, in-app with Drag & drop or HTML) saves the step's current content as a new template, named "journey name - channel" by default, with optional tags. The step keeps its own content and is not linked to the new template. Needs permission to edit templates.

Held by Joryio​

Under Advanced delivery options each message step chooses what happens when the person just received another message: Always send (the default, for transactional steps), May wait, then send, or May wait, skip if stale, with the longest the step may hold it. On a step that may wait, Joryio holds a low-value message for a while when the person got another message in the last day, then sends it, or drops it once the wait budget is spent on a skip-if-stale step. It runs after consent, quiet hours, subscription and frequency rules, never instead of them. When two of a person's messages are pending in the same window, the higher-value one goes first and the other waits, naming the message that went ahead. A held message shows as Held by Joryio - reason on the recipient timeline and as held in journey analytics, so a conversion rate is never silently diluted. A comparison group always sends; its result is on Settings, ML models under Held messages.

In-App Message Nodes​

In-app messages work differently from every other channel, so they get their own node. Email, SMS, push and WhatsApp are pushed out to the person. An in-app message is shown when the person next opens your app - so this node does not "send" anything. It queues the message for that user, and your app displays it on the next eligible session.

Add it by dropping a Message node and picking In-app as its channel.

What you configure​

  • Message (required) - where the content comes from. The node opens with the same choice as the email node:
    • Write it here - authored in the node and used only by this step. Pick an editor: Drag & drop (visual blocks and images), Classic - app style (headline, text, image and buttons that your app renders with its own fonts and colours, so it displays even in apps that block HTML) or HTML.
    • Use a campaign - reuse an in-app campaign you already built, including its variants.
    • From template - link a saved in-app template from Brand Studio. The step shows the template as it is when the message is queued, so editing the template updates every journey that uses it.
  • Layout - modal, banner, slide-up or full screen (a template built for a layout brings it along).
  • Priority - Urgent / High / Normal / Low. When several in-app messages are eligible at the same moment, the highest priority is shown first; ties are broken oldest-first, so a queued message is never buried by newer ones. Journey messages and in-app campaigns compete in the same ranking.
  • How often it can show - Show once (default), Show up to N times (with an optional minimum gap between shows), or Show until clicked or dismissed.
  • Stop trying after N days - if the person never opens the app within this window the message expires unseen. Defaults to 7 days.
  • Wait until displayed (optional) - see below.

When does the journey continue?​

By default the journey continues immediately after the message is queued - the same fire-and-continue behaviour as other channels, because you cannot know when someone will next open your app.

Tick Wait until displayed to hold the person at this step until they actually see the message. If they don't open the app within the wait window (default 24 hours), the journey continues anyway - it never stalls on a message that was never seen.

When to wait

Use Wait until displayed when the next step only makes sense after the person has seen the message - for example "show the onboarding tip, then wait, then email anyone who saw it". Leave it off for fire-and-forget nudges.

Frequency capping​

Journey in-app messages count toward the same in-app frequency cap as in-app campaigns - one shared daily limit per person, plus your cross-channel ceiling. A message blocked by the cap is skipped and the journey moves on. Suppressed contacts never receive in-app messages.

Reporting​

The node reports like any other message step, with in-app wording:

  • Displayed counts as the node's delivery (for in-app, being shown is the delivery)
  • Clicked counts taps on the message
  • There is no Opened step for in-app - the funnel is displayed → clicked

Conversions attributed to the journey use the same attribution window and model as other channels.

Delay Nodes​

Delay nodes pause the user's progression. Four delay types cover the common patterns:

Delay Types​

TypeWhen to use
DurationWait a fixed amount of time after the user reaches this step (e.g. "wait 7 days").
Calendar dateWait until a specific date + time-of-day (e.g. "release on 2026-05-17 at 10:00"). Users entering after that moment exit the journey.
Day of weekWait until the next occurrence of a chosen weekday + time-of-day (e.g. "next Monday at 12:00").
This person's best timeWait until the next occurrence of the hour this person usually opens messages or usually orders, optionally a few minutes before it (e.g. "their usual order hour, 30 min before"). See This person's best time.

Duration​

SettingDescription
Wait forNumber + unit (Minutes / Hours / Days / Weeks).
Then wait until a specific time of dayOptional. After the duration completes, keep waiting until the chosen time of day in the chosen timezone.
TimezoneCompany / User local / UTC (used by the time-of-day option).

Calendar date​

SettingDescription
DateTarget ISO date. Pickable from the inline calendar - click any day to select; arrows navigate months.
TimeHour + minute selectors.
TimezoneCompany / User local / UTC.

Day of week​

SettingDescription
Day & timeWeekday + hour + minute. The 7-cell preview row at the top of the modal is also clickable.
TimezoneCompany / User local / UTC.
If a user arrives after the cutoffWait (default) rolls them to next week's occurrence. Advance fires immediately so they don't lose a week.

This person's best time​

A win-back that lands at 19:30 for someone who usually orders at 20:00 - this mode waits for the next occurrence of the person's own peak hour, in their timezone, minus a lead.

SettingDescription
Based onWhen they usually open messages (their opens, reads, taps and clicks - counted per channel, see below) or When they usually order (their placed orders).
For channelOpens only. Next message's channel (auto) (default) reads the canvas: the channel of the next message step after this delay, when every path from the delay leads to one channel. Or name one - Email, SMS, WhatsApp, Push, In-app, Viber.
Lead timeMinutes before the hour to release the person (0-1440). 30 on a 20:00 peak releases at 19:30.
TimezoneUser local time (default - the person's timezone attribute, falling back to Company time) or Company time.

What counts as an "open" depends on the channel: email opens and clicks; push taps; in-app clicks (a display is not an open); WhatsApp read receipts, then clicks; Viber "seen", then clicks. SMS has no open - only link clicks count, so a person needs three SMS link clicks before an SMS hour of their own exists.

How the hour is chosen for a channel, in order:

  1. Own channel - the person has at least 3 events on this channel: the hour with the most of them.
  2. Same device - otherwise, at least 3 events across the channel's device family: the phone family (push, WhatsApp, Viber, SMS, in-app) or the desk family (email). Two SMS clicks and five push taps give an SMS wait the push hour.
  3. Any channel - otherwise, at least 3 events on any channel at all.
  4. Workspace - otherwise, the hour the whole workspace peaks on for this channel and its device family (refreshed every 10 minutes).
  5. Default - a workspace with no history at all: 10:00 for email, 19:00 for the phone family.

When they usually order, and an opens wait whose channel cannot be told from the canvas (no message step downstream, or branches to different channels), use the shorter chain: personal (3 events) - workspace - default (10:00 for opens, 19:00 for orders).

People without enough history use the workspace's usual hour; nobody waits at random. If the target instant has already passed today, or is less than five minutes away, the person waits for tomorrow's. The 30-day clamp applies as to every delay.

The person profile's Purchase Behavior card shows a Best time by channel table - one row per channel the workspace has history on, plus Orders - with the hour and where it came from ("own opens", "phone habit", "any channel", "workspace pattern", "default"), so you can see what a journey would do before you publish it. Each delay step records the channel and the rung it used, so the journey report can count personal versus workspace waits.

Personalize delay​

A toggle on every type. When on, the delay reads from a per-user variable instead of a fixed value:

SourceReads from
Context variablesexecution.context[varName] - the journey's incoming context (legacy default).
User custom attributeuser.attributes.<varName> and top-level user fields.
Event propertyA property of the event: the latest event a Behavior Split matched, else the event that started the journey (waitDays or properties.waitDays). If the property is missing or not usable, the delay does not wait - the step records it as delayVariableMissing and a warning is logged.

For Calendar date / Day of week with a personalized variable, you can also configure an offset (plus or minus, in hours / days / weeks) - e.g. "send 3 days before the renewal date".

A personalized date with no timezone of its own (2026-10-16T09:00:00, or a date alone such as 2026-10-16, which means midnight) is read in the step's Fallback timezone - Company, User local or UTC. A value that carries its own offset (2026-10-16T09:00:00+03:00, or a trailing Z) keeps it.

Timezone resolution​

ModeResolves to
Company timeThe workspace's timezone, or the organization's timezone from Settings when the workspace has none, else UTC - the same zone campaigns use.
User local timeuser.attributes.timezone (set by the SDK). Falls back to Company time if missing.
UTCUTC.

Limits​

  • All delay branches are clamped to a 30-day maximum. A Delay saves the values it shows (7 days, the date, day and time on screen). A Calendar date Delay without a date cannot be activated; any other incomplete Delay fires immediately rather than wedging the journey, with a structured warning logged.

Example: Wait 24 hours before sending a follow-up email.

Branch Nodes​

Branch nodes route users down different paths based on their attributes, event history, segment membership, and more. One block subsumes both the legacy Decision Split (Yes / No) and Audience Path (multi-pill ranked groups) - a single toggle in the modal flips between them.

The block's underlying nodeType is still condition for backwards compatibility with existing journey data. New journeys persist as branchMode: 'yesno' | 'multi'.

Two modes​

Yes / No - one named path + Everyone else. Use when the question is binary ("did this user purchase yes/no").

Multi - up to 10 named, ranked groups + Everyone else (11 routing paths total). Use when you need more than two routes ("VIP customers, frequent visitors, one-time buyers, everyone else"). Promote a Yes/No branch to Multi via Add a new group in the modal's rail. Simplify back via the Simplify back to Yes / No action (visible when exactly one named group + Everyone else remain).

Ranked-priority resolution​

In Multi mode, groups are evaluated in order. The first matching group wins. Users who don't match any named group route to Everyone else. You can drag rows in the rail to re-rank.

Per-group "Exit canvas"​

Each named group (and Everyone else) can be flagged Exit canvas instead of routing - users hitting that group complete the journey at the branch step with exitReason: 'branch_exit:<group name>'. Useful for "if condition met, no need to send anything - leave the flow."

Filter rows​

Each path / group is configured with one or more filter groups. Filters within a group are joined by an inner AND / OR joiner; groups themselves are joined by an outer groupOperator (typically OR).

The Branch modal uses the same <FilterRow> component the segment editor and campaign audience picker use, so it supports every filter type those surfaces support:

TypeWhat it checksOperators
attributeuser.attributes[field]equals, not_equals, gt, gte, lt, lte, contains, not_contains, exists, not_exists, in, not_in
default_attributeTop-level user fields (email, phone, externalId, firstName, lastName, country, ML scores, e-commerce derivatives)Same as attribute
event / behavioralEvent history with optional time windowperformed_event, not_performed_event, performed_event_in_last, not_performed_event_in_last, event_count_gte, event_count_lte
segmentSegment membership (recursive - segments can reference other segments, capped at depth 5)in, not_in
canvas_executionActive / completed / exited canvas executionsin_canvas, not_in_canvas, in_any_canvas, not_in_any_canvas, completed_canvas, exited_canvas
channel_subscriptionuser.subscriptions[channel].statussubscribed_to_channel, not_subscribed_to_channel, opted_in_to_channel
list_membershipList-subscription record on a chosen listmember_of_list, not_member_of_list
bounce_statusEmail validity / hard- or soft-bounce stateemail_valid, email_bounced, email_hard_bounced
ecommerceRFM segment, order count, spend, cart staterfm_segment_equals, rfm_segment_in, has_purchased, never_purchased, total_orders_*, total_spent_*, average_order_value_*, days_since_last_order_*, has_active_cart, no_active_cart, cart_value_*, cart_abandoned
entityuser.entityRecords[entity]exists, not_exists
appuser.appIdshas_app, not_has_app

All eleven types share a single evaluator (FilterEvaluator) used by both the canvas Branch worker and the in-app campaign evaluator - new types added there light up everywhere automatically.

Time window options (event filters)​

  • withinMinutes - Minutes (e.g., 30)
  • withinHours - Hours (e.g., 1, 24)
  • withinDays - Days (e.g., 7, 30)
  • eventTimeWindow - Seconds (used by the in-app evaluator)

Property conditions are supported too - for example, "performed Order Completed in last 30 days with value ≥ 100":

{
"type": "event",
"eventName": "Order Completed",
"operator": "performed_event_in_last",
"withinDays": 30,
"eventProperties": { "value": "100" }
}

The triggering event's values​

Inside a journey, an event property condition can compare with the value the triggering event carried instead of a fixed value. Next to the value box, choose Use the triggering event's value; the row then reads "hotel is the triggering event's hotel". On the wire the value is the template {{ trigger.properties.hotel }} (the short form {{ trigger.hotel }} is accepted too), resolved from the execution's trigger event just before the condition is evaluated.

A reference the trigger cannot answer - the property is missing, or the journey was entered by API and has no event - makes that condition false, and the runtime logs the property name once. It is never treated as blank.

Segments have no trigger, so the option appears only in journey conditions.

Counting the same thing​

The count operators (performed_count_gte, performed_count_lte) accept with the same values of - one to five event properties. The count is then taken per distinct combination of those values, and the condition holds when any combination reaches the number.

{
"type": "event",
"eventName": "hotel_viewed",
"operator": "performed_count_gte",
"value": 2,
"withinDays": 2,
"sameProperties": ["hotel", "check_in"],
"propertyConditions": [
{ "property": "hotel", "operator": "equals", "value": "{{ trigger.properties.hotel }}" },
{ "property": "check_in", "operator": "equals", "value": "{{ trigger.properties.check_in }}" }
]
}

Read aloud: "performed hotel_viewed at least 2 times in the last 2 days with the same hotel and check_in, where hotel is the triggering event's hotel and check_in is the triggering event's check_in". Without the two property conditions it is "viewed any one hotel twice for the same dates" - the segment form. For a journey that should start on the second view, use the repeat check on the trigger rather than this condition mid-journey.

In ClickHouse the grouped form is GROUP BY the extracted properties with HAVING count(DISTINCT event_id) >= N LIMIT 1; property names are passed as query parameters, so any tracked name works (spaces, hyphens, any script; a dotted name reads the exact key first, then the nested path), and an empty name fails the condition closed.

Canvas node card​

The node card adapts to the mode:

  • Yes / No - two source ports at the bottom (green = yes, red = no).
  • Multi - one source port per group on the right edge of the card, with the group name labelled on the row. Card grows vertically as groups are added.

Edge labels match the group name exactly (the worker reads edges by name when routing). If you rename a group, the connected edge stays attached because we key on the React Flow handle id; rename → save → no break.

Example: Abandoned Cart Flow​

  1. Trigger: Event = "Product Added"
  2. Delay: Wait 1 hour
  3. Branch (Yes / No): not_performed_event_in_last "Order Completed" in the last 1 hour
    • Yes: Send recovery email
    • No (Everyone else): Exit flow

Example: Multi-path RFM routing​

  1. Branch (Multi) with 3 named groups + Everyone else:
    • VIP customers (ecommerce.rfm_segment_equals: champions) → Send exclusive offer
    • Frequent visitors (event performed Page Viewed in last 7 days, event_count_gte: 10) → Send re-engagement
    • At risk (ecommerce.days_since_last_order_gte: 60) → Send win-back
    • Everyone else → Exit canvas

Behavior Split Nodes​

A Behavior Split routes users by what they do inside a time window. Where Branch reads state at the moment of evaluation, Behavior Split parks the user and watches the future - the first matching event (or end-of-window deadline) decides which path they follow.

It subsumes the legacy Action Path (single event + single condition) and event-keyed Audience Path blocks. One concept, two states.

Two modes​

ModeWhen to use
Did / Didn'tOne named "did" path + the implicit "No event in window" fallback. Equivalent of the legacy single-event Action Path.
MultiUp to 10 named, ranked groups + "No event in window". Promote to multi via Add a new group; collapse back via Simplify back to Did / Didn't when only one named group remains.

Wait window​

SettingBehavior
Wait up to N unitsFixed deadline (minutes / hours / days / weeks).
PersonalizeWindow pulled from {{canvas.x}} (context variable) or {{user.x}} (user attribute). The value should resolve to a number interpreted as the chosen unit.

The window is block-level - every group on the same Behavior Split shares the same deadline. Per-group windows made multi-path configs incoherent and were rarely used in practice, so they were removed when the node was redesigned.

Advance mode​

ModeBehavior
As soon as an event fires (default)The first event to match any trigger row wins. The user routes immediately. Ranking still matters as a tiebreaker for simultaneous matches inside one event tick.
At end of windowThe block waits the full window. If a user matches multiple groups during the window, the highest-ranked group wins. Choose this when ranking matters more than speed.

Trigger picker​

Each group's trigger list lives in a card with a discriminated trigger kind select at the top of every row. New kinds plug into the same picker without reshaping the saved config.

KindRuntime status
Perform Custom EventFully wired. Pick any event from your event registry. Property filters (AND-ed) narrow the match.
Place Order / Start Session / Make Purchase (Legacy)Predefined events with friendly labels. Wired - they resolve to canonical event names (place_order, start_session, make_purchase_legacy).
Perform Conversion EventSaved but not yet wired - pending the conversion-events runtime hook.
Add an Email AddressSaved but not yet wired - pending the attribute-set event stream.
Change Custom Attribute ValueSaved but not yet wired - pending the attribute-change event stream.

Unwired kinds show an inline amber callout in the row so authors aren't surprised at runtime.

Inside a group, trigger rows are OR-ed - any of them matching is enough to route the user. Inside a single trigger, multiple property filters are AND-ed.

Per-group "Exit canvas"​

A checkbox per group ends the journey when that path is chosen, instead of routing to a connected node. The execution closes with exitReason = "behavior_split_exit:<group_name>".

Canvas node card​

The Behavior Split card has a violet header band ("BEHAVIOR SPLIT") and a clock pill showing the window label ("3 days", "{{canvas.evaluation_window}} hours").

  • Did / Didn't mode - two bottom ports with stable ids did (violet) and timed_out (slate). Renaming the path on the modal doesn't orphan edges.
  • Multi mode - stacked rows, one per named group + the slate "No event in window" row. Each row owns its own source handle on the right edge of the card, with id = group-name-slug (the timed-out row uses timed_out).

When a group's trigger list is empty, the row shows a small warning chip - that group can never match, so the block won't function correctly until you either add a trigger or set Exit canvas.

Worked examples​

Example 1 - Cart abandonment: Did / Didn't mode. Trigger = purchase_completed. Window = 24 hours. Advance = As soon as an event fires. Wire the timed_out edge to a reminder email; wire the did edge to a thank-you. Users who purchase exit cleanly; users who don't get a nudge after a day.

Example 2 - Multi-channel re-engagement: Multi mode. Three ranked groups: 1. Made Purchase (event=purchase_completed), 2. Visited Pricing (event=page_view + property filter page=/pricing), 3. Opened Email (event=email_opened). Advance = At end of window. Window = 7 days. Wire each group to a different follow-up sequence; the No event in window edge goes to a longer-tail nurture path. Ranking matters: a user who both purchased AND visited pricing follows the Made Purchase path.

Experiment Nodes​

For A/B/n testing, winner-locking, multi-armed bandit optimization, or ML-driven per-user personalization, use the Experiment node. See the dedicated Experiment node guide for full coverage.

The standalone A/B Split node has been removed - the Experiment node in "Standard" path-type mode covers the same use case with more flexibility (control groups, delay groups, optional winner picking).

AI-Decision Nodes​

The AI-Decision node routes each user down the path the AI predicts is best for that individual - the per-user "offer fit" decision inside a journey. Where the Experiment node finds the globally winning path, AI-Decision picks a different path per user from their attributes and behavior.

Configure:

  • Paths - the routes the AI chooses among (each path continues to its own downstream branch).
  • Goal - Engagement (opens/clicks), Conversion (a specific event), or Revenue (purchase value). Determines what the model optimizes toward.
  • Conversion Event - shown when the goal is Conversion; the event that counts as success (a custom event like signup works).

How it decides (per user): the per-user model picks the best path (exploit); a small slice explores uncertain paths to keep learning (smart, not random); before the model has learned the paths, users are routed fairly (cold start). Outcomes are attributed back and the model retrains daily.

What counts as a "win", and what it learns from. A success follows your goal - Engagement counts opens and clicks, Conversion counts your conversion event, Revenue counts purchases. The win is credited to the path the person was routed down, so the AI learns which message/offer actually drives the outcome - not just which one gets opened. (For a Conversion or Revenue goal, an open or click is not a "win" on its own; it would optimize the wrong thing - a clickbait message can win opens but lose sales. Opens/clicks still help as inputs, below.) To choose a path per person, the AI looks at their recent behavior and profile, all learned from your existing data - nothing to label:

  • Engagement - how much they open and click (email, push, in-app), how recently they were active, how often they visit (sessions, page views), WhatsApp replies, and when of day/week they're usually active.
  • Value - what they've purchased: orders, total and average spend, and RFM (recency / frequency / monetary).
  • Profile - country, how long they've been a customer, and which channels can reach them (email / phone).

It self-warms. Because users enter a journey continuously over time, an AI-Decision node learns as the journey runs - early entrants are routed fairly while it gathers data, later entrants get personalized routing automatically. No setup beyond defining the paths and goal. (Frequency caps and quiet hours are not set on this node - they apply at the message/send nodes and at the workspace level, since a decision node only routes.)

One-time scheduled sends - "Warm up before full send". A journey scheduled to run once for a fixed audience can't self-warm - everyone would enter at the same moment, before the AI has seen a single outcome. For that case the AI-Decision node has a Warm up before full send toggle: the journey delivers to a small seed of the audience first, holds the rest, and automatically releases the remainder with personalized routing once the model has learned the paths (or after a max-hold you set). Continuously-triggered journeys don't need it - they already warm up on their own.

How credit is shared (multi-touch). When a user passes through several AI-Decision nodes in one journey and then converts, the credit is shared across those decisions, not handed entirely to the last one - because in a multi-step journey the earlier routing choices helped get there too. Recent decisions get the largest share and older ones fade gradually (a "time-decay" model). A single-decision journey simply credits that one decision in full. (This differs from an AI-Optimized campaign, which is a one-shot send and so credits its single most-recent decision - "last-touch.")

Next Best Message Nodes​

The Next Best Message node answers "which message" for each person: it scores the workspace's saved templates on the channels you allow and records the one it would send. It ships first in shadow mode: the node sends nothing and the journey continues down its single output exactly as before, while every pass records the pick next to what the journey then actually sent. That comparison is what decides whether the node graduates to sending under a holdout; read it on Settings, ML models.

Configure:

  • Channels to consider - email, in-app, push. Templates on these channels form the pool for every person; a channel the frequency rules would hold for a person is left out for that person.
  • Only templates with these tags - narrow the pool to templates carrying one of the tags (empty means every template).
  • Goal and Conversion event - what the recorded pick is later measured against, like the AI-Decision node.

How it scores (per person): the shared model from Settings, ML models rates each template by the person's behaviour and the template's content labels (so a brand-new template gets a real score), the person's preferred channel keeps its score while the other channels are discounted, and the highest score wins. Before a shared model is trained every template scores the same and the pick is a fair draw, which the record says.

Send mode. Switch the node's mode to Send once the shadow comparison looks right. A holdout share you choose (20% by default) keeps today's behaviour and leaves through the Holdout output, which you wire to your own message; everyone else gets the pick sent from this node itself (an email or push template, under the same consent, quiet-time and frequency rules as any message node) and leaves through the Sent output. The two arms are compared on Settings, ML models with a likely range each and a "no conversion loss" verdict, which is the criterion for keeping the node in send mode. In-app templates are scored but not sent by the node.

Update User Nodes​

Update User nodes have two row kinds - pick per row:

Update an attribute​

Set / increment / decrement / append / remove a value on a user attribute. Attributes come from your workspace registry (start typing to filter, no free-text - define new attributes in Settings → Attributes first). Operations available filter automatically by the attribute's type: only inc / dec for numbers, append / remove for arrays, etc.

Track an event​

Fire a tracked event for the user from inside the canvas. Useful for marking journey milestones ("completed_onboarding", "abandoned_checkout") that other canvases / segments can listen for. Event-name field autocompletes from your workspace's tracked events but accepts free text for new event names.

Multiple rows can be added to one Update User node - they apply in order. Drag the grip handle to reorder.

Use cases​

  • Tag users who completed a flow (engagement_tag = 'onboarded')
  • Update scores (engagement_score += 10)
  • Track journey events for downstream targeting (Track event: onboarding_completed)
  • Set flags for future targeting (feature_eligibility = true)

Custom JSON​

Besides the rows above, an Update User node has a second mode: switch Fields to Custom JSON at the top of the node. Nodes created before this mode existed stay in Fields mode.

In Custom JSON mode you write one JSON object in the same shape as the attributes, subscriptions and events fields of the POST /users API body. A typical use: a landing page on your own site sends an event through the API, the event starts this journey, and this step puts the person on a list, sets attributes and records a follow-up event.

{
"attributes": { "firstName": "Dana" },
"subscriptions": [
{ "listId": "<list id>", "channel": "email", "status": "subscribed" }
],
"events": [
{ "name": "Free Gift Requested", "properties": { "source": "landing-page" } }
]
}
KeyWhat it does
attributesSets each attribute on the person, like the API's attributes merge.
subscriptionsAdds the person to, or removes them from, subscription lists. listId is required; channel is email (default), sms, whatsapp, push or viber; status is subscribed (default) or unsubscribed.
eventsRecords events for the person. name is required; properties and timestamp (ISO date or epoch milliseconds) are optional. Events are recorded the same way as Track an event rows.

Use Insert example to start from the example above, and the list picker to insert one of your lists.

It always updates the person who entered the step. Identifiers cannot be set or changed: email, phone, externalId, userId, anonymousId, _id, id, aliases and the other identity keys are refused, both as top-level keys and as attribute names, in any letter case and with or without _ or -. Platform-managed attributes such as lastEventName are refused too, exactly as the API refuses them. Only attributes, subscriptions and events are allowed at the top level.

An opt-out is kept. If the person unsubscribed from a list on that channel, a subscribed item for it is skipped and reported ("opt-out kept") - a journey never re-subscribes someone. An explicit "status": "unsubscribed" is always applied. Every list must belong to this workspace; any other list id is skipped.

Double opt-in lists are handled the same way as through the API: the person is recorded as subscribed (source journey) and this step does not send a confirmation message.

Limits are the API's: at most 100 subscriptions and 25 events, and the API's size limits for attribute values and event properties. The JSON itself may be up to 50,000 characters, 10 levels deep and 5,000 values; each personalized value may render to at most 50,000 characters (500,000 in total).

Liquid in Custom JSON​

Use Liquid inside string values - the triggering event, the person's profile and journey variables are available, as in any other personalized field:

{
"attributes": {
"firstName": "{{ event.properties.first_name }}",
"giftSource": "{{ event.properties.source | default: 'landing-page' }}"
},
"subscriptions": [
{ "listId": "{{ event.properties.list_id }}", "channel": "email" }
],
"events": [
{ "name": "Free Gift Requested", "properties": { "plan": "{{ user.plan }}" } }
]
}

The JSON must be valid as written: Liquid goes only inside quoted values, never in keys and never as a bare value. Each value is rendered on its own and stays a text value, so data from the event can never change the structure of the JSON or reach an identifier. Because a rendered value is always text, write numbers and true / false literally.

When it is checked​

  • On save: invalid JSON, unknown keys, identifiers and the limits are refused with a message that names the step. An empty Custom JSON can be saved as a draft.
  • On activation: an empty Custom JSON, and any list id that is not a list in this workspace.
  • When it runs: values written with Liquid are checked after rendering. If anything is wrong, the step changes nothing, the reason is recorded on the person's journey run, and the person continues to the next step.
  • The Test tab renders the JSON against a sample person without writing anything.

Issue Coupon Nodes​

An Issue coupon node hands the person a code from a coupon program - created in the connected store at that moment - and keeps it with them. Every later message in the journey that uses {{ coupons.<slug>.code }} shows that same code, on every channel.

  • Program - pick an active program. The node does nothing else: put the code in the messages that follow.
  • Two exits: Issued (the person has a code) and Could not issue (the store could not create one, or the program is paused). Route the second exit to a message without a coupon, or end the journey there.
  • A retry of the node never creates a second code, and with the program's "reuse the person's open code" on, a person who re-enters the journey while their code is still open gets the same code again.

Webhook Nodes​

Webhook nodes make HTTP calls to external services.

Configuration​

SettingDescription
Method + URLGET / POST / PUT / PATCH / DELETE + endpoint URL. Liquid is supported in the URL.
AuthenticationNone / Bearer / Basic / API key / Signed (HMAC). Bearer / Basic / API-key compile into the right header on save (e.g. Authorization: Bearer <token>). Signed (HMAC) adds an X-Joryio-Signature header - a SHA-256 HMAC of the request body - so the receiver can verify the call genuinely came from Joryio. Signed (HMAC) uses the signing secret you enter. Secrets are encrypted when saved and come back as a masked field: leave it to keep the secret, clear it to remove it, type a new value to replace it. A saved secret is kept only for the address it was saved for: if you change the scheme, host or port of the URL, re-enter the secret before saving. A secret needs a literal address: no Liquid, user name or password in the scheme, host or port (Liquid in the path or query is fine). At send time the secret is attached only when the rendered URL still points to that address; otherwise the request goes out without it and the run view says so. Credential-looking custom headers (Authorization, a header named like a token, secret or API key) are masked the same way. In Journey Runs, a person's webhook step shows the HTTP status, ok or failed, the duration and any response fields saved to journey variables. A failed call also shows a Response block under the status (see Webhook Behavior below); a successful response body is never stored.
Custom headersKey/value rows. Liquid is supported in values. Content-Type is added when the request has a body and can be overridden; a Content-Type you set yourself is always sent.
Request bodyJSON, Form-encoded (application/x-www-form-urlencoded), or None (no body and no Content-Type - use it for GET/DELETE). JSON body validates inline as you type; a body that is only a Liquid tag (e.g. {{ user.id }}) shows "JSON after rendering is not guaranteed".
Response handlingOptional: extract a value from the response body (a $.a.b path, e.g. $.user.crm_id) into a journey variable (e.g. crm_id), so a later step can branch on it or template it. To persist it onto the user, add an Update User step that reads the variable.
Retry & timeoutPer-node max retry attempts and request timeout. (Backoff stays exponential and retries fire on 5xx + network errors - see Webhook Behavior below.)
System call - skip quiet hours & frequency capsOff by default: webhooks wait out quiet windows and respect frequency caps like any message. Turn it on only for pure system-to-system calls that shouldn't be delayed by a person's quiet hours. Suppression lists apply either way, and webhook recipients are billable engaged profiles regardless of this setting.

Body sent verbatim​

The body you configure is sent as-is to the receiver. There is no Joryio envelope wrapping the body.

If you want canvas / execution / user metadata in the request, add it yourself with Liquid:

{
"event": "user_signup",
"user_id": "{{ user.id }}",
"email": "{{ user.email }}",
"canvas_id": "{{ canvas.id }}",
"execution_id": "{{ execution.id }}",
"node_id": "{{ node.id }}"
}

Joryio also rides along three metadata headers automatically (so a receiver can pick them up without changing your body shape):

X-Joryio-Canvas-Id: <canvas id>
X-Joryio-Execution-Id: <execution id>
X-Joryio-Node-Id: <node id>

The body goes out byte for byte, exactly as rendered: a body that renders to nothing is not sent at all (never an empty ""), and text that is not JSON - for example a bare id from {{ user.id }} - is sent as-is, not wrapped in quotes. The HMAC signature covers exactly these bytes. Send test shows the request sent and warns when a JSON body is not valid JSON after rendering; the live send is never altered.

Liquid namespaces​

Available in the URL, header values, and JSON body text:

NamespaceContains
user.*Top-level user fields (user.id, user.email, user.phone) and all custom attributes (user.firstName, user.plan, …).
event.* / trigger.*The trigger event payload that started this execution.
canvas.*canvas.id, canvas.name.
execution.*execution.id, execution.startedAt.
node.*node.id, node.type, node.label.

Legacy flat keys ({{ canvasId }}, {{ executionId }}) still resolve.

Webhook Behavior​

A webhook node runs in one of two modes, depending on whether Response handling is on:

Async (default - no response mapping)

The canvas advances immediately after queueing the webhook, without waiting for the HTTP response. Best for fire-and-forget calls (notify a CRM, kick off a downstream job).

Synchronous (Response handling on)

When you map a response field into a journey variable, the node runs synchronously: the journey pauses at the webhook node until the response arrives (or a 15-second timeout), so the variable is ready for the very next step. It is fail-open - on timeout or error the journey continues anyway (the variable is left unset; if you've wired an on-error output, failure routes there instead). The execution is parked (it doesn't hold a worker), so this scales to large audiences.

In both modes:

  • Webhook failures never stop the canvas flow (fail-open).
  • Async webhooks retry on 5xx + network errors with exponential backoff, up to the node's max-attempts setting. 4xx responses fail immediately and are not retried; 429 responses honour the Retry-After header.
  • Failed responses are kept for debugging. When a call fails with a non-2xx answer, Joryio stores the status code, the content type and the first 2 KB of the response body, with values that look like secrets (Bearer / Basic tokens, JWTs, token / secret / password / api_key / authorization fields, long hex or base64 strings) masked before storing. Any credential the request itself sent (its token, password, API key or signature) is also removed wherever the response repeats it, including base64, URL-encoded, JSON-escaped and hex forms. Request headers are never stored. A timeout or network error stores only its error message. Open the Response block on the webhook step in Journey Runs (or on the failed event in a webhook campaign's recipient timeline) to read it. The body is shown only to people with access to personal data (the pii:view permission), because a response can echo a contact's data; others see the status and error with a note that the response is hidden. Stored responses are deleted automatically after 30 days. A successful (2xx) response body is never stored. Joryio staff can turn off storing response bodies for your organization on request; the status, duration and error are still recorded.
  • Only a 2xx response counts as delivered. A 3xx or 4xx (for example 401 or 404) is a failure: it shows as failed in the journey run and the outbox, and a synchronous webhook takes its on-error output, never the success path.
  • Synchronous webhooks fail fast (no retry loop) to keep the parked journey moving; an endpoint slower than 15s times out and fail-opens. A safety-net resumes any journey whose response is lost so a contact can never be parked forever.
  • A per-host circuit breaker trips after repeated host-health failures (timeouts / network / 5xx) to the same host and short-circuits further calls to it for a brief cooldown - so during an endpoint outage, sync calls fail-open instantly instead of each waiting the full timeout, and a dead host isn't hammered. It recovers automatically once the host responds again. (4xx/429 don't trip it - they mean the host is alive.)

Connector Nodes​

Connector nodes sync users with external ad platform audiences during canvas execution.

Configuration​

SettingDescription
IntegrationSelect a connected audience sync integration
ActionAdd to Audience, Remove from Audience, or Sync Audience
AudienceTarget audience on the external platform

Supported Platforms​

  • Meta Ads (Custom Audiences)
  • Google Ads (Customer Match)
  • TikTok Ads (Custom Audiences)
  • LinkedIn Ads (Matched Audiences)
  • Criteo Ads (Contact Lists)

Connector Behavior​

Async Processing

Connector actions are queued and processed asynchronously. The canvas advances immediately after queueing the action. Failed actions are retried up to 3 times with exponential backoff.

User data (email, phone, external ID) is hashed as required by each platform before being sent.

Exit Nodes​

Exit nodes terminate the canvas flow for a user.

Exit Reasons​

ReasonDescription
Goal AchievedUser completed the desired action
UnsubscribedUser opted out
No Longer EligibleUser no longer meets criteria
Manual ExitExplicit flow termination

Processing Order​

When multiple users enter a canvas simultaneously:

  1. Users are processed in parallel by canvas workers
  2. Each user's path is independent
  3. Message sending is distributed across channel-specific queues
  4. High-volume scenarios (1M+ users) are handled efficiently

E-Commerce Template Variables​

When sending messages from Canvas, e-commerce data is automatically available for personalization.

Cart Data​

VariableDescription
{{ cart.items }}Array of cart items
{{ cart.itemCount }}Total number of items
{{ cart.value }}Cart total value
{{ cart.subtotal }}Items subtotal, before shipping and tax (cart.value is the total the store sent, which can include shipping)
{{ cart.currency }}Currency code (USD, EUR, etc.)
{{ cart.checkoutUrl }}URL to resume checkout
{{ cart.abandoned }}Whether cart is abandoned

Example: Abandoned Cart Email​

<h2>You left items in your cart!</h2>

{% for item in cart.items %}
<div style="margin: 10px 0;">
<img src="{{ item.imageUrl }}" width="80" />
<strong>{{ item.name }}</strong>
<span>${{ item.price }} x {{ item.quantity }}</span>
</div>
{% endfor %}

<p><strong>Total: ${{ cart.value }}</strong></p>

<a href="{{ cart.checkoutUrl }}" style="background: #007bff; color: white; padding: 12px 24px;">
Complete Your Purchase
</a>

Cart Item Fields​

Each item in cart.items has:

FieldDescription
productIdProduct identifier
nameProduct name
priceUnit price
quantityQuantity in cart
totalprice × quantity
imageUrlProduct image URL
skuProduct SKU
variantIdVariant identifier

AI Agent Nodes​

An AI Agent node runs one of your AI agents for the person in the journey and stores the answer in a journey variable (see Agent output).

  • A Write agent has two branches: Success and Error.
  • A Classify agent has one branch per label, plus Unsure for an answer the agent is not confident about, plus Error.

Error is a single branch on purpose. Why a run failed (a timeout, a rate limit, a misconfigured key, a spent budget, a provider error) is written to agent.<variable>_outcome and agent.<variable>_error, so a Branch node after the agent can route on it when a journey needs that. An unconnected branch falls through to the default next step with the agent's fallback value, so a journey never stalls on an agent failure. The node shows what a run costs, about X per 1,000 runs, next to the agent picker.