SendBeam

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.

View as Markdown
Machine-readable version: /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> 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`.

ParameterInTypeRequiredDescription
pagequeryintegernoPage number, starting at 1.
limitqueryintegernoItems per page (1–100).
sortquery"created" | "name" | "email" | "status"noColumn to sort by. name is first name then last name. Every sort is stable across pages (ties fall back to newest first, then id).
dirquery"asc" | "desc"noDefaults to desc for created, asc otherwise.
qquerystringnoCase-insensitive substring match against email, first_name or last_name.
statusqueryContactStatusnoOnly contacts with this status.
tagquerystringnoOnly contacts carrying this tag (tag id).
listquerystringnoOnly contacts on this list (list id). With tag as well, a contact must satisfy both.
sourcequerystringnoOnly contacts whose source is exactly this, e.g. form, import or api.
fromquerystringnoOnly contacts added on or after this day (YYYY-MM-DD).
toquerystringnoOnly 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

FieldTypeRequiredDescription
emailstringyes
first_namestringno
last_namestringno
sourcestringnoWhere the contact came from. Defaults to api.
languagestring | nullnoThe 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_fieldsCustomFieldsnoA 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.
resubscribebooleannoSet 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

FieldTypeRequiredDescription
emailstringno
first_namestringno
last_namestringno
statusContactStatusnoarchived is out of the working list (hidden from listings unless asked for, never mailed, not counted) but not blocked.
custom_fieldsobjectnoMerged 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.
sourcestringno
languagestring | nullnoISO 639-1 code; null or an empty string clears it. An unknown code is a 400.
replace_custom_fieldsbooleannoWith 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.
resubscribebooleannoSet 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.
suppressbooleannoWith 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`.

ParameterInTypeRequiredDescription
modequery"delete" | "erase"noerase 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

FieldTypeRequiredDescription
tag_idstringyes

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

FieldTypeRequiredDescription
action"unsubscribe" | "archive" | "restore" | "delete" | "erase" | "add_tag" | "remove_tag" | "add_to_list" | "remove_from_list" | "export"yes
contact_idsarray of stringnoThe contacts to act on. Either this or filter.
filterobjectnoAlternative 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_idstringnoRequired for add_tag and remove_tag.
list_idstringnoRequired for add_to_list and remove_from_list.
suppressbooleannounsubscribe 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.

ParameterInTypeRequiredDescription
qquerystringnoMatch against email, first name or last name.
statusqueryContactStatusnoWithout it, archived contacts are left out.
tagquerystringnoOnly contacts carrying this tag (tag id).
listquerystringnoOnly contacts on this list (list id). With tag as well, a contact must satisfy both.
sourcequerystringnoOnly contacts whose source is exactly this, e.g. form, import or api.
fromquerystringnoOnly contacts added on or after this day (YYYY-MM-DD).
toquerystringnoOnly 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

FieldTypeRequiredDescription
filestringyesUTF-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"noStatus for rows whose status cell is blank or absent.
sourcestringnoSource label for rows without a source cell.
skip_duplicates"true" | "false"notrue 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"noWhether 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"noSkip 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.
tagsstringnoTags to attach to every contact in the file, ; or | separated (same rules as the tags column). Created when missing.
list_idstringnoAdd 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.
mappingstringnoOptional 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_fieldsstringnoOptional 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

FieldTypeRequiredDescription
keystringyes
labelstringnoDefaults to the key with underscores as spaces, capitalised.
type"text" | "number" | "boolean" | "date" | "dropdown"no
optionsarray of stringnoRequired for dropdown; ignored otherwise.
confirm_violationsbooleannoDeclare 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

FieldTypeRequiredDescription
labelstringno
type"text" | "number" | "boolean" | "date" | "dropdown"no
optionsarray of stringno
confirm_violationsbooleanno

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`.

ParameterInTypeRequiredDescription
emailquerystringnoCheck 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

FieldTypeRequiredDescription
reasonSuppressionImportReasonnoReasons an import may set (deleted is reserved for contact deletion). Aliases such as cleaned, cancelled, junk and spam are accepted and mapped.
entriesarray of string | objectyes

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`.

ParameterInTypeRequiredDescription
pagequeryintegernoPage number, starting at 1.
limitqueryintegerno
reasonquerySuppressionReasonno
domainquerystringnoCase-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`.

ParameterInTypeRequiredDescription
reasonquerySuppressionReasonno
domainquerystringno

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

FieldTypeRequiredDescription
emailstringno
email_hashstringno

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

FieldTypeRequiredDescription
source"mailchimp" | "mailerlite" | "kit" | "brevo" | "emailoctopus"yes
credentialsobjectyes

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

FieldTypeRequiredDescription
namestringyes
colorstringnoCSS 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

FieldTypeRequiredDescription
namestringno
colorstringno

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

FieldTypeRequiredDescription
namestringyes
descriptionstringno
double_optinbooleannoRequire 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

FieldTypeRequiredDescription
namestringno
descriptionstringno
double_optinbooleanno
offerable_across_accountbooleannoOffer 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`.

ParameterInTypeRequiredDescription
pagequeryintegernoPage number, starting at 1.
limitqueryintegernoItems 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

FieldTypeRequiredDescription
contact_idstringyes

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

FieldTypeRequiredDescription
contact_idstringyes

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)

FieldTypeRequiredDescription
contact_idsarray of stringnoOnly 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

FieldTypeRequiredDescription
namestringyes
rulesarray of SegmentRuleyes

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

FieldTypeRequiredDescription
namestringno
rulesarray of SegmentRuleno

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

