# Campaigns API

Create marketing campaigns and send now or schedule for later.

## Endpoints

- `GET /v1/campaigns`
- `POST /v1/campaigns`
- `GET /v1/campaigns/{id}`
- `PATCH /v1/campaigns/{id}`
- `DELETE /v1/campaigns/{id}`
- `POST /v1/campaigns/{id}/send`
- `POST /v1/campaigns/{id}/variants`
- `PATCH|DELETE /v1/campaigns/{id}/variants/{variant_id}`
- `GET /v1/campaigns/{id}/ab`
- `POST /v1/campaigns/{id}/ab/winner`

## Create a campaign

A campaign is one marketing email. Pick an audience — a list, a segment, or a single contact for a one-off email — and content from raw `html` or a saved template.

**Body parameters**

- `name` — *string*, required — Internal name for the campaign.
- `subject` — *string*, optional — Subject line (personalizable). Falls back to the template's subject if a `template_id` is given.
- `list_id` — *string*, optional — Audience: send to every subscribed contact on this list.
- `segment_id` — *string*, optional — Audience: send to contacts matching this segment (evaluated live).
- `contact_id` — *string*, optional — Audience: send to a single contact — a one-off marketing email.
- `html` — *string*, optional — Content: your HTML email. Inline CSS and `<style>` blocks are preserved. Personalize with `{{ contact.first_name }}`, with a fallback via `{{ contact.first_name | default: "there" }}`. Missing fields render empty.
- `template_id` — *string*, optional — Content: render a saved template (alternative to html).
- `content_json` / `theme_json` — *object*, optional — Content: the document the dashboard's visual editor produces, and its theme. Accepted so a campaign drafted in the editor can be updated over the API — send `html` or `template_id` if you're composing from code.
- `from_email` — *string*, optional — Sender address. Defaults to your shared sending domain.
- `from_name` — *string*, optional — Sender display name.
- `preview_text` — *string*, optional — Inbox preview text shown after the subject.
- `ab_enabled` — *boolean*, optional — Turn the campaign into an A/B test. See [A/B testing](#ab-testing).
- `ab_test_percent` — *integer*, optional — Share of the audience in the test, 5–100. Under 100 the remainder is held back for the winner; `100` splits the whole audience between the variants with no winner send.
- `ab_winner_metric` — *string*, optional — `open_rate` (default, best for subject lines) or `click_rate` (best for content). Unique, human-only engagement.
- `ab_wait_minutes` — *integer*, optional — How long to gather results before deciding, 30 to 10080 (7 days). Default 360 (6 hours).
- `variants` — *array*, optional — Create 2–4 variants with the campaign. Omit it with `ab_enabled` and a default A/B pair is seeded for you.

Send to a list (raw HTML):

```bash
curl -X POST https://emailbump.com/api/v1/campaigns \
  -H "Authorization: Bearer ebk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "July sale",
    "subject": "50% off, {{ contact.first_name | default: \"friend\" }}",
    "from_name": "Acme",
    "list_id": "5b3a0ea3-5c58-423e-92ff-0aaac7be0af3",
    "html": "<div style=\"font-family:sans-serif\"><h1>Summer sale</h1><p>Hi {{ contact.first_name | default: \"there\" }}, everything is 50% off this week.</p></div>"
  }'
```

One-off to a single contact:

```bash
curl -X POST https://emailbump.com/api/v1/campaigns \
  -H "Authorization: Bearer ebk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Personal note",
    "subject": "A quick hello",
    "contact_id": "5fef0867-b292-46ca-8371-e24d8575576c",
    "html": "<p>Hi {{ contact.first_name }}, just checking in!</p>"
  }'
```

## Send or schedule

`POST /v1/campaigns/{id}/send`

One endpoint for both: omit the body to send now, or pass `scheduled_at` to send at a future time.

**Body parameters**

- `scheduled_at` — *string*, optional — RFC 3339 timestamp, must be in the future. Omit to send immediately.

```bash
# Send now
curl -X POST https://emailbump.com/api/v1/campaigns/CAMPAIGN_ID/send \
  -H "Authorization: Bearer ebk_your_key"

# …or schedule for later
curl -X POST https://emailbump.com/api/v1/campaigns/CAMPAIGN_ID/send \
  -H "Authorization: Bearer ebk_your_key" \
  -H "Content-Type: application/json" \
  -d '{ "scheduled_at": "2026-08-01T09:00:00Z" }'
```

```json
{ "status": "sending", "recipients": 1240 }
```

```json
{ "status": "scheduled", "campaign": { "id": "…", "status": "scheduled" } }
```

## Send options

`POST /v1/campaigns/{id}/send` takes more than a time. The options below decide
*when each individual recipient* is released, and the strategy and the clock
combine freely — **except on an A/B test**, which must be a `fixed`,
`specific`-timezone send. See [A/B testing](#ab-testing).

**Strategy** — `send_strategy`

- `fixed` *(default)* — everyone goes at the scheduled instant.
- `gradual` — the audience is released in batches, so a large send warms up
  instead of arriving at the ISPs as one spike. Set `batch_percent` (1–50,
  default 10) and `batch_interval` (`minute` or `hour`).
- `smart` — each recipient gets the hour they have historically opened in
  (between 06:00 and 21:00 local, falling back to 10:00), within 24 hours of the
  scheduled time. It is **locked until the account has engagement to learn
  from**: 100 human opens or clicks, from 50 distinct contacts, across at least
  2 campaigns or flows, in the last 180 days. Send before that and you get
  *"Smart send time needs 100 tracked opens or clicks in the last 180 days"*.

**Which clock** — `timezone_mode`

- `specific` *(default)* — one instant for the whole audience. `scheduled_at` is
  already absolute, so `send_timezone` does **not** shift it.
- `local` — the same *wall-clock* time in each recipient's own zone, so a list
  spread across continents all get 8am rather than all getting the same instant.
  Here `send_timezone` is the zone whose wall-clock reading of `scheduled_at`
  gets replayed for everyone, and the fallback for recipients whose own timezone
  we don't know. It defaults to your account timezone; send `null` to clear it.
  `local_past_behavior` decides what happens to recipients whose local time has
  already passed: `next_day` *(default)* waits, `immediate` sends now.

**Audience timing** — `recipients_at_send_time`

`false` *(default)* snapshots the audience when you schedule. `true` re-resolves
it at send time, so people who join the list in between are included. It is
**not available with `timezone_mode: "local"`** — a local send takes one snapshot,
just before the earliest timezone goes out, so there is no later moment to
re-resolve against.

```bash
# Warm up a big send: 10% every hour, in each reader's own morning
curl -X POST https://emailbump.com/api/v1/campaigns/CAMPAIGN_ID/send \
  -H "Authorization: Bearer ebk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "scheduled_at": "2026-08-10T08:00:00Z",
    "send_strategy": "gradual",
    "batch_percent": 10,
    "batch_interval": "hour",
    "timezone_mode": "local",
    "local_past_behavior": "next_day"
  }'
```

Gradual batching layers on top of each recipient's base time, so it composes
with `local` rather than overriding it.

## A/B testing

Send two to four versions to a slice of the list, wait, then send the winner to
everyone else — without you being at a desk when the timer ends.

- `POST /v1/campaigns/{id}/variants` — add a variant (2–4 total).
- `PATCH /v1/campaigns/{id}/variants/{variant_id}` — edit one.
- `DELETE /v1/campaigns/{id}/variants/{variant_id}` — remove one.
- `GET /v1/campaigns/{id}/ab` — live per-variant results.
- `POST /v1/campaigns/{id}/ab/winner` — pick the winner yourself instead of waiting.

**Variant parameters**

- `label` — *string*, optional — What to call it, e.g. `"A"`.
- `subject` — *string*, optional — Overrides the campaign's subject. Blank clears the override.
- `preview_text` — *string*, optional — Overrides the campaign's preview text.
- `html` — *string*, optional — A different body. `null` clears it back to the campaign's shared content.
- `content_json` / `theme_json` — *object*, optional — The editor equivalent of `html`.
- `split_percent` — *integer*, optional — This variant's share of the test cohort, 1–99. Leave it off for an even split; if you set it on one, set it on all and make them total 100.

A variant only overrides what you give it, so testing a subject line means
setting `subject` and nothing else — the body stays shared.

Setting `ab_test_percent` to `100` turns it into a **simple split**: the whole
audience is divided between the variants and nobody is held back, so there is no
winner send. Use it to compare two versions across the entire list at once.

**An A/B send is a fixed send.** The held-back remainder has no release time
until the winner is picked, which cannot be reconciled with gradual pacing or
per-recipient local timezones, so `POST …/send` refuses both:

```json
{ "error": "A/B tests send the test batch all at once — switch the send strategy to fixed" }
{ "error": "A/B tests aren't available with recipient-local send times" }
```

`split_percent` is likewise checked when you send, not when you create: set it on
every variant and make them total 100, or omit it everywhere for an even split.
A campaign with 60/30 is accepted at create and refused at send with
*"Variant splits must add up to 100%"*.

```bash
curl -X POST https://emailbump.com/api/v1/campaigns \
  -H "Authorization: Bearer ebk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "August newsletter",
    "subject": "This month at Acme",
    "list_id": "5b3a0ea3-5c58-423e-92ff-0aaac7be0af3",
    "html": "<p>…</p>",
    "ab_enabled": true,
    "ab_test_percent": 30,
    "ab_winner_metric": "click_rate",
    "ab_wait_minutes": 240,
    "variants": [
      { "label": "A", "subject": "This month at Acme" },
      { "label": "B", "subject": "Three things we shipped in August" }
    ]
  }'
```

### How the test progresses

The campaign's `status` tells you where it is:

1. `sending` — the test cohort (`ab_test_percent` of the audience) is going out, split across the variants. The rest are held back.
2. `testing` — the test batch has finished and the clock is running. `ab_test_ends_at` is when the winner gets picked.
3. `sending` — a winner was chosen, and the held recipients are released to it.
4. `sent` — nothing held, nothing pending.

A campaign in `testing` has **already emailed part of your list**. Cancelling it
abandons the remainder; there is no pause, and it cannot be deleted until it is
stopped.

`POST /v1/campaigns/{id}/ab/winner` with `{ "variant_id": "…" }` decides early.
It only works while the test is running (`sending` or `testing`) and only once.

## Delete a campaign

`DELETE /v1/campaigns/{id}` → `204 No Content`

Removes a campaign that never sent, with its recipient rows and its A/B
variants. It is for clearing out drafts and mistakes — anything that has actually
reached someone is permanent, for the reason below.

```bash
curl -X DELETE https://emailbump.com/api/v1/campaigns/CAMPAIGN_ID \
  -H "Authorization: Bearer ebk_your_key"
```

**Only a campaign that never sent can be deleted.** There are two refusals, both
`409 Conflict`.

A campaign that is *going out* mustn't be called off silently. While the status
is `sending`, `scheduled` or `testing`:

```json
{ "error": "This campaign is scheduled, so deleting it would call off a send without saying so. Pause it first so it stops going out, or cancel it, then delete it." }
```

Pause or cancel it first. An A/B test in flight (`testing`) has no pause — cancel
it.

A campaign that *has gone out* keeps its place in the reports. Delivery events
carry a campaign id but survive the campaign, so removing it would leave every
open, click and bounce it produced counted in your totals with nothing to
attribute them to:

```json
{ "error": "This campaign has already sent, so it can't be deleted — its opens, clicks and bounces would stay in your reports with nothing to attribute them to. Only a campaign that never sent can be removed." }
```

That one is permanent: a sent campaign cannot be deleted, by anyone, ever. Delete
is for tidying up drafts and mistakes, not for erasing history.
