# Campaigns API

Create email campaigns, point them at a list, schedule them or send them now, and read their results. All routes need a bearer API key (see [API Overview](/docs/api/overview)).

## The campaign object

```json
{
  "id": "clx...",
  "name": "October newsletter",
  "subject": "What's new this month",
  "status": "SENT",
  "listId": "clx...",
  "scheduledAt": null,
  "sentAt": "2026-10-04T14:00:00.000Z",
  "createdAt": "2026-10-03T09:00:00.000Z",
  "stat": { "sent": 1200, "opened": 540, "clicked": 130, "bounced": 4, "unsubscribed": 2 }
}
```

`stat` is `null` until the campaign has stats. `stat.unsubscribed` is not currently incremented by the unsubscribe flow, so it can read 0 even when people have opted out. Read opt-outs from contacts (status `UNSUBSCRIBED`) instead.

A campaign created through the API stores the HTML you send in `body`. It is sent as written, with the unsubscribe footer and tracking added at send time. To design with blocks, use the dashboard builder or the [AI connector](/docs/ai/design-emails).

Limits: `name` up to 200 characters, `subject` up to 998 characters, `body` up to 512 KB.

## List campaigns

```text
GET /api/v1/campaigns
```

| Query    | Meaning                                                  |
| -------- | -------------------------------------------------------- |
| `limit`  | Page size, default 20, maximum 100                       |
| `after`  | Cursor from the previous page                            |
| `status` | `DRAFT`, `SCHEDULED` or `SENT`. Anything else is a `422` |
| `q`      | Case-insensitive search in name and subject              |

## Create a campaign

```text
POST /api/v1/campaigns
```

```json
{
  "name": "October newsletter",
  "subject": "What's new this month",
  "body": "<html>...</html>",
  "listId": "clx..."
}
```

`name`, `subject` and `body` are required. `listId` is optional and must be a list in your workspace (`404` otherwise). The campaign is created as a `DRAFT` and nothing is sent.

## Get, update and delete

```text
GET    /api/v1/campaigns/:id
PATCH  /api/v1/campaigns/:id
DELETE /api/v1/campaigns/:id
```

`PATCH` accepts `name`, `subject`, `body` and `listId` (use `null` to clear the list). Each field is validated like on create, and an empty value is rejected. A campaign that is already `SENT` returns `422` with code `CAMPAIGN_ALREADY_SENT`. `DELETE` returns `204`.

## Schedule a campaign

```text
POST /api/v1/campaigns/:id/schedule
```

```json
{ "scheduledAt": "2026-10-10T15:00:00Z" }
```

- The campaign needs a list first, otherwise `422`.
- `scheduledAt` is an ISO 8601 date and time, and must be at least 30 seconds in the future.
- A sent campaign cannot be scheduled (`CAMPAIGN_ALREADY_SENT`).
- Returns `id`, `name`, `status` (`SCHEDULED`) and `scheduledAt`.

To cancel, call `DELETE /api/v1/campaigns/:id/schedule`. It only works on a `SCHEDULED` campaign and sets it back to `DRAFT`.

## Send a campaign now

```text
POST /api/v1/campaigns/:id/send
```

The body is optional JSON. Two optional fields exist: `excludeEmails` (an array of addresses to skip) and `overrideQuietHours` (`true` to ignore your quiet-hours window).

The send goes through the same guardrails as a dashboard send: suppressed addresses and blocked domains are skipped, and your monthly plan limit is enforced. A send that would pass your daily cap, or that starts inside quiet hours, is refused as a whole with a `422`. A scheduled campaign that hits quiet hours is retried after they end. Your workspace also needs a physical mailing address in settings, because it is printed in every footer. See [Compliance](/docs/concepts/compliance) and [Sending Health](/docs/concepts/sending-health).

Recipients go out in batches of 100. The request stops starting new batches after about 45 seconds. If recipients are still owed, the rest goes out in the background, picked up by a job that runs every minute:

```json
{ "data": { "sent": 800, "failed": 0, "remaining": 400, "complete": false } }
```

`sent` and `failed` count this call only. When `complete` is `false`, do not call again to finish the job: the rest goes out on its own, and a repeated call never mails anyone twice. Poll `GET /api/v1/campaigns/:id` until `status` is `SENT`.

Errors:

| HTTP | Code                    | When                                                         |
| ---- | ----------------------- | ------------------------------------------------------------ |
| 404  | `NOT_FOUND`             | The campaign is not in your workspace                        |
| 422  | `CAMPAIGN_ALREADY_SENT` | The campaign was already sent                                |
| 403  | `FORBIDDEN`             | The workspace is suspended                                   |
| 402  | `QUOTA_EXCEEDED`        | The send would pass your monthly limit                       |
| 422  | `VALIDATION_ERROR`      | No audience, no subscribed contacts, or another send blocker |
