# API reference

Every endpoint of the SendBeam HTTP API at https://sendbeam.io, with authentication, request and response schemas and copy-paste examples. Also available as OpenAPI 3.1 at /openapi.json.

> Machine-readable version: [/openapi.json](https://sendbeam.io/openapi.json) (OpenAPI 3.1, CORS-open). This page is generated from it. Version 1.27.0.
> 

SendBeam is one email-marketing account for every site you run: contacts, tags, lists, segments, campaigns, templates, automations and forms, all behind one JSON API.

## Base URL

All endpoints are relative to `https://sendbeam.io`. Requests and responses use JSON (`Content-Type: application/json`) unless an operation says otherwise (CSV import/export, form-encoded unsubscribe, HTML subscriber pages).

## Authentication

Send your API key in the `x-api-key` header on every `/api/v1/*` request. Keys look like `sb_live_XXXXXXXX_YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY` (the prefix `sb_live_`, 8 lookup characters, an underscore, then 32 more characters). Create keys under **Settings → API keys** (`/settings/api-keys`); only workspace admins can create or revoke them, and the full key is shown once at creation. Keys are stored hashed and can be revoked at any time.

A missing, malformed or revoked key returns `401 {"error":"Unauthorized"}`. A key is scoped to one workspace; it can never read or write another workspace's data.

## Permissions

Each key carries a set of permissions chosen when it is created. The API checks the exact strings below and returns `403 {"error":"Forbidden: permission required"}` when the key lacks one:

`contacts:read`, `contacts:write`, `contacts:export`, `tags:read`, `tags:write`, `lists:read`, `lists:write`, `campaigns:read`, `campaigns:write`, `campaigns:send`, `templates:read`, `templates:write`, `automations:read`, `automations:write`, `forms:read`, `forms:write`, `segments:read`, `segments:write`, `webhooks:read`, `webhooks:write`, `transactional:send`, `ecommerce:read`, `ecommerce:write`, `audit:read`.

Each operation lists the permission it requires. Form management (`/api/v1/forms`) uses `forms:read` / `forms:write`; keys created before these existed that carry `automations:write` are still accepted there. Image hosting (`/api/v1/media`) uses `campaigns:write`.

**Authoring and sending are separate.** `campaigns:write` creates and edits a campaign; `campaigns:send` is what actually mails the audience, and is also required to send a single email (`/api/v1/send`) or run an RSS feed. A key granted the first and not the second can prepare everything and send nothing, which is the grant to give anything that runs unattended.

**Reading and exporting are separate.** `contacts:read` looks contacts up and pages through them; `contacts:export` is what downloads the list as a file — `GET /api/v1/contacts/export`, the bulk `export` action and `GET /api/v1/suppressions/export`. A key without it is refused by those three endpoints and nothing else: it can still look contacts up and page through them with `contacts:read`, but cannot download them as a file. Keys that held `contacts:read` before the two were split were granted `contacts:export` so nothing that worked stopped; keys created since ask for it separately (**Export contacts**, under Audience).

## Write throughput

With an API key, reads (`GET`) are unmetered on every plan. Writes (`POST`, `PUT`, `PATCH`, `DELETE`) also work on every plan and are metered per hour per workspace — Free 120, Starter 600, Pro and Business unlimited. Spending the hour answers `429` with a `Retry-After` header and refills on its own; it is a throughput limit, not a plan gate. Segment previews (`POST /api/v1/segments/preview`, `GET /api/v1/segments/{id}/preview`) carry their rules in the body but are reads, so they are never counted.

## Plan limits

Plans cap contacts (Free 500, Starter 2,500, Pro 10,000, Business 50,000 — pooled across the workspaces on one account), emails per month (2,000 / 15,000 / 60,000 / 250,000) and live automations per workspace (1 / 5 / unlimited / unlimited). When a write would exceed a cap the API refuses it with a plain-English message, for example `Contact limit reached (10,000 on the pro plan). Upgrade to add more.` (contact creation and import), `Monthly email limit reached (60,000 on the pro plan).` (campaign and single sends) or `Your plan allows 5 live automations across your workspaces. Pause one or upgrade.` (automation activation). Every plan-cap refusal is a `403`. A workspace whose sending has been paused for abuse receives `Sending is paused for this account…` (`403`) on send attempts.

Each plan also has an **hourly sending ceiling** on top of its monthly allowance; your plan's ceiling is shown in the app under Billing. `POST /api/v1/send` returns `429` with a `Retry-After` header saying when to try again once it is used up; campaign queues simply continue in the next hour. On the **Free** plan every list is double opt-in and campaigns — to `all`, a list or a segment — reach only contacts who have confirmed a subscription, so imported or API-created contacts who never confirmed are not mailed.

## Field limits

Contact `email` is at most 254 characters, `first_name` / `last_name` at most 100. `custom_fields` must be a flat object of at most 50 keys, each key 1–64 characters, each value a string of at most 200 characters, a finite number or a boolean (`null` values are dropped; arrays and nested objects are refused). Campaign and template `html_content` is at most 500,000 characters and `text_content` 200,000. CSV uploads are at most 5 MB. Over a limit the API answers `400` (contact fields, campaign update) or `413` (campaign/template creation, CSV upload).

## Suppression list

An address that unsubscribes, bounces, complains or is deleted stays on the workspace's suppression list after the contact row is gone (a hash of the address, plus a masked display form and the domain on rows written since 2026-09-14). Creating it again returns `409`; a CSV import brings it in as `unsubscribed` whatever `default_status` says. The person can return by subscribing again through a signup form; a workspace admin can also lift the block a deleted contact left (`POST /api/v1/suppressions/lift`) — never a bounce, a complaint or an erasure. To take a contact out of the working list WITHOUT blocking the address, archive it (`action: "archive"` on the bulk endpoint, or `status: "archived"` on PATCH).

## Pagination

List endpoints that can grow large (`/api/v1/contacts`, `/api/v1/campaigns`, `/api/v1/lists/{id}/contacts`, `/api/v1/webhooks/{id}/deliveries`) take `page` (default 1) and `limit` (default 50, maximum 100) query parameters and return a `pagination` object: `{ "page": 1, "limit": 50, "total": 1234, "total_pages": 25 }`. Other list endpoints return the full collection. The audit log (`/api/v1/account/audit-log`) pages by cursor instead: pass the `next_cursor` a page returns as `cursor` to get the next one.

## Errors

Every error is a JSON object with a single `error` string: `{ "error": "Contact not found" }`. `400` is a validation problem with your request, `401` authentication, `403` permission or plan (including plan caps), `404` a resource this workspace does not own, `409` a conflict (duplicate name, wrong state, suppressed address), `413` a body or upload over its size limit (see Field limits), `422` something about the target that must change first (unsubscribed contact, empty CSV, sending not configured), `429` rate limiting (public forms, and the hourly ceiling on `/api/v1/send`, which carries `Retry-After`), `500` an unexpected failure, `503` the delivery provider or an upstream service failed.

## Subscriber-facing endpoints

The unsubscribe and opt-in confirmation endpoints are opened by your subscribers from links in emails. They require no API key, return `text/html`, and are listed here so you know what your subscribers see.

## Contacts

People in your workspace. A contact is unique per email address.

### GET /api/v1/contacts

**List contacts.** Returns the workspace's contacts, newest first by default, with pagination. Optionally search by email or name; filter by status, tag, list membership, source and the date added — the same filters the contacts page, the CSV export and the bulk endpoint's `filter` take, combinable; and sort. Archived contacts are left out unless `status=archived` asks for them. Requires `contacts:read`.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `page` | query | integer | no | Page number, starting at 1. |
| `limit` | query | integer | no | Items per page (1–100). |
| `sort` | query | "created" \| "name" \| "email" \| "status" | no | Column to sort by. `name` is first name then last name. Every sort is stable across pages (ties fall back to newest first, then id). |
| `dir` | query | "asc" \| "desc" | no | Defaults to `desc` for `created`, `asc` otherwise. |
| `q` | query | string | no | Case-insensitive substring match against email, first_name or last_name. |
| `status` | query | ContactStatus | no | Only contacts with this status. |
| `tag` | query | string | no | Only contacts carrying this tag (tag id). |
| `list` | query | string | no | Only contacts on this list (list id). With `tag` as well, a contact must satisfy both. |
| `source` | query | string | no | Only contacts whose `source` is exactly this, e.g. `form`, `import` or `api`. |
| `from` | query | string | no | Only contacts added on or after this day (YYYY-MM-DD). |
| `to` | query | string | no | Only contacts added on or before this day (YYYY-MM-DD; the whole day counts). A date that is not real, such as `2026-02-31`, is ignored rather than rounded. |

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/contacts?page=1&limit=50" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` A page of contacts.

```
{
  "contacts": [
    {
      "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
      "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
      "email": "jane@example.com",
      "first_name": "Jane",
      "last_name": "Doe",
      "status": "subscribed",
      "custom_fields": {
        "plan": "pro"
      },
      "source": "api",
      "language": "en",
      "subscribed_at": "2026-09-01T10:00:00.000Z",
      "unsubscribed_at": null,
      "created_at": "2026-09-01T10:00:00.000Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 50,
    "total": 1234,
    "total_pages": 25
  }
}
```

`401`

No body.

`403`

No body.

`500`

No body.

### POST /api/v1/contacts

**Create a contact.** Creates a subscribed contact. The email is trimmed, lowercased, validated (at most 254 characters) and must be unique in the workspace; names are at most 100 characters and `custom_fields` must satisfy the CustomFields limits, otherwise `400`. An address on the workspace's suppression list is refused with `409`: one that **unsubscribed** can be re-added by sending `resubscribe: true` (you are asserting the person gave you new consent; the suppression entry is removed once the contact is created), one that **bounced** or **complained** never can, and one that was **deleted** only returns when the person signs up again through a form. Counts against the plan's contact cap. Enrols the contact in every active `contact_created` automation. Requires `contacts:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | yes |  |
| `first_name` | string | no |  |
| `last_name` | string | no |  |
| `source` | string | no | Where the contact came from. Defaults to `api`. |
| `language` | string \| null | no | The language the person reads in, as an ISO 639-1 code (`fr`, `de`). A campaign sent in more than one language picks the matching version. `fr-FR` and `pt_BR` are accepted and reduced to the primary code; an unknown code is a 400. |
| `custom_fields` | CustomFields | no | A flat object of at most 50 keys. Keys are 1–64 characters (`__proto__`, `constructor` and `prototype` are refused); values are strings of at most 200 characters, finite numbers or booleans. `null` values are dropped; arrays and nested objects are rejected with `400`. **Typed fields.** A key declared under Custom fields (`GET /custom-fields`) takes only a value of its type — a `number` field a number or a numeric string, a `boolean` field a boolean or `yes`/`no`/`true`/`false`, a `date` field a real calendar date (`YYYY-MM-DD`, an ISO date-time, or `DD/MM/YYYY`; stored as `YYYY-MM-DD`), a `dropdown` field one of its options — otherwise `400` `custom_fields. must be …`; an empty string clears a typed field. A key no field names is registered as a `text` field when it is first written. Available in emails as `{{custom_fields.}}` and in segment and automation rules as `custom_fields.`. Signup forms cap each value at 200 characters. |
| `resubscribe` | boolean | no | Set `true` when the person has given you new consent after unsubscribing: the address's `unsubscribed` suppression entry is removed once the contact is created. Has no effect on an address that bounced, complained or was deleted (still `409`). |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/contacts" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "jane@example.com",
  "first_name": "Jane",
  "last_name": "Doe",
  "source": "website",
  "language": "fr",
  "custom_fields": {
    "plan": "pro",
    "region": "London",
    "seats": 3,
    "trial": false
  },
  "resubscribe": false
}'
```

#### Responses

`201` Created.

```
{
  "contact": {
    "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "email": "jane@example.com",
    "first_name": "Jane",
    "last_name": "Doe",
    "status": "subscribed",
    "custom_fields": {
      "plan": "pro"
    },
    "source": "api",
    "language": "en",
    "subscribed_at": "2026-09-01T10:00:00.000Z",
    "unsubscribed_at": null,
    "created_at": "2026-09-01T10:00:00.000Z"
  }
}
```

`400` Invalid JSON body, `email is required`, `Invalid email address`, `email must be 254 characters or fewer`, `first_name must be 100 characters or fewer` (likewise `last_name`), `<field> must be a string`, `custom_fields must be an object`, `custom_fields may have at most 50 keys`, `custom_fields key "<key>" is not allowed (1–64 characters)`, `custom_fields.<key> must be 200 characters or fewer`, `custom_fields.<key> must be a string, number or boolean`, or `resubscribe must be true or false`.

```
{
  "error": "Contact not found"
}
```

`401`

No body.

`403` Missing permission, plan write gate, or the plan's contact cap is reached.

```
{
  "error": "Contact not found"
}
```

`409` A contact with this email already exists (with `resubscribe: true` the message points at `PATCH /api/v1/contacts/{id}`), or the address is on the workspace's suppression list: `unsubscribed` without `resubscribe: true`, or `bounced`, `complained` or `deleted` regardless.

```
{
  "error": "Contact not found"
}
```

`500`

No body.

### GET /api/v1/contacts/{id}

**Get a contact.** Returns one contact including its tags. Requires `contacts:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/contacts/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The contact.

```
{
  "contact": null
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`500`

No body.

### PATCH /api/v1/contacts/{id}

**Update a contact.** Partially updates a contact. Only `email`, `first_name`, `last_name`, `status`, `source` and `custom_fields` may be sent, plus the flags `resubscribe`, `suppress` and `replace_custom_fields`; any other key is rejected with `400`. An updated email is lowercased and validated.

**Custom fields merge.** `custom_fields` sets the keys you send and keeps every other key the contact already has; a key sent as `null` is removed. Send `replace_custom_fields: true` to replace the whole object instead (the behaviour before 2026-09-14). `{}` without the flag changes nothing.

**Unsubscribing.** Setting `status` to `unsubscribed` records `unsubscribed_at` — and only that: the address stays off campaigns but is not blocked, so a later import or signup form can bring it back as a subscriber. Add `suppress: true` to also put the address on the workspace's suppression list as `unsubscribed`, which no import, API call or form can undo until the person opts in again. The list is written after the update succeeds; a bounce or complaint already on file is never downgraded.

**Re-subscribing.** The suppression list is checked whenever the update would make a suppressed address a subscriber — renaming the contact to it, or setting `status` back to `subscribed` — and refuses with `409`. An `unsubscribed` entry can be lifted by sending `resubscribe: true` (you are asserting the person gave you new consent): the contact becomes `subscribed` with `subscribed_at` set to now, `unsubscribed_at` cleared and `source` unchanged, and the suppression entry is removed once the update succeeds. Bounced, complained and deleted addresses cannot be re-subscribed this way. Requires `contacts:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | no |  |
| `first_name` | string | no |  |
| `last_name` | string | no |  |
| `status` | ContactStatus | no | `archived` is out of the working list (hidden from listings unless asked for, never mailed, not counted) but not blocked. |
| `custom_fields` | object | no | Merged into the contact's custom fields: keys you send are set, a key sent as `null` is removed, every other key is kept. Values follow the CustomFields limits. Send `replace_custom_fields: true` to replace the whole object instead. |
| `source` | string | no |  |
| `language` | string \| null | no | ISO 639-1 code; `null` or an empty string clears it. An unknown code is a 400. |
| `replace_custom_fields` | boolean | no | With `true`, `custom_fields` replaces the contact's whole custom-field object (keys not sent are dropped) instead of merging. Requires `custom_fields` in the same request. |
| `resubscribe` | boolean | no | Set `true` when the person has given you new consent after unsubscribing. Sets `status` to `subscribed` (cannot be combined with another `status`), `subscribed_at` to now, clears `unsubscribed_at`, leaves `source` as it is, and removes the address's `unsubscribed` suppression entry. A bounced, complained or deleted address is still refused with `409`. |
| `suppress` | boolean | no | With `status: "unsubscribed"`, also block the address: it goes on the workspace's suppression list as `unsubscribed`, so no later import, API call or signup form can re-add it as a subscriber until the person opts in again. Without it an unsubscribe is a status only. Cannot be combined with `resubscribe`. |

#### Example request

```
curl -X PATCH "https://sendbeam.io/api/v1/contacts/id" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "first_name": "Janet",
  "custom_fields": {
    "plan": "business",
    "trial_ends": null
  }
}'
```

#### Responses

`200` Updated. With `suppress: true` the response also carries `suppressed` — whether the address is now on the suppression list.

```
{
  "contact": {
    "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "email": "jane@example.com",
    "first_name": "Jane",
    "last_name": "Doe",
    "status": "subscribed",
    "custom_fields": {
      "plan": "pro"
    },
    "source": "api",
    "language": "en",
    "subscribed_at": "2026-09-01T10:00:00.000Z",
    "unsubscribed_at": null,
    "created_at": "2026-09-01T10:00:00.000Z"
  },
  "suppressed": true
}
```

`400` Invalid JSON body, `Unknown field(s): <keys>. Updatable fields: email, first_name, last_name, status, source, custom_fields, language`, `status must be one of subscribed, unsubscribed, bounced, complained`, `custom_fields must be an object`, `Invalid email address`, `No updatable fields provided`, `resubscribe must be true or false`, `resubscribe: true cannot be combined with status <status>`, `suppress must be true or false`, `suppress: true requires status: unsubscribed`, `suppress: true cannot be combined with resubscribe: true`, `replace_custom_fields must be true or false`, or `replace_custom_fields: true requires custom_fields`.

```
{
  "error": "Unknown field(s): tenant_id. Updatable fields: email, first_name, last_name, status, source, custom_fields"
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`409` Another contact already uses this email, or the address is on the workspace's suppression list: `unsubscribed` without `resubscribe: true`, or `bounced`, `complained` or `deleted` regardless.

```
{
  "error": "Contact not found"
}
```

`500`

No body.

### DELETE /api/v1/contacts/{id}

**Delete a contact.** Permanently deletes a contact and its tag and list memberships. The address is added to the workspace's suppression list as `deleted` (so it cannot be re-created through the API or re-imported as subscribed) and replaced in the send log by an anonymous placeholder. A plain delete keeps a masked form of the address on that row and a workspace admin can lift the block later; `?mode=erase` is the request to be forgotten — the suppression row keeps the hash alone and is never lifted from the app. To remove a contact from the working list WITHOUT blocking the address, set `status: "archived"` with PATCH instead. Requires `contacts:write`.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `mode` | query | "delete" \| "erase" | no | `erase` for a data-subject erasure: hash-only, permanent block. |

#### Example request

```
curl -X DELETE "https://sendbeam.io/api/v1/contacts/id?mode=delete" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200`

No body.

`401`

No body.

`403`

No body.

`404`

No body.

`500`

No body.

### GET /api/v1/contacts/{id}/tags

**List a contact's tags.** Returns the tags attached to a contact with the time each was assigned. Requires `tags:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/contacts/id/tags" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` Tags on the contact.

```
{
  "tags": [
    {
      "id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33",
      "name": "Customer",
      "color": "#2563EB",
      "assigned_at": "2026-09-01T10:05:00.000Z"
    }
  ]
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`500`

No body.

### POST /api/v1/contacts/{id}/tags

**Add a tag to a contact.** Attaches an existing tag to the contact and enrols the contact in active `tag_added` automations whose `trigger_config.tag_id` matches. Requires `tags:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `tag_id` | string | yes |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/contacts/id/tags" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "tag_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33"
}'
```

#### Responses

`201` Tag attached.

```
{
  "contact_tag": {
    "contact_id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
    "tag_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33",
    "created_at": "2026-09-01T10:05:00.000Z"
  }
}
```

`400` Invalid JSON body or `tag_id is required`.

```
{
  "error": "tag_id is required"
}
```

`401`

No body.

`403`

No body.

`404` `Contact not found` or `Tag not found`.

```
{
  "error": "Tag not found"
}
```

`409` Contact already has this tag.

```
{
  "error": "Contact already has this tag"
}
```

`500`

No body.

### DELETE /api/v1/contacts/{id}/tags/{tagId}

**Remove a tag from a contact.** Detaches the tag from the contact. Requires `tags:write`.

#### Example request

```
curl -X DELETE "https://sendbeam.io/api/v1/contacts/id/tags/tagId" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200`

No body.

`401`

No body.

`403`

No body.

`404` `Contact not found`, `Tag not found`, or `This tag is not associated with the contact`.

```
{
  "error": "This tag is not associated with the contact"
}
```

`500`

No body.

### POST /api/v1/contacts/bulk

**Bulk action on contacts.** Runs one action over a set of contact IDs: `unsubscribe`, `archive`, `restore`, `delete`, `erase`, `add_tag`, `remove_tag`, `add_to_list`, `remove_from_list` or `export`. `archive` takes contacts out of the working list without touching their addresses (hidden from listings, never mailed, not counted; the status they had is kept for `restore`, which puts it back — a formerly subscribed contact whose address was blocked meanwhile comes back `unsubscribed`); it answers `503` until the workspace's database has the archive columns. `erase` is the data-subject request: like `delete`, but the suppression row keeps the hash alone and can never be lifted. Non-string IDs are dropped; IDs from other workspaces are ignored; `tag_id` / `list_id` must belong to this workspace. `export` responds with a CSV file instead of JSON. `unsubscribe` sets every selected `subscribed` contact to `unsubscribed` (`unsubscribed_at` now, one `contact.unsubscribed` event each) and leaves contacts already opted out alone; with `suppress: true` every selected address is also put on the suppression list as `unsubscribed` — blocked, not just marked — so no import, API call or signup form can re-add it until the person opts in again (a bounce or complaint already on file is never downgraded). Its response carries `unsubscribed`, `already_unsubscribed`, `suppressed` and `blocked`. `delete` also puts each address on the suppression list (reason `deleted`, source `delete`, masked form kept — a workspace admin can lift it) and pseudonymises it in the send log, exactly like deleting one contact. `add_to_list` adds only subscribed contacts, confirmed on a single opt-in list (they enrol in `list_joined` automations and fire `contact.list_joined`) or unconfirmed on a double opt-in list and on every Free-plan list — a bulk add never sends confirmation email; its response says how many were `added`, were `already_member`, or were `skipped_not_subscribed`. `remove_from_list` fires `contact.list_left` for each membership removed. Tag counts (`tagged`, `untagged`, `deleted`) are the number of IDs submitted; list counts are the number actually affected. Requires `contacts:write`; `export` additionally requires `contacts:export`, the same grant as `GET /api/v1/contacts/export`, and is refused before any contact is read without it.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `action` | "unsubscribe" \| "archive" \| "restore" \| "delete" \| "erase" \| "add_tag" \| "remove_tag" \| "add_to_list" \| "remove_from_list" \| "export" | yes |  |
| `contact_ids` | array of string | no | The contacts to act on. Either this or `filter`. |
| `filter` | object | no | Alternative to `contact_ids`: act on every contact matching the filter (up to 50,000) — the same `q` / `status` / `tag` / `list` / `source` / `from` / `to` the contacts page and `GET /api/v1/contacts` take. Archived contacts are included only when `status` is `archived`. |
| `tag_id` | string | no | Required for `add_tag` and `remove_tag`. |
| `list_id` | string | no | Required for `add_to_list` and `remove_from_list`. |
| `suppress` | boolean | no | `unsubscribe` only: also block every selected address on the suppression list. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/contacts/bulk" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "action": "add_tag",
  "contact_ids": [
    "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f"
  ],
  "tag_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33"
}'
```

#### Responses

`200` Action applied. JSON for `delete` / `add_tag` / `remove_tag`; a CSV attachment (`contacts-export.csv`, columns email,first_name,last_name,status,source,language,tags,created_at, then one `custom.<key>` column per custom field) for `export`.

```
{
  "success": true,
  "deleted": 1,
  "erased": true,
  "archived": 1,
  "already_archived": 1,
  "restored": 1,
  "not_archived": 1,
  "tagged": 1,
  "untagged": 1,
  "added": 1,
  "already_member": 1,
  "skipped_not_subscribed": 1,
  "membership": "confirmed",
  "removed": 1,
  "unsubscribed": 1,
  "already_unsubscribed": 1,
  "suppressed": 1,
  "blocked": true
}
```

`400` Invalid JSON body, `action is required`, `contact_ids array or filter is required`, `No valid contact IDs provided`, `suppress must be true or false`, `tag_id is required for add_tag action`, `tag_id is required for remove_tag action`, `list_id is required for add_to_list action`, `list_id is required for remove_from_list action`, or `Unknown action: <action>`.

```
{
  "error": "contact_ids array or filter is required"
}
```

`401`

No body.

`403`

No body.

`404` Tag not found in this workspace (`add_tag` and `remove_tag`), list not found (`add_to_list` and `remove_from_list`), or `No contacts match that filter` when `filter` was given and matched nobody.

```
{
  "error": "Tag not found"
}
```

`500` Bulk action failed.

```
{
  "error": "Bulk action failed"
}
```

`503` `archive` / `restore`: the workspace's database does not have the archive columns yet. Nothing was changed.

```
{
  "error": "Archiving is not available on this workspace yet: a database update is still to be applied. Nothing was changed."
}
```

### GET /api/v1/contacts/export

**Export contacts as CSV.** Every contact in the workspace (or those matching the same `q` / `status` / `tag` / `list` / `source` / `from` / `to` filters as `GET /api/v1/contacts`) as a CSV download with tags, lists and custom fields. Large audiences are exported in full; there is no page limit. Every cell is quoted, and a cell beginning with `=`, `+`, `-` or `@` is prefixed with a single quote so spreadsheets show it as text rather than evaluating it as a formula (the bulk `export` action does the same). Requires `contacts:export` — not `contacts:read`, which reads contacts a page at a time and cannot download the list.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `q` | query | string | no | Match against email, first name or last name. |
| `status` | query | ContactStatus | no | Without it, archived contacts are left out. |
| `tag` | query | string | no | Only contacts carrying this tag (tag id). |
| `list` | query | string | no | Only contacts on this list (list id). With `tag` as well, a contact must satisfy both. |
| `source` | query | string | no | Only contacts whose `source` is exactly this, e.g. `form`, `import` or `api`. |
| `from` | query | string | no | Only contacts added on or after this day (YYYY-MM-DD). |
| `to` | query | string | no | Only contacts added on or before this day (YYYY-MM-DD; the whole day counts). A date that is not real, such as `2026-02-31`, is ignored rather than rounded. |

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/contacts/export?q=string&status=subscribed" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` CSV file. Columns: email, first_name, last_name, status, source, language, tags, lists, created_at, subscribed_at, unsubscribed_at, then one `custom.<key>` column per custom field in use.

No body.

`401`

No body.

`403` The key lacks `contacts:export`. A key holding only `contacts:read` is refused here.

```
{
  "error": "Forbidden: contacts:export permission required"
}
```

### POST /api/v1/contacts/import

**Import contacts from CSV.** Uploads a CSV (multipart field `file`, at most 5 MB — roughly 50,000 rows) and creates contacts from it. Headers are case-insensitive with spaces and punctuation folded to underscores. Recognised columns: `email` (required), `first_name`, `last_name`, `status` (`subscribed`, `unsubscribed`, `bounced`, `complained`, `pending`; aliases `active`→subscribed, `cleaned`→bounced, `cancelled`/`canceled`→unsubscribed, `junk`/`spam`→complained, `unconfirmed`→pending; blank → `default_status`), `tags` (`;` or `|` separated, up to 20 per row, names up to 60 characters), `subscribed_at` (aliases `opted_in_at`, `signup_at`, `optin_time`, `created_at`; ISO 8601, `YYYY-MM-DD HH:MM:SS`, `YYYY-MM-DD` or `DD/MM/YYYY`; unparseable → now, future → now), `unsubscribed_at` (aliases `opted_out_at`, `unsub_time`), `source` (per row, up to 40 characters, overrides the request field), and the consent columns `consent_ip`, `consent_at` (aliases `confirm_time`, `opt_in_confirmed_at`) and `consent_source`, stored under `custom_fields` as `consent_ip`, `consent_confirmed_at` (normalised ISO 8601) and `consent_source`. Every other column becomes a custom field keyed by its snake_cased header (values up to 200 characters; keys that fail validation or exceed the 50-key limit are listed in `dropped_columns`; empty cells store nothing). Rows with `pending` status are never imported (`skipped_unconfirmed`). `unsubscribed`, `bounced` and `complained` rows are created with that status, `unsubscribed_at` set from the file or now, and recorded on the workspace suppression list (`suppressed`); they do not count towards the plan's contact cap — only rows that will be `subscribed` do, and the file is read only up to that allowance (plus one), so a `403` means the subscribed rows exceed the slots left. Addresses already on the suppression list are imported as `unsubscribed` whatever the file says. Repeated addresses within the file are merged (strongest status wins; tags and fields union). Existing contacts: with `skip_duplicates=true` (default) their names and status are left alone but the file's tags are attached, its custom fields merged in (file values win for the keys it carries), and a `subscribed` contact is escalated to the file's `unsubscribed`/`bounced`/`complained` status (never the reverse) — these are counted in `updated`; `skip_duplicates=false` also refreshes their names and source. Tags are matched case-insensitively against existing names and created when missing (at most 200 distinct names per import). `list_id` adds every subscribed contact in the file to that list: confirmed on a single opt-in list (`list_added`), unconfirmed on a double opt-in list or in a Free-plan workspace (`list_pending_confirmation`) — the importer never sends confirmation emails. **No automation runs for an imported contact unless `run_automations=true`**: an import brings people in, which is not the same decision as greeting them, and a list moved from another provider is full of people who were welcomed long ago. With it, newly created subscribed contacts enter active `contact_created` automations and contacts confirmed onto a list enter its `list_joined` automations; `automations_enrolled` reports how many enrolments that produced. Rows whose email is missing, malformed or over 254 characters, whose name is over 100 characters, or whose status is unrecognised are counted as `invalid`. Requires `contacts:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `file` | string | yes | UTF-8 CSV with an `email` column; see the operation description for every recognised column. Comma, semicolon and tab delimiters are detected from the header row. |
| `default_status` | "subscribed" \| "unsubscribed" | no | Status for rows whose `status` cell is blank or absent. |
| `source` | string | no | Source label for rows without a `source` cell. |
| `skip_duplicates` | "true" \| "false" | no | `true` keeps existing contacts' names (tags, custom fields and any opt-out status from the file are still applied); `false` also refreshes their names and source from the file. |
| `run_automations` | "true" \| "false" | no | Whether this import starts the workspace's automations for the contacts it creates — `contact_created`, and `list_joined` where `list_id` adds them to a list. **Defaults to `false`**, so an import never emails anybody by itself. Before 2026-09-22 these always ran and could not be switched off. |
| `check_domains` | "true" \| "false" | no | Skip rows whose domain cannot receive mail (does not exist, publishes a null MX, or has neither MX nor A/AAAA records), reported in `skipped_undeliverable` and `undeliverable_domains`. Only a definite DNS answer skips a row; well-known providers are not looked up and at most 500 distinct domains are checked per import. `false` skips the DNS lookups. Placeholder addresses — `example.com` / `.net` / `.org` and their subdomains, the reserved `.test`, `.invalid`, `.localhost` and `.example` domains, and the `abuse@` / `postmaster@` role mailboxes — are always skipped and named in `placeholder_addresses`, whatever this option says. |
| `tags` | string | no | Tags to attach to every contact in the file, `;` or `\|` separated (same rules as the `tags` column). Created when missing. |
| `list_id` | string | no | Add every subscribed contact in the file to this list. Unconfirmed on a double opt-in list (and every list in a Free-plan workspace); no confirmation email is sent by the import. |
| `mapping` | string | no | Optional JSON array describing what each column becomes — what the import page's mapping screen sends. Each entry is `{ column, target }` with `column` the 0-based index in the header row and `target` one of `email`, `first_name`, `last_name`, `status`, `tags`, `source`, `subscribed_at`, `unsubscribed_at`, `consent_ip`, `consent_at`, `consent_source`, `ignore`, or `custom` with a `key` (letters, digits, underscores). A column not listed is ignored. Exactly one column must be `email`; a single-value target may be mapped from one column only (the three date targets may take several — the first usable date wins); a custom key may be used once and cannot be a consent key. Without `mapping`, the headers are read as described above. |
| `new_fields` | string | no | Optional JSON array of custom fields to declare in the registry before the rows are typed against it: `{ key, type, label?, options? }` with `type` one of `text`, `number`, `boolean`, `date`, `dropdown` (a dropdown needs `options`). Each key must be one the `mapping` maps a column to. A field that already exists is used as it is; a type or options problem stops the import with `400` (or `409` when stored values would not fit the type — see the custom-fields API). While the registry migration is pending the keys import as untyped text and `fields_registry` is `unavailable`. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/contacts/import" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` Import finished.

```
{
  "imported": 118,
  "updated": 6,
  "skipped": 6,
  "skipped_duplicates": 6,
  "suppressed": 23,
  "skipped_unconfirmed": 4,
  "invalid": 2,
  "skipped_undeliverable": 4,
  "undeliverable_domains": [
    {
      "domain": "gmial.com",
      "rows": 2
    },
    {
      "domain": "example.com",
      "rows": 1,
      "reason": "example domain — reserved for documentation, never a real mailbox"
    },
    {
      "domain": "oldcompany.co.uk",
      "rows": 1
    }
  ],
  "skipped_placeholder": 1,
  "placeholder_addresses": [
    {
      "email": "test@example.com",
      "reason": "example domain — reserved for documentation, never a real mailbox"
    }
  ],
  "tags_created": 2,
  "tags_attached": 140,
  "custom_field_keys": [
    "company",
    "consent_ip",
    "consent_confirmed_at",
    "consent_source"
  ],
  "dropped_columns": [
    "Notes (long)"
  ],
  "list_added": 0,
  "list_pending_confirmation": 95,
  "truncated": false
}
```

`400` `Request must be multipart/form-data`, `Failed to parse form data`, `A file field named "file" is required`, `default_status must be subscribed or unsubscribed`, an invalid `mapping` (`mapping must map one column to email`, `mapping[1].target: "Email" is mapped from two columns`, …) or an invalid `new_fields` entry.

```
{
  "error": "A file field named \"file\" is required"
}
```

`401`

No body.

`403` Missing permission, plan write gate, or the file's subscribed rows exceed the plan's remaining contact slots (suppressed rows never count).

```
{
  "error": "Contact not found"
}
```

`404` `list_id` does not match a list in this workspace.

```
{
  "error": "List not found"
}
```

`413` The upload is larger than 5 MB (judged from `Content-Length` and again from the file itself).

```
{
  "error": "The file is too large. Uploads are limited to 5 MB — split the CSV and import it in parts."
}
```

`422` No importable rows in the file — no `email` header, every row invalid, or every row `pending`. The body also carries the `invalid` and `skipped_unconfirmed` counts.

```
{
  "error": "No valid contacts found. The CSV must have an \"email\" column header and at least one data row.",
  "invalid": 3,
  "skipped_unconfirmed": 0
}
```

`500` A batch failed to insert.

```
{
  "error": "Failed to import contacts",
  "details": "duplicate key value violates unique constraint"
}
```

## Custom fields

The registry of custom fields contacts carry: each has an immutable key, a label, a type (`text`, `number`, `boolean`, `date`, `dropdown`) and, for a dropdown, its options. Values written anywhere are checked against the type (`contacts:read` / `contacts:write`).

### GET /api/v1/custom-fields

**List custom fields.** Every custom field declared in the workspace, in display order, with how many contacts carry each key (`uses`, null when usage counts are unavailable). Also lists `unregistered` keys — keys contacts carry that no field names yet (register them with `POST /custom-fields/scan`) — and `unaddressable` keys, which contain characters a merge tag cannot use and are never registered. Requires `contacts:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/custom-fields" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The registry.

```
{
  "fields": [
    {
      "id": "5e2b8c1a-4d3f-4a6b-9c8d-1e2f3a4b5c6d",
      "key": "plan",
      "label": "Plan",
      "type": "dropdown",
      "options": [
        "free",
        "pro",
        "business"
      ],
      "position": 0,
      "uses": 1180,
      "created_at": "2026-09-14T09:00:00.000Z",
      "updated_at": "2026-09-14T09:00:00.000Z"
    },
    {
      "id": "6f3c9d2b-5e4a-4b7c-8d9e-2f3a4b5c6d7e",
      "key": "renewal_date",
      "label": "Renewal date",
      "type": "date",
      "options": [],
      "position": 1,
      "uses": 312,
      "created_at": "2026-09-14T09:00:00.000Z",
      "updated_at": "2026-09-14T09:00:00.000Z"
    }
  ],
  "unregistered": [
    {
      "key": "legacy_score",
      "uses": 4
    }
  ],
  "unaddressable": [],
  "usage_available": true
}
```

`401`

No body.

`403`

No body.

`503` The registry is not available yet (its database update has not been applied); contact fields keep working untyped.

```
{
  "error": "Contact not found"
}
```

### POST /api/v1/custom-fields

**Create a custom field.** Declares a field. `key` is 1–64 characters of letters, digits and underscores, unique in the workspace and **cannot be changed afterwards** (change the `label`, or delete the field and add another). `type` defaults to `text`; a `dropdown` needs at least one option. A key contacts already carry may be declared — if the chosen type does not fit some stored values the request is refused with `409` and the count (`violations`), until `confirm_violations: true` is sent; stored values are never rewritten. At most 100 fields per workspace. Requires `contacts:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | string | yes |  |
| `label` | string | no | Defaults to the key with underscores as spaces, capitalised. |
| `type` | "text" \| "number" \| "boolean" \| "date" \| "dropdown" | no |  |
| `options` | array of string | no | Required for `dropdown`; ignored otherwise. |
| `confirm_violations` | boolean | no | Declare the field even though some stored values do not fit the type (they are kept as they are). |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/custom-fields" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "key": "string",
  "label": "string",
  "type": "text",
  "options": [
    "string"
  ],
  "confirm_violations": false
}'
```

#### Responses

`201` Created.

```
{
  "field": {
    "id": "5e2b8c1a-4d3f-4a6b-9c8d-1e2f3a4b5c6d",
    "key": "plan",
    "label": "Plan",
    "type": "dropdown",
    "options": [
      "free",
      "pro",
      "business"
    ],
    "position": 0,
    "created_at": "2026-09-14T09:00:00.000Z",
    "updated_at": "2026-09-14T09:00:00.000Z"
  }
}
```

`400` `key is required`, `key must be 1–64 characters of letters, digits and underscores…`, `label must be 80 characters or fewer`, `type must be one of text, number, boolean, date, dropdown`, `a dropdown field needs at least one option`, `a dropdown may have at most 100 options`, or `A workspace may have at most 100 custom fields.`

```
{
  "error": "Contact not found"
}
```

`401`

No body.

`403`

No body.

`409` The key exists, or stored values do not fit the chosen type (then `violations` is present: `count`, `examples`, `partial`).

```
{
  "error": "3 contacts hold a value for \"renewal_date\" that is not a valid date (for example \"soon\"). Those values are kept exactly as they are and will be flagged on each contact until someone corrects them. Send confirm_violations: true to change the type anyway.",
  "violations": {
    "count": 3,
    "examples": [
      "soon"
    ],
    "partial": false
  }
}
```

`503` The registry is not available yet.

```
{
  "error": "Contact not found"
}
```

### PATCH /api/v1/custom-fields/{key}

**Update a custom field.** Changes `label`, `type` and/or `options`. The key cannot be changed (`400`). A type or option change that stored values would violate is refused with `409` and the count until `confirm_violations: true` is sent; even then no stored value is altered — each is flagged on its contact until someone corrects it, and `violations` in the `200` body says how many. Requires `contacts:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | string | no |  |
| `type` | "text" \| "number" \| "boolean" \| "date" \| "dropdown" | no |  |
| `options` | array of string | no |  |
| `confirm_violations` | boolean | no |  |

