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 Type | Description |
|---|---|
| Event | User enters when they perform a specific event |
| Segment Entry | User enters when they join a segment |
| Entity Change | User enters when a related entity record changes |
| API | User enters via API call |
| Schedule | Users enter on a recurring schedule |
| WhatsApp Message | User enters when they send a WhatsApp message |
| Inbound SMS | A known contact enters when they text in (filter by SMS account, message type, and Message Text / Message Type conditions) |
Message Nodes
Message nodes send communications to users through various channels.
Supported Channels
- Email - Send personalized emails
- SMS - Send text messages
- 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 messages are not a Message-node channel. Because they are delivered when the user next opens your app (rather than pushed out), they have their own In-App Message node - 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).
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
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:
- Message is personalized with user data
- Message is added to the sending queue
- User advances to next canvas step
- 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
Subscription Consent
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 (
subscribedoropted_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
approvedstate
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 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.
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 from the In-App Message tile in the builder palette.
What you configure
- In-app campaign (required) - the in-app campaign that supplies the content, layout and variants. The node reuses the campaign you already built, so the message looks identical wherever it is delivered from.
- 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.
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. Three delay types cover the common patterns:
Delay Types
| Type | When to use |
|---|---|
| Duration | Wait a fixed amount of time after the user reaches this step (e.g. "wait 7 days"). |
| Calendar date | Wait 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 week | Wait until the next occurrence of a chosen weekday + time-of-day (e.g. "next Monday at 12:00"). |
Duration
| Setting | Description |
|---|---|
| Wait for | Number + unit (Minutes / Hours / Days / Weeks). |
| Then wait until a specific time of day | Optional. After the duration completes, keep waiting until the chosen time of day in the chosen timezone. |
| Timezone | Company / User local / UTC (used by the time-of-day option). |
Calendar date
| Setting | Description |
|---|---|
| Date | Target ISO date. Pickable from the inline calendar - click any day to select; arrows navigate months. |
| Time | Hour + minute selectors. |
| Timezone | Company / User local / UTC. |
Day of week
| Setting | Description |
|---|---|
| Day & time | Weekday + hour + minute. The 7-cell preview row at the top of the modal is also clickable. |
| Timezone | Company / User local / UTC. |
| If a user arrives after the cutoff | Wait (default) rolls them to next week's occurrence. Advance fires immediately so they don't lose a week. |
Personalize delay
A toggle on every type. When on, the delay reads from a per-user variable instead of a fixed value:
| Source | Reads from |
|---|---|
| Context variables | execution.context[varName] - the journey's incoming context (legacy default). |
| User custom attribute | user.attributes.<varName> and top-level user fields. |
| Event property | execution.context.event.<varName> and trigger payload. |
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".
Timezone resolution
| Mode | Resolves to |
|---|---|
| Company time | workspace.settings.timezone (cached per worker for 60s). |
| User local time | user.attributes.timezone (set by the SDK). Falls back to Company time if missing. |
| UTC | UTC. |
Limits
- All delay branches are clamped to a 30-day maximum. A misconfigured node 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
nodeTypeis stillconditionfor backwards compatibility with existing journey data. New journeys persist asbranchMode: '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:
| Type | What it checks | Operators |
|---|---|---|
attribute | user.attributes[field] | equals, not_equals, gt, gte, lt, lte, contains, not_contains, exists, not_exists, in, not_in |
default_attribute | Top-level user fields (email, phone, externalId, firstName, lastName, country, ML scores, e-commerce derivatives) | Same as attribute |
event / behavioral | Event history with optional time window | performed_event, not_performed_event, performed_event_in_last, not_performed_event_in_last, event_count_gte, event_count_lte |
segment | Segment membership (recursive - segments can reference other segments, capped at depth 5) | in, not_in |
canvas_execution | Active / completed / exited canvas executions | in_canvas, not_in_canvas, in_any_canvas, not_in_any_canvas, completed_canvas, exited_canvas |
channel_subscription | user.subscriptions[channel].status | subscribed_to_channel, not_subscribed_to_channel, opted_in_to_channel |
list_membership | List-subscription record on a chosen list | member_of_list, not_member_of_list |
bounce_status | Email validity / hard- or soft-bounce state | email_valid, email_bounced, email_hard_bounced |
ecommerce | RFM segment, order count, spend, cart state | rfm_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 |
entity | user.entityRecords[entity] | exists, not_exists |
app | user.appIds | has_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" }
}
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
- Trigger: Event = "Product Added"
- Delay: Wait 1 hour
- 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
- 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
- VIP customers (
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
| Mode | When to use |
|---|---|
| Did / Didn't | One named "did" path + the implicit "No event in window" fallback. Equivalent of the legacy single-event Action Path. |
| Multi | Up 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
| Setting | Behavior |
|---|---|
| Wait up to N units | Fixed deadline (minutes / hours / days / weeks). |
| Personalize | Window 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
| Mode | Behavior |
|---|---|
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 window | The 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.
| Kind | Runtime status |
|---|---|
| Perform Custom Event | Fully 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 Event | Saved but not yet wired - pending the conversion-events runtime hook. |
| Add an Email Address | Saved but not yet wired - pending the attribute-set event stream. |
| Change Custom Attribute Value | Saved 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) andtimed_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 usestimed_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
signupworks).
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.")
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)
Webhook Nodes
Webhook nodes make HTTP calls to external services.
Configuration
| Setting | Description |
|---|---|
| Method + URL | GET / POST / PUT / PATCH / DELETE + endpoint URL. Liquid is supported in the URL. |
| Authentication | None / 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. |
| Custom headers | Key/value rows. Liquid is supported in values. Content-Type is added by default and can be overridden. |
| Request body | JSON, Form-encoded (application/x-www-form-urlencoded), or None. JSON body validates inline as you type. |
| Response handling | Optional: 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 & timeout | Per-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 caps | Off 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>
Liquid namespaces
Available in the URL, header values, and JSON body text:
| Namespace | Contains |
|---|---|
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:
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).
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-Afterheader. - 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
| Setting | Description |
|---|---|
| Integration | Select a connected audience sync integration |
| Action | Add to Audience, Remove from Audience, or Sync Audience |
| Audience | Target 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
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
| Reason | Description |
|---|---|
| Goal Achieved | User completed the desired action |
| Unsubscribed | User opted out |
| No Longer Eligible | User no longer meets criteria |
| Manual Exit | Explicit flow termination |
Processing Order
When multiple users enter a canvas simultaneously:
- Users are processed in parallel by canvas workers
- Each user's path is independent
- Message sending is distributed across channel-specific queues
- 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
| Variable | Description |
|---|---|
{{ cart.items }} | Array of cart items |
{{ cart.itemCount }} | Total number of items |
{{ cart.value }} | Cart total value |
{{ 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:
| Field | Description |
|---|---|
productId | Product identifier |
name | Product name |
price | Unit price |
quantity | Quantity in cart |
total | price × quantity |
imageUrl | Product image URL |
sku | Product SKU |
variantId | Variant identifier |