---
title: Using sequences with the API | Lightfield
description: Create a sequence with agent or template steps, test and publish its draft, enroll contacts, change it while it runs, and read what happened, with one sequence carried from start to finish.
---

The Sequences API is in Preview and may introduce breaking changes.

This guide builds one sequence over the REST API and follows it from creation to its first replies. Responses are trimmed to the fields each step is about. The [Sequence API reference](/api/resources/sequence/index.md) has the full shape of each one.

## The published version and the draft

A sequence has a **published version**, which is what runs, and at most one **open draft**, which is where edits happen. Enrolled contacts follow the published version. A draft changes nothing until it is published.

```
create -> draft -> publish -> DEACTIVATED -> activate -> ACTIVE
```

Once published, the loop repeats: open a draft, edit it, publish it. The sequence keeps its status and its enrollments through each publish.

Enrollments and messages belong to the published version, so a sequence keeps running while a draft is open.

## Before you start

Writes, including starting a test, need the `sequences:update` scope, and creating a sequence needs `sequences:create`. Reads need `sequences:read`. Listing the variables a template can use also needs `contacts:read` and `accounts:read`. See [Scopes](/using-the-api/scopes/index.md).

A sequence refers to two other resources by ID, so look them up first: the senders it sends from, and the members who review messages before they go out.

Terminal window

```
curl "https://api.lightfield.app/v1/senders?channel[equal]=EMAIL&archived[equal]=false" \
    -H 'Lightfield-Version: 2026-03-01' \
    -H "Authorization: Bearer $API_KEY"
```

```
{
  "object": "list",
  "data": [
    { "id": "snd_cmuyynvgn000d", "channel": "EMAIL", "health": "HEALTHY", "emailAddress": "ada@outbound.example.com" },
    { "id": "snd_cmuyynvgq000f", "channel": "EMAIL", "health": "HEALTHY", "emailAddress": "ben@outbound.example.com" }
  ],
  "totalCount": 2
}
```

Sender IDs start with `snd_`. Member IDs, from [List members](/api/resources/member/methods/list/index.md), start with `mem_`. Contact IDs start with `con_`.

## Create a sequence

Create takes a name, the definition (messaging, steps, send window, pacing), and the senders and reviewers. Only `name` and `definition.steps` are required.

Terminal window

```
curl https://api.lightfield.app/v1/sequences \
    -H 'Content-Type: application/json' \
    -H 'Lightfield-Version: 2026-03-01' \
    -H "Authorization: Bearer $API_KEY" \
    -H 'Idempotency-Key: q4-outbound-create' \
    -d '{
      "name": "Q4 outbound",
      "definition": {
        "description": "Book intro calls with heads of RevOps",
        "messaging": {
          "persona": "Head of RevOps",
          "valueProp": "Clean CRM with no manual entry",
          "callToAction": "15-minute intro call"
        },
        "sendWindow": { "startHour": 8, "endHour": 18, "timezone": "America/New_York" },
        "pacing": { "EMAIL": { "dailyLimit": 40 } },
        "steps": [
          { "type": "EMAIL", "name": "Intro", "prompt": "Introduce Lightfield in three sentences.", "deliveryMode": "AUTO" },
          { "type": "WAIT", "duration": 2, "unit": "DAY" },
          { "type": "EMAIL", "name": "Follow-up", "prompt": "Short follow-up on the intro.", "deliveryMode": "FOR_REVIEW" }
        ]
      },
      "reviewers": ["mem_cmuyynvg60005"],
      "senders": ["snd_cmuyynvgn000d", "snd_cmuyynvgq000f"]
    }'
```

```
{
  "sequenceId": "seq_cmuyyy75r0016",
  "name": "Q4 outbound",
  "version": "Y211eXl5NzV3MDAxOGl0OTVyMGluZGxtcDox",
  "channels": ["EMAIL"],
  "definition": {
    "description": "Book intro calls with heads of RevOps",
    "messaging": { "persona": "Head of RevOps", "valueProp": "Clean CRM with no manual entry", "trigger": null, "callToAction": "15-minute intro call" },
    "sendWindow": { "startHour": 8, "endHour": 18, "daysOfWeek": [1, 2, 3, 4, 5], "timezone": "America/New_York" },
    "pacing": { "EMAIL": { "dailyLimit": 40 }, "LINKEDIN_INVITE": { "dailyLimit": null }, "LINKEDIN_MESSAGE": { "dailyLimit": null } },
    "priority": "NORMAL",
    "steps": [
      { "id": "step-1", "type": "EMAIL", "name": "Intro", "mode": "AGENT", "prompt": "Introduce Lightfield in three sentences.", "deliveryMode": "AUTO" },
      { "id": "step-2", "type": "WAIT", "duration": 2, "unit": "DAY" },
      { "id": "step-3", "type": "EMAIL", "name": "Follow-up", "mode": "AGENT", "prompt": "Short follow-up on the intro.", "deliveryMode": "FOR_REVIEW" }
    ]
  },
  "reviewers": [{ "id": "mem_cmuyynvg60005", "objectType": "member" }],
  "senders": [
    { "id": "snd_cmuyynvgn000d", "objectType": "sender" },
    { "id": "snd_cmuyynvgq000f", "objectType": "sender" }
  ],
  "publishWarnings": []
}
```

