Orchestrate: journeys
A journey moves the people using your app through a sequence of touches over days or weeks: it enrolls someone when something happens (an event, a page visit, joining an audience, a trait, a date), then shows them in-app guides, sends them emails, waits, waits for something they do, and branches on what they did, while measuring a goal such as "created a deal within 14 days".
Journeys live under Journeys in the sidebar. The page has three tabs: Journeys, Emails (the email templates journeys send, see email.md) and Suppressions (addresses that are never emailed).
Concepts
| Term | Meaning |
|---|---|
| Journey | A trigger, a graph of steps, re-entry rules and an optional goal. Belongs to one project and one environment (development, staging or production), whose end users and events it uses. |
| Status | draft → active ⇄ paused → archived. Only drafts and paused journeys can be edited; every saved edit increases the journey's version. |
| Enrollment | One person's run through a journey: where they are (currentStepId), when they're due next, and how it ended (completed, exited with a reason, or failed). Re-entries are new enrollments with the next cycle. |
| Step event | What happened at a step for an enrollment (entered, completed, converted, timeout, branch_yes / branch_no, sent, skipped, error); step analytics count them. |
Triggers
| Trigger | Enrolls someone when… |
|---|---|
| Does an event | they do a built-in event (page_view, guide_completed, …) or one your app tracks with track(), optionally only when its properties match up to 5 filters (plan equals pro, seats > 10, source is set). |
| Visits a page | they view a page whose path matches a pattern: /settings/billing, /contacts/:id or /contacts/* (one segment), /reports/** (anything below). Case and trailing slashes are ignored, and so is the query string. |
| Completes a guide | they complete the chosen guide. |
| Enters an audience | they join an audience: added to a static audience, or matched by a rule-based audience when your app identifies them. Also enroll current members enrolls everyone already in it on the first start. |
| Has a trait | a trait sent with identify() matches (plan is pro, seats is at least 10, beta is true). People are checked whenever they're active; Also enroll everyone who matches now adds people who already match on the first start. |
| A date in a trait comes up | the date in a trait (ISO 8601, e.g. renewal_date = 2026-11-30) plus an offset arrives: -3 = three days before. Checked every 15 minutes. |
Only activity in the journey's environment counts, and only activity after the journey starts (or resumes), except for the two "enroll existing" options above.
Steps
The first step is the entry point; every step names the step that follows it (next), and a journey without a following step ends there. The builder draws the graph top to bottom, with a + between steps to insert one, branches as Yes / No columns that rejoin where both sides meet, and "Continues at …" links when a path jumps to a step drawn elsewhere.
| Step | What it does |
|---|---|
| Show a guide | Shows a guide in your app (see below). With Wait until they complete or dismiss it the step finishes when they complete or dismiss it, or after the timeout (default 72h); without it, the journey moves on straight away and they keep seeing the guide until they leave the journey. |
| Send an email | Sends an email template to the person's email trait, once (see Email). |
| Wait | Waits a number of minutes, hours or days (365 days at most, also along any path). |
| Wait until | Waits until the person does an event, visits a page or completes a guide after reaching the step. If it doesn't happen within the timeout, they go to the If it times out step, or leave the journey. |
| Branch | Sends people down Yes or No depending on a trait, whether they did something in the last N days, audience membership, a completed guide, or whether they opened / clicked the email sent at an earlier email step (put a wait in between). |
| Exit | Ends the journey for them. |
A journey has at most 40 steps. Before it starts, Guidely checks that step ids are unique, every step it points at exists, there are no loops, every step can be reached from the first, every step has its settings, and the guides, audiences and email templates it uses exist in the project (and aren't archived). Problems are shown on the steps in the builder, and starting is refused until they're fixed.
How guide steps reach people
The SDK isn't involved: each guide step has a static audience the journey manages, named Journey · <journey> · <step>. While someone is at the step, the journey adds them to it (removing them when the step ends, or when they leave the journey for steps that don't wait); identify() already returns the audiences a person is in, so a guide that targets that audience shows to exactly the people at the step.
The step panel shows whether the guide's live version (and its draft) targets the step's audience and whether it's live in the journey's environment. Target the guide at this step adds an Audience member rule for the step's audience to the guide's draft targeting (keeping its other rules, needs Edit guides); publish the guide to make it live. Completion is read from the guide history Guidely already keeps (guide_completed / guide_dismissed); a guide the person completed before reaching the step counts as completed.
Email steps
Email steps use the project's customer email channel (Settings → Email): it must be on, with a verified sender and a postal address, or the journey won't start. Each person gets an email step's email at most once per enrollment (the send is keyed <enrollmentId>:<stepId>, so retries never send twice); people without an email trait, who unsubscribed (from everything or the template's category), bounced or were suppressed are skipped and recorded as skipped. Delivery, opens, clicks, bounces and unsubscribes come from SendGrid's event webhook. See email.md.
Enrollment rules
- Once per person (default): someone who entered never enters again.
- Again after they leave: someone can enter again once their previous run ended and the cool-down (
reentryAfterHours, default 24) has passed; only activity after that counts.
Someone already in the journey is never enrolled twice. Enroll someone now on the Analytics tab enrolls one person by user id or end-user id whatever the trigger, for testing. In development and staging it needs Edit journeys; in production Start journeys (and a confirmation). Remove from journey takes someone out (same permissions).
Goal
A goal is an event, a page visit or a completed guide within N days of entering (default 14). Reaching it sets the enrollment's goalReachedAt (the time of the activity) whether the person is still in the journey or already finished it. With Leave the journey on reaching it, people who convert stop getting guides and emails (checked at least hourly). The conversion rate is people who reached the goal / people enrolled.
Analytics
The Analytics tab shows enrolled / in progress / completed / left early / failed, the goal conversion rate and median time to goal, a step funnel (reached vs completed), enrollments and conversions per day, email performance per email step (sent, delivered, opened, clicked, bounced, unsubscribed, skipped), why people left early, and the people in the journey with their current step. While a journey runs, the builder shows live counts on each step (here now, reached, done).
The worker
runJourneys() in src/server/services/journey-engine.ts runs on every outbox pass (about every 10 seconds). Each pass:
- Enrolls: for each active journey (least recently scanned first, up to 100 a pass), it locks the journey row (
FOR UPDATE SKIP LOCKED, so one worker scans a journey at a time), reads up to 500 trigger rows after the journey'sscanCursor(events through the(projectId, name|customName, occurredAt)indexes, audience memberships, environment activity for traits, a text range on the trait for dates), and creates enrollments for the people the enrollment rules let in. The cursor moves to the last row read when the batch was full, otherwise to now minus a 5-minute overlap, so events that arrive a little late are still seen; re-reading the overlap is harmless because enrollments are unique per (journey, person, cycle). - Advances: it claims up to 200 due enrollments (
nextRunAt <= now,FOR UPDATE SKIP LOCKED; a claim older than 5 minutes belongs to a dead worker and is taken over), runs each one's current step and every step that can run straight after it, and saves where it stopped and when to look again (a wait's end, a timeout, or a re-check every 10 minutes for steps that listen).
Every side effect is idempotent, so a crash between a side effect and saving the state just replays the step: audience membership is an upsert, the email is keyed per enrollment and step, and step events are unique per (enrollment, step, kind). Every save checks the claim is still the worker's, so a worker that lost its claim never overwrites newer progress. A step that throws is retried after 1 minute, 5 minutes, 30 minutes and 2 hours; after 5 failed attempts the enrollment is marked failed. Someone whose step was removed while the journey was paused leaves with the reason step_removed.
Pausing freezes everyone where they are (no one new enters; waits that end meanwhile continue on resume). Archiving takes everyone out, empties the guide-step audiences and stops the journey for good; analytics are kept.
Permissions
| Action | Permission |
|---|---|
| See journeys and their analytics | any member (viewer) |
| Create, edit and delete drafts, edit paused journeys | Edit journeys (journeys.edit, editors by default) |
| Start, pause, resume, archive | Start journeys (journeys.publish, admins by default) |
| Enroll someone now / remove someone | Edit journeys in development and staging, Start journeys in production |
| Target a guide at a step | Edit journeys and Edit guides |
Every change of state (create, edit, start, pause, resume, archive, delete, targeting a guide, test enrollment, removing someone) is in the audit log.
API
All under /api/v1, with the dashboard session (see api.md):
| Method and path | ||||||
|---|---|---|---|---|---|---|
| `GET /projects/:id/journeys?status=current\ | draft\ | active\ | paused\ | archived\ | all` | Journeys with their enrollment and goal numbers |
POST /projects/:id/journeys | Create a draft: { name, description?, environment?, trigger?, steps?, reentry?, reentryAfterHours?, goal? } | |||||
GET /journeys/:id | The journey, its problems, names of what it uses, guide-step targeting status, email channel status | |||||
PATCH /journeys/:id | Edit a draft or paused journey; pass expectedVersion to refuse stale edits (409) | |||||
DELETE /journeys/:id | Delete a draft | |||||
POST /journeys/:id/activate · pause · resume · archive | Lifecycle | |||||
GET /journeys/:id/analytics?days=30 | Totals, per-step numbers, email numbers, daily enrollments, exit reasons | |||||
GET /journeys/:id/enrollments?status=&stepId=&page= | People in the journey | |||||
DELETE /journeys/:id/enrollments/:enrollmentId | Remove someone | |||||
POST /journeys/:id/test-enroll | { userId }: enroll someone now | |||||
POST /journeys/:id/target-guide | { stepId }: add the step's audience to its guide's draft targeting |
The trigger, step and goal JSON shapes are the zod schemas in src/lib/journeys.ts (TriggerSchema, StepSchema, GoalSchema). AI assistants can read journeys over MCP with list_journeys and get_journey_status.