# Campaigns

> Drafts with Custom HTML content, test email, scheduling, results.

Reading needs Campaigns: view, every change Campaigns: manage.

## What the API can do

The API creates **regular campaigns with Custom HTML content**. A/B, multivariate and resend campaigns, the drag & drop and the simple editor, and templates are made in the app. The API still lists and reads every campaign, and schedules, cancels or deletes them.

| Method and path | What it does |
|---|---|
| `GET /campaigns` | Lists campaigns, newest change first. |
| `POST /campaigns` | Creates a draft. |
| `GET /campaigns/{id}` | Reads one campaign (with its content if it is Custom HTML). |
| `PATCH /campaigns/{id}` | Changes a draft. |
| `DELETE /campaigns/{id}` | Deletes a campaign. |
| `POST /campaigns/{id}/test` | Sends a test email. |
| `POST /campaigns/{id}/schedule` | Sends now or schedules a draft. |
| `POST /campaigns/{id}/cancel` | Takes a scheduled campaign back, or stops one that is sending. |
| `GET /campaigns/{id}/activity` | The recipients and what they did. |

## The campaign object

```json
{
  "id": "30bc6133-16ad-4074-b311-204127a80b62",
  "name": "Winter-ready garden (EN)",
  "type": "regular",
  "status": "sent",
  "editor": "dragdrop",
  "subject": "Get your garden ready for winter",
  "from_name": "Kertlak Garden Shop",
  "from_email": "news@news.kertlak.example.com",
  "reply_to": "info@kertlak.example.com",
  "recipients": {"all": false, "groups": ["5e30c6ff-0462-47d1-9a6c-7db4c33d6b9a"], "segments": [], "exclude_groups": [], "exclude_segments": [], "has_rules": false},
  "delivery": "instant",
  "scheduled_for": null,
  "created_at": "2026-10-15T07:00:00Z",
  "updated_at": "2026-10-20T08:02:25.333333Z",
  "sent_at": "2026-10-20T08:02:25.333333Z",
  "stats": {"recipients": 1272, "sent": 1272, "delivered": 1266, "opened": 337, "clicked": 57, "unsubscribed": 4, "bounced": 0, "soft_bounced": 6, "complaints": 0, "open_rate": 26.49, "click_rate": 4.48}
}
```

| Key | Meaning |
|---|---|
| `type` | `regular`, `ab`, `multivariate` or `resend`. Campaigns made in the app are read without their type's own settings. |
| `status` | `draft`, `ready` (scheduled, in the Outbox), `sending`, `paused`, `sent` or `canceled`. |
| `editor` | `dragdrop`, `simple`, `html` (Custom HTML) or `null` (no content yet). |
| `recipients` | `all` (every active subscriber) or the group and segment lists, with exclusions. `has_rules` is `true` when the recipients are chosen by a rule set built in the app; the lists are then empty. |
| `delivery` | `instant`, `scheduled`, `timezone_based`, or `null` for a draft. |
| `scheduled_for` | When sending starts, in UTC. For `timezone_based`, when the first time zone reaches the chosen local time. |
| `sent_at` | When a sent campaign finished sending. |
| `stats` | `opened` and `clicked` are unique; `bounced` is permanent bounces; `open_rate` and `click_rate` are percentages of `sent`. |
| `content` | Only in single-campaign answers of a Custom HTML campaign: `html` and `text`. |

## List campaigns

| Parameter | Meaning |
|---|---|
| `status` | `draft`, `outbox` (ready, sending, paused) or `sent` (sent, canceled). Without it: every campaign. |
| `type` | `regular`, `ab`, `multivariate`, `resend`. |
| `search` | Name or subject contains. |
| `limit`, `cursor` | Paging. `total` is always there. |

```bash
curl -H "Authorization: Bearer $ECHO_TOKEN" "$ECHO_API/campaigns?status=outbox"
```

```json
{"data":[{"id":"f8ab784a-0a6a-45f0-8dd8-38ecd146826a","name":"Novemberi hírlevél: téli madáretetés","type":"regular","status":"ready","editor":"dragdrop","subject":"Téli madáretetés: mikor kezdjük és mivel?","from_name":"Kertlak Webshop","from_email":"hirlevel@news.kertlak.example.com","reply_to":"info@kertlak.example.com","recipients":{"all":false,"groups":["d5478c4a-97d3-4fa9-b38d-dd32c41dbac3"],"segments":[],"exclude_groups":[],"exclude_segments":[],"has_rules":false},"delivery":"scheduled","scheduled_for":"2026-11-05T08:00:00Z","created_at":"2026-10-19T05:20:29.127968Z","updated_at":"2026-10-21T09:20:29.127968Z","sent_at":null,"stats":{"recipients":0,"sent":0,"delivered":0,"opened":0,"clicked":0,"unsubscribed":0,"bounced":0,"soft_bounced":0,"complaints":0,"open_rate":0,"click_rate":0}}],"meta":{"next_cursor":null,"prev_cursor":null,"limit":25,"total":1}}
```

