Sequences
Lifecycle email as a small, declarative state machine. Enroll once, transition by event, observe by webhook.
A sequence is a lifecycle email programme expressed as states + edges. You define it once in the dashboard (or via the API); your code enrols contacts and emits events; GenieOS walks the graph and sends the right template at the right time.
The model fits a wide range of programmes:
- Onboarding (
signup \u2192 verified \u2192 first_action \u2192 retained). - Trial (
trial_started \u2192 day_3_nudge \u2192 day_10_offer \u2192 expired). - Re-engagement (
gone_quiet \u2192 reach_out \u2192 we_miss_you \u2192 archived). - Course / cohort drip.
Sequences are explicitly not a marketing automation builder with 200 node types — that path leads to mystery boxes nobody can debug. Four primitives, all auditable from the API.
The four node types
| Node | Behaviour |
|---|---|
state | Holding pen. Contact sits here until an event or wait expires. |
wait | Pure delay. After duration, transitions to then. |
send | Enqueues a transactional send, then transitions on a webhook. |
branch | Boolean split. Evaluates condition against the contact + event. |
Edges are triggered by events you emit (product.viewed,
subscription.cancelled) or by GenieOS signals (message.opened).
Defining a sequence
// sequences/onboarding.json
{
"name": "onboarding",
"version": 2,
"entry": "signup",
"nodes": {
"signup": { "type": "send", "template": "welcome", "then": "wait_24h" },
"wait_24h": { "type": "wait", "duration": "24h", "then": "first_action_check" },
"first_action_check": {
"type": "branch",
"condition": "events.user_completed_first_action.exists",
"true": "retained",
"false": "nudge"
},
"nudge": { "type": "send", "template": "first_action_nudge", "then": "wait_72h" },
"wait_72h": { "type": "wait", "duration": "72h", "then": "give_up" },
"give_up": { "type": "state", "terminal": true },
"retained": { "type": "state", "terminal": true }
}
}Push it via the CLI or the dashboard:
genie sequences push ./sequences/onboarding.jsoncondition is a small, sandboxed expression language with three sources:
contact.*— fields on the enrolled contact.events.<type>.*— most recent matching event for the contact.enroll.*— variables passed at enrolment.
No I/O. No loops. No regex. If you need that, do the work in your service and emit a derived event.
Enrolment
Enrol a contact when something irreversible happens — they signed up, their trial started, they cancelled.
await mg.sequences.enroll(
'onboarding',
{
contact: { email: 'ada@example.com', external_id: 'usr_123' },
variables: { plan: 'pro' },
},
{ idempotencyKey: 'usr_123:onboarding' },
);Idempotency on enrolment is strongly recommended. Many real systems emit "user signed up" twice (once from the API, once from a webhook); the key keeps a single enrolment.
Transitions by event
Most edges are driven by events you emit. The same event also serves as
the input to branch conditions.
await mg.events.emit({
type: 'user_completed_first_action',
contact: { external_id: 'usr_123' },
metadata: { action: 'created_first_template' },
});This event:
- Wakes the
first_action_checkbranch (if the contact is parked there). - Becomes available to subsequent branches as
events.user_completed_first_action.*. - Lands in the audit log.
Observing transitions
Subscribe sequence.* events on a webhook to mirror state into your data
warehouse, dashboards, or in-app UI.
{
"id": "evt_01J...",
"type": "sequence.transitioned",
"data": {
"sequence": "onboarding",
"version": 2,
"contact": { "external_id": "usr_123", "email": "ada@example.com" },
"from": "first_action_check",
"to": "retained",
"trigger": {
"kind": "branch",
"condition_result": true,
"matched_event": "evt_01JABC..."
}
}
}Other shapes you\u2019ll see: sequence.entered, sequence.exited,
sequence.send_skipped (suppression list), sequence.error.
Pausing, leaving, deleting
// Pause a contact in place. Wakes on resume.
await mg.sequences.pause('onboarding', { external_id: 'usr_123' });
// Resume from the same node.
await mg.sequences.resume('onboarding', { external_id: 'usr_123' });
// Yank them out entirely.
await mg.sequences.unenroll('onboarding', { external_id: 'usr_123' });A contact can\u2019t be in the same sequence twice; re-enrolling restarts at
entry.
Versioning
Editing a sequence increments version. Already-enrolled contacts keep
their version; new enrolments use the latest. This means you can safely
ship breaking sequence changes without disrupting in-flight contacts.
The sequence.versioned webhook fires on each publish; use it to
automatically restart your CI tests of derived dashboards.
Why this is small on purpose
We resisted the urge to ship for-each, parallel, dynamic template
rendering, sub-sequences, etc. They turn an auditable graph into a
guessable script. If you need that level of orchestration, drive it
from your service and treat sequences as a notification channel.