#### Example request

```
curl -X PATCH "https://sendbeam.io/api/v1/custom-fields/key" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "label": "string",
  "type": "text",
  "options": [
    "string"
  ],
  "confirm_violations": false
}'
```

#### Responses

`200` Updated.

```
{
  "field": {
    "id": "5e2b8c1a-4d3f-4a6b-9c8d-1e2f3a4b5c6d",
    "key": "plan",
    "label": "Plan",
    "type": "dropdown",
    "options": [
      "free",
      "pro",
      "business"
    ],
    "position": 0,
    "created_at": "2026-09-14T09:00:00.000Z",
    "updated_at": "2026-09-14T09:00:00.000Z"
  },
  "violations": {
    "count": 1,
    "examples": [
      "string"
    ],
    "partial": true
  }
}
```

`400` `A field's key cannot be changed…`, `Nothing to update: send label, type or options.`, or a label/type/options validation message.

```
{
  "error": "Contact not found"
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`409` Stored values do not fit the new type or options; `violations` is present.

```
{
  "error": "string",
  "violations": {
    "count": 1,
    "examples": [
      "string"
    ],
    "partial": true
  }
}
```

`503` The registry is not available yet.

```
{
  "error": "Contact not found"
}
```

### DELETE /api/v1/custom-fields/{key}

**Delete a custom field.** Removes the field **and its value from every contact** in the workspace, and takes it off every signup form that collected it. Segment rules, automation rules and `{{custom_fields.<key>}}` merge tags that name it are left as they are and stop matching or resolving (the pre-send check reports them). Cannot be undone. A key that an integration keeps sending is registered again, as Text, the next time it arrives. Requires `contacts:write`.

#### Example request

```
curl -X DELETE "https://sendbeam.io/api/v1/custom-fields/key" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` Deleted.

```
{
  "ok": true,
  "key": "legacy_score",
  "contacts_cleared": 4,
  "forms_updated": 0
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`503` The registry is not available yet.

```
{
  "error": "Contact not found"
}
```

### POST /api/v1/custom-fields/scan

**Register keys already in use.** Scans the workspace's contacts for keys that have no field yet and declares each one. The type is inferred only where every stored value agrees (all booleans → `boolean`, all numbers → `number`, all `YYYY-MM-DD` strings → `date`), otherwise `text`, so a scan never declares a type a stored value violates. Keys with characters a merge tag cannot use are reported in `unaddressable` and left alone. Requires `contacts:write`.

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/custom-fields/scan" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` What was registered.

```
{
  "ok": true,
  "added": [
    {
      "id": "7a4d0e3c-6f5b-4c8d-9e0f-3a4b5c6d7e8f",
      "key": "legacy_score",
      "label": "Legacy score",
      "type": "number",
      "options": [],
      "position": 2
    }
  ],
  "unaddressable": [
    "Plan Name"
  ]
}
```

`401`

No body.

`403`

No body.

`503` The registry is not available yet.

```
{
  "error": "Contact not found"
}
```

## Suppressions

Addresses that are never emailed: unsubscribes, bounces, complaints and deleted contacts. Import your old platform's lists here before your first send.

### GET /api/v1/suppressions

**Suppression counts, or check one address.** Without parameters: how many addresses are on the workspace's suppression list, split by reason. With `?email=`: whether that one address is suppressed, with its reason and when it was added. Addresses are stored as one-way hashes; the list itself is paged by `GET /api/v1/suppressions/list` and downloaded by `GET /api/v1/suppressions/export`. Requires `contacts:read`.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `email` | query | string | no | Check this address (case-insensitive; surrounding whitespace ignored). |

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/suppressions?email=jane%40example.com" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` Counts by reason (no `email`), or the result for one address.

```
{
  "count": 1204,
  "by_reason": {
    "unsubscribed": 1130,
    "bounced": 61,
    "complained": 9,
    "deleted": 4
  }
}
```

`400` `email` is not a valid address.

```
{
  "error": "email must be a valid email address"
}
```

`401`

No body.

`403`

No body.

`500`

No body.

### POST /api/v1/suppressions

**Import suppressions (JSON or CSV).** Puts addresses on the workspace's suppression list so they are never emailed. Use it first when migrating: load the old platform's unsubscribes, bounces and complaints before importing contacts or sending.

Send either a JSON body (`entries`, at most 5,000 per request) or `multipart/form-data` with a CSV in `file` (at most 5 MB; header row with `email` and optional `reason`, or one bare address per line). Each entry's `reason` is `unsubscribed`, `bounced` or `complained`; blank means the request-level default `reason` (itself defaulting to `unsubscribed`). Common spellings from other platforms are accepted: `cleaned`, `hard bounce`, `invalid` → `bounced`; `cancelled`, `canceled`, `opted out` → `unsubscribed`; `junk`, `spam`, `abuse`, `complaint` → `complained`. `deleted` cannot be imported.

A stronger reason (complained > bounced > unsubscribed > deleted) replaces a weaker one; a weaker one never downgrades. Repeated addresses in one request are folded together and the extra rows counted as `unchanged`, so `received = added + upgraded + unchanged + invalid`. Rows with a malformed address (or over 254 characters) or an unknown reason are counted as `invalid` and skipped.

Existing contacts with a matching address whose status is still `subscribed` are switched to the imported status with `unsubscribed_at` set to now (`contacts_updated`); contacts that are already unsubscribed, bounced or complained are left as they are. No contact is ever created. Work is batched 200 addresses at a time; a database failure part-way returns `500` and the earlier batches stay written, so the request is safe to repeat. Requires `contacts:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `reason` | SuppressionImportReason | no | Reasons an import may set (`deleted` is reserved for contact deletion). Aliases such as `cleaned`, `cancelled`, `junk` and `spam` are accepted and mapped. |
| `entries` | array of string \| object | yes |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/suppressions" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "reason": "unsubscribed",
  "entries": [
    "jane@example.com"
  ]
}'
```

#### Responses

`200` Import finished (also when every row was invalid).

```
{
  "received": 3,
  "added": 2,
  "upgraded": 1,
  "unchanged": 0,
  "invalid": 0,
  "contacts_updated": 1,
  "by_reason": {
    "unsubscribed": 1,
    "bounced": 1,
    "complained": 1
  }
}
```

`400` `Invalid JSON body`, `entries array is required`, `entries must not be empty`, `entries: at most 5,000 per request (send several requests, or upload a CSV)`, `reason must be unsubscribed, bounced or complained`, `Failed to parse form data`, or `A file field named "file" is required`.

```
{
  "error": "entries array is required"
}
```

`401`

No body.

`403`

No body.

`413` The upload is larger than 5 MB.

```
{
  "error": "The file is too large. Uploads are limited to 5 MB — split the CSV and import it in parts."
}
```

`422` The CSV has no data rows (or no `email` header).

```
{
  "error": "No rows found. The CSV needs an \"email\" column header (optionally \"reason\") and at least one data row.",
  "invalid": 0
}
```

`500` A batch failed to write. `details` carries the database message; repeat the request once the cause is fixed.

```
{
  "error": "Failed to import suppressions",
  "details": "connection reset"
}
```

### GET /api/v1/suppressions/list

**Browse the suppression list.** A page of the suppression list, newest first. Each row carries the reason, when it was added, where it came from (`source`), a masked display form of the address (`j***@example.com`) and its domain — stored on rows written since 2026-09-14, derived at read time from a contact row that still carries the address for older rows, and null for the rest (older hash-only rows and erasures; those are never back-filled). `contact` is that still-existing contact, when there is one: the only place the full address appears. `liftable` says whether `POST /api/v1/suppressions/lift` would accept the row. `details_available` is false while the workspace's database is missing the display columns (every detail is then null and `domain` is ignored). Requires `contacts:read`.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `page` | query | integer | no | Page number, starting at 1. |
| `limit` | query | integer | no |  |
| `reason` | query | SuppressionReason | no |  |
| `domain` | query | string | no | Case-insensitive substring of the stored domain. |

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/suppressions/list?page=1&limit=50" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` A page of rows.

```
{
  "suppressions": [
    {
      "email_hash": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8",
      "reason": "bounced",
      "source": "provider",
      "created_at": "2026-09-14T09:12:41.000Z",
      "email_masked": "j***@example.com",
      "email_domain": "example.com",
      "contact": {
        "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
        "email": "jane@example.com",
        "status": "bounced"
      },
      "liftable": false
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 50,
    "total": 1,
    "total_pages": 1
  },
  "details_available": true
}
```

`400` `reason` is not one of the four reasons.

```
{
  "error": "reason must be unsubscribed, bounced, complained or deleted"
}
```

`401`

No body.

`403`

No body.

`500`

No body.

### GET /api/v1/suppressions/export

**Export the suppression list as CSV.** The whole list (or one reason / domain) as a CSV download, newest first. Columns: `email` (filled only where a contact row still carries the address), `email_masked`, `domain`, `reason`, `source`, `added_at`, `contact_id`. Every cell is quoted and formula-safe, like the contacts export. Requires `contacts:export`, the same grant as the contacts export; the paged list and the counts stay on `contacts:read`.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `reason` | query | SuppressionReason | no |  |
| `domain` | query | string | no |  |

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/suppressions/export?reason=unsubscribed&domain=string" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` CSV file (`suppressions.csv`, or `suppressions-<reason>.csv`).

No body.

`400` `reason` is not one of the four reasons.

```
{
  "error": "Contact not found"
}
```

`401`

No body.

`403` The key lacks `contacts:export`.

```
{
  "error": "Forbidden: contacts:export permission required"
}
```

`500`

No body.

### POST /api/v1/suppressions/lift

**Lift the block a deleted contact left.** Takes a `deleted` suppression off the list so the address can be re-added by an import, the API or a signup form. Refused with `409` for a bounce or a complaint (permanent), an unsubscribe (that needs the person's new consent — re-subscribe the contact, or `resubscribe: true`), and an erasure (the person asked to be forgotten; only they can return, through a form). Identify the row by `email` or by the `email_hash` from the list. Session callers must hold the workspace admin role (`403` otherwise); an API key needs `contacts:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | no |  |
| `email_hash` | string | no |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/suppressions/lift" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "jane@example.com",
  "email_hash": "string"
}'
```

#### Responses

`200` Lifted.

```
{
  "lifted": true,
  "reason": "deleted"
}
```

`400` Invalid JSON body, `email or email_hash is required`, or a malformed value.

```
{
  "error": "email or email_hash is required"
}
```

`401`

No body.

`403` Missing `contacts:write`, or a session caller who is not a workspace admin.

```
{
  "error": "Only a workspace admin can lift a block. Ask a workspace admin, or ask to be given the admin role under Settings → Team."
}
```

`404` The address is not on the suppression list.

```
{
  "error": "This address is not on the suppression list."
}
```

`409` The row cannot be lifted; `reason` says why.

```
{
  "error": "This address previously bounced and cannot be re-added.",
  "reason": "bounced"
}
```

`500`

No body.

## Imports

Pull an audience straight from Mailchimp, MailerLite, Kit, Brevo or EmailOctopus with an API key the customer supplies. Keys are used for the one request and never stored.

### POST /api/v1/imports/connect

**Check a platform API key and list what it can import.** Validates a Mailchimp, MailerLite, Kit, Brevo or EmailOctopus API key with one cheap call and returns the account, the lists it can see (Mailchimp audiences, MailerLite groups, Kit tags, Brevo lists, EmailOctopus lists — MailerLite and Kit also offer an "All subscribers" entry with id `""`) with subscriber counts, and the source's capabilities (what comes across: opt-in dates, consent evidence, bounces, complaints, tags, custom fields; plus notes and limitations). Credentials are used for this request only: never stored, never logged, never echoed. For Mailchimp the data centre is read from the key's `-usNN` suffix (pass `credentials.dc` if your key has none). Requires `contacts:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `source` | "mailchimp" \| "mailerlite" \| "kit" \| "brevo" \| "emailoctopus" | yes |  |
| `credentials` | object | yes |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/imports/connect" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "source": "mailchimp",
  "credentials": {
    "api_key": "string",
    "dc": "string"
  }
}'
```

#### Responses

`200` The key works.

```
{
  "ok": true,
  "source": "mailchimp",
  "label": "Mailchimp",
  "account": {
    "name": "Acme Newsletter",
    "email": "jane@acme.example"
  },
  "lists": [
    {
      "id": "a1b2c3d4e5",
      "name": "Newsletter",
      "member_count": 1180,
      "suppressed_count": 42,
      "kind": "audience"
    }
  ],
  "requires_list": true,
  "capabilities": {
    "platform": "mailchimp",
    "label": "Mailchimp",
    "provides": {
      "subscribed_at": true,
      "consent_ip": true,
      "consent_timestamp": true,
      "consent_source": true,
      "bounces": true,
      "complaints": false,
      "unsubscribes": true,
      "tags": true,
      "custom_fields": true
    },
    "notes": [
      "\"cleaned\" members are imported as bounced."
    ],
    "limitations": [
      "No complaint (spam report) status is exposed on members."
    ]
  },
  "subrequests": 2
}
```

`400` Bad request, or the platform rejected the key (`ok: false`).

```
{
  "error": "Contact not found"
}
```

`401`

No body.

`403`

No body.

`503` The platform could not be reached or returned an error other than an auth failure. The message never contains the key.

```
{
  "ok": false,
  "error": "Kit request failed: GET https://api.kit.com/v4/account: HTTP 503"
}
```

### POST /api/v1/imports/run

**Import contacts from a platform.** Streams the platform's subscribers into the workspace: each source page is normalised, its unsubscribed / bounced / complained addresses are written to the suppression list **first**, then the page goes through the same write path as the CSV importer (contacts, tags, custom fields, optional list membership, `contact_created` and `list_joined` automations). Status mapping — Mailchimp `cleaned` → bounced, `pending` → not imported, `archived`/`transactional` → skipped (`skipped_other`); MailerLite `junk` → complained, `unconfirmed` → not imported; Kit `cancelled` → unsubscribed, `inactive` → not imported. Consent evidence becomes the custom fields `consent_ip`, `consent_confirmed_at`, `consent_source` where the platform exposes it.

Caps per run: 25,000 contacts written (rounded up to the end of the source page in progress), plus a request budget and a time budget. When a cap is hit, or the plan has no room for the next page of subscribed contacts, the run stops between pages and returns `truncated: true`, `stop_reason` (`max_contacts`, `subrequests`, `time`, `contact_limit`) and `next_hint` with a `cursor`; pass it back as `options.cursor` (with the same `source` and `list_ids`) to continue from that page. Re-runs are idempotent: existing contacts are skipped (`skipped_duplicates`), tags and fields are additive, a suppressed status always wins over subscribed and a stronger suppression reason never downgrades.

Kit: tags and signup attribution arrive with each subscriber (`include=tags,attribution`), so any number of tags is mapped in one pass. Mailchimp: members with more than 50 tags have their full tag set fetched separately (a bounded number of such lookups per run; the rest are counted in `tags_truncated`). Brevo: a blacklisted contact costs one extra call to tell a bounce, a complaint and an unsubscribe apart (a bounded number of such lookups per run; the rest are recorded as unsubscribed). `list_ids` are audience ids (Mailchimp, required), group ids (MailerLite), tag ids (Kit), list ids (Brevo), or list ids (EmailOctopus, required); an empty id or no `list_ids` means all subscribers for MailerLite, Kit and Brevo. Credentials are used for this request only and never stored or logged. Requires `contacts:write`.

#### Request body

See the OpenAPI document for the body schema.

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/imports/run" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d 'null'
```

#### Responses

`200` The run finished or stopped cleanly at a cap (`truncated`). Totals cover this run only.

```
{
  "ok": true,
  "source": "mailchimp",
  "imported": 1102,
  "updated": 3,
  "skipped_duplicates": 3,
  "suppressed": 42,
  "skipped_unconfirmed": 11,
  "skipped_other": 2,
  "skipped_suppressed": 0,
  "invalid": 1,
  "tags_created": 4,
  "tags_attached": 1580,
  "tags_skipped": false,
  "tags_truncated": 0,
  "custom_field_keys": [
    "company",
    "consent_ip",
    "consent_confirmed_at",
    "consent_source"
  ],
  "list_added": 1102,
  "list_pending_confirmation": 0,
  "contacts_read": 1161,
  "contacts_written": 1147,
  "pages": 2,
  "subrequests": 2,
  "db_requests": 31,
  "duration_ms": 6120,
  "truncated": false,
  "stop_reason": null,
  "next_hint": null,
  "capabilities": {
    "platform": "mailchimp"
  }
}
```

`400` Bad request (`source`, `credentials`, `list_ids`, `options.max_contacts`, `options.cursor`), or the platform rejected the key.

```
{
  "error": "list_ids is required for Mailchimp: pass at least one list id from /api/v1/imports/connect"
}
```

`401`

No body.

`403`

No body.

`404` `options.list_id` does not match a list in this workspace.

```
{
  "error": "List not found"
}
```

`500` A database write failed part-way. The body carries the totals written so far; earlier pages stay written, so run again.

```
{
  "error": "Failed to import contacts",
  "details": "connection reset"
}
```

`503` The platform failed part-way (network error or a non-auth error). The body carries the totals written so far.

```
{
  "error": "MailerLite request failed: GET https://connect.mailerlite.com/api/subscribers: HTTP 500"
}
```

## Tags

Labels you attach to contacts. Adding a tag can trigger automations.

### GET /api/v1/tags

**List tags.** Returns all tags in the workspace sorted by name. Requires `tags:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/tags" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` All tags.

```
{
  "tags": [
    {
      "id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33",
      "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
      "name": "Customer",
      "color": "#2563EB",
      "created_at": "2026-08-20T09:00:00.000Z"
    }
  ]
}
```

`401`

No body.

`403`

No body.

`500`

No body.

### POST /api/v1/tags

**Create a tag.** Creates a tag. Names are unique per workspace. `color` defaults to `#6B7280`. Requires `tags:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes |  |
| `color` | string | no | CSS colour, typically a hex code. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/tags" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Customer",
  "color": "#2563EB"
}'
```

#### Responses

`201` Created.

```
{
  "tag": {
    "id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "Customer",
    "color": "#2563EB",
    "created_at": "2026-08-20T09:00:00.000Z"
  }
}
```

`400` Invalid JSON body or `name is required`.

```
{
  "error": "name is required"
}
```

`401`

No body.

`403`

No body.

`409` A tag with this name already exists.

```
{
  "error": "A tag with this name already exists"
}
```

`500`

No body.

### GET /api/v1/tags/{id}

**Get a tag.** Returns one tag with `contact_count`, the number of contacts carrying it. Requires `tags:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/tags/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The tag.

```
{
  "tag": null
}
```

`401`

No body.

`403`

No body.

`404` Tag not found in this workspace.

```
{
  "error": "Tag not found"
}
```

### PATCH /api/v1/tags/{id}

**Update a tag.** Renames or recolours a tag. Empty strings are ignored. Requires `tags:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no |  |
| `color` | string | no |  |

#### Example request

```
curl -X PATCH "https://sendbeam.io/api/v1/tags/id" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "VIP customer"
}'
```

#### Responses

`200` Updated.

```
{
  "tag": {
    "id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "Customer",
    "color": "#2563EB",
    "created_at": "2026-08-20T09:00:00.000Z"
  }
}
```

`400`

No body.

`401`

No body.

`403`

No body.

`404` Tag not found.

```
{
  "error": "Tag not found"
}
```

`409` A tag with this name already exists.

```
{
  "error": "A tag with this name already exists"
}
```

`500`

No body.

### DELETE /api/v1/tags/{id}

**Delete a tag.** Deletes the tag and removes it from every contact. Requires `tags:write`.

#### Example request

```
curl -X DELETE "https://sendbeam.io/api/v1/tags/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200`

No body.

`401`

No body.

`403`

No body.

`404` Tag not found.

```
{
  "error": "Tag not found"
}
```

`500`

No body.

## Lists

Named groups of contacts. Lists can require double opt-in.

### GET /api/v1/lists

**List lists.** Returns every list in the workspace, newest first. Requires `lists:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/lists" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` All lists.

```
{
  "lists": [
    {
      "id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
      "name": "Newsletter",
      "description": "Weekly product updates",
      "double_optin": false,
      "offerable_across_account": false,
      "created_at": "2026-08-20T09:00:00.000Z"
    }
  ]
}
```

`401`

No body.

`403`

No body.

`500`

No body.

### POST /api/v1/lists

**Create a list.** Creates a list. Names are unique per workspace. Set `double_optin: true` to require email confirmation before members are mailed (Free-plan workspaces are double opt-in on every list regardless). Requires `lists:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes |  |
| `description` | string | no |  |
| `double_optin` | boolean | no | Require members to confirm by email before campaigns reach them. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/lists" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Newsletter",
  "description": "Weekly product updates",
  "double_optin": false
}'
```

#### Responses

`201` Created.

```
{
  "list": {
    "id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "Newsletter",
    "description": "Weekly product updates",
    "double_optin": false,
    "offerable_across_account": false,
    "created_at": "2026-08-20T09:00:00.000Z"
  }
}
```

`400` Invalid JSON body or `name is required`.

```
{
  "error": "name is required"
}
```

`401`

No body.

`403`

No body.

`409` A list with this name already exists.

```
{
  "error": "A list with this name already exists"
}
```

`500`

No body.

### GET /api/v1/lists/{id}

**Get a list.** Returns one list with its member count. Requires `lists:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/lists/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The list.

```
{
  "list": null
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`500`

No body.

### PATCH /api/v1/lists/{id}

**Update a list.** Renames a list, changes its description (an empty `description` clears it; an empty `name` is ignored), switches double opt-in on or off (a change to `double_optin` only affects people who join from then on), or marks the list offerable to people who subscribed on your other workspaces (`offerable_across_account` — see the List schema for what that does and does not do). Requires `lists:write`. `offerable_across_account` needs more than the scope: only a signed-in workspace admin of a workspace that belongs to an account may change it, so a request carrying it from an API key, a member seat or a workspace with no account is refused with `403` and nothing else in the body is applied.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no |  |
| `description` | string | no |  |
| `double_optin` | boolean | no |  |
| `offerable_across_account` | boolean | no | Offer this list on the preference pages of your other workspaces. Signed-in workspace admin of a workspace on an account only; `403` otherwise. |

#### Example request

```
curl -X PATCH "https://sendbeam.io/api/v1/lists/id" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "description": "Monthly digest",
  "double_optin": true
}'
```

#### Responses

`200` Updated.

```
{
  "list": {
    "id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "Newsletter",
    "description": "Weekly product updates",
    "double_optin": false,
    "offerable_across_account": false,
    "created_at": "2026-08-20T09:00:00.000Z"
  }
}
```

`400`

No body.

`401`

No body.

`403`

No body.

`404`

No body.

`409` A list with this name already exists.

```
{
  "error": "A list with this name already exists"
}
```

`500`

No body.

### DELETE /api/v1/lists/{id}

**Delete a list.** Deletes the list and its memberships. Contacts themselves are kept. Requires `lists:write`.

#### Example request

```
curl -X DELETE "https://sendbeam.io/api/v1/lists/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200`

No body.

`401`

No body.

`403`

No body.

`404`

No body.

`500`

No body.

### GET /api/v1/lists/{id}/contacts

**List members of a list.** Returns the contacts in a list, most recently added first, with pagination. Each contact carries `added_at`. Requires `lists:read`.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `page` | query | integer | no | Page number, starting at 1. |
| `limit` | query | integer | no | Items per page (1–100). |

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/lists/id/contacts?page=1&limit=50" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` A page of members.

```
{
  "contacts": [
    null
  ],
  "pagination": {
    "page": 1,
    "limit": 50,
    "total": 1234,
    "total_pages": 25
  }
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`500`

No body.

### POST /api/v1/lists/{id}/contacts

**Add a contact to a list.** Adds an existing `subscribed` contact to the list. On a double opt-in list — or in a Free-plan workspace, where every list is double opt-in — the membership is created unconfirmed (`membership: "pending_confirmation"`, `confirmed: false`) and the contact is emailed a confirmation link; campaigns to the list skip them until they click it, and `list_joined` automations enrol them at that point. Otherwise the membership is confirmed immediately (`membership: "confirmed"`) and `list_joined` automations targeting this list (or any list) enrol the contact now. `double_optin_sent` says whether a confirmation email went out on this request: confirmations to one address are throttled, so a pending membership can come back with `double_optin_sent: false`. Posting the same contact again while they are still unconfirmed sends the confirmation again (subject to that throttle) and returns `200` with `already_member: true`; a contact who is already a confirmed member is a `409`. A contact whose status is not `subscribed` is refused with `422`. Requires `lists:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `contact_id` | string | yes |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/lists/id/contacts" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "contact_id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f"
}'
```

#### Responses

`200` The contact was already an unconfirmed member of this double opt-in list; the confirmation email was sent again (unless one went to this address recently).

```
{
  "list_contact": {
    "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "contact_id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
    "created_at": "2026-09-01T10:10:00.000Z",
    "confirmed": false
  },
  "double_optin_sent": true,
  "membership": "pending_confirmation",
  "already_member": true
}
```

`201` Added — confirmed at once, or pending the contact's confirmation on a double opt-in list.

```
{
  "list_contact": {
    "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "contact_id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
    "created_at": "2026-09-01T10:10:00.000Z",
    "confirmed": true
  },
  "double_optin_sent": true,
  "membership": "confirmed",
  "already_member": true
}
```

`400` Invalid JSON body or `contact_id is required`.

```
{
  "error": "contact_id is required"
}
```

`401`

No body.

`403`

No body.

`404` `List not found` or `Contact not found`.

```
{
  "error": "Contact not found"
}
```

`409` Contact is already a confirmed member of this list.

```
{
  "error": "Contact is already in this list"
}
```

`422` The contact is not `subscribed` (unsubscribed, bounced or complained contacts cannot join a list).

```
{
  "error": "Cannot add a contact with status: unsubscribed. Only subscribed contacts can be added to a list."
}
```

`500`

No body.

### DELETE /api/v1/lists/{id}/contacts

**Remove a contact from a list.** Removes the contact from the list. The contact ID is sent in the JSON body. Requires `lists:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `contact_id` | string | yes |  |

#### Example request

```
curl -X DELETE "https://sendbeam.io/api/v1/lists/id/contacts" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "contact_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
}'
```

#### Responses

`200`

No body.

`400` Invalid JSON body or `contact_id is required`.

```
{
  "error": "contact_id is required"
}
```

`401`

No body.

`403`

No body.

`404` `List not found` or `Contact is not in this list`.

```
{
  "error": "Contact is not in this list"
}
```

`500`

No body.

### POST /api/v1/lists/{id}/confirmations

**Send confirmation emails to pending members.** Sends (or re-sends) the double opt-in confirmation email to members of the list who have not confirmed yet — the deliberate counterpart of a bulk add or an import, which never send one. Only unconfirmed memberships are read, and the token is written only while the row is still unconfirmed, so a member who has already clicked can never be mailed by this. A member emailed anything in the last 24 hours is skipped (`skipped_recent`), a member no longer `subscribed` is skipped (`skipped_not_subscribed`), at most 500 are mailed per request (`remaining` says how many are left; `stopped: "cap"`), and five consecutive provider refusals stop the run (`stopped: "send_failures"` — plan quota, paused workspace, sender problem). Pass `contact_ids` to limit the run to some members. Each email counts against the monthly allowance. Requires `lists:write`.

