> ## Documentation Index
> Fetch the complete documentation index at: https://docs.coldsend.pro/llms.txt
> Use this file to discover all available pages before exploring further.

# Subsequences

> Conditional branches that pull a lead off the main follow-up path when something happens.

A **subsequence** is a conditional branch. When a lead does something — replies with a keyword, gets tagged as interested, bounces — the branch pulls them off the main follow-up chain and onto its own sequence of steps.

<Info>
  Subsequences are the complement of [sequences](/campaigns/sequences). Sequences are the **MAIN** chain every lead walks; subsequences are **branches** that interrupt it. They are managed by different endpoints, use different trigger vocabularies, and measure waits differently.
</Info>

## `subsequences_enabled`

Every campaign carries a `subsequences_enabled` flag, returned by `GET /campaigns/{campaign_id}` and `GET /campaigns/{campaign_id}/flow`.

It is **derived, not a setting** — you never write it directly:

| Action | Effect |
| - | - |
| Create the first subsequence | Flag flips to `true` automatically |
| Delete the last remaining subsequence | Flag flips back to `false` automatically |

<Warning>
  The flag gates **branches only**. It has no effect on the MAIN follow-up chain: a campaign with `subsequences_enabled: false` runs its sequences exactly as before, and turning it on does not move existing follow-ups onto the graph.
</Warning>

Update, delete and manual-move all require the flag to be `true`. If it is not, they return `400`:

```json theme={null}
{"detail": "Subsequences are not enabled for this campaign. Create a subsequence first to enable them."}
```

Creating a subsequence does **not** require it — that is how the flag gets turned on in the first place.

## Reading the Graph

**GET** `/api/public/v1/campaigns/{campaign_id}/flow` — scope `campaigns:read`

```python theme={null}
flow = requests.get(
    f"{base_url}/api/public/v1/campaigns/{campaign_id}/flow",
    headers={"X-API-Key": api_key}
).json()

print(flow["subsequences_enabled"])
for node in flow["nodes"]:
    print(node["node_group"], node["node_type"], node["id"], len(node.get("variants") or []))
```

```json theme={null}
{
  "campaign_id": "550e8400-e29b-41d4-a716-446655440000",
  "subsequences_enabled": true,
  "nodes": [
    {
      "id": "a1000000-0000-0000-0000-000000000001",
      "campaign_id": "550e8400-e29b-41d4-a716-446655440000",
      "node_type": "EMAIL",
      "node_group": "MAIN",
      "label": "Initial Email",
      "position": null,
      "variants": null
    },
    {
      "id": "b2000000-0000-0000-0000-000000000001",
      "campaign_id": "550e8400-e29b-41d4-a716-446655440000",
      "node_type": "EMAIL",
      "node_group": "SUBSEQUENCE",
      "label": "Send case study",
      "position": null,
      "variants": [
        {
          "variant_name": "A",
          "variant_label": null,
          "subject": "The case study you asked for",
          "body": "Hi {{first_name}}, here it is...",
          "html_body": null,
          "weight": 100,
          "content_type": null
        }
      ]
    }
  ],
  "edges": [
    {
      "id": "c3000000-0000-0000-0000-000000000001",
      "from_node_id": "a1000000-0000-0000-0000-000000000001",
      "to_node_id": "b2000000-0000-0000-0000-000000000001",
      "kind": "EVENT",
      "condition_type": "KEYWORD",
      "condition_params": {"phrases": ["case study", "send it over"]},
      "wait_seconds": null,
      "priority": 0,
      "is_active": true
    }
  ]
}
```

### Node and edge vocabulary

| Field | Values | Meaning |
| - | - | - |
| `node_type` | `EMAIL`, `EXIT` | Send a variant set, or terminate |
| `node_group` | `MAIN`, `SUBSEQUENCE` | Main chain, or a branch |
| `kind` | `EVENT`, `TIMEOUT` | Push/interrupt on behaviour, or scheduled wait |
| `condition_type` | see below | What the edge is waiting for |

An **EVENT** edge enters a branch; a **TIMEOUT** edge is the wait between two branch steps.

<Info>
  `variants` is populated for `SUBSEQUENCE` nodes **only**. A MAIN node returns `variants: null` because its content is served by the [sequences](/campaigns/sequences) endpoints — returning it in both places would give you two sources of truth for the same step.
</Info>

<Warning>
  This endpoint exposes node and edge IDs that the public API gives you **no way to edit directly**. That is deliberate: you need `entry_from_node_id` and `target_subsequence_node_id` from here, and everything else is managed through the subsequence endpoints below. There is no `/flow/nodes` or `/flow/edges` route on the public surface.
</Warning>

## Creating a Branch

**POST** `/api/public/v1/campaigns/{campaign_id}/flow/subsequences` — scope `campaigns:update`, returns **201**

