# Campaigns

> Source: https://developers.anymail.watch/campaigns/

A campaign is a sequence, a list of prospects and the mailboxes it sends from. The API covers the whole life of a campaign: create a draft, set its mailboxes, audience and schedule, write the [steps](/campaign-steps/), start it, read the [statistics](/campaign-stats/), pause, resume and stop it. Each call runs the same function as the matching button in the app, with the same checks.

| Method | Path | Action |
|---|---|---|
| `GET` | `/api/v1/campaigns` | List campaigns with progress |
| `POST` | `/api/v1/campaigns` | Create a draft |
| `GET` | `/api/v1/campaigns/{id}` | Get a campaign with settings, steps and statistics |
| `PATCH` | `/api/v1/campaigns/{id}` | Change the name, mailboxes, audience, limits and schedule |
| `DELETE` | `/api/v1/campaigns/{id}` | Delete a draft |
| `POST` | `/api/v1/campaigns/{id}/start` | Start a draft or resume a paused campaign |
| `POST` | `/api/v1/campaigns/{id}/stop` | End a campaign for good |
| `GET` | `/api/v1/campaigns/{id}/prospects` | List the enrolments of a campaign |
| `POST` | `/api/v1/campaigns/{id}/prospects` | Enrol existing prospects |
| `POST` | `/api/v1/campaigns/{id}/pause` | Pause a running campaign |
| `POST` | `/api/v1/campaigns/{id}/resume` | Resume a paused campaign |