#### Request body (optional)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `contact_ids` | array of string | no | Only these members (still only the unconfirmed ones among them). |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/lists/id/confirmations" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "contact_ids": [
    "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
  ]
}'
```

#### Responses

`200` What was done.

```
{
  "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "pending": 12,
  "sent": 10,
  "skipped_recent": 2,
  "skipped_not_subscribed": 0,
  "failed": 0,
  "remaining": 0,
  "cap": 500,
  "stopped": null
}
```

`400` Invalid JSON body or `contact_ids` malformed.

```
{
  "error": "contact_ids must be an array of contact ids"
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`422` The list is single opt-in (and the plan does not force double opt-in): there is nothing to confirm.

```
{
  "error": "This list does not use double opt-in, so there is nothing to confirm."
}
```

`500`

No body.

## Segments

Saved rule sets that select contacts dynamically.

### GET /api/v1/segments

**List segments.** Returns every segment, newest first, each with a live `contact_count` (raw rule match) and `reachable_count` (the subscribed subset a campaign could actually reach). Requires `segments:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/segments" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` All segments.

```
{
  "segments": [
    null
  ]
}
```

`401`

No body.

`403`

No body.

`500`

No body.

### POST /api/v1/segments

**Create a segment.** Saves a named rule set. All rules must match (AND). Requires `segments:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes |  |
| `rules` | array of SegmentRule | yes |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/segments" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "London customers",
  "rules": [
    {
      "field": "custom_fields.region",
      "operator": "equals",
      "value": "London"
    },
    {
      "field": "status",
      "operator": "equals",
      "value": "subscribed"
    }
  ]
}'
```

#### Responses

`201` Created.

```
{
  "segment": {
    "id": "2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "London customers",
    "rules": [
      {
        "field": "custom_fields.region",
        "operator": "equals",
        "value": "London"
      }
    ],
    "created_at": "2026-08-25T12:00:00.000Z"
  }
}
```

`400`

No body.

`401`

No body.

`403`

No body.

`500`

No body.

### GET /api/v1/segments/{id}

**Get a segment.** Returns one segment with the same live `contact_count` and `reachable_count` the list endpoint computes. Requires `segments:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/segments/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The segment.

```
{
  "segment": null
}
```

`401`

No body.

`403`

No body.

`404` Segment not found in this workspace.

```
{
  "error": "Segment not found"
}
```

### PATCH /api/v1/segments/{id}

**Update a segment.** Renames a segment and/or replaces its rules. An empty `rules` array is ignored. Requires `segments:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no |  |
| `rules` | array of SegmentRule | no |  |

#### Example request

```
curl -X PATCH "https://sendbeam.io/api/v1/segments/id" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "string",
  "rules": [
    {
      "field": "custom_fields.region",
      "operator": "equals",
      "value": "London"
    }
  ]
}'
```

#### Responses

`200` Updated.

```
{
  "segment": {
    "id": "2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "London customers",
    "rules": [
      {
        "field": "custom_fields.region",
        "operator": "equals",
        "value": "London"
      }
    ],
    "created_at": "2026-08-25T12:00:00.000Z"
  }
}
```

`401`

No body.

`403`

No body.

`404` Segment not found.

```
{
  "error": "Segment not found"
}
```

`500`

No body.

### DELETE /api/v1/segments/{id}

**Delete a segment.** Deletes the segment. Returns `204` with no body. Requires `segments:write`.

#### Example request

```
curl -X DELETE "https://sendbeam.io/api/v1/segments/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`204` Deleted.

No body.

`401`

No body.

`403`

No body.

`404` Segment not found.

```
{
  "error": "Segment not found"
}
```

`500`

No body.

### POST /api/v1/segments/preview

**Preview rules before saving.** Counts the contacts matching an ad-hoc rule set and returns up to 5 sample contacts. A read: it is exempt from the Pro-plan write gate. Requires `segments:read`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `rules` | array of SegmentRule | yes |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/segments/preview" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "rules": [
    {
      "field": "email",
      "operator": "contains",
      "value": "@example.com"
    }
  ]
}'
```

#### Responses

`200`

No body.

`400`

No body.

`401`

No body.

`403`

No body.

`500`

No body.

### GET /api/v1/segments/{id}/preview

**Preview a saved segment.** Counts the contacts matching a saved segment and returns up to 5 sample contacts. Requires `segments:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/segments/id/preview" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200`

No body.

`401`

No body.

`403`

No body.

`404` Segment not found.

```
{
  "error": "Segment not found"
}
```

`500`

No body.

## Campaigns

One-off email sends to a list, a segment or everyone.

### GET /api/v1/campaigns

**List campaigns.** Returns campaigns, newest first, with pagination. Optionally filter by status. Requires `campaigns:read`.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `page` | query | integer | no | Page number, starting at 1. |
| `limit` | query | integer | no | Items per page (1–100). |
| `status` | query | CampaignStatus | no |  |

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/campaigns?page=1&limit=50" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` A page of campaigns.

```
{
  "campaigns": [
    {
      "id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
      "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
      "name": "September newsletter",
      "subject": "What's new this month",
      "from_name": "Acme",
      "from_email": "hello@acme.com",
      "html_content": "<h1>Hello {{first_name}}</h1>",
      "text_content": "Hello {{first_name}}",
      "status": "draft",
      "send_to_type": "list",
      "send_to_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "scheduled_at": null,
      "sent_at": null,
      "stats_sent": 0,
      "stats_delivered": 0,
      "stats_opened": 0,
      "stats_clicked": 0,
      "stats_bounced": 0,
      "stats_unsubscribed": 0,
      "created_at": "2026-09-01T11:00:00.000Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 50,
    "total": 1234,
    "total_pages": 25
  }
}
```

`401`

No body.

`403`

No body.

`500`

No body.

### POST /api/v1/campaigns

**Create a campaign.** Creates a draft campaign. `send_to_type` chooses the audience: `all` (every subscribed contact), `list` or `segment` (then `send_to_id` must reference one this workspace owns). A draft may be created without a `subject` (the campaign wizard saves as soon as there is a name); the send endpoint refuses a campaign whose subject is still empty. `html_content` is limited to 500,000 characters and `text_content` to 200,000 (`413`). Send a `draft_key` of your own (a UUID) to make the create idempotent: a second create with the same key returns the existing draft with `200` instead of making another. Nothing is sent until you call the send endpoint. Requires `campaigns:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `draft_key` | string | no | Optional idempotency key (a UUID, or up to 64 letters, digits, `-`, `_`), unique per workspace. A repeat create with the same key returns the existing draft (`200`). |
| `language` | string \| null | no | ISO 639-1 code of the language the campaign's own subject and body are written in (#80). Null clears it. |
| `languages` | object \| null | no | Translations keyed by ISO 639-1 code, up to five, each with its own `subject` and `html_content` (optional `text_content`, `blocks`). A contact whose `language` matches a key receives that version; everyone else the campaign's own. Keys must differ from `language`. Cannot be combined with `ab_test` (`400`). Null clears them. |
| `subject_b` | string | no | A second subject line to test against `subject` — the two-version shape. Setting it (with `ab_test`) arms a subject-line test; `null` removes a subject test. Ignored when `ab_test.variants` is given. |
| `send_pace_hours` | integer \| null | no | Spread the send over this many hours (see Campaign); null for the workspace default. |
| `send_at_best_time` | boolean | no | Send each contact at their usual open hour (see Campaign). Not with `ab_test`. |
| `ab_test` | object | no | A/B test settings. Either `subject_b` plus these settings (a two-version subject test), or `test_on` plus `variants` — up to four extra versions (B–E) of the subject line, the from name or the email content; version A is the campaign itself. On send, `sample_pct` of the audience is split evenly between the versions, and after `wait_minutes` the version with the better rate on `metric` goes to everyone else. An audience under two recipients per version sends plain with version A (state `skipped`). `null` removes the test. |
| `name` | string | yes |  |
| `subject` | string | no | May be omitted or empty on a draft; required to send. |
| `from_name` | string | no |  |
| `from_email` | string | yes | Stored with the campaign. Delivery uses the workspace's saved sender details, which must be the workspace's own shared address or an address on a sending domain it has verified; that check runs on every send. |
| `html_content` | string | no | Supports `{{first_name}}`, `{{last_name}}`, `{{email}}`, `{{custom_fields.}}`, `{{workspace_name}}` (the workspace's name — the sender's business), `{{sender_name}}` (the From name), `{{unsubscribe_url}}` and `{{web_version_url}}` merge tags, each with an optional fallback after a pipe — `{{first_name\|there}}` — used when the contact has no value. Write `\\|` for a literal pipe in the fallback. Conditional content: `{{#if custom_fields.plan is "pro"}}…{{else}}…{{/if}}`, with the operators is, is not, contains, does not contain, starts with, ends with, is set, is not set, is greater than and is less than, nesting one level. A tag or condition that does not resolve is sent exactly as typed. At most 500,000 characters. |
| `text_content` | string | no | The same merge tags and conditional blocks as `html_content`, inserted unescaped. At most 200,000 characters. |
| `blocks` | array of BuilderBlock | no | The visual builder's block JSON, when `html_content` was rendered from it. Optional; `null` or absent means HTML only. |
| `send_to_type` | SendToType | yes |  |
| `send_to_id` | string \| null | no | Required when `send_to_type` is `list` or `segment`. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/campaigns" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "draft_key": "string",
  "language": "en",
  "languages": {
    "fr": {
      "subject": "Nouveautés de septembre",
      "html_content": "<p>Bonjour…</p>"
    }
  },
  "subject_b": "string",
  "send_pace_hours": null,
  "send_at_best_time": true,
  "ab_test": {
    "sample_pct": 20,
    "wait_minutes": 120,
    "metric": "opens",
    "test_on": "subject",
    "variants": [
      {
        "subject": "string",
        "from_name": "string",
        "html_content": "string",
        "text_content": "string",
        "blocks": [],
        "send_offset_minutes": 60
      }
    ]
  },
  "name": "September newsletter",
  "subject": "What'\''s new this month",
  "from_name": "Acme",
  "from_email": "hello@acme.com",
  "html_content": "<h1>Hello {{first_name}}</h1>",
  "text_content": "Hello {{first_name}}",
  "blocks": [
    {
      "id": "block_1",
      "type": "text",
      "props": {
        "heading": "This month",
        "text": "Hi {{first_name|there}},",
        "padding": 24
      }
    }
  ],
  "send_to_type": "all",
  "send_to_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
}'
```

#### Responses

`200` A draft with this `draft_key` already exists in the workspace; it is returned unchanged.

```
{
  "campaign": {
    "id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "September newsletter",
    "subject": "What's new this month",
    "from_name": "Acme",
    "from_email": "hello@acme.com",
    "html_content": "<h1>Hello {{first_name}}</h1>",
    "text_content": "Hello {{first_name}}",
    "status": "draft",
    "send_to_type": "list",
    "send_to_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "scheduled_at": null,
    "sent_at": null,
    "stats_sent": 0,
    "stats_delivered": 0,
    "stats_opened": 0,
    "stats_clicked": 0,
    "stats_bounced": 0,
    "stats_unsubscribed": 0,
    "created_at": "2026-09-01T11:00:00.000Z"
  },
  "existing": true
}
```

`201` Created draft.

```
{
  "campaign": {
    "id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "September newsletter",
    "subject": "What's new this month",
    "from_name": "Acme",
    "from_email": "hello@acme.com",
    "html_content": "<h1>Hello {{first_name}}</h1>",
    "text_content": "Hello {{first_name}}",
    "status": "draft",
    "send_to_type": "list",
    "send_to_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "scheduled_at": null,
    "sent_at": null,
    "stats_sent": 0,
    "stats_delivered": 0,
    "stats_opened": 0,
    "stats_clicked": 0,
    "stats_bounced": 0,
    "stats_unsubscribed": 0,
    "created_at": "2026-09-01T11:00:00.000Z"
  }
}
```

`400` Invalid JSON body, `Campaign name is required.`, `From email is required.`, `Invalid send_to_type. Must be all, list, or segment.`, `A list must be selected.` / `A segment must be selected.`, or a malformed `draft_key`.

```
{
  "error": "Campaign name is required."
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`413`

No body.

`500` Failed to create campaign.

```
{
  "error": "Failed to create campaign."
}
```

### GET /api/v1/campaigns/{id}

**Get a campaign.** Returns one campaign with a `stats` object computed from its per-recipient send records. Requires `campaigns:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/campaigns/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The campaign.

```
{
  "campaign": null
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`500`

No body.

### PATCH /api/v1/campaigns/{id}

**Update a campaign.** Updates a `draft` or `scheduled` campaign. Only the listed fields are accepted; others are ignored. `name` may not be emptied; `subject` may be empty on a draft but not on a scheduled campaign. When `send_to_type` is `list` or `segment`, `send_to_id` must be supplied in the same request and belong to this workspace. `blocks` (the visual builder's block JSON) is stored when sent; sending new `html_content` without `blocks` clears them. To avoid overwriting a concurrent edit, send `expected_updated_at` — the `updated_at` you last read; if the campaign has changed since, nothing is written and the answer is `409` with `code: "stale"` and the current `updated_at`. Requires `campaigns:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `expected_updated_at` | string \| null | no | The `updated_at` you last read. When the campaign has changed since, the update is refused with `409` `code: "stale"`. Omit or send `null` to skip the check. |
| `name` | string | no | May not be emptied. |
| `subject` | string | no | May be empty on a draft, not on a scheduled campaign. |
| `from_name` | string | no |  |
| `from_email` | string | no |  |
| `html_content` | string | no | At most 500,000 characters (`400` otherwise). |
| `text_content` | string | no | At most 200,000 characters (`400` otherwise). |
| `send_to_type` | SendToType | no |  |
| `send_to_id` | string \| null | no |  |

#### Example request

```
curl -X PATCH "https://sendbeam.io/api/v1/campaigns/id" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "subject": "What'\''s new in September",
  "send_to_type": "all",
  "send_to_id": null
}'
```

#### Responses

`200` Updated.

```
{
  "campaign": {
    "id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "September newsletter",
    "subject": "What's new this month",
    "from_name": "Acme",
    "from_email": "hello@acme.com",
    "html_content": "<h1>Hello {{first_name}}</h1>",
    "text_content": "Hello {{first_name}}",
    "status": "draft",
    "send_to_type": "list",
    "send_to_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "scheduled_at": null,
    "sent_at": null,
    "stats_sent": 0,
    "stats_delivered": 0,
    "stats_opened": 0,
    "stats_clicked": 0,
    "stats_bounced": 0,
    "stats_unsubscribed": 0,
    "created_at": "2026-09-01T11:00:00.000Z"
  }
}
```

`400` Invalid JSON body, `No valid fields to update.`, `Campaign name is required.`, `A scheduled campaign needs a subject line.`, `Invalid send_to_type.`, `A list must be selected.` / `A segment must be selected.`, `html_content is too large (max 500,000 characters).`, or `text_content is too large (max 200,000 characters).`

```
{
  "error": "Contact not found"
}
```

`401`

No body.

`403`

No body.

`404` `Campaign not found.` or `The selected list or segment was not found.`

```
{
  "error": "Campaign not found."
}
```

`409` Campaign is not editable, or (`code: "stale"`) it changed since the `expected_updated_at` you sent — re-read it and try again.

```
{
  "error": "string",
  "code": "stale",
  "updated_at": "2026-09-02T12:00:00Z"
}
```

`500` Failed to update campaign.

```
{
  "error": "Failed to update campaign."
}
```

### DELETE /api/v1/campaigns/{id}

**Delete a draft or cancelled campaign.** Removes a campaign that never went out. Sent and sending campaigns are kept as records (`409`); cancel a scheduled campaign first. Requires `campaigns:write`.

#### Example request

```
curl -X DELETE "https://sendbeam.io/api/v1/campaigns/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`204` Deleted.

No body.

`401`

No body.

`403`

No body.

`404` Campaign not found.

```
{
  "error": "Campaign not found"
}
```

`409` The campaign is scheduled, sending or sent.

```
{
  "error": "Only draft or cancelled campaigns can be deleted; this one is sent."
}
```

### POST /api/v1/campaigns/{id}/send

**Send or schedule a campaign.** With a `scheduled_at` in the future the campaign becomes `scheduled` and is sent by the scheduler at that time (200). Without a body (or without `scheduled_at`) the audience is resolved now — subscribed contacts only; on a double opt-in list only confirmed members; on the Free plan only contacts who have confirmed a subscription, whatever the audience — the plan's monthly email quota is checked for the whole audience, recipients are queued and the campaign becomes `sending` (202). Delivery drains in the background, fairly across every sending campaign and within the plan's hourly ceiling (what does not fit waits for the next hour); the workspace's sender address is re-validated on every send, rows that cannot be sent are recorded as failed with the reason, and the campaign flips to `sent` when the queue is empty. Only `draft` or `scheduled` campaigns can be sent. `GET /api/v1/campaigns/{id}/audience` returns the recipient count and the plan check the send will apply, without sending. Requires `campaigns:send` — authoring a campaign (`campaigns:write`) does not permit mailing its audience.

#### Request body (optional)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `scheduled_at` | string | no | ISO 8601 timestamp in the future. Omit to send immediately. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/campaigns/id/send" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "scheduled_at": "2026-09-10T09:00:00Z"
}'
```

#### Responses

`200` Scheduled.

```
{
  "id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
  "status": "scheduled",
  "scheduled_at": "2026-09-10T09:00:00.000Z"
}
```

`202` Queued for immediate delivery.

```
{
  "id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
  "status": "sending",
  "queued": 1834,
  "skipped_placeholder": 0
}
```

`400` `scheduled_at must be a valid future date.` or `No subscribed contacts found for this audience.`

```
{
  "error": "No subscribed contacts found for this audience."
}
```

`401`

No body.

`403` Missing permission, plan write gate, sending paused for this workspace, or the audience would exceed the plan's monthly email quota.

```
{
  "error": "Contact not found"
}
```

`404`

No body.

`409` Campaign is not in `draft` or `scheduled` state.

```
{
  "error": "Cannot send a campaign with status 'sent'."
}
```

`500` `Failed to schedule campaign.` or `Failed to create send queue.`

```
{
  "error": "Failed to create send queue."
}
```

### GET /api/v1/campaigns/{id}/audience

**Preview a campaign's audience.** Returns how many contacts the campaign would reach if it were sent now, computed by exactly the resolver the send endpoint and the scheduler use: `subscribed` contacts only; on a double opt-in list only confirmed members; on the Free plan only contacts who have confirmed a subscription, whatever the audience. `can_send` says whether `POST /api/v1/campaigns/{id}/send` would be accepted right now (campaign is `draft` or `scheduled`, at least one recipient, sending not paused, and the audience fits the plan's remaining monthly email quota); when it is false, `reason` is the message the send would fail with. `placeholders` names subscribed contacts that can never receive mail — addresses on `example.com` / `.net` / `.org`, on a reserved `.test`, `.invalid`, `.localhost` or `.example` domain, or `abuse@` / `postmaster@`. The send leaves them out (the same rule the import applies), so they are not in `recipients`; they are named so they can be archived or deleted. `placeholder_count` is the total when more than twenty are named. Nothing is changed and nothing is sent. The audience is resolved again at send time, so a count taken earlier can differ if contacts join, leave, unsubscribe or confirm in between. Requires `campaigns:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/campaigns/id/audience" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The audience as it stands now.

```
{
  "audience": {
    "send_to_type": "all",
    "send_to_id": null,
    "recipients": 1
  },
  "can_send": true,
  "reason": "string",
  "placeholders": [
    {
      "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
      "email": "string",
      "kind": "example_domain",
      "reason": "string"
    }
  ],
  "placeholder_count": 1
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`500`

No body.

### POST /api/v1/campaigns/{id}/test

**Send a test copy.** Sends the campaign as a test — to the signed-in person by default, or to `to` (a list of addresses, or one comma-separated string), each copy with `[Test]` in front of the subject, the same From, merge tags and footer as the real send, and a working view-in-browser link. With `contact_id` (a contact of this workspace) the merge tags are rendered with that contact's name, email and custom fields while the copies still go to `to`; the unsubscribe link in a test is bound to the test, never to a real contact. Test copies are not written to the sends ledger, do not count against the plan and do not appear in the campaign's stats. The number of addresses per test is capped per plan, and there is an hourly allowance of test emails per workspace. Session only: the default recipient is the signed-in person, so an API key gets `400`. Requires `campaigns:write` (and `contacts:read` with `contact_id`).

#### Request body (optional)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `to` | array of string \| string | no | Recipients. Defaults to the signed-in person. |
| `contact_id` | string \| null | no | Render the copy as this contact. |
| `language` | string \| null | no | Send this language version (a code the campaign is written in, or one of its `languages`); a code the campaign has no version for is a 400. Default: the chosen contact's language, else the campaign's own. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/campaigns/id/test" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "to": [
    "jane@example.com"
  ],
  "contact_id": null,
  "language": null
}'
```

#### Responses

`200` What went out.

```
{
  "ok": true,
  "to": [
    "me@acme.com",
    "colleague@acme.com"
  ],
  "failed": [],
  "subject": "[Test] What's new this month",
  "rendered_as": {
    "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
    "email": "jane@client.com"
  },
  "warnings": []
}
```

`400` Not a session, an address that is not one, more addresses than the plan allows, a malformed `contact_id`, a campaign with no content yet, or a workspace whose sender address is not one it may send as (the campaign's own from address is used when it is allowed, otherwise the workspace sender — the same rule as the real send).

```
{
  "error": "colleague@ is not a valid email address"
}
```

`401`

No body.

`403`

No body.

`404` `Campaign not found.` or `Contact not found.`

```
{
  "error": "Contact not found."
}
```

`429` The workspace's hourly test-email allowance is used up.

```
{
  "error": "Contact not found"
}
```

`503` The provider accepted no copy; `failed` names each address and reason.

```
{
  "error": "Contact not found"
}
```

### POST /api/v1/campaigns/preview

**Render an email for one contact.** Resolves the merge tags in `subject`, `html` and `text` exactly as a send would, for the contact named by `contact_id` (one of this workspace's) or, without one, for a stand-in built from the signed-in person (empty fields for an API key). `{{web_version_url}}` becomes the campaign's plain web-version page when `campaign_id` is given; `{{unsubscribe_url}}` is a dead link — a preview can never act on the contact. Conditional blocks (`{{#if custom_fields.plan is "pro"}}…{{else}}…{{/if}}`) in `html` and `text` are evaluated for that contact; a subject line never carries one and is returned with its block tags as typed. Nothing is stored or sent. Same size caps as campaign creation (`413`). Requires `campaigns:read`, plus `contacts:read` with `contact_id`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subject` | string | no |  |
| `html` | string | no |  |
| `text` | string | no |  |
| `contact_id` | string \| null | no |  |
| `campaign_id` | string \| null | no |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/campaigns/preview" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "subject": "string",
  "html": "string",
  "text": "string",
  "contact_id": null,
  "campaign_id": null
}'
```

#### Responses

`200` The rendered parts.

```
{
  "subject": "Hi Jane",
  "html": "<p>Your plan: Gold</p>",
  "text": "",
  "rendered_as": {
    "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
    "email": "jane@client.com",
    "name": "Jane Doe"
  }
}
```

`400` Invalid JSON body, nothing to render, or a malformed `contact_id` / `campaign_id`.

```
{
  "error": "Contact not found"
}
```

`401`

No body.

`403`

No body.

`404` `Contact not found.` or `Campaign not found.`

```
{
  "error": "Contact not found."
}
```

`413`

No body.

### POST /api/v1/campaigns/audience

**Preview an audience before saving a campaign.** The same answer as `GET /api/v1/campaigns/{id}/audience` for an audience that is not saved yet: send `send_to_type` (`all`, `list` or `segment`) and, for a list or segment, its `send_to_id`. The campaign wizard's Review step calls this before the draft exists. `can_send` is evaluated as for a draft. Nothing is changed and nothing is sent. Requires `campaigns:read`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `send_to_type` | SendToType | yes |  |
| `send_to_id` | string \| null | no | Required for `list` and `segment`; ignored for `all`. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/campaigns/audience" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "send_to_type": "list",
  "send_to_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
}'
```

#### Responses

`200` The audience as it stands now.

```
{
  "audience": {
    "send_to_type": "all",
    "send_to_id": null,
    "recipients": 1
  },
  "can_send": true,
  "reason": "string",
  "placeholders": [
    {
      "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
      "email": "string",
      "kind": "example_domain",
      "reason": "string"
    }
  ],
  "placeholder_count": 1
}
```

`400` Invalid JSON body, `send_to_type must be one of all, list, segment`, or `send_to_id is required for a <type> audience`.

```
{
  "error": "send_to_id is required for a list audience"
}
```

`401`

No body.

`403`

No body.

`500`

No body.

### POST /api/v1/campaigns/{id}/cancel

**Cancel a scheduled campaign, or stop one that is sending.** Moves a `scheduled` campaign to `cancelled` so it will not be sent. A `sending` campaign — one going out over a day at each contact's best time, or a send-time test with a version still to come — is stopped instead: the recipients not yet sent are removed, `status_reason` records how many, and the campaign completes as `sent` within the minute (the response carries `status: sending` and `stopped`). Campaigns in any other state answer `409`. Requires `campaigns:write`.

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/campaigns/id/cancel" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` Cancelled.

```
{
  "id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
  "status": "cancelled"
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`409` Not scheduled.

```
{
  "error": "Only scheduled campaigns can be cancelled. This campaign has status 'draft'."
}
```

`500` Failed to cancel campaign.

```
{
  "error": "Failed to cancel campaign."
}
```

### POST /api/v1/campaigns/subject-ideas

**Suggest subject lines.** Five alternative subject lines written from the current subject and the email's text (the first ~1,500 characters, merge tags removed). Nothing is stored. There is a daily allowance per workspace. `503` when the installation has no suggestion service configured. Requires `campaigns:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subject` | string | no |  |
| `html_content` | string | no |  |
| `audience` | string | no | A label for who receives it, e.g. the list name. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/campaigns/subject-ideas" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "subject": "string",
  "html_content": "string",
  "audience": "string"
}'
```

#### Responses

`200` Suggestions.

```
{
  "ideas": [
    {
      "subject": "Three things you asked for, now live",
      "why": "direct, names the payoff"
    }
  ]
}
```

`400` Neither a subject nor content was given.

```
{
  "error": "Give a subject or some email content to work from."
}
```

`401`

No body.

`403`

No body.

`429` The plan's daily allowance of drafts is used.

```
{
  "error": "Contact not found"
}
```

`503` Not enabled on this installation, the service is busy, or no usable suggestions came back.

```
{
  "error": "Contact not found"
}
```

### GET /api/v1/campaigns/{id}/report

**Links clicked, email clients and poll answers.** Per-link click counts (`clicks` = every click, `unique` = distinct recipients), the email clients and devices behind the human opens, and the answers to any poll block in the email, all from the tracked open and click events. Machine opens and clicks (link scanners, mail-client prefetchers) are excluded. Gmail and Yahoo proxy images, so their device is reported as `Unknown`. `polls` holds one entry per poll block, in the email's order — its `id` is the block's, `question` and the option `label`s come from the campaign's block JSON (a blank question and `Option n` labels when the campaign has none) — with a `count` per option and `answers`, the number of recipients who answered. One answer per recipient: a person who clicks twice is counted once, for their latest click. Poll answers count in the top-level `clicks` but are not listed under `links`. `revenue` — the orders your store sent to SendBeam that were attributed to this campaign (see /docs/ecommerce/revenue) — is present only when there is at least one such order: a campaign that earned nothing and a campaign whose store sends no orders are different answers. Requires `campaigns:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/campaigns/id/report" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The report.

```
{
  "campaign_id": "2f9c1b1e-3f9e-4a3b-9c2f-1d1e2f3a4b5c",
  "links": [
    {
      "url": "https://example.com/post",
      "clicks": 41,
      "unique": 33
    }
  ],
  "clients": [
    {
      "name": "Gmail",
      "count": 120
    },
    {
      "name": "Apple Mail",
      "count": 64
    }
  ],
  "devices": [
    {
      "name": "Unknown",
      "count": 120
    },
    {
      "name": "Mobile",
      "count": 50
    },
    {
      "name": "Desktop",
      "count": 14
    }
  ],
  "opens": 184,
  "clicks": 63,
  "polls": [
    {
      "id": "block_1758540000000_k3j9x2",
      "question": "How useful was this email?",
      "answers": 22,
      "options": [
        {
          "index": 1,
          "label": "Very useful",
          "count": 14
        },
        {
          "index": 2,
          "label": "Somewhat",
          "count": 6
        },
        {
          "index": 3,
          "label": "Not really",
          "count": 2
        }
      ]
    }
  ],
  "revenue": {
    "orders": 12,
    "total": 1240.5,
    "currency": "GBP",
    "by_currency": [
      {
        "currency": "GBP",
        "total": 1240.5,
        "orders": 12
      }
    ]
  }
}
```

`401`

No body.

`403`

No body.

`404` Campaign not found.

```
{
  "error": "Campaign not found"
}
```

### GET /api/v1/campaigns/{id}/ab-test

**A/B test state and live counts.** The test settings and state stored on the campaign, what it varies (`test_on`), each version (`versions[]`: `letter`, `label`, and the `subject`, `from_name`, or `send_offset_minutes` + `release_at` it tests) and live per-version counts from the send queue (`live.a`, `live.b`, … : `sent`, `opened`, `clicked`, and — once an order your store sent in has been attributed to that version — `revenue`, `orders`, `currency`). Requires `campaigns:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/campaigns/id/ab-test" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` Test state.

```
{
  "ab_test": {},
  "test_on": "subject",
  "subject": "string",
  "subject_b": "string",
  "versions": [
    {
      "letter": "a",
      "label": "string",
      "subject": "string",
      "from_name": "string"
    }
  ],
  "live": {}
}
```

`401`

No body.

`403`

No body.

`404` Campaign not found, or it has no A/B test.

```
{
  "error": "This campaign has no A/B test"
}
```

### POST /api/v1/campaigns/{id}/ab-test

**End an A/B test now.** Settles a running test straight away: with `{"winner": "a" | "b" | … }` that version goes to the remaining recipients (it must be one of the versions the test sent); with no body the version with the better rate on the test's metric wins — opens or clicks per recipient, or for a `revenue` test the most attributed revenue per recipient (ties, including no orders at all, go to the earlier letter). The queue worker does the same automatically once `decide_at` passes. Requires `campaigns:write`.

#### Request body (optional)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `winner` | "a" \| "b" \| "c" \| "d" \| "e" | no |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/campaigns/id/ab-test" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "winner": "a"
}'
```

#### Responses

`200` The decided state.

```
{
  "ab_test": {}
}
```

`400` `winner` is not a version letter, or not one of this test's versions, or `Invalid JSON body`.

```
{
  "error": "winner must be one of \"a\", \"b\", \"c\", \"d\", \"e\""
}
```

`401`

No body.

`403`

No body.

`404` Campaign not found.

```
{
  "error": "Campaign not found"
}
```

`409` The campaign is not running a test (not sent yet, already decided, skipped, or no test) — or it is a send-time test whose later versions have not all gone out yet and no `winner` was named (`Not every send time has gone out yet — wait, or choose a version yourself`).

```
{
  "error": "This campaign is not running an A/B test"
}
```

### POST /api/v1/campaigns/{id}/duplicate

**Duplicate a campaign.** Creates a new draft copying the content and audience of an existing campaign. The copy is named `<original name> (Copy)`. With the optional body `{"audience": "non_openers"}` on a sent campaign, the copy is named `<original name> (Send again to non-openers)` and its audience is a segment called `Did not open: <original name>` with the single rule `campaign not_opened <id>` — the people the original reached who have not opened it, re-evaluated at send time; the segment is reused when the same campaign is sent again later. Requires `campaigns:write`.

#### Request body (optional)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `audience` | "non_openers" | no | Aim the copy at the people who received the original and did not open it. Only valid on a sent campaign. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/campaigns/id/duplicate" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "audience": "non_openers"
}'
```

#### Responses

`201` The new draft.

```
{
  "campaign": {
    "id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "September newsletter",
    "subject": "What's new this month",
    "from_name": "Acme",
    "from_email": "hello@acme.com",
    "html_content": "<h1>Hello {{first_name}}</h1>",
    "text_content": "Hello {{first_name}}",
    "status": "draft",
    "send_to_type": "list",
    "send_to_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "scheduled_at": null,
    "sent_at": null,
    "stats_sent": 0,
    "stats_delivered": 0,
    "stats_opened": 0,
    "stats_clicked": 0,
    "stats_bounced": 0,
    "stats_unsubscribed": 0,
    "created_at": "2026-09-01T11:00:00.000Z"
  }
}
```

`400` `Invalid JSON body`, `audience must be "non_openers" when given`, or `Only a sent campaign has non-openers to send to again`.

```
{
  "error": "Only a sent campaign has non-openers to send to again"
}
```

`401`

No body.

`403`

No body.

`404` Campaign not found.

```
{
  "error": "Campaign not found"
}
```

`500` Failed to duplicate campaign.

```
{
  "error": "Failed to duplicate campaign"
}
```

## Assistants

Drafts written from a brief for a person to review. Behind a feature flag; nothing is sent or saved by an assistant.

### POST /api/v1/ai/email-draft

**Draft an email from a brief.** A subject line and a list of builder blocks written from the brief — the same `blocks` shape a campaign or template takes — for the editor to load and a person to edit. The prompt carries the brief and the workspace's custom-field names only; nothing is sent or saved. Behind the ai-assist flag (`404` when it is off for the workspace). A daily allowance per workspace is shared by the assistants (`429`). Requires `campaigns:write` or `templates:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `brief` | string | yes | Who it is for, what it says, what the reader should do. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/ai/email-draft" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "brief": "string"
}'
```

#### Responses

`200` The draft.

```
{
  "subject": "string",
  "name": "string",
  "blocks": [
    {
      "id": "block_1",
      "type": "text",
      "props": {
        "heading": "This month",
        "text": "Hi {{first_name|there}},",
        "padding": 24
      }
    }
  ],
  "warnings": [
    "string"
  ],
  "model": "string"
}
```

`400` No brief, or too long a one.

```
{
  "error": "Describe the email you want: who it is for, what it says, and what the reader should do."
}
```

`401`

No body.

`403`

No body.

`429` The plan's daily allowance of drafts is used.

```
{
  "error": "Contact not found"
}
```

`503` No model could answer, or the draft came back unusable.

```
{
  "error": "Contact not found"
}
```

### POST /api/v1/ai/automation-draft

**Draft an automation from a brief.** A recipe — the same shape the recipe gallery uses: a trigger, a linear sequence of send_email / wait / add_tag / remove_tag steps, and a placeholder for every list, form or tag the draft names — for a person to resolve and import. Nothing is created here. Behind the ai-assist flag (`404` when off); the assistants' shared daily allowance (`429`). Requires `automations:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `brief` | string | yes |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/ai/automation-draft" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "brief": "string"
}'
```

#### Responses

`200` The drafted recipe, with its outline.

```
{
  "recipe": {},
  "model": "string"
}
```

`400` No brief.

```
{
  "error": "Contact not found"
}
```

`401`

No body.

`403`

No body.

`429` The plan's daily allowance of drafts is used.

```
{
  "error": "Contact not found"
}
```

`503` No model could answer, or the draft came back unusable.

```
{
  "error": "Contact not found"
}
```

### POST /api/v1/ai/automation-draft/import

**Import a drafted automation.** Imports a recipe returned by the draft route as a draft automation, the way a gallery recipe is imported: `choices` names, per placeholder, an existing id (`{ "existing": id }`), a name to create (`{ "create": name }`) or `{ "later": true }`. The recipe is rebuilt from a whitelist before it is trusted. Requires `automations:write`, plus the write permission for anything a choice would create.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `recipe` | object | yes |  |
| `choices` | object | no |  |
| `name` | string | no |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/ai/automation-draft/import" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "recipe": {},
  "choices": {},
  "name": "string"
}'
```

#### Responses

`201` The draft automation, what was created on the way, and the placeholders left for the builder.

```
{
  "automation": {
    "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "Welcome series",
    "description": "Three emails over a week",
    "trigger_type": "contact_created",
    "trigger_config": {},
    "status": "active",
    "created_at": "2026-08-26T08:00:00.000Z"
  },
  "created": {},
  "unresolved": [
    "string"
  ]
}
```

`400` The draft is not in an importable shape, or a choice is wrong.

```
{
  "error": "Contact not found"
}
```

`401`

No body.

`403`

No body.

`500`

No body.

## Templates

Reusable email designs.

### GET /api/v1/templates

**List templates.** Returns templates, newest first, optionally filtered by category. Requires `templates:read`.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `category` | query | TemplateCategory | no |  |

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/templates?category=welcome" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` Templates.

```
{
  "templates": [
    {
      "id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
      "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
      "name": "Welcome email",
      "subject": "Welcome, {{first_name}}!",
      "html_content": "<h1>Hi {{first_name}}</h1>",
      "category": "welcome",
      "created_at": "2026-08-20T09:00:00.000Z"
    }
  ]
}
```

`401`

No body.

`403`

No body.

`500`

No body.

### POST /api/v1/templates

**Create a template.** Creates a template. An unknown or missing `category` becomes `custom`. `html_content` is limited to 500,000 characters (`413`). Send `blocks` — the visual builder's block JSON — alongside `html_content` rendered from it and the template reopens in the visual builder; leave it out or send `null` for an HTML-only template. At most 200 blocks / 200,000 characters of block JSON (`400`). Requires `templates:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes |  |
| `subject` | string | yes |  |
| `html_content` | string | yes |  |
| `category` | TemplateCategory | no |  |
| `blocks` | array of BuilderBlock | no |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/templates" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Welcome email",
  "subject": "Welcome to {{first_name}}!",
  "html_content": "<h1>Hi {{first_name}}</h1><p>Thanks for joining.</p>",
  "category": "welcome",
  "blocks": [
    {
      "id": "block_1",
      "type": "text",
      "props": {
        "heading": "This month",
        "text": "Hi {{first_name|there}},",
        "padding": 24
      }
    }
  ]
}'
```

#### Responses

`201` Created.

```
{
  "template": {
    "id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "Welcome email",
    "subject": "Welcome, {{first_name}}!",
    "html_content": "<h1>Hi {{first_name}}</h1>",
    "category": "welcome",
    "created_at": "2026-08-20T09:00:00.000Z"
  }
}
```

`400` Invalid JSON body, `name is required`, `subject is required`, or `html_content is required`.

```
{
  "error": "html_content is required"
}
```

`401`

No body.

`403`

No body.

`413`

No body.

`500`

No body.

### GET /api/v1/custom-blocks

**List custom blocks.** The workspace's own email blocks, by name. Requires `templates:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/custom-blocks" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` Custom blocks.

```
{
  "custom_blocks": [
    {
      "id": "b7c8d9e0-f1a2-4b3c-8d4e-5f6a7b8c9d0e",
      "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
      "name": "Hero",
      "description": "Headline, one line and a button on the brand colour.",
      "mjml": "<mjml><mj-body><mj-section background-color=\"#111827\"><mj-column><mj-text color=\"#ffffff\" font-size=\"28px\">[[headline]]</mj-text><mj-button href=\"[[cta:url]]\">[[cta_label]]</mj-button></mj-column></mj-section></mj-body></mjml>",
      "html": "<div style=\"\">…[[headline]]…</div>",
      "head_html": "<style type=\"text/css\">…</style>",
      "slots": [
        {
          "key": "headline",
          "type": "text",
          "label": "Headline",
          "default": "Big news"
        },
        {
          "key": "cta",
          "type": "url",
          "label": "Button link",
          "default": "https://acme.test"
        },
        {
          "key": "cta_label",
          "type": "text",
          "label": "Button text",
          "default": "Read more"
        }
      ],
      "created_at": "2026-09-26T09:00:00.000Z",
      "updated_at": "2026-09-26T09:00:00.000Z"
    }
  ]
}
```

`401`

No body.

`403`

No body.

`500`

No body.

### POST /api/v1/custom-blocks

**Create a custom block.** Compiles the MJML once and stores the block. Mark what a marketer may change as slots: `[[headline]]` for text, `[[cta:url]]` for a link, `[[hero:image]]` for an image. `slots` may set a label and a default per key; keys and types always come from the markup. `400` names the problem when the MJML does not compile; soft findings (an attribute MJML ignored) come back as `warnings` on a `201`. `mjml` is limited to 100,000 characters (`413`). Requires `templates:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes |  |
| `description` | string | no |  |
| `mjml` | string | yes |  |
| `slots` | array of object | no |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/custom-blocks" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Hero",
  "description": "string",
  "mjml": "string",
  "slots": [
    {
      "key": "string",
      "label": "string",
      "default": "string"
    }
  ]
}'
```

#### Responses

`201` Created.

```
{
  "custom_block": {
    "id": "b7c8d9e0-f1a2-4b3c-8d4e-5f6a7b8c9d0e",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "Hero",
    "description": "Headline, one line and a button on the brand colour.",
    "mjml": "<mjml><mj-body><mj-section background-color=\"#111827\"><mj-column><mj-text color=\"#ffffff\" font-size=\"28px\">[[headline]]</mj-text><mj-button href=\"[[cta:url]]\">[[cta_label]]</mj-button></mj-column></mj-section></mj-body></mjml>",
    "html": "<div style=\"\">…[[headline]]…</div>",
    "head_html": "<style type=\"text/css\">…</style>",
    "slots": [
      {
        "key": "headline",
        "type": "text",
        "label": "Headline",
        "default": "Big news"
      },
      {
        "key": "cta",
        "type": "url",
        "label": "Button link",
        "default": "https://acme.test"
      },
      {
        "key": "cta_label",
        "type": "text",
        "label": "Button text",
        "default": "Read more"
      }
    ],
    "created_at": "2026-09-26T09:00:00.000Z",
    "updated_at": "2026-09-26T09:00:00.000Z"
  },
  "warnings": [
    "string"
  ]
}
```

`400` Invalid JSON body, `name is required`, `mjml is required`, or the MJML did not compile (the message says why).

```
{
  "error": "mjml must be a whole document: start with <mjml> and put the block inside <mj-body>."
}
```

`401`

No body.

`403`

No body.

`413`

No body.

`500`

No body.

### POST /api/v1/custom-blocks/preview

**Compile MJML without saving.** What the block editor shows while you type: the compiled fragment and head, the slots the markup declares, and `document`, a whole email with the slots filled from `values` (or their defaults) for a preview frame. Nothing is stored. Requires `templates:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `mjml` | string | yes |  |
| `slots` | array of object | no |  |
| `values` | object | no |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/custom-blocks/preview" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "mjml": "string",
  "slots": [
    {
      "key": "string",
      "label": "string",
      "default": "string"
    }
  ],
  "values": {}
}'
```

#### Responses

`200` Compiled.

```
{
  "html": "string",
  "head_html": "string",
  "slots": [
    {
      "key": "headline",
      "type": "text",
      "label": "Headline",
      "default": "Big news"
    }
  ],
  "warnings": [
    "string"
  ],
  "document": "string"
}
```

`400` Invalid JSON body, `mjml is required`, or the MJML did not compile.

```
{
  "error": "The MJML compiled to an empty body. Put the block inside <mj-body>."
}
```

`401`

No body.

`403`

No body.

`413`

No body.

### GET /api/v1/custom-blocks/{id}

