# Sequence Lifecycle

> Create, preview, update, archive, and read analytics for sequences

Source: https://docs.graph8.com/developers/sequences-lifecycle/

The [Sequences](https://docs.graph8.com/developers/sequences/) page covers basic operations (list, get, run, pause/resume, manage enrolled contacts). This page covers the **lifecycle** endpoints used to author and inspect a sequence end-to-end - typically what a CLI or build tool needs.

All endpoints share the `/sequences` prefix and require a Bearer API key.

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/sequences` | POST | Create a sequence (with optional steps + channels) |
| `/sequences/{id}/preview` | GET | Read sequence + steps + channels (no enrollment) |
| `/sequences/{id}` | PATCH | Update sequence metadata |
| `/sequences/{id}/steps/{step_id}` | PATCH | Update a single step |
| `/sequences/{id}/pause` | POST | Pause a live sequence |
| `/sequences/{id}/resume` | POST | Resume a paused sequence |
| `/sequences/{id}/analytics` | GET | Comprehensive analytics report |
| `/sequences/{id}` | DELETE | Soft-delete (archive) a sequence |

A sequence in a **transitional** status (`scheduling`, `pausing`, `resuming`, `terminating`) cannot be PATCHed - you'll get `409 Conflict`. Wait for the operation to settle.

For machine-readable schemas see the [interactive API docs](https://be.graph8.com/api/v1/docs).

### Enum reference

- **`step_type`** (case-insensitive): `EMAIL | PHONE | SMS | WHATSAPP | HEYREACH | MANUAL_DIALER`. The alias `linkedin` maps to `HEYREACH`. For Unipile-based LinkedIn pass `UNIPILE` explicitly.
- **`input_type`**: `ON_DEMAND | MANUAL_TEMPLATE | AI_GENERATED_TEMPLATE`. Aliases: `template` / `manual` → `MANUAL_TEMPLATE`; `ai` / `ai_template` → `AI_GENERATED_TEMPLATE`.
- **`channel_type`**: `SMTP | GMAIL | INBOXKIT | PHONE | LINKEDIN | SMS | WHATSAPP`.

### `step_data` by channel

`step_data` is a free-form object whose meaningful keys depend on `step_type`. Unknown keys are silently ignored — a key that belongs to another channel has no effect. Pick the keys for your step's channel from the sections below.

> **Caution: LinkedIn copy does NOT use `body` / `subject`**
> Those are EMAIL-only keys. A HEYREACH step that only sets `body` is accepted but sends no copy. Use the `linkedin_*` keys described below.

**EMAIL**

```json
{
  "subject": "...",
  "body": "...",
  "instructions": "...",
  "email_type": "plain"
}
```

| Key | Type | Description |
|-----|------|-------------|
| `subject` | string | Email subject line |
| `body` | string | Email body — supports `{{merge_tokens}}` |
| `instructions` | string | Used when `input_type` is `AI_GENERATED_TEMPLATE` |
| `email_type` | string | `"plain"` (default) or `"html"` |

**HEYREACH / LinkedIn**

`step_type: "HEYREACH"` (or the `linkedin` alias). `linkedin_action_type` is **required** — a LinkedIn step without it is accepted at create time but fails at send time.

| `linkedin_action_type` | Copy / config keys |
|---|---|
| `CONNECTION_REQUEST` | `linkedin_connection_message` (string), `linkedin_connection_fallback` (optional string), `linkedin_connection_withdraw_days` (optional integer, default `25`) |
| `MESSAGE` | `linkedin_message_text` (string) — supports `{{merge_tokens}}` |
| `INMAIL` | `linkedin_inmail_subject` (string), `linkedin_inmail_body` (string) |
| `FOLLOW` | — (no copy) |
| `VIEW_PROFILE` | — (no copy) |
| `LIKE_POST` | `linkedin_like_reaction_type` (optional integer: `0`=Like, `1`=Celebrate, `2`=Support, `3`=Love, `4`=Insightful, `5`=Funny; default `1`), `linkedin_like_react_before_days` (optional integer, default `3`) |

```json
{
  "linkedin_action_type": "MESSAGE",
  "linkedin_message_text": "Hi {{first_name}}, ..."
}
```

For AI-generated LinkedIn copy: set `input_type: "AI_GENERATED_TEMPLATE"` on the step, plus `step_data.linkedin_input_mode: "instructions"` and `step_data.linkedin_instructions: "<prompt>"`. Use `linkedin_input_mode: "manual"` with the explicit copy keys above for fixed text.

> **Note: HeyReach vs Unipile**
> The `linkedin` alias maps to HeyReach. For Unipile-based LinkedIn, pass `step_type: "UNIPILE"` explicitly — it uses the same `linkedin_*` `step_data` keys and the same `linkedin_action_type` requirement.

**PHONE / MANUAL_DIALER**

```json
{
  "dial_dnc": false,
  "phone_options": ["mobile", "direct"]
}
```

| Key | Type | Description |
|-----|------|-------------|
| `dial_dnc` | boolean | Dial Do-Not-Call numbers (default `false`) |
| `phone_options` | string[] | Phone types to dial |

**SMS / WHATSAPP**

```json
{
  "message_body": "...",
  "media_urls": ["https://..."],
  "whatsapp_template_sid": "...",
  "whatsapp_template_variables": {}
}
```

| Key | Type | Description |
|-----|------|-------------|
| `message_body` | string | Message text — supports `{{merge_tokens}}` |
| `media_urls` | string[] | WhatsApp media attachment URLs |
| `whatsapp_template_sid` | string | WhatsApp Business template SID |
| `whatsapp_template_variables` | object | Values for the template variables |

---

> **Note: Ids in these examples**
> The request examples use shell variables (`$SEQUENCE_ID` and `$STEP_ID`) for ids. Set them from the `id` fields returned when you create or list sequences and their steps. Ids shown in sample responses (`seq_abc`, `stp_1`) are illustrative only and do not exist.

## Create Sequence

`POST /sequences`

Create a sequence in `drafted` status. You can optionally include `steps[]` and `channels[]` in the same call.

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | **Yes** | Sequence name |
| `user_email` | string | **Yes** | Owner email address |
| `description` | string | No | Free-form description |
| `finish_on_reply` | boolean | No (default `true`) | Stop contacting on reply |
| `send_in_same_thread` | boolean | No (default `false`) | Send follow-ups in the same email thread |
| `wait_for_new_contacts` | boolean | No (default `false`) | Keep sequence waiting for new enrollments |
| `associated_list_id` | integer | No | Contact list to associate |
| `campaign_id` | string | No | AI Studio campaign UUID to link |
| `sequence_kind` | string | No (default `"cold_outbound"`) | `"cold_outbound"` or `"nurture"`. Immutable after creation. |
| `pinned_mailbox_id` | integer | No | Required when `sequence_kind` is `"nurture"`; ignored otherwise. Immutable after creation. |
| `steps` | object[] | No | Step configs — see step config table below |
| `channels` | object[] | No | Channel configs — see channel config table below |

#### Step config

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `step_order` | integer | **Yes** | Order within the sequence (1-indexed) |
| `step_type` | string | **Yes** | `EMAIL`, `PHONE`, `SMS`, `WHATSAPP`, `HEYREACH`, `MANUAL_DIALER` (or alias `linkedin`) |
| `input_type` | string | No (default `ON_DEMAND`) | `ON_DEMAND`, `MANUAL_TEMPLATE`, or `AI_GENERATED_TEMPLATE` |
| `time_interval` | integer | No (default `0`) | Delay in seconds after the previous step |
| `step_data` | object | No | Step content — see `step_data` by channel above. LinkedIn steps use `linkedin_*` keys, not `body`. |

#### Channel config

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `channel_id` | integer | **Yes** | Mailbox or phone resource ID |
| `channel_value` | string | **Yes** | The address or number (e.g. `rep@org.com`) |
| `channel_type` | string | **Yes** | `SMTP`, `GMAIL`, `INBOXKIT`, `PHONE`, `LINKEDIN`, `SMS`, or `WHATSAPP` |
| `channel_data` | object | No | Optional metadata |

### Example

**cURL**

```bash
curl -X POST "https://be.graph8.com/api/v1/sequences" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Q2 Outbound",
    "user_email": "rep@graph8.com",
    "associated_list_id": 5,
    "steps": [
      {
        "step_order": 1,
        "step_type": "EMAIL",
        "input_type": "MANUAL_TEMPLATE",
        "time_interval": 0,
        "step_data": {"subject": "Quick intro", "body": "Hi {{first_name}}..."}
      }
    ],
    "channels": [
      {
        "channel_id": 12,
        "channel_value": "rep@graph8.com",
        "channel_type": "SMTP"
      }
    ]
  }'