FieldTypeRequiredDescription
rulesarray of SegmentRuleyes

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`.

ParameterInTypeRequiredDescription
pagequeryintegernoPage number, starting at 1.
limitqueryintegernoItems per page (1–100).
statusqueryCampaignStatusno

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

FieldTypeRequiredDescription
draft_keystringnoOptional 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).
languagestring | nullnoISO 639-1 code of the language the campaign's own subject and body are written in (#80). Null clears it.
languagesobject | nullnoTranslations 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_bstringnoA 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_hoursinteger | nullnoSpread the send over this many hours (see Campaign); null for the workspace default.
send_at_best_timebooleannoSend each contact at their usual open hour (see Campaign). Not with ab_test.
ab_testobjectnoA/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.
namestringyes
subjectstringnoMay be omitted or empty on a draft; required to send.
from_namestringno
from_emailstringyesStored 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_contentstringnoSupports {{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_contentstringnoThe same merge tags and conditional blocks as html_content, inserted unescaped. At most 200,000 characters.
blocksarray of BuilderBlocknoThe visual builder's block JSON, when html_content was rendered from it. Optional; null or absent means HTML only.
send_to_typeSendToTypeyes
send_to_idstring | nullnoRequired 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

FieldTypeRequiredDescription
expected_updated_atstring | nullnoThe 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.
namestringnoMay not be emptied.
subjectstringnoMay be empty on a draft, not on a scheduled campaign.
from_namestringno
from_emailstringno
html_contentstringnoAt most 500,000 characters (400 otherwise).
text_contentstringnoAt most 200,000 characters (400 otherwise).
send_to_typeSendToTypeno
send_to_idstring | nullno

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)

FieldTypeRequiredDescription
scheduled_atstringnoISO 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)

FieldTypeRequiredDescription
toarray of string | stringnoRecipients. Defaults to the signed-in person.
contact_idstring | nullnoRender the copy as this contact.
languagestring | nullnoSend 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

FieldTypeRequiredDescription
subjectstringno
htmlstringno
textstringno
contact_idstring | nullno
campaign_idstring | nullno

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

FieldTypeRequiredDescription
send_to_typeSendToTypeyes
send_to_idstring | nullnoRequired 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

FieldTypeRequiredDescription
subjectstringno
html_contentstringno
audiencestringnoA 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)

FieldTypeRequiredDescription
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)

FieldTypeRequiredDescription
audience"non_openers"noAim 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

FieldTypeRequiredDescription
briefstringyesWho 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

FieldTypeRequiredDescription
briefstringyes

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

FieldTypeRequiredDescription
recipeobjectyes
choicesobjectno
namestringno

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`.

ParameterInTypeRequiredDescription
categoryqueryTemplateCategoryno

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

FieldTypeRequiredDescription
namestringyes
subjectstringyes
html_contentstringyes
categoryTemplateCategoryno
blocksarray of BuilderBlockno

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

FieldTypeRequiredDescription
namestringyes
descriptionstringno
mjmlstringyes
slotsarray of objectno

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

FieldTypeRequiredDescription
mjmlstringyes
slotsarray of objectno
valuesobjectno

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

FieldTypeRequiredDescription
namestringno
descriptionstringno
mjmlstringno
slotsarray of objectno

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

FieldTypeRequiredDescription
namestringno
subjectstringno
html_contentstringno
categoryTemplateCategoryno

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

FieldTypeRequiredDescription
filestringyesThe 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

FieldTypeRequiredDescription
namestringno
feed_urlstringnoPublic http(s) URL of an RSS 2.0 or Atom feed.
send_to_type"all" | "list" | "segment"no
send_to_idstringno
template_idstringnoA template with the {{rss_items}} tag (the "Latest posts" block); null = built-in digest layout.
subject_templatestringnoPlaceholders: {{item_title}}, {{feed_title}}, {{item_count}}.
introstringno
frequency"immediate" | "daily" | "weekly"no
send_hourintegernoUTC.
send_weekdayintegerno0 = Sunday.
max_itemsintegerno
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

FieldTypeRequiredDescription
namestringno
feed_urlstringnoPublic http(s) URL of an RSS 2.0 or Atom feed.
send_to_type"all" | "list" | "segment"no
send_to_idstringno
template_idstringnoA template with the {{rss_items}} tag (the "Latest posts" block); null = built-in digest layout.
subject_templatestringnoPlaceholders: {{item_title}}, {{feed_title}}, {{item_count}}.
introstringno
frequency"immediate" | "daily" | "weekly"no
send_hourintegernoUTC.
send_weekdayintegerno0 = Sunday.
max_itemsintegerno
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)

FieldTypeRequiredDescription
forcebooleanno

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`.

ParameterInTypeRequiredDescription
statusqueryAutomationStatusno

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

FieldTypeRequiredDescription
namestringyes
descriptionstringno
trigger_typeTriggerTypeyes
trigger_configTriggerConfignoTrigger-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.
stepsarray of AutomationStepInputyes

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

FieldTypeRequiredDescription
namestringno
descriptionstringno
trigger_typeTriggerTypeno
trigger_configTriggerConfignoTrigger-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.
stepsarray of AutomationStepInputnoReplaces 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`.

ParameterInTypeRequiredDescription
idpathstringyesAutomation 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

FieldTypeRequiredDescription
contact_idstringno
emailstringno

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)

FieldTypeRequiredDescription
namestringnoName for the new automation. Defaults to the recipe's name.
choicesobjectnoOne 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

FieldTypeRequiredDescription
eventstringyesWhat happened, as a name you also type into the trigger. Letters, numbers, dot, dash or underscore; lower-cased on arrival.
emailstringyesWho 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

FieldTypeRequiredDescription
namestringyes
descriptionstringno

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

FieldTypeRequiredDescription
tenant_idstringyesThe 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