**Get a custom block.** One block, source and compiled output. Requires `templates:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/custom-blocks/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The block.

```
{
  "custom_block": {
    "id": "b7c8d9e0-f1a2-4b3c-8d4e-5f6a7b8c9d0e",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "Hero",
    "description": "Headline, one line and a button on the brand colour.",
    "mjml": "<mjml><mj-body><mj-section background-color=\"#111827\"><mj-column><mj-text color=\"#ffffff\" font-size=\"28px\">[[headline]]</mj-text><mj-button href=\"[[cta:url]]\">[[cta_label]]</mj-button></mj-column></mj-section></mj-body></mjml>",
    "html": "<div style=\"\">…[[headline]]…</div>",
    "head_html": "<style type=\"text/css\">…</style>",
    "slots": [
      {
        "key": "headline",
        "type": "text",
        "label": "Headline",
        "default": "Big news"
      },
      {
        "key": "cta",
        "type": "url",
        "label": "Button link",
        "default": "https://acme.test"
      },
      {
        "key": "cta_label",
        "type": "text",
        "label": "Button text",
        "default": "Read more"
      }
    ],
    "created_at": "2026-09-26T09:00:00.000Z",
    "updated_at": "2026-09-26T09:00:00.000Z"
  }
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`500`

No body.

### PATCH /api/v1/custom-blocks/{id}

**Update a custom block.** Any of `name`, `description`, `mjml` and `slots`. New MJML is compiled; labels and defaults already set survive a markup change, and a slot the markup no longer names is dropped. Emails that already placed the block keep the copy they took; the builder picks up the new design when they are next opened. Requires `templates:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no |  |
| `description` | string | no |  |
| `mjml` | string | no |  |
| `slots` | array of object | no |  |

#### Example request

```
curl -X PATCH "https://sendbeam.io/api/v1/custom-blocks/id" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "string",
  "description": "string",
  "mjml": "string",
  "slots": [
    {
      "key": "string",
      "label": "string",
      "default": "string"
    }
  ]
}'
```

#### Responses

`200` Updated.

```
{
  "custom_block": {
    "id": "b7c8d9e0-f1a2-4b3c-8d4e-5f6a7b8c9d0e",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "Hero",
    "description": "Headline, one line and a button on the brand colour.",
    "mjml": "<mjml><mj-body><mj-section background-color=\"#111827\"><mj-column><mj-text color=\"#ffffff\" font-size=\"28px\">[[headline]]</mj-text><mj-button href=\"[[cta:url]]\">[[cta_label]]</mj-button></mj-column></mj-section></mj-body></mjml>",
    "html": "<div style=\"\">…[[headline]]…</div>",
    "head_html": "<style type=\"text/css\">…</style>",
    "slots": [
      {
        "key": "headline",
        "type": "text",
        "label": "Headline",
        "default": "Big news"
      },
      {
        "key": "cta",
        "type": "url",
        "label": "Button link",
        "default": "https://acme.test"
      },
      {
        "key": "cta_label",
        "type": "text",
        "label": "Button text",
        "default": "Read more"
      }
    ],
    "created_at": "2026-09-26T09:00:00.000Z",
    "updated_at": "2026-09-26T09:00:00.000Z"
  },
  "warnings": [
    "string"
  ]
}
```

`400` Invalid JSON body, `Nothing to update`, or the MJML did not compile.

```
{
  "error": "Nothing to update"
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`413`

No body.

`500`

No body.

### DELETE /api/v1/custom-blocks/{id}

**Delete a custom block.** Removes the block from the library. Emails that placed it keep their copy. Requires `templates:write`.

#### Example request

```
curl -X DELETE "https://sendbeam.io/api/v1/custom-blocks/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`204` Deleted.

No body.

`401`

No body.

`403`

No body.

`404`

No body.

`500`

No body.

### GET /api/v1/templates/{id}

**Get a template.** Returns one template. Requires `templates:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/templates/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The template.

```
{
  "template": {
    "id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "Welcome email",
    "subject": "Welcome, {{first_name}}!",
    "html_content": "<h1>Hi {{first_name}}</h1>",
    "category": "welcome",
    "created_at": "2026-08-20T09:00:00.000Z"
  }
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`500`

No body.

### PATCH /api/v1/templates/{id}

**Update a template.** Updates any of the template fields. Empty `name`/`subject` and invalid `category` values are ignored. `html_content` is limited to 500,000 characters (`413`). `blocks` (the visual builder's block JSON) is stored when sent; sending new `html_content` without `blocks` clears them, so stale block JSON never masquerades as the current design. Requires `templates:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no |  |
| `subject` | string | no |  |
| `html_content` | string | no |  |
| `category` | TemplateCategory | no |  |

#### Example request

```
curl -X PATCH "https://sendbeam.io/api/v1/templates/id" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "subject": "Welcome aboard, {{first_name}}"
}'
```

#### Responses

`200` Updated.

```
{
  "template": {
    "id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "Welcome email",
    "subject": "Welcome, {{first_name}}!",
    "html_content": "<h1>Hi {{first_name}}</h1>",
    "category": "welcome",
    "created_at": "2026-08-20T09:00:00.000Z"
  }
}
```

`400`

No body.

`401`

No body.

`403`

No body.

`404`

No body.

`413`

No body.

`500`

No body.

### DELETE /api/v1/templates/{id}

**Delete a template.** Deletes the template. Returns `204` with no body. Requires `templates:write`.

#### Example request

```
curl -X DELETE "https://sendbeam.io/api/v1/templates/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`204` Deleted.

No body.

`401`

No body.

`403`

No body.

`404`

No body.

`500`

No body.

## Media

Images hosted for the email builder: upload a PNG, JPEG, GIF or WebP and get a public URL to put in an email; list and remove what the workspace has uploaded (`campaigns:read` / `campaigns:write`). Storage is capped per plan.

### GET /api/v1/media

**List hosted images.** Every image the workspace has uploaded, newest first, with the storage used and the plan's cap. Always `200`: when image hosting is not enabled on the installation the body says `configured: false` with an empty list, so a client can decide whether to offer an upload at all. Requires `campaigns:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/media" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The library.

```
{
  "configured": true,
  "files": [
    {
      "id": "mfp3k2x0a1b2c3d4e5f6.jpg",
      "url": "https://media.sendbeam.io/m/7c9e6679742a4b1d9a3f0e2c/mfp3k2x0a1b2c3d4e5f6.jpg",
      "name": "hero.jpg",
      "size": 184320,
      "type": "image/jpeg",
      "uploaded": "2026-09-14T09:00:00.000Z"
    }
  ],
  "count": 1,
  "used_bytes": 184320,
  "quota_bytes": 26214400,
  "max_bytes": 2097152,
  "types": [
    "image/png",
    "image/jpeg",
    "image/gif",
    "image/webp"
  ],
  "truncated": false
}
```

`401`

No body.

`403`

No body.

`503` The library could not be read; try again.

```
{
  "error": "Contact not found"
}
```

### POST /api/v1/media

**Upload an image.** Hosts one image and returns its public URL for use in an email. Send `multipart/form-data` with the file in a field called `file`. The bytes are inspected: PNG, JPEG, GIF and WebP are accepted by their signature, whatever the declared type or file name; anything else is `415` (SVG included — email clients do not render it and it can carry script). At most 2 MB (`413`). Storage is capped per plan (`403` with `used_bytes` and `quota_bytes` when the file would not fit) and uploads at 120 an hour per workspace (`429`). The URL is permanent for as long as the file exists; a later delete leaves emails that used it with a broken picture. Requires `campaigns:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `file` | string | yes | The image (PNG, JPEG, GIF or WebP, at most 2 MB). |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/media" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`201` Stored.

```
{
  "file": {
    "id": "mfp3k2x0a1b2c3d4e5f6.jpg",
    "url": "https://media.sendbeam.io/m/7c9e6679742a4b1d9a3f0e2c/mfp3k2x0a1b2c3d4e5f6.jpg",
    "name": "hero.jpg",
    "size": 184320,
    "type": "image/jpeg",
    "uploaded": "2026-09-14T09:00:00.000Z"
  },
  "used_bytes": 184320,
  "quota_bytes": 26214400
}
```

`400` No `file` field, or the request is not multipart.

```
{
  "error": "Contact not found"
}
```

`401`

No body.

`403` Missing permission, or the plan's image storage is full (then `used_bytes` and `quota_bytes` are present).

```
{
  "error": "string",
  "used_bytes": 1,
  "quota_bytes": 1
}
```

`413` Larger than 2 MB.

```
{
  "error": "Contact not found"
}
```

`415` Not a PNG, JPEG, GIF or WebP.

```
{
  "error": "Contact not found"
}
```

`429` More than 120 uploads in an hour.

```
{
  "error": "Contact not found"
}
```

`503` Image hosting is not enabled on this installation (`configured: false`), or the store did not answer.

```
{
  "error": "string",
  "configured": true
}
```

### DELETE /api/v1/media/{id}

**Remove a hosted image.** Deletes the file. Ids are resolved inside the caller's own workspace, so another workspace's id is simply `404`. Emails that already reference the URL keep the reference and show a broken picture. Requires `campaigns:write`.

#### Example request

```
curl -X DELETE "https://sendbeam.io/api/v1/media/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` Removed.

```
{
  "ok": true,
  "id": "string",
  "note": "string"
}
```

`401`

No body.

`403`

No body.

`404` No such image in this workspace.

```
{
  "error": "Contact not found"
}
```

`503` Image hosting is not enabled on this installation, or the store did not answer.

```
{
  "error": "Contact not found"
}
```

## RSS to email

Feeds that turn new posts into campaigns on a schedule (`campaigns:read` / `campaigns:write`).

### GET /api/v1/rss-feeds

**List feeds.**

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/rss-feeds" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The workspace's feeds.

```
{
  "feeds": [
    null
  ]
}
```

`401`

No body.

`403`

No body.

### POST /api/v1/rss-feeds

**Add a feed.** Watches an RSS 2.0 or Atom feed and sends new posts to the audience as a campaign. The feed is fetched and parsed before it is saved: a URL that is not a working feed is refused with `422` and the reason, and a successful create answers with `check` (how many posts the feed holds and the newest title) and stores the feed's title. Checked continuously afterwards: `immediate` feeds every 15 minutes, `daily`/`weekly` feeds hourly with the send in `send_hour` (UTC). Requires `campaigns:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no |  |
| `feed_url` | string | no | Public http(s) URL of an RSS 2.0 or Atom feed. |
| `send_to_type` | "all" \| "list" \| "segment" | no |  |
| `send_to_id` | string | no |  |
| `template_id` | string | no | A template with the `{{rss_items}}` tag (the "Latest posts" block); null = built-in digest layout. |
| `subject_template` | string | no | Placeholders: `{{item_title}}`, `{{feed_title}}`, `{{item_count}}`. |
| `intro` | string | no |  |
| `frequency` | "immediate" \| "daily" \| "weekly" | no |  |
| `send_hour` | integer | no | UTC. |
| `send_weekday` | integer | no | 0 = Sunday. |
| `max_items` | integer | no |  |
| `status` | "active" \| "paused" | no |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/rss-feeds" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "string",
  "feed_url": "string",
  "send_to_type": "all",
  "send_to_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "template_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "subject_template": "{{item_title}}",
  "intro": "string",
  "frequency": "daily",
  "send_hour": 9,
  "send_weekday": 1,
  "max_items": 5,
  "status": "active"
}'
```

#### Responses

`201` Created. `check` says what the fetch found.

```
{
  "feed": null,
  "check": {
    "total_items": 12,
    "newest_title": "What shipped in September"
  }
}
```

`400` `name is required`, `feed_url must be a valid http(s) URL`, `feed_url must be a public hostname`, `send_to_id is required for a list audience`, `frequency must be immediate, daily or weekly`, `send_hour must be 0–23 (UTC)`, `max_items must be 1–10`.

```
{
  "error": "feed_url must be a public hostname"
}
```

`401`

No body.

`403`

No body.

`404` The list, segment or template is not this workspace's.

```
{
  "error": "List not found"
}
```

`422` The feed could not be fetched or parsed: `That URL is not an RSS or Atom feed.`, `The feed answered HTTP 403.`, `The feed is larger than 2 MB.`

```
{
  "error": "That URL is not an RSS or Atom feed."
}
```

### GET /api/v1/rss-feeds/{id}

**Get a feed and its recent campaigns.**

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/rss-feeds/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The feed and up to 20 campaigns it created.

```
{
  "feed": null,
  "campaigns": [
    {}
  ]
}
```

`401`

No body.

`403`

No body.

`404` Feed not found.

```
{
  "error": "Feed not found"
}
```

### PATCH /api/v1/rss-feeds/{id}

**Update a feed.** Any subset of the create fields, plus `status` (`active` / `paused`). A new `feed_url` is fetched and parsed before it replaces the old one (`422` with the reason if it is not a working feed); on success the response carries `check` and any earlier `last_error` is cleared. Requires `campaigns:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no |  |
| `feed_url` | string | no | Public http(s) URL of an RSS 2.0 or Atom feed. |
| `send_to_type` | "all" \| "list" \| "segment" | no |  |
| `send_to_id` | string | no |  |
| `template_id` | string | no | A template with the `{{rss_items}}` tag (the "Latest posts" block); null = built-in digest layout. |
| `subject_template` | string | no | Placeholders: `{{item_title}}`, `{{feed_title}}`, `{{item_count}}`. |
| `intro` | string | no |  |
| `frequency` | "immediate" \| "daily" \| "weekly" | no |  |
| `send_hour` | integer | no | UTC. |
| `send_weekday` | integer | no | 0 = Sunday. |
| `max_items` | integer | no |  |
| `status` | "active" \| "paused" | no |  |

#### Example request

```
curl -X PATCH "https://sendbeam.io/api/v1/rss-feeds/id" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "string",
  "feed_url": "string",
  "send_to_type": "all",
  "send_to_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "template_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "subject_template": "{{item_title}}",
  "intro": "string",
  "frequency": "daily",
  "send_hour": 9,
  "send_weekday": 1,
  "max_items": 5,
  "status": "active"
}'
```

#### Responses

`200` Updated. `check` is present only when `feed_url` was sent.

```
{
  "feed": null,
  "check": {
    "total_items": 12,
    "newest_title": "What shipped in September"
  }
}
```

`400` Validation error, or `No valid fields to update`.

```
{
  "error": "status must be active or paused"
}
```

`401`

No body.

`403`

No body.

`404` Feed, list, segment or template not found.

```
{
  "error": "Feed not found"
}
```

`422` The new feed URL could not be fetched or parsed.

```
{
  "error": "The feed answered HTTP 404."
}
```

### DELETE /api/v1/rss-feeds/{id}

**Delete a feed.** Campaigns already sent from it are kept (their `rss_feed_id` becomes null). Requires `campaigns:write`.

#### Example request

```
curl -X DELETE "https://sendbeam.io/api/v1/rss-feeds/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`204` Deleted.

No body.

`401`

No body.

`403`

No body.

`404` Feed not found.

```
{
  "error": "Feed not found"
}
```

### POST /api/v1/rss-feeds/{id}/preview

**Fetch the feed and render the email.** Fetches the feed now and returns what a send would contain — the new items (or, when nothing is new, the newest ones as a sample), the subject and the rendered HTML. Nothing is sent or recorded. Requires `campaigns:read`.

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/rss-feeds/id/preview" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` Preview.

```
{
  "feed_title": "string",
  "total_items": 1,
  "new_items": 1,
  "items": [
    {
      "key": "string",
      "title": "string",
      "link": "string",
      "summary": "string",
      "date": "2026-09-02T12:00:00Z"
    }
  ],
  "subject": "string",
  "html": "string"
}
```

`401`

No body.

`403`

No body.

`404` Feed not found.

```
{
  "error": "Feed not found"
}
```

`422` The feed could not be fetched or parsed: `That URL is not an RSS or Atom feed.`, `The feed answered HTTP 403.`, `The feed is larger than 2 MB.`

```
{
  "error": "That URL is not an RSS or Atom feed."
}
```

### POST /api/v1/rss-feeds/{id}/run

**Send now.** Sends the posts that are new since the last send as a campaign. With `{"force": true}` the newest posts are sent even if nothing is new. "Nothing new" is a `200` with `sent: false`. Requires `campaigns:send` — running a feed sends a campaign, unlike editing the feed itself (`campaigns:write`).

#### Request body (optional)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `force` | boolean | no |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/rss-feeds/id/run" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "force": false
}'
```

#### Responses

`200` Nothing was sent; `reason` says why (nothing new, feed error, empty audience, plan gate).

```
{
  "sent": false,
  "reason": "string",
  "items": 1
}
```

`202` A campaign was created and is sending.

```
{
  "sent": true,
  "campaign_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "items": 1,
  "subject": "string"
}
```

`400` `Invalid JSON body`.

```
{
  "error": "Invalid JSON body"
}
```

`401`

No body.

`403`

No body.

`404` Feed not found.

```
{
  "error": "Feed not found"
}
```

## Automations

Trigger-driven step sequences (send email, wait, condition, add/remove tag).

### GET /api/v1/automations

**List automations.** Returns automations, newest first, without their steps. Optionally filter by status. Requires `automations:read`.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `status` | query | AutomationStatus | no |  |

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/automations?status=active" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` Automations.

```
{
  "automations": [
    {
      "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c",
      "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
      "name": "Welcome series",
      "description": "Three emails over a week",
      "trigger_type": "contact_created",
      "trigger_config": {},
      "status": "active",
      "created_at": "2026-08-26T08:00:00.000Z"
    }
  ]
}
```

`401`

No body.

`403`

No body.

`500`

No body.

### POST /api/v1/automations

**Create an automation.** Creates an automation in `draft` status with its steps. `steps` is required but may be empty. Every template, tag, list or form referenced in `trigger_config` or a step's `config` must belong to this workspace; otherwise `400` names the offending field. Activate it separately. The response contains the automation without its steps. Requires `automations:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes |  |
| `description` | string | no |  |
| `trigger_type` | TriggerType | yes |  |
| `trigger_config` | TriggerConfig | no | Trigger-specific settings. `tag_added` / `tag_removed` use `tag_id` (omit for any tag). `list_joined` accepts `list_id`; `form_submitted` accepts `form_id`; `automation` accepts `automation_id`. `field_updated` requires `field`. `date_anniversary` and `date_specific` require `date_field` — a custom field holding `YYYY-MM-DD`, or `created_at` for an anniversary — plus optional `offset_days` (0–365) and `direction` (`before`, `on`, `after`). `contact_created`, `api`, `cart_abandoned`, `product_viewed` and `order_placed` take no settings — they fire for the matching event from any store or webhook that posts it (see `POST /api/v1/ecommerce/events`). `event_received` takes `event_name` — the name another system will post to `POST /api/v1/events`; omit it to run for every event the workspace is sent. Any id given must belong to this workspace, or the request is refused with `400`. |
| `steps` | array of AutomationStepInput | yes |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/automations" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Welcome series",
  "trigger_type": "contact_created",
  "trigger_config": {},
  "steps": [
    {
      "type": "send_email",
      "config": {
        "template_id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b"
      }
    },
    {
      "type": "wait",
      "config": {
        "duration_minutes": 1440
      }
    },
    {
      "type": "add_tag",
      "config": {
        "tag_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33"
      }
    }
  ]
}'
```

#### Responses

`201` Created draft.

```
{
  "automation": {
    "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "Welcome series",
    "description": "Three emails over a week",
    "trigger_type": "contact_created",
    "trigger_config": {},
    "status": "active",
    "created_at": "2026-08-26T08:00:00.000Z"
  }
}
```

`400` Invalid JSON body, `name is required`, `trigger_type must be one of: contact_created, tag_added, list_joined, form_submitted`, `steps must be an array`, `Each step must be an object`, `Invalid step type: <type>. Must be one of: send_email, wait, condition, add_tag, remove_tag`, or `<field>: <id> does not belong to this workspace` where `<field>` is `trigger_config.<key>` or `steps[<n>].config.<key>`.

```
{
  "error": "Contact not found"
}
```

`401`

No body.

`403`

No body.

`500` `Failed to create automation` or `Failed to save automation steps` (the automation is rolled back).

```
{
  "error": "Failed to save automation steps"
}
```

### GET /api/v1/automations/{id}

**Get an automation.** Returns one automation with its steps in order. Requires `automations:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/automations/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The automation.

```
{
  "automation": null
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`500`

No body.

### PATCH /api/v1/automations/{id}

**Update an automation.** Updates any of `name`, `description`, `trigger_type`, `trigger_config`; if `steps` is present every existing step is replaced by the new array. The whole body is validated before anything is written — including that every referenced template, tag, list or form belongs to this workspace — so a rejected request changes nothing. Contacts already enrolled continue from their current step index. The response contains the automation without its steps. Requires `automations:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no |  |
| `description` | string | no |  |
| `trigger_type` | TriggerType | no |  |
| `trigger_config` | TriggerConfig | no | Trigger-specific settings. `tag_added` / `tag_removed` use `tag_id` (omit for any tag). `list_joined` accepts `list_id`; `form_submitted` accepts `form_id`; `automation` accepts `automation_id`. `field_updated` requires `field`. `date_anniversary` and `date_specific` require `date_field` — a custom field holding `YYYY-MM-DD`, or `created_at` for an anniversary — plus optional `offset_days` (0–365) and `direction` (`before`, `on`, `after`). `contact_created`, `api`, `cart_abandoned`, `product_viewed` and `order_placed` take no settings — they fire for the matching event from any store or webhook that posts it (see `POST /api/v1/ecommerce/events`). `event_received` takes `event_name` — the name another system will post to `POST /api/v1/events`; omit it to run for every event the workspace is sent. Any id given must belong to this workspace, or the request is refused with `400`. |
| `steps` | array of AutomationStepInput | no | Replaces all existing steps. |

#### Example request

```
curl -X PATCH "https://sendbeam.io/api/v1/automations/id" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Welcome series v2"
}'
```

#### Responses

`200` Updated.

```
{
  "automation": {
    "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "Welcome series",
    "description": "Three emails over a week",
    "trigger_type": "contact_created",
    "trigger_config": {},
    "status": "active",
    "created_at": "2026-08-26T08:00:00.000Z"
  }
}
```

`400` Invalid JSON body, `name must be a non-empty string`, `trigger_type must be one of: …`, `steps must be an array`, `Each step must be an object`, `Invalid step type: <type>`, or `<field>: <id> does not belong to this workspace` (`trigger_config.<key>` or `steps[<n>].config.<key>`).

```
{
  "error": "Contact not found"
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`500` `Failed to update automation`, `Failed to replace automation steps`, or `Failed to save updated steps`.

```
{
  "error": "Failed to update automation"
}
```

### DELETE /api/v1/automations/{id}

**Delete an automation.** Deletes the automation together with its steps and every enrolment (active or finished). Requires `automations:write`.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Automation ID. |

#### Example request

```
curl -X DELETE "https://sendbeam.io/api/v1/automations/9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`204` Deleted.

No body.

`401`

No body.

`403`

No body.

`404` No automation with that id in this workspace.

```
{
  "error": "Automation not found"
}
```

`500`

No body.

### POST /api/v1/automations/{id}/activate

**Activate an automation.** Runs the pre-flight checks and, if they pass, sets the automation to `active` so new trigger events enrol contacts. Refused with `422` and a `blockers` list when it could not do what it says: no steps or trigger, an email step with nothing to send (a template that no longer exists, or no subject/content), a step or trigger pointing at a tag, list, form or automation that no longer exists, a wait with no length, or a workspace that cannot send. Non-blocking `warnings` (no email step, an unreachable step) come back with the `200`. Counts against the plan's live-automation cap, which is per workspace. Requires `automations:write`.

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/automations/id/activate" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` Now active.

```
{
  "automation": {
    "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "Welcome series",
    "description": "Three emails over a week",
    "trigger_type": "contact_created",
    "trigger_config": {},
    "status": "active",
    "created_at": "2026-08-26T08:00:00.000Z"
  },
  "warnings": [
    {
      "code": "template_missing",
      "message": "string",
      "step_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
    }
  ]
}
```

`401`

No body.

`403` Missing permission, plan write gate, or the plan's live-automation cap is reached.

```
{
  "error": "Your plan allows 5 live automations in this workspace. Pause one or upgrade."
}
```

`404`

No body.

`409` Already active.

```
{
  "error": "Automation is already active"
}
```

`422` Pre-flight failed; `error` summarises, `blockers` lists each problem with the step it concerns.

```
{
  "error": "This automation cannot be activated yet: step 1 sends a template that no longer exists — choose another or write the email in the step.",
  "blockers": [
    {
      "code": "template_missing",
      "step_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
      "message": "step 1 sends a template that no longer exists — choose another or write the email in the step."
    }
  ],
  "warnings": []
}
```

`500` Failed to activate automation.

```
{
  "error": "Failed to activate automation"
}
```

### POST /api/v1/automations/{id}/pause

**Pause an automation.** Sets the automation to `paused`; no new enrolments happen and pending steps stop advancing. Works from `active` or `draft`. Requires `automations:write`.

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/automations/id/pause" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` Now paused.

```
{
  "automation": {
    "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "Welcome series",
    "description": "Three emails over a week",
    "trigger_type": "contact_created",
    "trigger_config": {},
    "status": "active",
    "created_at": "2026-08-26T08:00:00.000Z"
  }
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`409` Already paused.

```
{
  "error": "Automation is already paused"
}
```

`500` Failed to pause automation.

```
{
  "error": "Failed to pause automation"
}
```

### POST /api/v1/automations/{id}/trigger

**Start an automation for one contact.** Enrols a single contact, bypassing trigger matching, and runs the first steps straight away in the background of this request (a wait, or anything the request could not finish, is picked up by the scheduler within about a minute). The automation must be `active` and must carry a trigger of type `api` — otherwise nothing on the outside could be allowed to inject contacts into a sequence whose author never meant it to be driven that way. Every other rule still applies: subscribed contacts only, never enrolled twice at once, and repeats obey `repeat_enabled` and `repeat_cooldown_hours`. Requires `automations:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `contact_id` | string | no |  |
| `email` | string | no |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/automations/id/trigger" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "contact_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "email": "jane@example.com"
}'
```

#### Responses

`202` The contact was enrolled and will reach the first step on the next processing pass.

```
{
  "enrolled": true,
  "automation_id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c",
  "contact_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
}
```

`400` Neither contact_id nor email was given.

```
{
  "error": "contact_id or email is required"
}
```

`401`

No body.

`403`

No body.

`404` No such automation, or no such contact in this workspace.

```
{
  "error": "Contact not found"
}
```

`409` Understood, and deliberately did nothing: the automation is not active or has no `api` trigger, or the contact is unsubscribed, already in it, or inside its repeat cooldown.

```
{
  "enrolled": false,
  "reason": "The contact was not enrolled. The automation needs a trigger of type \"api\", and the contact must be subscribed, not already in this automation, and past its repeat cooldown."
}
```

`500` Failed to start the automation.

```
{
  "error": "Internal server error"
}
```

### GET /api/v1/automation-recipes

**List automation recipes.** The gallery behind "New automation": ready-made automations (welcome series, tag hand-off, birthday, renewal reminder, re-engagement, post-purchase, form → sales, signup anniversary), each with its trigger, its steps in order and the placeholders — a list, a tag, a form, a Date field — an import has to resolve. Requires `automations:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/automation-recipes" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` Every recipe.

```
{
  "recipes": [
    {
      "slug": "welcome-series",
      "name": "Welcome series",
      "tagline": "Three emails over a week for everyone who joins a list.",
      "description": "…",
      "outline": {
        "triggers": [
          "Contact joins list \"List to watch\""
        ],
        "steps": [
          {
            "text": "Send “Welcome — here is what to expect”"
          },
          {
            "text": "Wait 2 days"
          }
        ],
        "repeats": "Each contact goes through once"
      },
      "placeholders": [
        {
          "id": "list",
          "kind": "list",
          "label": "List to watch",
          "hint": "Joining this list starts the series.",
          "suggested": "Newsletter"
        }
      ]
    }
  ]
}
```

`401`

No body.

`403`

No body.

### GET /api/v1/automation-recipes/{slug}

**Get one recipe.** One recipe with its outline and placeholders — what a `POST` here will ask about. Requires `automations:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/automation-recipes/slug" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The recipe.

```
{
  "recipe": {
    "slug": "welcome-series",
    "name": "Welcome series",
    "tagline": "Three emails over a week for everyone who joins a list.",
    "description": "…",
    "outline": {
      "triggers": [
        "Contact joins list \"List to watch\""
      ],
      "steps": [
        {
          "text": "Send “Welcome — here is what to expect”"
        },
        {
          "text": "Wait 2 days"
        }
      ],
      "repeats": "Each contact goes through once"
    },
    "placeholders": [
      {
        "id": "list",
        "kind": "list",
        "label": "List to watch",
        "hint": "Joining this list starts the series.",
        "suggested": "Newsletter"
      }
    ]
  }
}
```

`401`

No body.

`403`

No body.

`404` No such recipe.

```
{
  "error": "Recipe not found"
}
```

### POST /api/v1/automation-recipes/{slug}

**Create a draft automation from a recipe.** Materialises the recipe into a `draft` automation (never active) with its starter copy, resolving each placeholder by the choice given: `{ existing: <id or key> }` uses something the workspace already has; `{ create: <name or key> }` makes it now — a tag or list of that name is reused case-insensitively, a field is registered with the recipe's type when the custom-field registry exists; `{ later: true }` (tags, lists and forms only) leaves the slot empty with a `recipe_placeholder` marker in the config, so the automation cannot be activated until it is chosen in the editor or the author explicitly opts for "any". A placeholder with no choice takes the recipe's suggestion (create it; a form is left for later), so an empty body is a complete import. A recipe with an anniversary trigger inherits the yearly repeat default. Requires `automations:write`; a choice that creates something also needs that thing's own permission (`tags:write`, `lists:write`, `contacts:write` for a field), or the call is refused with `403` before anything is made.

#### Request body (optional)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no | Name for the new automation. Defaults to the recipe's name. |
| `choices` | object | no | One entry per placeholder id. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/automation-recipes/slug" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Newsletter welcome",
  "choices": {
    "list": {
      "existing": "9c1e2d3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f"
    },
    "welcomed": {
      "create": "Welcomed"
    }
  }
}'
```

#### Responses

`201` The draft, with what the import created and what it left for later.

```
{
  "automation": {
    "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c",
    "name": "Welcome series",
    "status": "draft",
    "trigger_type": "list_joined"
  },
  "created": {
    "tags": [
      "Welcomed"
    ],
    "lists": [
      "Newsletter"
    ],
    "fields": []
  },
  "unresolved": [],
  "registry_unavailable": false
}
```

`400` A choice names no placeholder of this recipe, has the wrong shape, a `create` for a form, a `later` for a field, an `existing` id that is not this workspace's, or a field key that is not registered.

```
{
  "error": "choices.list: 9c1e… is not a list in this workspace"
}
```

`401`

No body.

`403`

No body.

`404` No such recipe.

```
{
  "error": "Recipe not found"
}
```

`409` Registering the recipe's field would conflict with values contacts already hold (see the custom-fields API).

```
{
  "error": "Contact not found"
}
```

`500`

No body.

### POST /api/v1/events

**Tell SendBeam that something happened.** Reports an event by NAME, and runs every active automation carrying the "Something happened elsewhere" trigger for that name. The caller says what happened; the workspace decides what it should do, so neither side has to know the other's automation ids — unlike `POST /api/v1/automations/{id}/trigger`, which names one automation and breaks when it is rebuilt.

An event never creates a contact: somebody else's system mentioning an address is not consent to email it. An address the workspace does not hold answers `200` with `"matched": false` rather than an error, so a sender does not retry a normal case for ever. Names are lower-cased, so `Deal_Won` and `deal_won` are one event. Requires `contacts:write`, because running an automation can send email.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `event` | string | yes | What happened, as a name you also type into the trigger. Letters, numbers, dot, dash or underscore; lower-cased on arrival. |
| `email` | string | yes | Who it happened to. Matched against contacts in this workspace; never created. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/events" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "event": "deal_won",
  "email": "jane@example.com"
}'
```

#### Responses

`200` Accepted. `matched` says whether the address is a contact here, and `enrolled` how many automations started.

```
{
  "ok": true,
  "event": "string",
  "matched": true,
  "enrolled": 1
}
```

`400` `event` or `email` is missing, or the name is not in the allowed shape.

```
{
  "error": "event must be 1-60 characters: letters, numbers, dot, dash or underscore"
}
```

`401`

No body.

`403`

No body.

## Workspace blueprints

A saved snapshot of one workspace's configuration — custom fields, automations and templates, never contacts/lists or sending domains — that can be applied into any other workspace on the same account, most usefully when creating one. Admin-only.

### GET /api/v1/workspace-blueprints

**List the account's blueprints.** Every blueprint saved on this login's account, newest first, with counts of what each one captured. Requires `automations:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/workspace-blueprints" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The blueprints — an empty list with `available: false` before the account has been set up (MIGRATION-ACCOUNTS.sql pending), never an error.

```
{
  "blueprints": [
    {
      "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
      "account_id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c",
      "source_tenant_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33",
      "created_by": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
      "name": "Agency starter kit",
      "description": "",
      "created_at": "2026-09-15T09:00:00.000Z",
      "updated_at": "2026-09-15T09:00:00.000Z",
      "counts": {
        "custom_fields": 2,
        "automations": 1,
        "templates": 3
      }
    }
  ],
  "available": true
}
```

`401`

No body.

`403`

No body.

### POST /api/v1/workspace-blueprints

**Save the current workspace as a blueprint.** Captures the AUTHENTICATED workspace's custom-field definitions, its automations (each turned back into the same recipe shape `POST /api/v1/automation-recipes/{slug}` resolves, so applying one goes through that same import) and its email templates (any hosted image a template references is copied into the destination workspace on apply). Contacts, lists and sending domains are never captured. Admin-only; requires `automations:read`, `templates:read` and `contacts:read` (reading every domain a blueprint touches).

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes |  |
| `description` | string | no |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/workspace-blueprints" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Agency starter kit",
  "description": "Custom fields, welcome automation and templates every new client site starts with."
}'
```

#### Responses

`201` Saved.

```
{
  "blueprint": {
    "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "account_id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c",
    "source_tenant_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33",
    "created_by": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
    "name": "Agency starter kit",
    "description": "",
    "created_at": "2026-09-15T09:00:00.000Z",
    "updated_at": "2026-09-15T09:00:00.000Z",
    "counts": {
      "custom_fields": 2,
      "automations": 1,
      "templates": 3
    }
  }
}
```

`400` Missing or out-of-range `name`, or a bad `description`.

```
{
  "error": "Give the blueprint a name between 2 and 80 characters."
}
```

`401`

No body.

`403` Missing permission, or a member seat (blueprints are admin-only).

```
{
  "error": "Contact not found"
}
```

`503` The account is not set up yet (MIGRATION-ACCOUNTS.sql pending), or the blueprints table itself is (MIGRATION-WORKSPACE-BLUEPRINTS.sql pending).

```
{
  "error": "Contact not found"
}
```

### DELETE /api/v1/workspace-blueprints/{id}

**Delete a blueprint.** Removes the blueprint from the account. Never touches a workspace it was already applied to — applying one copies its content in, so nothing keeps pointing back at the blueprint row afterward. Admin-only.

#### Example request

```
curl -X DELETE "https://sendbeam.io/api/v1/workspace-blueprints/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`204` Deleted.

No body.

`401`

No body.

`403` Not an admin on this account.

```
{
  "error": "Contact not found"
}
```

`404` No such blueprint on this account.

```
{
  "error": "Blueprint not found"
}
```

`503` The blueprints table is not set up yet (MIGRATION-WORKSPACE-BLUEPRINTS.sql pending).

```
{
  "error": "Contact not found"
}
```

### POST /api/v1/workspace-blueprints/{id}/apply

**Apply a blueprint into a workspace.** Materialises the blueprint into `tenant_id`: custom fields are created (a key already declared there is left alone and reported as skipped, never overwritten), each captured automation is imported as a `draft` exactly as `POST /api/v1/automation-recipes/{slug}` would (a tag/list/field it needs is created under its captured name, reusing one of that name if it already exists there), and each template is created with any hosted image copied into the destination's own storage (a note says how many could not be, rather than shipping a broken image reference). The target workspace must belong to the SAME account as the blueprint. A session caller may target any workspace they hold the admin role in; an API key, as everywhere else in this API, may only target the one workspace it was minted for.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `tenant_id` | string | yes | The workspace to apply the blueprint into. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/workspace-blueprints/id/apply" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "tenant_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
}'
```

#### Responses

`200` What was created, skipped or could not be carried over.

```
{
  "applied": {
    "custom_fields": {
      "created": [
        "renewal_date"
      ],
      "skipped": [
        "plan"
      ],
      "unavailable": false
    },
    "templates": {
      "created": 3,
      "failed": 0,
      "media_copied": 2,
      "media_not_carried_over": 0
    },
    "automations": {
      "created": [
        {
          "name": "Welcome series",
          "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c",
          "unresolved": []
        }
      ],
      "failed": []
    }
  }
}
```

`400` `tenant_id` missing, or names a workspace that is not on this account.

```
{
  "error": "Contact not found"
}
```

`401`

No body.

`403` Missing permission for something this blueprint would create, or the caller does not administer the target workspace.

```
{
  "error": "Contact not found"
}
```

`404` No such blueprint on this account.

```
{
  "error": "Blueprint not found"
}
```

`503` The account or the blueprints table is not set up yet.

```
{
  "error": "Contact not found"
}
```

## Forms

Signup and contact forms you embed on your sites (`forms:read` / `forms:write`), plus the public submission endpoint.

### GET /api/v1/forms

**List forms.** Returns every form in the workspace, newest first, each with its `views`, `submissions` and `conversion_rate` (see the Form schema). `turnstile_secret` is always masked (`••••••••` when set, `null` otherwise). Requires `forms:read` (`forms:write` or a legacy `automations:write` also satisfies it).

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/forms" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` Forms.

```
{
  "forms": [
    {
      "id": "11111111-2222-4333-8444-555555555555",
      "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
      "name": "Newsletter signup",
      "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "fields": [
        "email",
        "first_name",
        "last_name",
        "company"
      ],
      "thank_you_message": "Thanks for subscribing!",
      "redirect_url": null,
      "status": "active",
      "kind": "signup",
      "notify_email": null,
      "notify_subject": null,
      "allowed_origins": [
        "https://acme.com"
      ],
      "turnstile_site_key": "0x4AAAAAAA",
      "turnstile_secret": "••••••••",
      "daily_cap": 200,
      "created_at": "2026-08-28T14:00:00.000Z",
      "views": 1840,
      "submissions": 92,
      "conversion_rate": 0.05
    }
  ]
}
```

`401`

No body.

`403`

No body.

`500` Failed to fetch forms.

```
{
  "error": "Failed to fetch forms"
}
```

### POST /api/v1/forms