```python theme={null}
response = requests.post(
    f"{base_url}/api/public/v1/campaigns/{campaign_id}/flow/subsequences",
    headers={"X-API-Key": api_key},
    json={
        "trigger_type": "KEYWORD",
        "trigger_params": {"phrases": ["case study", "send it over"]},
        "priority": 10,
        "label": "Case study track",
        "steps": [
            {
                "wait_seconds": 0,
                "label": "Send case study",
                "variants": [
                    {
                        "variant_name": "A",
                        "subject": "The case study you asked for",
                        "body": "Hi {{first_name}},\n\nAttached is the case study.\n\nBest,\nJohn",
                        "weight": 100
                    }
                ]
            },
            {
                "wait_seconds": 259200,
                "variants": [
                    {
                        "variant_name": "A",
                        "subject": "Worth a quick call?",
                        "body": "Hi {{first_name}},\n\nDid the case study land?\n\nBest,\nJohn"
                    }
                ]
            }
        ]
    }
)

subsequence_id = response.json()["entry_edge"]["id"]
```

### `subsequence_id` is the entry edge

<Warning>
  Every endpoint below that takes a `subsequence_id` wants the **entry edge's** ID — `entry_edge.id` in the create response, or the `EVENT` edge in `GET /flow`. It is not a node ID.

  Manual moves are the exception: `target_subsequence_node_id` wants the branch's first **node** ID, which is `entry_edge.to_node_id`.
</Warning>

### Entry triggers

| `trigger_type` | Fires on | `trigger_params` | Matching |
| - | - | - | - |
| `INTENT_TAG` | An intent tag is applied to a thread | `{"tag_names": ["Demo Booked", "Interested"]}` | Exact string equality against the applied tag label |
| `KEYWORD` | A reply is received | `{"phrases": ["case study", "next week"]}` | Case-insensitive substring against the reply body |
| `STATUS` | A lead's status changes | `{"status": "BOUNCED"}` | Exact match on the status the lead moved **to** |

Valid `status` values are the lead statuses: `PENDING`, `QUEUED`, `SENT`, `OPENED`, `REPLIED`, `FAILED`, `BOUNCED`, `UNSUBSCRIBED`, `EXTERNAL`, `BLOCKED`.

`INTENT_TAG` also still honours the legacy single-value shape `{"tag_name": "Demo Booked"}`, and `KEYWORD` coerces a bare string into a one-element list.

<Warning>
  These three are the **only** valid branch entry triggers. `NOT_OPENED`, `NOT_REPLIED` and `OPENED_BUT_NOT_REPLIED` belong to the MAIN chain and are rejected here with a **422**. The two vocabularies do not overlap in either direction.
</Warning>

<Warning>
  **An entry trigger with missing or empty parameters is accepted by the API and then never fires.** The branch is created, `subsequences_enabled` flips on, and nothing ever enters it — the worker only logs a warning. The same is true of a `STATUS` trigger naming a value that is not a lead status. Always read the branch back with `GET /flow` and confirm `condition_params` is populated.
</Warning>

### Steps

| Field | Type | Required | Description |
| - | - | - | - |
| `wait_seconds` | int ≥ 0 | Yes | Seconds before this step fires. The first step is normally `0`. |
| `condition_type` | string | No | Defaults to `ALWAYS`. |
| `label` | string | No | Step name; falls back to the branch label, then `Subsequence Step N`. |
| `variants` | array | No | Email content for this step. |

<Warning>
  Branch steps wait in **seconds**, not days. `campaigns.update` sequences use `wait_days`; this endpoint uses `wait_seconds`. Three days is `259200`, not `3`.
</Warning>

At least one step is required — an empty `steps` array is a 422 on create and a 400 on update.

### `condition_type` between steps

Leave it at the default `ALWAYS`.

<Warning>
  Every other condition is **negative** — `NOT_REPLIED`, `NOT_OPENED`, `OPENED_BUT_NOT_REPLIED`. A branch is entered *because* something happened, usually a reply, so `email_replied` is already true for every lead the branch holds and a negative condition can never be satisfied. Steps gated on one are **silently discarded at send time** — the lead simply stops advancing, with no error anywhere.
</Warning>

### Variants

Variant field names differ between this endpoint and `campaigns.update`:

| Subsequence step | Campaign sequence | Column |
| - | - | - |
| `subject` | `subject_template` | `subject_template` |
| `body` | `email_content` | `body_template` |
| `html_body` | — | `html_template` |
| `weight` | `distribution_percent` | `distribution_percent` |

```json theme={null}
{
  "variants": [
    {"variant_name": "A", "subject": "Subject A", "body": "Body A", "weight": 60},
    {"variant_name": "B", "subject": "Subject B", "body": "Body B", "weight": 40}
  ]
}
```

