Docs Go to app →

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_submitted enrolls a contact when they submit the form formId (the confirmed submission for double opt-in forms). Find the id with letter forms list.
  • list_entered enrolls a contact when they are newly added to the list listId (via a form, import, the dashboard, or letter lists add-member). Re-adding an existing member does not re-fire. Find the id with letter lists list.
  • oncePerContact (event, form, and list triggers): when true, re-firing the trigger won’t re-enrol a contact who already started.
  • Audience filter - every trigger accepts an optional filter (a FilterSpec) 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 a where clause that matches on the firing event’s own properties, not the contact. Use it when the precise target is the event payload (e.g. the occurrence where plan == "free"), which a contact trait can’t capture (a user with one Pro and one Free site is isPro: true, yet a Free-site event should still enrol them). where is 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 filter trait values and where event values are matched as strings, so a boolean trait isPro: false is matched with "value": "false" (not false), and a numeric trait seats: 3 with "value": "3". Passing a raw boolean/number is rejected with a 400 (Expected string, received boolean). Run letter traits list to 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 its duration object - { "config": { "type": "wait", "duration": { "value": 1, "unit": "hours" } } }, not { "value": 1, "unit": "hours" }. The draft endpoint rejects unknown or mis-cased keys with a 400, 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 the received leg, the instant the event arrives; or
  • timeout elapses first - the enrollment continues down the timeout leg.
{ "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.type that doesn’t match the node type are rejected with a 400. 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 .../publish returns 400 with an errors array 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 yes edge and one no edge;
    • every wait_event node has exactly one received edge and one timeout edge;
    • 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 with email.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, audience filter, and event-property where conditions 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.