**Create a form.** Creates an active form. `kind` is `signup` (default: creates/subscribes a contact, optionally joins `list_id`, which must be one of this workspace's lists) or `contact` (emails the message to `notify_email`, never creates a contact; `list_id` is forced to null). `notify_email` must be a workspace member's address or an address on one of the workspace's verified sending domains (`400` otherwise). Defaults: `fields` `["email","first_name","last_name"]` for signup or `["email","name","subject","message"]` for contact; `thank_you_message` `Thanks for subscribing!` or `Thanks — your message has been sent. We'll reply by email.`. Requires `forms:write` (a legacy `automations:write` is also accepted).

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes |  |
| `headline` | string \| null | no |  |
| `body` | string \| null | no |  |
| `button_label` | string \| null | no |  |
| `image_url` | string \| null | no |  |
| `indexable` | boolean | no |  |
| `ab_test` | object \| null | no | A second version of the form tested against the first. While `status` is `testing`, half of the views of the hosted page, the pop-up and the WordPress plugin see version B at random (per view, no cookie; inline embeds always show A); each view and submission is counted against its version. Set `{ "status": "testing", "b": {…} }` to start (started_at is set by the server), `{ "status": "decided", "winner": "a"\|"b" }` to end it — deciding for B writes its copy onto the form — and `null` to clear. |
| `kind` | FormKind | no |  |
| `list_id` | string | no | Signup forms only. Must be one of this workspace's lists. |
| `fields` | array of string | no |  |
| `thank_you_message` | string | no |  |
| `redirect_url` | string | no |  |
| `notify_email` | string \| null | no | Required for contact forms. Must be a workspace member's address or an address on one of the workspace's verified sending domains. |
| `notify_subject` | string \| null | no |  |
| `allowed_origins` | array \| null | no | Up to 20 origins of the form `scheme://host[:port]`; null clears. |
| `turnstile_site_key` | string \| null | no |  |
| `turnstile_secret` | string \| null | no |  |
| `daily_cap` | integer | no |  |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/forms" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Contact us",
  "kind": "contact",
  "notify_email": "hello@acme.com",
  "notify_subject": "Website enquiry",
  "allowed_origins": [
    "https://acme.com"
  ],
  "daily_cap": 100
}'
```

#### Responses

`201` Created.

```
{
  "form": {
    "id": "11111111-2222-4333-8444-555555555555",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "Newsletter signup",
    "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "fields": [
      "email",
      "first_name",
      "last_name",
      "company"
    ],
    "thank_you_message": "Thanks for subscribing!",
    "redirect_url": null,
    "status": "active",
    "kind": "signup",
    "notify_email": null,
    "notify_subject": null,
    "allowed_origins": [
      "https://acme.com"
    ],
    "turnstile_site_key": "0x4AAAAAAA",
    "turnstile_secret": "••••••••",
    "daily_cap": 200,
    "created_at": "2026-08-28T14:00:00.000Z",
    "views": 1840,
    "submissions": 92,
    "conversion_rate": 0.05
  }
}
```

`400`

No body.

`401`

No body.

`403`

No body.

`500` `Failed to create form`, or a hint that a database migration is pending.

```
{
  "error": "Failed to create form"
}
```

### PUT /api/v1/forms

**Update a form.** Updates the form identified by `id` in the body. Only supplied fields change. Sending `turnstile_secret` as the mask `••••••••` leaves the stored secret unchanged. Switching `kind` to `contact` clears `list_id` and requires a `notify_email` (new or already stored). A new `list_id` must be one of this workspace's lists and a new `notify_email` a workspace member's address or one on a verified sending domain (`400` otherwise). Requires `forms:write` (a legacy `automations:write` is also accepted).

#### Request body

See the OpenAPI document for the body schema.

#### Example request

```
curl -X PUT "https://sendbeam.io/api/v1/forms" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "id": "11111111-2222-4333-8444-555555555555",
  "status": "inactive"
}'
```

#### Responses

`200` Updated.

```
{
  "form": {
    "id": "11111111-2222-4333-8444-555555555555",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "Newsletter signup",
    "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "fields": [
      "email",
      "first_name",
      "last_name",
      "company"
    ],
    "thank_you_message": "Thanks for subscribing!",
    "redirect_url": null,
    "status": "active",
    "kind": "signup",
    "notify_email": null,
    "notify_subject": null,
    "allowed_origins": [
      "https://acme.com"
    ],
    "turnstile_site_key": "0x4AAAAAAA",
    "turnstile_secret": "••••••••",
    "daily_cap": 200,
    "created_at": "2026-08-28T14:00:00.000Z",
    "views": 1840,
    "submissions": 92,
    "conversion_rate": 0.05
  }
}
```

`400`

No body.

`401`

No body.

`403`

No body.

`404`

No body.

`500` `Failed to update form`, or a hint that a database migration is pending.

```
{
  "error": "Failed to update form"
}
```

### DELETE /api/v1/forms

**Delete a form.** Deletes the form identified by `id` in the body. Requires `forms:write` (a legacy `automations:write` is also accepted).

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |

#### Example request

```
curl -X DELETE "https://sendbeam.io/api/v1/forms" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
}'
```

#### Responses

`200`

No body.

`400` Invalid JSON body or `id is required`.

```
{
  "error": "id is required"
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`500` Failed to delete form.

```
{
  "error": "Failed to delete form"
}
```

### GET /api/v1/forms/{id}

**Get a form.** One form by id, with its `views`, `submissions` and `conversion_rate`. `turnstile_secret` is masked as on the list. A form belonging to another workspace is a `404`, never its row. Requires `forms:read` (`forms:write` or a legacy `automations:write` also satisfies it).

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/forms/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The form.

```
{
  "form": {
    "id": "11111111-2222-4333-8444-555555555555",
    "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
    "name": "Newsletter signup",
    "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "fields": [
      "email",
      "first_name",
      "last_name",
      "company"
    ],
    "thank_you_message": "Thanks for subscribing!",
    "redirect_url": null,
    "status": "active",
    "kind": "signup",
    "notify_email": null,
    "notify_subject": null,
    "allowed_origins": [
      "https://acme.com"
    ],
    "turnstile_site_key": "0x4AAAAAAA",
    "turnstile_secret": "••••••••",
    "daily_cap": 200,
    "created_at": "2026-08-28T14:00:00.000Z",
    "views": 1840,
    "submissions": 92,
    "conversion_rate": 0.05
  }
}
```

`401`

No body.

`403`

No body.

`404`

No body.

### POST /api/forms/{formId}

**Submit a form (public).** Public, no API key: call it from your website with `fetch` or a plain form handler. CORS is open (`Access-Control-Allow-Origin: *`, or the matched origin when the form restricts `allowed_origins`).

**Signup forms** upsert a subscribed contact (`source: "form"`), merge any declared custom fields into `custom_fields`, add the contact to the form's list, send a double opt-in email when the list (or the Free plan) requires it — confirmations to one address are throttled, and each counts towards the monthly email allowance — enrol `form_submitted` and `contact_created` automations (and `list_joined` immediately, or on confirmation for double opt-in), and email the owner if `notify_email` is set. A brand-new contact counts against the plan's contact cap. This is also the only way an address on the suppression list can come back.

**Contact forms** (`kind: "contact"`) require `message`, store the submission and email it to `notify_email` with the visitor as Reply-To. No contact is created.

**Abuse protection.** Submissions are checked against the form being active, its `allowed_origins`, the email address itself, Cloudflare Turnstile when the form has a secret, and rate limits. Automated submissions are also filtered by checks we do not document, and a `200` confirms only that the request was accepted, not that a contact was created — build your integration on the form working for a real person, not on the status code.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | yes |  |
| `first_name` | string | no |  |
| `last_name` | string | no |  |
| `name` | string | no | Contact forms: full name (falls back to first_name + last_name). |
| `subject` | string | no | Contact forms only. |
| `message` | string | no | Contact forms: required. |
| `turnstile_token` | string | no | Cloudflare Turnstile response when the form has Turnstile enabled (`cf-turnstile-response` is accepted as an alias). |

#### Example request

```
curl -X POST "https://sendbeam.io/api/forms/formId" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "jane@example.com",
  "first_name": "Jane",
  "last_name": "Doe",
  "name": "Jane Doe",
  "subject": "Question about pricing",
  "message": "Do you offer annual billing?",
  "turnstile_token": "string"
}'
```

#### Responses

`200` Accepted.

```
{
  "success": true,
  "message": "Thanks for subscribing!",
  "redirect_url": null
}
```

`400` Invalid JSON body, `A valid email address is required`, or (contact forms) `A message is required`.

```
{
  "error": "A valid email address is required"
}
```

`403` Origin not allowed, Turnstile verification failed (`turnstile` carries Cloudflare's error codes), or the workspace's contact cap is reached.

```
{
  "error": "string",
  "turnstile": "string"
}
```

`404` Form not found or inactive.

```
{
  "error": "Form not found or inactive"
}
```

`429` Rate limited.

```
{
  "error": "Contact not found"
}
```

`500` Failed to subscribe.

```
{
  "error": "Failed to subscribe"
}
```

`503` Contact forms only: the message could not be delivered to the owner. Fall back to a `mailto:` link.

```
{
  "error": "Contact not found"
}
```

### POST /api/forms/{formId}/view

**Record a form view (public).** Public, no API key, no body: the inline embed code sends this once as it loads (`navigator.sendBeacon`), so the form's `views` and `conversion_rate` include the sites it is pasted into. The hosted page and the pop-up count their own views, so nothing needs to call this for them. If you render a form yourself from its endpoint, call it once each time the form is shown to a person.

A view is dropped, with the same `204`, when the request looks automated (a crawler user agent, a prefetch or link preview, or more views of one form from one address in an hour than a person would produce). A form that restricts `allowed_origins` takes views only from those origins.

#### Example request

```
curl -X POST "https://sendbeam.io/api/forms/formId/view"
```

#### Responses

`204` Received. Says nothing about whether the view was counted.

No body.

`403` Origin not allowed for this form.

No body.

`404` Form not found or inactive.

No body.

`429` Too many views of this form from this address; the rest of the hour's views are not counted.

No body.

## Sending

Send a single transactional email to one contact.

### POST /api/v1/send

**Send a single email to a contact.** Sends one transactional email to a contact from the workspace's sender identity (re-validated on every send: the workspace's own shared address or a domain it has verified). Only `subscribed` contacts can be mailed. Counts against the plan's monthly email quota and its hourly sending ceiling; a paused workspace or exhausted monthly quota is refused with `403`, an exhausted hourly ceiling with `429` and a `Retry-After` header, before anything is sent. Every send is recorded in the workspace's Activity page (filter **API**) with its delivery, open and click events, and is included in Reports. Requires `campaigns:send`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `contact_id` | string | yes |  |
| `subject` | string | yes |  |
| `html_content` | string | yes |  |
| `text_content` | string | no | Plain-text alternative. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/send" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "contact_id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
  "subject": "Your receipt",
  "html_content": "<p>Hi {{first_name}}, thanks for your order.</p>",
  "text_content": "Hi Jane, thanks for your order."
}'
```

#### Responses

`200` Accepted for delivery.

```
{
  "ok": true
}
```

`400` Invalid JSON body or `contact_id, subject, and html_content are required`.

```
{
  "error": "contact_id, subject, and html_content are required"
}
```

`401`

No body.

`403` Missing permission, plan write gate, sending paused for this workspace, or the monthly email quota is exhausted.

```
{
  "error": "Contact not found"
}
```

`404`

No body.

`422` Platform sending is not configured, or the contact is not subscribed.

```
{
  "error": "Contact not found"
}
```

`429` The plan's hourly sending ceiling is used up. Carries a `Retry-After` header saying how many seconds to wait.

```
{
  "error": "Hourly send limit reached. Try again later."
}
```

`503` The delivery provider rejected the message; `error` carries the provider's reason.

```
{
  "error": "Failed to send email"
}
```

### POST /api/v1/transactional

**Send site email to any address.** Sends the email a site sends to its own users — order confirmations, password resets, booking reminders — through the workspace's verified domain. Unlike `POST /api/v1/send`, recipients need not be contacts, and an unsubscribed person still receives mail they asked for (a marketing opt-out does not cover receipts); addresses that bounced or complained before are refused and listed in `skipped`. The message is delivered as written: no merge tags, no tracking, no unsubscribe link. `to`, `cc` and `bcc` each accept an address, `"Name <address>"`, `{ email, name }` or an array of those — every address gets its own copy — up to 10 per call. `from_email` must be on a verified domain (otherwise the workspace sender is used); `from_name` is used either way. `headers` may carry up to ten `X-*` headers. Counts against the monthly quota and hourly ceiling like every send; a bounce or complaint on a non-contact address suppresses that address. When the recipient is a contact, the send shows on their activity page. Requires `transactional:send`. The WordPress plugin's site-email option uses this endpoint.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `to` | string \| array of string \| object | yes | Recipient(s): an address, `"Name "`, `{ email, name }`, or an array of those. |
| `cc` | string \| array of string | no | Same shapes as `to`; each address gets its own copy. |
| `bcc` | string \| array of string | no | Same shapes as `to`; each address gets its own copy. |
| `subject` | string | yes |  |
| `html` | string | no | HTML body. `html_content` is accepted as an alias. Required unless `text` is given. |
| `text` | string | no | Plain-text body (`text_content` alias). A text-only message is also rendered as simple HTML. |
| `reply_to` | string | no | One address, optionally `"Name "`. |
| `from_name` | string | no |  |
| `from_email` | string | no | Used only when the address is on a domain verified in this workspace; otherwise the workspace sender applies. |
| `headers` | object | no | Up to ten `X-*` headers, single-line, under 500 characters each. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/transactional" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "to": "Jane Doe <jane@example.com>",
  "cc": "string",
  "bcc": "string",
  "subject": "Your order #1001",
  "html": "<p>Thanks for your order.</p>",
  "text": "Thanks for your order.",
  "reply_to": "orders@example.com",
  "from_name": "Example Shop",
  "from_email": "orders@example.com",
  "headers": {
    "X-Order-Id": "1001"
  }
}'
```

#### Responses

`200` At least one copy was accepted for delivery.

```
{
  "ok": true,
  "sent": [
    {
      "to": "jane@example.com",
      "message_id": "msg_5f8b2c…"
    }
  ]
}
```

`400` Invalid body; `error` names the field.

```
{
  "error": "`to` is required: an email address, \"Name <address>\", { email, name } or an array of those"
}
```

`401`

No body.

`403` Missing permission, sending paused, or the monthly email quota is exhausted.

```
{
  "error": "Forbidden: transactional:send permission required"
}
```

`422` Platform sending is not configured, or no recipient can take delivery — each address is suppressed, or sits on a reserved documentation/test domain that can never receive mail (`skipped` says which).

```
{
  "error": "No deliverable recipient: every address is suppressed or not a routable mailbox.",
  "skipped": [
    {
      "to": "old@acme.co",
      "reason": "This address bounced before and is not mailed again."
    },
    {
      "to": "jane@example.com",
      "reason": "Not a deliverable address: example domain — reserved for documentation, never a real mailbox."
    }
  ]
}
```

`429` The plan's hourly sending ceiling cannot fit this many recipients. Carries a `Retry-After` header saying how many seconds to wait.

```
{
  "error": "Hourly send limit reached. Try again later."
}
```

`503` No copy could be sent; `error` carries the provider's reason and `failed` lists each address.

```
{
  "error": "Sender not allowed."
}
```

## Webhooks

Register an https endpoint and SendBeam POSTs a signed JSON payload to it whenever a subscribed event happens in the workspace. Endpoints carry a workspace-wide signing secret, so they are admin-only: an API key with the scope below works whoever made it, but a signed-in person who holds a MEMBER seat is refused with 403 on every route here.

### GET /api/v1/webhooks

**List webhook endpoints.** Returns every webhook endpoint in the workspace, newest first. The signing `secret` is never included — it is shown once when the endpoint is created and can only be replaced, not read back. Requires `webhooks:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/webhooks" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` Webhook endpoints.

```
{
  "webhooks": [
    {
      "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
      "url": "https://example.com/hooks/sendbeam",
      "description": "Sync new contacts into the CRM",
      "event_types": [
        "contact.created"
      ],
      "filters": {
        "list_ids": [
          "b3f1c2a4-1111-4a2b-8c3d-0000000000aa"
        ]
      },
      "enabled": true,
      "disabled_reason": null,
      "consecutive_failures": 0,
      "last_success_at": null,
      "last_failure_at": null,
      "created_at": "2026-09-02T12:00:00Z"
    }
  ]
}
```

`401`

No body.

`403`

No body.

`500` Failed to list webhooks.

```
{
  "error": "Failed to list webhooks"
}
```

### POST /api/v1/webhooks

**Create a webhook endpoint.** Registers an endpoint and subscribes it to one or more events. The `url` must be `https://`, carry no credentials, and resolve to a public address — a private, loopback or link-local target is refused. Counts against the plan's webhook cap, pooled across all workspaces on the account (Free 1, Starter 5, Pro and Business unlimited). **This is the only response that ever contains `secret`**: store it when you receive it, because no later request can show it again. Requires `webhooks:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | string | yes | Public `https://` URL to POST to. Must not resolve to a private, loopback or link-local address, and must not contain credentials. |
| `description` | string | no | Optional note for your own reference. |
| `event_types` | array of WebhookEvent | yes | At least one event. Duplicates are collapsed; an unknown name is refused and named back to you. |
| `filters` | WebhookFilters | no | Optional scope. Omitted or `{}` means unfiltered — every event of a subscribed type fires, which is also every existing endpoint's behaviour before this field existed. A dimension you set must be present on the event itself or that endpoint is skipped for it (an endpoint scoped to a list, for instance, never receives a listless event); dimensions you set combine with AND, ids within one dimension combine with OR. Every id is checked against your own workspace at write time; an id that is not yours is refused, not silently ignored. At most 100 ids per key. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/webhooks" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://example.com/hooks/sendbeam",
  "description": "Sync new contacts into the CRM",
  "event_types": [
    "contact.created",
    "contact.unsubscribed"
  ],
  "filters": {
    "list_ids": [
      "b3f1c2a4-1111-4a2b-8c3d-0000000000aa"
    ]
  }
}'
```

#### Responses

`201` Created, with the signing secret in the clear this one time.

```
{
  "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "url": "https://example.com/hooks/sendbeam",
  "description": "Sync new contacts into the CRM",
  "event_types": [
    "contact.created"
  ],
  "filters": {
    "list_ids": [
      "b3f1c2a4-1111-4a2b-8c3d-0000000000aa"
    ]
  },
  "enabled": true,
  "secret": "9f2c1a0b3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8",
  "created_at": "2026-09-02T12:00:00Z"
}
```

`400` Invalid JSON body, `url is required`, a URL that is refused (`The URL must use https://`, `That address is not a public IP`, `That host resolves to a private address`, `That host is not reachable from the internet`, `The URL must not contain credentials`, `The URL is too long`, `That host name does not resolve`), `event_types must be a non-empty array of event names`, `Unknown event type: <name>. Valid events are: …`, `description must be a string`, or `description is too long (<n> characters; the limit is 200)`.

```
{
  "error": "Contact not found"
}
```

`401`

No body.

`403` Missing permission, or the plan's per-workspace webhook cap is reached.

```
{
  "error": "Your plan allows 1 webhook endpoint across your workspaces. Delete one or upgrade."
}
```

`500` Failed to create webhook.

```
{
  "error": "Failed to create webhook"
}
```

### GET /api/v1/webhooks/{id}

**Get a webhook endpoint.** Returns one endpoint, with the same fields as the list and never the `secret`. Requires `webhooks:read`.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/webhooks/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The endpoint.

```
{
  "webhook": {
    "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
    "url": "https://example.com/hooks/sendbeam",
    "description": "Sync new contacts into the CRM",
    "event_types": [
      "contact.created"
    ],
    "filters": {
      "list_ids": [
        "b3f1c2a4-1111-4a2b-8c3d-0000000000aa"
      ]
    },
    "enabled": true,
    "disabled_reason": null,
    "consecutive_failures": 0,
    "last_success_at": null,
    "last_failure_at": null,
    "created_at": "2026-09-02T12:00:00Z"
  }
}
```

`401`

No body.

`403`

No body.

`404`

No body.

### PATCH /api/v1/webhooks/{id}

**Update a webhook endpoint.** Updates any of `url`, `description`, `event_types` and `enabled`; omitted fields are left alone. A changed `url` is re-validated the same way creation validates it. Setting `enabled` to `true` on an endpoint that was auto-disabled clears `disabled_reason` and resets `consecutive_failures` to 0, so it starts again with a clean record. The response never contains `secret`. Requires `webhooks:write`.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | string | no |  |
| `description` | string \| null | no |  |
| `event_types` | array of WebhookEvent | no |  |
| `filters` | WebhookFilters | no | Optional scope. Omitted or `{}` means unfiltered — every event of a subscribed type fires, which is also every existing endpoint's behaviour before this field existed. A dimension you set must be present on the event itself or that endpoint is skipped for it (an endpoint scoped to a list, for instance, never receives a listless event); dimensions you set combine with AND, ids within one dimension combine with OR. Every id is checked against your own workspace at write time; an id that is not yours is refused, not silently ignored. At most 100 ids per key. |
| `enabled` | boolean | no | Set to `true` to re-enable an endpoint that was auto-disabled; that also clears `disabled_reason` and resets `consecutive_failures`. |

#### Example request

```
curl -X PATCH "https://sendbeam.io/api/v1/webhooks/id" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://example.com/hooks/sendbeam-v2",
  "description": null,
  "event_types": [
    "contact.created"
  ],
  "filters": {
    "list_ids": [
      "b3f1c2a4-1111-4a2b-8c3d-0000000000aa"
    ]
  },
  "enabled": true
}'
```

#### Responses

`200` Updated endpoint.

```
{
  "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "url": "https://example.com/hooks/sendbeam",
  "description": "Sync new contacts into the CRM",
  "event_types": [
    "contact.created"
  ],
  "filters": {
    "list_ids": [
      "b3f1c2a4-1111-4a2b-8c3d-0000000000aa"
    ]
  },
  "enabled": true,
  "disabled_reason": null,
  "consecutive_failures": 0,
  "last_success_at": null,
  "last_failure_at": null,
  "created_at": "2026-09-02T12:00:00Z"
}
```

`400` Invalid JSON body, `No valid fields to update`, `url must be a non-empty string`, a refused URL, `enabled must be true or false`, or an `event_types` / `description` validation message.

```
{
  "error": "No valid fields to update"
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`500` Failed to update webhook.

```
{
  "error": "Failed to update webhook"
}
```

### DELETE /api/v1/webhooks/{id}

**Delete a webhook endpoint.** Deletes the endpoint along with its queued and logged deliveries. Requires `webhooks:write`.

#### Example request

```
curl -X DELETE "https://sendbeam.io/api/v1/webhooks/id" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`204` Deleted.

No body.

`401`

No body.

`403`

No body.

`404`

No body.

`500` Failed to delete webhook.

```
{
  "error": "Failed to delete webhook"
}
```

### POST /api/v1/webhooks/{id}/rotate-secret

**Rotate the signing secret.** Replaces the endpoint's signing secret and returns the new one. The old secret stops verifying immediately — there is no overlap window — so update your receiver as soon as you have the new value. Together with creation, this is the only response that contains `secret`. Requires `webhooks:write`.

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/webhooks/id/rotate-secret" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The new signing secret.

```
{
  "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "secret": "3b7d4bad9bdd2b0d7b3dcb6d9f2c1a0b3d4e5f60718293a4b5c6d7e8f90a1b2c3"
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`500` Failed to rotate the signing secret.

```
{
  "error": "Failed to rotate the signing secret"
}
```

### GET /api/v1/webhooks/{id}/deliveries

**List recent deliveries.** The delivery log for one endpoint, most recent first, for debugging an integration. The stored request body is omitted by default because it can contain contact data; pass `include=payload` to get it. Requires `webhooks:read`.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `page` | query | integer | no | Page number, starting at 1. |
| `limit` | query | integer | no | Items per page (1–100). |
| `status` | query | WebhookDeliveryStatus | no | Only deliveries in this state. |
| `include` | query | "payload" | no | Comma-separated extras. `payload` adds the exact JSON body sent to each `payload` field. |

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/webhooks/id/deliveries?page=1&limit=50" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` A page of deliveries.

```
{
  "deliveries": [
    {
      "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
      "event_type": "contact.created",
      "status": "pending",
      "attempts": 1,
      "last_status_code": 200,
      "last_error": null,
      "delivered_at": null,
      "created_at": "2026-09-02T12:00:00Z",
      "payload": {}
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 50,
    "total": 1234,
    "total_pages": 25
  }
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`500` Failed to list deliveries.

```
{
  "error": "Failed to list deliveries"
}
```

### GET /api/v1/events

**List recent events.** The most recent events of one type in the workspace, newest first, each exactly the JSON body a webhook endpoint received for it — so an integration can show real data, for example in a test step, before its own endpoint has been sent anything. Events are read from the deliveries made to your webhook endpoints: an event that no endpoint was subscribed to when it happened is not listed, an event sent to several endpoints is listed once, and test events are never listed. `id` is one of that event's delivery ids and stays the same from one call to the next. No events is an empty list, not an error. Requires `webhooks:read`.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `type` | query | WebhookEvent | yes | The event to list. An unknown name is refused and named back to you. |
| `limit` | query | integer | no | How many events to return (1–25). A number outside the range is brought inside it. |

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/events?type=contact.created&limit=10" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` Up to `limit` events, newest first.

```
{
  "events": [
    {
      "id": "8d7f3c1e-2b4a-4c6d-9e8f-0a1b2c3d4e5f",
      "event": "contact.created",
      "created_at": "2026-09-11T09:12:04.000Z",
      "data": {
        "contact": {
          "id": "3f0e2b6e-9a11-4d7a-8c3c-2f2b4b1c9d10",
          "email": "ada@example.com",
          "status": "subscribed",
          "first_name": "Ada",
          "last_name": "Lovelace",
          "source": "api",
          "language": null,
          "custom_fields": {},
          "created_at": "2026-09-11T09:12:04.000Z",
          "subscribed_at": "2026-09-11T09:12:04.000Z",
          "unsubscribed_at": null
        }
      }
    }
  ]
}
```

`400` `type` is missing or is not an event name.

```
{
  "error": "Unknown event type: contact.signup. Valid events are: contact.created, contact.updated, …"
}
```

`401`

No body.

`403`

No body.

`500` Failed to list events.

```
{
  "error": "Failed to list events"
}
```

### POST /api/v1/webhooks/{id}/test

**Send a test event.** POSTs one synthetic, signed payload to the endpoint immediately and reports the outcome, so you can check a new endpoint without waiting for a real event. The body has the same shape as a real event of that type — a contact event has the person under `data.contact` — with `"test": true` at the top of `data`, obviously fake values, and a delivery id that starts `test_`, so anything you map from a test keeps working when real events arrive. It defaults to a `contact.created` event; pass `event` to use another. A test is not a real event, so it is not added to the delivery log. The response is `200` whether the endpoint accepted the payload or not — read `ok`. Requires `webhooks:write`.

#### Request body (optional)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `event` | any | no | Which event name to put in the test payload. Defaults to `contact.created`. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/webhooks/id/test" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "event": null
}'
```

#### Responses

`200` The result of the attempt.

```
{
  "ok": true,
  "status": 200,
  "error": "timed out"
}
```

`400` Invalid JSON body or `Unknown event type: <name>`.

```
{
  "error": "Unknown event type: contact.exploded"
}
```

`401`

No body.

`403`

No body.

`404`

No body.

`500`

No body.

## Connect

The one-button connection the WordPress plugin uses: the site owner approves what the site may do on a SendBeam page, and the site exchanges the resulting one-use grant for an API key. No API key — obtaining one is the point.

### POST /api/v1/connect/exchange

**Exchange a connect grant for an API key.** Second half of the Connect flow the WordPress plugin uses. The site owner approves the connection at `https://sendbeam.io/connect/wordpress?…` in a pop-up; SendBeam mints an API key with the approved scopes and redirects the pop-up back to `return_to` with `state` and `grant`. The site's SERVER then posts the grant here and receives the key — once. Send no `x-api-key`: the grant is the credential. It is bound to the `state` the site generated and to the site origin the key was minted for, expires ten minutes after it was issued, and is destroyed the first time it is used, so the key never travels through a browser. Scopes are the plugin's own words — `forms`, `contacts:write`, `transactional:send`, `ecommerce`, `domain` — and the key they map to carries the ordinary API permissions behind them. Approving the connection also SETS THE SITE UP, and the response says what was done: the sending domain the owner chose is added with its DNS records (scope `domain`), a workspace with no signup form is given a `Subscribers` list and a `Newsletter signup` form, and a workspace with no from line takes the site's name and `hello@` that domain. Nothing already chosen is changed, and a previous key for the same site — matched by name OR by the host it was minted for — is revoked, so a reconnection replaces rather than accumulates. When there is no domain, `domain_state` and `domain_note` say why: a hosting company's host cannot be added at all, and a plan at its domain cap says so in words.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `grant` | string | yes | The grant id from the redirect, 43 characters of base64url. |
| `state` | string | yes | The token the site generated and sent as `state`, returned unchanged. |
| `site_url` | string | yes | The site origin the grant was minted for, exactly as sent. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/connect/exchange" \
  -H "Content-Type: application/json" \
  -d '{
  "grant": "k7Qb2m9x1s4d6f8g0h2j4k6l8n0p2r4t6v8x0z2b4d6",
  "state": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
  "site_url": "https://example.com"
}'
```

#### Responses

`200` The key, once. Store it; it is never shown again.

```
{
  "api_key": "sb_live_XXXXXXXX_YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY",
  "key_prefix": "XXXXXXXX",
  "workspace": {
    "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
    "name": "Example Shop"
  },
  "scopes": [
    "forms",
    "transactional:send",
    "domain"
  ],
  "default_form": {
    "id": "8a7b6c5d-4e3f-2a1b-0c9d-8e7f6a5b4c3d",
    "name": "Newsletter signup"
  },
  "domain": {
    "name": "example.com",
    "verified": false,
    "records": [
      {
        "type": "CNAME",
        "name": "send.example.com",
        "value": "send.9f2c.dom.sendbeam.io",
        "found": null
      }
    ],
    "domain_connect_url": null,
    "checked_at": null
  },
  "domain_state": "ok",
  "domain_note": null,
  "sender": {
    "from_name": "Example Shop",
    "from_email": "hello@example.com"
  }
}
```

`400` Malformed body, or the `state` or `site_url` does not match the grant.

```
{
  "error": "invalid"
}
```

`410` The grant has expired, has already been used, or never existed. Start the connection again.

```
{
  "error": "grant_expired_or_used"
}
```

`429` More than 20 attempts in an hour from this address or for this site.

```
{
  "error": "rate_limited"
}
```

### GET /api/v1/connect/status

**What the connected site still has to do.** What the connected site shows its owner: the workspace, the signup form to offer, the sending domain with the DNS records still outstanding, and the sender line. Cheap enough to call on every admin-page render — cache it for a minute — and it makes no external lookups at all once the domain is verified. The `domain` block requires `domains:read` and is `null` without it; it is also `null` for a key that was not minted by a Connect flow, because such a key belongs to no site. `domain_state` and `domain_note` say which of those it is, so a site can tell an owner what to do rather than only that something is missing. Which domain a key is told about is fixed at connection time and cannot be chosen by the caller. The POST form of this call does not count against the workspace’s hourly write allowance: it reads.

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/connect/status" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` The connection as it stands.

```
{
  "workspace": {
    "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
    "name": "Example Shop"
  },
  "default_form": {
    "id": "8a7b6c5d-4e3f-2a1b-0c9d-8e7f6a5b4c3d",
    "name": "Newsletter signup"
  },
  "domain": {
    "name": "example.com",
    "verified": true,
    "records": [
      {
        "type": "CNAME",
        "name": "send.example.com",
        "value": "send.9f2c.dom.sendbeam.io",
        "found": true
      }
    ],
    "domain_connect_url": null,
    "checked_at": "2026-09-23T15:04:05Z"
  },
  "domain_state": "ok",
  "domain_note": null,
  "sender": {
    "from_name": "Example Shop",
    "from_email": "hello@example.com"
  }
}
```

`401`

No body.

### POST /api/v1/connect/status

**Check the sending domain now.** The same body, after running the sending domain’s DNS check now — what the Check button under Settings → Sending does. Use it when the owner says they have added the records, rather than waiting for the background re-check. Requires `domains:read`. Rate limited to 12 an hour per key; send `{}` or omit the body for a plain read, which is not limited.

#### Request body (optional)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `check_domain` | boolean | no | Resolve the domain's records and ask the provider, then answer with the fresh `verified` and `checked_at`. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/connect/status" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "check_domain": false
}'
```

#### Responses

`200` The connection as it stands, checked just now.

```
{
  "workspace": {
    "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
    "name": "Example Shop"
  },
  "default_form": {
    "id": "8a7b6c5d-4e3f-2a1b-0c9d-8e7f6a5b4c3d",
    "name": "Newsletter signup"
  },
  "domain": {
    "name": "example.com",
    "verified": true,
    "records": [
      {
        "type": "CNAME",
        "name": "send.example.com",
        "value": "send.9f2c.dom.sendbeam.io",
        "found": true
      }
    ],
    "domain_connect_url": null,
    "checked_at": "2026-09-23T15:04:05Z"
  },
  "domain_state": "ok",
  "domain_note": null,
  "sender": {
    "from_name": "Example Shop",
    "from_email": "hello@example.com"
  }
}
```

`401`

No body.

`403` The key lacks `domains:read`, so the check is refused rather than run and withheld.

```
{
  "error": "Forbidden: domains:read permission required"
}
```

`429` More than 12 checks in an hour with this key. The body carries `retry_after` (seconds) and a `message` written for the person who pressed the button, and the response carries a `Retry-After` header saying the same. SendBeam re-checks an unverified domain on its own every few minutes, so waiting costs nothing.

```
{
  "error": "rate_limited",
  "retry_after": 3600,
  "message": "SendBeam re-checks this domain on its own every few minutes. You can ask again in 60 minutes."
}
```

### GET /api/v1/domains/{id}/connect

**Send the owner to their registrar to add the records.** Automatic DNS (Domain Connect): redirects the SIGNED-IN workspace admin to their own DNS provider, where they approve SendBeam’s records and are sent back. A browser redirect, not an API-key call — it is the address `domain_connect_url` carries in a Connect status body, and it is opened in a tab rather than fetched. When the provider cannot apply the records the browser comes back to SendBeam’s sending settings with a reason instead.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | The sending domain to set up. |
| `return_to` | query | string | no | Where to put the owner down when the registrar has finished — a WordPress site sends its own plugin page, because that is where the button was pressed and the plugin is what has to notice the records are in. Honoured ONLY when its origin is that of a site this workspace holds an ACTIVE Connect key for; anything else is ignored and the flow ends on SendBeam’s sending settings as before. The site is returned to with `sb_dc=done`, or `sb_dc=error&sb_dc_reason=` when the registrar declined. |

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/domains/9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d/connect?return_to=string"
```

#### Responses

`302` To the registrar’s approval page, or back to `/settings/domains` with a `dc_error` reason when it cannot start.

No body.

### POST /api/v1/connect/disconnect

**Revoke the calling key.** Revokes THE KEY THAT MADE THIS CALL, and nothing else — there is no key id to send, because the credential is the request. Use it when the site owner disconnects in WordPress, so deleting a plugin does not leave a live key behind that can write contacts and send the site’s mail. The workspace is untouched: the sending domain, the list, the form and the sender stay exactly where they are, and other sites sending from the same domain are unaffected. Any later call with the same key is `401`, which is what makes it idempotent. Like the status check, it does not count against the workspace’s hourly write allowance.

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/connect/disconnect" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`204` Revoked. No body.

No body.

`400` The request was signed by a session rather than an API key, so there is no key to retire.

```
{
  "error": "This endpoint retires the API key that called it. Sign the request with that key."
}
```

`401`

No body.

`500`

No body.

## Audit log

Who did what in the workspace: sign-ins, keys, team, sending, webhooks, connections, exports and account policy. Read-only. A key needs `audit:read`; a person needs the workspace admin role. Entries are kept for 12 months.

### GET /api/v1/account/audit-log

**List audit events.** Who did what in the workspace, newest first: sign-ins and sign-outs, two-factor and passkey changes, API keys created and revoked, team changes, sender and domain changes, webhooks, connections, exports, and the account security policy. Cursor-paged: pass the `next_cursor` a page returns as `cursor` for the next one; `next_cursor` is null on the last page. Entries are kept for `retention_days` (365) and a query never reaches further back. Requires `audit:read`; a session caller needs the workspace admin role. `scope=account` (sessions only, for a person who administers every workspace on the account) returns every workspace's events with `workspace_id` and `workspace_name` on each.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `cursor` | query | string | no | Opaque cursor from the previous page's `next_cursor`. Omit for the first page. |
| `limit` | query | integer | no | Items per page (1–100). |
| `action` | query | string | no | One action (`api_key.created`), or a family as a prefix ending in a dot (`team.`). Actions: `session.signed_in`, `session.signed_out`, `session.ended_by_policy`, `access.denied`, `mfa.enabled`, `mfa.disabled`, `password.changed`, `passkey.added`, `passkey.removed`, `trusted_device.remembered`, `trusted_device.forgotten`, `api_key.created`, `api_key.revoked`, `team.invited`, `team.invitation_revoked`, `team.joined`, `team.role_changed`, `team.member_removed`, `team.ownership_transferred`, `workspace.renamed`, `workspace.deletion_scheduled`, `workspace.restored`, `workspace.exported`, `contacts.exported`, `suppressions.exported`, `sending.identity_changed`, `domain.added`, `domain.removed`, `webhook.created`, `webhook.updated`, `webhook.deleted`, `webhook.secret_rotated`, `connection.connected`, `connection.updated`, `connection.disconnected`, `ecommerce.secret_set`, `ecommerce.secret_cleared`, `account.policy_changed`, `account.scim_token_created`, `account.scim_token_revoked`, `account.erasure_run`, `scim.user_provisioned`, `scim.user_renamed`, `scim.user_deprovisioned`. |
| `actor` | query | string | no | Only events whose actor label starts with this: an email address or an API key name. Case-insensitive. |
| `from` | query | string | no | Only events on or after this day (YYYY-MM-DD). |
| `to` | query | string | no | Only events on or before this day (YYYY-MM-DD; the whole day counts). |
| `scope` | query | "workspace" \| "account" | no | `workspace` (default) or `account`. Sessions only: an API key always reads the workspace it was minted for. |