`variant_name` defaults to `A`, `B`, `C`… by position when omitted, is uppercased and truncated to 10 characters. `weight` is clamped to 0–100. `variant_label` and `content_type` are also accepted.

<Warning>
  **Unrecognised keys are dropped silently**, not rejected. Sending `subject_template` here instead of `subject` produces a step with no subject and no error. Read the branch back with `GET /flow` and check `variants` after writing.
</Warning>

A step with no `variants` at all enrolls and schedules but has nothing to render.

### `priority`

The entry edge's evaluation order among the campaign's EVENT edges. Higher is evaluated first.

<Warning>
  Priority must be **unique** across the campaign's EVENT edges. Reusing one returns `400`:

  ```json theme={null}
  {"detail": "An EVENT edge with priority 10 already exists"}
  ```
</Warning>

### `entry_from_node_id`

The MAIN node the branch hangs off. **Omit it** unless you have a reason not to — the server resolves the campaign's root MAIN node and creates one if the campaign predates the graph. Supplying a node that is not `MAIN` returns `400`.

## Updating a Branch

**PUT** `/api/public/v1/campaigns/{campaign_id}/flow/subsequences/{subsequence_id}` — scope `campaigns:update`

```python theme={null}
response = requests.put(
    f"{base_url}/api/public/v1/campaigns/{campaign_id}/flow/subsequences/{subsequence_id}",
    headers={"X-API-Key": api_key},
    json={"trigger_params": {"phrases": ["case study", "examples", "proof"]}}
)
```

Partial by design: any field you omit is left alone. You can retarget the trigger without touching the steps, or replace the steps without touching the trigger.

<Warning>
  When you **do** send `steps`, it replaces the **whole chain** — not one step. Send every step you want to keep, in order.
</Warning>

The replacement is reconciled **positionally** against the persisted nodes rather than by delete-and-recreate, so node and enrollment IDs the worker has already recorded survive an edit. Leads currently in the branch stay in it. Sending an empty `steps` array is a 400.

Updatable fields: `label`, `trigger_type`, `trigger_params`, `priority`, `is_active`, `steps`. Setting `is_active: false` parks the branch without deleting it.

## Deleting a Branch

**DELETE** `/api/public/v1/campaigns/{campaign_id}/flow/subsequences/{subsequence_id}` — scope `campaigns:update`

```python theme={null}
response = requests.delete(
    f"{base_url}/api/public/v1/campaigns/{campaign_id}/flow/subsequences/{subsequence_id}",
    headers={"X-API-Key": api_key}
)
# {"success": true, "message": "Subsequence deleted"}
```

Passing a TIMEOUT edge instead of an entry edge returns `400`:

```json theme={null}
{"detail": "The specified edge is not a subsequence entry (EVENT) edge"}
```

Deleting the campaign's **last** branch flips `subsequences_enabled` back to `false`.

## Moving Leads by Hand

**POST** `/api/public/v1/campaigns/{campaign_id}/leads/move-to-subsequence` — scope `leads:update`

Note the path: this lives under `/leads`, not `/flow`.

```python theme={null}
response = requests.post(
    f"{base_url}/api/public/v1/campaigns/{campaign_id}/leads/move-to-subsequence",
    headers={"X-API-Key": api_key},
    json={
        "lead_ids": ["650e8400-e29b-41d4-a716-446655440000"],
        "target_subsequence_node_id": "b2000000-0000-0000-0000-000000000001"
    }
)
```

```json theme={null}
{
  "success_count": 1,
  "skipped_leads": [],
  "enrollments": [
    {
      "id": "d4000000-0000-0000-0000-000000000001",
      "lead_id": "650e8400-e29b-41d4-a716-446655440000",
      "campaign_id": "550e8400-e29b-41d4-a716-446655440000",
      "entry_node_id": "b2000000-0000-0000-0000-000000000001",
      "entry_edge_id": null,
      "trigger_reason": "Manual move via API",
      "entered_at": "2026-01-14T09:12:44Z",
      "completed_at": null
    }
  ]
}
```

A manual move:

* enrolls each lead in the target branch;
* **invalidates their pending MAIN follow-ups**, so they stop receiving the main chain;
* skips leads already enrolled, reporting them in `skipped_leads`;
* emits a [`subsequence_entered`](/webhooks/event-types) webhook event per new enrollment.

`entry_edge_id` is `null` for a manual move — no edge fired. That, plus the `trigger_reason`, is how you tell a hand move from an automatic entry downstream.

| Constraint | Result |
| - | - |
| `lead_ids` must be 1–500 entries | `422` outside the range |
| Every lead must belong to this campaign | `404` — the whole call is rejected, nothing is enrolled |
| Target must be a `SUBSEQUENCE` node | `400` `Target node must be a SUBSEQUENCE node` |