Lists never contain `content`.

## Create a draft

`POST /campaigns`. `name` and `content.html` are required.

| Key | Meaning |
|---|---|
| `name` | Internal name (up to 255 characters). |
| `subject`, `from_name`, `from_email`, `reply_to` | The email's details. Without them the workspace's defaults are used. `from_email` must be on a verified sending domain; this is checked when you schedule, not before. |
| `recipients` | `all`, `groups`, `segments`, `exclude_groups`, `exclude_segments` (IDs). Replaces the lists. |
| `content.html` | The Custom HTML. It is sanitized as in the app, and every merge tag in it must exist. |
| `content.text` | The plain text version. Without it, Echo makes one from the HTML. |

```bash
curl -X POST "$ECHO_API/campaigns" \
  -H "Authorization: Bearer $ECHO_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Őszi kertápolás","subject":"Őszi kertápolás: öt tipp","from_name":"Kertlak Webshop","from_email":"hirlevel@news.kertlak.example.com","recipients":{"groups":["d5478c4a-97d3-4fa9-b38d-dd32c41dbac3"]},"content":{"html":"<h1>Kedves {$name},</h1><p>itt az őszi kertápolási tippsorunk.</p><p><a href=\"{$unsubscribe}\">Leiratkozás</a></p>"}}'
```

`201 Created`:

```json
{"data":{"id":"899d27ec-f6f3-4551-a7c5-7fc993cd4002","name":"Őszi kertápolás","type":"regular","status":"draft","editor":"html","subject":"Őszi kertápolás: öt tipp","from_name":"Kertlak Webshop","from_email":"hirlevel@news.kertlak.example.com","reply_to":"info@kertlak.example.com","recipients":{"all":false,"groups":["d5478c4a-97d3-4fa9-b38d-dd32c41dbac3"],"segments":[],"exclude_groups":[],"exclude_segments":[],"has_rules":false},"delivery":null,"scheduled_for":null,"created_at":"2026-10-22T06:08:40.347097Z","updated_at":"2026-10-22T06:08:40.363886Z","sent_at":null,"stats":{"recipients":0,"sent":0,"delivered":0,"opened":0,"clicked":0,"unsubscribed":0,"bounced":0,"soft_bounced":0,"complaints":0,"open_rate":0,"click_rate":0},"content":{"html":"\u003ch1\u003eKedves {$name},\u003c/h1\u003e\u003cp\u003eitt az őszi kertápolási tippsorunk.\u003c/p\u003e\u003cp\u003e\u003ca href=\"{$unsubscribe}\"\u003eLeiratkozás\u003c/a\u003e\u003c/p\u003e","text":"***************\nKedves {$name},\n***************\n\nitt az őszi kertápolási tippsorunk.\n\nLeiratkozás ( {$unsubscribe} )"}}}
```

Merge tags are the fields' tags (`{$name}`, `{$city}`, your custom fields) and the system links `{$unsubscribe}`, `{$preferences}`, `{$webview}` and `{$forward}`. An unknown tag:

```json
{"error":{"code":"unknown_tags","message":"Unknown personalization tags: {$keresztnev}","fields":{"content.html":"Unknown personalization tags: {$keresztnev}"}}}
```

If a step after creating the draft fails (for example the content), the draft is deleted again and the answer is that step's error.

## Get and change a draft

`GET /campaigns/{id}` returns the campaign; a Custom HTML campaign also has `content`.

`PATCH /campaigns/{id}` changes the given keys of a **draft** (same keys as create). `content` replaces the whole content, and is accepted for Custom HTML campaigns and for drafts without content yet.

```bash
curl -X PATCH "$ECHO_API/campaigns/899d27ec-f6f3-4551-a7c5-7fc993cd4002" \
  -H "Authorization: Bearer $ECHO_TOKEN" -H "Content-Type: application/json" \
  -d '{"subject":"Őszi kertápolás: öt tipp a hidegek előtt"}'
```

| Error | When |
|---|---|
| `409 not_editable` | The campaign isn't a draft (also for a rename), or you sent `content` for a campaign made in another editor. |
| `409 editor_chosen` | Another request chose a different editor for this draft at the same moment. |

PATCH isn't atomic: the details are saved before the content, so a content error can leave the other changes saved.

## Send a test email

`POST /campaigns/{id}/test` sends the campaign to 1 to 5 addresses, with `[Test]` before the subject. The sender must be on a verified sending domain and the content must be set.