#### Example request

```
curl -X GET "https://sendbeam.io/api/v1/account/audit-log?cursor=string&limit=50" \
  -H "x-api-key: sb_live_…"
```

#### Responses

`200` A page of events.

```
{
  "events": [
    {
      "id": "0192b6a4-6d3e-7c1a-9f2e-3b4c5d6e7f80",
      "created_at": "2026-09-25T09:12:41.000Z",
      "action": "api_key.created",
      "actor_type": "user",
      "actor_id": "4f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
      "actor_label": "jane@example.com",
      "target_type": "api_key",
      "target_id": "9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
      "target_label": "Zapier",
      "workspace_id": "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
      "workspace_name": "Harbour Lane",
      "metadata": {
        "permissions": [
          "contacts:read",
          "contacts:write"
        ]
      },
      "ip": "203.0.113.7"
    }
  ],
  "next_cursor": null,
  "retention_days": 365
}
```

`401`

No body.

`403` The key lacks `audit:read`, the session is not a workspace admin, or `scope=account` was asked for by a key or by someone who does not administer every workspace on the account.

```
{
  "error": "Forbidden: audit:read permission required"
}
```

`404` The workspace has no account.

```
{
  "error": "No account"
}
```

`500` The log could not be read.

```
{
  "error": "The audit log could not be read"
}
```

## Subscriber pages

HTML pages subscribers reach from email links. No authentication.

### GET /c/{id}

**Web version of a sent campaign.** The "view in browser" page for a campaign that has been sent (`{{web_version_url}}` in the email). With `contact` and the signed `token` from that recipient's email the page is personalised — merge tags filled in and a working unsubscribe link; without them, or with a token that does not verify, it renders the campaign with empty merge fields. Drafts, scheduled and cancelled campaigns are 404. Not indexed.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Campaign ID. |
| `contact` | query | string | no | Recipient contact ID, for a personalised view. |
| `token` | query | string | no | The recipient's signed token (the same one as their unsubscribe link). |

#### Example request

```
curl -X GET "https://sendbeam.io/c/9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d?contact=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d&token=string"
```

#### Responses

`200` The campaign as an HTML page.

No body (HTML page).

`404` No sent campaign with this id.

No body (HTML page).

### GET /p/{poll}/{option}

**Where a poll answer lands.** The page a poll button in an email opens. The answer is recorded by the click itself — every link in a sent email is tracked per recipient, and this URL on the click event is the answer — so the page stores nothing and only thanks the reader. `poll` is the poll block's id and `option` the button's 1-based position; anything else is 404. Not indexed. Results are in `GET /api/v1/campaigns/{id}/report` under `polls`.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `poll` | path | string | yes | The poll block's id. |
| `option` | path | integer | yes | Which answer, 1-based. |

#### Example request

```
curl -X GET "https://sendbeam.io/p/string/1"
```

#### Responses

`200` A thank-you page.

No body (HTML page).

`404` Not a poll answer path.

No body.

### GET /api/unsubscribe

**Unsubscribe confirmation page.** Never changes state (link scanners follow GET). The signed `token` from the email link is required and is verified against `contact` and `campaign`; a link without a valid signature shows an "invalid link" page. A contact on one or more lists sees a preference page — each list ticked, the campaign's own list marked "this email" — with Save preferences (leave only the unticked lists) and Unsubscribe from everything. When another workspace on the same account has marked a list offerable, that page also carries a "More from us" tab — never the one it opens on — listing those lists unticked, with a Sign me up button. Otherwise shows a page with a button that POSTs back with the same parameters. Returns `text/html`.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `contact` | query | string | yes | Contact ID from the email link. |
| `campaign` | query | string | no | Campaign ID, to attribute the unsubscribe. |
| `token` | query | string | yes | Signed token from the email link. Bound to the contact and campaign. |

#### Example request

```
curl -X GET "https://sendbeam.io/api/unsubscribe?contact=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d&campaign=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
```

#### Responses

`200` Confirmation page, or an "already unsubscribed" page.

No body (HTML page).

`400` Missing `contact`.

No body (HTML page).

`403` Token missing, invalid or expired.

No body (HTML page).

`404` Unknown contact.

No body (HTML page).

### POST /api/unsubscribe

**Unsubscribe a contact.** Performs the unsubscribe. Accepts the parameters from the query string (RFC 8058 one-click, as mailbox providers POST to the `List-Unsubscribe` URL) or from a form-encoded body (the confirmation page). The signed `token` is required and must verify against `contact` and `campaign`; links without a valid signature (including those from emails sent before links were signed) are refused with `403`. Sets the contact to `unsubscribed`, adds the address to the workspace's suppression list and, if `campaign` is given, increments that campaign's unsubscribe count. With `action=join` — the "More from us" form on the preference page — nothing is unsubscribed: each id in `join` is a list that another workspace on the same account has marked offerable, and the contact is signed up to it through that workspace's ordinary signup path, so its suppression list, contact cap and double opt-in all apply; the page says only whether anything changed. Returns `text/html`.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `contact` | query | string | no | Contact ID (required here or in the body). |
| `campaign` | query | string | no |  |
| `token` | query | string | no |  |

#### Request body (optional)

See the OpenAPI document for the body schema.

#### Example request

```
curl -X POST "https://sendbeam.io/api/unsubscribe?contact=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d&campaign=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
```

#### Responses

`200` Unsubscribed, or already unsubscribed.

No body (HTML page).

`400` Missing `contact`.

No body (HTML page).

`403` Token missing, invalid or expired.

No body (HTML page).

`404` Unknown contact.

No body (HTML page).

`500` Update failed.

No body (HTML page).

### GET /api/confirm-optin

**Confirm a double opt-in subscription.** Target of the link in the double opt-in email. Marks the list membership confirmed and enrols `list_joined` automations. Returns `text/html`.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `token` | query | string | yes |  |
| `list` | query | string | yes |  |
| `contact` | query | string | yes |  |

#### Example request

```
curl -X GET "https://sendbeam.io/api/confirm-optin?token=string&list=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
```

#### Responses

`200` Subscription confirmed.

No body (HTML page).

`400` Missing parameters, or the link is expired/invalid/already used.

No body (HTML page).

## MCP

The Model Context Protocol server for AI agents: every operation in this document that a key may call, offered as a tool to Claude Code, Cursor and any other MCP client. See the guide at /docs/integrations/mcp.

### POST /api/v1/mcp

**MCP server (Streamable HTTP).** A Model Context Protocol server over Streamable HTTP, stateless, JSON responses. Send JSON-RPC 2.0 messages (`initialize`, `ping`, `tools/list`, `tools/call`; a notification alone answers `202`). Authenticate with the workspace API key as `Authorization: Bearer sb_live_…` or in `x-api-key` — or, for an app connected through OAuth (Claude.ai and other clients that sign in rather than take a key), with the access token that sign-in produced; a `401` carries `WWW-Authenticate` naming the protected-resource metadata at `/.well-known/oauth-protected-resource/api/v1/mcp`. Either way the key's permissions decide which tools are listed and callable — each tool is one operation in this document, run with the same checks a direct call gets, and a write spends the same hourly allowance. Behind a feature flag (`404` when off). `GET` and `DELETE` answer `405`: there is no server stream and no session.

#### Request body

See the OpenAPI document for the body schema.

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/mcp" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "jsonrpc": "2.0",
  "id": "string",
  "method": "initialize",
  "params": {}
}'
```

#### Responses

`200` The JSON-RPC response (or, for a batch, the array of responses).

```
{}
```

`202` The message was a notification; nothing to answer.

No body.

`400` Not JSON, an empty batch, or more than 20 requests in one.

```
{}
```

`401`

No body.

`404` The MCP server is not switched on for this workspace.

```
{
  "error": "Contact not found"
}
```

## E-commerce

### POST /api/v1/ecommerce/events

**Report a cart, product-view, order, refund or cancellation event.** Feeds the three native e-commerce automation triggers (`cart_abandoned`, `product_viewed`, `order_placed` — see `TriggerType`): matches or creates the contact by email using the same rules as every other place a contact is upserted from an external event (a suppressed address is never (re)subscribed; the plan's contact cap is respected), then fires any automation built on the matching trigger — at once, the same as a form submission. `order_placed` also increases the contact's `lifetime_value` custom field (Number, auto-registered) by `value`; it is always ADDED, never overwritten, so orders accumulate. Two auth paths: an `x-api-key` with `ecommerce:write` and this JSON body (the WordPress plugin, n8n, Zapier, your own code); or, alongside it, Shopify's own webhook format verified by `X-Shopify-Hmac-Sha256` against a per-workspace secret set at Settings → E-commerce — post that path directly from Shopify Admin → Settings → Notifications → Webhooks, no app required (`?tenant=<workspace id>` identifies the workspace, since Shopify cannot send a custom header). A Shopify request with no configured secret, or a signature that does not verify, is refused; a request that verifies but names an unmapped topic, or a Shopify checkout/order with no known email yet, is acknowledged (`200`, `processed: false`) rather than treated as an error, since a store's webhook delivery must not be retried forever over something that will never resolve.

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | "cart_abandoned" \| "product_viewed" \| "order_placed" \| "order_refunded" \| "order_cancelled" | yes | `order_refunded` and `order_cancelled` adjust an `order_placed` reported earlier with the same `external_id`: they net the order off every revenue figure and the contact's `lifetime_value`, fire no automation, and never create a contact. `email` is optional for them. A refund without `value` is the whole remaining total; a second identical refund nets nothing. |
| `email` | string | yes |  |
| `name` | string | no | Split on the first space into first_name/last_name; only ever fills in a name the contact does not already have. |
| `value` | number | no | Cart/order total in the store's own currency. For `order_placed` it is required in practice: without it the event neither updates `lifetime_value` nor is stored as an order, so it can never be attributed. Optional otherwise. |
| `currency` | string | no |  |
| `external_id` | string | no | The order's id in your store. With it, an `order_placed` is stored once (a retry is a no-op) and attributed to the campaign or automation email that preceded it — see /docs/ecommerce/revenue. Without it the order still fires triggers and moves `lifetime_value`, but is never stored or attributed. `order_id` is accepted as an alias. |
| `order_id` | string | no | Alias of `external_id`. |
| `placed_at` | string | no | When the order was placed. Defaults to now; attribution windows are measured back from it. |
| `source` | string | no | Recorded as the contact's `source` only when this call creates a new contact. Defaults to `api`. |

#### Example request

```
curl -X POST "https://sendbeam.io/api/v1/ecommerce/events" \
  -H "x-api-key: sb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "type": "cart_abandoned",
  "email": "jane@example.com",
  "name": "string",
  "value": 1,
  "currency": "GBP",
  "external_id": "string",
  "order_id": "string",
  "placed_at": "2026-09-02T12:00:00Z",
  "source": "string"
}'
```

#### Responses

`200` Processed — or deliberately not (`processed: false`, with `reason` — `suppressed` or `contact_limit` for the x-api-key path, `unknown_order` for an adjustment naming an order this endpoint never stored). An adjustment answers with `adjusted` and the `amount` netted this time (0 when already applied).

```
{
  "ok": true,
  "processed": true,
  "contact_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33",
  "contact_created": false,
  "enrolled": true,
  "lifetime_value": 214.3
}
```

`400` Invalid JSON body, an invalid `type`/`email`/`value`, or (Shopify path) a missing `?tenant=`.

```
{
  "error": "Contact not found"
}
```

`401` Missing/invalid API key, or (Shopify path) an unknown workspace, no webhook secret configured for it, or a signature that does not verify.

```
{
  "error": "Invalid Shopify webhook signature"
}
```

`403` Missing `ecommerce:write` permission.

```
{
  "error": "Forbidden: ecommerce:write permission required"
}
```

`500` Contact lookup/creation failed.

```
{
  "error": "Contact not found"
}
```

## Schemas

JsonRpcRequest — A JSON-RPC 2.0 request as MCP defines it. For `tools/call`, `params` is `{ name, arguments }`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `jsonrpc` | "2.0" | yes |  |
| `id` | string \| integer | no | Absent on a notification. |
| `method` | "initialize" \| "ping" \| "tools/list" \| "tools/call" \| "notifications/initialized" | yes |  |
| `params` | object | no |  |

Example:

```
{
  "jsonrpc": "2.0",
  "id": "string",
  "method": "initialize",
  "params": {}
}
```

Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | string | yes |  |

Example:

```
{
  "error": "Contact not found"
}
```

ConnectForm — A signup form the site can put on a page.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `name` | string | yes |  |

Example:

```
{
  "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "name": "Newsletter signup"
}
```

ConnectDomainRecord — One DNS record the site owner adds at their registrar. Every one is a CNAME, and none carries a priority.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | string | yes |  |
| `name` | string | yes | The record name, fully qualified. |
| `value` | string | yes | What it points at. |
| `found` | boolean \| null | yes | Whether the last check resolved this record. `null` when it has never been checked — show that as "not checked yet" rather than as a record that is wrong. Show the tick per row: two of three records right looks like none at all without it. |

Example:

```
{
  "type": "CNAME",
  "name": "send.example.com",
  "value": "send.9f2c.dom.sendbeam.io",
  "found": false
}
```

ConnectDomain — The site's sending domain. Requires `domains:read`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | The host, with any leading `www.` removed. |
| `verified` | boolean | yes | True once every record resolves and the provider agrees. Do not route a site's email through an unverified domain. |
| `records` | array of ConnectDomainRecord | yes | The records still to add, in the order to show them. |
| `domain_connect_url` | string \| null | yes | Open this in a new tab and the owner's own registrar adds the records for them. Present only when the registrar supports it and the owner has not already been through it. |
| `checked_at` | string \| null | yes | When the records were last resolved. |

Example:

```
{
  "name": "example.com",
  "verified": true,
  "records": [
    {
      "type": "CNAME",
      "name": "send.example.com",
      "value": "send.9f2c.dom.sendbeam.io",
      "found": false
    }
  ],
  "domain_connect_url": null,
  "checked_at": null
}
```

ConnectSender — The workspace's from line. `from_email` is null until one is chosen.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `from_name` | string | yes |  |
| `from_email` | string \| null | yes |  |

Example:

```
{
  "from_name": "Example Shop",
  "from_email": "hello@example.com"
}
```

ConnectDomainState — Why `domain` is what it is. `ok` whenever there is one. `not_granted`: the owner left the sending-domain line unticked when they connected. `no_permission`: this key holds no `domains:read`. `no_site`: the key belongs to no site, so it belongs to no domain. `not_found`: the workspace no longer has a row for that host. `managed_host`: the host is a hosting company’s (example.wordpress.com, a bare IP address), which mail cannot be sent from. `unavailable`: the read, or the step that would have added it, failed.

```
{
  "type": "string",
  "enum": [
    "ok",
    "not_granted",
    "no_permission",
    "no_site",
    "not_found",
    "managed_host",
    "unavailable"
  ],
  "description": "Why `domain` is what it is. `ok` whenever there is one. `not_granted`: the owner left the sending-domain line unticked when they connected. `no_permission`: this key holds no `domains:read`. `no_site`: the key belongs to no site, so it belongs to no domain. `not_found`: the workspace no longer has a row for that host. `managed_host`: the host is a hosting company’s (example.wordpress.com, a bare IP address), which mail cannot be sent from. `unavailable`: the read, or the step that would have added it, failed."
}
```

Example:

```
"ok"
```

ConnectStatus

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `workspace` | object | yes |  |
| `default_form` | ConnectForm \| null | yes |  |
| `domain` | ConnectDomain \| null | yes | Null without `domains:read`, and null for a key that belongs to no site. `domain_state` says which. |
| `domain_state` | ConnectDomainState | yes | Why `domain` is what it is. `ok` whenever there is one. `not_granted`: the owner left the sending-domain line unticked when they connected. `no_permission`: this key holds no `domains:read`. `no_site`: the key belongs to no site, so it belongs to no domain. `not_found`: the workspace no longer has a row for that host. `managed_host`: the host is a hosting company’s (example.wordpress.com, a bare IP address), which mail cannot be sent from. `unavailable`: the read, or the step that would have added it, failed. |
| `domain_note` | string \| null | yes | One sentence for the site owner when there is no domain — written for them, and carrying the real reason where there is one ("Your plan allows 1 sending domain per workspace."). Null when `domain_state` is `ok`. |
| `sender` | ConnectSender | yes | The workspace's from line. `from_email` is null until one is chosen. |

Example:

```
{
  "workspace": {
    "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
    "name": "Example Shop"
  },
  "default_form": {
    "id": "8a7b6c5d-4e3f-2a1b-0c9d-8e7f6a5b4c3d",
    "name": "Newsletter signup"
  },
  "domain": {
    "name": "example.com",
    "verified": true,
    "records": [
      {
        "type": "CNAME",
        "name": "send.example.com",
        "value": "send.9f2c.dom.sendbeam.io",
        "found": true
      }
    ],
    "domain_connect_url": null,
    "checked_at": "2026-09-23T15:04:05Z"
  },
  "domain_state": "ok",
  "domain_note": null,
  "sender": {
    "from_name": "Example Shop",
    "from_email": "hello@example.com"
  }
}
```

MediaFile

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | File id (`<id>.<ext>`), unique in the workspace; what `DELETE /media/{id}` takes. |
| `url` | string | yes | Public URL to use in an email. |
| `name` | string | yes | The original file name. |
| `size` | integer | yes | Bytes. |
| `type` | "image/png" \| "image/jpeg" \| "image/gif" \| "image/webp" | yes |  |
| `uploaded` | string | yes |  |

Example:

```
{
  "id": "string",
  "url": "string",
  "name": "string",
  "size": 1,
  "type": "image/png",
  "uploaded": "2026-09-02T12:00:00Z"
}
```

MediaListing

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `configured` | boolean | yes | False when image hosting is not enabled on the installation; the list is then empty and uploads answer `503`. |
| `files` | array of MediaFile | yes |  |
| `count` | integer | yes |  |
| `used_bytes` | integer | yes | Storage the workspace is using. |
| `quota_bytes` | integer | yes | The plan's cap. |
| `max_bytes` | integer | yes | Largest single upload accepted. |
| `types` | array of string | no |  |
| `truncated` | boolean | no | True when the workspace holds more than 5,000 images and the list stopped there. |

Example:

```
{
  "configured": true,
  "files": [
    {
      "id": "string",
      "url": "string",
      "name": "string",
      "size": 1,
      "type": "image/png",
      "uploaded": "2026-09-02T12:00:00Z"
    }
  ],
  "count": 1,
  "used_bytes": 1,
  "quota_bytes": 1,
  "max_bytes": 1,
  "types": [
    "string"
  ],
  "truncated": true
}
```

WebhookEvent — One of the events an endpoint can subscribe to. The list is generated from the platform's own event catalog, so it cannot drift from what is actually sent.

```
{
  "type": "string",
  "description": "One of the events an endpoint can subscribe to. The list is generated from the platform's own event catalog, so it cannot drift from what is actually sent.",
  "enum": [
    "contact.created",
    "contact.updated",
    "contact.unsubscribed",
    "contact.resubscribed",
    "contact.bounced",
    "contact.complained",
    "contact.deleted",
    "contact.tag_added",
    "contact.tag_removed",
    "contact.list_joined",
    "contact.list_left",
    "email.sent",
    "email.delivered",
    "email.opened",
    "email.clicked",
    "email.bounced",
    "email.complained",
    "campaign.sent",
    "form.submitted",
    "domain.verified",
    "domain.failed",
    "workspace.paused",
    "workspace.resumed",
    "workspace.health_warning",
    "automation.failed",
    "automation.step_reached"
  ],
  "example": "contact.created"
}
```

Example:

```
"contact.created"
```

WebhookDeliveryStatus — `pending` is queued or waiting for its next retry, `delivered` got a 2xx, `failed` used up its attempts, `abandoned` was dropped because the endpoint was disabled or deleted first.

```
{
  "type": "string",
  "description": "`pending` is queued or waiting for its next retry, `delivered` got a 2xx, `failed` used up its attempts, `abandoned` was dropped because the endpoint was disabled or deleted first.",
  "enum": [
    "pending",
    "delivered",
    "failed",
    "abandoned"
  ]
}
```

Example:

```
"pending"
```

WebhookFilters — Optional scope. Omitted or `{}` means unfiltered — every event of a subscribed type fires, which is also every existing endpoint's behaviour before this field existed. A dimension you set must be present on the event itself or that endpoint is skipped for it (an endpoint scoped to a list, for instance, never receives a listless event); dimensions you set combine with AND, ids within one dimension combine with OR. Every id is checked against your own workspace at write time; an id that is not yours is refused, not silently ignored. At most 100 ids per key.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `list_ids` | array of string | no | Only contact.list_joined / contact.list_left for these lists (and any contact event tied to one of them). |
| `form_ids` | array of string | no | Only form.submitted for these forms (and contacts created through one of them). |
| `tag_ids` | array of string | no | Only contact.tag_added / contact.tag_removed for these tags. |
| `campaign_ids` | array of string | no | Only campaign.sent, and the email.* events, for these campaigns. |
| `domain_ids` | array of string | no | Only domain.verified / domain.failed for these sending domains. |

Example:

```
{
  "list_ids": [
    "b3f1c2a4-1111-4a2b-8c3d-0000000000aa"
  ]
}
```

WebhookEndpoint — A registered endpoint. The signing secret is never part of this shape.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `url` | string | yes |  |
| `description` | string \| null | yes | Your own note about what this endpoint is for. |
| `event_types` | array of WebhookEvent | yes | The events this endpoint receives. |
| `filters` | WebhookFilters | yes | Optional scope. Omitted or `{}` means unfiltered — every event of a subscribed type fires, which is also every existing endpoint's behaviour before this field existed. A dimension you set must be present on the event itself or that endpoint is skipped for it (an endpoint scoped to a list, for instance, never receives a listless event); dimensions you set combine with AND, ids within one dimension combine with OR. Every id is checked against your own workspace at write time; an id that is not yours is refused, not silently ignored. At most 100 ids per key. |
| `enabled` | boolean | yes | A disabled endpoint receives nothing; anything queued for it while disabled is abandoned. |
| `disabled_reason` | string \| null | yes | Set when SendBeam disabled the endpoint itself after sustained failure; null when you disabled it or it is enabled. |
| `consecutive_failures` | integer | yes | Failed deliveries in a row. Reset to 0 by the next success, and by re-enabling an auto-disabled endpoint. |
| `last_success_at` | string \| null | yes |  |
| `last_failure_at` | string \| null | yes |  |
| `created_at` | string | yes |  |

Example:

```
{
  "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "url": "https://example.com/hooks/sendbeam",
  "description": "Sync new contacts into the CRM",
  "event_types": [
    "contact.created"
  ],
  "filters": {
    "list_ids": [
      "b3f1c2a4-1111-4a2b-8c3d-0000000000aa"
    ]
  },
  "enabled": true,
  "disabled_reason": null,
  "consecutive_failures": 0,
  "last_success_at": null,
  "last_failure_at": null,
  "created_at": "2026-09-02T12:00:00Z"
}
```

WebhookCreate

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | string | yes | Public `https://` URL to POST to. Must not resolve to a private, loopback or link-local address, and must not contain credentials. |
| `description` | string | no | Optional note for your own reference. |
| `event_types` | array of WebhookEvent | yes | At least one event. Duplicates are collapsed; an unknown name is refused and named back to you. |
| `filters` | WebhookFilters | no | Optional scope. Omitted or `{}` means unfiltered — every event of a subscribed type fires, which is also every existing endpoint's behaviour before this field existed. A dimension you set must be present on the event itself or that endpoint is skipped for it (an endpoint scoped to a list, for instance, never receives a listless event); dimensions you set combine with AND, ids within one dimension combine with OR. Every id is checked against your own workspace at write time; an id that is not yours is refused, not silently ignored. At most 100 ids per key. |

Example:

```
{
  "url": "https://example.com/hooks/sendbeam",
  "description": "Sync new contacts into the CRM",
  "event_types": [
    "contact.created",
    "contact.unsubscribed"
  ],
  "filters": {
    "list_ids": [
      "b3f1c2a4-1111-4a2b-8c3d-0000000000aa"
    ]
  }
}
```

WebhookUpdate — Every field is optional; send only what you want changed.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | string | no |  |
| `description` | string \| null | no |  |
| `event_types` | array of WebhookEvent | no |  |
| `filters` | WebhookFilters | no | Optional scope. Omitted or `{}` means unfiltered — every event of a subscribed type fires, which is also every existing endpoint's behaviour before this field existed. A dimension you set must be present on the event itself or that endpoint is skipped for it (an endpoint scoped to a list, for instance, never receives a listless event); dimensions you set combine with AND, ids within one dimension combine with OR. Every id is checked against your own workspace at write time; an id that is not yours is refused, not silently ignored. At most 100 ids per key. |
| `enabled` | boolean | no | Set to `true` to re-enable an endpoint that was auto-disabled; that also clears `disabled_reason` and resets `consecutive_failures`. |

Example:

```
{
  "url": "https://example.com/hooks/sendbeam-v2",
  "description": null,
  "event_types": [
    "contact.created"
  ],
  "filters": {
    "list_ids": [
      "b3f1c2a4-1111-4a2b-8c3d-0000000000aa"
    ]
  },
  "enabled": true
}
```

WebhookCreated — The created endpoint, plus the signing secret. This is the only time the secret is returned in the clear.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `url` | string | yes |  |
| `description` | string \| null | yes |  |
| `event_types` | array of WebhookEvent | yes |  |
| `filters` | WebhookFilters | yes | Optional scope. Omitted or `{}` means unfiltered — every event of a subscribed type fires, which is also every existing endpoint's behaviour before this field existed. A dimension you set must be present on the event itself or that endpoint is skipped for it (an endpoint scoped to a list, for instance, never receives a listless event); dimensions you set combine with AND, ids within one dimension combine with OR. Every id is checked against your own workspace at write time; an id that is not yours is refused, not silently ignored. At most 100 ids per key. |
| `enabled` | boolean | yes |  |
| `secret` | string | yes | 64 hexadecimal characters. Store it now — it is never shown again, only replaced. |
| `created_at` | string | yes |  |

Example:

```
{
  "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "url": "https://example.com/hooks/sendbeam",
  "description": "Sync new contacts into the CRM",
  "event_types": [
    "contact.created"
  ],
  "filters": {
    "list_ids": [
      "b3f1c2a4-1111-4a2b-8c3d-0000000000aa"
    ]
  },
  "enabled": true,
  "secret": "9f2c1a0b3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8",
  "created_at": "2026-09-02T12:00:00Z"
}
```

WebhookSecret

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `secret` | string | yes | The new signing secret, 64 hexadecimal characters. The previous one is invalid from this moment. |

Example:

```
{
  "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "secret": "3b7d4bad9bdd2b0d7b3dcb6d9f2c1a0b3d4e5f60718293a4b5c6d7e8f90a1b2c3"
}
```

WebhookDelivery — One attempt record from the delivery log. `payload` is present only when the request asked for it with `include=payload`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Also the `id` field inside the payload and the `X-SendBeam-Delivery` header, stable across retries — use it to deduplicate. |
| `event_type` | WebhookEvent | yes | One of the events an endpoint can subscribe to. The list is generated from the platform's own event catalog, so it cannot drift from what is actually sent. |
| `status` | WebhookDeliveryStatus | yes | `pending` is queued or waiting for its next retry, `delivered` got a 2xx, `failed` used up its attempts, `abandoned` was dropped because the endpoint was disabled or deleted first. |
| `attempts` | integer | yes | How many times it has been POSTed so far. |
| `last_status_code` | integer \| null | yes | HTTP status of the most recent attempt, null if the request never got a response. |
| `last_error` | string \| null | yes | Why the most recent attempt failed, truncated to 500 characters. |
| `delivered_at` | string \| null | yes |  |
| `created_at` | string | yes |  |
| `payload` | object | no | The exact JSON body sent. Only present with `include=payload`. |

Example:

```
{
  "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "event_type": "contact.created",
  "status": "pending",
  "attempts": 1,
  "last_status_code": 200,
  "last_error": null,
  "delivered_at": null,
  "created_at": "2026-09-02T12:00:00Z",
  "payload": {}
}
```

WebhookTestRequest

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `event` | any | no | Which event name to put in the test payload. Defaults to `contact.created`. |

Example:

```
{
  "event": null
}
```

WebhookTestResult

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `ok` | boolean | yes | True when the endpoint answered with a 2xx. |
| `status` | integer | no | The HTTP status the endpoint returned, when it returned one. |
| `error` | string | no | Why the attempt failed: a timeout, a connection problem, a redirect (which is never followed), or the URL no longer passing validation. |

Example:

```
{
  "ok": true,
  "status": 200,
  "error": "timed out"
}
```

ImportSourceCredentials

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `source` | "mailchimp" \| "mailerlite" \| "kit" \| "brevo" \| "emailoctopus" | yes |  |
| `credentials` | object | yes |  |

Example:

```
{
  "source": "mailchimp",
  "credentials": {
    "api_key": "string",
    "dc": "string"
  }
}
```

ImportSourceList

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Audience / group / tag / list id; `""` is the "All subscribers" entry (MailerLite, Kit). |
| `name` | string | yes |  |
| `member_count` | integer | yes | Subscribed members as reported by the platform; null when it reports none (Kit tags). |
| `suppressed_count` | integer | no | Unsubscribed + bounced (+ complained) as reported by the platform. |
| `kind` | "audience" \| "group" \| "tag" \| "all" | no |  |

Example:

```
{
  "id": "string",
  "name": "string",
  "member_count": 1,
  "suppressed_count": 1,
  "kind": "audience"
}
```

ImportCapabilities

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `platform` | string | yes |  |
| `label` | string | yes |  |
| `provides` | object | yes | Keys: subscribed_at, consent_ip, consent_timestamp, consent_source, bounces, complaints, unsubscribes, tags, custom_fields. |
| `notes` | array of string | yes |  |
| `limitations` | array of string | yes |  |

Example:

```
{
  "platform": "string",
  "label": "string",
  "provides": {},
  "notes": [
    "string"
  ],
  "limitations": [
    "string"
  ]
}
```

ImportConnectResult

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `ok` | boolean | yes |  |
| `source` | string | yes |  |
| `label` | string | yes |  |
| `account` | object | yes |  |
| `lists` | array of ImportSourceList | yes |  |
| `requires_list` | boolean | yes | True when `list_ids` must be given to `/run` (Mailchimp, EmailOctopus). |
| `capabilities` | ImportCapabilities | yes |  |
| `subrequests` | integer | no | Calls made to the platform for this check. |

Example:

```
{
  "ok": true,
  "source": "string",
  "label": "string",
  "account": {
    "name": "string",
    "email": "string"
  },
  "lists": [
    {
      "id": "string",
      "name": "string",
      "member_count": 1,
      "suppressed_count": 1,
      "kind": "audience"
    }
  ],
  "requires_list": true,
  "capabilities": {
    "platform": "string",
    "label": "string",
    "provides": {},
    "notes": [
      "string"
    ],
    "limitations": [
      "string"
    ]
  },
  "subrequests": 1
}
```

ImportRunRequest

```
{
  "allOf": [
    {
      "$ref": "#/components/schemas/ImportSourceCredentials"
    },
    {
      "type": "object",
      "properties": {
        "list_ids": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "maxItems": 20,
          "description": "Audience ids (Mailchimp, required), group ids (MailerLite), tag ids (Kit), list ids (Brevo), or list ids (EmailOctopus, required). Empty → all subscribers (MailerLite, Kit, Brevo)."
        },
        "options": {
          "type": "object",
          "properties": {
            "tags": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "description": "Tags attached to every imported contact (created when missing)."
            },
            "list_id": {
              "type": "string",
              "format": "uuid",
              "description": "Add every subscribed contact to this list (unconfirmed on a double opt-in list; no email is sent)."
            },
            "include_unsubscribed": {
              "type": "boolean",
              "default": true,
              "description": "`false` skips unsubscribed / bounced / complained records instead of suppressing them (`skipped_suppressed`). Keep `true` to carry your opt-outs across."
            },
            "max_contacts": {
              "type": "integer",
              "minimum": 1,
              "maximum": 25000,
              "description": "Stop after this many contacts have been written (rounded up to the end of the source page)."
            },
            "source_label": {
              "type": "string",
              "maxLength": 40,
              "default": "<source>-import",
              "description": "Source label on new contacts."
            },
            "cursor": {
              "type": "object",
              "description": "The `next_hint.cursor` of a previous truncated run of the same source and `list_ids`."
            }
          }
        }
      }
    }
  ]
}
```

Example:

```
null
```

ImportRunResult

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `ok` | boolean | yes |  |
| `source` | string | yes |  |
| `imported` | integer | yes | New contacts created. |
| `updated` | integer | yes | Existing contacts changed (fields merged, status escalated). |
| `skipped_duplicates` | integer | yes | Records whose address already existed. |
| `suppressed` | integer | yes | Opted-out addresses recorded on the suppression list this run (new, upgraded or already there). |
| `skipped_unconfirmed` | integer | yes | Pending / unconfirmed / inactive people — never imported. |
| `skipped_undeliverable` | integer | no | Records dropped because they can never be delivered: placeholder addresses, plus records whose domain cannot receive mail (no such domain, null MX, or neither MX nor A records). |
| `undeliverable_domains` | array of object | no | Those domains, most records first, at most ten. |
| `skipped_placeholder` | integer | no | Of `skipped_undeliverable`, records that were placeholder addresses (example domains, reserved test domains, `abuse@` / `postmaster@`). |
| `placeholder_addresses` | PlaceholderAddresses | no | Placeholder addresses, named, at most twenty: `example.com` / `.net` / `.org` and their subdomains, the reserved `.test`, `.invalid`, `.localhost` and `.example` domains, and the `abuse@` / `postmaster@` role mailboxes. None can ever be a real subscriber. |
| `skipped_other` | integer | yes | Records the platform marks as archived or transactional. |
| `skipped_suppressed` | integer | yes | Opted-out records skipped because `include_unsubscribed` was `false`. |
| `invalid` | integer | yes | Records without a usable email address. |
| `tags_created` | integer | yes |  |
| `tags_attached` | integer | yes |  |
| `tags_skipped` | boolean | yes | Always `false` (tags now arrive inline from every platform); kept for compatibility. |
| `tags_truncated` | integer | no | Mailchimp only: members with more than 50 tags whose full tag set could not be fetched within this run's lookup budget; they carry the custom field `mailchimp_tags_truncated`. |
| `custom_field_keys` | array of string | yes |  |
| `list_added` | integer | yes |  |
| `list_pending_confirmation` | integer | yes |  |
| `contacts_read` | integer | yes | Records read from the platform this run. |
| `contacts_written` | integer | yes | Records handed to the importer this run (imported + updated + duplicates). |
| `pages` | integer | yes | Source pages written. |
| `subrequests` | integer | yes | Requests made to the platform. |
| `db_requests` | integer | yes | Estimated database round-trips. |
| `duration_ms` | integer | yes |  |
| `truncated` | boolean | yes | True when the run stopped before the source was exhausted; continue with `next_hint.cursor`. |
| `stop_reason` | "max_contacts" \| "subrequests" \| "time" \| "contact_limit" \| null | yes |  |
| `next_hint` | object | yes |  |
| `capabilities` | ImportCapabilities | yes |  |

Example:

```
{
  "ok": true,
  "source": "string",
  "imported": 1,
  "updated": 1,
  "skipped_duplicates": 1,
  "suppressed": 1,
  "skipped_unconfirmed": 1,
  "skipped_undeliverable": 1,
  "undeliverable_domains": [
    {
      "domain": "string",
      "rows": 1
    }
  ],
  "skipped_placeholder": 1,
  "placeholder_addresses": [
    {
      "email": "string",
      "reason": "string"
    }
  ],
  "skipped_other": 1,
  "skipped_suppressed": 1,
  "invalid": 1,
  "tags_created": 1,
  "tags_attached": 1,
  "tags_skipped": true,
  "tags_truncated": 1,
  "custom_field_keys": [
    "string"
  ],
  "list_added": 1,
  "list_pending_confirmation": 1,
  "contacts_read": 1,
  "contacts_written": 1,
  "pages": 1,
  "subrequests": 1,
  "db_requests": 1,
  "duration_ms": 1,
  "truncated": true,
  "stop_reason": "max_contacts",
  "next_hint": {
    "message": "string",
    "cursor": {},
    "list_ids": [
      "string"
    ]
  },
  "capabilities": {
    "platform": "string",
    "label": "string",
    "provides": {},
    "notes": [
      "string"
    ],
    "limitations": [
      "string"
    ]
  }
}
```

Pagination

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `page` | integer | yes |  |
| `limit` | integer | yes |  |
| `total` | integer | yes | Total items across all pages. |
| `total_pages` | integer | yes |  |

Example:

```
{
  "page": 1,
  "limit": 50,
  "total": 1234,
  "total_pages": 25
}
```

ContactStatus — `archived` is out of the working list (hidden from listings unless asked for, never mailed, not counted) but not blocked.

```
{
  "type": "string",
  "enum": [
    "subscribed",
    "unsubscribed",
    "bounced",
    "complained",
    "archived"
  ],
  "description": "`archived` is out of the working list (hidden from listings unless asked for, never mailed, not counted) but not blocked."
}
```

Example:

```
"subscribed"
```