Write calls need a token with the `write` scope. See [Authentication](/authentication/#scopes).

## From zero to a running campaign

1. `GET /mailboxes` and `GET /domains` to pick mailboxes whose domain passes SPF, DKIM and DMARC. See [Domains](/domains/).
2. `GET /prospects?q=…` to find the audience. See [Prospects](/prospects/).
3. `POST /campaigns` with `name`, `mailbox_ids` and `prospect_ids`.
4. `POST /campaigns/{id}/steps` once for each email. See [Steps and variants](/campaign-steps/).
5. `POST /campaigns/{id}/start`. This sends real email.
6. `GET /campaigns/{id}/stats` to follow the results. See [Statistics](/campaign-stats/).

## List campaigns

| Parameter | Type | Notes |
|---|---|---|
| `status` | string | One of `draft`, `active`, `paused`, `completed`. This is the raw status column. |
| `folder_id` | UUID | Campaigns in one folder. |
| `archived` | string | `true` or `false`. |
| `limit` | integer | 1 to 100. Default 50. |
| `cursor` | string | See [Pagination](/pagination/). |

```bash
curl "https://app.anymail.watch/api/v1/campaigns?status=active" \
  -H "Authorization: Bearer $ANYMAIL_TOKEN"
```

**Response `200`**

```json
{
  "data": [
    {
      "id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "name": "Q3 outbound",
      "status": "active",
      "display_status": "running",
      "pause_reason": null,
      "campaign_type": "outbound",
      "folder": { "id": "8d1c0e2a-7b3f-4c5d-9e6a-1f2b3c4d5e6f", "name": "Sales" },
      "steps": 4,
      "progress": { "planned": 400, "done": 312, "enrolled": 100, "finished": 61, "pct": 78 },
      "created_at": "2026-08-10T09:00:00.000+00:00",
      "started_at": "2026-08-11T07:30:00.000+00:00",
      "archived_at": null
    }
  ],
  "next_cursor": null
}
```

- `status` is the raw column. `display_status` is one of `draft`, `running`, `paused`, `done`, `archived` or `other`, and matches the badge in the app. An `active` campaign at 100% progress with at least one enrolment is `done`. `archived_at` takes precedence.
- `progress.planned` is the number of enrolled prospects multiplied by the number of email steps. `progress.done` counts the steps passed by active enrolments plus all steps of stopped and completed enrolments. `pct` runs from 0 to 100.
- Campaigns have no `updated_at`. The timestamps are `created_at`, `started_at` and `archived_at`.

**Errors:** `400` for an invalid `status`, `folder_id`, `archived`, `limit` or `cursor`.

## Create a draft

`POST /api/v1/campaigns` creates a campaign with status `draft`, like **New campaign** in the app. All fields are optional. An unknown field returns `400`.

| Field | Type | Notes |
|---|---|---|
| `name` | string | Up to 200 characters. Default `Untitled campaign`. |
| `mailbox_ids` | array of UUID | Sending mailboxes, in rotation order. Up to 1000. A draft may have none. |
| `prospect_ids` | array of UUID | The audience. Up to 1000. Duplicates are merged. Nobody is enrolled until the start. |
| `campaign_type` | string or null | One of `cold_outreach`, `newsletter`, `webinar_invite`, `offer`. |

```bash
curl -X POST "https://app.anymail.watch/api/v1/campaigns" \
  -H "Authorization: Bearer $ANYMAIL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Q4 outbound","mailbox_ids":["b2c3d4e5-0000-4000-8000-000000000001"],"prospect_ids":["7c9e6679-7425-40de-944b-e07fc1f90ae7"]}'
```

**Response `201`** returns the campaign in the list shape, with `"status": "draft"`, `"steps": 0` and a new `id`. New campaigns get the default settings of the app.

**Errors:** `400` with `` Unknown field(s): … ``, `` `mailbox_ids` must be an array of up to 1000 UUIDs. ``, `One of the picked mailboxes isn't a mailbox of this workspace.` or `Pick one of the listed campaign types, or leave it empty for no type.`

## Update a campaign

`PATCH /api/v1/campaigns/{id}` changes only the fields you send. Send at least one.

| Field | Type | Notes |
|---|---|---|
| `name` | string | Not empty. Up to 200 characters. |
| `mailbox_ids` | array of UUID | Replaces the mailboxes and their order. Not allowed while the campaign is running: pause it first. A paused or completed campaign needs at least one mailbox. |
| `prospect_ids` | array of UUID | Replaces the audience. Draft only. To add people to a running campaign, use [Enrol prospects](#enrol-prospects). |
| `campaign_type` | string or null | As for create. |
| `daily_enroll_limit` | integer or null | New enrolments per day, 1 or more. `null` means no limit. |
| `timezone` | string or null | IANA time zone of the send window, for example `Europe/Warsaw`. `null` uses the workspace value. |
| `use_prospect_timezone` | boolean | Send in the time zone of each prospect when it is known. |
| `schedule` | object or null | `{ "start_hour": 9, "end_hour": 17, "send_days": [1,2,3,4,5] }`. Hours 0 to 24, start before end. Days 0 (Sunday) to 6 (Saturday), at least one. `null` inherits the schedule of the workspace. |

```bash
curl -X PATCH "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" \
  -H "Authorization: Bearer $ANYMAIL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"daily_enroll_limit":30,"schedule":{"start_hour":9,"end_hour":17,"send_days":[1,2,3,4,5]}}'
```

**Response `200`** returns the campaign in the list shape. Read the settings with `GET /campaigns/{id}`.

**Errors**

| Status | Message |
|---|---|
| `400` | `` Send at least one of: … ``, `` Unknown field(s): … ``, ``` `name` can't be empty. ```, `Daily enroll has to be a whole number of 1 or more, or null for no limit.`, `<zone> is not a timezone this server knows.`, a schedule message such as `Sending days have to be whole numbers between 0 (Sunday) and 6 (Saturday).` |
| `404` | `Campaign not found.` |
| `409` | `The audience can only be set on a draft; enrol more people with POST /campaigns/{id}/prospects.` |
| `409` | `Pause the campaign before changing its sending mailboxes.` or `Pick at least one sending mailbox: a campaign with none can't send.` |

## Delete a draft

`DELETE /api/v1/campaigns/{id}` deletes a campaign with status `draft`. The response is `204` with no body. A campaign that has sent anything is a record of what went out, so the API does not delete it. Stop it instead.

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

**Errors:** `404` with `Campaign not found.`, `409` with `Only a draft can be deleted; stop the campaign instead.`

## Start a campaign

`POST /api/v1/campaigns/{id}/start` has no body. It runs the **Start** button of the app. On a draft it checks the campaign, enrols the audience and schedules step 1. On a paused campaign it works like [Resume](#resume-a-campaign). **This sends real email.**

The checks, in this order:

1. The campaign is not already running and not completed.
2. It has at least one sending mailbox.
3. No step is empty. A step needs a subject or a body.
4. The audience has prospects.
5. Every prospect has every field used in the steps and in the active A/B variants.
6. On the first start: no prospect is active in another campaign of the workspace, unless the campaign allows duplicates in the app.

When a check fails, nothing is enrolled and the campaign keeps its status.

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

**Response `200`**

```json
{
  "campaign": { "id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "Q4 outbound", "status": "active", "display_status": "running" },
  "enrolled": 48
}
```

`campaign` is the list shape, shortened here. `enrolled` is the number of enrolments this call created. It is `0` on a resume.

**Errors**

| Status | Message |
|---|---|
| `404` | `Campaign not found.` |
| `409` | `Campaign is already running.`, `A completed campaign can't be started again; duplicate it in the app.` |
| `409` | `This campaign has no sending mailbox.`, `Add at least one step first.`, `This campaign's audience has no prospects.` or a message about an empty step. |
| `409` | `<n> prospect(s) are missing a field used in the copy. Fix them or narrow the audience: nothing was enrolled.` The body adds `issues`, as in [Resume](#resume-a-campaign). |
| `409` | `<n> prospect(s) are already active in another campaign. …` The body adds `conflicts`. |

```json
{
  "error": "1 prospect(s) are already active in another campaign. Two sequences to the same person at once is what gets a domain blocked: remove them here or stop them there. Nothing was enrolled.",
  "conflicts": [
    {
      "prospectId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      "email": "ada@example.com",
      "campaigns": [{ "id": "8a1b2c3d-0000-4000-8000-000000000009", "name": "Q3 outbound", "nextStep": 3 }]
    }
  ]
}
```

## Stop a campaign

`POST /api/v1/campaigns/{id}/stop` has no body. It does what **Stop** does in the app: every active enrolment becomes `stopped` with the reason `Stopped: the campaign was ended by the operator.`, and the campaign becomes `completed`. This cannot be undone. To stop sending for a while, [pause](#pause-a-campaign) instead.

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

**Response `200`** returns the campaign in the list shape with `"status": "completed"`.

**Errors:** `404` with `Campaign not found.`, `409` with `A draft has nothing to stop; delete it instead.` or `Campaign is already completed.`

## Get a campaign

`GET /api/v1/campaigns/{id}` returns the row above plus settings, steps, mailboxes and statistics.

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

**Response `200`** (the fields of the list row are shortened)

```json
{
  "id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "name": "Q3 outbound",
  "status": "active",
  "display_status": "running",
  "settings": {
    "daily_enroll_limit": 20,
    "timezone": null,
    "use_prospect_timezone": true,
    "window_start_hour": 9,
    "window_end_hour": 17,
    "send_on_weekends": false,
    "send_days": null,
    "business_days_only": false
  },
  "step_list": [
    { "id": "a1b2c3d4-0000-4000-8000-000000000001", "position": 1, "delay_days": 0, "subject": "Quick question" }
  ],
  "mailboxes": [
    { "id": "b2c3d4e5-0000-4000-8000-000000000001", "email": "sales@example.com" }
  ],
  "stats": {
    "sent": 312,
    "delivered": 305,
    "bounced": 7,
    "replied": 19,
    "unsubscribed": 2,
    "opened": null,
    "clicked": null,
    "reply_rate": 0.0609,
    "replies": { "total": 19, "interested": 6, "neutral": 9, "notInterested": 4, "autoreplied": 3 },
    "prospects": { "enrolled": 100, "queued": 31, "finished": 61, "invalid": null },
    "tracking_enabled": false
  }
}
```

The statistics come from the same source as the **Stats** tab in the app.

- `delivered` is `sent` minus `bounced`.
- `replies` leaves out autoresponders and reports them separately as `autoreplied`.
- `queued` is the number of active enrolments whose next step is in the future. `finished` is the number of completed enrolments.
- `invalid` counts prospects with an `invalid` verification result. It is `null` when nobody in this campaign has been verified.
- `opened` and `clicked` are `null` until the tracking domain of the workspace is verified, and `tracking_enabled` is `false`. `null` means the value is not measured. It does not mean zero.
- `settings.window_start_hour` and `settings.window_end_hour` can be `null`, which means they inherit the workspace value.

**Errors:** `404` with `{"error":"Campaign not found."}`. This includes a campaign from another workspace and an id that is not a UUID.

## List enrolments

`GET /api/v1/campaigns/{id}/prospects` returns one row for each person enrolled in the campaign.

| Parameter | Type | Notes |
|---|---|---|
| `status` | string | One of `active`, `stopped`, `completed`. |
| `limit` | integer | 1 to 100. Default 50. |
| `cursor` | string | See [Pagination](/pagination/). |

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

**Response `200`**

```json
{
  "data": [
    {
      "id": "e0a1c2d3-0000-4000-8000-000000000001",
      "prospect": {
        "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
        "email": "ada@example.com",
        "first_name": "Ada",
        "last_name": null,
        "company_id": "1b4e28ba-2fa1-11d2-883f-0016d3cca427"
      },
      "status": "stopped",
      "current_step": 3,
      "next_step_at": null,
      "outcome": { "label": "Stopped", "negative": true, "reason": "Bounced: 550 5.1.1 user unknown" },
      "enrolled_at": "2026-08-11T07:30:00.000+00:00"
    }
  ],
  "next_cursor": null
}
```

- `id` is the id of the enrolment. The id of the prospect is in `prospect.id`.
- `current_step` is the number of the step that goes out next. `1` means nothing has gone out yet.
- `outcome.label` is `Active`, `Stopped` or `Completed`, the word on the badge in the app. `outcome.negative` is `true` when the enrolment stopped because of a bounce, an unsubscribe or a suppression. `outcome.reason` is the last error recorded on the enrolment.

**Errors:** `404` with `Campaign not found.` The API checks the campaign before it reads enrolments. `400` for an invalid `status`, `limit` or `cursor`.

## Enrol prospects

`POST /api/v1/campaigns/{id}/prospects` enrols existing prospects of the workspace in a `draft` or `active` campaign. It runs the same function as **Add prospects** on the campaign page, so the rules apply to each person separately. The call does not fail as a whole.

**Body** (JSON, up to 64 KiB). Send exactly one of these fields, with 1 to 200 items. Duplicates in the list are merged and the first one wins. Addresses are compared trimmed and lower-cased.

| Field | Type | Notes |
|---|---|---|
| `prospect_ids` | array of UUID | Prospect ids. |
| `emails` | array of string | Email addresses of existing prospects. |

```bash
curl -X POST "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/prospects" \
  -H "Authorization: Bearer $ANYMAIL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"emails":["ada@example.com","bob@example.com","nobody@example.com"]}'
```

**Response `200`**

```json
{
  "campaign": { "id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "Q3 outbound", "status": "active" },
  "data": [
    { "prospect_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "email": "ada@example.com", "result": "enrolled" },
    { "prospect_id": "1b4e28ba-2fa1-11d2-883f-0016d3cca427", "email": null, "result": "already-enrolled" },
    { "prospect_id": null, "email": "nobody@example.com", "result": "not-found" }
  ],
  "summary": {
    "requested": 3,
    "enrolled": 1,
    "already-enrolled": 1,
    "suppressed": 0,
    "conflict": 0,
    "missing-field": 0,
    "anonymized": 0,
    "not-found": 1
  }
}
```

| `result` | Meaning |
|---|---|
| `enrolled` | The prospect entered the campaign. Step 1 is scheduled for now, with a mailbox from the rotation of the campaign. |
| `already-enrolled` | The prospect is already in this campaign. |
| `suppressed` | The address is on the suppression list of the workspace or on the global list. |
| `conflict` | The prospect is active in another campaign of the workspace. |
| `missing-field` | A snippet used in a subject or body has no value for this prospect. |
| `anonymized` | The prospect was anonymised after an erasure request, so it cannot be enrolled. |
| `not-found` | No such prospect in this workspace. This includes the id of another workspace. |

`data` keeps the order of the request after duplicates are merged. For `not-found`, `prospect_id` is `null` when you sent `emails`, and `email` is `null` when you sent `prospect_ids`. `email` is also `null` for `already-enrolled`.

The status is `200` even when nobody was enrolled. Read the numbers in `summary`.

**Errors**

| Status | Message |
|---|---|
| `400` | The body errors listed on [Errors](/errors/), `` Provide exactly one of `prospect_ids` or `emails`. ``, `` `prospect_ids` must be a non-empty array of up to 200 UUIDs. ``, `` `emails` must be a non-empty array of up to 200 email addresses. ``, `` `prospect_ids[3]` must be a UUID. ``, `` `emails[2]` must be a non-empty string. `` |
| `404` | `Campaign not found.` |
| `409` | `This campaign can't take new prospects in its current state (paused).`, `Add step 1 before enrolling anyone.` or `This campaign has no sending mailbox.` |

To create a prospect and enrol it in one call, use `POST /prospects?campaign=<id>`. See [Prospects](/prospects/).

## Pause a campaign

`POST /api/v1/campaigns/{id}/pause` has no body. It does what **Pause** does in the app: `status` becomes `paused` and `pause_reason` becomes `Paused by the operator.` Enrolments stay in place. The engine sends nothing from a paused campaign, and a resume continues each sequence where it stopped.

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

**Response `200`** returns the campaign in the list shape, without the `data` wrapper, with `"status": "paused"` and `"display_status": "paused"`.

**Errors:** `404` with `Campaign not found.` A `409` explains why the campaign cannot be paused. Only a running campaign can be paused, so a draft, an already paused campaign and a completed campaign each return `409`.

## Resume a campaign

`POST /api/v1/campaigns/{id}/resume` has no body. It follows the **Resume campaign** path of the app, with the same checks:

- The campaign has a sending mailbox.
- The campaign has at least one step.
- The audience has prospects.
- Every prospect of the audience has all the fields used in the steps and in the active A/B variants.

The write sets `status` to `active`, sets `started_at` to the current time and clears `pause_reason`. It does not create or undo enrolments.

Only a paused campaign can be resumed. To start a draft, use [Start](#start-a-campaign). A completed campaign cannot run again. Duplicate it in the app.

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

**Response `200`** returns the campaign in the list shape with `"status": "active"`.

**Errors**

| Status | Message |
|---|---|
| `404` | `Campaign not found.` |
| `409` | `Campaign is already running.` |
| `409` | `Only a paused campaign can be resumed` followed by the reason: the campaign is a draft (start it from the app) or completed (run it again from the app). |
| `409` | `This campaign has no sending mailbox.`, `Add at least one step first.` or `This campaign's audience has no prospects.` |
| `409` | A message that starts with the number of prospects missing a field used in the copy. The campaign stays paused. The body adds an `issues` array. |

```json
{
  "error": "2 prospect(s) are missing a field used in the copy. Fix them or narrow the audience — the campaign stays paused.",
  "issues": [
    { "email": "ada@example.com", "missing": ["first_name"] }
  ]
}
```

## Related

- [Steps and variants](/campaign-steps/)
- [Statistics](/campaign-stats/)
- [Prospects](/prospects/)
- [Mailboxes](/mailboxes/)