```

### Response `201 Created`

```json
{
  "data": {
    "id": "seq_abc",
    "name": "Q2 Outbound",
    "status": "drafted",
    "created_at": "2026-04-15T10:00:00Z"
  }
}
```

> **Note: Nurture sequences**
> A nurture is an ordinary sequence with `sequence_kind: "nurture"` — the same step types and structure as a cold-outbound sequence, created through this same endpoint. There is no separate `/nurtures` endpoint. Key differences:
>
> - `pinned_mailbox_id` is **required**, and every email channel must use that one mailbox (no multi-mailbox load-balancing). A nurture with a missing or mismatched mailbox returns **422**.
> - Email steps send at up to **500/day per mailbox** (vs the standard cold-outbound cap). Non-email channels keep their standard caps.
> - Nurture creation is gated by the **`ENABLE_NURTURE`** feature flag.
> - `sequence_kind` and `pinned_mailbox_id` are **immutable** — set once at creation, never patchable.
>
> Enrollment, run/pause/resume, analytics, and contact listing are identical to cold-outbound sequences.

### Errors

| Status | Meaning |
|--------|---------|
| `400` | Invalid step or channel config |
| `422` | Nurture sequence missing or mismatched `pinned_mailbox_id` |

---

## Preview Sequence

`GET /sequences/{sequence_id}/preview`

Read-only view of a sequence with all steps and channels. Does not enroll anyone.

### Path Parameters

| Parameter | Type | Description |
|-----------|------|-------------|
| `sequence_id` | string | Sequence UUID |

### Example

**cURL**

```bash
curl "https://be.graph8.com/api/v1/sequences/$SEQUENCE_ID/preview" \
  -H "Authorization: Bearer $API_KEY"