SuppressionSource — Where a suppression came from: `link` (unsubscribe link or preference page), `provider` (bounce or complaint report), `import`, `owner` (blocked in the app or with `suppress: true`), `delete` (a deleted contact; an admin may lift it), `erasure` (a request to be forgotten; hash only, never lifted). Null on rows written before 2026-09-14.

```
{
  "type": "string",
  "enum": [
    "link",
    "provider",
    "import",
    "owner",
    "delete",
    "erasure"
  ],
  "description": "Where a suppression came from: `link` (unsubscribe link or preference page), `provider` (bounce or complaint report), `import`, `owner` (blocked in the app or with `suppress: true`), `delete` (a deleted contact; an admin may lift it), `erasure` (a request to be forgotten; hash only, never lifted). Null on rows written before 2026-09-14."
}
```

Example:

```
"link"
```

AuditEvent

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `created_at` | string | yes |  |
| `action` | string | yes | One of the actions listed on the `action` parameter of `GET /api/v1/account/audit-log`. New actions may be added; treat unknown ones as informational. |
| `actor_type` | "user" \| "api_key" \| "scim" \| "system" | yes |  |
| `actor_id` | string \| null | no | The user id or API key id, null for SCIM and the system. |
| `actor_label` | string \| null | no | The actor's email address, the API key's name, `SCIM` or `System`. |
| `target_type` | string \| null | no | What was acted on: `api_key`, `user`, `invitation`, `webhook`, `domain`, `connection`, `tenant`, `account`… |
| `target_id` | string \| null | no |  |
| `target_label` | string \| null | no | The target as a person would name it: a key name, an email address, a URL, a domain. |
| `workspace_id` | string \| null | no | Null for an account-level event (policy, SCIM, erasure). |
| `workspace_name` | string \| null | no |  |
| `metadata` | object | yes | Details specific to the action, credentials never included: a role, a permission list, a before/after pair, a sign-in method. |
| `ip` | string \| null | no | The network address the request came from. |

Example:

```
{
  "id": "0192b6a4-6d3e-7c1a-9f2e-3b4c5d6e7f80",
  "created_at": "2026-09-25T09:12:41.000Z",
  "action": "team.role_changed",
  "actor_type": "user",
  "actor_id": "4f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
  "actor_label": "jane@example.com",
  "target_type": "user",
  "target_id": "7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a",
  "target_label": "sam@example.com",
  "workspace_id": "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
  "workspace_name": "Harbour Lane",
  "metadata": {
    "role": "admin"
  },
  "ip": "203.0.113.7"
}
```

SuppressionRow

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email_hash` | string | yes | SHA-256 hex of the normalised address — the key for `POST /api/v1/suppressions/lift`. |
| `reason` | SuppressionReason | yes | Why an address is suppressed. Strength order: complained > bounced > unsubscribed > deleted. |
| `source` | SuppressionSource \| null | yes |  |
| `created_at` | string | yes |  |
| `email_masked` | string \| null | yes | `j***@example.com`: stored on rows written since 2026-09-14, derived from a still-existing contact row otherwise, null when neither (older rows, erasures). |
| `email_domain` | string \| null | yes |  |
| `contact` | object \| null | yes | The contact row that still carries this address, when there is one — the only place the full address appears. |
| `liftable` | boolean | yes | True for a `deleted` row that is not an erasure. |

Example:

```
{
  "email_hash": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8",
  "reason": "deleted",
  "source": "delete",
  "created_at": "2026-09-14T09:12:41.000Z",
  "email_masked": "j***@example.com",
  "email_domain": "example.com",
  "contact": null,
  "liftable": true
}
```

SuppressionReason — Why an address is suppressed. Strength order: complained > bounced > unsubscribed > deleted.

```
{
  "type": "string",
  "enum": [
    "unsubscribed",
    "bounced",
    "complained",
    "deleted"
  ],
  "description": "Why an address is suppressed. Strength order: complained > bounced > unsubscribed > deleted."
}
```

Example:

```
"unsubscribed"
```

SuppressionImportReason — Reasons an import may set (`deleted` is reserved for contact deletion). Aliases such as `cleaned`, `cancelled`, `junk` and `spam` are accepted and mapped.

```
{
  "type": "string",
  "enum": [
    "unsubscribed",
    "bounced",
    "complained"
  ],
  "description": "Reasons an import may set (`deleted` is reserved for contact deletion). Aliases such as `cleaned`, `cancelled`, `junk` and `spam` are accepted and mapped."
}
```

Example:

```
"unsubscribed"
```

SuppressionImport

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `reason` | SuppressionImportReason | no | Reasons an import may set (`deleted` is reserved for contact deletion). Aliases such as `cleaned`, `cancelled`, `junk` and `spam` are accepted and mapped. |
| `entries` | array of string \| object | yes |  |

Example:

```
{
  "reason": "unsubscribed",
  "entries": [
    "jane@example.com"
  ]
}
```

SuppressionImportResult

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `received` | integer | yes | Rows or entries in the request. Equals added + upgraded + unchanged + invalid. |
| `added` | integer | yes | Addresses that were not on the list before. |
| `upgraded` | integer | yes | Addresses already on the list whose reason became stronger. |
| `unchanged` | integer | yes | Already on the list with the same or a stronger reason, plus repeats within the request. |
| `invalid` | integer | yes | Rows skipped: malformed address, over 254 characters, or an unknown reason. |
| `contacts_updated` | integer | yes | Existing contacts switched from `subscribed` to the imported status. |
| `by_reason` | object | yes | Valid, de-duplicated addresses split by the reason applied. |

Example:

```
{
  "received": 3,
  "added": 2,
  "upgraded": 1,
  "unchanged": 0,
  "invalid": 0,
  "contacts_updated": 1,
  "by_reason": {
    "unsubscribed": 1,
    "bounced": 1,
    "complained": 1
  }
}
```

SuppressionSummary

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `count` | integer | yes | Addresses on the list, all reasons. |
| `by_reason` | object | yes |  |

Example:

```
{
  "count": 1204,
  "by_reason": {
    "unsubscribed": 1130,
    "bounced": 61,
    "complained": 9,
    "deleted": 4
  }
}
```

SuppressionCheck

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | yes | The address as normalised (trimmed, lower-cased). |
| `suppressed` | boolean | yes |  |
| `reason` | SuppressionReason | no | Why an address is suppressed. Strength order: complained > bounced > unsubscribed > deleted. |
| `created_at` | string | no | When the address was added to the list. Present when suppressed. |

Example:

```
{
  "email": "bob@example.com",
  "suppressed": true,
  "reason": "bounced",
  "created_at": "2026-09-03T09:12:41.000Z"
}
```

CustomFieldDefinition

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | no |  |
| `key` | string | yes | Immutable. What `{{custom_fields.<key>}}`, `custom_fields.<key>` in rules, CSV headers and API payloads use. |
| `label` | string | yes | What the app shows; freely editable. |
| `type` | "text" \| "number" \| "boolean" \| "date" \| "dropdown" | yes |  |
| `options` | array of string | yes | Dropdown choices, in order; empty for other types. |
| `position` | integer | no |  |
| `uses` | integer \| null | no | Contacts carrying the key (list endpoint only; null when usage counts are unavailable). |
| `created_at` | string | no |  |
| `updated_at` | string | no |  |

Example:

```
{
  "id": "5e2b8c1a-4d3f-4a6b-9c8d-1e2f3a4b5c6d",
  "key": "plan",
  "label": "Plan",
  "type": "dropdown",
  "options": [
    "free",
    "pro",
    "business"
  ],
  "position": 0,
  "created_at": "2026-09-14T09:00:00.000Z",
  "updated_at": "2026-09-14T09:00:00.000Z"
}
```

CustomFieldInput

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | string | yes |  |
| `label` | string | no | Defaults to the key with underscores as spaces, capitalised. |
| `type` | "text" \| "number" \| "boolean" \| "date" \| "dropdown" | no |  |
| `options` | array of string | no | Required for `dropdown`; ignored otherwise. |
| `confirm_violations` | boolean | no | Declare the field even though some stored values do not fit the type (they are kept as they are). |

Example:

```
{
  "key": "string",
  "label": "string",
  "type": "text",
  "options": [
    "string"
  ],
  "confirm_violations": false
}
```

CustomFieldViolations

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `count` | integer | no | Contacts whose stored value does not fit. |
| `examples` | array of string | no | Up to five offending values. |
| `partial` | boolean | no | True when only the most common values were checked, so `count` is a floor. |

Example:

```
{
  "count": 1,
  "examples": [
    "string"
  ],
  "partial": true
}
```

CustomFields — A flat object of at most 50 keys. Keys are 1–64 characters (`__proto__`, `constructor` and `prototype` are refused); values are strings of at most 200 characters, finite numbers or booleans. `null` values are dropped; arrays and nested objects are rejected with `400`. **Typed fields.** A key declared under Custom fields (`GET /custom-fields`) takes only a value of its type — a `number` field a number or a numeric string, a `boolean` field a boolean or `yes`/`no`/`true`/`false`, a `date` field a real calendar date (`YYYY-MM-DD`, an ISO date-time, or `DD/MM/YYYY`; stored as `YYYY-MM-DD`), a `dropdown` field one of its options — otherwise `400` `custom_fields.<key> must be …`; an empty string clears a typed field. A key no field names is registered as a `text` field when it is first written. Available in emails as `{{custom_fields.<key>}}` and in segment and automation rules as `custom_fields.<key>`. Signup forms cap each value at 200 characters.

```
{
  "type": "object",
  "maxProperties": 50,
  "propertyNames": {
    "minLength": 1,
    "maxLength": 64
  },
  "additionalProperties": {
    "oneOf": [
      {
        "type": "string",
        "maxLength": 200
      },
      {
        "type": "number"
      },
      {
        "type": "boolean"
      }
    ]
  },
  "description": "A flat object of at most 50 keys. Keys are 1–64 characters (`__proto__`, `constructor` and `prototype` are refused); values are strings of at most 200 characters, finite numbers or booleans. `null` values are dropped; arrays and nested objects are rejected with `400`. **Typed fields.** A key declared under Custom fields (`GET /custom-fields`) takes only a value of its type — a `number` field a number or a numeric string, a `boolean` field a boolean or `yes`/`no`/`true`/`false`, a `date` field a real calendar date (`YYYY-MM-DD`, an ISO date-time, or `DD/MM/YYYY`; stored as `YYYY-MM-DD`), a `dropdown` field one of its options — otherwise `400` `custom_fields.<key> must be …`; an empty string clears a typed field. A key no field names is registered as a `text` field when it is first written. Available in emails as `{{custom_fields.<key>}}` and in segment and automation rules as `custom_fields.<key>`. Signup forms cap each value at 200 characters.",
  "example": {
    "plan": "pro",
    "region": "London",
    "seats": 3,
    "trial": false
  }
}
```

Example:

```
{
  "plan": "pro",
  "region": "London",
  "seats": 3,
  "trial": false
}
```

Contact

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `tenant_id` | string | yes | Workspace ID. |
| `email` | string | yes |  |
| `first_name` | string | yes |  |
| `last_name` | string | yes |  |
| `status` | ContactStatus | yes | `archived` is out of the working list (hidden from listings unless asked for, never mailed, not counted) but not blocked. |
| `custom_fields` | CustomFields | yes | A flat object of at most 50 keys. Keys are 1–64 characters (`__proto__`, `constructor` and `prototype` are refused); values are strings of at most 200 characters, finite numbers or booleans. `null` values are dropped; arrays and nested objects are rejected with `400`. **Typed fields.** A key declared under Custom fields (`GET /custom-fields`) takes only a value of its type — a `number` field a number or a numeric string, a `boolean` field a boolean or `yes`/`no`/`true`/`false`, a `date` field a real calendar date (`YYYY-MM-DD`, an ISO date-time, or `DD/MM/YYYY`; stored as `YYYY-MM-DD`), a `dropdown` field one of its options — otherwise `400` `custom_fields.<key> must be …`; an empty string clears a typed field. A key no field names is registered as a `text` field when it is first written. Available in emails as `{{custom_fields.<key>}}` and in segment and automation rules as `custom_fields.<key>`. Signup forms cap each value at 200 characters. |
| `source` | string | yes | e.g. `api`, `import`, `form`, or whatever was supplied at creation. |
| `language` | string \| null | no | ISO 639-1 code of the language the person reads in, or null when not known. Set through the API, an import column, a form field named `language`, or the contact page; never inferred. |
| `subscribed_at` | string \| null | yes |  |
| `unsubscribed_at` | string \| null | yes |  |
| `archived_at` | string \| null | no | Set while `status` is `archived`. Archiving never writes to the suppression list, so the address stays re-addable. |
| `archived_from` | string \| null | no | The status the contact held before it was archived, restored on un-archive. A subscribed contact whose address was blocked meanwhile comes back unsubscribed. |
| `created_at` | string | yes |  |

Example:

```
{
  "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
  "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
  "email": "jane@example.com",
  "first_name": "Jane",
  "last_name": "Doe",
  "status": "subscribed",
  "custom_fields": {
    "plan": "pro"
  },
  "source": "api",
  "language": "en",
  "subscribed_at": "2026-09-01T10:00:00.000Z",
  "unsubscribed_at": null,
  "created_at": "2026-09-01T10:00:00.000Z"
}
```

ContactSummary

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `email` | string | yes |  |
| `first_name` | string | yes |  |
| `last_name` | string | yes |  |

Example:

```
{
  "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "email": "jane@example.com",
  "first_name": "string",
  "last_name": "string"
}
```

PlaceholderAddresses — Placeholder addresses, named, at most twenty: `example.com` / `.net` / `.org` and their subdomains, the reserved `.test`, `.invalid`, `.localhost` and `.example` domains, and the `abuse@` / `postmaster@` role mailboxes. None can ever be a real subscriber.

```
{
  "type": "array",
  "maxItems": 20,
  "description": "Placeholder addresses, named, at most twenty: `example.com` / `.net` / `.org` and their subdomains, the reserved `.test`, `.invalid`, `.localhost` and `.example` domains, and the `abuse@` / `postmaster@` role mailboxes. None can ever be a real subscriber.",
  "items": {
    "type": "object",
    "required": [
      "email",
      "reason"
    ],
    "properties": {
      "email": {
        "type": "string"
      },
      "reason": {
        "type": "string"
      }
    }
  }
}
```

Example:

```
[
  {
    "email": "string",
    "reason": "string"
  }
]
```

AudiencePreview

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `audience` | object | yes |  |
| `can_send` | boolean | yes |  |
| `reason` | string | no | Present when `can_send` is false: the error a send would return. |
| `placeholders` | array of object | yes | Recipients that can never receive mail, sorted by address, at most twenty named. |
| `placeholder_count` | integer | yes | Every placeholder recipient, including any beyond the twenty named. |

Example:

```
{
  "audience": {
    "send_to_type": "all",
    "send_to_id": null,
    "recipients": 1
  },
  "can_send": true,
  "reason": "string",
  "placeholders": [
    {
      "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
      "email": "string",
      "kind": "example_domain",
      "reason": "string"
    }
  ],
  "placeholder_count": 1
}
```

ContactUpdate — Only these keys are accepted; anything else is a 400.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | no |  |
| `first_name` | string | no |  |
| `last_name` | string | no |  |
| `status` | ContactStatus | no | `archived` is out of the working list (hidden from listings unless asked for, never mailed, not counted) but not blocked. |
| `custom_fields` | object | no | Merged into the contact's custom fields: keys you send are set, a key sent as `null` is removed, every other key is kept. Values follow the CustomFields limits. Send `replace_custom_fields: true` to replace the whole object instead. |
| `source` | string | no |  |
| `language` | string \| null | no | ISO 639-1 code; `null` or an empty string clears it. An unknown code is a 400. |
| `replace_custom_fields` | boolean | no | With `true`, `custom_fields` replaces the contact's whole custom-field object (keys not sent are dropped) instead of merging. Requires `custom_fields` in the same request. |
| `resubscribe` | boolean | no | Set `true` when the person has given you new consent after unsubscribing. Sets `status` to `subscribed` (cannot be combined with another `status`), `subscribed_at` to now, clears `unsubscribed_at`, leaves `source` as it is, and removes the address's `unsubscribed` suppression entry. A bounced, complained or deleted address is still refused with `409`. |
| `suppress` | boolean | no | With `status: "unsubscribed"`, also block the address: it goes on the workspace's suppression list as `unsubscribed`, so no later import, API call or signup form can re-add it as a subscriber until the person opts in again. Without it an unsubscribe is a status only. Cannot be combined with `resubscribe`. |

Example:

```
{
  "first_name": "Janet",
  "custom_fields": {
    "plan": "business",
    "trial_ends": null
  }
}
```

AssignedTag

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `name` | string | yes |  |
| `color` | string | yes |  |
| `assigned_at` | string | yes |  |

Example:

```
{
  "id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33",
  "name": "Customer",
  "color": "#2563EB",
  "assigned_at": "2026-09-01T10:05:00.000Z"
}
```

ContactWithTags

```
{
  "allOf": [
    {
      "$ref": "#/components/schemas/Contact"
    },
    {
      "type": "object",
      "required": [
        "tags"
      ],
      "properties": {
        "tags": {
          "type": "array",
          "items": {
            "$ref": "#/components/schemas/AssignedTag"
          }
        }
      }
    }
  ]
}
```

Example:

```
null
```

Tag

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `tenant_id` | string | yes |  |
| `name` | string | yes |  |
| `color` | string | yes |  |
| `created_at` | string | yes |  |

Example:

```
{
  "id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33",
  "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
  "name": "Customer",
  "color": "#2563EB",
  "created_at": "2026-08-20T09:00:00.000Z"
}
```

ContactTag

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `contact_id` | string | yes |  |
| `tag_id` | string | yes |  |
| `created_at` | string | yes |  |

Example:

```
{
  "contact_id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
  "tag_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33",
  "created_at": "2026-09-01T10:05:00.000Z"
}
```

List

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `tenant_id` | string | yes |  |
| `name` | string | yes |  |
| `description` | string | yes |  |
| `double_optin` | boolean | yes | When true, new members must confirm by email before campaigns reach them. |
| `offerable_across_account` | boolean | no | When true, the list is offered — unticked, under "More from us" on the preference page — to people who subscribed on another workspace of the same account. Nobody joins unless they tick it themselves, and they join through this workspace's ordinary signup path: its suppression list, its plan's contact cap and its double opt-in all apply. Never appears on a form or in a campaign link, and never shows anyone this list's contacts. False by default; only a signed-in workspace admin of a workspace that belongs to an account can turn it on. |
| `created_at` | string | yes |  |

Example:

```
{
  "id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
  "name": "Newsletter",
  "description": "Weekly product updates",
  "double_optin": false,
  "offerable_across_account": false,
  "created_at": "2026-08-20T09:00:00.000Z"
}
```

ListWithCount

```
{
  "allOf": [
    {
      "$ref": "#/components/schemas/List"
    },
    {
      "type": "object",
      "required": [
        "member_count"
      ],
      "properties": {
        "member_count": {
          "type": "integer"
        }
      }
    }
  ]
}
```

Example:

```
null
```

ListContact — A list membership. The confirmation token behind a pending double opt-in only ever travels in the confirmation email and is never returned by the API.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `list_id` | string | yes |  |
| `contact_id` | string | yes |  |
| `created_at` | string | yes |  |
| `confirmed` | boolean | no | Double opt-in state. `false` until the contact clicks the confirmation link on a double opt-in list. |

Example:

```
{
  "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "contact_id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
  "created_at": "2026-09-01T10:10:00.000Z",
  "confirmed": true
}
```

ListContactResult

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `list_contact` | ListContact | yes | A list membership. The confirmation token behind a pending double opt-in only ever travels in the confirmation email and is never returned by the API. |
| `double_optin_sent` | boolean | yes | True when a confirmation email was sent on this request. False on a single opt-in list, and on a double opt-in list when one already went to this address in the last 10 minutes — the membership still awaits confirmation (see `membership`). |
| `membership` | "confirmed" \| "pending_confirmation" | yes | `confirmed`: the contact is on the list and campaigns to it reach them. `pending_confirmation`: they are on the list but skipped by campaigns until they click the confirmation link. |
| `already_member` | boolean | yes | True when the contact was already on the list before this request (only returned for an unconfirmed member of a double opt-in list, with status 200). |

Example:

```
{
  "list_contact": {
    "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "contact_id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
    "created_at": "2026-09-01T10:10:00.000Z",
    "confirmed": true
  },
  "double_optin_sent": true,
  "membership": "confirmed",
  "already_member": true
}
```

ListMember

```
{
  "allOf": [
    {
      "$ref": "#/components/schemas/Contact"
    },
    {
      "type": "object",
      "required": [
        "added_at"
      ],
      "properties": {
        "added_at": {
          "type": "string",
          "format": "date-time",
          "description": "When the contact joined the list."
        }
      }
    }
  ]
}
```

Example:

```
null
```

SegmentRule — One condition. Rules in a segment are ANDed and evaluated identically for previews, counts and campaign sends. `field` is one of `email`, `first_name`, `last_name`, `status`, `source`, `language` (an ISO 639-1 code), `created_at`, or `custom_fields.<key>`. Text operators are case-insensitive; `greater_than` / `less_than` compare numerically or chronologically when both sides parse as numbers or dates. `is` / `is_not` are aliases of `equals` / `not_equals`. `is_empty` and `is_not_empty` ignore `value`, but the key must still be present — send an empty string. The `tag` field takes `has_tag` / `not_has_tag` with a tag id as the value. The `campaign` field takes `opened`, `not_opened`, `clicked` or `not_clicked` with a sent campaign id as the value; `not_opened` / `not_clicked` mean the contact RECEIVED that campaign and did not open / click it, so they never match people who were not sent it. The `activity` field takes `opened_within`, `not_opened_within`, `clicked_within` or `not_clicked_within` with a number of days (1–365) as the value, counting any open or click on any email from the workspace in that window; the `not_*_within` forms match every other contact.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `field` | string | yes |  |
| `operator` | "equals" \| "is" \| "not_equals" \| "is_not" \| "contains" \| "not_contains" \| "starts_with" \| "ends_with" \| "is_empty" \| "is_not_empty" \| "greater_than" \| "less_than" \| "has_tag" \| "not_has_tag" \| "opened" \| "not_opened" \| "clicked" \| "not_clicked" \| "opened_within" \| "not_opened_within" \| "clicked_within" \| "not_clicked_within" | yes |  |
| `value` | string | yes | Required (non-empty) for every operator except `is_empty` / `is_not_empty`, where it may be omitted. A tag id for `tag`, a campaign id for `campaign`, a number of days (1–365) for `activity`. |

Example:

```
{
  "field": "custom_fields.region",
  "operator": "equals",
  "value": "London"
}
```

Segment

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `tenant_id` | string | yes |  |
| `name` | string | yes |  |
| `rules` | array of SegmentRule | yes |  |
| `created_at` | string | yes |  |

Example:

```
{
  "id": "2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a",
  "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
  "name": "London customers",
  "rules": [
    {
      "field": "custom_fields.region",
      "operator": "equals",
      "value": "London"
    }
  ],
  "created_at": "2026-08-25T12:00:00.000Z"
}
```

SegmentWithCount

```
{
  "allOf": [
    {
      "$ref": "#/components/schemas/Segment"
    },
    {
      "type": "object",
      "required": [
        "contact_count",
        "reachable_count"
      ],
      "properties": {
        "contact_count": {
          "type": "integer",
          "description": "Contacts matching the rules, whatever their status."
        },
        "reachable_count": {
          "type": "integer",
          "description": "Matching contacts whose status is subscribed."
        }
      }
    }
  ]
}
```

Example:

```
null
```

CampaignStatus

```
{
  "type": "string",
  "enum": [
    "draft",
    "scheduled",
    "sending",
    "sent",
    "cancelled"
  ]
}
```

Example:

```
"draft"
```

SendToType

```
{
  "type": "string",
  "enum": [
    "all",
    "list",
    "segment"
  ]
}
```

Example:

```
"all"
```

FeedItem

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | string | yes | The item's guid/id, else its link. |
| `title` | string | yes |  |
| `link` | string | yes | http(s) only; empty otherwise. |
| `summary` | string | yes | Plain text, at most 320 characters. |
| `date` | string | yes |  |

Example:

```
{
  "key": "string",
  "title": "string",
  "link": "string",
  "summary": "string",
  "date": "2026-09-02T12:00:00Z"
}
```

RssFeedInput

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no |  |
| `feed_url` | string | no | Public http(s) URL of an RSS 2.0 or Atom feed. |
| `send_to_type` | "all" \| "list" \| "segment" | no |  |
| `send_to_id` | string | no |  |
| `template_id` | string | no | A template with the `{{rss_items}}` tag (the "Latest posts" block); null = built-in digest layout. |
| `subject_template` | string | no | Placeholders: `{{item_title}}`, `{{feed_title}}`, `{{item_count}}`. |
| `intro` | string | no |  |
| `frequency` | "immediate" \| "daily" \| "weekly" | no |  |
| `send_hour` | integer | no | UTC. |
| `send_weekday` | integer | no | 0 = Sunday. |
| `max_items` | integer | no |  |
| `status` | "active" \| "paused" | no |  |

Example:

```
{
  "name": "string",
  "feed_url": "string",
  "send_to_type": "all",
  "send_to_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "template_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "subject_template": "{{item_title}}",
  "intro": "string",
  "frequency": "daily",
  "send_hour": 9,
  "send_weekday": 1,
  "max_items": 5,
  "status": "active"
}
```

RssFeed

```
{
  "allOf": [
    {
      "$ref": "#/components/schemas/RssFeedInput"
    },
    {
      "type": "object",
      "required": [
        "id",
        "tenant_id",
        "name",
        "feed_url",
        "status",
        "created_at"
      ],
      "properties": {
        "id": {
          "type": "string",
          "format": "uuid"
        },
        "tenant_id": {
          "type": "string",
          "format": "uuid"
        },
        "last_checked_at": {
          "type": "string",
          "format": "date-time",
          "nullable": true
        },
        "last_sent_at": {
          "type": "string",
          "format": "date-time",
          "nullable": true
        },
        "last_item_key": {
          "type": "string",
          "nullable": true
        },
        "last_error": {
          "type": "string",
          "nullable": true
        },
        "feed_title": {
          "type": "string",
          "nullable": true
        },
        "created_at": {
          "type": "string",
          "format": "date-time"
        },
        "updated_at": {
          "type": "string",
          "format": "date-time"
        }
      }
    }
  ]
}
```

Example:

```
null
```

FeedCheck — What fetching the feed found when it was saved.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `total_items` | integer | yes | Posts in the feed right now. |
| `newest_title` | string | yes | Title of the newest post, or null for an empty feed. |

Example:

```
{
  "total_items": 12,
  "newest_title": "What shipped in September"
}
```

Share

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes |  |
| `count` | integer | yes |  |

Example:

```
{
  "name": "string",
  "count": 1
}
```

PollResult — How a campaign's recipients answered one poll block. An answer is the recipient's click on one of the block's buttons; one per recipient, the latest.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | The poll block's id, as it appears in the answer links (`/p/{id}/{option}`). |
| `question` | string | yes | The block's question; empty when the campaign has no block JSON for it. |
| `answers` | integer | yes | Recipients who answered, each counted once. |
| `options` | array of object | yes |  |

Example:

```
{
  "id": "string",
  "question": "string",
  "answers": 1,
  "options": [
    {
      "index": 1,
      "label": "string",
      "count": 1
    }
  ]
}
```

LanguageVersion — One translation of a campaign: the whole email in that language.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subject` | string | yes |  |
| `html_content` | string | yes |  |
| `text_content` | string | no |  |
| `blocks` | array of BuilderBlock | no |  |

Example:

```
{
  "subject": "string",
  "html_content": "string",
  "text_content": "string",
  "blocks": [
    {
      "id": "block_1",
      "type": "text",
      "props": {
        "heading": "This month",
        "text": "Hi {{first_name|there}},",
        "padding": 24
      }
    }
  ]
}
```

AbVariantStats — Counts for one version. `revenue`, `orders` and `currency` appear once an order your store sent in has been attributed to a send of this version: `revenue` is the total of those orders in `currency` — the largest currency when they were placed in more than one — and is never converted or added across currencies.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `sent` | integer | yes |  |
| `opened` | integer | yes |  |
| `clicked` | integer | yes |  |
| `revenue` | number | no | In `currency`; never converted. |
| `orders` | integer | no |  |
| `currency` | string | no |  |

Example:

```
{
  "sent": 1,
  "opened": 1,
  "clicked": 1,
  "revenue": 1,
  "orders": 1,
  "currency": "GBP"
}
```