Three things to notice in the response:

- **It is a draft, not a sequence.** It has a `sequenceId` and a `version`, not an `id` and a `status`.
- **Defaults were filled in.** `daysOfWeek` is Monday to Friday, `priority` is `NORMAL`, every pacing key is present with `null` for the ones you did not set, and each step has an `id`. Each send step also has a `mode`: `AGENT` for a step written from its `prompt`, `TEMPLATE` for one that sends a [template](#write-a-template-step).
- **Nothing can send yet.** The sequence exists, but it has no published version.

The `Idempotency-Key` header makes the create safe to retry. A replay with the same key returns the same draft.

A create that fails leaves nothing behind. The response says what to fix:

```
{
  "error": {
    "type": "bad_request",
    "code": "invalid_configuration",
    "message": "The draft has 1 problem. Nothing was saved.",
    "issues": [
      { "code": "invalid_value", "path": "definition.sendWindow.startHour", "stepId": null, "message": "startHour must be before endHour." }
    ]
  }
}
```

Every refused sequence write has this shape. [Sequence errors](/using-the-api/errors/#sequence-errors/index.md) lists the codes.

### Add LinkedIn steps

A sequence can mix email and LinkedIn. LinkedIn has three step types: `LINKEDIN_INVITE` sends a connection request, with `includeNote: true` to attach a note; `WAIT_FOR_CONNECTION` holds for up to its `duration` until the request is accepted; and `LINKEDIN_MESSAGE` sends a direct message, which LinkedIn only delivers to a connection. So the shape is invite, wait for connection, then message:

```
"steps": [
  { "type": "LINKEDIN_INVITE", "name": "Connect", "prompt": "Ask to connect, one line on why.", "deliveryMode": "AUTO", "includeNote": true },
  { "type": "WAIT_FOR_CONNECTION", "duration": 5, "unit": "DAY" },
  { "type": "LINKEDIN_MESSAGE", "name": "Hello", "prompt": "Thank them for connecting and ask for 15 minutes.", "deliveryMode": "AUTO" },
  { "type": "WAIT", "duration": 3, "unit": "DAY" },
  { "type": "EMAIL", "name": "Follow-up", "prompt": "Short follow-up by email.", "deliveryMode": "AUTO" }
]
```

If the wait runs out before they accept, later LinkedIn messages are skipped and email steps still run. LinkedIn steps need a LinkedIn sender in `senders` and a plan that includes LinkedIn; without the plan, the save is refused with a `not_entitled` issue. The ordering rules and the note budget are covered in [Building sequences](/sequences/building-sequences/#linkedin-order/index.md).

### Write a template step

A send step either has the agent write each message from a `prompt`, or sends copy you write as a `template`, with variables filled in from each contact’s record. One sequence can mix both. Send a `template` instead of a `prompt` and the step is a template step; `mode` is optional, because the content decides it.

```
"steps": [
  {
    "type": "EMAIL",
    "name": "Intro",
    "deliveryMode": "AUTO",
    "template": {
      "subject": "{{ coalesce({rel:account.col:name}, \"Your team\") }} and clean CRM data",
      "body": "Hi {{ coalesce({col:firstName}, \"there\") }},\n\nLightfield keeps your CRM up to date with **no manual entry**. Worth a [15-minute intro](https://lightfield.app/demo)?"
    }
  },
  { "type": "WAIT", "duration": 3, "unit": "DAY" },
  {
    "type": "EMAIL",
    "name": "Follow-up",
    "deliveryMode": "FOR_REVIEW",
    "template": {
      "subject": "Following up",
      "body": "Hi {{ coalesce({col:firstName}, \"there\") }}, any thoughts on my note? Happy to send a 2-minute video instead."
    }
  }
]
```

The template’s shape depends on the step type:

| Step type          | `template`                                                                                                                                                                                          |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `EMAIL`            | `subject`, plain text, and `body`, in Markdown. Paragraphs, bold, italics, links and lists keep their formatting. Leave out the sign-off and signature; they are added when the message is written. |
| `LINKEDIN_MESSAGE` | `body`, plain text.                                                                                                                                                                                 |
| `LINKEDIN_INVITE`  | `body`, plain text: the connection note, sent only when `includeNote` is `true`.                                                                                                                    |

Every `EMAIL` template needs a `subject`. Follow-up emails reply in the first email’s thread, so only the first email’s subject is sent. Template steps send exactly what you write: no quality checks are applied to the copy.

#### Variables

Write every variable with a fallback, the text a contact missing that value gets:

```
{{ coalesce(<variable>, "fallback") }}
```

An empty fallback, `""`, is allowed and inserts nothing, so write the text around it to read well without the value: `Following up, {{ coalesce({col:firstName}, "") }}.` sends `Following up, .` to a contact with no first name. These are the variables a template can read:

| Variable                                          | Reads                                                             |
| ------------------------------------------------- | ----------------------------------------------------------------- |
| `{col:firstName}`, `{col:lastName}`               | The contact’s first or last name                                  |
| `{attr:ad_…}`                                     | A field of the contact                                            |
| `{rel:account.col:name}`                          | The name of the contact’s first account                           |
| `{rel:account.ad_…}`                              | A field of the contact’s first account                            |
| `{rel:sender.col:name}`, `{rel:sender.col:email}` | The name or email address of the sender the message goes out from |

A field variable names the field by the `ad_` ID that [List contact field definitions](/api/resources/contact/methods/definitions/index.md) and [List account field definitions](/api/resources/account/methods/definitions/index.md) return. The field must be of type `TEXT`, `NUMBER`, `CURRENCY`, `DATETIME`, `SINGLE_SELECT`, `FULL_NAME`, `EMAIL` or `URL`. A `SINGLE_SELECT` field prints its option’s label, written `{attr:ad_….label}` or `{rel:account.ad_….label}`.

Rather than assembling the references yourself, [List template variables](/api/resources/sequence/methods/listTemplateVariables/index.md) returns every variable this workspace can use:

Terminal window

```
curl https://api.lightfield.app/v1/sequences/template/variables \
    -H 'Lightfield-Version: 2026-03-01' \
    -H "Authorization: Bearer $API_KEY"
```

```
{
  "object": "list",
  "data": [
    { "label": "First name", "group": "contact", "ref": "{{ {col:firstName} }}", "field": null },
    { "label": "Job title", "group": "contact", "ref": "{{ {attr:ad_cmuyynvh2000h} }}", "field": { "id": "ad_cmuyynvh2000h", "valueType": "TEXT" } },
    { "label": "Name", "group": "account", "ref": "{{ {rel:account.col:name} }}", "field": null },
    { "label": "Industry", "group": "account", "ref": "{{ {rel:account.ad_cmuyynvh5000j.label} }}", "field": { "id": "ad_cmuyynvh5000j", "valueType": "SINGLE_SELECT" } },
    { "label": "Name", "group": "sender", "ref": "{{ {rel:sender.col:name} }}", "field": null }
  ],
  "totalCount": 5
}
```

A `ref` sent as returned is refused, because it has no fallback. Put the part inside `{{ }}` into `coalesce`: `{{ {col:firstName} }}` is written `{{ coalesce({col:firstName}, "there") }}`.

A save is refused with `invalid_value`, and `path` pointing at the subject or body (`definition.steps.0.template.body`), when a template has:

- a variable without a fallback,
- anything inside `{{ }}` that is not a variable, since it would be sent as written, or
- a field that is not one of the types above, or that does not exist.

A variable inside a link address, such as `[demo](https://example.com/{{ … }})`, is refused too, but as `400 invalid_configuration` with only a `message`, and no `issues`.

Template steps must be turned on for the workspace. If they are not, a save that adds or changes a template is refused with a `not_entitled` issue at `definition.steps.N.mode`; write the step with a `prompt` instead.

Template steps read back with `mode: "TEMPLATE"` and their `template`, and no `prompt`. Field variables read back with their `ad_` IDs, so a template you read can be sent back unchanged.

## Read it back

Retrieve the sequence and its draft side by side.

Terminal window

```
curl https://api.lightfield.app/v1/sequences/seq_cmuyyy75r0016 \
    -H 'Lightfield-Version: 2026-03-01' \
    -H "Authorization: Bearer $API_KEY"
```

```
{
  "id": "seq_cmuyyy75r0016",
  "objectType": "sequence",
  "name": "Q4 outbound",
  "status": "DRAFT",
  "channels": [],
  "definition": null,
  "reviewers": [],
  "senders": [],
  "firstActivatedAt": null,
  "lastActivatedAt": null
}
```

The sequence is `DRAFT` with no definition, no channels and no senders, because none of that is published yet. The content you sent lives on the draft:

Terminal window

```
curl https://api.lightfield.app/v1/sequences/seq_cmuyyy75r0016/draft \
    -H 'Lightfield-Version: 2026-03-01' \
    -H "Authorization: Bearer $API_KEY"
```

This returns exactly what create returned. The same split holds for the rest of the API: [List sequences](/api/resources/sequence/methods/list/index.md) includes the new sequence with `definition: null`, [Retrieve sequence performance](/api/resources/sequence/methods/retrievePerformance/index.md) reports zero counts and no steps, and enrolling a contact returns `400 sequence_not_published`.

## Edit the draft

Saves are partial: send the fields you want to change, with the draft’s current `version` as `expectedVersion`.

Terminal window

```
curl https://api.lightfield.app/v1/sequences/seq_cmuyyy75r0016/draft \
    -H 'Content-Type: application/json' \
    -H 'Lightfield-Version: 2026-03-01' \
    -H "Authorization: Bearer $API_KEY" \
    -d '{
      "expectedVersion": "Y211eXl5NzV3MDAxOGl0OTVyMGluZGxtcDox",
      "definition": {
        "messaging": { "trigger": "Just raised a Series B" },
        "sendWindow": { "startHour": 9 },
        "pacing": { "EMAIL": { "dailyLimit": null } }
      }
    }'
```

```
{
  "version": "Y211eXl5NzV3MDAxOGl0OTVyMGluZGxtcDoy",
  "definition": {
    "messaging": { "persona": "Head of RevOps", "valueProp": "Clean CRM with no manual entry", "trigger": "Just raised a Series B", "callToAction": "15-minute intro call" },
    "sendWindow": { "startHour": 9, "endHour": 18, "daysOfWeek": [1, 2, 3, 4, 5], "timezone": "America/New_York" },
    "pacing": { "EMAIL": { "dailyLimit": null }, "LINKEDIN_INVITE": { "dailyLimit": null }, "LINKEDIN_MESSAGE": { "dailyLimit": null } }
  }
}
```

Only the three fields changed, and `null` cleared the daily limit. The response carries a new `version`; use it on the next save.

Two fields do not merge. `definition.steps` replaces the whole step list: include the `id` of each step you keep, in the same relative order and with the same type, and omit the `id` for a new step. Reviewers and senders take `add` and `remove` lists instead of a full list.

Terminal window

```
curl https://api.lightfield.app/v1/sequences/seq_cmuyyy75r0016/draft \
    -H 'Content-Type: application/json' \
    -H 'Lightfield-Version: 2026-03-01' \
    -H "Authorization: Bearer $API_KEY" \
    -d '{
      "expectedVersion": "Y211eXl5NzV3MDAxOGl0OTVyMGluZGxtcDoy",
      "definition": {
        "steps": [
          { "id": "step-1", "type": "EMAIL", "name": "Intro", "prompt": "Introduce Lightfield in two sentences.", "deliveryMode": "AUTO" },
          { "type": "WAIT", "duration": 3, "unit": "DAY" },
          { "id": "step-3", "type": "EMAIL", "name": "Follow-up", "prompt": "Short follow-up on the intro.", "deliveryMode": "FOR_REVIEW" }
        ]
      },
      "reviewers": { "add": ["mem_cmuyynvgh000b"] }
    }'
```

The two-day wait is gone and a three-day wait took its place with a new `id`; `step-1` and `step-3` kept theirs.

To keep a step’s content, send it back as you read it. Changing a kept step between agent and template follows its content: send a `template` to make it a template step, or a `prompt` with `mode: "AGENT"` to make it an agent step. A kept template step sent with only a `prompt` is refused, so a template is never dropped by accident.

### The version fence

`version` changes on every edit, from the API or from someone working on the same draft in Lightfield. A save or publish whose `expectedVersion` is behind returns `409`:

```
{
  "error": {
    "type": "conflict",
    "message": "The sequence draft changed since it was read. Retrieve the draft and retry with its version."
  }
}
```

Retrieve the draft, look at what changed, and retry with the current `version`. The draft is never half-saved: a refused write changes nothing.

## Test it on one contact

A test runs the sequence on one real contact without sending anything. Steps run back to back with waits skipped, and each message is composed from the contact’s data, so you can read what an agent step writes and what a template’s variables and fallbacks produce before anyone receives it.

A test is not an enrollment. It does not appear in enrollments, messages or performance, and the contact can still be enrolled. It does need a sender that can send for each channel the steps use, since sender variables and the signature are filled from it.

Terminal window

```
curl https://api.lightfield.app/v1/sequences/seq_cmuyyy75r0016/tests \
    -H 'Content-Type: application/json' \
    -H 'Lightfield-Version: 2026-03-01' \
    -H "Authorization: Bearer $API_KEY" \
    -d '{ "contactId": "con_cmuyyy6zv0007", "expectedVersion": "Y211eXl5NzV3MDAxOGl0OTVyMGluZGxtcDoz" }'
```

```
{ "id": "sqe_t3stq8m2vk1o", "status": "RUNNING", "target": "DRAFT", "contactId": "con_cmuyyy6zv0007" }
```

The test runs the open draft, or the published version when no draft is open; `target` says which. With `expectedVersion`, it runs exactly the draft you last read, and returns `409` if the draft has changed or is no longer open, for example after a publish or discard. To test the published version, leave `expectedVersion` out. Editing the draft after a start does not change a test already running.

Starting a test is not idempotent: it does not read `Idempotency-Key`, so a retry after a timeout starts a second test. Keep the `id` from the response. It is the only way to read the result, since tests are not listed anywhere.

The request returns right away. Poll the test every few seconds until `status` is `COMPLETED` or `FAILED`, and give up after a few minutes. While it is `RUNNING`, `steps` fill in as they finish; act only on the final result.

Terminal window

```
curl https://api.lightfield.app/v1/sequences/seq_cmuyyy75r0016/tests/sqe_t3stq8m2vk1o \
    -H 'Lightfield-Version: 2026-03-01' \
    -H "Authorization: Bearer $API_KEY"
```

```
{
  "id": "sqe_t3stq8m2vk1o",
  "status": "COMPLETED",
  "target": "DRAFT",
  "contactId": "con_cmuyyy6zv0007",
  "steps": [
    {
      "stepId": "step-1",
      "name": "Intro",
      "type": "EMAIL",
      "mode": "AGENT",
      "outcome": "COMPOSED",
      "message": { "subject": "Clean CRM data at Acme", "body": "Hi Grace,\n\nLightfield keeps your CRM up to date without manual entry. Open to a short intro?" },
      "error": null
    },
    { "stepId": "step-tf9jgb7g", "name": null, "type": "WAIT", "mode": null, "outcome": "SKIPPED", "message": null, "error": null },
    {
      "stepId": "step-3",
      "name": "Follow-up",
      "type": "EMAIL",
      "mode": "AGENT",
      "outcome": "COMPOSED",
      "message": { "subject": "Following up on Lightfield", "body": "Hi Grace, following up on my note from Tuesday." },
      "error": null
    }
  ]
}
```

Each step has one `outcome`:

| Outcome    | Meaning                                                                                                                                                                               |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `COMPOSED` | `message` holds exactly what this contact would get: `subject` and a Markdown `body` for email, `body` for LinkedIn, with template variables and fallbacks filled in.                 |
| `SKIPPED`  | The step ran and produced nothing. A wait is always `SKIPPED` in a test, and so is a send step with nothing to send, such as an invite another step already sent.                     |
| `FAILED`   | `error` says why: `CONFIGURATION_ERROR` when the sequence or the contact needs a change, such as a template variable that cannot be filled, or `SYSTEM_ERROR` when Lightfield failed. |
| `PENDING`  | The test has not reached the step, including a step a finished test never reached.                                                                                                    |

A step that fails ends the test as `FAILED`, and the steps after it stay `PENDING`. So check each step’s `outcome`, not only the test’s `status`. A follow-up email’s `subject` is the one its step composes; it is sent as a reply in the first email’s thread.

A test of template steps shows the copy with its variables and fallbacks filled in. Here the contact has no first name, so the first email used the fallback, and the follow-up read a field that was deleted after the template was written:

```
{
  "id": "sqe_t3stz4q9hb2m",
  "status": "FAILED",
  "target": "DRAFT",
  "contactId": "con_cmuyyy72u0009",
  "steps": [
    {
      "stepId": "step-1",
      "name": "Intro",
      "type": "EMAIL",
      "mode": "TEMPLATE",
      "outcome": "COMPOSED",
      "message": { "subject": "Acme and clean CRM data", "body": "Hi there,\n\nLightfield keeps your CRM up to date with **no manual entry**. Worth a [15-minute intro](https://lightfield.app/demo)?" },
      "error": null
    },
    { "stepId": "step-2", "name": null, "type": "WAIT", "mode": null, "outcome": "SKIPPED", "message": null, "error": null },
    {
      "stepId": "step-3",
      "name": "Follow-up",
      "type": "EMAIL",
      "mode": "TEMPLATE",
      "outcome": "FAILED",
      "message": null,
      "error": { "code": "CONFIGURATION_ERROR", "message": "The template reads a field that no longer exists: Job title. Edit the step to remove it." }
    }
  ]
}
```

`error.message` is plain language and says what to fix.

A test never sends anything, whatever the steps’ delivery modes, and nothing is added to For review. Agent steps use model credits, as they do when the sequence runs.

A start is refused with:

- `400 invalid_configuration` and `invalidContacts` when the contact does not exist or lacks what a step needs (an email address, a LinkedIn URL), or with `issues` when the draft cannot run;
- `400 no_selectable_sender` when a channel the steps use has no sender that can send, even though nothing is sent;
- `400 permissions_not_granted` when an agent step built in Lightfield requests permissions nobody has granted;
- `409` when `expectedVersion` is not the open draft’s `version`, or no draft is open;
- `429 too_many_requests` when the workspace already has 10 tests running (retry once one is `COMPLETED` or `FAILED`), or has started more than 5 tests a minute (retry after the `Retry-After` header’s seconds). `message` names the limit, and no test is started;
- `403` when the API key does not have `sequences:update`.

Reading a test returns `404` for a test that does not exist or belongs to another sequence, and for an enrollment ID; read enrollments with [Retrieve a sequence enrollment](/api/resources/sequence/methods/retrieveEnrollment/index.md).

## Publish

Publishing turns the draft into the published version.

Terminal window

```
curl https://api.lightfield.app/v1/sequences/seq_cmuyyy75r0016/draft/publish \
    -H 'Content-Type: application/json' \
    -H 'Lightfield-Version: 2026-03-01' \
    -H "Authorization: Bearer $API_KEY" \
    -d '{ "expectedVersion": "Y211eXl5NzV3MDAxOGl0OTVyMGluZGxtcDoz" }'
```

```
{
  "id": "seq_cmuyyy75r0016",
  "status": "DEACTIVATED",
  "channels": ["EMAIL"],
  "definition": { "steps": [ { "id": "step-1", "type": "EMAIL" }, { "id": "step-tf9jgb7g", "type": "WAIT" }, { "id": "step-3", "type": "EMAIL" } ] },
  "reviewers": [{ "id": "mem_cmuyynvg60005", "objectType": "member" }, { "id": "mem_cmuyynvgh000b", "objectType": "member" }],
  "senders": [{ "id": "snd_cmuyynvgn000d", "objectType": "sender" }, { "id": "snd_cmuyynvgq000f", "objectType": "sender" }]
}
```

The response is the sequence, now with a definition, channels and senders. The first publish leaves it `DEACTIVATED`: published, able to take enrollments, not yet sending. The draft is closed, so [Retrieve a sequence draft](/api/resources/sequence/methods/retrieveDraft/index.md) now returns `404 draft_not_found`.

A refused publish lists its problems under `issues` the same way a refused save does. Publish also checks things a save does not: that every reviewer is still an active member, that every sender is still usable, and that the plan covers every channel the steps use.

## Activate and deactivate

Activate starts sending to enrolled contacts.

Terminal window

```
curl -X POST https://api.lightfield.app/v1/sequences/seq_cmuyyy75r0016/activate \
    -H 'Lightfield-Version: 2026-03-01' \
    -H "Authorization: Bearer $API_KEY"
```

```
{
  "id": "seq_cmuyyy75r0016",
  "status": "ACTIVE",
  "firstActivatedAt": "2026-10-08T03:25:04.208Z",
  "lastActivatedAt": "2026-10-08T03:25:04.208Z"
}
```

Activate needs an available sender for each channel the steps use; without one it returns `400 no_selectable_sender` and the sequence stays as it was. Activating an active sequence returns it unchanged, and the same goes for deactivating a paused one.

Deactivate pauses sending. Enrollments keep their place and resume when the sequence is activated again. In both directions the sequence’s `status` changes at once, while the enrollments’ statuses catch up in the background.

## Enroll contacts

Enrollment takes contact IDs to `add`, to `remove`, or both, in one request.

Terminal window

```
curl https://api.lightfield.app/v1/sequences/seq_cmuyyy75r0016/enrollments \
    -H 'Content-Type: application/json' \
    -H 'Lightfield-Version: 2026-03-01' \
    -H "Authorization: Bearer $API_KEY" \
    -d '{ "add": ["con_cmuyyy6zv0007", "con_cmuyyy72u0009"] }'
```

```
{
  "results": [
    { "contactId": "con_cmuyyy6zv0007", "outcome": "enrolled", "enrollmentId": "sqe_vk1olqpc1500" },
    { "contactId": "con_cmuyyy72u0009", "outcome": "enrolled", "enrollmentId": "sqe_ca90sfmok1kf" }
  ]
}
```

Each contact gets one of four outcomes. `enrolled` and `unenrolled` did what they say. `already_enrolled` means the contact had an open enrollment, which was left alone. `no_active_enrollment` means a removal found nothing to cancel. Additions are checked as a group: if any contact cannot be enrolled, the request returns `400 invalid_configuration` with the contact and reason under `invalidContacts`, and nobody is enrolled.

Right after enrolling, the enrollment is on the first step:

Terminal window

```
curl https://api.lightfield.app/v1/sequences/seq_cmuyyy75r0016/enrollments/sqe_vk1olqpc1500 \
    -H 'Lightfield-Version: 2026-03-01' \
    -H "Authorization: Bearer $API_KEY"
```

```
{
  "id": "sqe_vk1olqpc1500",
  "contactId": "con_cmuyyy6zv0007",
  "status": "ACTIVE",
  "currentStepId": "step-1",
  "remainingSends": 2,
  "enrolledBy": "mem_cmuyyicgi000b",
  "enrolledAt": "2026-10-08T03:26:11.902Z",
  "lastActivityAt": null,
  "lastActivityKind": null,
  "repliedAt": null,
  "completedAt": null,
  "cancelledAt": null
}
```

`currentStepId` matches a step in the published `definition.steps`, and `remainingSends` counts the email and LinkedIn steps still ahead. You can enroll into a deactivated sequence; the contacts wait and are sent to once it is activated.

To take a contact out, put their ID in `remove`. Their remaining steps stop, and the enrollment stays readable with status `CANCELLED` and a `cancelledAt`. A contact whose enrollment ended, for any reason, can be enrolled again with `add`.

## Change a sequence that is running

Editing a published sequence is the same draft loop. A save with no `expectedVersion` opens a new draft from the published version:

Terminal window

```
curl https://api.lightfield.app/v1/sequences/seq_cmuyyy75r0016/draft \
    -H 'Content-Type: application/json' \
    -H 'Lightfield-Version: 2026-03-01' \
    -H "Authorization: Bearer $API_KEY" \
    -d '{
      "definition": {
        "steps": [
          { "id": "step-1", "type": "EMAIL", "name": "Intro", "prompt": "Introduce Lightfield in one sentence and ask for 15 minutes.", "deliveryMode": "AUTO" },
          { "id": "step-tf9jgb7g", "type": "WAIT", "duration": 3, "unit": "DAY" },
          { "type": "EMAIL", "name": "Breakup", "prompt": "Polite last note.", "deliveryMode": "AUTO" }
        ]
      }
    }'
```

```
{
  "version": "Y211eXoyM3BzMDAxa2l0OTVxYTFibmtoYjox",
  "publishWarnings": [
    {
      "code": "step_removed",
      "severity": "destructive",
      "stepIds": ["step-3"],
      "message": "\"Follow-up\" was removed. Contacts that have not reached it will skip it, and any message waiting for review at that step is recalled."
    },
    {
      "code": "automatic_send_step_inserted",
      "severity": "destructive",
      "stepIds": ["step-aus2eesh"],
      "message": "\"Breakup\" was added and sends automatically. Contacts that have not passed this point may receive it."
    }
  ]
}
```

`publishWarnings` is the part to read before publishing a live sequence. It describes what the change does to contacts already enrolled; `destructive` means some of them may skip, repeat or receive a step they would not have. A publish never recalls a message that is already queued or being sent. While the draft is open, the sequence keeps running on the published version, and [Retrieve a sequence](/api/resources/sequence/methods/retrieve/index.md) still shows the old steps.

Publish when you are happy with the warnings. The sequence stays `ACTIVE`, and enrolled contacts continue on the new version from wherever they are. If you change your mind, [Discard a sequence draft](/api/resources/sequence/methods/discardDraft/index.md) with the draft’s `version` throws the draft away and leaves the published version untouched.

## Watch it run

Three reads cover what is happening. Enrollments, filtered by status, show where each contact is:

Terminal window

```
curl "https://api.lightfield.app/v1/sequences/seq_cmuyyy75r0016/enrollments?status[equal]=AWAITING_REVIEW" \
    -H 'Lightfield-Version: 2026-03-01' \
    -H "Authorization: Bearer $API_KEY"
```

Messages are every email and LinkedIn message the sequence sent, plus each enrollment’s first reply. Filter by `direction` to see replies, or by `status` to see what is still waiting to go out:

Terminal window

```
curl "https://api.lightfield.app/v1/sequences/seq_cmuyyy75r0016/messages?direction[equal]=INBOUND" \
    -H 'Lightfield-Version: 2026-03-01' \
    -H "Authorization: Bearer $API_KEY"
```

```
{
  "object": "list",
  "data": [
    {
      "id": "sqm_i_cmV3bHk6c3FlX3ZrMW9scXBjMTUwMDpFTUFJTA",
      "direction": "INBOUND",
      "enrollmentId": "sqe_vk1olqpc1500",
      "contactId": "con_cmuyyy6zv0007",
      "sequenceStepId": "step-1",
      "sequenceStepType": "EMAIL",
      "channel": "EMAIL",
      "createdAt": "2026-10-09T14:02:51.000Z",
      "receivedAt": "2026-10-09T14:02:51.000Z",
      "reference": { "objectType": "email", "id": "eml_cmuz1k2p40012" }
    }
  ],
  "totalCount": 1
}
```

List items carry no content. Retrieve one to read it: [Retrieve a sequence message](/api/resources/sequence/methods/retrieveMessage/index.md) adds `content` with the subject and plain-text body, readable with `sequences:read`. The `reference` points at the underlying record in the [Emails](/api/resources/email/index.md) or [Messages](/api/resources/message/index.md) API, which keep their own scopes.

Performance totals the sequence’s lifetime engagement by step type and per step:

Terminal window

```
curl https://api.lightfield.app/v1/sequences/seq_cmuyyy75r0016/performance \
    -H 'Lightfield-Version: 2026-03-01' \
    -H "Authorization: Bearer $API_KEY"
```

```
{
  "byStepType": {
    "EMAIL": { "sent": 2, "delivered": 2, "opened": 1, "replied": 1, "bounced": 0, "openRate": 0.5, "replyRate": 0.5, "bounceRate": 0, "currentEnrollments": 1 },
    "WAIT": { "currentEnrollments": 0 }
  },
  "perStep": [
    { "sequenceStepId": "step-1", "sequenceStepType": "EMAIL", "sent": 2, "delivered": 2, "opened": 1, "replied": 1, "bounced": 0, "openRate": 0.5, "replyRate": 0.5, "bounceRate": 0, "currentEnrollments": 1 },
    { "sequenceStepId": "step-tf9jgb7g", "sequenceStepType": "WAIT", "currentEnrollments": 0 },
    { "sequenceStepId": "step-aus2eesh", "sequenceStepType": "EMAIL", "sent": 0, "delivered": 0, "opened": 0, "replied": 0, "bounced": 0, "openRate": null, "replyRate": null, "bounceRate": null, "currentEnrollments": 0 }
  ]
}
```

`currentEnrollments` is the live view: how many contacts are on each step right now. The counts are lifetime and include steps that were later removed, as long as they sent something. Rates are `null` until there is something to divide by.

## Discard

Discarding a draft needs its `version`, like any other draft write. On a published sequence it only closes the draft:

```
{ "sequenceId": "seq_cmuyyy75r0016", "sequenceDeleted": false }
```

On a sequence that has never been published, the draft is all there is, so discarding it deletes the sequence too, and `sequenceDeleted` is `true`. After that the sequence returns `404`.

## When something is refused

Every sequence write is all-or-nothing. A `400` means the request itself needs to change, and the body says where: `issues` for a draft, `invalidContacts` for an enrollment, or `message` and `param` otherwise. A `409` means the draft or the sequence moved since you read it; read it again and retry with what you see. [Sequence errors](/using-the-api/errors/#sequence-errors/index.md) has every code and what to do about it.