<Warning>
  Lead membership is validated **all-or-nothing**. If one ID in a batch of 400 belongs to a different campaign, the entire request fails with `404` and the message names only the count — `{"detail": "3 lead(s) do not belong to this campaign"}` — not which ones. Validate IDs against `GET /campaigns/{campaign_id}/leads` first.
</Warning>

## Per-Branch Analytics

**GET** `/api/public/v1/analytics/campaigns/{campaign_id}/subsequences` — scope `campaigns:read`

```python theme={null}
data = requests.get(
    f"{base_url}/api/public/v1/analytics/campaigns/{campaign_id}/subsequences",
    headers={"X-API-Key": api_key}
).json()

for branch in data["subsequences"]:
    m = branch["metrics"]
    print(branch["label"], branch["trigger_type"],
          m["enrollments_total"], m["enrollments_completed"], m["conversion_rate"])
```

| Field | Description |
| - | - |
| `subsequences[].entry_node_id` | The branch's first node — join this back to `GET /flow` |
| `subsequences[].label`, `trigger_type`, `trigger_params` | Copied from the entry edge |
| `subsequences[].metrics.enrollments_total` | Leads that ever entered |
| `subsequences[].metrics.enrollments_active` | Still in the branch |
| `subsequences[].metrics.enrollments_completed` | Finished the branch |
| `subsequences[].metrics.conversion_rate` | Completed ÷ total |
| `subsequences[].metrics.steps[]` | Per-step breakdown |
| `summary.total_enrollments`, `total_active`, `overall_conversion_rate` | Across every branch |

A campaign belonging to another team is a `404`, indistinguishable from one that does not exist.

## Lead Journey

**GET** `/api/public/v1/campaigns/{campaign_id}/leads/{lead_id}/journey` — scope `leads:read`

The whole of one lead's path through the campaign graph — main chain and branches together, in send order.

```python theme={null}
journey = requests.get(
    f"{base_url}/api/public/v1/campaigns/{campaign_id}/leads/{lead_id}/journey",
    headers={"X-API-Key": api_key}
).json()

print(journey["status"], journey["current"], journey["next_scheduled"])
for step in journey["steps"]:
    print(step["node_key"], step["subject"], step["sent"], step["trigger_invalidated"])
for e in journey["enrollments"]:
    print("branch:", e["entry_node_id"], e["trigger_reason"], e["entered_at"])
```

| Field | Description |
| - | - |
| `steps[]` | Every touchpoint. `node_id` is set for branch emails and `null` for main-chain steps; `step_number` is the reverse. |
| `steps[].trigger_invalidated` | The follow-up was cancelled because its condition no longer held — this is where a manual move shows up |
| `enrollments[]` | Branch enrollments, with `trigger_reason` and `entry_edge_id` (`null` when moved by hand) |
| `replies[]` | Inbound replies, with `in_reply_to` matching a sent step's `message_id` |
| `current` | `node_key` of the last sent touchpoint |
| `next_scheduled` | `node_key` of the earliest future, unsent, non-invalidated follow-up — `null` when nothing is pending |

`node_key` is the node ID for branch steps and `step:{step_number}` for main-chain steps, which is what lets one timeline mix both.

This is the fastest way to answer "why did this lead stop getting emails" — read `trigger_invalidated` down the steps and check `enrollments`.

## Error Codes

Flow errors return a **plain string** in `detail`, not an object.

| Status | Cause |
| - | - |
| `400` | Branch not enabled, wrong edge kind, non-MAIN entry source, negative between-step condition, duplicate priority, empty `steps` on update, non-SUBSEQUENCE move target |
| `401` | Missing or invalid API key |
| `403` | Key lacks the required scope |
| `404` | Campaign or edge not found, or belongs to another team; leads outside the campaign on a manual move |
| `422` | Unknown `trigger_type`, `steps` missing or empty on create, `wait_seconds` negative, 0 or 501+ `lead_ids` |
| `500` | Unexpected server error |

Failed mutations roll back — a `400` mid-create leaves no partial branch behind.

## Next Steps

<CardGroup cols={2}>
  <Card title="Follow-up Sequences" icon="list" href="/campaigns/sequences">
    The MAIN chain that branches interrupt.
  </Card>

  <Card title="Leads" icon="users" href="/leads/overview">
    Find the lead IDs a manual move needs.
  </Card>

  <Card title="Webhook Events" icon="zap" href="/webhooks/event-types">
    `subsequence_entered` and `subsequence_completed`.
  </Card>

  <Card title="Analytics" icon="bar-chart-2" href="/analytics/overview">
    Campaign-level metrics alongside per-branch numbers.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.