Campaign

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subject_b` | string | no | Version B's subject line when a subject-line test is set (a mirror of `ab_test.variants[0].subject`); null for from-name and content tests. |
| `ab_test` | object | no | A/B test settings and state: `sample_pct`, `wait_minutes`, `metric` (`opens`, `clicks` or `revenue`); `test_on` (`subject` — also when absent — , `from_name`, `content` or `send_time`); `variants` (versions B…E, each carrying only the field that differs: `subject`, `from_name`, or `html_content`/`text_content`/`blocks`); `status` (`pending` before send, `testing`, `decided`, `skipped`); `decide_at`; `winner` (a version letter); `decided_at`; `decided_by` (`auto`/`owner`); `samples` (per-letter sample sizes; `sample_a`/`sample_b` are kept for older readers), `held`; `reason` when the test was cut down or skipped; and once decided the per-version counts under `stats` (`a`/`b` also at the top level), each an `AbVariantStats` — with `revenue`, `orders` and `currency` where a store's orders were attributed to that version. A send-time test also carries `release_at` (per letter, when each version goes out) and, once decided, `remainder_at`. |
| `id` | string | yes |  |
| `tenant_id` | string | yes |  |
| `name` | string | yes |  |
| `subject` | string | yes |  |
| `from_name` | string | yes |  |
| `from_email` | string | yes |  |
| `status_reason` | string \| null | no | Why the campaign is in its current status when that is not self-evident — why a send stopped, or why a schedule did not fire. Free text — read it, do not match on it. |
| `send_pace_hours` | integer \| null | no | Spread the send over this many hours: the queue paces it at audience ÷ (hours × 60) a minute, on top of the plan's ceilings. Null = as fast as the plan allows, or the workspace's default pace. |
| `send_at_best_time` | boolean | no | Send each contact at the hour they usually open — the most common UTC hour of their opens over the last 180 days, needing at least three — within 24 hours of the start; contacts without enough opens go at the start. Never combined with `ab_test` (`400`). Pro and Business, behind a flag while it is being tried (`403` otherwise). |
| `rss_feed_id` | string \| null | no | The RSS feed that generated this campaign, when it was created by a feed rather than by hand. |
| `html_content` | string | yes |  |
| `text_content` | string | yes |  |
| `language` | string \| null | no | ISO 639-1 code of the language the campaign's own subject and body are written in — the version every contact without a matching translation receives. Null = unspecified. |
| `languages` | object \| null | no | Translations keyed by ISO 639-1 code (up to five). Present on single-campaign responses and omitted from the list. A campaign carries either language versions or an A/B test, never both. |
| `blocks` | array of BuilderBlock | no | The visual builder's block JSON when `html_content` was rendered from it; `null` when the email is HTML only. A draft with blocks reopens in the visual builder, and Duplicate carries them across. |
| `status` | CampaignStatus | yes |  |
| `send_to_type` | SendToType | yes |  |
| `send_to_id` | string \| null | yes | List or segment ID when `send_to_type` is `list`/`segment`. |
| `scheduled_at` | string \| null | yes |  |
| `sent_at` | string \| null | yes |  |
| `stats_sent` | integer | yes |  |
| `stats_delivered` | integer | yes |  |
| `stats_opened` | integer | yes |  |
| `stats_clicked` | integer | yes |  |
| `stats_bounced` | integer | yes |  |
| `stats_unsubscribed` | integer | yes |  |
| `created_at` | string | yes |  |
| `updated_at` | string | no | Last write. Send it back as `expected_updated_at` on update to detect a concurrent edit. |
| `draft_key` | string \| null | no | The idempotency key the draft was created with, if any. |

Example:

```
{
  "id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
  "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
  "name": "September newsletter",
  "subject": "What's new this month",
  "from_name": "Acme",
  "from_email": "hello@acme.com",
  "html_content": "<h1>Hello {{first_name}}</h1>",
  "text_content": "Hello {{first_name}}",
  "status": "draft",
  "send_to_type": "list",
  "send_to_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "scheduled_at": null,
  "sent_at": null,
  "stats_sent": 0,
  "stats_delivered": 0,
  "stats_opened": 0,
  "stats_clicked": 0,
  "stats_bounced": 0,
  "stats_unsubscribed": 0,
  "created_at": "2026-09-01T11:00:00.000Z"
}
```

CampaignWithStats

```
{
  "allOf": [
    {
      "$ref": "#/components/schemas/Campaign"
    },
    {
      "type": "object",
      "required": [
        "stats"
      ],
      "properties": {
        "stats": {
          "type": "object",
          "required": [
            "total_sends",
            "delivered",
            "opened",
            "bounced"
          ],
          "description": "Counts of per-recipient send records by current status.",
          "properties": {
            "total_sends": {
              "type": "integer"
            },
            "delivered": {
              "type": "integer"
            },
            "opened": {
              "type": "integer"
            },
            "bounced": {
              "type": "integer"
            },
            "by_language": {
              "type": "array",
              "description": "For a campaign with language versions: sent, opened and clicked per language that went out, the campaign's own language first (its code, or null when it has none). Empty for a single-language campaign.",
              "items": {
                "type": "object",
                "required": [
                  "language",
                  "sent",
                  "opened",
                  "clicked"
                ],
                "properties": {
                  "language": {
                    "type": "string",
                    "nullable": true,
                    "description": "ISO 639-1 code"
                  },
                  "sent": {
                    "type": "integer"
                  },
                  "opened": {
                    "type": "integer"
                  },
                  "clicked": {
                    "type": "integer"
                  }
                }
              }
            }
          },
          "example": {
            "total_sends": 1834,
            "delivered": 1790,
            "opened": 612,
            "bounced": 9,
            "by_language": [
              {
                "language": "en",
                "sent": 1500,
                "opened": 480,
                "clicked": 96
              },
              {
                "language": "fr",
                "sent": 334,
                "opened": 132,
                "clicked": 30
              }
            ]
          }
        }
      }
    }
  ]
}
```

Example:

```
null
```

CampaignCreate

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `draft_key` | string | no | Optional idempotency key (a UUID, or up to 64 letters, digits, `-`, `_`), unique per workspace. A repeat create with the same key returns the existing draft (`200`). |
| `language` | string \| null | no | ISO 639-1 code of the language the campaign's own subject and body are written in (#80). Null clears it. |
| `languages` | object \| null | no | Translations keyed by ISO 639-1 code, up to five, each with its own `subject` and `html_content` (optional `text_content`, `blocks`). A contact whose `language` matches a key receives that version; everyone else the campaign's own. Keys must differ from `language`. Cannot be combined with `ab_test` (`400`). Null clears them. |
| `subject_b` | string | no | A second subject line to test against `subject` — the two-version shape. Setting it (with `ab_test`) arms a subject-line test; `null` removes a subject test. Ignored when `ab_test.variants` is given. |
| `send_pace_hours` | integer \| null | no | Spread the send over this many hours (see Campaign); null for the workspace default. |
| `send_at_best_time` | boolean | no | Send each contact at their usual open hour (see Campaign). Not with `ab_test`. |
| `ab_test` | object | no | A/B test settings. Either `subject_b` plus these settings (a two-version subject test), or `test_on` plus `variants` — up to four extra versions (B–E) of the subject line, the from name or the email content; version A is the campaign itself. On send, `sample_pct` of the audience is split evenly between the versions, and after `wait_minutes` the version with the better rate on `metric` goes to everyone else. An audience under two recipients per version sends plain with version A (state `skipped`). `null` removes the test. |
| `name` | string | yes |  |
| `subject` | string | no | May be omitted or empty on a draft; required to send. |
| `from_name` | string | no |  |
| `from_email` | string | yes | Stored with the campaign. Delivery uses the workspace's saved sender details, which must be the workspace's own shared address or an address on a sending domain it has verified; that check runs on every send. |
| `html_content` | string | no | Supports `{{first_name}}`, `{{last_name}}`, `{{email}}`, `{{custom_fields.<key>}}`, `{{workspace_name}}` (the workspace's name — the sender's business), `{{sender_name}}` (the From name), `{{unsubscribe_url}}` and `{{web_version_url}}` merge tags, each with an optional fallback after a pipe — `{{first_name\|there}}` — used when the contact has no value. Write `\\|` for a literal pipe in the fallback. Conditional content: `{{#if custom_fields.plan is "pro"}}…{{else}}…{{/if}}`, with the operators is, is not, contains, does not contain, starts with, ends with, is set, is not set, is greater than and is less than, nesting one level. A tag or condition that does not resolve is sent exactly as typed. At most 500,000 characters. |
| `text_content` | string | no | The same merge tags and conditional blocks as `html_content`, inserted unescaped. At most 200,000 characters. |
| `blocks` | array of BuilderBlock | no | The visual builder's block JSON, when `html_content` was rendered from it. Optional; `null` or absent means HTML only. |
| `send_to_type` | SendToType | yes |  |
| `send_to_id` | string \| null | no | Required when `send_to_type` is `list` or `segment`. |

Example:

```
{
  "draft_key": "string",
  "language": "en",
  "languages": {
    "fr": {
      "subject": "Nouveautés de septembre",
      "html_content": "<p>Bonjour…</p>"
    }
  },
  "subject_b": "string",
  "send_pace_hours": null,
  "send_at_best_time": true,
  "ab_test": {
    "sample_pct": 20,
    "wait_minutes": 120,
    "metric": "opens",
    "test_on": "subject",
    "variants": [
      {
        "subject": "string",
        "from_name": "string",
        "html_content": "string",
        "text_content": "string",
        "blocks": [],
        "send_offset_minutes": 60
      }
    ]
  },
  "name": "September newsletter",
  "subject": "What's new this month",
  "from_name": "Acme",
  "from_email": "hello@acme.com",
  "html_content": "<h1>Hello {{first_name}}</h1>",
  "text_content": "Hello {{first_name}}",
  "blocks": [
    {
      "id": "block_1",
      "type": "text",
      "props": {
        "heading": "This month",
        "text": "Hi {{first_name|there}},",
        "padding": 24
      }
    }
  ],
  "send_to_type": "all",
  "send_to_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
}
```

CampaignUpdate

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `expected_updated_at` | string \| null | no | The `updated_at` you last read. When the campaign has changed since, the update is refused with `409` `code: "stale"`. Omit or send `null` to skip the check. |
| `name` | string | no | May not be emptied. |
| `subject` | string | no | May be empty on a draft, not on a scheduled campaign. |
| `from_name` | string | no |  |
| `from_email` | string | no |  |
| `html_content` | string | no | At most 500,000 characters (`400` otherwise). |
| `text_content` | string | no | At most 200,000 characters (`400` otherwise). |
| `send_to_type` | SendToType | no |  |
| `send_to_id` | string \| null | no |  |

Example:

```
{
  "subject": "What's new in September",
  "send_to_type": "all",
  "send_to_id": null
}
```

TemplateCategory

```
{
  "type": "string",
  "enum": [
    "welcome",
    "newsletter",
    "promotion",
    "transactional",
    "custom"
  ]
}
```

Example:

```
"welcome"
```

CustomBlockSlot — A part of a custom block a marketer may change. Declared in the MJML as `[[key]]` (text), `[[key:url]]` (a link) or `[[key:image]]` (an image address).

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | string | yes |  |
| `type` | "text" \| "url" \| "image" | yes |  |
| `label` | string | yes |  |
| `default` | string | yes |  |

Example:

```
{
  "key": "headline",
  "type": "text",
  "label": "Headline",
  "default": "Big news"
}
```

CustomBlock — A block of the workspace's own, written in MJML and compiled when saved. `html` is the compiled body fragment with its slot markers in place; `head_html` the styles and Outlook conditionals an email carries once for it. The visual builder places it as a block of type `custom`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `tenant_id` | string | yes |  |
| `name` | string | yes |  |
| `description` | string | yes |  |
| `mjml` | string | yes | The source, up to 100,000 characters. |
| `html` | string | yes |  |
| `head_html` | string | yes |  |
| `slots` | array of CustomBlockSlot | yes |  |
| `created_at` | string | yes |  |
| `updated_at` | string | yes |  |

Example:

```
{
  "id": "b7c8d9e0-f1a2-4b3c-8d4e-5f6a7b8c9d0e",
  "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
  "name": "Hero",
  "description": "Headline, one line and a button on the brand colour.",
  "mjml": "<mjml><mj-body><mj-section background-color=\"#111827\"><mj-column><mj-text color=\"#ffffff\" font-size=\"28px\">[[headline]]</mj-text><mj-button href=\"[[cta:url]]\">[[cta_label]]</mj-button></mj-column></mj-section></mj-body></mjml>",
  "html": "<div style=\"\">…[[headline]]…</div>",
  "head_html": "<style type=\"text/css\">…</style>",
  "slots": [
    {
      "key": "headline",
      "type": "text",
      "label": "Headline",
      "default": "Big news"
    },
    {
      "key": "cta",
      "type": "url",
      "label": "Button link",
      "default": "https://acme.test"
    },
    {
      "key": "cta_label",
      "type": "text",
      "label": "Button text",
      "default": "Read more"
    }
  ],
  "created_at": "2026-09-26T09:00:00.000Z",
  "updated_at": "2026-09-26T09:00:00.000Z"
}
```

BuilderBlock — One block of the visual email builder. `props` holds the block's settings (text, colours, padding…) and is not validated field by field; the builder treats every prop as optional.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | no |  |
| `type` | "header" \| "text" \| "image" \| "button" \| "divider" \| "spacer" \| "columns" \| "social" \| "footer" \| "rss" \| "html" \| "video" \| "feature" \| "quote" \| "list" \| "poll" \| "custom" | yes |  |
| `props` | object | yes | For a `custom` block: `blockId` (a custom block of the workspace), `fragment` and `head` (its compiled HTML, snapshotted when placed), `slots` and `values` (the marketer's text, links and images, by slot key). |

Example:

```
{
  "id": "block_1",
  "type": "text",
  "props": {
    "heading": "This month",
    "text": "Hi {{first_name|there}},",
    "padding": 24
  }
}
```

Template

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `tenant_id` | string | yes |  |
| `name` | string | yes |  |
| `subject` | string | yes |  |
| `html_content` | string | yes |  |
| `category` | TemplateCategory | yes |  |
| `blocks` | array of BuilderBlock | no | The visual builder's block JSON when `html_content` was rendered from it; `null` when the template is HTML only. A template with blocks reopens in the visual builder. |
| `created_at` | string | yes |  |

Example:

```
{
  "id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
  "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
  "name": "Welcome email",
  "subject": "Welcome, {{first_name}}!",
  "html_content": "<h1>Hi {{first_name}}</h1>",
  "category": "welcome",
  "created_at": "2026-08-20T09:00:00.000Z"
}
```

AutomationStatus

```
{
  "type": "string",
  "enum": [
    "active",
    "paused",
    "draft"
  ]
}
```

Example:

```
"active"
```

HealthIssue

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `code` | string | yes |  |
| `message` | string | yes |  |
| `step_id` | string | no | The step the issue concerns, when it is about one. |

Example:

```
{
  "code": "template_missing",
  "message": "string",
  "step_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
}
```

TriggerType

```
{
  "type": "string",
  "enum": [
    "contact_created",
    "list_joined",
    "form_submitted",
    "tag_added",
    "tag_removed",
    "field_updated",
    "date_anniversary",
    "date_specific",
    "api",
    "automation",
    "cart_abandoned",
    "product_viewed",
    "order_placed",
    "event_received"
  ]
}
```

Example:

```
"contact_created"
```

ConditionRule — Either a contact-field test or an email-activity test. An activity rule asks whether the contact opened or clicked the email sent by an EARLIER step in this automation, named by that step's id.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | "field" \| "email_activity" | no | Defaults to `field`. |
| `field` | string | no |  |
| `operator` | "equals" \| "not_equals" \| "contains" \| "not_contains" \| "is_empty" \| "is_not_empty" \| "starts_with" \| "ends_with" \| "greater_than" \| "less_than" | no |  |
| `value` | string | no |  |
| `activity` | "opened" \| "not_opened" \| "clicked" \| "not_clicked" | no |  |
| `step_id` | string | no | The send_email step the activity refers to. |

Example:

```
{
  "field": "source",
  "operator": "equals",
  "value": "form"
}
```

AutomationTriggerInput — One way into the automation. Up to 3 per automation, combined with OR: a contact matching any of them enters.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | TriggerType | yes |  |
| `config` | TriggerConfig | no | Trigger-specific settings. `tag_added` / `tag_removed` use `tag_id` (omit for any tag). `list_joined` accepts `list_id`; `form_submitted` accepts `form_id`; `automation` accepts `automation_id`. `field_updated` requires `field`. `date_anniversary` and `date_specific` require `date_field` — a custom field holding `YYYY-MM-DD`, or `created_at` for an anniversary — plus optional `offset_days` (0–365) and `direction` (`before`, `on`, `after`). `contact_created`, `api`, `cart_abandoned`, `product_viewed` and `order_placed` take no settings — they fire for the matching event from any store or webhook that posts it (see `POST /api/v1/ecommerce/events`). `event_received` takes `event_name` — the name another system will post to `POST /api/v1/events`; omit it to run for every event the workspace is sent. Any id given must belong to this workspace, or the request is refused with `400`. |
| `filters` | array of ConditionRule | no | A contact who fires this trigger but fails these does not enter. |

Example:

```
{
  "type": "tag_added",
  "config": {
    "tag_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33"
  },
  "filters": []
}
```

TriggerConfig — Trigger-specific settings. `tag_added` / `tag_removed` use `tag_id` (omit for any tag). `list_joined` accepts `list_id`; `form_submitted` accepts `form_id`; `automation` accepts `automation_id`. `field_updated` requires `field`. `date_anniversary` and `date_specific` require `date_field` — a custom field holding `YYYY-MM-DD`, or `created_at` for an anniversary — plus optional `offset_days` (0–365) and `direction` (`before`, `on`, `after`). `contact_created`, `api`, `cart_abandoned`, `product_viewed` and `order_placed` take no settings — they fire for the matching event from any store or webhook that posts it (see `POST /api/v1/ecommerce/events`). `event_received` takes `event_name` — the name another system will post to `POST /api/v1/events`; omit it to run for every event the workspace is sent. Any id given must belong to this workspace, or the request is refused with `400`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `tag_id` | string | no |  |
| `list_id` | string | no |  |
| `form_id` | string | no |  |
| `automation_id` | string | no |  |
| `field` | string | no |  |
| `date_field` | string | no |  |
| `offset_days` | integer | no |  |
| `direction` | "before" \| "on" \| "after" | no |  |
| `event_name` | string | no | `event_received` only: the name the other system posts as `event` to `POST /api/v1/events`. Send it lower-case — letters, numbers, dot, dash or underscore, starting with a letter or number — exactly as that endpoint reduces an incoming name to, since the two are compared as strings: a trigger named `Deal_Won` never fires, because no event arrives spelt that way. Omit it to listen to every event. |
| `recipe_placeholder` | string | no | Written by a recipe import when the tag, list or form was left for later: the label of what is still to choose. While it is present and the id is empty, activation is refused. Cleared by the editor once an id is chosen, or when the author opts for "any". |

Example:

```
{
  "tag_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33"
}
```

StepType

```
{
  "type": "string",
  "enum": [
    "send_email",
    "wait",
    "condition",
    "add_tag",
    "remove_tag",
    "update_field",
    "unsubscribe",
    "trigger_automation",
    "call_webhook",
    "split"
  ]
}
```

Example:

```
"send_email"
```

StepConfig — Step settings by type. `send_email`: `template_id` (subject and HTML are read from the template at send time, so template edits apply to every automation that uses it) or inline `subject` + `html_content` (both required), optionally `text_content`; a step with neither is refused with `400`. When both are present the inline fields win. `wait`: `mode` — `duration` with `duration_minutes`, or `time_of_day` / `day_of_week` / `day_of_month` with `time` (HH:MM), `weekdays` (0=Sunday), `day_of_month`, and `timezone`; `optimised` reuses the hour the contact signed up at. `condition`: `match` (`all` / `any` / `none`) plus `rules`; the older flat `field` / `operator` / `value` is still accepted and now takes the **no** branch instead of ejecting the contact. `add_tag` / `remove_tag`: `tag_id`. `update_field`: `field` and `value`, plus `mode` — `set` (default; every existing automation keeps this behaviour) writes `value` as-is, `increment` adds `value` (a number, may be negative) to the field's current number instead of replacing it — a running counter such as lifetime value or visit count. `increment` only works on a Number custom field; an undeclared key is registered as Number (never Text) the first time it is used this way. `unsubscribe`: `list_id` to leave one list, omitted to unsubscribe entirely. `trigger_automation`: `automation_id`. `call_webhook`: an optional `label` (up to 80 characters) naming this step — the step calls no URL of its own; it emits the `automation.step_reached` webhook event, so it does something only where a webhook endpoint in the workspace subscribes to that event. Every id must belong to this workspace, or the request is refused with `400` naming the step and key. `split`: `branches` — exactly two entries `{key:"a",pct}`, `{key:"b",pct}` with whole-number percentages summing to 100 (default 50/50; 100/0 sends everyone down A); the A share follows `yes_step_id`, the B share `no_step_id`, and each contact's branch is fixed when they reach the step. `send_email` also takes an optional `subject_b` (up to 200 characters) to send half of the contacts reaching the step a second subject line.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `template_id` | string | no |  |
| `subject` | string | no |  |
| `html_content` | string | no |  |
| `text_content` | string | no |  |
| `mode` | "duration" \| "time_of_day" \| "day_of_week" \| "day_of_month" \| "set" \| "increment" | no | `wait` uses duration/time_of_day/day_of_week/day_of_month; `update_field` uses set/increment (defaults to set). |
| `duration_minutes` | integer | no |  |
| `duration_value` | integer | no |  |
| `duration_unit` | "minutes" \| "hours" \| "days" | no |  |
| `time` | string | no |  |
| `weekdays` | array of integer | no |  |
| `day_of_month` | integer | no |  |
| `timezone` | string | no |  |
| `optimised` | boolean | no |  |
| `match` | "all" \| "any" \| "none" | no |  |
| `rules` | array of ConditionRule | no |  |
| `field` | string | no |  |
| `operator` | string | no |  |
| `value` | string | no |  |
| `tag_id` | string | no |  |
| `list_id` | string | no |  |
| `automation_id` | string | no |  |
| `subject_b` | string | no | `send_email` only: a second subject line. Half of the contacts reaching the step get each; the automation page reports opens and clicks per subject. |
| `branches` | array of object | no | `split` only: the two shares, `a` then `b`, whole-number percentages summing to 100. Default 50/50; 100/0 sends everyone down A. The A share follows `yes_step_id`, the B share `no_step_id`, and each contact's branch is fixed when they reach the step. |

Example:

```
{
  "template_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "subject": "string",
  "html_content": "string",
  "text_content": "string",
  "mode": "duration",
  "duration_minutes": 1,
  "duration_value": 1,
  "duration_unit": "minutes",
  "time": "09:00",
  "weekdays": [
    1
  ],
  "day_of_month": 1,
  "timezone": "Europe/London",
  "optimised": true,
  "match": "all",
  "rules": [
    {
      "field": "source",
      "operator": "equals",
      "value": "form"
    }
  ],
  "field": "string",
  "operator": "string",
  "value": "string",
  "tag_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "list_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "automation_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "subject_b": "string",
  "branches": [
    {
      "key": "a",
      "pct": 1
    }
  ]
}
```

AutomationStepInput — Steps form a graph. Each step may name the step that runs after it via `next_step_id` — and two steps may name the same one, which is how branches rejoin. `parent_id` positions a step under another for layout, and a `condition` or `split` step names its two outcomes via `yes_step_id` and `no_step_id`. Those fields reference the `id` of another step in the same array — either an existing step uuid (which is preserved, so contacts already in the automation keep their place) or any string key you invent for a new step. Send an array with no `parent_id` anywhere and it is chained in order, exactly as before.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | no | Existing step uuid to keep, or a client-side key other steps can reference. |
| `type` | StepType | yes |  |
| `step_order` | integer | no | Orders siblings for display. Defaults to the array index. Does not drive execution. |
| `config` | StepConfig | no | Step settings by type. `send_email`: `template_id` (subject and HTML are read from the template at send time, so template edits apply to every automation that uses it) or inline `subject` + `html_content` (both required), optionally `text_content`; a step with neither is refused with `400`. When both are present the inline fields win. `wait`: `mode` — `duration` with `duration_minutes`, or `time_of_day` / `day_of_week` / `day_of_month` with `time` (HH:MM), `weekdays` (0=Sunday), `day_of_month`, and `timezone`; `optimised` reuses the hour the contact signed up at. `condition`: `match` (`all` / `any` / `none`) plus `rules`; the older flat `field` / `operator` / `value` is still accepted and now takes the **no** branch instead of ejecting the contact. `add_tag` / `remove_tag`: `tag_id`. `update_field`: `field` and `value`, plus `mode` — `set` (default; every existing automation keeps this behaviour) writes `value` as-is, `increment` adds `value` (a number, may be negative) to the field's current number instead of replacing it — a running counter such as lifetime value or visit count. `increment` only works on a Number custom field; an undeclared key is registered as Number (never Text) the first time it is used this way. `unsubscribe`: `list_id` to leave one list, omitted to unsubscribe entirely. `trigger_automation`: `automation_id`. `call_webhook`: an optional `label` (up to 80 characters) naming this step — the step calls no URL of its own; it emits the `automation.step_reached` webhook event, so it does something only where a webhook endpoint in the workspace subscribes to that event. Every id must belong to this workspace, or the request is refused with `400` naming the step and key. `split`: `branches` — exactly two entries `{key:"a",pct}`, `{key:"b",pct}` with whole-number percentages summing to 100 (default 50/50; 100/0 sends everyone down A); the A share follows `yes_step_id`, the B share `no_step_id`, and each contact's branch is fixed when they reach the step. `send_email` also takes an optional `subject_b` (up to 200 characters) to send half of the contacts reaching the step a second subject line. |
| `parent_id` | string | no | Layout only: the step this one sits under. When no `next_step_id` is given anywhere, the forward edges are derived from this, which is how an ordered list still works. |
| `next_step_id` | string | no | The step that runs next. Several steps may name the SAME target, which is how two branches rejoin. Not valid on a condition, which branches with yes_step_id / no_step_id. |
| `yes_step_id` | string | no | condition and split steps: the step taken when a condition passes, or the A share of a split. Omit to end that path. |
| `no_step_id` | string | no | condition and split steps: the step taken when a condition fails, or the B share of a split. Omit to end that path. |

Example:

```
{
  "id": "string",
  "type": "send_email",
  "step_order": 1,
  "config": {
    "template_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
    "subject": "string",
    "html_content": "string",
    "text_content": "string",
    "mode": "duration",
    "duration_minutes": 1,
    "duration_value": 1,
    "duration_unit": "minutes",
    "time": "09:00",
    "weekdays": [
      1
    ],
    "day_of_month": 1,
    "timezone": "Europe/London",
    "optimised": true,
    "match": "all",
    "rules": [
      {
        "field": "source",
        "operator": "equals",
        "value": "form"
      }
    ],
    "field": "string",
    "operator": "string",
    "value": "string",
    "tag_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
    "list_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
    "automation_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
    "subject_b": "string",
    "branches": [
      {
        "key": "a",
        "pct": 1
      }
    ]
  },
  "parent_id": "string",
  "next_step_id": "string",
  "yes_step_id": "string",
  "no_step_id": "string"
}
```

AutomationStep

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `automation_id` | string | yes |  |
| `step_order` | integer | yes |  |
| `type` | StepType | yes |  |
| `config` | StepConfig | yes | Step settings by type. `send_email`: `template_id` (subject and HTML are read from the template at send time, so template edits apply to every automation that uses it) or inline `subject` + `html_content` (both required), optionally `text_content`; a step with neither is refused with `400`. When both are present the inline fields win. `wait`: `mode` — `duration` with `duration_minutes`, or `time_of_day` / `day_of_week` / `day_of_month` with `time` (HH:MM), `weekdays` (0=Sunday), `day_of_month`, and `timezone`; `optimised` reuses the hour the contact signed up at. `condition`: `match` (`all` / `any` / `none`) plus `rules`; the older flat `field` / `operator` / `value` is still accepted and now takes the **no** branch instead of ejecting the contact. `add_tag` / `remove_tag`: `tag_id`. `update_field`: `field` and `value`, plus `mode` — `set` (default; every existing automation keeps this behaviour) writes `value` as-is, `increment` adds `value` (a number, may be negative) to the field's current number instead of replacing it — a running counter such as lifetime value or visit count. `increment` only works on a Number custom field; an undeclared key is registered as Number (never Text) the first time it is used this way. `unsubscribe`: `list_id` to leave one list, omitted to unsubscribe entirely. `trigger_automation`: `automation_id`. `call_webhook`: an optional `label` (up to 80 characters) naming this step — the step calls no URL of its own; it emits the `automation.step_reached` webhook event, so it does something only where a webhook endpoint in the workspace subscribes to that event. Every id must belong to this workspace, or the request is refused with `400` naming the step and key. `split`: `branches` — exactly two entries `{key:"a",pct}`, `{key:"b",pct}` with whole-number percentages summing to 100 (default 50/50; 100/0 sends everyone down A); the A share follows `yes_step_id`, the B share `no_step_id`, and each contact's branch is fixed when they reach the step. `send_email` also takes an optional `subject_b` (up to 200 characters) to send half of the contacts reaching the step a second subject line. |
| `parent_id` | string | no |  |
| `next_step_id` | string | no |  |
| `yes_step_id` | string | no |  |
| `no_step_id` | string | no |  |
| `created_at` | string | yes |  |

Example:

```
{
  "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "automation_id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c",
  "step_order": 0,
  "type": "send_email",
  "config": {
    "template_id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b"
  },
  "parent_id": null,
  "next_step_id": null,
  "yes_step_id": null,
  "no_step_id": null,
  "created_at": "2026-08-26T08:00:00.000Z"
}
```

Automation

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `tenant_id` | string | yes |  |
| `name` | string | yes |  |
| `description` | string | yes |  |
| `trigger_type` | TriggerType | yes |  |
| `trigger_config` | TriggerConfig | yes | Trigger-specific settings. `tag_added` / `tag_removed` use `tag_id` (omit for any tag). `list_joined` accepts `list_id`; `form_submitted` accepts `form_id`; `automation` accepts `automation_id`. `field_updated` requires `field`. `date_anniversary` and `date_specific` require `date_field` — a custom field holding `YYYY-MM-DD`, or `created_at` for an anniversary — plus optional `offset_days` (0–365) and `direction` (`before`, `on`, `after`). `contact_created`, `api`, `cart_abandoned`, `product_viewed` and `order_placed` take no settings — they fire for the matching event from any store or webhook that posts it (see `POST /api/v1/ecommerce/events`). `event_received` takes `event_name` — the name another system will post to `POST /api/v1/events`; omit it to run for every event the workspace is sent. Any id given must belong to this workspace, or the request is refused with `400`. |
| `triggers` | array of AutomationTriggerInput | no | Every way into this automation. trigger_type/trigger_config mirror the first entry. |
| `repeat_enabled` | boolean | no | Whether a contact who finished may enter again. Defaults to false — re-entry re-sends the whole sequence — except when a trigger is `date_anniversary`, where a new automation defaults to true so it runs every year. Only creation applies a default; an update never changes repeat settings it was not sent. |
| `repeat_cooldown_hours` | integer | no | Minimum hours between two enrolments of the same contact. Default 24; 7200 (300 days) when a trigger is `date_anniversary`, so a yearly run can never fire twice in one year. |
| `trigger_broken` | boolean | no | Derived: the trigger names a list, form or tag that no longer exists, so the automation can never fire. |
| `last_error` | string \| null | no | The most recent failure while running this automation, as shown on its health panel. Free text — read it, do not match on it. |
| `last_error_at` | string \| null | no | When `last_error` was recorded. |
| `error_count` | integer | no | Failures since the automation was last activated. Resets on activation. |
| `status` | AutomationStatus | yes |  |
| `created_at` | string | yes |  |

Example:

```
{
  "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c",
  "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
  "name": "Welcome series",
  "description": "Three emails over a week",
  "trigger_type": "contact_created",
  "trigger_config": {},
  "status": "active",
  "created_at": "2026-08-26T08:00:00.000Z"
}
```

AutomationWithSteps

```
{
  "allOf": [
    {
      "$ref": "#/components/schemas/Automation"
    },
    {
      "type": "object",
      "required": [
        "steps"
      ],
      "properties": {
        "steps": {
          "type": "array",
          "items": {
            "$ref": "#/components/schemas/AutomationStep"
          }
        }
      }
    }
  ]
}
```

Example:

```
null
```

AutomationCreate

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes |  |
| `description` | string | no |  |
| `trigger_type` | TriggerType | yes |  |
| `trigger_config` | TriggerConfig | no | Trigger-specific settings. `tag_added` / `tag_removed` use `tag_id` (omit for any tag). `list_joined` accepts `list_id`; `form_submitted` accepts `form_id`; `automation` accepts `automation_id`. `field_updated` requires `field`. `date_anniversary` and `date_specific` require `date_field` — a custom field holding `YYYY-MM-DD`, or `created_at` for an anniversary — plus optional `offset_days` (0–365) and `direction` (`before`, `on`, `after`). `contact_created`, `api`, `cart_abandoned`, `product_viewed` and `order_placed` take no settings — they fire for the matching event from any store or webhook that posts it (see `POST /api/v1/ecommerce/events`). `event_received` takes `event_name` — the name another system will post to `POST /api/v1/events`; omit it to run for every event the workspace is sent. Any id given must belong to this workspace, or the request is refused with `400`. |
| `steps` | array of AutomationStepInput | yes |  |

Example:

```
{
  "name": "Welcome series",
  "trigger_type": "contact_created",
  "trigger_config": {},
  "steps": [
    {
      "type": "send_email",
      "config": {
        "template_id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b"
      }
    },
    {
      "type": "wait",
      "config": {
        "duration_minutes": 1440
      }
    },
    {
      "type": "add_tag",
      "config": {
        "tag_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33"
      }
    }
  ]
}
```

AutomationRecipe

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `slug` | string | yes |  |
| `name` | string | yes |  |
| `tagline` | string | yes |  |
| `description` | string | yes |  |
| `outline` | object | yes | What the recipe does, in words, with placeholders named by their label. |
| `placeholders` | array of object | yes |  |

Example:

```
{
  "slug": "welcome-series",
  "name": "Welcome series",
  "tagline": "Three emails over a week for everyone who joins a list.",
  "description": "…",
  "outline": {
    "triggers": [
      "Contact joins list \"List to watch\""
    ],
    "steps": [
      {
        "text": "Send “Welcome — here is what to expect”"
      },
      {
        "text": "Wait 2 days"
      }
    ],
    "repeats": "Each contact goes through once"
  },
  "placeholders": [
    {
      "id": "list",
      "kind": "list",
      "label": "List to watch",
      "hint": "Joining this list starts the series.",
      "suggested": "Newsletter"
    }
  ]
}
```

AutomationRecipeImport

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no | Name for the new automation. Defaults to the recipe's name. |
| `choices` | object | no | One entry per placeholder id. |

Example:

```
{
  "name": "Newsletter welcome",
  "choices": {
    "list": {
      "existing": "9c1e2d3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f"
    },
    "welcomed": {
      "create": "Welcomed"
    }
  }
}
```

WorkspaceBlueprintCreate

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes |  |
| `description` | string | no |  |

Example:

```
{
  "name": "Agency starter kit",
  "description": "Custom fields, welcome automation and templates every new client site starts with."
}
```

WorkspaceBlueprintSummary

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `account_id` | string | yes |  |
| `source_tenant_id` | string | no | The workspace it was captured from — null once that workspace has been deleted (the blueprint itself still applies, minus any hosted images). |
| `created_by` | string | no |  |
| `name` | string | yes |  |
| `description` | string | yes |  |
| `created_at` | string | yes |  |
| `updated_at` | string | yes |  |
| `counts` | object | yes |  |

Example:

```
{
  "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "account_id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c",
  "source_tenant_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33",
  "created_by": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
  "name": "Agency starter kit",
  "description": "",
  "created_at": "2026-09-15T09:00:00.000Z",
  "updated_at": "2026-09-15T09:00:00.000Z",
  "counts": {
    "custom_fields": 2,
    "automations": 1,
    "templates": 3
  }
}
```

WorkspaceBlueprintApplyResult

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `custom_fields` | object | yes |  |
| `templates` | object | yes |  |
| `automations` | object | yes |  |

Example:

```
{
  "custom_fields": {
    "created": [
      "renewal_date"
    ],
    "skipped": [
      "plan"
    ],
    "unavailable": false
  },
  "templates": {
    "created": 3,
    "failed": 0,
    "media_copied": 2,
    "media_not_carried_over": 0
  },
  "automations": {
    "created": [
      {
        "name": "Welcome series",
        "id": "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c",
        "unresolved": []
      }
    ],
    "failed": []
  }
}
```

AutomationUpdate

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no |  |
| `description` | string | no |  |
| `trigger_type` | TriggerType | no |  |
| `trigger_config` | TriggerConfig | no | Trigger-specific settings. `tag_added` / `tag_removed` use `tag_id` (omit for any tag). `list_joined` accepts `list_id`; `form_submitted` accepts `form_id`; `automation` accepts `automation_id`. `field_updated` requires `field`. `date_anniversary` and `date_specific` require `date_field` — a custom field holding `YYYY-MM-DD`, or `created_at` for an anniversary — plus optional `offset_days` (0–365) and `direction` (`before`, `on`, `after`). `contact_created`, `api`, `cart_abandoned`, `product_viewed` and `order_placed` take no settings — they fire for the matching event from any store or webhook that posts it (see `POST /api/v1/ecommerce/events`). `event_received` takes `event_name` — the name another system will post to `POST /api/v1/events`; omit it to run for every event the workspace is sent. Any id given must belong to this workspace, or the request is refused with `400`. |
| `steps` | array of AutomationStepInput | no | Replaces all existing steps. |

Example:

```
{
  "name": "Welcome series v2"
}
```

FormKind

```
{
  "type": "string",
  "enum": [
    "signup",
    "contact"
  ]
}
```

Example:

```
"signup"
```

Form

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `tenant_id` | string | yes |  |
| `name` | string | yes |  |
| `list_id` | string \| null | yes | List that signups join. Always null for contact forms. |
| `fields` | array of string | yes | Field names the form accepts. Beyond the built-ins, each name is stored as a custom field on the contact (signup) or included in the notification (contact). |
| `thank_you_message` | string | yes |  |
| `redirect_url` | string \| null | yes |  |
| `status` | "active" \| "inactive" | yes |  |
| `kind` | FormKind | no |  |
| `notify_email` | string \| null | no | Owner address that receives contact-form messages or signup notifications. |
| `notify_subject` | string \| null | no | Subject prefix for owner notifications (defaults to the form name). |
| `allowed_origins` | array of string | no | Origins (`https://example.com`) allowed to submit. Empty = any. |
| `turnstile_site_key` | string \| null | no |  |
| `turnstile_secret` | string \| null | no | Always masked as `••••••••` when set. |
| `daily_cap` | integer | no | Maximum submissions per 24 hours. Set and shown on the form. |
| `created_at` | string | yes |  |
| `views` | integer | no | Times the form has been shown to a person, since counting began: on its hosted page, in the pop-up, through the WordPress plugin, and by inline embeds whose code was copied after views were introduced. Crawlers, prefetches and repeated loads from one visitor are not counted. Returned by the GET operations. |
| `submissions` | integer | no | Submissions accepted since counting began. Attempts dropped by the automated-submission checks are not counted. Returned by the GET operations. |
| `headline` | string \| null | no | The hosted page's own headline; null = the default. A heading passed in an embed or pop-up URL still wins. |
| `body` | string \| null | no | The line of copy under the headline on the hosted page. |
| `button_label` | string \| null | no | The submit button's label; null = Subscribe (Send message for a contact form). |
| `ab_test` | object \| null | no | A second version of the form tested against the first. While `status` is `testing`, half of the views of the hosted page, the pop-up and the WordPress plugin see version B at random (per view, no cookie; inline embeds always show A); each view and submission is counted against its version. Set `{ "status": "testing", "b": {…} }` to start (started_at is set by the server), `{ "status": "decided", "winner": "a"\|"b" }` to end it — deciding for B writes its copy onto the form — and `null` to clear. |
| `image_url` | string \| null | no | An https image shown above the form on its hosted page and used as the share image. |
| `indexable` | boolean | no | Whether search engines may index the hosted page. False unless turned on; embeds and pop-ups are never indexable. |
| `versions` | object | no | Views, submissions and conversion rate of version A and version B (B is its share of the totals; A is the rest). Returned by the GET operations. |
| `conversion_rate` | number \| null | no | `submissions` ÷ `views`, 0–1 to four decimal places; `null` until the form has been viewed. Not capped: a form driven through the API has submissions with no view, so its rate can exceed 1. Returned by the GET operations. |

Example:

```
{
  "id": "11111111-2222-4333-8444-555555555555",
  "tenant_id": "7a1e9d4c-3b2f-4e8a-9c6d-1f2e3d4c5b6a",
  "name": "Newsletter signup",
  "list_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "fields": [
    "email",
    "first_name",
    "last_name",
    "company"
  ],
  "thank_you_message": "Thanks for subscribing!",
  "redirect_url": null,
  "status": "active",
  "kind": "signup",
  "notify_email": null,
  "notify_subject": null,
  "allowed_origins": [
    "https://acme.com"
  ],
  "turnstile_site_key": "0x4AAAAAAA",
  "turnstile_secret": "••••••••",
  "daily_cap": 200,
  "created_at": "2026-08-28T14:00:00.000Z",
  "views": 1840,
  "submissions": 92,
  "conversion_rate": 0.05
}
```

FormVersionStats

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `views` | integer | yes |  |
| `submissions` | integer | yes |  |
| `conversion_rate` | number \| null | yes |  |

Example:

```
{
  "views": 1,
  "submissions": 1,
  "conversion_rate": null
}
```

FormVersionB — What version B changes. Every field is optional; at least one must be set. Fields never change between versions.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `heading` | string | no |  |
| `sub` | string | no |  |
| `button` | string | no |  |
| `thank_you_message` | string | no |  |
| `accent` | string | no | A hex colour for the button. |

Example:

```
{
  "heading": "string",
  "sub": "string",
  "button": "string",
  "thank_you_message": "string",
  "accent": "#f97316"
}
```

FormAbTest — A second version of the form tested against the first. While `status` is `testing`, half of the views of the hosted page, the pop-up and the WordPress plugin see version B at random (per view, no cookie; inline embeds always show A); each view and submission is counted against its version. Set `{ "status": "testing", "b": {…} }` to start (started_at is set by the server), `{ "status": "decided", "winner": "a"|"b" }` to end it — deciding for B writes its copy onto the form — and `null` to clear.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | "testing" \| "decided" | no |  |
| `started_at` | string | no |  |
| `decided_at` | string | no |  |
| `winner` | "a" \| "b" | no |  |
| `b` | FormVersionB | no | What version B changes. Every field is optional; at least one must be set. Fields never change between versions. |

Example:

```
{
  "status": "testing",
  "started_at": "2026-09-26T09:00:00.000Z",
  "b": {
    "heading": "Get the weekly letter",
    "button": "Count me in"
  }
}
```

FormCreate

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes |  |
| `headline` | string \| null | no |  |
| `body` | string \| null | no |  |
| `button_label` | string \| null | no |  |
| `image_url` | string \| null | no |  |
| `indexable` | boolean | no |  |
| `ab_test` | object \| null | no | A second version of the form tested against the first. While `status` is `testing`, half of the views of the hosted page, the pop-up and the WordPress plugin see version B at random (per view, no cookie; inline embeds always show A); each view and submission is counted against its version. Set `{ "status": "testing", "b": {…} }` to start (started_at is set by the server), `{ "status": "decided", "winner": "a"\|"b" }` to end it — deciding for B writes its copy onto the form — and `null` to clear. |
| `kind` | FormKind | no |  |
| `list_id` | string | no | Signup forms only. Must be one of this workspace's lists. |
| `fields` | array of string | no |  |
| `thank_you_message` | string | no |  |
| `redirect_url` | string | no |  |
| `notify_email` | string \| null | no | Required for contact forms. Must be a workspace member's address or an address on one of the workspace's verified sending domains. |
| `notify_subject` | string \| null | no |  |
| `allowed_origins` | array \| null | no | Up to 20 origins of the form `scheme://host[:port]`; null clears. |
| `turnstile_site_key` | string \| null | no |  |
| `turnstile_secret` | string \| null | no |  |
| `daily_cap` | integer | no |  |

Example:

```
{
  "name": "Contact us",
  "kind": "contact",
  "notify_email": "hello@acme.com",
  "notify_subject": "Website enquiry",
  "allowed_origins": [
    "https://acme.com"
  ],
  "daily_cap": 100
}
```

FormUpdate

```
{
  "allOf": [
    {
      "type": "object",
      "required": [
        "id"
      ],
      "properties": {
        "id": {
          "type": "string",
          "format": "uuid"
        },
        "status": {
          "type": "string",
          "enum": [
            "active",
            "inactive"
          ]
        }
      }
    },
    {
      "$ref": "#/components/schemas/FormCreate"
    }
  ],
  "example": {
    "id": "11111111-2222-4333-8444-555555555555",
    "status": "inactive"
  }
}
```

Example:

```
{
  "id": "11111111-2222-4333-8444-555555555555",
  "status": "inactive"
}
```

FormSubmission — Built-in fields plus any custom field declared in the form's `fields`. Undeclared keys are ignored. String values are trimmed and capped (200 characters; 5,000 for `message`).

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | yes |  |
| `first_name` | string | no |  |
| `last_name` | string | no |  |
| `name` | string | no | Contact forms: full name (falls back to first_name + last_name). |
| `subject` | string | no | Contact forms only. |
| `message` | string | no | Contact forms: required. |
| `turnstile_token` | string | no | Cloudflare Turnstile response when the form has Turnstile enabled (`cf-turnstile-response` is accepted as an alias). |

Example:

```
{
  "email": "jane@example.com",
  "first_name": "Jane",
  "last_name": "Doe",
  "name": "Jane Doe",
  "subject": "Question about pricing",
  "message": "Do you offer annual billing?",
  "turnstile_token": "string"
}
```

FormSubmissionResult

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `success` | boolean | yes |  |
| `message` | string | yes | The form's thank-you message. |
| `redirect_url` | string \| null | yes |  |

Example:

```
{
  "success": true,
  "message": "Thanks for subscribing!",
  "redirect_url": null
}
```

---
Source: https://sendbeam.io/docs/api
