# Steps and variants

> Source: https://developers.anymail.watch/campaign-steps/

A step is one email of the sequence. Each step has one to four variants, labelled `A` to `D`, for A/B tests. The content of a step is its variant `A`.

| Method | Path | Action |
|---|---|---|
| `GET` | `/api/v1/campaigns/{id}/steps` | List the steps with their variants |
| `POST` | `/api/v1/campaigns/{id}/steps` | Add a step at the end |
| `PATCH` | `/api/v1/campaigns/{id}/steps/{stepId}` | Edit a step |
| `DELETE` | `/api/v1/campaigns/{id}/steps/{stepId}` | Delete a step |
| `POST` | `/api/v1/campaigns/{id}/steps/{stepId}/variants` | Add a variant |
| `PATCH` | `/api/v1/campaigns/{id}/steps/{stepId}/variants/{label}` | Edit a variant |
| `DELETE` | `/api/v1/campaigns/{id}/steps/{stepId}/variants/{label}` | Delete a variant |

## How writes work

Every write reads the whole sequence, changes one element and saves the whole sequence with the function of the sequence editor in the app. The validation of variants, the cleaning of HTML, the step schedules and the other nodes of the sequence (tasks and conditions made in the app) follow the editor rules. The API has no content rules of its own.

- Writes are allowed on a `draft` or a `paused` campaign. A running campaign returns `409`. [Pause](/campaigns/#pause-a-campaign) it first.
- Every write returns the whole sequence in the shape of `GET /steps`.
- A step is identified by its position, as in the editor. After you delete step 2, the old step 3 becomes step 2 and takes the `id` of the old step 2. Read the ids again from the response after a delete.
- Snippets such as `{{first_name}}` and `{{company}}` are filled from the prospect when the email goes out. A missing value blocks the [start](/campaigns/#start-a-campaign).

## List steps

```bash
curl "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/steps" \
  -H "Authorization: Bearer $ANYMAIL_TOKEN"
```

**Response `200`**

```json
{
  "data": [
    {
      "id": "a1b2c3d4-0000-4000-8000-000000000001",
      "position": 1,
      "name": null,
      "delay_days": 0,
      "subject": "Quick question, {{first_name}}",
      "body": "Hi {{first_name}},\n\nAre you the right person to talk to about outbound at {{company}}?",
      "content_format": "plain",
      "append_signature": true,
      "preview_text": null,
      "schedule": null,
      "variants": [
        {
          "label": "A",
          "subject": "Quick question, {{first_name}}",
          "body": "Hi {{first_name}},\n\nAre you the right person to talk to about outbound at {{company}}?",
          "content_format": "plain",
          "append_signature": true,
          "active": true,
          "preview_text": null
        },
        {
          "label": "B",
          "subject": "{{first_name}}, a question about {{company}}",
          "body": "Hi {{first_name}},\n\nAre you the right person to talk to about outbound at {{company}}?",
          "content_format": "plain",
          "append_signature": true,
          "active": true,
          "preview_text": null
        }
      ]
    }
  ]
}
```

| Field | Notes |
|---|---|
| `delay_days` | Days after the previous step. Step 1 usually has `0`. |
| `subject` | An empty subject on a follow-up keeps the email in the thread of the previous one. |
| `content_format` | `plain` or `html`. HTML is cleaned when it is saved. |
| `append_signature` | Add the signature of the sending mailbox. |
| `schedule` | The send window of this step, or `null` to follow the campaign schedule. |
| `variants[].active` | An inactive variant is kept but not sent. |

**Errors:** `404` with `Campaign not found.`

## Add a step

`POST /api/v1/campaigns/{id}/steps` adds a step at the end of the sequence. Without `delay_days`, the first step gets `0` and later steps get `2`.

**Body** (JSON, up to 64 KiB). All fields are optional. An unknown field returns `400`.

| Field | Type | Notes |
|---|---|---|
| `subject` | string | |
| `body` | string | |
| `name` | string or null | A label for the step in the app. |
| `delay_days` | integer | 0 or more. |
| `content_format` | string | `plain` or `html`. |
| `append_signature` | boolean | |
| `preview_text` | string or null | The preview line of the inbox. |
| `schedule` | object or null | A step schedule in the shape that `GET` returns. |

```bash
curl -X POST "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/steps" \
  -H "Authorization: Bearer $ANYMAIL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"subject":"","body":"Hi {{first_name}}, a short follow-up on my last note.","delay_days":3}'
```

**Response `201`** returns the whole sequence.

**Errors**

| Status | Message |
|---|---|
| `400` | `` Unknown field(s): … ``, `` `delay_days` must be a number. ``, `` `content_format` must be plain or html. `` |
| `404` | `Campaign not found.` |
| `409` | `` Steps can be edited only on a draft or a paused campaign — this one is active. Pause it first. `` |
| `409` | A rule of the sequence editor, for example `Add at least one email step with a subject and a body.` or `Step 2 schedule: …` |

## Edit a step

`PATCH /api/v1/campaigns/{id}/steps/{stepId}` takes the fields of the add call. `subject`, `body`, `content_format`, `append_signature` and `preview_text` change variant `A`. Variants `B` to `D` stay as they are. `name`, `delay_days` and `schedule` change the step.

```bash
curl -X PATCH "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/steps/a1b2c3d4-0000-4000-8000-000000000002" \
  -H "Authorization: Bearer $ANYMAIL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"delay_days":4}'
```

**Response `200`** returns the whole sequence. **Errors:** as for add, plus `404` with `Step not found.`

## Delete a step

`DELETE /api/v1/campaigns/{id}/steps/{stepId}` removes a step. The steps after it move up one position. The response is `200` with the whole sequence.

```bash
curl -X DELETE "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/steps/a1b2c3d4-0000-4000-8000-000000000002" \
  -H "Authorization: Bearer $ANYMAIL_TOKEN"
```

**Errors:** `404` with `Campaign not found.` or `Step not found.`, `409` with `A campaign needs at least one step; edit this one instead.`

## Add a variant

`POST /api/v1/campaigns/{id}/steps/{stepId}/variants` adds the next free label: `B`, then `C`, then `D`. Fields you omit are copied from the first variant. A new variant is active unless you send `"active": false`.

| Field | Type |
|---|---|
| `subject` | string |
| `body` | string |
| `content_format` | `plain` or `html` |
| `append_signature` | boolean |
| `preview_text` | string or null |
| `active` | boolean |

```bash
curl -X POST "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/steps/a1b2c3d4-0000-4000-8000-000000000001/variants" \
  -H "Authorization: Bearer $ANYMAIL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"subject":"{{first_name}}, a question about {{company}}"}'
```

**Response `201`** returns the whole sequence.

**Errors:** `404` with `Step not found.`, `409` with `A step can have at most 4 variants.`

## Edit or delete a variant

`PATCH /api/v1/campaigns/{id}/steps/{stepId}/variants/{label}` takes the fields of the add call. `{label}` is `A` to `D` and is not case-sensitive. To stop sending a variant without losing it, send `{"active": false}`.

`DELETE /api/v1/campaigns/{id}/steps/{stepId}/variants/{label}` removes a variant. The last variant of a step cannot be deleted. Delete the step instead.

```bash
curl -X PATCH "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/steps/a1b2c3d4-0000-4000-8000-000000000001/variants/B" \
  -H "Authorization: Bearer $ANYMAIL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"active":false}'
```

Both return `200` with the whole sequence.

**Errors:** `404` with `Variant not found.`, `409` with `A step needs at least one variant; delete the step instead.`

## Related

- [Campaigns](/campaigns/)
- [Statistics](/campaign-stats/)
- [MCP server](/mcp-server/)