```bash
curl -X POST "$ECHO_API/campaigns/899d27ec-f6f3-4551-a7c5-7fc993cd4002/test" \
  -H "Authorization: Bearer $ECHO_TOKEN" -H "Content-Type: application/json" \
  -d '{"emails":["teszt@example.com"]}'
```

`202 Accepted`, empty body. A workspace can send 50 test emails in any 24 hours, the app's tests included; over that the answer is `422`.

## Schedule and cancel

`POST /campaigns/{id}/schedule` takes a draft out of the drafts:

| Key | Meaning |
|---|---|
| `delivery` | `instant` (send now), `scheduled` or `timezone_based` (each subscriber at the same local time). |
| `date` | `YYYY-MM-DD`, for `scheduled` and `timezone_based`. |
| `time` | `HH:MM`, in 15-minute steps, for `scheduled` and `timezone_based`. |
| `timezone` | An IANA name from [`GET /timezones`](https://docs.uptools.io/echo/developer-guide/groups-segments-fields/#time-zones), for `scheduled`. |

The app's pre-send checks run first: a sender on a verified sending domain, the company details, an unsubscribe link in the content (unless the automatic footer is on) and recipients. A campaign that can't be sent answers `422 validation` with the fields. Times are at most a year ahead; `timezone_based` works for regular campaigns at least 24 hours ahead.

```bash
curl -X POST "$ECHO_API/campaigns/899d27ec-f6f3-4551-a7c5-7fc993cd4002/schedule" \
  -H "Authorization: Bearer $ECHO_TOKEN" -H "Content-Type: application/json" \
  -d '{"delivery":"scheduled","date":"2027-09-30","time":"09:00","timezone":"Europe/Budapest"}'
```

`200 OK` with the campaign, now `"status":"ready"`, `"delivery":"scheduled"` and `"scheduled_for":"2027-09-30T07:00:00Z"`. A time off the 15-minute grid:

```json
{"error":{"code":"validation","message":"Please check the highlighted fields.","fields":{"schedule":"Choose a time in the future, in 15-minute steps."}}}
```

`instant` really starts sending to every recipient. Use a test email or a small test group first.

`POST /campaigns/{id}/cancel` (no body): a scheduled campaign goes back to the drafts (`"status":"draft"`, `"delivery":null`); a sending or paused one stops and becomes `canceled`. Any other status is `409 not_editable`.

```bash
curl -X POST "$ECHO_API/campaigns/899d27ec-f6f3-4551-a7c5-7fc993cd4002/cancel" -H "Authorization: Bearer $ECHO_TOKEN"
```

## Delete a campaign

```bash
curl -X DELETE "$ECHO_API/campaigns/899d27ec-f6f3-4551-a7c5-7fc993cd4002" -H "Authorization: Bearer $ECHO_TOKEN"
```

`204 No Content`. A sending or paused campaign must be canceled first (`409 not_editable`), and a campaign that was sent within the last 30 days can't be deleted yet (`409 recently_sent`).

## Recipient activity

`GET /campaigns/{id}/activity` lists the recipients of a campaign that has started sending (an earlier one answers an empty page).

| Parameter | Meaning |
|---|---|
| `type` | `all` (default), `opened`, `clicked`, `forwarded`, `unopened`, `unsubscribed`, `hard_bounce`, `soft_bounce`, `complaints`. |
| `search` | Email address contains (needs Subscribers: view). |
| `limit`, `cursor` | Paging. |

```bash
curl -H "Authorization: Bearer $ECHO_TOKEN" \
  "$ECHO_API/campaigns/30bc6133-16ad-4074-b311-204127a80b62/activity?type=clicked&limit=2"
```

```json
{"data":[{"subscriber":{"id":"04393fa0-81ff-44a3-82e2-67af141df99a","email":"alice.clarke7@example.com"},"opens":1,"clicks":1,"sent_at":"2026-10-20T08:00:23.444444Z","opened_at":"2026-10-20T08:34:16.826706Z","clicked_at":"2026-10-20T08:47:18.826706Z"},{"subscriber":{"id":"f56a4743-a5db-4d64-b9dc-bb69afdf0659","email":"alice.hughes92@example.com"},"opens":1,"clicks":1,"sent_at":"2026-10-20T08:02:19.666667Z","opened_at":"2026-10-20T09:58:56.816405Z","clicked_at":"2026-10-20T10:09:11.816405Z"}],"meta":{"next_cursor":"cDoy","prev_cursor":null,"limit":2,"total":57}}
```

`opened_at` and `clicked_at` are the first open and click. Without Subscribers: view the `subscriber` object has only `id`.
