Journey Builder
A journey is a multi-step drip that reacts to events as they happen. While a campaign is "send this message to this segment right now," a journey is "when this thing happens, kick off this sequence."
Navigation
Access journeys from Engage > Journeys.
Node Types
Every journey is a graph of these six node types:
| Node | Purpose |
|---|---|
| Entry | Event-based trigger that admits a rider into the journey |
| Wait | Pause for a fixed duration or until another event |
| Send | Email, SMS, or push using a template |
| Branch | Predicate on customer state - splits into if_true / if_false paths |
| Goal | Conversion checkpoint that exits the journey on success |
| Exit | Terminal node |
Entry Triggers
These are the events Engage can listen for:
| Event | Fires when |
|---|---|
customer.created | New rider signs up |
ride.ended | A ride completes |
ride.force_ended | A ride was force-ended (you ended it server-side) |
wallet.low_balance | Wallet drops below a configured threshold |
violation.created | A violation is logged against a rider |
subscription.lapsed | A subscription transitions to canceled or expired |
rider_score.tier_changed | Rider score tier moves up or down |
referral.converted | A referred rider takes their first ride |
Each entry trigger accepts an optional filter expression so you can narrow it. Example: customer.created with filter marketing_email_consent = true only admits riders who opted in at signup.
Re-Entry Rules
What happens if the trigger fires twice for the same rider?
| Rule | Behavior |
|---|---|
never | Once per customer ever (recommended for Welcome Series) |
after_cooldown | Re-enters after N days (recommended for Win-Back) |
every_trigger | Re-enters on every matching event (rare - used for Low Balance) |
Re-entry is enforced at journey ingest, not at step time.
Sample Journey - Welcome Series
This is the journey that ships as the Welcome Series preset. It is the canonical example.
┌────────────┐
│ ENTRY │ customer.created (consent = true)
│ signup │
└─────┬──────┘
│
▼
┌────────────┐
│ SEND email │ tpl_welcome_intro
└─────┬──────┘
│
▼
┌────────────┐
│ WAIT 48h │
└─────┬──────┘
│
▼
┌────────────┐ ┌───────────────┐
│ BRANCH │── true ─►│ GOAL │
│ rides ≥ 1 │ │ first_ride │
└─────┬──────┘ └───────────────┘
│ false
▼
┌────────────┐
│ SEND email │ tpl_how_to_ride
└─────┬──────┘
│
▼
┌────────────┐
│ WAIT 72h │
└─────┬──────┘
│
▼
┌────────────┐
│ SEND email │ tpl_promo_first_ride
└─────┬──────┘
│
▼
EXIT
The underlying JSON definition that Engage stores looks like this:
{
"entry": {
"type": "event",
"event": "customer.created",
"filters": { "marketing_email_consent": true }
},
"reentry": "never",
"steps": [
{ "id": "s1", "type": "send", "channel": "email", "template_id": "tpl_welcome_intro", "next": "s2" },
{ "id": "s2", "type": "wait", "duration_hours": 48, "next": "s3" },
{
"id": "s3",
"type": "branch",
"predicate": { "rides.total_count": { "gte": 1 } },
"if_true": "goal_hit",
"if_false": "s4"
},
{ "id": "s4", "type": "send", "channel": "email", "template_id": "tpl_how_to_ride", "next": "s5" },
{ "id": "s5", "type": "wait", "duration_hours": 72, "next": "s6" },
{ "id": "s6", "type": "send", "channel": "email", "template_id": "tpl_promo_first_ride", "next": "exit" },
{ "id": "goal_hit", "type": "goal", "name": "first_ride_taken" },
{ "id": "exit", "type": "exit" }
]
}
Built-In Presets
Engage ships with four journey presets you can spin up in one click:
| Preset | Trigger | Steps |
|---|---|---|
| Welcome Series | customer.created | 3 sends + 1 branch + 1 goal (see above) |
| Win-Back 30/60/90 | Lapsed scheduled scan | 3 sends with branches that exit on activity |
| Low Balance | wallet.low_balance | Push first, email after 4h if no top-up, exit on top-up |
| Blank | Choose your own | Just an entry + exit - you fill in the rest |
Editing Journeys
The dashboard shows a read-only SVG canvas plus a JSON editor. You edit by:
- Spinning up a preset.
- Modifying the JSON to swap templates, change wait durations, or rearrange branches.
- Saving (bumps the journey
versionautomatically). - Publishing.
Why a JSON editor?
The first version of Engage uses a read-only canvas to ship faster. A drag-and-drop canvas is a planned upgrade. Today, the JSON editor with syntax highlighting covers every real-world editing need.
Branch Predicates
Branches evaluate against the same attribute surface as segments. Examples:
{ "wallet.current_balance": { "gt": 5 } }
{ "rides.total_count": { "gte": 3 } }
{ "subscription.status": { "eq": "active" } }
{ "rider_score_tier": { "in": ["gold", "platinum"] } }
Operators supported: eq, ne, gt, gte, lt, lte, in, nin, exists.
Wait Nodes
Two kinds:
| Wait type | Use |
|---|---|
| Fixed duration | duration_hours: 48 - simple delay |
| Until next event | Pause until a specific event fires (e.g., until: "ride.ended") |
If an "until next event" wait is pending and the customer is force-exited (account deletion, RTBF), the wait terminates and the journey records an exit.
Goal Tracking
A goal node has a name and is treated as a successful exit. Goals show up in journey analytics with their own conversion rate.
Common goal patterns:
| Goal | Triggers |
|---|---|
first_ride_taken | Welcome Series - exits when the customer takes a ride |
wallet_topped_up | Low Balance - exits when the customer adds funds |
subscription_renewed | Renewal Series - exits when they renew |
rider_reactivated | Win-Back - exits when they take a ride |
Race Conditions and Locks
Multiple workers can advance the same journey simultaneously. Engage uses Postgres row-level locks to keep them safe:
- Due
journey_runsare claimed viaengage_claim_due_journey_runsusingFOR UPDATE SKIP LOCKED. - Two workers calling that RPC at the same moment receive disjoint sets of runs.
next_step_due_atis updated atomically on claim so the run does not re-appear in the queue.
You do not configure any of this - it just works.
Stale Data Inside a Journey
A common bug class in other tools: a journey publishes with {{wallet.balance}} rendered at journey-publish time, then 30 days later the actual send uses that stale snapshot.
Engage avoids this completely. Every send re-resolves variables and re-evaluates branches at the moment of send. There is no published snapshot of rider state.
Customer in Multiple Journeys
A single rider can be in any number of journeys simultaneously. The 24-hour dedup across campaigns prevents the same template from firing twice in 24 hours, even if two journeys both target it.
If a rider deletes their account mid-journey, GDPR erasure sets journey_runs.exited_at on every active run and drops PII from engagement_events.
Publishing and Pausing
| Action | Effect |
|---|---|
| Save | Bumps version; does not affect already-active runs |
| Publish (active = true) | Journey accepts new entries |
| Pause (active = false) | No new entries; in-flight runs continue until exit |
| Archive | No new entries; in-flight runs are exited |
The publish/unpublish action is logged in engage_compliance_audit_log for the audit trail.
Best Practices
- Always include a branch that exits on success. Otherwise the journey keeps sending to riders who already did the thing.
- Keep wait durations realistic. A 48-hour delay is gentle; 5-minute delays for "marketing" sends feel spammy.
- Use goals. The conversion-rate metric is only meaningful when you tell Engage what success looks like.
- Test with a small segment first. You can wrap an entry trigger with
customer.id = "your-test-account-id"to dry-run the journey against yourself before opening the floodgates.
Troubleshooting
Journey is "published" but runs aren't being created
Check the entry trigger and any filters. A trigger like customer.created with filter marketing_email_consent = true only admits new signups who opted in - if your signup flow isn't capturing consent, no one will enter.
Run stuck at a wait node
Check next_step_due_at on the row. If it's in the past, the engage-journey cron is stalled. If it's in the future, the wait is still in effect.
Same template fired twice for the same rider
Check both the 24-hour dedup window and the re-entry rule on the journey. every_trigger re-entry plus an entry event that fires frequently will trigger this - switch to after_cooldown with N days.
Need Help?
For journey help, contact support@levyelectric.com.