Sequences
A sequence is an automated drip: contacts enter on a trigger, then flow through a graph of steps - send an email, wait, branch on a condition, exit. You can build one on the dashboard canvas, or author it entirely from the CLI / Management API. This guide is the complete reference for the JSON shapes, so you never have to reverse-engineer them.
A sequence has two parts:
trigger- how contacts enter.graph- the nodes (steps) and edges (connections) between them.
Both are stored as JSON. You save them with PUT .../sequences/{id}/draft, then
ship with POST .../sequences/{id}/publish.
Trigger
How a contact enters. A discriminated union on type:
// Enrol on a track() event.
{ "type": "event", "eventName": "modification_limit_reached", "oncePerContact": true }
// Enrol the first time a contact is identified.
{ "type": "contact_created" }
// Enrol when a contact submits a specific form.
{ "type": "form_submitted", "formId": "frm_123", "oncePerContact": true }
// Enrol when a contact is added to a specific list.
{ "type": "list_entered", "listId": "lst_123", "oncePerContact": false }
form_submittedenrolls a contact when they submit the formformId(the confirmed submission for double opt-in forms). Find the id withletter forms list.list_enteredenrolls a contact when they are newly added to the listlistId(via a form, import, the dashboard, orletter lists add-member). Re-adding an existing member does not re-fire. Find the id withletter lists list.oncePerContact(event, form, and list triggers): whentrue, re-firing the trigger won’t re-enrol a contact who already started.- Audience filter - every trigger accepts an optional
filter(aFilterSpec) evaluated against the contact (their traits and prior event history). Only contacts matching the filter get enrolled; the rest are skipped. This is how you target a subset, e.g. “free users only”:
{
"type": "event",
"eventName": "modification_limit_reached",
"oncePerContact": true,
"filter": { "kind": "trait", "path": "plan", "op": "eq", "value": "free" }
}
- Event-property conditions (
where) - event triggers also accept awhereclause that matches on the firing event’s ownproperties, not the contact. Use it when the precise target is the event payload (e.g. the occurrence whereplan == "free"), which a contact trait can’t capture (a user with one Pro and one Free site isisPro: true, yet a Free-site event should still enrol them).whereis a single condition object or an array of them, AND-ed together:
{
"type": "event",
"eventName": "modification_limit_reached",
"oncePerContact": true,
"where": [{ "property": "plan", "op": "eq", "value": "free" }]
}
Each condition is { property, op, value? }. op is one of eq, neq,
contains, not_contains, exists, not_exists (value is omitted for the
last two). You can combine where (event payload) and filter (contact) on
the same trigger.
Values compare as strings. Both
filtertrait values andwhereevent values are matched as strings, so a boolean traitisPro: falseis matched with"value": "false"(notfalse), and a numeric traitseats: 3with"value": "3". Passing a raw boolean/number is rejected with a400(Expected string, received boolean). Runletter traits listto see the keys, inferred types, and a sample value for every trait in the project.
Graph
{ "nodes": [...], "edges": [...] }. Exactly one node is the trigger node
(id trigger); every other node must be reachable from it, and every path must
end at an exit.
Nodes
Each node is { id, type, position, config }. position is { x, y } canvas
coordinates - cosmetic when authoring via the API, any numbers are fine.
config.type must equal the node’s type. The six node types:
// trigger - exactly one; id is always "trigger".
{ "id": "trigger", "type": "trigger", "position": { "x": 0, "y": 0 },
"config": { "type": "trigger" } }
// email - sends one email. Omit templateId to use the project default.
{ "id": "n_email", "type": "email", "position": { "x": 0, "y": 120 },
"config": { "type": "email", "subject": "You hit your limit",
"preview": "Here's how to lift it",
"bodyDoc": { "type": "doc", "content": [ /* TipTap nodes */ ] },
"templateId": null } }
// wait - delay before the next step.
{ "id": "n_wait", "type": "wait", "position": { "x": 0, "y": 240 },
"config": { "type": "wait", "duration": { "value": 1, "unit": "hours" } } }
// wait_event - pause until an event fires ("received" leg) or a timeout
// elapses ("timeout" leg). Needs one "received" edge and one "timeout" edge.
{ "id": "n_wait_event", "type": "wait_event", "position": { "x": 0, "y": 300 },
"config": { "type": "wait_event", "eventName": "completed_onboarding",
"timeout": { "value": 7, "unit": "days" } } }
// branch - yes/no split (needs one "yes" edge and one "no" edge).
{ "id": "n_branch", "type": "branch", "position": { "x": 0, "y": 360 },
"config": { "type": "branch", "condition": { /* BranchCondition */ } } }
// exit - ends the sequence for the contact.
{ "id": "n_exit", "type": "exit", "position": { "x": 0, "y": 480 },
"config": { "type": "exit", "reason": "done" } }
Note the field names. The email body is
bodyDoc(camelCase), and a wait nests itsdurationobject -{ "config": { "type": "wait", "duration": { "value": 1, "unit": "hours" } } }, not{ "value": 1, "unit": "hours" }. The draft endpoint rejects unknown or mis-cased keys with a400, so a typo fails loudly rather than saving a graph the engine can’t run.
Durations
Sequence waits and branch windows use minutes, hours, or days:
{ "value": 1, "unit": "hours" }
(This is a different scale from segment/FilterSpec event windows, which use
hour / day.)
Branch conditions
A branch node’s condition is a discriminated union on kind:
// Did the contact fire an event (optionally within a window)?
{ "kind": "event", "eventName": "Upgraded", "occurred": true,
"window": { "value": 2, "unit": "days" } }
// Compare a contact or account trait.
{ "kind": "trait", "scope": "contact", "path": "plan",
"op": "eq", "value": "pro" }
trait.op is one of eq, neq, contains, not_contains, exists,
not_exists (value is omitted for the last two). scope is contact or
account.
Wait for event
A wait_event node parks the contact until one of two things happens:
- the contact fires
eventName- the enrollment continues down thereceivedleg, the instant the event arrives; or timeoutelapses first - the enrollment continues down thetimeoutleg.
{ "type": "wait_event", "eventName": "completed_onboarding",
"timeout": { "value": 7, "unit": "days" } }
Only events that occur after the contact reaches the step count - earlier
occurrences in the enrollment are ignored. This is the key difference from a
branch on kind: "event", which asks “did it already happen?” and moves on
immediately. Use wait_event for “send the nudge only if they haven’t
done X within a week”, and a branch for “they’re here now, which way do they
go?”.
Like a branch, a wait_event node has two labelled legs, so its outgoing
edges carry a branch of "received" or "timeout" (see below).
Edges
{ id, source, target, branch? }. branch is only set on edges leaving a
two-leg node: "yes" / "no" for a branch, "received" / "timeout" for a
wait-for-event.
{ "id": "e1", "source": "trigger", "target": "n_email" }
{ "id": "e2", "source": "n_branch", "target": "n_upsell", "branch": "yes" }
{ "id": "e3", "source": "n_branch", "target": "n_exit", "branch": "no" }
{ "id": "e4", "source": "n_wait_event", "target": "n_thanks", "branch": "received" }
{ "id": "e5", "source": "n_wait_event", "target": "n_nudge", "branch": "timeout" }
Validation
- Draft saves are strict on shape but lenient on completeness. Unknown keys,
wrong types, or a
config.typethat doesn’t match the nodetypeare rejected with a400. But a half-built draft (an email with no subject yet, a disconnected node) still saves - so you can author incrementally. - Publish runs the full structural check.
POST .../publishreturns400with anerrorsarray unless the graph is shippable:- exactly one trigger node, every node reachable from it;
- every single-leg node (everything except branch, wait_event, exit) has exactly one outgoing edge;
- every branch node has exactly one
yesedge and onenoedge; - every wait_event node has exactly one
receivededge and onetimeoutedge; - every path eventually reaches an
exit(no dead ends, no infinite loops); - each email has a subject and a non-empty
bodyDoc(a doc that renders no text fails withemail.body_empty); each wait has a positive duration; each wait_event has an event name and a positive timeout; each branch condition is complete; the trigger’s event name, audiencefilter, and event-propertywhereconditions are valid.
- Dry-run before you publish.
GET .../sequences/{id}/validate(CLI:letter sequences validate <id>) runs the same checks against the current draft and returns{ ok, errors }without shipping, so you can confirm a graph is shippable in one call.
Email bodies
bodyDoc is a TipTap / ProseMirror document - the same
rich-text JSON the dashboard composer produces, rendered to HTML + text at send
time. The envelope is a doc node whose content is an array of block nodes:
{
"type": "doc",
"content": [
{ "type": "paragraph",
"content": [{ "type": "text", "text": "You've hit your modification limit." }] },
{ "type": "paragraph",
"content": [
{ "type": "text", "text": "Upgrade to keep going - " },
{ "type": "text", "marks": [{ "type": "bold" }], "text": "it's instant." }
] }
]
}
Hand-writing ProseMirror is tedious, so the CLI’s sequences scaffold accepts
--body-md (Markdown) and converts it for you - see below.
Worked example: the CLI
Goal: send one email to free users, one hour after they fire
modification_limit_reached.
The fastest path is sequences scaffold, which builds a valid trigger + graph
(trigger → wait → email → exit) in one shot:
letter sequences scaffold \
--name "Limit reached - free users" \
--event modification_limit_reached \
--audience-trait plan=free \
--wait 1h \
--subject "You hit your limit" \
--body-md "You've reached your modification limit.\n\n**Upgrade** to keep going." \
--publish
Prefer to drive it yourself? Create, draft, then publish:
# 1. Create a draft sequence, capture its id.
id=$(letter sequences create --name "Limit reached" --json | jq -r .id)
# 2. Save the graph + trigger. (See `letter sequences schema` for the shapes.)
letter sequences draft "$id" \
--trigger '{"type":"event","eventName":"modification_limit_reached","oncePerContact":true,"filter":{"kind":"trait","path":"plan","op":"eq","value":"free"}}' \
--graph @graph.json \
--expected-revision 0
# 3. Publish (fails with the validation errors if the graph isn't shippable).
letter sequences publish "$id"
letter sequences schema prints the canonical trigger / node / edge shapes plus
a known-good starter graph, so you can author against a reference instead of
guessing. Run letter sequences draft --help for the flags (--graph accepts
inline JSON or @file.json).
Worked example: the Management API
The same flow over HTTP (authenticated with a lt_pat_* token from
letter login):
# Create.
SEQ=$(curl -s https://api.letter.app/v1/projects/$SLUG/sequences \
-H "Authorization: Bearer $LETTER_PAT" \
-H "Content-Type: application/json" \
-d '{"name":"Limit reached - free users"}' | jq -r .id)
# Draft.
curl -s -X PUT https://api.letter.app/v1/projects/$SLUG/sequences/$SEQ/draft \
-H "Authorization: Bearer $LETTER_PAT" \
-H "Content-Type: application/json" \
-d @draft.json
# Publish.
curl -s -X POST https://api.letter.app/v1/projects/$SLUG/sequences/$SEQ/publish \
-H "Authorization: Bearer $LETTER_PAT"
Where draft.json is:
{
"expected_revision": 0,
"trigger": {
"type": "event",
"eventName": "modification_limit_reached",
"oncePerContact": true,
"filter": { "kind": "trait", "path": "plan", "op": "eq", "value": "free" }
},
"graph": {
"nodes": [
{ "id": "trigger", "type": "trigger", "position": { "x": 0, "y": 0 },
"config": { "type": "trigger" } },
{ "id": "wait1", "type": "wait", "position": { "x": 0, "y": 120 },
"config": { "type": "wait", "duration": { "value": 1, "unit": "hours" } } },
{ "id": "email1", "type": "email", "position": { "x": 0, "y": 240 },
"config": { "type": "email", "subject": "You hit your limit",
"bodyDoc": { "type": "doc", "content": [
{ "type": "paragraph", "content": [
{ "type": "text", "text": "Upgrade to keep going." }] }] } } },
{ "id": "exit1", "type": "exit", "position": { "x": 0, "y": 360 },
"config": { "type": "exit" } }
],
"edges": [
{ "id": "e1", "source": "trigger", "target": "wait1" },
{ "id": "e2", "source": "wait1", "target": "email1" },
{ "id": "e3", "source": "email1", "target": "exit1" }
]
}
}
The full schema for every shape above is published in the machine-readable
openapi.json under the Graph,
SequenceTrigger, NodeConfig, BranchCondition, and FilterSpec components.