FieldTypeRequiredDescription
namestringyes
headlinestring | nullno
bodystring | nullno
button_labelstring | nullno
image_urlstring | nullno
indexablebooleanno
ab_testobject | nullnoA 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.
kindFormKindno
list_idstringnoSignup forms only. Must be one of this workspace's lists.
fieldsarray of stringno
thank_you_messagestringno
redirect_urlstringno
notify_emailstring | nullnoRequired for contact forms. Must be a workspace member's address or an address on one of the workspace's verified sending domains.
notify_subjectstring | nullno
allowed_originsarray | nullnoUp to 20 origins of the form scheme://host[:port]; null clears.
turnstile_site_keystring | nullno
turnstile_secretstring | nullno
daily_capintegerno

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

FieldTypeRequiredDescription
idstringyes

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

FieldTypeRequiredDescription
emailstringyes
first_namestringno
last_namestringno
namestringnoContact forms: full name (falls back to first_name + last_name).
subjectstringnoContact forms only.
messagestringnoContact forms: required.
turnstile_tokenstringnoCloudflare 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

FieldTypeRequiredDescription
contact_idstringyes
subjectstringyes
html_contentstringyes
text_contentstringnoPlain-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

FieldTypeRequiredDescription
tostring | array of string | objectyesRecipient(s): an address, "Name <address>", { email, name }, or an array of those.
ccstring | array of stringnoSame shapes as to; each address gets its own copy.
bccstring | array of stringnoSame shapes as to; each address gets its own copy.
subjectstringyes
htmlstringnoHTML body. html_content is accepted as an alias. Required unless text is given.
textstringnoPlain-text body (text_content alias). A text-only message is also rendered as simple HTML.
reply_tostringnoOne address, optionally "Name <address>".
from_namestringno
from_emailstringnoUsed only when the address is on a domain verified in this workspace; otherwise the workspace sender applies.
headersobjectnoUp 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

FieldTypeRequiredDescription
urlstringyesPublic https:// URL to POST to. Must not resolve to a private, loopback or link-local address, and must not contain credentials.
descriptionstringnoOptional note for your own reference.
event_typesarray of WebhookEventyesAt least one event. Duplicates are collapsed; an unknown name is refused and named back to you.
filtersWebhookFiltersnoOptional 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

FieldTypeRequiredDescription
urlstringno
descriptionstring | nullno
event_typesarray of WebhookEventno
filtersWebhookFiltersnoOptional 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.
enabledbooleannoSet 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`.

ParameterInTypeRequiredDescription
pagequeryintegernoPage number, starting at 1.
limitqueryintegernoItems per page (1–100).
statusqueryWebhookDeliveryStatusnoOnly deliveries in this state.
includequery"payload"noComma-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`.

ParameterInTypeRequiredDescription
typequeryWebhookEventyesThe event to list. An unknown name is refused and named back to you.
limitqueryintegernoHow 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)

FieldTypeRequiredDescription
eventanynoWhich 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

FieldTypeRequiredDescription
grantstringyesThe grant id from the redirect, 43 characters of base64url.
statestringyesThe token the site generated and sent as state, returned unchanged.
site_urlstringyesThe 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)

FieldTypeRequiredDescription
check_domainbooleannoResolve 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.

ParameterInTypeRequiredDescription
idpathstringyesThe sending domain to set up.
return_toquerystringnoWhere 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=<code> 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.

ParameterInTypeRequiredDescription
cursorquerystringnoOpaque cursor from the previous page's next_cursor. Omit for the first page.
limitqueryintegernoItems per page (1–100).
actionquerystringnoOne 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.
actorquerystringnoOnly events whose actor label starts with this: an email address or an API key name. Case-insensitive.
fromquerystringnoOnly events on or after this day (YYYY-MM-DD).
toquerystringnoOnly events on or before this day (YYYY-MM-DD; the whole day counts).
scopequery"workspace" | "account"noworkspace (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.

ParameterInTypeRequiredDescription
idpathstringyesCampaign ID.
contactquerystringnoRecipient contact ID, for a personalised view.
tokenquerystringnoThe 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`.

ParameterInTypeRequiredDescription
pollpathstringyesThe poll block's id.
optionpathintegeryesWhich 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`.

ParameterInTypeRequiredDescription
contactquerystringyesContact ID from the email link.
campaignquerystringnoCampaign ID, to attribute the unsubscribe.
tokenquerystringyesSigned 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`.

ParameterInTypeRequiredDescription
contactquerystringnoContact ID (required here or in the body).
campaignquerystringno
tokenquerystringno

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`.

ParameterInTypeRequiredDescription
tokenquerystringyes
listquerystringyes
contactquerystringyes

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

FieldTypeRequiredDescription
type"cart_abandoned" | "product_viewed" | "order_placed" | "order_refunded" | "order_cancelled"yesorder_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.
emailstringyes
namestringnoSplit on the first space into first_name/last_name; only ever fills in a name the contact does not already have.
valuenumbernoCart/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.
currencystringno
external_idstringnoThe 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_idstringnoAlias of external_id.
placed_atstringnoWhen the order was placed. Defaults to now; attribution windows are measured back from it.
sourcestringnoRecorded 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 }`.
FieldTypeRequiredDescription
jsonrpc"2.0"yes
idstring | integernoAbsent on a notification.
method"initialize" | "ping" | "tools/list" | "tools/call" | "notifications/initialized"yes
paramsobjectno

Example:

{
  "jsonrpc": "2.0",
  "id": "string",
  "method": "initialize",
  "params": {}
}
Error
FieldTypeRequiredDescription
errorstringyes

Example:

{
  "error": "Contact not found"
}
ConnectForm — A signup form the site can put on a page.
FieldTypeRequiredDescription
idstringyes
namestringyes

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.
FieldTypeRequiredDescription
typestringyes
namestringyesThe record name, fully qualified.
valuestringyesWhat it points at.
foundboolean | nullyesWhether 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`.
FieldTypeRequiredDescription
namestringyesThe host, with any leading `www.` removed.
verifiedbooleanyesTrue once every record resolves and the provider agrees. Do not route a site's email through an unverified domain.
recordsarray of ConnectDomainRecordyesThe records still to add, in the order to show them.
domain_connect_urlstring | nullyesOpen 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_atstring | nullyesWhen 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.
FieldTypeRequiredDescription
from_namestringyes
from_emailstring | nullyes

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
FieldTypeRequiredDescription
workspaceobjectyes
default_formConnectForm | nullyes
domainConnectDomain | nullyesNull without `domains:read`, and null for a key that belongs to no site. `domain_state` says which.
domain_stateConnectDomainStateyesWhy `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_notestring | nullyesOne 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`.
senderConnectSenderyesThe 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
FieldTypeRequiredDescription
idstringyesFile id (`<id>.<ext>`), unique in the workspace; what `DELETE /media/{id}` takes.
urlstringyesPublic URL to use in an email.
namestringyesThe original file name.
sizeintegeryesBytes.
type"image/png" | "image/jpeg" | "image/gif" | "image/webp"yes
uploadedstringyes