```

### Response

```json
{
  "data": {
    "id": "seq_abc",
    "name": "Q2 Outbound",
    "status": "drafted",
    "description": null,
    "steps": [
      {
        "id": "stp_1",
        "step_order": 1,
        "step_type": "EMAIL",
        "input_type": "MANUAL_TEMPLATE",
        "time_interval": 0,
        "step_data": {"subject": "Quick intro", "body": "..."}
      }
    ],
    "channels": [
      {"id": "ch_1", "channel_id": 12, "channel_value": "rep@graph8.com", "channel_type": "SMTP"}
    ]
  }
}
```

---

## Update Sequence

`PATCH /sequences/{sequence_id}`

Update sequence metadata. Send only the fields you want to change. Returns `409` if the sequence is in a transitional status (`scheduling`, `pausing`, `resuming`, `terminating`).

### Path Parameters

| Parameter | Type | Description |
|-----------|------|-------------|
| `sequence_id` | string | Sequence UUID |

### Request Body

All fields are optional.

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Sequence name |
| `description` | string | Description |
| `is_shared` | boolean | Share with the org |
| `finish_on_reply` | boolean | Finish on reply |
| `send_in_same_thread` | boolean | Same-thread follow-ups |
| `wait_for_new_contacts` | boolean | Wait for new contacts |
| `schedule_id` | string | UUID from `GET /schedules` |
| `appointment_id` | integer | Integer from `GET /event-types` |
| `textual_agent_name` | string | Agent name from `GET /voice/dialer/agents` (since 0.9.7) |
| `voice_agent_name` | string | Same agent picker — pass the same value as `textual_agent_name` for the common "one agent, all channels" case |

> **Caution: `sequence_kind` and `pinned_mailbox_id` are immutable**
> These fields are intentionally not accepted here. They are set once at creation (including the defaults `cold_outbound` / `null`). To change either, delete and recreate the sequence.

### Example

**cURL**

```bash
curl -X PATCH "https://be.graph8.com/api/v1/sequences/$SEQUENCE_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Q2 Outbound v2", "send_in_same_thread": true}'
```

### Errors

| Status | Meaning |
|--------|---------|
| `404` | Sequence not found |
| `409` | Sequence is in a transitional status (`scheduling`, `pausing`, `resuming`, `terminating`) |

---

## Update Step

`PATCH /sequences/{sequence_id}/steps/{step_id}`

Update a single step. The same `409` rule applies if the parent sequence is transitioning.

### Path Parameters

| Parameter | Type | Description |
|-----------|------|-------------|
| `sequence_id` | string | Sequence UUID |
| `step_id` | string | Step UUID |

### Request Body

All fields are optional.

| Field | Type | Description |
|-------|------|-------------|
| `step_data` | object | Step content — see `step_data` by channel above. **Replaced wholesale, not merged.** Send the complete object every time; any key you omit is dropped. |
| `time_interval` | integer | Delay in seconds after the previous step |
| `step_type` | string | `EMAIL`, `PHONE`, `SMS`, `WHATSAPP`, `HEYREACH`, `MANUAL_DIALER` |
| `input_type` | string | `ON_DEMAND`, `MANUAL_TEMPLATE`, or `AI_GENERATED_TEMPLATE` |

> **Caution: `step_data` is replaced wholesale, not merged**
> Send the **complete** `step_data` object every time — any key you omit is dropped. For example, patching only `linkedin_message_text` on a LinkedIn step will erase its `linkedin_action_type` and break the step. Re-send all keys the step needs.

### Example

**cURL**

```bash
curl -X PATCH "https://be.graph8.com/api/v1/sequences/$SEQUENCE_ID/steps/$STEP_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"step_data": {"subject": "Quick intro v2", "body": "Updated..."}}'
```

---

## Pause / Resume

`POST /sequences/{sequence_id}/pause`
`POST /sequences/{sequence_id}/resume`

Pause a live sequence (or resume a paused one). Returns the number of contacts affected.

### Response

```json
{
  "data": {
    "sequence_id": "seq_abc",
    "status": "paused",
    "contacts_affected": 150
  }
}
```

---

## Analytics

`GET /sequences/{sequence_id}/analytics`

Returns a comprehensive analytics report - overview KPIs, performance over time, engagement breakdown, contact distribution, per-step metrics, and sender distribution. The shape mirrors the internal `SequenceReports` model.

### Example

**cURL**

```bash
curl "https://be.graph8.com/api/v1/sequences/$SEQUENCE_ID/analytics" \
  -H "Authorization: Bearer $API_KEY"
```

### Response

```json
{
  "data": {
    "overview": {"sent": 320, "opened": 180, "replied": 22},
    "performance": {"open_rate": 0.56, "reply_rate": 0.07},
    "engagement": {"by_day": [...]},
    "timeline": [...],
    "contact_distribution": {"active": 150, "completed": 100, "stopped": 70},
    "step_breakdown": [
      {"step_id": "stp_1", "sent": 320, "opened": 180}
    ],
    "step_creation_methods": [...],
    "sender_distribution": [...]
  }
}
```

The full schema lives at [/api/v1/docs](https://be.graph8.com/api/v1/docs) under `SequenceAnalyticsResponse`.

---

## Delete (Archive)

`DELETE /sequences/{sequence_id}`

Soft-deletes a sequence by setting `is_archived = true`. Already-archived sequences return `400`. Sequences in certain non-terminal states cannot be archived and return `409`.

### Example

**cURL**

```bash
curl -X DELETE "https://be.graph8.com/api/v1/sequences/$SEQUENCE_ID" \
  -H "Authorization: Bearer $API_KEY"
```

### Errors

| Status | Meaning |
|--------|---------|
| `400` | Sequence is already archived |
| `404` | Sequence not found |
| `409` | Sequence cannot be archived in its current state |
