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.
Running a fleet on this hardware?
Levy Fleets runs scooters, e-bikes, mopeds and golf carts on the IoT hardware you already have, with integration pages for 29 vendors including Segway, Okai, Omni, Queclink, Invers and Comodule. Keyless unlock, geofencing, rider payments and rider support are included, and the Managed plan is a share of revenue with no per-vehicle software fee.