Example:

{
  "id": "string",
  "url": "string",
  "name": "string",
  "size": 1,
  "type": "image/png",
  "uploaded": "2026-09-02T12:00:00Z"
}
MediaListing
FieldTypeRequiredDescription
configuredbooleanyesFalse when image hosting is not enabled on the installation; the list is then empty and uploads answer `503`.
filesarray of MediaFileyes
countintegeryes
used_bytesintegeryesStorage the workspace is using.
quota_bytesintegeryesThe plan's cap.
max_bytesintegeryesLargest single upload accepted.
typesarray of stringno
truncatedbooleannoTrue 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.
FieldTypeRequiredDescription
list_idsarray of stringnoOnly contact.list_joined / contact.list_left for these lists (and any contact event tied to one of them).
form_idsarray of stringnoOnly form.submitted for these forms (and contacts created through one of them).
tag_idsarray of stringnoOnly contact.tag_added / contact.tag_removed for these tags.
campaign_idsarray of stringnoOnly campaign.sent, and the email.* events, for these campaigns.
domain_idsarray of stringnoOnly 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.
FieldTypeRequiredDescription
idstringyes
urlstringyes
descriptionstring | nullyesYour own note about what this endpoint is for.
event_typesarray of WebhookEventyesThe events this endpoint receives.
filtersWebhookFiltersyesOptional 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.
enabledbooleanyesA disabled endpoint receives nothing; anything queued for it while disabled is abandoned.
disabled_reasonstring | nullyesSet when SendBeam disabled the endpoint itself after sustained failure; null when you disabled it or it is enabled.
consecutive_failuresintegeryesFailed deliveries in a row. Reset to 0 by the next success, and by re-enabling an auto-disabled endpoint.
last_success_atstring | nullyes
last_failure_atstring | nullyes
created_atstringyes

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
FieldTypeRequiredDescription
urlstringyesPublic `https://` URL to POST to. Must not resolve to a private, loopback or link-local address, and must not contain credentials.
descriptionstringnoOptional note for your own reference.
event_typesarray of WebhookEventyesAt least one event. Duplicates are collapsed; an unknown name is refused and named back to you.
filtersWebhookFiltersnoOptional 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.
FieldTypeRequiredDescription
urlstringno
descriptionstring | nullno
event_typesarray of WebhookEventno
filtersWebhookFiltersnoOptional 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.
enabledbooleannoSet 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.
FieldTypeRequiredDescription
idstringyes
urlstringyes
descriptionstring | nullyes
event_typesarray of WebhookEventyes
filtersWebhookFiltersyesOptional 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.
enabledbooleanyes
secretstringyes64 hexadecimal characters. Store it now — it is never shown again, only replaced.
created_atstringyes

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
FieldTypeRequiredDescription
idstringyes
secretstringyesThe 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`.
FieldTypeRequiredDescription
idstringyesAlso the `id` field inside the payload and the `X-SendBeam-Delivery` header, stable across retries — use it to deduplicate.
event_typeWebhookEventyesOne 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.
statusWebhookDeliveryStatusyes`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.
attemptsintegeryesHow many times it has been POSTed so far.
last_status_codeinteger | nullyesHTTP status of the most recent attempt, null if the request never got a response.
last_errorstring | nullyesWhy the most recent attempt failed, truncated to 500 characters.
delivered_atstring | nullyes
created_atstringyes
payloadobjectnoThe 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
FieldTypeRequiredDescription
eventanynoWhich event name to put in the test payload. Defaults to `contact.created`.

Example:

{
  "event": null
}
WebhookTestResult
FieldTypeRequiredDescription
okbooleanyesTrue when the endpoint answered with a 2xx.
statusintegernoThe HTTP status the endpoint returned, when it returned one.
errorstringnoWhy 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
FieldTypeRequiredDescription
source"mailchimp" | "mailerlite" | "kit" | "brevo" | "emailoctopus"yes
credentialsobjectyes

Example:

{
  "source": "mailchimp",
  "credentials": {
    "api_key": "string",
    "dc": "string"
  }
}
ImportSourceList
FieldTypeRequiredDescription
idstringyesAudience / group / tag / list id; `""` is the "All subscribers" entry (MailerLite, Kit).
namestringyes
member_countintegeryesSubscribed members as reported by the platform; null when it reports none (Kit tags).
suppressed_countintegernoUnsubscribed + 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
FieldTypeRequiredDescription
platformstringyes
labelstringyes
providesobjectyesKeys: subscribed_at, consent_ip, consent_timestamp, consent_source, bounces, complaints, unsubscribes, tags, custom_fields.
notesarray of stringyes
limitationsarray of stringyes

Example:

{
  "platform": "string",
  "label": "string",
  "provides": {},
  "notes": [
    "string"
  ],
  "limitations": [
    "string"
  ]
}
ImportConnectResult
FieldTypeRequiredDescription
okbooleanyes
sourcestringyes
labelstringyes
accountobjectyes
listsarray of ImportSourceListyes
requires_listbooleanyesTrue when `list_ids` must be given to `/run` (Mailchimp, EmailOctopus).
capabilitiesImportCapabilitiesyes
subrequestsintegernoCalls 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
FieldTypeRequiredDescription
okbooleanyes
sourcestringyes
importedintegeryesNew contacts created.
updatedintegeryesExisting contacts changed (fields merged, status escalated).
skipped_duplicatesintegeryesRecords whose address already existed.
suppressedintegeryesOpted-out addresses recorded on the suppression list this run (new, upgraded or already there).
skipped_unconfirmedintegeryesPending / unconfirmed / inactive people — never imported.
skipped_undeliverableintegernoRecords 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_domainsarray of objectnoThose domains, most records first, at most ten.
skipped_placeholderintegernoOf `skipped_undeliverable`, records that were placeholder addresses (example domains, reserved test domains, `abuse@` / `postmaster@`).
placeholder_addressesPlaceholderAddressesnoPlaceholder 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_otherintegeryesRecords the platform marks as archived or transactional.
skipped_suppressedintegeryesOpted-out records skipped because `include_unsubscribed` was `false`.
invalidintegeryesRecords without a usable email address.
tags_createdintegeryes
tags_attachedintegeryes
tags_skippedbooleanyesAlways `false` (tags now arrive inline from every platform); kept for compatibility.
tags_truncatedintegernoMailchimp 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_keysarray of stringyes
list_addedintegeryes
list_pending_confirmationintegeryes
contacts_readintegeryesRecords read from the platform this run.
contacts_writtenintegeryesRecords handed to the importer this run (imported + updated + duplicates).
pagesintegeryesSource pages written.
subrequestsintegeryesRequests made to the platform.
db_requestsintegeryesEstimated database round-trips.
duration_msintegeryes
truncatedbooleanyesTrue when the run stopped before the source was exhausted; continue with `next_hint.cursor`.
stop_reason"max_contacts" | "subrequests" | "time" | "contact_limit" | nullyes
next_hintobjectyes
capabilitiesImportCapabilitiesyes

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
FieldTypeRequiredDescription
pageintegeryes
limitintegeryes
totalintegeryesTotal items across all pages.
total_pagesintegeryes

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
FieldTypeRequiredDescription
idstringyes
created_atstringyes
actionstringyesOne 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_idstring | nullnoThe user id or API key id, null for SCIM and the system.
actor_labelstring | nullnoThe actor's email address, the API key's name, `SCIM` or `System`.
target_typestring | nullnoWhat was acted on: `api_key`, `user`, `invitation`, `webhook`, `domain`, `connection`, `tenant`, `account`…
target_idstring | nullno
target_labelstring | nullnoThe target as a person would name it: a key name, an email address, a URL, a domain.
workspace_idstring | nullnoNull for an account-level event (policy, SCIM, erasure).
workspace_namestring | nullno
metadataobjectyesDetails specific to the action, credentials never included: a role, a permission list, a before/after pair, a sign-in method.
ipstring | nullnoThe 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
FieldTypeRequiredDescription
email_hashstringyesSHA-256 hex of the normalised address — the key for `POST /api/v1/suppressions/lift`.
reasonSuppressionReasonyesWhy an address is suppressed. Strength order: complained > bounced > unsubscribed > deleted.
sourceSuppressionSource | nullyes
created_atstringyes
email_maskedstring | nullyes`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_domainstring | nullyes
contactobject | nullyesThe contact row that still carries this address, when there is one — the only place the full address appears.
liftablebooleanyesTrue 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
FieldTypeRequiredDescription
reasonSuppressionImportReasonnoReasons an import may set (`deleted` is reserved for contact deletion). Aliases such as `cleaned`, `cancelled`, `junk` and `spam` are accepted and mapped.
entriesarray of string | objectyes

Example:

{
  "reason": "unsubscribed",
  "entries": [
    "jane@example.com"
  ]
}
SuppressionImportResult
FieldTypeRequiredDescription
receivedintegeryesRows or entries in the request. Equals added + upgraded + unchanged + invalid.
addedintegeryesAddresses that were not on the list before.
upgradedintegeryesAddresses already on the list whose reason became stronger.
unchangedintegeryesAlready on the list with the same or a stronger reason, plus repeats within the request.
invalidintegeryesRows skipped: malformed address, over 254 characters, or an unknown reason.
contacts_updatedintegeryesExisting contacts switched from `subscribed` to the imported status.
by_reasonobjectyesValid, 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
FieldTypeRequiredDescription
countintegeryesAddresses on the list, all reasons.
by_reasonobjectyes

Example:

{
  "count": 1204,
  "by_reason": {
    "unsubscribed": 1130,
    "bounced": 61,
    "complained": 9,
    "deleted": 4
  }
}
SuppressionCheck
FieldTypeRequiredDescription
emailstringyesThe address as normalised (trimmed, lower-cased).
suppressedbooleanyes
reasonSuppressionReasonnoWhy an address is suppressed. Strength order: complained > bounced > unsubscribed > deleted.
created_atstringnoWhen 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
FieldTypeRequiredDescription
idstringno
keystringyesImmutable. What `{{custom_fields.<key>}}`, `custom_fields.<key>` in rules, CSV headers and API payloads use.
labelstringyesWhat the app shows; freely editable.
type"text" | "number" | "boolean" | "date" | "dropdown"yes
optionsarray of stringyesDropdown choices, in order; empty for other types.
positionintegerno
usesinteger | nullnoContacts carrying the key (list endpoint only; null when usage counts are unavailable).
created_atstringno
updated_atstringno

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
FieldTypeRequiredDescription
keystringyes
labelstringnoDefaults to the key with underscores as spaces, capitalised.
type"text" | "number" | "boolean" | "date" | "dropdown"no
optionsarray of stringnoRequired for `dropdown`; ignored otherwise.
confirm_violationsbooleannoDeclare 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
FieldTypeRequiredDescription
countintegernoContacts whose stored value does not fit.
examplesarray of stringnoUp to five offending values.
partialbooleannoTrue 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
FieldTypeRequiredDescription
idstringyes
tenant_idstringyesWorkspace ID.
emailstringyes
first_namestringyes
last_namestringyes
statusContactStatusyes`archived` is out of the working list (hidden from listings unless asked for, never mailed, not counted) but not blocked.
custom_fieldsCustomFieldsyesA 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.
sourcestringyese.g. `api`, `import`, `form`, or whatever was supplied at creation.
languagestring | nullnoISO 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_atstring | nullyes
unsubscribed_atstring | nullyes
archived_atstring | nullnoSet while `status` is `archived`. Archiving never writes to the suppression list, so the address stays re-addable.
archived_fromstring | nullnoThe status the contact held before it was archived, restored on un-archive. A subscribed contact whose address was blocked meanwhile comes back unsubscribed.
created_atstringyes

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
FieldTypeRequiredDescription
idstringyes
emailstringyes
first_namestringyes
last_namestringyes

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
FieldTypeRequiredDescription
audienceobjectyes
can_sendbooleanyes
reasonstringnoPresent when `can_send` is false: the error a send would return.
placeholdersarray of objectyesRecipients that can never receive mail, sorted by address, at most twenty named.
placeholder_countintegeryesEvery 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.
FieldTypeRequiredDescription
emailstringno
first_namestringno
last_namestringno
statusContactStatusno`archived` is out of the working list (hidden from listings unless asked for, never mailed, not counted) but not blocked.
custom_fieldsobjectnoMerged 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.
sourcestringno
languagestring | nullnoISO 639-1 code; `null` or an empty string clears it. An unknown code is a 400.
replace_custom_fieldsbooleannoWith `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.
resubscribebooleannoSet `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`.
suppressbooleannoWith `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
FieldTypeRequiredDescription
idstringyes
namestringyes
colorstringyes
assigned_atstringyes

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
FieldTypeRequiredDescription
idstringyes
tenant_idstringyes
namestringyes
colorstringyes
created_atstringyes

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
FieldTypeRequiredDescription
contact_idstringyes
tag_idstringyes
created_atstringyes

Example:

{
  "contact_id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
  "tag_id": "5b1c1f2e-8d3a-4c0b-9e7f-2a6d4c8b1e33",
  "created_at": "2026-09-01T10:05:00.000Z"
}
List
FieldTypeRequiredDescription
idstringyes
tenant_idstringyes
namestringyes
descriptionstringyes
double_optinbooleanyesWhen true, new members must confirm by email before campaigns reach them.
offerable_across_accountbooleannoWhen 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_atstringyes

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.
FieldTypeRequiredDescription
list_idstringyes
contact_idstringyes
created_atstringyes
confirmedbooleannoDouble 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
FieldTypeRequiredDescription
list_contactListContactyesA 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_sentbooleanyesTrue 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_memberbooleanyesTrue 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.
FieldTypeRequiredDescription
fieldstringyes
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
valuestringyesRequired (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
FieldTypeRequiredDescription
idstringyes
tenant_idstringyes
namestringyes
rulesarray of SegmentRuleyes
created_atstringyes

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
FieldTypeRequiredDescription
keystringyesThe item's guid/id, else its link.
titlestringyes
linkstringyeshttp(s) only; empty otherwise.
summarystringyesPlain text, at most 320 characters.
datestringyes

Example:

{
  "key": "string",
  "title": "string",
  "link": "string",
  "summary": "string",
  "date": "2026-09-02T12:00:00Z"
}
RssFeedInput
FieldTypeRequiredDescription
namestringno
feed_urlstringnoPublic http(s) URL of an RSS 2.0 or Atom feed.
send_to_type"all" | "list" | "segment"no
send_to_idstringno
template_idstringnoA template with the `{{rss_items}}` tag (the "Latest posts" block); null = built-in digest layout.
subject_templatestringnoPlaceholders: `{{item_title}}`, `{{feed_title}}`, `{{item_count}}`.
introstringno
frequency"immediate" | "daily" | "weekly"no
send_hourintegernoUTC.
send_weekdayintegerno0 = Sunday.
max_itemsintegerno
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.
FieldTypeRequiredDescription
total_itemsintegeryesPosts in the feed right now.
newest_titlestringyesTitle of the newest post, or null for an empty feed.

Example:

{
  "total_items": 12,
  "newest_title": "What shipped in September"
}
Share
FieldTypeRequiredDescription
namestringyes
countintegeryes

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.
FieldTypeRequiredDescription
idstringyesThe poll block's id, as it appears in the answer links (`/p/{id}/{option}`).
questionstringyesThe block's question; empty when the campaign has no block JSON for it.
answersintegeryesRecipients who answered, each counted once.
optionsarray of objectyes

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.
FieldTypeRequiredDescription
subjectstringyes
html_contentstringyes
text_contentstringno
blocksarray of BuilderBlockno

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.
FieldTypeRequiredDescription
sentintegeryes
openedintegeryes
clickedintegeryes
revenuenumbernoIn `currency`; never converted.
ordersintegerno
currencystringno

Example:

{
  "sent": 1,
  "opened": 1,
  "clicked": 1,
  "revenue": 1,
  "orders": 1,
  "currency": "GBP"
}
Campaign
FieldTypeRequiredDescription
subject_bstringnoVersion 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_testobjectnoA/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`.
idstringyes
tenant_idstringyes
namestringyes
subjectstringyes
from_namestringyes
from_emailstringyes
status_reasonstring | nullnoWhy 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_hoursinteger | nullnoSpread 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_timebooleannoSend 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_idstring | nullnoThe RSS feed that generated this campaign, when it was created by a feed rather than by hand.
html_contentstringyes
text_contentstringyes
languagestring | nullnoISO 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.
languagesobject | nullnoTranslations 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.
blocksarray of BuilderBlocknoThe 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.
statusCampaignStatusyes
send_to_typeSendToTypeyes
send_to_idstring | nullyesList or segment ID when `send_to_type` is `list`/`segment`.
scheduled_atstring | nullyes
sent_atstring | nullyes
stats_sentintegeryes
stats_deliveredintegeryes
stats_openedintegeryes
stats_clickedintegeryes
stats_bouncedintegeryes
stats_unsubscribedintegeryes
created_atstringyes
updated_atstringnoLast write. Send it back as `expected_updated_at` on update to detect a concurrent edit.
draft_keystring | nullnoThe 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
FieldTypeRequiredDescription
draft_keystringnoOptional 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`).
languagestring | nullnoISO 639-1 code of the language the campaign's own subject and body are written in (#80). Null clears it.
languagesobject | nullnoTranslations 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_bstringnoA 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_hoursinteger | nullnoSpread the send over this many hours (see Campaign); null for the workspace default.
send_at_best_timebooleannoSend each contact at their usual open hour (see Campaign). Not with `ab_test`.
ab_testobjectnoA/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.
namestringyes
subjectstringnoMay be omitted or empty on a draft; required to send.
from_namestringno
from_emailstringyesStored 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_contentstringnoSupports `{{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_contentstringnoThe same merge tags and conditional blocks as `html_content`, inserted unescaped. At most 200,000 characters.
blocksarray of BuilderBlocknoThe visual builder's block JSON, when `html_content` was rendered from it. Optional; `null` or absent means HTML only.
send_to_typeSendToTypeyes
send_to_idstring | nullnoRequired 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
FieldTypeRequiredDescription
expected_updated_atstring | nullnoThe `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.
namestringnoMay not be emptied.
subjectstringnoMay be empty on a draft, not on a scheduled campaign.
from_namestringno
from_emailstringno
html_contentstringnoAt most 500,000 characters (`400` otherwise).
text_contentstringnoAt most 200,000 characters (`400` otherwise).
send_to_typeSendToTypeno
send_to_idstring | nullno

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).
FieldTypeRequiredDescription
keystringyes
type"text" | "url" | "image"yes
labelstringyes
defaultstringyes

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`.
FieldTypeRequiredDescription
idstringyes
tenant_idstringyes
namestringyes
descriptionstringyes
mjmlstringyesThe source, up to 100,000 characters.
htmlstringyes
head_htmlstringyes
slotsarray of CustomBlockSlotyes
created_atstringyes
updated_atstringyes

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.
FieldTypeRequiredDescription
idstringno
type"header" | "text" | "image" | "button" | "divider" | "spacer" | "columns" | "social" | "footer" | "rss" | "html" | "video" | "feature" | "quote" | "list" | "poll" | "custom"yes
propsobjectyesFor 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
FieldTypeRequiredDescription
idstringyes
tenant_idstringyes
namestringyes
subjectstringyes
html_contentstringyes
categoryTemplateCategoryyes
blocksarray of BuilderBlocknoThe 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_atstringyes

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
FieldTypeRequiredDescription
codestringyes
messagestringyes
step_idstringnoThe 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.
FieldTypeRequiredDescription
type"field" | "email_activity"noDefaults to `field`.
fieldstringno
operator"equals" | "not_equals" | "contains" | "not_contains" | "is_empty" | "is_not_empty" | "starts_with" | "ends_with" | "greater_than" | "less_than"no
valuestringno
activity"opened" | "not_opened" | "clicked" | "not_clicked"no
step_idstringnoThe 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.
FieldTypeRequiredDescription
typeTriggerTypeyes
configTriggerConfignoTrigger-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`.
filtersarray of ConditionRulenoA 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`.
FieldTypeRequiredDescription
tag_idstringno
list_idstringno
form_idstringno
automation_idstringno
fieldstringno
date_fieldstringno
offset_daysintegerno
direction"before" | "on" | "after"no
event_namestringno`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_placeholderstringnoWritten 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.
FieldTypeRequiredDescription
template_idstringno
subjectstringno
html_contentstringno
text_contentstringno
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_minutesintegerno
duration_valueintegerno
duration_unit"minutes" | "hours" | "days"no
timestringno
weekdaysarray of integerno
day_of_monthintegerno
timezonestringno
optimisedbooleanno
match"all" | "any" | "none"no
rulesarray of ConditionRuleno
fieldstringno
operatorstringno
valuestringno
tag_idstringno
list_idstringno
automation_idstringno
subject_bstringno`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.
branchesarray of objectno`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.
FieldTypeRequiredDescription
idstringnoExisting step uuid to keep, or a client-side key other steps can reference.
typeStepTypeyes
step_orderintegernoOrders siblings for display. Defaults to the array index. Does not drive execution.
configStepConfignoStep 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_idstringnoLayout 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_idstringnoThe 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_idstringnocondition and split steps: the step taken when a condition passes, or the A share of a split. Omit to end that path.
no_step_idstringnocondition 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
FieldTypeRequiredDescription
idstringyes
automation_idstringyes
step_orderintegeryes
typeStepTypeyes
configStepConfigyesStep 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_idstringno
next_step_idstringno
yes_step_idstringno
no_step_idstringno
created_atstringyes

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
FieldTypeRequiredDescription
idstringyes
tenant_idstringyes
namestringyes
descriptionstringyes
trigger_typeTriggerTypeyes
trigger_configTriggerConfigyesTrigger-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`.
triggersarray of AutomationTriggerInputnoEvery way into this automation. trigger_type/trigger_config mirror the first entry.
repeat_enabledbooleannoWhether 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_hoursintegernoMinimum 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_brokenbooleannoDerived: the trigger names a list, form or tag that no longer exists, so the automation can never fire.
last_errorstring | nullnoThe most recent failure while running this automation, as shown on its health panel. Free text — read it, do not match on it.
last_error_atstring | nullnoWhen `last_error` was recorded.
error_countintegernoFailures since the automation was last activated. Resets on activation.
statusAutomationStatusyes
created_atstringyes

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
FieldTypeRequiredDescription
namestringyes
descriptionstringno
trigger_typeTriggerTypeyes
trigger_configTriggerConfignoTrigger-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`.
stepsarray of AutomationStepInputyes

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
FieldTypeRequiredDescription
slugstringyes
namestringyes
taglinestringyes
descriptionstringyes
outlineobjectyesWhat the recipe does, in words, with placeholders named by their label.
placeholdersarray of objectyes

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
FieldTypeRequiredDescription
namestringnoName for the new automation. Defaults to the recipe's name.
choicesobjectnoOne entry per placeholder id.

Example:

{
  "name": "Newsletter welcome",
  "choices": {
    "list": {
      "existing": "9c1e2d3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f"
    },
    "welcomed": {
      "create": "Welcomed"
    }
  }
}
WorkspaceBlueprintCreate
FieldTypeRequiredDescription
namestringyes
descriptionstringno

Example:

{
  "name": "Agency starter kit",
  "description": "Custom fields, welcome automation and templates every new client site starts with."
}
WorkspaceBlueprintSummary
FieldTypeRequiredDescription
idstringyes
account_idstringyes
source_tenant_idstringnoThe workspace it was captured from — null once that workspace has been deleted (the blueprint itself still applies, minus any hosted images).
created_bystringno
namestringyes
descriptionstringyes
created_atstringyes
updated_atstringyes
countsobjectyes

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
FieldTypeRequiredDescription
custom_fieldsobjectyes
templatesobjectyes
automationsobjectyes

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
FieldTypeRequiredDescription
namestringno
descriptionstringno
trigger_typeTriggerTypeno
trigger_configTriggerConfignoTrigger-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`.
stepsarray of AutomationStepInputnoReplaces all existing steps.

Example:

{
  "name": "Welcome series v2"
}
FormKind
{
  "type": "string",
  "enum": [
    "signup",
    "contact"
  ]
}

Example:

"signup"
Form
FieldTypeRequiredDescription
idstringyes
tenant_idstringyes
namestringyes
list_idstring | nullyesList that signups join. Always null for contact forms.
fieldsarray of stringyesField 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_messagestringyes
redirect_urlstring | nullyes
status"active" | "inactive"yes
kindFormKindno
notify_emailstring | nullnoOwner address that receives contact-form messages or signup notifications.
notify_subjectstring | nullnoSubject prefix for owner notifications (defaults to the form name).
allowed_originsarray of stringnoOrigins (`https://example.com`) allowed to submit. Empty = any.
turnstile_site_keystring | nullno
turnstile_secretstring | nullnoAlways masked as `••••••••` when set.
daily_capintegernoMaximum submissions per 24 hours. Set and shown on the form.
created_atstringyes
viewsintegernoTimes 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.
submissionsintegernoSubmissions accepted since counting began. Attempts dropped by the automated-submission checks are not counted. Returned by the GET operations.
headlinestring | nullnoThe hosted page's own headline; null = the default. A heading passed in an embed or pop-up URL still wins.
bodystring | nullnoThe line of copy under the headline on the hosted page.
button_labelstring | nullnoThe submit button's label; null = Subscribe (Send message for a contact form).
ab_testobject | nullnoA 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_urlstring | nullnoAn https image shown above the form on its hosted page and used as the share image.
indexablebooleannoWhether search engines may index the hosted page. False unless turned on; embeds and pop-ups are never indexable.
versionsobjectnoViews, 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_ratenumber | nullno`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
FieldTypeRequiredDescription
viewsintegeryes
submissionsintegeryes
conversion_ratenumber | nullyes

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.
FieldTypeRequiredDescription
headingstringno
substringno
buttonstringno
thank_you_messagestringno
accentstringnoA 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.
FieldTypeRequiredDescription
status"testing" | "decided"no
started_atstringno
decided_atstringno
winner"a" | "b"no
bFormVersionBnoWhat 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
FieldTypeRequiredDescription
namestringyes
headlinestring | nullno
bodystring | nullno
button_labelstring | nullno
image_urlstring | nullno
indexablebooleanno
ab_testobject | nullnoA 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.
kindFormKindno
list_idstringnoSignup forms only. Must be one of this workspace's lists.
fieldsarray of stringno
thank_you_messagestringno
redirect_urlstringno
notify_emailstring | nullnoRequired for contact forms. Must be a workspace member's address or an address on one of the workspace's verified sending domains.
notify_subjectstring | nullno
allowed_originsarray | nullnoUp to 20 origins of the form `scheme://host[:port]`; null clears.
turnstile_site_keystring | nullno
turnstile_secretstring | nullno
daily_capintegerno

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`).
FieldTypeRequiredDescription
emailstringyes
first_namestringno
last_namestringno
namestringnoContact forms: full name (falls back to first_name + last_name).
subjectstringnoContact forms only.
messagestringnoContact forms: required.
turnstile_tokenstringnoCloudflare 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
FieldTypeRequiredDescription
successbooleanyes
messagestringyesThe form's thank-you message.
redirect_urlstring | nullyes

Example:

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

Stuck, or found a gap? Ask in the community — questions, tips and every release note, with this page as the source of truth.