Mail docs
API reference

Mail API.

Send transactional email on your own domain. Every endpoint, request, and response — generated from the live OpenAPI spec.

Base URL

https://api.mail.productcraft.co

Auth

Authorization: Bearer …

Spec version

OpenAPI 3.0.0 · v0.1.0

Brand

get/v1/workspaces/{workspace_id}/brandAuth

Get the resolved brand tokens for the workspace. Always returns a complete object, with platform defaults filling in any unset fields.

Path parameters

workspace_id*string · uuid

Response · 200

resolved*object

Fully-resolved tokens every render uses. The frontend reads this for previews so what the editor shows == what recipients get.

raw*object

User-set values only (null when the workspace has never customised). Null fields signal "not customised — falling through to default".

Example

Request

GET /v1/workspaces/{workspace_id}/brand
Authorization: Bearer YOUR_TOKEN

Response

{
  "resolved": {},
  "raw": {}
}
put/v1/workspaces/{workspace_id}/brandAuth

Replace the workspace brand. Any field omitted from the body is reset to the platform default for that field. To partially-update, send back what GET returned with the desired diff applied.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

logo_urlstring · uri

Public URL of your logo. Recommended: PNG/SVG, ≤200px tall, hosted on a domain you control or any CDN reachable at send time.

brand_namestring

Display name shown in the email header (falls back to your workspace display name if not set).

primary_colorstring

Primary brand color. Used for buttons, links, and accent strokes. Hex string (e.g. #2d29d7).

secondary_colorstring

Secondary brand color, typically darker than primary. Used for hover states + secondary accents.

text_colorstring

Body text color (default: near-black).

muted_text_colorstring

Muted text color for footers + secondary copy.

background_colorstring

Page background — the area outside the email card.

card_background_colorstring

Email card background.

font_stackstring

CSS font-family stack. Email clients can't use webfonts reliably — list system fonts in fallback order.

button_radiusstring

CSS length value for button border-radius (e.g. "6px", "0", "9999px" for fully round).

container_max_widthstring

Maximum width of the email card. Bulletproof tables max-out at this.

footer_htmlstring

Custom HTML to render in the email footer. Inline styles only — no <style> tags. Used by templates that opt into `{{> bp.footer}}`.

footer_addressstring

Postal address line for compliance footers (CAN-SPAM, etc.). Plain text, displayed verbatim.

social_linksobject

Map of social-platform → URL. Recognized keys: twitter, x, linkedin, github, instagram, facebook, youtube, tiktok, mastodon, bluesky, web. Keys are stored and returned exactly as sent.

extrasobject

Extra key/value tokens addressable as {{brand.extras.<key>}} in templates. Use for marketing strings, secondary accent colors, etc. Keys are stored and returned exactly as sent (`hero_title` stays `hero_title`, `heroTitle` stays `heroTitle`), so `<key>` is the key you sent.

Example: {"hero_title":"Spring sale"}

Response · 200

resolved*object

Fully-resolved tokens every render uses. The frontend reads this for previews so what the editor shows == what recipients get.

raw*object

User-set values only (null when the workspace has never customised). Null fields signal "not customised — falling through to default".

Example

Request

PUT /v1/workspaces/{workspace_id}/brand
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "logo_url": "string",
  "brand_name": "string",
  "primary_color": "string",
  "secondary_color": "string",
  "text_color": "string",
  "muted_text_color": "string",
  "background_color": "string",
  "card_background_color": "string",
  "font_stack": "string",
  "button_radius": "string",
  "container_max_width": "string",
  "footer_html": "string",
  "footer_address": "string",
  "social_links": {},
  "extras": {
    "hero_title": "Spring sale"
  }
}

Response

{
  "resolved": {},
  "raw": {}
}
delete/v1/workspaces/{workspace_id}/brandAuth

Remove all brand customizations and revert to platform defaults.

Path parameters

workspace_id*string · uuid

Response · 204

Brand customisations cleared.


Deliverability

get/v1/workspaces/{workspace_id}/deliverabilityAuth

Deliverability verdict for the workspace over a lookback window. `unconfirmed` means mail was handed off and nothing came back either way — it is not a synonym for healthy.

Path parameters

workspace_id*string · uuid

Query parameters

window_daysnumber

Lookback in days, 1–90. Defaults to 14 — the length of the outage in tasks/mail-api/002.

Response · 200

verdict*enum (5)

Overall verdict. `unconfirmed` is NOT a synonym for healthy: it means mail was handed off and nothing came back in either direction, which is the shape a blackholed sender makes. `healthy` requires positive recipient-side evidence.

Example: "unconfirmed"

window_days*number

Lookback used, in days.

Example: 14

left_queue*number

Messages handed off for delivery in the window.

Example: 80

queued*number

Messages still waiting to be handed off.

Example: 0

confirmed*number

Messages with recipient-side evidence of arrival. Zero across the estate today — nothing emits that evidence yet, and reporting zero is the honest answer rather than assuming success.

Example: 0

negative*number

bounced + complained + failed + deferred.

Example: 7

deferred*number

Messages currently deferred.

Example: 235

bounced*number

Messages hard-bounced.

Example: 7

negative_rate*number

negative / left_queue.

Example: 0.0875

confirmed_rate*number

confirmed / left_queue.

Example: 0

block_signals*array

Diagnostics that matched a known reputation-block signature.

summary*string

One actionable sentence.

Example: "80 message(s) were handed off and none has been confirmed as arriving."

domains*array

Each sending domain with its DMARC grade. Gmail and Yahoo require DMARC from bulk senders, so a domain with `dmarc.location = none` is a deliverability risk regardless of the verdict.

Example

Request

GET /v1/workspaces/{workspace_id}/deliverability
Authorization: Bearer YOUR_TOKEN

Response

{
  "verdict": "unconfirmed",
  "window_days": 14,
  "left_queue": 80,
  "queued": 0,
  "confirmed": 0,
  "negative": 7,
  "deferred": 235,
  "bounced": 7,
  "negative_rate": 0.0875,
  "confirmed_rate": 0,
  "block_signals": [
    {
      "detail": "554 5.7.1 Blocked using Proofpoint Dynamic Reputation",
      "count": 37
    }
  ],
  "summary": "80 message(s) were handed off and none has been confirmed as arriving.",
  "domains": [
    {
      "fqdn": "mail.example.com",
      "status": "pending",
      "dmarc": {}
    }
  ]
}

Outbound-Messages

get/v1/workspaces/{workspace_id}/messagesAuth

List outbound messages with cursor pagination

One row per recipient delivery, newest first. Filters combine with AND: `q` (free text over recipient, from, subject, message id and Message-ID), `status` (any of, repeatable), `domain_id`, `api_key_id`, `source`, `template`, `created_after` / `created_before` (at most 30 days apart), plus the legacy `recipient` and `subject_contains`. `GET /messages/export` takes exactly the same filters and returns the same rows.

Path parameters

workspace_id*string · uuid

Query parameters

qstring

Free-text search, case-insensitive. Matches a substring of the recipient, the from address or the subject, or exactly a message id (uuid) or a Message-ID (the provider message id, with or without its `<…@…>` domain).

statusarray

Only messages in any of these statuses. Repeat the parameter (`status=bounced&status=complained`), use the bracket form (`status[]=bounced&status[]=complained`) or comma-separate the values (`status=bounced,complained`); a single value is the original single-status filter.

domain_idstring · uuid

Only messages sent from this domain (`domain.id`).

api_key_idstring · uuid

Only messages sent with this Platform API Key. Recorded from 2026-10-12; older messages have no key.

sourceenum (4)

How the message was sent: `api_key` (a Platform API Key), `dashboard` (a signed-in person, in the console or through MCP), `platform` (in-cluster product mail), `test_send` (a template test send). Recorded from 2026-10-12; older messages all read `platform`.

templatestring

Filter by template name.

created_afterstring · date-time

Only messages created at or after this instant. With `created_before`, the window may span at most 30 days (the log retention).

created_beforestring · date-time

Only messages created before this instant. Without `created_after`, the window is the 30 days before it.

recipientstring

Legacy: filter by exact recipient address. Prefer `q`.

subject_containsstring

Legacy: substring filter on subject (case-insensitive, max 200 chars). Prefer `q`.

limitnumber

Page size, 1–100

cursorstring

Opaque cursor from the previous page's `pagination.next_cursor`. Omit for the first page.

Response · 200

data*array
pagination*object
has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

next_cursor*string

Opaque cursor for the next page; null when no more pages.

Example

Request

GET /v1/workspaces/{workspace_id}/messages
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "workspace_id": "00000000-0000-0000-0000-000000000000",
      "domain_id": "00000000-0000-0000-0000-000000000000",
      "api_key_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "source": "api_key",
      "from_address": "string",
      "to_address": "string",
      "subject": "string",
      "template_name": "string",
      "status": "queued",
      "smtp_transcript": "string",
      "spf_result": "string",
      "dkim_result": "string",
      "dmarc_result": "string",
      "bounce_code": "string",
      "bounce_detail": "string",
      "bounce_type": "Permanent",
      "bounce_subtype": "General",
      "transport": "ses",
      "created_at": "2026-01-01T00:00:00.000Z",
      "sent_at": "2026-01-01T00:00:00.000Z",
      "delivered_at": "2026-01-01T00:00:00.000Z",
      "deferred_at": "2026-01-01T00:00:00.000Z",
      "deferral_count": 0,
      "email_id": "00000000-0000-0000-0000-000000000000",
      "cc": [
        "string"
      ],
      "bcc": [
        "string"
      ],
      "reply_to": [
        "string"
      ],
      "tags": [
        {
          "name": "category",
          "value": "confirm_email"
        }
      ]
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": "string"
  }
}
delete/v1/workspaces/{workspace_id}/messagesAuth

RTBF: delete every message log row for `recipient` (case-insensitive). Returns the deleted-row count.

Path parameters

workspace_id*string · uuid

Query parameters

recipient*string

Response · 200

deleted*number

Number of rows deleted.

Example

Request

DELETE /v1/workspaces/{workspace_id}/messages
Authorization: Bearer YOUR_TOKEN

Response

{
  "deleted": 0
}
get/v1/workspaces/{workspace_id}/messages/exportAuth

Stream the filtered message log as CSV. Same filters as GET /messages. Body columns are intentionally omitted; bodies remain gated by `mail.message.body.read`. Rate-limited 1 concurrent + 5/hour per workspace.

Columns: id, created_at, sent_at, delivered_at, deferred_at, deferral_count, from_address, to_address, subject, template_name, status, spf_result, dkim_result, dmarc_result, bounce_code, bounce_detail, api_key_id, source, domain. The filters are resolved by the same code as `GET /messages`, so the same query string exports exactly the rows the list pages through. A rejected filter (400) does not count against the hourly cap.

Path parameters

workspace_id*string · uuid

Query parameters

qstring

Free-text search, case-insensitive. Matches a substring of the recipient, the from address or the subject, or exactly a message id (uuid) or a Message-ID (the provider message id, with or without its `<…@…>` domain).

statusarray

Only messages in any of these statuses. Repeat the parameter (`status=bounced&status=complained`), use the bracket form (`status[]=bounced&status[]=complained`) or comma-separate the values (`status=bounced,complained`); a single value is the original single-status filter.

domain_idstring · uuid

Only messages sent from this domain (`domain.id`).

api_key_idstring · uuid

Only messages sent with this Platform API Key. Recorded from 2026-10-12; older messages have no key.

sourceenum (4)

How the message was sent: `api_key` (a Platform API Key), `dashboard` (a signed-in person, in the console or through MCP), `platform` (in-cluster product mail), `test_send` (a template test send). Recorded from 2026-10-12; older messages all read `platform`.

templatestring

Filter by template name.

created_afterstring · date-time

Only messages created at or after this instant. With `created_before`, the window may span at most 30 days (the log retention).

created_beforestring · date-time

Only messages created before this instant. Without `created_after`, the window is the 30 days before it.

recipientstring

Legacy: filter by exact recipient address. Prefer `q`.

subject_containsstring

Legacy: substring filter on subject (case-insensitive, max 200 chars). Prefer `q`.

Response · 200

text/csv stream with one header row + one row per message. Content-Disposition: attachment.

get/v1/workspaces/{workspace_id}/messages/settingsAuth

Read this workspace's outbound-message settings. Absent → defaults (redact_bodies: false).

Path parameters

workspace_id*string · uuid

Response · 200

workspace_id*string · uuid
redact_bodies*boolean

Example

Request

GET /v1/workspaces/{workspace_id}/messages/settings
Authorization: Bearer YOUR_TOKEN

Response

{
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "redact_bodies": false
}
patch/v1/workspaces/{workspace_id}/messages/settingsAuth

Update this workspace's outbound-message settings. Gated on `mail.message.delete` — same privilege bar as RTBF, since both touch the PII boundary.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

redact_bodies*boolean

When true, messages sent from now on are logged without their body (nothing is stored), and /messages/:id/body returns null body_html + body_text even to callers with mail.message.body.read. Turn it on for workspaces whose mail carries single-use links or codes. Turning it off later does not bring back bodies of messages sent while it was on. Defaults to false.

Response · 200

workspace_id*string · uuid
redact_bodies*boolean

Example

Request

PATCH /v1/workspaces/{workspace_id}/messages/settings
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "redact_bodies": false
}

Response

{
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "redact_bodies": false
}
get/v1/workspaces/{workspace_id}/messages/{id}Auth

Get a single outbound message (metadata + delivery verdicts; body not included) with its event timeline: `events[]` oldest first, `last_event`, `transport`, `bounce_type`, `bounce_subtype`. Use /:id/body for cleartext content.

Path parameters

workspace_id*string · uuid
id*string · uuid

Response · 200

id*string · uuid
workspace_id*string · uuid
domain_id*string · uuid

The sending domain (`domain.id`). Null for rows whose domain was deleted.

api_key_id*string · uuid

The Platform API Key that sent the message. Null for console, platform, MCP and test sends without a key, and unknown (null) for messages created before 2026-10-12.

Example: "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"

source*enum (4)

How the message was sent: `api_key`, `dashboard`, `platform` or `test_send`. Unknown for messages created before 2026-10-12, which all read `platform`.

Example: "api_key"

from_address*string
to_address*string
subject*string
template_name*string
status*enum (8)
smtp_transcript*string
spf_result*string
dkim_result*string
dmarc_result*string
bounce_code*string
bounce_detail*string
bounce_type*enum (3)

Bounce type, when the message bounced or was deferred: `Permanent`, `Transient` or `Undetermined`. `Permanent` adds the recipient to the suppression list.

Example: "Permanent"

bounce_subtype*string

Bounce subtype, e.g. `General`, `NoEmail`, `Suppressed`, `MailboxFull`.

Example: "General"

transport*enum (2)

How the message was sent: `ses` (delivery tracking: delivery, bounces and complaints reported) or `smtp` (sent before delivery tracking started or through a custom SMTP relay: hand-off confirmed, delivery not measurable). Null until the send is acknowledged.

Example: "ses"

created_at*string · date-time
sent_at*string · date-time

When the message was handed off for delivery. This is the fact the send path actually establishes.

delivered_at*string · date-time

When the message was confirmed delivered to the recipient. Null unless there is recipient-side evidence — a hand-off is not delivery.

deferred_at*string · date-time

Most recent deferral. Set when the destination refused to take the message for now and delivery is still being retried.

deferral_count*number

How many times a destination has refused to take this message. The count is the operator-legible number, not the flag: one greylist is noise, a dozen on the same recipient is a reputation block.

Example: 0

email_id*string · uuid

The email (`POST /emails` or a template send) this message belongs to: one email, one message per recipient (`GET /emails/{id}`). Null for messages sent before 2026-11-06.

cc*array

The email's Cc list, as sent; null when none.

bcc*array

The email's Bcc list, as sent; null when none.

reply_to*array

The email's Reply-To list, as sent; null when none.

tags*array

The email's tags (`[{ name, value }]`); null when none.

events*array

Every recorded transition, oldest first (at most 200).

last_event*object

The most recent event, or null for a row that predates event recording.

Example

Request

GET /v1/workspaces/{workspace_id}/messages/{id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "api_key_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "source": "api_key",
  "from_address": "string",
  "to_address": "string",
  "subject": "string",
  "template_name": "string",
  "status": "queued",
  "smtp_transcript": "string",
  "spf_result": "string",
  "dkim_result": "string",
  "dmarc_result": "string",
  "bounce_code": "string",
  "bounce_detail": "string",
  "bounce_type": "Permanent",
  "bounce_subtype": "General",
  "transport": "ses",
  "created_at": "2026-01-01T00:00:00.000Z",
  "sent_at": "2026-01-01T00:00:00.000Z",
  "delivered_at": "2026-01-01T00:00:00.000Z",
  "deferred_at": "2026-01-01T00:00:00.000Z",
  "deferral_count": 0,
  "email_id": "00000000-0000-0000-0000-000000000000",
  "cc": [
    "string"
  ],
  "bcc": [
    "string"
  ],
  "reply_to": [
    "string"
  ],
  "tags": [
    {
      "name": "category",
      "value": "confirm_email"
    }
  ],
  "events": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "type": "delivered",
      "occurred_at": "2026-01-01T00:00:00.000Z",
      "detail": {
        "source": "ses",
        "smtp_response": "250 2.0.0 OK",
        "remote_mta": "203.0.113.7"
      }
    }
  ],
  "last_event": {}
}
get/v1/workspaces/{workspace_id}/messages/{id}/bodyAuth

Get the cleartext body of an outbound message. Gated by `mail.message.body.read` (admin+ by default; not granted to plain members).

Path parameters

workspace_id*string · uuid
id*string · uuid

Response · 200

id*string · uuid
workspace_id*string · uuid
domain_id*string · uuid

The sending domain (`domain.id`). Null for rows whose domain was deleted.

api_key_id*string · uuid

The Platform API Key that sent the message. Null for console, platform, MCP and test sends without a key, and unknown (null) for messages created before 2026-10-12.

Example: "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"

source*enum (4)

How the message was sent: `api_key`, `dashboard`, `platform` or `test_send`. Unknown for messages created before 2026-10-12, which all read `platform`.

Example: "api_key"

from_address*string
to_address*string
subject*string
template_name*string
status*enum (8)
smtp_transcript*string
spf_result*string
dkim_result*string
dmarc_result*string
bounce_code*string
bounce_detail*string
bounce_type*enum (3)

Bounce type, when the message bounced or was deferred: `Permanent`, `Transient` or `Undetermined`. `Permanent` adds the recipient to the suppression list.

Example: "Permanent"

bounce_subtype*string

Bounce subtype, e.g. `General`, `NoEmail`, `Suppressed`, `MailboxFull`.

Example: "General"

transport*enum (2)

How the message was sent: `ses` (delivery tracking: delivery, bounces and complaints reported) or `smtp` (sent before delivery tracking started or through a custom SMTP relay: hand-off confirmed, delivery not measurable). Null until the send is acknowledged.

Example: "ses"

created_at*string · date-time
sent_at*string · date-time

When the message was handed off for delivery. This is the fact the send path actually establishes.

delivered_at*string · date-time

When the message was confirmed delivered to the recipient. Null unless there is recipient-side evidence — a hand-off is not delivery.

deferred_at*string · date-time

Most recent deferral. Set when the destination refused to take the message for now and delivery is still being retried.

deferral_count*number

How many times a destination has refused to take this message. The count is the operator-legible number, not the flag: one greylist is noise, a dozen on the same recipient is a reputation block.

Example: 0

email_id*string · uuid

The email (`POST /emails` or a template send) this message belongs to: one email, one message per recipient (`GET /emails/{id}`). Null for messages sent before 2026-11-06.

cc*array

The email's Cc list, as sent; null when none.

bcc*array

The email's Bcc list, as sent; null when none.

reply_to*array

The email's Reply-To list, as sent; null when none.

tags*array

The email's tags (`[{ name, value }]`); null when none.

body_html*string

Cleartext HTML body. Null when the workspace has `redact_bodies = true`.

body_text*string

Cleartext text body. Null when the workspace has `redact_bodies = true`.

Example

Request

GET /v1/workspaces/{workspace_id}/messages/{id}/body
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "api_key_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "source": "api_key",
  "from_address": "string",
  "to_address": "string",
  "subject": "string",
  "template_name": "string",
  "status": "queued",
  "smtp_transcript": "string",
  "spf_result": "string",
  "dkim_result": "string",
  "dmarc_result": "string",
  "bounce_code": "string",
  "bounce_detail": "string",
  "bounce_type": "Permanent",
  "bounce_subtype": "General",
  "transport": "ses",
  "created_at": "2026-01-01T00:00:00.000Z",
  "sent_at": "2026-01-01T00:00:00.000Z",
  "delivered_at": "2026-01-01T00:00:00.000Z",
  "deferred_at": "2026-01-01T00:00:00.000Z",
  "deferral_count": 0,
  "email_id": "00000000-0000-0000-0000-000000000000",
  "cc": [
    "string"
  ],
  "bcc": [
    "string"
  ],
  "reply_to": [
    "string"
  ],
  "tags": [
    {
      "name": "category",
      "value": "confirm_email"
    }
  ],
  "body_html": "string",
  "body_text": "string"
}

Domains

get/v1/workspaces/{workspace_id}/domainsAuth

List domains registered to this workspace (newest first), cursor-paginated.

Path parameters

workspace_id*string · uuid

Query parameters

limitinteger

Page size. Defaults to 20; values above 100 are clamped to 100.

cursorstring

Opaque cursor from the previous page's `pagination.next_cursor`. Omit for the first page.

searchstring

Case-insensitive substring match on the domain fqdn.

statusenum (8)

Filter by `verification_status` (`not_started`, `pending`, `partially_verified`, `verified`, `temporary_failure`, `failed`, `disabled`), applied in the query so the cursor keeps its meaning. The legacy `status` value `active` is still accepted and matches `status = active`. `pending` now means the `verification_status` (checked, not verified yet, within 72 h); before 2026-10 it matched `status = pending`, which is `not_started`, `pending`, `partially_verified` and the pending part of `failed`. Unrecognised values are rejected with a 400.

Response · 200

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /v1/workspaces/{workspace_id}/domains
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "seq_id": "3",
      "workspace_id": "00000000-0000-0000-0000-000000000000",
      "owner_account_id": "00000000-0000-0000-0000-000000000000",
      "fqdn": "productcraft.co",
      "name": "productcraft.co",
      "intent": "receive",
      "capabilities": {
        "sending": "enabled",
        "receiving": "disabled"
      },
      "tls": "opportunistic",
      "open_tracking": false,
      "click_tracking": false,
      "tracking_subdomain": null,
      "status": "pending",
      "health": "ok",
      "health_changed_at": "2026-01-01T00:00:00.000Z",
      "health_grace_ends_at": "2026-01-01T00:00:00.000Z",
      "verification_token": "string",
      "test_token": "string",
      "test_received_at": "2026-01-01T00:00:00.000Z",
      "dkim_selector": "string",
      "dkim_public_key": "string",
      "dns_provider": {},
      "dns_provider_id": "cloudflare",
      "dns_provider_detected": "cloudflare",
      "verification_status": "pending",
      "recommended_pending": 2,
      "region": "eu-north-1",
      "region_name": "Stockholm",
      "dns_check_results": {},
      "last_dns_check_at": "2026-01-01T00:00:00.000Z",
      "dns_zone": "example.com",
      "ses_dkim_status": "PENDING",
      "mail_from_subdomain": "bounce",
      "mail_from_status": "PENDING",
      "dmarc_managed": true,
      "setup_method": null,
      "dmarc_grade": {},
      "verified_at": "2026-01-01T00:00:00.000Z",
      "created_at": "2026-01-01T00:00:00.000Z",
      "dns_instructions": [
        {
          "purpose": "dkim",
          "type": "TXT",
          "host": "productcraft._domainkey.mail.example.com",
          "value": "string",
          "ttl": 0,
          "priority": 0,
          "required": true
        }
      ],
      "records": [
        {
          "record": "DKIM",
          "purpose": "dkim",
          "name": "productcraft._domainkey.mail",
          "type": "TXT",
          "value": "string",
          "priority": 10,
          "ttl": 3600,
          "status": "verified",
          "required": true
        }
      ],
      "test_address": "test-abc123@productcraft.co"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
post/v1/workspaces/{workspace_id}/domainsAuth

Register a domain for a workspace

Accepts Resend's create-domain body as well as ours: `name` for `fqdn`, `capabilities` for `intent`, `custom_return_path` for the MAIL FROM label, and `region`, `tls`, `open_tracking`, `click_tracking`, `tracking_subdomain`. Defaults to sending only (`intent: send`), as Resend does. Options we do not offer yet are refused with 422 rather than ignored.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

tlsenum (2)

`opportunistic` only for now (TLS whenever the receiver offers it); `enforced` is refused with 422 `TLS_ENFORCED_NOT_AVAILABLE`.

open_trackingboolean

Only `false` for now; `true` is refused with 422 `TRACKING_NOT_AVAILABLE`.

Example: false

click_trackingboolean

Only `false` for now; `true` is refused with 422 `TRACKING_NOT_AVAILABLE`.

Example: false

tracking_subdomainstring

Not available yet: any value is refused with 422 `TRACKING_NOT_AVAILABLE`.

fqdnstring

Fully qualified domain name. Required unless `name` is sent.

Example: "mail.example.com"

namestring

Resend's name for `fqdn`. Send either; both must agree when both are sent.

Example: "mail.example.com"

intentenum (3)

How the workspace plans to use this domain; drives which DNS records gate verification. Defaults to `send` (Resend's default: sending on, receiving off). Same as `capabilities`; send one or the other, or both in agreement.

capabilitiesobject

Resend's shape of `intent`: `sending` defaults to `enabled`, `receiving` to `disabled`; at least one must be enabled.

regionstring

The AWS region to send from. Every domain of this deployment sends from the same region (`region` on any domain, e.g. `eu-north-1`); any other value is refused with 422 `REGION_NOT_AVAILABLE`.

Example: "eu-north-1"

custom_return_pathstring

Resend's name for `mail_from_subdomain`: the custom MAIL FROM (return-path) label, `bounce` → `bounce.<fqdn>`. Sending domains only; defaults to `bounce`. Refused with 409 `MAIL_FROM_HOST_IN_USE` when that host already has records that are not ours.

Example: "bounce"

Response · 201

id*string · uuid
seq_id*string

Sequential id per workspace.

Example: "3"

workspace_id*string · uuid
owner_account_id*string · uuid
fqdn*string

Example: "productcraft.co"

name*string

Same as `fqdn` (Resend's field name).

Example: "productcraft.co"

intent*enum (3)

How the workspace uses this domain; `capabilities` is the same thing in Resend's shape. Changeable with PATCH.

capabilities*object
sending*enum (2)

Sending from this domain (DKIM signing).

Example: "enabled"

receiving*enum (2)

Receiving mail at this domain (MX pointed at ProductCraft).

Example: "disabled"

tls*enum (1)

TLS on delivery to the receiving server. Always `opportunistic` today (TLS whenever the receiver offers it); `enforced` is not available yet.

Example: "opportunistic"

open_tracking*boolean

Open tracking. Always false: tracking is not available yet.

Example: false

click_tracking*boolean

Click tracking. Always false: tracking is not available yet.

Example: false

tracking_subdomain*string

Tracking subdomain. Always null: tracking is not available yet.

Example: null

status*enum (3)

Example: "pending"

health*enum (3)

Drift on a verified domain. `degraded`: a required record (DKIM, or MX for receiving) went missing or changed, or DKIM signing no longer verifies; mail still flows and workspace owners are emailed. `failing`: still degraded 72 h later; sends from the domain are refused with 422 `domain_not_verified_by_provider`. Back to `ok` as soon as the records are. A failed DNS lookup never changes it, and recommended records (MAIL FROM, DMARC) never do. Always `ok` while the domain is not `active`.

Example: "ok"

health_changed_at*string · date-time

When `health` last changed; null if it has always been `ok`.

health_grace_ends_at*string · date-time

While `health` is `degraded`: when the 72 h grace ends and the domain becomes `failing` unless the records are back. Null otherwise.

verification_token*string

Ownership token for the `_productcraft-verify` TXT. Issued for receive-only domains; sending domains prove ownership with their DKIM record, so it is null for send/both domains created since 2026-10.

test_token*string
test_received_at*string · date-time
dkim_selector*string
dkim_public_key*string

Public DKIM key in PEM form (private half stays server-side).

dns_provider*object

The effective DNS provider: the user's choice when set, otherwise the one detected from the zone's NS records; null when neither is known. Changed in 2026-10 from the raw id string, which is now `dns_provider_id`.

dns_provider_id*string

The provider id chosen in the setup wizard (PATCH `dns_provider`), or null when not chosen. Kept for one release for clients that read the old string `dns_provider`; use `dns_provider.id`.

Example: "cloudflare"

dns_provider_detected*enum (12)

DNS provider detected from the zone's NS records when the domain was added; `dns_provider` reports the effective one.

verification_status*enum (7)

One status in Resend's vocabulary, derived from `status`, `health`, `intent` and the record states; first match wins. `disabled`: the domain is disabled. `failed`: `health` is `failing`, or still pending 72 h after it was added. `temporary_failure`: verified, but a required record went missing or changed (`health = degraded`, within the 72 h grace). `verified`: active and healthy. `partially_verified`: a send + receive domain where exactly one of sending (DKIM, and DKIM signing) and receiving (MX) verifies. `pending`: checked at least once, not verified yet. `not_started`: not checked yet. Recommended records (MAIL FROM, DMARC) never change it; see `recommended_pending`.

Example: "pending"

recommended_pending*integer

Recommended records (custom MAIL FROM MX + SPF, DMARC) that are not verified yet. Informational; they never block the domain.

Example: 2

region*string

The region mail from this domain is sent from, e.g. `eu-north-1`. Read-only and the same for every domain of this deployment; null when not configured.

Example: "eu-north-1"

region_name*string

Display name of `region`.

Example: "Stockholm"

dns_check_results*object

Snapshot of the most recent DNS check (any shape).

last_dns_check_at*string · date-time
dns_zone*string · hostname

Zone apex found by walking SOA records up from the fqdn; null before the first check.

Example: "example.com"

ses_dkim_status*enum (6)

DKIM signing verification of this domain, mirrored on every check. `not_enabled` where sending verification is off in this environment (local, preview); null while the domain is not registered for signing yet.

mail_from_subdomain*string

Custom MAIL FROM label (`bounce` → `bounce.<fqdn>`); null when off. On by default for sending domains created since 2026-10.

Example: "bounce"

mail_from_status*enum (4)

Verification of the custom MAIL FROM host (its MX record); null when no custom MAIL FROM is configured.

dmarc_managed*boolean

false when the customer opted out of our `_dmarc` recommendation (PATCH `dmarc_managed`).

Example: true

setup_method*enum (3)

How the DNS records were last set up by an automated path: `provider_api` after `POST .../dns-records/apply` wrote them with a provider token, `domain_connect` after a Domain Connect flow. Null when the records were (or are being) added by hand.

Example: null

dmarc_grade*object

The DMARC policy that governs this domain, graded on the last check; null before the first check of a sending domain.

verified_at*string · date-time
created_at*string · date-time
dns_instructions*array

DNS records to publish for this domain's intent. Records with `required: true` gate `active`; the rest are informational.

records*array

The same records in Resend's `records[]` shape, each with its status.

test_address*string

Convenience accessor for the test-email mailbox address on this domain.

Example: "test-abc123@productcraft.co"

Example

Request

POST /v1/workspaces/{workspace_id}/domains
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "tls": "opportunistic",
  "open_tracking": false,
  "click_tracking": false,
  "tracking_subdomain": "string",
  "fqdn": "mail.example.com",
  "name": "mail.example.com",
  "intent": "receive",
  "capabilities": {},
  "region": "eu-north-1",
  "custom_return_path": "bounce"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "seq_id": "3",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "owner_account_id": "00000000-0000-0000-0000-000000000000",
  "fqdn": "productcraft.co",
  "name": "productcraft.co",
  "intent": "receive",
  "capabilities": {
    "sending": "enabled",
    "receiving": "disabled"
  },
  "tls": "opportunistic",
  "open_tracking": false,
  "click_tracking": false,
  "tracking_subdomain": null,
  "status": "pending",
  "health": "ok",
  "health_changed_at": "2026-01-01T00:00:00.000Z",
  "health_grace_ends_at": "2026-01-01T00:00:00.000Z",
  "verification_token": "string",
  "test_token": "string",
  "test_received_at": "2026-01-01T00:00:00.000Z",
  "dkim_selector": "string",
  "dkim_public_key": "string",
  "dns_provider": {},
  "dns_provider_id": "cloudflare",
  "dns_provider_detected": "cloudflare",
  "verification_status": "pending",
  "recommended_pending": 2,
  "region": "eu-north-1",
  "region_name": "Stockholm",
  "dns_check_results": {},
  "last_dns_check_at": "2026-01-01T00:00:00.000Z",
  "dns_zone": "example.com",
  "ses_dkim_status": "PENDING",
  "mail_from_subdomain": "bounce",
  "mail_from_status": "PENDING",
  "dmarc_managed": true,
  "setup_method": null,
  "dmarc_grade": {},
  "verified_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "dns_instructions": [
    {
      "purpose": "dkim",
      "type": "TXT",
      "host": "productcraft._domainkey.mail.example.com",
      "value": "string",
      "ttl": 0,
      "priority": 0,
      "required": true
    }
  ],
  "records": [
    {
      "record": "DKIM",
      "purpose": "dkim",
      "name": "productcraft._domainkey.mail",
      "type": "TXT",
      "value": "string",
      "priority": 10,
      "ttl": 3600,
      "status": "verified",
      "required": true
    }
  ],
  "test_address": "test-abc123@productcraft.co"
}
get/v1/workspaces/{workspace_id}/domains/lookupAuth

Look up a domain before adding it: zone, DNS provider, Domain Connect support, a better subdomain, existing records.

Creates nothing. Resolves only names at or under `fqdn` and its zone apex, plus the Domain Connect endpoints the zone advertises (fetched through the SSRF guard; cached per zone for an hour). Limited to 30 lookups per workspace per minute.

Path parameters

workspace_id*string · uuid

Query parameters

fqdn*string · hostname

Domain to look up (not created).

Response · 200

fqdn*string

Example: "acme.com"

zone*string

Zone apex found by walking SOA records; null when the domain does not resolve.

Example: "acme.com"

provider*object
id*enum (12)

Provider id, matching the console's provider guides.

name*string

Example: "Cloudflare"

source*enum (2)

How it was identified: NS host names, or the Domain Connect settings the zone advertises.

nameservers*array

Example: ["ns1.digitalocean.com","ns2.digitalocean.com"]

domain_connect*object
available*boolean

Our Domain Connect template is onboarded at this provider: one-click setup is possible.

supported*boolean

The zone's DNS provider runs Domain Connect at all.

provider_id*string

Example: "cloudflare.com"

provider_name*string

Example: "Cloudflare"

reason*enum (5)

Why `available` is false: the zone advertises no Domain Connect (`no_record`), its settings could not be read (`settings_unreachable`), the provider has not onboarded our template (`template_not_onboarded`), DNS did not answer (`lookup_failed`), or the provider is ready but ProductCraft has not switched Domain Connect on yet (`disabled`).

suggestion*object

A better name to send from when an apex was typed: `mail.<apex>`, or `send.<apex>` when `mail.` already has other records.

existing*object

Records already there that will matter.

Example

Request

GET /v1/workspaces/{workspace_id}/domains/lookup
Authorization: Bearer YOUR_TOKEN

Response

{
  "fqdn": "acme.com",
  "zone": "acme.com",
  "provider": {
    "id": "cloudflare",
    "name": "Cloudflare",
    "source": "ns",
    "nameservers": [
      "ns1.digitalocean.com",
      "ns2.digitalocean.com"
    ]
  },
  "domain_connect": {
    "available": false,
    "supported": false,
    "provider_id": "cloudflare.com",
    "provider_name": "Cloudflare",
    "reason": "no_record"
  },
  "suggestion": {},
  "existing": {}
}
get/v1/workspaces/{workspace_id}/domains/{domain_id}Auth

Get a single domain (with DNS instructions and its latest events).

The domain with `verification_status`, `region`, the effective `dns_provider`, `recommended_pending`, its DNS instructions, and `events`: the latest 20 domain events, newest first.

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Response · 200

id*string · uuid
seq_id*string

Sequential id per workspace.

Example: "3"

workspace_id*string · uuid
owner_account_id*string · uuid
fqdn*string

Example: "productcraft.co"

name*string

Same as `fqdn` (Resend's field name).

Example: "productcraft.co"

intent*enum (3)

How the workspace uses this domain; `capabilities` is the same thing in Resend's shape. Changeable with PATCH.

capabilities*object
sending*enum (2)

Sending from this domain (DKIM signing).

Example: "enabled"

receiving*enum (2)

Receiving mail at this domain (MX pointed at ProductCraft).

Example: "disabled"

tls*enum (1)

TLS on delivery to the receiving server. Always `opportunistic` today (TLS whenever the receiver offers it); `enforced` is not available yet.

Example: "opportunistic"

open_tracking*boolean

Open tracking. Always false: tracking is not available yet.

Example: false

click_tracking*boolean

Click tracking. Always false: tracking is not available yet.

Example: false

tracking_subdomain*string

Tracking subdomain. Always null: tracking is not available yet.

Example: null

status*enum (3)

Example: "pending"

health*enum (3)

Drift on a verified domain. `degraded`: a required record (DKIM, or MX for receiving) went missing or changed, or DKIM signing no longer verifies; mail still flows and workspace owners are emailed. `failing`: still degraded 72 h later; sends from the domain are refused with 422 `domain_not_verified_by_provider`. Back to `ok` as soon as the records are. A failed DNS lookup never changes it, and recommended records (MAIL FROM, DMARC) never do. Always `ok` while the domain is not `active`.

Example: "ok"

health_changed_at*string · date-time

When `health` last changed; null if it has always been `ok`.

health_grace_ends_at*string · date-time

While `health` is `degraded`: when the 72 h grace ends and the domain becomes `failing` unless the records are back. Null otherwise.

verification_token*string

Ownership token for the `_productcraft-verify` TXT. Issued for receive-only domains; sending domains prove ownership with their DKIM record, so it is null for send/both domains created since 2026-10.

test_token*string
test_received_at*string · date-time
dkim_selector*string
dkim_public_key*string

Public DKIM key in PEM form (private half stays server-side).

dns_provider*object

The effective DNS provider: the user's choice when set, otherwise the one detected from the zone's NS records; null when neither is known. Changed in 2026-10 from the raw id string, which is now `dns_provider_id`.

dns_provider_id*string

The provider id chosen in the setup wizard (PATCH `dns_provider`), or null when not chosen. Kept for one release for clients that read the old string `dns_provider`; use `dns_provider.id`.

Example: "cloudflare"

dns_provider_detected*enum (12)

DNS provider detected from the zone's NS records when the domain was added; `dns_provider` reports the effective one.

verification_status*enum (7)

One status in Resend's vocabulary, derived from `status`, `health`, `intent` and the record states; first match wins. `disabled`: the domain is disabled. `failed`: `health` is `failing`, or still pending 72 h after it was added. `temporary_failure`: verified, but a required record went missing or changed (`health = degraded`, within the 72 h grace). `verified`: active and healthy. `partially_verified`: a send + receive domain where exactly one of sending (DKIM, and DKIM signing) and receiving (MX) verifies. `pending`: checked at least once, not verified yet. `not_started`: not checked yet. Recommended records (MAIL FROM, DMARC) never change it; see `recommended_pending`.

Example: "pending"

recommended_pending*integer

Recommended records (custom MAIL FROM MX + SPF, DMARC) that are not verified yet. Informational; they never block the domain.

Example: 2

region*string

The region mail from this domain is sent from, e.g. `eu-north-1`. Read-only and the same for every domain of this deployment; null when not configured.

Example: "eu-north-1"

region_name*string

Display name of `region`.

Example: "Stockholm"

dns_check_results*object

Snapshot of the most recent DNS check (any shape).

last_dns_check_at*string · date-time
dns_zone*string · hostname

Zone apex found by walking SOA records up from the fqdn; null before the first check.

Example: "example.com"

ses_dkim_status*enum (6)

DKIM signing verification of this domain, mirrored on every check. `not_enabled` where sending verification is off in this environment (local, preview); null while the domain is not registered for signing yet.

mail_from_subdomain*string

Custom MAIL FROM label (`bounce` → `bounce.<fqdn>`); null when off. On by default for sending domains created since 2026-10.

Example: "bounce"

mail_from_status*enum (4)

Verification of the custom MAIL FROM host (its MX record); null when no custom MAIL FROM is configured.

dmarc_managed*boolean

false when the customer opted out of our `_dmarc` recommendation (PATCH `dmarc_managed`).

Example: true

setup_method*enum (3)

How the DNS records were last set up by an automated path: `provider_api` after `POST .../dns-records/apply` wrote them with a provider token, `domain_connect` after a Domain Connect flow. Null when the records were (or are being) added by hand.

Example: null

dmarc_grade*object

The DMARC policy that governs this domain, graded on the last check; null before the first check of a sending domain.

verified_at*string · date-time
created_at*string · date-time
dns_instructions*array

DNS records to publish for this domain's intent. Records with `required: true` gate `active`; the rest are informational.

records*array

The same records in Resend's `records[]` shape, each with its status.

test_address*string

Convenience accessor for the test-email mailbox address on this domain.

Example: "test-abc123@productcraft.co"

events*array

The latest 20 domain events, newest first. Page through all of them with `GET .../domains/{id}/events`.

Example

Request

GET /v1/workspaces/{workspace_id}/domains/{domain_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "seq_id": "3",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "owner_account_id": "00000000-0000-0000-0000-000000000000",
  "fqdn": "productcraft.co",
  "name": "productcraft.co",
  "intent": "receive",
  "capabilities": {
    "sending": "enabled",
    "receiving": "disabled"
  },
  "tls": "opportunistic",
  "open_tracking": false,
  "click_tracking": false,
  "tracking_subdomain": null,
  "status": "pending",
  "health": "ok",
  "health_changed_at": "2026-01-01T00:00:00.000Z",
  "health_grace_ends_at": "2026-01-01T00:00:00.000Z",
  "verification_token": "string",
  "test_token": "string",
  "test_received_at": "2026-01-01T00:00:00.000Z",
  "dkim_selector": "string",
  "dkim_public_key": "string",
  "dns_provider": {},
  "dns_provider_id": "cloudflare",
  "dns_provider_detected": "cloudflare",
  "verification_status": "pending",
  "recommended_pending": 2,
  "region": "eu-north-1",
  "region_name": "Stockholm",
  "dns_check_results": {},
  "last_dns_check_at": "2026-01-01T00:00:00.000Z",
  "dns_zone": "example.com",
  "ses_dkim_status": "PENDING",
  "mail_from_subdomain": "bounce",
  "mail_from_status": "PENDING",
  "dmarc_managed": true,
  "setup_method": null,
  "dmarc_grade": {},
  "verified_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "dns_instructions": [
    {
      "purpose": "dkim",
      "type": "TXT",
      "host": "productcraft._domainkey.mail.example.com",
      "value": "string",
      "ttl": 0,
      "priority": 0,
      "required": true
    }
  ],
  "records": [
    {
      "record": "DKIM",
      "purpose": "dkim",
      "name": "productcraft._domainkey.mail",
      "type": "TXT",
      "value": "string",
      "priority": 10,
      "ttl": 3600,
      "status": "verified",
      "required": true
    }
  ],
  "test_address": "test-abc123@productcraft.co",
  "events": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "domain_id": "00000000-0000-0000-0000-000000000000",
      "type": "verified",
      "occurred_at": "2026-01-01T00:00:00.000Z",
      "detail": {
        "records": [
          {
            "purpose": "dkim",
            "state": "missing"
          }
        ]
      }
    }
  ]
}
patch/v1/workspaces/{workspace_id}/domains/{domain_id}Auth

Update domain settings: capabilities, DNS provider hint, custom MAIL FROM subdomain, DMARC recommendation.

`intent` (or Resend's `capabilities`) changes what the domain is used for. A pending domain just changes; a verified domain gaining sending or receiving has the new records checked live and is refused with 409 `CAPABILITY_NOT_VERIFIED` (the records to publish in `details.records`) until they pass. Turning receiving off is refused while active mailboxes exist (409 `DOMAIN_HAS_MAILBOXES`, the mailboxes in `details.mailboxes`; deleted mailboxes do not count); turning sending off also turns the custom MAIL FROM off. `tls`, `open_tracking`, `click_tracking` and `tracking_subdomain` are accepted for Resend compatibility; only the values in effect today pass (422 otherwise). `dns_provider` is the provider the setup wizard renders host names for. `mail_from_subdomain` sets the custom MAIL FROM label (`bounce` → `bounce.<fqdn>`; `null` turns it off): the domain's record set gains the MAIL FROM MX + SPF records, and once the domain is verified its MAIL FROM is pointed there (the default return address stays in use until the MX record is found, so mail never stops). Refused with 409 when that host already carries records that are not ours. `dmarc_managed: false` hides the `_dmarc` recommendation for domains whose DMARC is managed elsewhere (the policy is still detected and graded).

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window.

Request body

tlsenum (2)

`opportunistic` only for now (TLS whenever the receiver offers it); `enforced` is refused with 422 `TLS_ENFORCED_NOT_AVAILABLE`.

open_trackingboolean

Only `false` for now; `true` is refused with 422 `TRACKING_NOT_AVAILABLE`.

Example: false

click_trackingboolean

Only `false` for now; `true` is refused with 422 `TRACKING_NOT_AVAILABLE`.

Example: false

tracking_subdomainstring

Not available yet: any value is refused with 422 `TRACKING_NOT_AVAILABLE`.

intentenum (3)

Change how the domain is used. On a verified domain, a capability it gains must verify first: its records are checked live and the change is refused with 409 `CAPABILITY_NOT_VERIFIED` (records to publish in `details.records`) until they pass. Dropping receiving is refused with 409 `DOMAIN_HAS_MAILBOXES` while active mailboxes exist (listed in `details.mailboxes`). Same as `capabilities`; send one or the other, or both in agreement.

capabilitiesobject

Resend's shape of `intent`. Omitted fields keep their current value; at least one must stay enabled.

dns_providerstring

Identifier from the wizard's provider list. Free-form so we can ship new entries without a migration. NULL clears the choice.

mail_from_subdomainstring

Custom MAIL FROM label: one DNS label, so `bounce` makes the envelope sender `…@bounce.<fqdn>` and SPF align with the From domain. `null` turns the custom MAIL FROM off (the default return address is used; DKIM still aligns). Sending domains only.

Example: "bounce"

dmarc_managedboolean

false: do not recommend a `_dmarc` record for this domain (DMARC is managed elsewhere). The policy that applies is still detected and graded in `dmarc_grade`.

Example: false

Response · 200

id*string · uuid
seq_id*string

Sequential id per workspace.

Example: "3"

workspace_id*string · uuid
owner_account_id*string · uuid
fqdn*string

Example: "productcraft.co"

name*string

Same as `fqdn` (Resend's field name).

Example: "productcraft.co"

intent*enum (3)

How the workspace uses this domain; `capabilities` is the same thing in Resend's shape. Changeable with PATCH.

capabilities*object
sending*enum (2)

Sending from this domain (DKIM signing).

Example: "enabled"

receiving*enum (2)

Receiving mail at this domain (MX pointed at ProductCraft).

Example: "disabled"

tls*enum (1)

TLS on delivery to the receiving server. Always `opportunistic` today (TLS whenever the receiver offers it); `enforced` is not available yet.

Example: "opportunistic"

open_tracking*boolean

Open tracking. Always false: tracking is not available yet.

Example: false

click_tracking*boolean

Click tracking. Always false: tracking is not available yet.

Example: false

tracking_subdomain*string

Tracking subdomain. Always null: tracking is not available yet.

Example: null

status*enum (3)

Example: "pending"

health*enum (3)

Drift on a verified domain. `degraded`: a required record (DKIM, or MX for receiving) went missing or changed, or DKIM signing no longer verifies; mail still flows and workspace owners are emailed. `failing`: still degraded 72 h later; sends from the domain are refused with 422 `domain_not_verified_by_provider`. Back to `ok` as soon as the records are. A failed DNS lookup never changes it, and recommended records (MAIL FROM, DMARC) never do. Always `ok` while the domain is not `active`.

Example: "ok"

health_changed_at*string · date-time

When `health` last changed; null if it has always been `ok`.

health_grace_ends_at*string · date-time

While `health` is `degraded`: when the 72 h grace ends and the domain becomes `failing` unless the records are back. Null otherwise.

verification_token*string

Ownership token for the `_productcraft-verify` TXT. Issued for receive-only domains; sending domains prove ownership with their DKIM record, so it is null for send/both domains created since 2026-10.

test_token*string
test_received_at*string · date-time
dkim_selector*string
dkim_public_key*string

Public DKIM key in PEM form (private half stays server-side).

dns_provider*object

The effective DNS provider: the user's choice when set, otherwise the one detected from the zone's NS records; null when neither is known. Changed in 2026-10 from the raw id string, which is now `dns_provider_id`.

dns_provider_id*string

The provider id chosen in the setup wizard (PATCH `dns_provider`), or null when not chosen. Kept for one release for clients that read the old string `dns_provider`; use `dns_provider.id`.

Example: "cloudflare"

dns_provider_detected*enum (12)

DNS provider detected from the zone's NS records when the domain was added; `dns_provider` reports the effective one.

verification_status*enum (7)

One status in Resend's vocabulary, derived from `status`, `health`, `intent` and the record states; first match wins. `disabled`: the domain is disabled. `failed`: `health` is `failing`, or still pending 72 h after it was added. `temporary_failure`: verified, but a required record went missing or changed (`health = degraded`, within the 72 h grace). `verified`: active and healthy. `partially_verified`: a send + receive domain where exactly one of sending (DKIM, and DKIM signing) and receiving (MX) verifies. `pending`: checked at least once, not verified yet. `not_started`: not checked yet. Recommended records (MAIL FROM, DMARC) never change it; see `recommended_pending`.

Example: "pending"

recommended_pending*integer

Recommended records (custom MAIL FROM MX + SPF, DMARC) that are not verified yet. Informational; they never block the domain.

Example: 2

region*string

The region mail from this domain is sent from, e.g. `eu-north-1`. Read-only and the same for every domain of this deployment; null when not configured.

Example: "eu-north-1"

region_name*string

Display name of `region`.

Example: "Stockholm"

dns_check_results*object

Snapshot of the most recent DNS check (any shape).

last_dns_check_at*string · date-time
dns_zone*string · hostname

Zone apex found by walking SOA records up from the fqdn; null before the first check.

Example: "example.com"

ses_dkim_status*enum (6)

DKIM signing verification of this domain, mirrored on every check. `not_enabled` where sending verification is off in this environment (local, preview); null while the domain is not registered for signing yet.

mail_from_subdomain*string

Custom MAIL FROM label (`bounce` → `bounce.<fqdn>`); null when off. On by default for sending domains created since 2026-10.

Example: "bounce"

mail_from_status*enum (4)

Verification of the custom MAIL FROM host (its MX record); null when no custom MAIL FROM is configured.

dmarc_managed*boolean

false when the customer opted out of our `_dmarc` recommendation (PATCH `dmarc_managed`).

Example: true

setup_method*enum (3)

How the DNS records were last set up by an automated path: `provider_api` after `POST .../dns-records/apply` wrote them with a provider token, `domain_connect` after a Domain Connect flow. Null when the records were (or are being) added by hand.

Example: null

dmarc_grade*object

The DMARC policy that governs this domain, graded on the last check; null before the first check of a sending domain.

verified_at*string · date-time
created_at*string · date-time
dns_instructions*array

DNS records to publish for this domain's intent. Records with `required: true` gate `active`; the rest are informational.

records*array

The same records in Resend's `records[]` shape, each with its status.

test_address*string

Convenience accessor for the test-email mailbox address on this domain.

Example: "test-abc123@productcraft.co"

Example

Request

PATCH /v1/workspaces/{workspace_id}/domains/{domain_id}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "tls": "opportunistic",
  "open_tracking": false,
  "click_tracking": false,
  "tracking_subdomain": "string",
  "intent": "receive",
  "capabilities": {},
  "dns_provider": "string",
  "mail_from_subdomain": "bounce",
  "dmarc_managed": false
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "seq_id": "3",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "owner_account_id": "00000000-0000-0000-0000-000000000000",
  "fqdn": "productcraft.co",
  "name": "productcraft.co",
  "intent": "receive",
  "capabilities": {
    "sending": "enabled",
    "receiving": "disabled"
  },
  "tls": "opportunistic",
  "open_tracking": false,
  "click_tracking": false,
  "tracking_subdomain": null,
  "status": "pending",
  "health": "ok",
  "health_changed_at": "2026-01-01T00:00:00.000Z",
  "health_grace_ends_at": "2026-01-01T00:00:00.000Z",
  "verification_token": "string",
  "test_token": "string",
  "test_received_at": "2026-01-01T00:00:00.000Z",
  "dkim_selector": "string",
  "dkim_public_key": "string",
  "dns_provider": {},
  "dns_provider_id": "cloudflare",
  "dns_provider_detected": "cloudflare",
  "verification_status": "pending",
  "recommended_pending": 2,
  "region": "eu-north-1",
  "region_name": "Stockholm",
  "dns_check_results": {},
  "last_dns_check_at": "2026-01-01T00:00:00.000Z",
  "dns_zone": "example.com",
  "ses_dkim_status": "PENDING",
  "mail_from_subdomain": "bounce",
  "mail_from_status": "PENDING",
  "dmarc_managed": true,
  "setup_method": null,
  "dmarc_grade": {},
  "verified_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "dns_instructions": [
    {
      "purpose": "dkim",
      "type": "TXT",
      "host": "productcraft._domainkey.mail.example.com",
      "value": "string",
      "ttl": 0,
      "priority": 0,
      "required": true
    }
  ],
  "records": [
    {
      "record": "DKIM",
      "purpose": "dkim",
      "name": "productcraft._domainkey.mail",
      "type": "TXT",
      "value": "string",
      "priority": 10,
      "ttl": 3600,
      "status": "verified",
      "required": true
    }
  ],
  "test_address": "test-abc123@productcraft.co"
}
delete/v1/workspaces/{workspace_id}/domains/{domain_id}Auth

Delete a domain (must have no active mailboxes left).

Permanently deletes the domain, its DNS record set and its Amazon SES sending identity. Mailboxes you already deleted on it are erased with it, together with the mail they still held and its stored files; this cannot be undone. Refused with 409 `DOMAIN_HAS_MAILBOXES` while any mailbox on it is active.

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Response · 204

Domain deleted, with its deleted mailboxes and their remaining mail.

get/v1/workspaces/{workspace_id}/domains/{domain_id}/eventsAuth

List a domain's events (its timeline), newest first, cursor-paginated.

Added, records found, verified, degraded, failing, recovered, MAIL FROM verified, DMARC detected, check failed. An event is written only when something changed, so a domain checked every few minutes does not grow a row per check.

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Query parameters

limitinteger

Page size. Defaults to 20; values above 100 are clamped to 100.

cursorstring

Opaque cursor from the previous page's `pagination.next_cursor`. Omit for the first page.

Response · 200

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /v1/workspaces/{workspace_id}/domains/{domain_id}/events
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "domain_id": "00000000-0000-0000-0000-000000000000",
      "type": "verified",
      "occurred_at": "2026-01-01T00:00:00.000Z",
      "detail": {
        "records": [
          {
            "purpose": "dkim",
            "state": "missing"
          }
        ]
      }
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
post/v1/workspaces/{workspace_id}/domains/{domain_id}/verifyAuth

Run a synchronous DNS check against this domain; flips status to active when the required record set passes.

Legacy shape, kept as an alias of `POST .../dns-records/check`: returns the domain on success and a 400 with `dns_check_results` when a required record fails. New integrations should use `POST .../dns-records/check`, which returns per-record states.

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Response · 200

id*string · uuid
seq_id*string

Sequential id per workspace.

Example: "3"

workspace_id*string · uuid
owner_account_id*string · uuid
fqdn*string

Example: "productcraft.co"

name*string

Same as `fqdn` (Resend's field name).

Example: "productcraft.co"

intent*enum (3)

How the workspace uses this domain; `capabilities` is the same thing in Resend's shape. Changeable with PATCH.

capabilities*object
sending*enum (2)

Sending from this domain (DKIM signing).

Example: "enabled"

receiving*enum (2)

Receiving mail at this domain (MX pointed at ProductCraft).

Example: "disabled"

tls*enum (1)

TLS on delivery to the receiving server. Always `opportunistic` today (TLS whenever the receiver offers it); `enforced` is not available yet.

Example: "opportunistic"

open_tracking*boolean

Open tracking. Always false: tracking is not available yet.

Example: false

click_tracking*boolean

Click tracking. Always false: tracking is not available yet.

Example: false

tracking_subdomain*string

Tracking subdomain. Always null: tracking is not available yet.

Example: null

status*enum (3)

Example: "pending"

health*enum (3)

Drift on a verified domain. `degraded`: a required record (DKIM, or MX for receiving) went missing or changed, or DKIM signing no longer verifies; mail still flows and workspace owners are emailed. `failing`: still degraded 72 h later; sends from the domain are refused with 422 `domain_not_verified_by_provider`. Back to `ok` as soon as the records are. A failed DNS lookup never changes it, and recommended records (MAIL FROM, DMARC) never do. Always `ok` while the domain is not `active`.

Example: "ok"

health_changed_at*string · date-time

When `health` last changed; null if it has always been `ok`.

health_grace_ends_at*string · date-time

While `health` is `degraded`: when the 72 h grace ends and the domain becomes `failing` unless the records are back. Null otherwise.

verification_token*string

Ownership token for the `_productcraft-verify` TXT. Issued for receive-only domains; sending domains prove ownership with their DKIM record, so it is null for send/both domains created since 2026-10.

test_token*string
test_received_at*string · date-time
dkim_selector*string
dkim_public_key*string

Public DKIM key in PEM form (private half stays server-side).

dns_provider*object

The effective DNS provider: the user's choice when set, otherwise the one detected from the zone's NS records; null when neither is known. Changed in 2026-10 from the raw id string, which is now `dns_provider_id`.

dns_provider_id*string

The provider id chosen in the setup wizard (PATCH `dns_provider`), or null when not chosen. Kept for one release for clients that read the old string `dns_provider`; use `dns_provider.id`.

Example: "cloudflare"

dns_provider_detected*enum (12)

DNS provider detected from the zone's NS records when the domain was added; `dns_provider` reports the effective one.

verification_status*enum (7)

One status in Resend's vocabulary, derived from `status`, `health`, `intent` and the record states; first match wins. `disabled`: the domain is disabled. `failed`: `health` is `failing`, or still pending 72 h after it was added. `temporary_failure`: verified, but a required record went missing or changed (`health = degraded`, within the 72 h grace). `verified`: active and healthy. `partially_verified`: a send + receive domain where exactly one of sending (DKIM, and DKIM signing) and receiving (MX) verifies. `pending`: checked at least once, not verified yet. `not_started`: not checked yet. Recommended records (MAIL FROM, DMARC) never change it; see `recommended_pending`.

Example: "pending"

recommended_pending*integer

Recommended records (custom MAIL FROM MX + SPF, DMARC) that are not verified yet. Informational; they never block the domain.

Example: 2

region*string

The region mail from this domain is sent from, e.g. `eu-north-1`. Read-only and the same for every domain of this deployment; null when not configured.

Example: "eu-north-1"

region_name*string

Display name of `region`.

Example: "Stockholm"

dns_check_results*object

Snapshot of the most recent DNS check (any shape).

last_dns_check_at*string · date-time
dns_zone*string · hostname

Zone apex found by walking SOA records up from the fqdn; null before the first check.

Example: "example.com"

ses_dkim_status*enum (6)

DKIM signing verification of this domain, mirrored on every check. `not_enabled` where sending verification is off in this environment (local, preview); null while the domain is not registered for signing yet.

mail_from_subdomain*string

Custom MAIL FROM label (`bounce` → `bounce.<fqdn>`); null when off. On by default for sending domains created since 2026-10.

Example: "bounce"

mail_from_status*enum (4)

Verification of the custom MAIL FROM host (its MX record); null when no custom MAIL FROM is configured.

dmarc_managed*boolean

false when the customer opted out of our `_dmarc` recommendation (PATCH `dmarc_managed`).

Example: true

setup_method*enum (3)

How the DNS records were last set up by an automated path: `provider_api` after `POST .../dns-records/apply` wrote them with a provider token, `domain_connect` after a Domain Connect flow. Null when the records were (or are being) added by hand.

Example: null

dmarc_grade*object

The DMARC policy that governs this domain, graded on the last check; null before the first check of a sending domain.

verified_at*string · date-time
created_at*string · date-time
dns_instructions*array

DNS records to publish for this domain's intent. Records with `required: true` gate `active`; the rest are informational.

records*array

The same records in Resend's `records[]` shape, each with its status.

test_address*string

Convenience accessor for the test-email mailbox address on this domain.

Example: "test-abc123@productcraft.co"

Example

Request

POST /v1/workspaces/{workspace_id}/domains/{domain_id}/verify
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "seq_id": "3",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "owner_account_id": "00000000-0000-0000-0000-000000000000",
  "fqdn": "productcraft.co",
  "name": "productcraft.co",
  "intent": "receive",
  "capabilities": {
    "sending": "enabled",
    "receiving": "disabled"
  },
  "tls": "opportunistic",
  "open_tracking": false,
  "click_tracking": false,
  "tracking_subdomain": null,
  "status": "pending",
  "health": "ok",
  "health_changed_at": "2026-01-01T00:00:00.000Z",
  "health_grace_ends_at": "2026-01-01T00:00:00.000Z",
  "verification_token": "string",
  "test_token": "string",
  "test_received_at": "2026-01-01T00:00:00.000Z",
  "dkim_selector": "string",
  "dkim_public_key": "string",
  "dns_provider": {},
  "dns_provider_id": "cloudflare",
  "dns_provider_detected": "cloudflare",
  "verification_status": "pending",
  "recommended_pending": 2,
  "region": "eu-north-1",
  "region_name": "Stockholm",
  "dns_check_results": {},
  "last_dns_check_at": "2026-01-01T00:00:00.000Z",
  "dns_zone": "example.com",
  "ses_dkim_status": "PENDING",
  "mail_from_subdomain": "bounce",
  "mail_from_status": "PENDING",
  "dmarc_managed": true,
  "setup_method": null,
  "dmarc_grade": {},
  "verified_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "dns_instructions": [
    {
      "purpose": "dkim",
      "type": "TXT",
      "host": "productcraft._domainkey.mail.example.com",
      "value": "string",
      "ttl": 0,
      "priority": 0,
      "required": true
    }
  ],
  "records": [
    {
      "record": "DKIM",
      "purpose": "dkim",
      "name": "productcraft._domainkey.mail",
      "type": "TXT",
      "value": "string",
      "priority": 10,
      "ttl": 3600,
      "status": "verified",
      "required": true
    }
  ],
  "test_address": "test-abc123@productcraft.co"
}
get/v1/workspaces/{workspace_id}/domains/{domain_id}/dns-recordsAuth

Per-record DNS status for a domain.

Every record the domain needs for its intent, with its state (`missing`, `mismatch`, `conflict`, `propagating`, `verified`), what was found, a propagation hint and timestamps, as of the last check (the verifier re-checks pending domains every 5 minutes and active ones hourly). Does not run a DNS lookup; use `POST .../dns-records/check` for that.

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Query parameters

zonestring · hostname

Render `name_relative` against this zone (the domain itself or one of its parents). Defaults to the zone found by walking SOA records.

Response · 200

domain_id*string · uuid
fqdn*string

Example: "mail.example.com"

status*enum (3)
health*enum (3)

Drift on a verified domain. `degraded`: a required record (DKIM, or MX for receiving) went missing or changed, or DKIM signing no longer verifies; mail still flows and workspace owners are emailed. `failing`: still degraded 72 h later; sends from the domain are refused with 422 `domain_not_verified_by_provider`. Back to `ok` as soon as the records are. A failed DNS lookup never changes it, and recommended records (MAIL FROM, DMARC) never do. Always `ok` while the domain is not `active`.

Example: "ok"

health_changed_at*string · date-time

When `health` last changed; null if it has always been `ok`.

health_grace_ends_at*string · date-time

While `health` is `degraded`: when the 72 h grace ends and the domain becomes `failing` unless the records are back. Null otherwise.

zone*string

Zone the relative names are rendered against: the `zone` query parameter, else the zone found by walking SOA records (null before the first check).

Example: "example.com"

last_checked_at*string · date-time
records*array
ses*object
enabled*boolean

Whether sending verification (DKIM signing) is switched on for this deployment.

dkim_status*enum (6)

DKIM signing verification of the DKIM record. `not_enabled` where sending verification is off in this environment (local, preview); null while the domain is not registered for signing yet.

mail_from_status*enum (4)

Verification of the custom MAIL FROM host; null when none is configured.

Example

Request

GET /v1/workspaces/{workspace_id}/domains/{domain_id}/dns-records
Authorization: Bearer YOUR_TOKEN

Response

{
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "fqdn": "mail.example.com",
  "status": "pending",
  "health": "ok",
  "health_changed_at": "2026-01-01T00:00:00.000Z",
  "health_grace_ends_at": "2026-01-01T00:00:00.000Z",
  "zone": "example.com",
  "last_checked_at": "2026-01-01T00:00:00.000Z",
  "records": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "purpose": "dkim",
      "type": "TXT",
      "host": "productcraft._domainkey.mail.example.com",
      "name_relative": "productcraft._domainkey.mail",
      "value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqh…",
      "priority": 10,
      "ttl": 3600,
      "required": true,
      "state": "verified",
      "found": [
        "string"
      ],
      "conflicts": [
        {
          "code": "spf_multiple",
          "severity": "blocking",
          "message": "string",
          "suggested_value": "v=spf1 include:amazonses.com include:_spf.google.com ~all"
        }
      ],
      "hint": "Your nameservers already serve it; public resolvers will within ~30 min (the zone's negative-cache TTL is 1800 s).",
      "covered_by": "_dmarc.example.com",
      "first_verified_at": "2026-01-01T00:00:00.000Z",
      "last_checked_at": "2026-01-01T00:00:00.000Z",
      "last_changed_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "ses": {
    "enabled": false,
    "dkim_status": "PENDING",
    "mail_from_status": "PENDING"
  }
}
get/v1/workspaces/{workspace_id}/domains/{domain_id}/dns-records/exportAuth

Download the domain's DNS records as a BIND zone fragment or a CSV.

`format=bind`: a zone-file fragment (`$ORIGIN <zone>.`, `$TTL 3600`, names relative to the zone, MX with priority, TXT split into ≤255-byte quoted strings, a comment per record with its purpose, required/recommended and any conflict) that Cloudflare and Route 53 import directly. `format=csv`: `type,name,value,priority,ttl,purpose,required` (the common DNS-import columns plus two), RFC 4180 quoting, fully qualified names. By default only records not yet verified; `all=true` for every record. Served as an attachment.

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Query parameters

format*enum (2)
zonestring · hostname

BIND: the `$ORIGIN` names are relative to (the domain or one of its parents). Defaults to the detected zone, else the domain itself.

allboolean

Include records that already verify.

Response · 200

The file.

post/v1/workspaces/{workspace_id}/domains/{domain_id}/dns-records/checkAuth

Check the domain's DNS records now.

Queries the zone's authoritative nameservers and a public resolver for every record, stores the result, flips a pending domain whose required records pass to `active`, and returns the same body as `GET .../dns-records`. Never fails because records are missing: their states say so. Limited to one call per domain every 10 seconds. Safe to retry: it changes nothing but the stored check result, so it takes no Idempotency-Key.

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Query parameters

zonestring · hostname

As on `GET .../dns-records`.

Response · 200

domain_id*string · uuid
fqdn*string

Example: "mail.example.com"

status*enum (3)
health*enum (3)

Drift on a verified domain. `degraded`: a required record (DKIM, or MX for receiving) went missing or changed, or DKIM signing no longer verifies; mail still flows and workspace owners are emailed. `failing`: still degraded 72 h later; sends from the domain are refused with 422 `domain_not_verified_by_provider`. Back to `ok` as soon as the records are. A failed DNS lookup never changes it, and recommended records (MAIL FROM, DMARC) never do. Always `ok` while the domain is not `active`.

Example: "ok"

health_changed_at*string · date-time

When `health` last changed; null if it has always been `ok`.

health_grace_ends_at*string · date-time

While `health` is `degraded`: when the 72 h grace ends and the domain becomes `failing` unless the records are back. Null otherwise.

zone*string

Zone the relative names are rendered against: the `zone` query parameter, else the zone found by walking SOA records (null before the first check).

Example: "example.com"

last_checked_at*string · date-time
records*array
ses*object
enabled*boolean

Whether sending verification (DKIM signing) is switched on for this deployment.

dkim_status*enum (6)

DKIM signing verification of the DKIM record. `not_enabled` where sending verification is off in this environment (local, preview); null while the domain is not registered for signing yet.

mail_from_status*enum (4)

Verification of the custom MAIL FROM host; null when none is configured.

Example

Request

POST /v1/workspaces/{workspace_id}/domains/{domain_id}/dns-records/check
Authorization: Bearer YOUR_TOKEN

Response

{
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "fqdn": "mail.example.com",
  "status": "pending",
  "health": "ok",
  "health_changed_at": "2026-01-01T00:00:00.000Z",
  "health_grace_ends_at": "2026-01-01T00:00:00.000Z",
  "zone": "example.com",
  "last_checked_at": "2026-01-01T00:00:00.000Z",
  "records": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "purpose": "dkim",
      "type": "TXT",
      "host": "productcraft._domainkey.mail.example.com",
      "name_relative": "productcraft._domainkey.mail",
      "value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqh…",
      "priority": 10,
      "ttl": 3600,
      "required": true,
      "state": "verified",
      "found": [
        "string"
      ],
      "conflicts": [
        {
          "code": "spf_multiple",
          "severity": "blocking",
          "message": "string",
          "suggested_value": "v=spf1 include:amazonses.com include:_spf.google.com ~all"
        }
      ],
      "hint": "Your nameservers already serve it; public resolvers will within ~30 min (the zone's negative-cache TTL is 1800 s).",
      "covered_by": "_dmarc.example.com",
      "first_verified_at": "2026-01-01T00:00:00.000Z",
      "last_checked_at": "2026-01-01T00:00:00.000Z",
      "last_changed_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "ses": {
    "enabled": false,
    "dkim_status": "PENDING",
    "mail_from_status": "PENDING"
  }
}
post/v1/workspaces/{workspace_id}/domains/{domain_id}/share-linksAuth

Create a share link: a read-only DNS setup page for whoever manages the domain's DNS.

Returns a link (valid 1–7 days, default 7) to a public page that shows the domain name, its DNS records with copy-ready values, their live status and zone-file / CSV downloads, without an account. The page shows nothing else about the workspace. The link is shown only in this response (only its hash is stored); revoke it with `DELETE .../share-links/{link_id}`. Optionally emails it from ProductCraft to up to 5 recipients per domain per day (20 per workspace). Requires `mail.update` on the domain.

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A replay returns the same link and sends no second email.

Request body

expires_in_daysinteger

Days until the link stops working (1–7).

Example: 7

email_toarray

Optional: email the link to these people (whoever manages the DNS). At most 5 recipients per domain per UTC day and 20 per workspace, counted across all links; over that the request is refused with 429 and no link is created. Each recipient gets their own email from ProductCraft with the domain name, the link and its expiry.

Example: ["it@example.com"]

Response · 201

id*string · uuid
domain_id*string · uuid
status*enum (3)

`active` until `expires_at` or until revoked.

Example: "active"

created_by*string

Principal (`sub`) that created the link.

created_at*string · date-time
expires_at*string · date-time
revoked_at*string · date-time
last_viewed_at*string · date-time

Last time the public page was opened (updated at most once a minute).

emailed_to_count*integer

How many recipients the link was emailed to.

Example: 1

url*string · uri

The page to send: a read-only setup page with the records, live status and downloads, no account needed. Shown only in this response; it cannot be retrieved again (only a hash is stored). When the request carried an `Idempotency-Key`, a retry with the same key within 24 h returns this same response.

Example: "https://console.productcraft.co/mail/setup/3q2-7wVdZ6xYb9mJk1Lr0pQs4tUv8wXyZaBcDeFgHiJ"

api_url*string · uri

The same link as JSON (`GET /v1/public/domain-setup/{token}`), for scripts.

Example: "https://api.mail.productcraft.co/v1/public/domain-setup/3q2-7wVdZ6xYb9mJk1Lr0pQs4tUv8wXyZaBcDeFgHiJ"

emailed_to*array

Recipients the email was queued for (an address whose email could not be queued is left out).

Example: ["it@example.com"]

Example

Request

POST /v1/workspaces/{workspace_id}/domains/{domain_id}/share-links
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "expires_in_days": 7,
  "email_to": [
    "it@example.com"
  ]
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "status": "active",
  "created_by": "string",
  "created_at": "2026-01-01T00:00:00.000Z",
  "expires_at": "2026-01-01T00:00:00.000Z",
  "revoked_at": "2026-01-01T00:00:00.000Z",
  "last_viewed_at": "2026-01-01T00:00:00.000Z",
  "emailed_to_count": 1,
  "url": "https://console.productcraft.co/mail/setup/3q2-7wVdZ6xYb9mJk1Lr0pQs4tUv8wXyZaBcDeFgHiJ",
  "api_url": "https://api.mail.productcraft.co/v1/public/domain-setup/3q2-7wVdZ6xYb9mJk1Lr0pQs4tUv8wXyZaBcDeFgHiJ",
  "emailed_to": [
    "it@example.com"
  ]
}
post/v1/workspaces/{workspace_id}/domains/{domain_id}/dns-records/applyAuth

Set up the domain's DNS records in one step with a short-lived Cloudflare or DigitalOcean API token.

Reads the zone through the provider's API and plans every record the domain needs: `create`, `update` (only ever an SPF merge into an existing SPF record, shown with `before`/`after`) or `skip` (already there). `dry_run: true` returns the plan and changes nothing; `dry_run: false` applies exactly that plan, runs a DNS check straight away and records `setup_method = provider_api`. Only names at or under the domain are written, nothing is ever deleted, and Cloudflare records are DNS-only (not proxied). The recommended DMARC record (`p=none`) is included when `include_dmarc` is true (the default) and no DMARC policy applies to the domain yet; an existing policy is never touched. Any blocking conflict refuses the whole apply with 409. The token is used for this request only: it is never stored, logged, queued or returned; revoke it afterwards (`warnings` says so when it could see more than this zone). Applies are limited to one per domain every 10 seconds; dry runs are not limited.

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Headers

Idempotency-Key*string

Required. Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`). A retry with the same key and body (the token is not part of the comparison) replays the first response instead of writing again, for 24 hours.

Request body

provider*enum (2)

The DNS provider that hosts the zone.

Example: "cloudflare"

token*string · password

A short-lived API token for the provider, used for this request only and never stored, logged or returned. Cloudflare: an API token limited to this one zone with the **Zone → DNS → Edit** permission and a short expiry. DigitalOcean: a personal access token with custom scopes `domain:read`, `domain:create`, `domain:update` and a 1-hour expiry. Revoke it once the apply is done.

Example: "dop_v1_EXAMPLE_TOKEN_DO_NOT_USE"

zonestring · hostname

The zone at the provider (the domain or one of its parents). Defaults to the closest parent zone the token can see.

Example: "example.com"

dry_run*boolean

`true`: return the plan and change nothing. `false`: apply the plan and return what was written.

Example: true

include_dmarcboolean

Also add the recommended DMARC record (`_dmarc.<fqdn>` `v=DMARC1; p=none;`). Only ever planned when no DMARC policy applies at the domain or its organisational domain; an existing policy is never touched, whatever this says.

Example: true

Response · 200 The plan (dry run), or the plan with each record's `result` and the post-apply DNS check.

domain_id*string · uuid
fqdn*string · hostname

Example: "mail.example.com"

provider*enum (2)

Example: "cloudflare"

zone*string · hostname

The provider zone the records go into.

Example: "example.com"

dry_run*boolean

Example: true

applied*boolean

Whether records were written (false on a dry run).

Example: false

plan*array
conflicts*array

Every conflict in the plan, flattened, with the record it belongs to.

counts*object
create*integer

Example: 4

update*integer

Example: 0

skip*integer

Example: 0

failed*integer

Writes that failed (apply only).

Example: 0

warnings*array

`token_scope_broader_than_zone`: the token can see more than this zone; it worked, but revoke it now. `dmarc_lookup_failed`: we could not tell whether a DMARC policy applies, so DMARC was left out. `partially_applied`: some writes failed (see each item's `error`); run it again once fixed, finished records are skipped.

Example: ["token_scope_broader_than_zone"]

dmarc_available*boolean

No DMARC policy applies to this domain, so the optional DMARC record can be added (`include_dmarc`).

Example: true

dns_records*object

After an apply: the result of the DNS check run right away (new records are usually `propagating`). Null on a dry run.

Example

Request

POST /v1/workspaces/{workspace_id}/domains/{domain_id}/dns-records/apply
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "provider": "cloudflare",
  "token": "dop_v1_EXAMPLE_TOKEN_DO_NOT_USE",
  "zone": "example.com",
  "dry_run": true,
  "include_dmarc": true
}

Response

{
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "fqdn": "mail.example.com",
  "provider": "cloudflare",
  "zone": "example.com",
  "dry_run": true,
  "applied": false,
  "plan": [
    {
      "action": "create",
      "purpose": "dkim",
      "type": "TXT",
      "host": "productcraft._domainkey.mail.example.com",
      "name_relative": "productcraft._domainkey.mail",
      "value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqh…",
      "priority": null,
      "ttl": 3600,
      "required": true,
      "optional": false,
      "existing": [
        "string"
      ],
      "before": null,
      "after": "v=DKIM1; k=rsa; p=MIIBIjANBgkqh…",
      "note": null,
      "conflicts": [
        {
          "code": "spf_multiple",
          "severity": "blocking",
          "message": "string",
          "suggested_value": "v=spf1 include:amazonses.com include:_spf.google.com ~all"
        }
      ],
      "result": "created",
      "error": null
    }
  ],
  "conflicts": [
    {
      "code": "spf_multiple",
      "severity": "blocking",
      "message": "string",
      "suggested_value": "v=spf1 include:amazonses.com include:_spf.google.com ~all",
      "purpose": "mail_from_mx",
      "host": "bounce.mail.example.com"
    }
  ],
  "counts": {
    "create": 4,
    "update": 0,
    "skip": 0,
    "failed": 0
  },
  "warnings": [
    "token_scope_broader_than_zone"
  ],
  "dmarc_available": true,
  "dns_records": {}
}
post/v1/workspaces/{workspace_id}/domains/{domain_id}/domain-connect/startAuth

Start one-click DNS setup at the domain's DNS provider (Domain Connect).

Returns a signed Domain Connect URL for the provider that hosts the domain's zone (Cloudflare, GoDaddy, IONOS, Vercel and the others that have onboarded ProductCraft's template). Open it in the browser: the user signs in at their provider and confirms, the provider writes the DKIM key at `productcraft._domainkey.<domain>` and the MAIL FROM records at `bounce.<domain>` (MX to the regional feedback host, SPF merged into any existing SPF there), and then redirects to `GET /v1/domain-connect/callback`, which records `setup_method = domain_connect`, checks the records and returns the user to the domain in the console. With `include_dmarc` and no DMARC policy in place yet, a second confirmation adds `v=DMARC1; p=none;` at `_dmarc.<domain>`. The URL is valid for 10 minutes and works once. ProductCraft never receives a DNS credential. For sending domains whose MAIL FROM label is `bounce` (the default). Not available while ProductCraft has Domain Connect switched off (`domain_connect.available` in `GET .../domains/lookup` says whether it is).

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A replay returns the same URL, which still works only once.

Request body

include_dmarcboolean

Also add the recommended DMARC record (`v=DMARC1; p=none;` at `_dmarc.<domain>`) as a second confirmation at the provider. Ignored, and not offered, when a DMARC policy already applies to the domain or its organisational domain, when DMARC is not managed here (`dmarc_managed: false`), or when the provider has not onboarded the DMARC template.

Example: true

Response · 200 The signed apply URL to open in the browser.

url*string · uri

The signed Domain Connect apply URL at the DNS provider. Open it in the user's browser (a top-level navigation, not a fetch): the provider asks them to sign in and confirm, writes the records, and sends them back to ProductCraft, which checks the records and returns them to the domain's page in the console. Valid for 10 minutes and usable once.

Example: "https://dash.cloudflare.com/domainconnect/v2/domainTemplates/providers/productcraft.co/services/mail/apply?domain=example.com&host=mail&dkimkey=MIIBIjANBg...&redirect_uri=https%3A%2F%2Fapi.mail.productcraft.co%2Fv1%2Fdomain-connect%2Fcallback&state=3q2-7wVdZ6xYb9mJk1Lr0pQs4tUv8wXy&key=_dck1&sig=Qm9n..."

provider_name*string

The DNS provider the URL goes to, for the button label.

Example: "Cloudflare"

zone*string · hostname

The zone the records are written into.

Example: "example.com"

includes_dmarc*boolean

A second confirmation for the DMARC record follows the first one at the provider.

Example: true

expires_at*string · date-time

When the URL stops working (10 minutes).

Example

Request

POST /v1/workspaces/{workspace_id}/domains/{domain_id}/domain-connect/start
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "include_dmarc": true
}

Response

{
  "url": "https://dash.cloudflare.com/domainconnect/v2/domainTemplates/providers/productcraft.co/services/mail/apply?domain=example.com&host=mail&dkimkey=MIIBIjANBg...&redirect_uri=https%3A%2F%2Fapi.mail.productcraft.co%2Fv1%2Fdomain-connect%2Fcallback&state=3q2-7wVdZ6xYb9mJk1Lr0pQs4tUv8wXy&key=_dck1&sig=Qm9n...",
  "provider_name": "Cloudflare",
  "zone": "example.com",
  "includes_dmarc": true,
  "expires_at": "2026-01-01T00:00:00.000Z"
}
get/v1/domain-connect/callback

Domain Connect return URL (browser redirect target, not for API clients).

Where the DNS provider sends the user after they confirm or cancel. Consumes `state` (single use), and on success records `setup_method = domain_connect`, runs a DNS check and redirects to the domain's Records page in the console with `?domain_connect=applied`, or first to the provider again for the DMARC record when that was requested. A cancel or provider error redirects with `?domain_connect=error&reason=<code>` (`access_denied` when the user cancelled); an unknown, used or expired state redirects to the console with `reason=state_expired`. Never returns JSON.

Query parameters

statestring

The state ProductCraft put in the apply URL.

errorstring

Set by the provider when the user cancelled (`access_denied`) or the apply failed.

error_descriptionobject

Provider-supplied detail; ignored.

Response · 302

Redirect to the console (or, for the optional DMARC step, back to the DNS provider).

post/v1/workspaces/{workspace_id}/domains/{domain_id}/test-sendAuth

Send a built-in test email from this domain, no template needed.

Sends a fixed message (subject `[TEST] Sending from <domain> works`, a short body naming the domain and the From address) so a freshly verified domain can be tried before any template exists. The From address defaults to `test@<domain>` and must be at this exact domain. Like a template test send it skips the suppression list and the ordinary send limits, counts against the workspace test-send limit (30 per hour), and is recorded with `source: test_send`.

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

fromstring

Sender. Either a bare address (`hello@acme.com`) or a display name plus the address in angle brackets (`Acme <hello@acme.com>`; quote a name that contains a comma: `"Acme, Inc." <hello@acme.com>`). The display name is at most 200 characters. The address's domain must itself be an active (verified) sending domain of the workspace; a verified subdomain does not cover its parent (with only `mail.acme.com` verified, `hello@acme.com` is refused with 422 `from_domain_not_verified`). Must be an address at this exact domain. Defaults to `test@<domain>`.

Example: "Acme <hello@acme.com>"

to*string · email

Recipient of the test email. The suppression list is not checked for test sends; they count against the workspace test-send limit (30 per hour) instead of the send limits.

Example: "you@acme.com"

Response · 202 Accepted; queued for delivery.

id*string · uuid

Id of the stored message (`outbound_message.id`). Pass it to `GET /messages/{id}` and match it against `data.email_id` on webhook events. Null only if the message log write failed; the send itself was still queued.

Example: "4f8b2c1e-9a3d-4e57-b6f0-2d1c8e7a9b05"

accepted*boolean

Always true on a 202.

Example: true

from*string

The From field as sent, display name included.

Example: "Acme <hello@acme.com>"

to*object

The `to` field as sent: one address, or the array.

Example: "user@example.com"

subject*string
email_id*string · uuid

The email this send created (`GET /emails/{id}`): one email, one message per recipient. Equals `id` for a send whose first message was recorded.

Example: "4f8b2c1e-9a3d-4e57-b6f0-2d1c8e7a9b05"

Example

Request

POST /v1/workspaces/{workspace_id}/domains/{domain_id}/test-send
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "from": "Acme <hello@acme.com>",
  "to": "you@acme.com"
}

Response

{
  "id": "4f8b2c1e-9a3d-4e57-b6f0-2d1c8e7a9b05",
  "accepted": true,
  "from": "Acme <hello@acme.com>",
  "to": "user@example.com",
  "subject": "string",
  "email_id": "4f8b2c1e-9a3d-4e57-b6f0-2d1c8e7a9b05"
}

Public

get/v1/public/domain-setup/{token}

Public, read-only DNS setup for a shared domain (no authentication).

What a share link opens: the domain name, every DNS record it needs with its live state, the DKIM signing check, and download links. Nothing about the workspace. States are as of the last check (every 5 minutes while the domain is pending). Answers 404 for an unknown, expired or revoked link. Limited to 60 requests per minute per IP.

Path parameters

token*string

The share token from the link (43 characters, base64url).

Query parameters

zonestring · hostname

Render `name_relative` against this zone (the domain or one of its parents).

Response · 200

fqdn*string

Example: "mail.example.com"

status*enum (3)

`active` once the required records verify.

zone*string

Zone the relative names are rendered against.

Example: "example.com"

last_checked_at*string · date-time

When the records were last checked (every 5 minutes while the domain is pending, hourly once active).

expires_at*string · date-time

When this link stops working.

records*array
ses*object
dkim_status*enum (6)

DKIM signing verification of the DKIM record.

mail_from_status*enum (4)

Verification of the custom MAIL FROM host.

exports*object
bind*string · uri

BIND zone fragment of the records still to publish.

csv*string · uri

CSV of the records still to publish.

Example

Request

GET /v1/public/domain-setup/{token}

Response

{
  "fqdn": "mail.example.com",
  "status": "pending",
  "zone": "example.com",
  "last_checked_at": "2026-01-01T00:00:00.000Z",
  "expires_at": "2026-01-01T00:00:00.000Z",
  "records": [
    {
      "purpose": "dkim",
      "type": "TXT",
      "host": "productcraft._domainkey.mail.example.com",
      "name_relative": "productcraft._domainkey.mail",
      "value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqh…",
      "priority": 10,
      "ttl": 3600,
      "required": true,
      "state": "missing",
      "found": [
        "string"
      ],
      "conflicts": [
        {
          "code": "spf_multiple",
          "severity": "blocking",
          "message": "string",
          "suggested_value": "v=spf1 include:amazonses.com include:_spf.google.com ~all"
        }
      ],
      "hint": "string",
      "covered_by": "_dmarc.example.com",
      "last_checked_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "ses": {
    "dkim_status": "PENDING",
    "mail_from_status": "PENDING"
  },
  "exports": {
    "bind": "string",
    "csv": "string"
  }
}
get/v1/public/domain-setup/{token}/export

Download a shared domain's DNS records as a BIND zone fragment or a CSV (no authentication).

Same files as `GET .../domains/{domain_id}/dns-records/export`, under the share token. By default only records not yet verified; `all=true` for every record. 404 for an unknown, expired or revoked link. Shares the 60-per-minute IP limit.

Path parameters

token*string

The share token from the link (43 characters, base64url).

Query parameters

format*enum (2)
zonestring · hostname

BIND: the zone the names are relative to. Defaults to the detected zone, else the domain itself.

allboolean

Include records that already verify.

Response · 200

The file.

get/v1/u/{token}

Hosted unsubscribe and preferences page (no authentication).

What `{{{unsubscribe_url}}}` and the `List-Unsubscribe` header open: an HTML page in the workspace brand with an Unsubscribe button for the topic the email was sent on (or for everything), and the public topics as checkboxes. Opening it changes nothing. An unknown link answers 404 and an expired one (links last 365 days) 410, both as a page.

Path parameters

token*string

The token from the unsubscribe link (`u1.` followed by base64url).

Response · 200

The preferences page.

post/v1/u/{token}

One-click unsubscribe (RFC 8058) and the preferences form (no authentication).

The `List-Unsubscribe-Post: List-Unsubscribe=One-Click` target. A mailbox provider POSTs `List-Unsubscribe=One-Click` (form-encoded); the recipient is unsubscribed from the topic the email was sent on, or from all unsubscribable mail when it had no topic, and the answer is a short `200 text/plain`. No confirmation step, as RFC 8058 requires. The hosted page posts `action=unsubscribe` (same effect) or `action=save` with `topic` (repeated, the topic ids to stay subscribed to; unchecked public topics are opted out) and `all=1` (unsubscribe from everything), and gets the page back. Transactional mail (a send with no topic whose template has no unsubscribe link) is never affected. Emits `contact.updated`, and `contact.created` for a recipient who was not a contact yet.

Path parameters

token*string

The token from the unsubscribe link (`u1.` followed by base64url).

Request body

actionenum (2)
topicobject
allenum (1)

Response · 200

Unsubscribed (`text/plain` for one-click) or the updated page (`text/html`).

Example

Request

POST /v1/u/{token}
Content-Type: application/json

{
  "action": "unsubscribe",
  "topic": {},
  "all": "1"
}

Outbound-Webhooks

get/v1/workspaces/{workspace_id}/webhooksAuth

List the workspace default + every per-domain override. Plaintext signing secrets are NOT returned — only the last-4-char hint. Mint or rotate via the secret endpoints to receive the plaintext (once). Deprecated: use `/webhooks/endpoints` instead. Responses carry `Deprecation: true` and `Sunset: Fri, 15 Jan 2027 00:00:00 GMT`; after 2027-01-15 this route returns 410. The slot is an ordinary endpoint (`legacy_slot` set) in `GET /webhooks/endpoints`.

Path parameters

workspace_id*string · uuid

Response · 200

Array of object

id*string · uuid
workspace_id*string · uuid
domain_id*string · uuid

Null for the workspace-default config; set for a per-domain override.

url*string · uri
event_types*array
signing_secret_hint*string

Last 4 chars of the active signing secret.

prev_signing_secret_active*boolean

True while a previous signing secret is still being honoured (24h grace after rotation).

prev_signing_secret_expires_at*string · date-time
disabled_at*string · date-time

Set when auto-disabled after sustained failure.

disabled_reason*string
consecutive_failures*number
created_at*string · date-time
updated_at*string · date-time

Example

Request

GET /v1/workspaces/{workspace_id}/webhooks
Authorization: Bearer YOUR_TOKEN

Response

[
  {
    "id": "00000000-0000-0000-0000-000000000000",
    "workspace_id": "00000000-0000-0000-0000-000000000000",
    "domain_id": "00000000-0000-0000-0000-000000000000",
    "url": "string",
    "event_types": [
      "string"
    ],
    "signing_secret_hint": "string",
    "prev_signing_secret_active": false,
    "prev_signing_secret_expires_at": "2026-01-01T00:00:00.000Z",
    "disabled_at": "2026-01-01T00:00:00.000Z",
    "disabled_reason": "string",
    "consecutive_failures": 0,
    "created_at": "2026-01-01T00:00:00.000Z",
    "updated_at": "2026-01-01T00:00:00.000Z"
  }
]
get/v1/workspaces/{workspace_id}/webhooks/defaultAuth

Get the workspace-default webhook config (returns null if unset). Deprecated: use `/webhooks/endpoints` instead. Responses carry `Deprecation: true` and `Sunset: Fri, 15 Jan 2027 00:00:00 GMT`; after 2027-01-15 this route returns 410. The slot is an ordinary endpoint (`legacy_slot` set) in `GET /webhooks/endpoints`.

Path parameters

workspace_id*string · uuid

Response · 200 Workspace-default config, or null if not configured.

id*string · uuid
workspace_id*string · uuid
domain_id*string · uuid

Null for the workspace-default config; set for a per-domain override.

url*string · uri
event_types*array
signing_secret_hint*string

Last 4 chars of the active signing secret.

prev_signing_secret_active*boolean

True while a previous signing secret is still being honoured (24h grace after rotation).

prev_signing_secret_expires_at*string · date-time
disabled_at*string · date-time

Set when auto-disabled after sustained failure.

disabled_reason*string
consecutive_failures*number
created_at*string · date-time
updated_at*string · date-time

Example

Request

GET /v1/workspaces/{workspace_id}/webhooks/default
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "url": "string",
  "event_types": [
    "string"
  ],
  "signing_secret_hint": "string",
  "prev_signing_secret_active": false,
  "prev_signing_secret_expires_at": "2026-01-01T00:00:00.000Z",
  "disabled_at": "2026-01-01T00:00:00.000Z",
  "disabled_reason": "string",
  "consecutive_failures": 0,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
put/v1/workspaces/{workspace_id}/webhooks/defaultAuth

Create or replace the workspace-default outbound webhook. Returns the plaintext signing secret ONCE — store it now. Deprecated: use `/webhooks/endpoints` instead. Responses carry `Deprecation: true` and `Sunset: Fri, 15 Jan 2027 00:00:00 GMT`; after 2027-01-15 this route returns 410. The slot is an ordinary endpoint (`legacy_slot` set) in `GET /webhooks/endpoints`.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

url*string · uri

Destination URL. Must be absolute https:// with a public TLD.

Example: "https://api.acme.com/webhooks/mail"

event_types*array

Events the destination subscribes to: any name of the catalogue (`email.*`, `domain.*`, `suppression.*`; see `/webhooks/endpoints`), or a pre-042 `message.*` alias (`message.sent|delivered|deferred|bounced|complained|failed`, kept for one release), which is delivered under the alias name. Email events carry the message; `domain.*` events carry `data: { id, fqdn, status, health }`, where `health` is `ok | degraded | failing` (drift on a verified domain; `failing` = still broken 72 h later, sends refused). `domain.created` adds `intent` and `created_at`; `domain.verified` adds `verified_at`; `domain.updated` adds `changes: [{ purpose, from, to }]` (a record purpose such as `dkim`, `mx`, `mail_from_mx`, `dmarc` with its state `missing | mismatch | conflict | propagating | verified`, or `ses` / `ses_mail_from` with the signing / MAIL FROM check status) and fires only on a real change; `domain.deleted` is sent after the domain is removed.

Example: ["email.sent","email.bounced"]

Response · 200

id*string · uuid
workspace_id*string · uuid
domain_id*string · uuid

Null for the workspace-default config; set for a per-domain override.

url*string · uri
event_types*array
signing_secret_hint*string

Last 4 chars of the active signing secret.

prev_signing_secret_active*boolean

True while a previous signing secret is still being honoured (24h grace after rotation).

prev_signing_secret_expires_at*string · date-time
disabled_at*string · date-time

Set when auto-disabled after sustained failure.

disabled_reason*string
consecutive_failures*number
created_at*string · date-time
updated_at*string · date-time
signing_secret*string

Plaintext signing secret. Returned once on create / rotate; never reconstructable.

Example

Request

PUT /v1/workspaces/{workspace_id}/webhooks/default
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "url": "https://api.acme.com/webhooks/mail",
  "event_types": [
    "email.sent",
    "email.bounced"
  ]
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "url": "string",
  "event_types": [
    "string"
  ],
  "signing_secret_hint": "string",
  "prev_signing_secret_active": false,
  "prev_signing_secret_expires_at": "2026-01-01T00:00:00.000Z",
  "disabled_at": "2026-01-01T00:00:00.000Z",
  "disabled_reason": "string",
  "consecutive_failures": 0,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z",
  "signing_secret": "string"
}
delete/v1/workspaces/{workspace_id}/webhooks/defaultAuth

Delete the workspace-default webhook config. Deprecated: use `/webhooks/endpoints` instead. Responses carry `Deprecation: true` and `Sunset: Fri, 15 Jan 2027 00:00:00 GMT`; after 2027-01-15 this route returns 410. The slot is an ordinary endpoint (`legacy_slot` set) in `GET /webhooks/endpoints`.

Path parameters

workspace_id*string · uuid

Response · 204

Workspace-default webhook deleted.

post/v1/workspaces/{workspace_id}/webhooks/default/rotate-secretAuth

Rotate the workspace-default webhook signing secret. Previous secret remains valid for 24h. Plaintext returned once. Deprecated: use `/webhooks/endpoints` instead. Responses carry `Deprecation: true` and `Sunset: Fri, 15 Jan 2027 00:00:00 GMT`; after 2027-01-15 this route returns 410. The slot is an ordinary endpoint (`legacy_slot` set) in `GET /webhooks/endpoints`.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Response · 200

id*string · uuid
workspace_id*string · uuid
domain_id*string · uuid

Null for the workspace-default config; set for a per-domain override.

url*string · uri
event_types*array
signing_secret_hint*string

Last 4 chars of the active signing secret.

prev_signing_secret_active*boolean

True while a previous signing secret is still being honoured (24h grace after rotation).

prev_signing_secret_expires_at*string · date-time
disabled_at*string · date-time

Set when auto-disabled after sustained failure.

disabled_reason*string
consecutive_failures*number
created_at*string · date-time
updated_at*string · date-time
signing_secret*string

Plaintext signing secret. Returned once on create / rotate; never reconstructable.

Example

Request

POST /v1/workspaces/{workspace_id}/webhooks/default/rotate-secret
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "url": "string",
  "event_types": [
    "string"
  ],
  "signing_secret_hint": "string",
  "prev_signing_secret_active": false,
  "prev_signing_secret_expires_at": "2026-01-01T00:00:00.000Z",
  "disabled_at": "2026-01-01T00:00:00.000Z",
  "disabled_reason": "string",
  "consecutive_failures": 0,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z",
  "signing_secret": "string"
}
get/v1/workspaces/{workspace_id}/webhooks/domains/{domain_id}Auth

Get the per-domain webhook override (returns null if unset). Deprecated: use `/webhooks/endpoints` instead. Responses carry `Deprecation: true` and `Sunset: Fri, 15 Jan 2027 00:00:00 GMT`; after 2027-01-15 this route returns 410. The slot is an ordinary endpoint (`legacy_slot` set) in `GET /webhooks/endpoints`.

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Response · 200 Per-domain override, or null if not configured (an unset override is not a 404).

id*string · uuid
workspace_id*string · uuid
domain_id*string · uuid

Null for the workspace-default config; set for a per-domain override.

url*string · uri
event_types*array
signing_secret_hint*string

Last 4 chars of the active signing secret.

prev_signing_secret_active*boolean

True while a previous signing secret is still being honoured (24h grace after rotation).

prev_signing_secret_expires_at*string · date-time
disabled_at*string · date-time

Set when auto-disabled after sustained failure.

disabled_reason*string
consecutive_failures*number
created_at*string · date-time
updated_at*string · date-time

Example

Request

GET /v1/workspaces/{workspace_id}/webhooks/domains/{domain_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "url": "string",
  "event_types": [
    "string"
  ],
  "signing_secret_hint": "string",
  "prev_signing_secret_active": false,
  "prev_signing_secret_expires_at": "2026-01-01T00:00:00.000Z",
  "disabled_at": "2026-01-01T00:00:00.000Z",
  "disabled_reason": "string",
  "consecutive_failures": 0,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
put/v1/workspaces/{workspace_id}/webhooks/domains/{domain_id}Auth

Create or replace the per-domain webhook override. Returns the plaintext signing secret ONCE — store it now. Deprecated: use `/webhooks/endpoints` instead. Responses carry `Deprecation: true` and `Sunset: Fri, 15 Jan 2027 00:00:00 GMT`; after 2027-01-15 this route returns 410. The slot is an ordinary endpoint (`legacy_slot` set) in `GET /webhooks/endpoints`.

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

url*string · uri

Destination URL. Must be absolute https:// with a public TLD.

Example: "https://api.acme.com/webhooks/mail"

event_types*array

Events the destination subscribes to: any name of the catalogue (`email.*`, `domain.*`, `suppression.*`; see `/webhooks/endpoints`), or a pre-042 `message.*` alias (`message.sent|delivered|deferred|bounced|complained|failed`, kept for one release), which is delivered under the alias name. Email events carry the message; `domain.*` events carry `data: { id, fqdn, status, health }`, where `health` is `ok | degraded | failing` (drift on a verified domain; `failing` = still broken 72 h later, sends refused). `domain.created` adds `intent` and `created_at`; `domain.verified` adds `verified_at`; `domain.updated` adds `changes: [{ purpose, from, to }]` (a record purpose such as `dkim`, `mx`, `mail_from_mx`, `dmarc` with its state `missing | mismatch | conflict | propagating | verified`, or `ses` / `ses_mail_from` with the signing / MAIL FROM check status) and fires only on a real change; `domain.deleted` is sent after the domain is removed.

Example: ["email.sent","email.bounced"]

Response · 200

id*string · uuid
workspace_id*string · uuid
domain_id*string · uuid

Null for the workspace-default config; set for a per-domain override.

url*string · uri
event_types*array
signing_secret_hint*string

Last 4 chars of the active signing secret.

prev_signing_secret_active*boolean

True while a previous signing secret is still being honoured (24h grace after rotation).

prev_signing_secret_expires_at*string · date-time
disabled_at*string · date-time

Set when auto-disabled after sustained failure.

disabled_reason*string
consecutive_failures*number
created_at*string · date-time
updated_at*string · date-time
signing_secret*string

Plaintext signing secret. Returned once on create / rotate; never reconstructable.

Example

Request

PUT /v1/workspaces/{workspace_id}/webhooks/domains/{domain_id}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "url": "https://api.acme.com/webhooks/mail",
  "event_types": [
    "email.sent",
    "email.bounced"
  ]
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "url": "string",
  "event_types": [
    "string"
  ],
  "signing_secret_hint": "string",
  "prev_signing_secret_active": false,
  "prev_signing_secret_expires_at": "2026-01-01T00:00:00.000Z",
  "disabled_at": "2026-01-01T00:00:00.000Z",
  "disabled_reason": "string",
  "consecutive_failures": 0,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z",
  "signing_secret": "string"
}
delete/v1/workspaces/{workspace_id}/webhooks/domains/{domain_id}Auth

Delete the per-domain webhook override. Deprecated: use `/webhooks/endpoints` instead. Responses carry `Deprecation: true` and `Sunset: Fri, 15 Jan 2027 00:00:00 GMT`; after 2027-01-15 this route returns 410. The slot is an ordinary endpoint (`legacy_slot` set) in `GET /webhooks/endpoints`.

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Response · 204

Per-domain webhook override deleted. Idempotent: also 204 when no override is configured, so there is no 404.

post/v1/workspaces/{workspace_id}/webhooks/domains/{domain_id}/rotate-secretAuth

Rotate the per-domain webhook signing secret. Previous secret remains valid for 24h. Plaintext returned once. Deprecated: use `/webhooks/endpoints` instead. Responses carry `Deprecation: true` and `Sunset: Fri, 15 Jan 2027 00:00:00 GMT`; after 2027-01-15 this route returns 410. The slot is an ordinary endpoint (`legacy_slot` set) in `GET /webhooks/endpoints`.

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Response · 200

id*string · uuid
workspace_id*string · uuid
domain_id*string · uuid

Null for the workspace-default config; set for a per-domain override.

url*string · uri
event_types*array
signing_secret_hint*string

Last 4 chars of the active signing secret.

prev_signing_secret_active*boolean

True while a previous signing secret is still being honoured (24h grace after rotation).

prev_signing_secret_expires_at*string · date-time
disabled_at*string · date-time

Set when auto-disabled after sustained failure.

disabled_reason*string
consecutive_failures*number
created_at*string · date-time
updated_at*string · date-time
signing_secret*string

Plaintext signing secret. Returned once on create / rotate; never reconstructable.

Example

Request

POST /v1/workspaces/{workspace_id}/webhooks/domains/{domain_id}/rotate-secret
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "url": "string",
  "event_types": [
    "string"
  ],
  "signing_secret_hint": "string",
  "prev_signing_secret_active": false,
  "prev_signing_secret_expires_at": "2026-01-01T00:00:00.000Z",
  "disabled_at": "2026-01-01T00:00:00.000Z",
  "disabled_reason": "string",
  "consecutive_failures": 0,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z",
  "signing_secret": "string"
}
get/v1/workspaces/{workspace_id}/webhooks/attemptsAuth

Delivery attempts across all of this workspace's webhook endpoints, newest first, cursor-paginated, optionally for one event or one endpoint. Includes status code, latency, response body (truncated to 1KB), and error if any.

Path parameters

workspace_id*string · uuid

Query parameters

limitnumber

Page size, 1–200 (default 50). Page further back with `cursor`.

cursorstring

Opaque cursor from the previous page's `pagination.next_cursor`. Omit for the first page.

event_idstring

Only the attempts for this event (`evt_<uuid>`).

endpoint_idstring · uuid

Only the attempts made to this endpoint.

Response · 200

data*array

Attempts, newest first.

pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /v1/workspaces/{workspace_id}/webhooks/attempts
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "event_id": "evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f",
      "event_type": "email.bounced",
      "attempt_number": 0,
      "status_code": 0,
      "error_message": "string",
      "response_body_truncated": "string",
      "latency_ms": 0,
      "attempted_at": "2026-01-01T00:00:00.000Z",
      "url": "string",
      "domain_id": "00000000-0000-0000-0000-000000000000",
      "endpoint_id": "00000000-0000-0000-0000-000000000000"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
get/v1/workspaces/{workspace_id}/webhooks/event-typesAuth

List the events an endpoint can subscribe to.

Each type with a plain description, its group and an example `data` object (what "Send test" delivers).

Path parameters

workspace_id*string · uuid

Response · 200

data*array

Every event type this product sends.

Example

Request

GET /v1/workspaces/{workspace_id}/webhooks/event-types
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "type": "email.sent",
      "description": "A user signed up or was created by an admin.",
      "category": "Users",
      "example": {},
      "emitted": false,
      "legacy_alias": "message.bounced"
    }
  ]
}
get/v1/workspaces/{workspace_id}/webhooks/endpointsAuth

List webhook endpoints, oldest first.

At most 10, so this is always one page. Signing secrets are never returned here, only their last 4 characters.

Path parameters

workspace_id*string · uuid

Response · 200

data*array

The endpoints, newest first.

pagination*object

Always one page (an endpoint limit applies).

Example

Request

GET /v1/workspaces/{workspace_id}/webhooks/endpoints
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "scope_type": "workspace",
      "scope_id": "string",
      "url": "https://api.acme.com/webhooks",
      "description": "Production receiver",
      "event_types": [],
      "enabled": false,
      "status": {},
      "disabled_at": "2026-01-01T00:00:00.000Z",
      "disabled_reason": {},
      "consecutive_failures": 0,
      "first_failure_at": "2026-01-01T00:00:00.000Z",
      "signing_secret_hint": "x9Q=",
      "prev_signing_secret_active": false,
      "prev_signing_secret_expires_at": "2026-01-01T00:00:00.000Z",
      "legacy_signature": false,
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z",
      "events": [
        "email.delivered"
      ],
      "domain_id": "00000000-0000-0000-0000-000000000000",
      "legacy_slot": "default"
    }
  ],
  "pagination": {}
}
post/v1/workspaces/{workspace_id}/webhooks/endpointsAuth

Add a webhook endpoint. Returns its signing secret once.

Deliveries are signed with the Standard Webhooks headers `webhook-id`, `webhook-timestamp` and `webhook-signature` (`v1,<base64 HMAC-SHA256 of "<id>.<timestamp>.<body>">`); the returned `whsec_…` secret works unchanged with any Standard Webhooks library. Failed deliveries are retried after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1-256 chars, `[A-Za-z0-9_-:]`) that makes a retry of this request safe for 24 hours. The same key with a different body returns 409 `IDEMPOTENCY_KEY_REUSE`.

Request body

url*string · uri

Where to send events: an absolute https:// URL on a public host.

Example: "https://api.acme.com/webhooks"

descriptionstring

A label for people.

Example: "Production receiver"

event_typesarray

Event types from `GET …/webhooks/event-types`. Empty or omitted means every event, including types added later. Older names (`message.sent`, `message.delivered`, `message.deferred`, `message.bounced`, `message.complained`, `message.failed`) are still accepted; an endpoint subscribed under one receives the event with that name as its `type`.

Example: ["email.sent","email.delivered"]

enabledboolean

Send `false` to create it switched off.

Example: true

eventsarray

Older name for `event_types`, accepted and returned until the legacy webhook routes are retired (Sunset: 2027-01-15). Send one of the two.

Example: ["email.delivered","email.bounced"]

domain_idstring · uuid

Only events about this domain. Omit or null for every event of the workspace.

Example: null

Response · 201

id*string · uuid

The endpoint's id.

scope_type*string

What owns the endpoint: `workspace`, `app` or `community`.

Example: "workspace"

scope_id*string

Id of the owning workspace, app or community.

url*string · uri

Where events are sent.

Example: "https://api.acme.com/webhooks"

description*string

A label for people; null when unset.

Example: "Production receiver"

event_types*array

Subscribed event types. Empty means every event, including types added later.

Example: []

enabled*boolean

The on/off switch (`PATCH … { enabled }`).

status*object

`enabled` receives events. `disabled` was switched off. `auto_disabled` was switched off after 20 failed deliveries in a row or 24 hours of failures; `PATCH { enabled: true }` turns it back on.

disabled_at*string · date-time

When it was switched off; null while enabled.

disabled_reason*object

`manual`: switched off by a caller. `failing`: switched off automatically after failed deliveries. Null while enabled.

consecutive_failures*number

Failed automatic deliveries in a row; a success resets it.

first_failure_at*string · date-time

Start of the current failure streak.

signing_secret_hint*string

Last 4 characters of the signing secret.

Example: "x9Q="

prev_signing_secret_active*boolean

True while the previous secret still signs too (after a rotation, for its grace period).

prev_signing_secret_expires_at*string · date-time

Until then the previous secret signs too (after a rotation).

legacy_signature*boolean

True for endpoints carried over from the product's previous webhook system: they also receive the old signature header until its sunset date.

created_at*string · date-time

When the endpoint was created.

updated_at*string · date-time

When the endpoint last changed.

events*array

Same as `event_types`. Older name for `event_types`, accepted and returned until the legacy webhook routes are retired (Sunset: 2027-01-15). Send one of the two.

Example: ["email.delivered"]

domain_id*string · uuid

Only events about this domain; null for every event of the workspace.

legacy_slot*enum (2)

Set on the rows behind the deprecated routes: `default` (`/webhooks/default`) and `domain` (`/webhooks/domains/{id}`, which receives its domain's events instead of the default). Null for an endpoint created here.

signing_secret*string

The signing secret, shown once: pass it unchanged to any Standard Webhooks verifier. Store it now; it cannot be shown again.

Example: "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"

Example

Request

POST /v1/workspaces/{workspace_id}/webhooks/endpoints
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "url": "https://api.acme.com/webhooks",
  "description": "Production receiver",
  "event_types": [
    "email.sent",
    "email.delivered"
  ],
  "enabled": true,
  "events": [
    "email.delivered",
    "email.bounced"
  ],
  "domain_id": null
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "scope_type": "workspace",
  "scope_id": "string",
  "url": "https://api.acme.com/webhooks",
  "description": "Production receiver",
  "event_types": [],
  "enabled": false,
  "status": {},
  "disabled_at": "2026-01-01T00:00:00.000Z",
  "disabled_reason": {},
  "consecutive_failures": 0,
  "first_failure_at": "2026-01-01T00:00:00.000Z",
  "signing_secret_hint": "x9Q=",
  "prev_signing_secret_active": false,
  "prev_signing_secret_expires_at": "2026-01-01T00:00:00.000Z",
  "legacy_signature": false,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z",
  "events": [
    "email.delivered"
  ],
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "legacy_slot": "default",
  "signing_secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}
get/v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}Auth

Get one webhook endpoint (without its secret).

The endpoint's URL, subscription, on/off state, failure streak and the last 4 characters of its signing secret. The full secret is only returned when the endpoint is created or its secret is rotated.

Path parameters

workspace_id*string · uuid
endpoint_id*string · uuid

Response · 200

id*string · uuid

The endpoint's id.

scope_type*string

What owns the endpoint: `workspace`, `app` or `community`.

Example: "workspace"

scope_id*string

Id of the owning workspace, app or community.

url*string · uri

Where events are sent.

Example: "https://api.acme.com/webhooks"

description*string

A label for people; null when unset.

Example: "Production receiver"

event_types*array

Subscribed event types. Empty means every event, including types added later.

Example: []

enabled*boolean

The on/off switch (`PATCH … { enabled }`).

status*object

`enabled` receives events. `disabled` was switched off. `auto_disabled` was switched off after 20 failed deliveries in a row or 24 hours of failures; `PATCH { enabled: true }` turns it back on.

disabled_at*string · date-time

When it was switched off; null while enabled.

disabled_reason*object

`manual`: switched off by a caller. `failing`: switched off automatically after failed deliveries. Null while enabled.

consecutive_failures*number

Failed automatic deliveries in a row; a success resets it.

first_failure_at*string · date-time

Start of the current failure streak.

signing_secret_hint*string

Last 4 characters of the signing secret.

Example: "x9Q="

prev_signing_secret_active*boolean

True while the previous secret still signs too (after a rotation, for its grace period).

prev_signing_secret_expires_at*string · date-time

Until then the previous secret signs too (after a rotation).

legacy_signature*boolean

True for endpoints carried over from the product's previous webhook system: they also receive the old signature header until its sunset date.

created_at*string · date-time

When the endpoint was created.

updated_at*string · date-time

When the endpoint last changed.

events*array

Same as `event_types`. Older name for `event_types`, accepted and returned until the legacy webhook routes are retired (Sunset: 2027-01-15). Send one of the two.

Example: ["email.delivered"]

domain_id*string · uuid

Only events about this domain; null for every event of the workspace.

legacy_slot*enum (2)

Set on the rows behind the deprecated routes: `default` (`/webhooks/default`) and `domain` (`/webhooks/domains/{id}`, which receives its domain's events instead of the default). Null for an endpoint created here.

Example

Request

GET /v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "scope_type": "workspace",
  "scope_id": "string",
  "url": "https://api.acme.com/webhooks",
  "description": "Production receiver",
  "event_types": [],
  "enabled": false,
  "status": {},
  "disabled_at": "2026-01-01T00:00:00.000Z",
  "disabled_reason": {},
  "consecutive_failures": 0,
  "first_failure_at": "2026-01-01T00:00:00.000Z",
  "signing_secret_hint": "x9Q=",
  "prev_signing_secret_active": false,
  "prev_signing_secret_expires_at": "2026-01-01T00:00:00.000Z",
  "legacy_signature": false,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z",
  "events": [
    "email.delivered"
  ],
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "legacy_slot": "default"
}
patch/v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}Auth

Change a webhook endpoint: URL, description, events, on/off.

Only the fields sent change. `enabled: true` (or a new URL) also clears an automatic disable. Switching off cancels queued retries. The secret is unchanged; rotate it with `POST …/rotate-secret`.

Path parameters

workspace_id*string · uuid
endpoint_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1-256 chars, `[A-Za-z0-9_-:]`) that makes a retry of this request safe for 24 hours. The same key with a different body returns 409 `IDEMPOTENCY_KEY_REUSE`.

Request body

urlstring · uri

A new absolute https:// URL on a public host.

Example: "https://api.acme.com/webhooks"

descriptionstring

A new label; null clears it.

Example: "Production receiver"

event_typesarray

Replaces the subscription. Event types from `GET …/webhooks/event-types`. Empty or omitted means every event, including types added later. Older names (`message.sent`, `message.delivered`, `message.deferred`, `message.bounced`, `message.complained`, `message.failed`) are still accepted; an endpoint subscribed under one receives the event with that name as its `type`.

Example: ["email.sent","email.delivered"]

enabledboolean

Switch it on or off. Switching on also clears an automatic disable.

Example: true

eventsarray

Replaces the subscription. Older name for `event_types`, accepted and returned until the legacy webhook routes are retired (Sunset: 2027-01-15). Send one of the two.

domain_idstring · uuid

Domain filter; null removes it. Not allowed on a legacy slot (its domain is what makes it one).

Response · 200

id*string · uuid

The endpoint's id.

scope_type*string

What owns the endpoint: `workspace`, `app` or `community`.

Example: "workspace"

scope_id*string

Id of the owning workspace, app or community.

url*string · uri

Where events are sent.

Example: "https://api.acme.com/webhooks"

description*string

A label for people; null when unset.

Example: "Production receiver"

event_types*array

Subscribed event types. Empty means every event, including types added later.

Example: []

enabled*boolean

The on/off switch (`PATCH … { enabled }`).

status*object

`enabled` receives events. `disabled` was switched off. `auto_disabled` was switched off after 20 failed deliveries in a row or 24 hours of failures; `PATCH { enabled: true }` turns it back on.

disabled_at*string · date-time

When it was switched off; null while enabled.

disabled_reason*object

`manual`: switched off by a caller. `failing`: switched off automatically after failed deliveries. Null while enabled.

consecutive_failures*number

Failed automatic deliveries in a row; a success resets it.

first_failure_at*string · date-time

Start of the current failure streak.

signing_secret_hint*string

Last 4 characters of the signing secret.

Example: "x9Q="

prev_signing_secret_active*boolean

True while the previous secret still signs too (after a rotation, for its grace period).

prev_signing_secret_expires_at*string · date-time

Until then the previous secret signs too (after a rotation).

legacy_signature*boolean

True for endpoints carried over from the product's previous webhook system: they also receive the old signature header until its sunset date.

created_at*string · date-time

When the endpoint was created.

updated_at*string · date-time

When the endpoint last changed.

events*array

Same as `event_types`. Older name for `event_types`, accepted and returned until the legacy webhook routes are retired (Sunset: 2027-01-15). Send one of the two.

Example: ["email.delivered"]

domain_id*string · uuid

Only events about this domain; null for every event of the workspace.

legacy_slot*enum (2)

Set on the rows behind the deprecated routes: `default` (`/webhooks/default`) and `domain` (`/webhooks/domains/{id}`, which receives its domain's events instead of the default). Null for an endpoint created here.

Example

Request

PATCH /v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "url": "https://api.acme.com/webhooks",
  "description": "Production receiver",
  "event_types": [
    "email.sent",
    "email.delivered"
  ],
  "enabled": true,
  "events": [
    "email.sent"
  ],
  "domain_id": "00000000-0000-0000-0000-000000000000"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "scope_type": "workspace",
  "scope_id": "string",
  "url": "https://api.acme.com/webhooks",
  "description": "Production receiver",
  "event_types": [],
  "enabled": false,
  "status": {},
  "disabled_at": "2026-01-01T00:00:00.000Z",
  "disabled_reason": {},
  "consecutive_failures": 0,
  "first_failure_at": "2026-01-01T00:00:00.000Z",
  "signing_secret_hint": "x9Q=",
  "prev_signing_secret_active": false,
  "prev_signing_secret_expires_at": "2026-01-01T00:00:00.000Z",
  "legacy_signature": false,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z",
  "events": [
    "email.delivered"
  ],
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "legacy_slot": "default"
}
delete/v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}Auth

Delete a webhook endpoint.

Queued retries are dropped. Its past attempts stay in the event log until the events expire.

Path parameters

workspace_id*string · uuid
endpoint_id*string · uuid

Response · 204

Deleted.

post/v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}/rotate-secretAuth

Rotate the signing secret. The new one is returned once.

For `grace_hours` (default 24) the previous secret keeps signing too: `webhook-signature` then lists one `v1,` signature per secret, so receivers can switch over without dropping a delivery.

Path parameters

workspace_id*string · uuid
endpoint_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1-256 chars, `[A-Za-z0-9_-:]`) that makes a retry of this request safe for 24 hours. The same key with a different body returns 409 `IDEMPOTENCY_KEY_REUSE`.

Request body

grace_hoursnumber

Hours the previous secret keeps signing too (0 ends it now).

Example: 24

Response · 200

id*string · uuid

The endpoint's id.

scope_type*string

What owns the endpoint: `workspace`, `app` or `community`.

Example: "workspace"

scope_id*string

Id of the owning workspace, app or community.

url*string · uri

Where events are sent.

Example: "https://api.acme.com/webhooks"

description*string

A label for people; null when unset.

Example: "Production receiver"

event_types*array

Subscribed event types. Empty means every event, including types added later.

Example: []

enabled*boolean

The on/off switch (`PATCH … { enabled }`).

status*object

`enabled` receives events. `disabled` was switched off. `auto_disabled` was switched off after 20 failed deliveries in a row or 24 hours of failures; `PATCH { enabled: true }` turns it back on.

disabled_at*string · date-time

When it was switched off; null while enabled.

disabled_reason*object

`manual`: switched off by a caller. `failing`: switched off automatically after failed deliveries. Null while enabled.

consecutive_failures*number

Failed automatic deliveries in a row; a success resets it.

first_failure_at*string · date-time

Start of the current failure streak.

signing_secret_hint*string

Last 4 characters of the signing secret.

Example: "x9Q="

prev_signing_secret_active*boolean

True while the previous secret still signs too (after a rotation, for its grace period).

prev_signing_secret_expires_at*string · date-time

Until then the previous secret signs too (after a rotation).

legacy_signature*boolean

True for endpoints carried over from the product's previous webhook system: they also receive the old signature header until its sunset date.

created_at*string · date-time

When the endpoint was created.

updated_at*string · date-time

When the endpoint last changed.

events*array

Same as `event_types`. Older name for `event_types`, accepted and returned until the legacy webhook routes are retired (Sunset: 2027-01-15). Send one of the two.

Example: ["email.delivered"]

domain_id*string · uuid

Only events about this domain; null for every event of the workspace.

legacy_slot*enum (2)

Set on the rows behind the deprecated routes: `default` (`/webhooks/default`) and `domain` (`/webhooks/domains/{id}`, which receives its domain's events instead of the default). Null for an endpoint created here.

signing_secret*string

The signing secret, shown once: pass it unchanged to any Standard Webhooks verifier. Store it now; it cannot be shown again.

Example: "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"

Example

Request

POST /v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}/rotate-secret
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "grace_hours": 24
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "scope_type": "workspace",
  "scope_id": "string",
  "url": "https://api.acme.com/webhooks",
  "description": "Production receiver",
  "event_types": [],
  "enabled": false,
  "status": {},
  "disabled_at": "2026-01-01T00:00:00.000Z",
  "disabled_reason": {},
  "consecutive_failures": 0,
  "first_failure_at": "2026-01-01T00:00:00.000Z",
  "signing_secret_hint": "x9Q=",
  "prev_signing_secret_active": false,
  "prev_signing_secret_expires_at": "2026-01-01T00:00:00.000Z",
  "legacy_signature": false,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z",
  "events": [
    "email.delivered"
  ],
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "legacy_slot": "default",
  "signing_secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}
post/v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}/testAuth

Send a test event to this endpoint now and return the result.

Delivers the catalogue example of `event_type`, signed like any delivery, with `"test": true` in the body. Works while the endpoint is switched off or not subscribed to that type, is logged in the event log, and is never retried or counted toward an automatic disable. Waits up to 15 s for your receiver.

Path parameters

workspace_id*string · uuid
endpoint_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1-256 chars, `[A-Za-z0-9_-:]`) that makes a retry of this request safe for 24 hours. The same key with a different body returns 409 `IDEMPOTENCY_KEY_REUSE`.

Request body

event_type*enum (23)

Which event to send; its catalogue example is the `data`.

Example: "email.sent"

Response · 200 The attempt, successful or not.

id*string · uuid

The attempt's id.

event_id*string

The event that was delivered.

Example: "evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f"

endpoint_id*string · uuid

The endpoint it was delivered to.

event_type*string

The event's type.

Example: "user.created"

attempt_number*number

1 for the first delivery; retries and replays count up.

trigger*object

`automatic` (first delivery or a retry), `replay` or `test`.

url*string · uri

Where it was sent.

status_code*number

HTTP status; null when no response came back.

succeeded*boolean

True for a 2xx response.

error_message*string

Why the attempt failed when no 2xx came back; null on success.

Example: "request timed out"

response_body*string

The first 1 KiB of the response body.

latency_ms*number

Milliseconds until the response; null when none came back.

attempted_at*string · date-time

When the attempt was made.

response_body_truncated*string

Same as `response_body` (the first 1 KiB of the response); the older name.

domain_id*string · uuid

The endpoint's domain filter, if it has one.

Example

Request

POST /v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}/test
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "event_type": "email.sent"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "event_id": "evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f",
  "endpoint_id": "00000000-0000-0000-0000-000000000000",
  "event_type": "user.created",
  "attempt_number": 0,
  "trigger": {},
  "url": "string",
  "status_code": 0,
  "succeeded": false,
  "error_message": "request timed out",
  "response_body": "string",
  "latency_ms": 0,
  "attempted_at": "2026-01-01T00:00:00.000Z",
  "response_body_truncated": "string",
  "domain_id": "00000000-0000-0000-0000-000000000000"
}
get/v1/workspaces/{workspace_id}/webhooks/eventsAuth

List webhook events, newest first (kept for 30 days).

Every event logged while at least one endpoint existed, with the latest state per endpoint and when the next retry is due.

Path parameters

workspace_id*string · uuid

Query parameters

typearray

Only these types. Repeat the parameter or comma-separate.

endpoint_idstring · uuid

Only events sent (or queued) to this endpoint.

statusenum (4)

Only events in this delivery status.

created_afterstring · date-time

Only events created at or after this instant.

created_beforestring · date-time

Only events created before this instant.

limitinteger

Page size.

cursorstring

The previous page's `pagination.next_cursor`.

email_idstring · uuid

Only events about this email (`outbound_message.id`).

domain_idstring · uuid

Only events about this domain.

Response · 200

data*array

The events, newest first.

pagination*object

The cursor for the next page.

Example

Request

GET /v1/workspaces/{workspace_id}/webhooks/events
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f",
      "type": "email.sent",
      "created_at": "2026-01-01T00:00:00.000Z",
      "test": false,
      "status": {},
      "payload": {},
      "deliveries": [
        {
          "endpoint_id": "00000000-0000-0000-0000-000000000000",
          "attempt_number": 0,
          "status_code": 0,
          "succeeded": false,
          "attempted_at": "2026-01-01T00:00:00.000Z",
          "next_attempt_at": "2026-01-01T00:00:00.000Z"
        }
      ],
      "email_id": "00000000-0000-0000-0000-000000000000",
      "domain_id": "00000000-0000-0000-0000-000000000000"
    }
  ],
  "pagination": {}
}
get/v1/workspaces/{workspace_id}/webhooks/events/{event_id}Auth

Get one webhook event with every delivery attempt.

The event as it was sent (`type`, `data`) and every delivery attempt to every endpoint, with its trigger, HTTP status, error and the start of the response body. Use it to debug a receiver, then `replay` to send it again.

Path parameters

workspace_id*string · uuid
event_id*string

The event id, `evt_<uuid>`.

Response · 200

id*string

Sent as `webhook-id`; the same on every retry and replay.

Example: "evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f"

type*object

The event type (one of this product's catalogue).

Example: "email.sent"

created_at*string · date-time

When the event happened.

test*boolean

Made by "Send test".

status*object

`pending`: a delivery is queued. `failed`: the latest attempt to some endpoint failed and none is queued. `delivered`: every endpoint answered 2xx. `not_sent`: no endpoint subscribed.

payload*object

The JSON body exactly as sent.

deliveries*array

Latest attempt per endpoint (an endpoint whose first attempt is still queued is not listed yet).

email_id*string · uuid

The email the event is about, when there is one.

domain_id*string · uuid

The domain the event is about.

attempts*array

Every attempt, newest first.

Example

Request

GET /v1/workspaces/{workspace_id}/webhooks/events/{event_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f",
  "type": "email.sent",
  "created_at": "2026-01-01T00:00:00.000Z",
  "test": false,
  "status": {},
  "payload": {},
  "deliveries": [
    {
      "endpoint_id": "00000000-0000-0000-0000-000000000000",
      "attempt_number": 0,
      "status_code": 0,
      "succeeded": false,
      "attempted_at": "2026-01-01T00:00:00.000Z",
      "next_attempt_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "email_id": "00000000-0000-0000-0000-000000000000",
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "attempts": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "event_id": "evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f",
      "endpoint_id": "00000000-0000-0000-0000-000000000000",
      "event_type": "user.created",
      "attempt_number": 0,
      "trigger": {},
      "url": "string",
      "status_code": 0,
      "succeeded": false,
      "error_message": "request timed out",
      "response_body": "string",
      "latency_ms": 0,
      "attempted_at": "2026-01-01T00:00:00.000Z",
      "response_body_truncated": "string",
      "domain_id": "00000000-0000-0000-0000-000000000000"
    }
  ]
}
post/v1/workspaces/{workspace_id}/webhooks/events/{event_id}/replayAuth

Send a logged event again, to its endpoints or to one endpoint.

Same body and `webhook-id`, freshly signed, to every switched-on endpoint that receives this event type now, or only to `endpoint_id`. Waits for the receivers (up to 15 s each) and returns the attempts. A success cancels a retry still queued for that endpoint.

Path parameters

workspace_id*string · uuid
event_id*string

The event id, `evt_<uuid>`.

Query parameters

endpoint_idstring · uuid

Send to this endpoint only.

Headers

Idempotency-Keystring

Opaque client-generated string (1-256 chars, `[A-Za-z0-9_-:]`) that makes a retry of this request safe for 24 hours. The same key with a different body returns 409 `IDEMPOTENCY_KEY_REUSE`.

Response · 200

event_id*string

The replayed event (the same id as the original).

attempts*array

One attempt per endpoint it was sent to.

Example

Request

POST /v1/workspaces/{workspace_id}/webhooks/events/{event_id}/replay
Authorization: Bearer YOUR_TOKEN

Response

{
  "event_id": "string",
  "attempts": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "event_id": "evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f",
      "endpoint_id": "00000000-0000-0000-0000-000000000000",
      "event_type": "user.created",
      "attempt_number": 0,
      "trigger": {},
      "url": "string",
      "status_code": 0,
      "succeeded": false,
      "error_message": "request timed out",
      "response_body": "string",
      "latency_ms": 0,
      "attempted_at": "2026-01-01T00:00:00.000Z",
      "response_body_truncated": "string",
      "domain_id": "00000000-0000-0000-0000-000000000000"
    }
  ]
}

Stats

get/v1/workspaces/{workspace_id}/statsAuth

Mail stats for a workspace

Outbound sent, delivered, bounced, complained, deferred, suppressed and unconfirmed counts in the window, the deliverability, bounce and complaint rates, domain totals, the last send time and a daily sent series. For ranges, buckets, filters and a per-domain breakdown use `GET /emails/metrics`; the rates here use its definitions. `delivered` means the recipient's mail server confirmed the message; `unconfirmed` means the message was handed off and nothing came back either way, which is not the same as delivered. Days are UTC. Rates are 0 to 1, or null when not measured: delivery and complaints are only reported for mail with delivery tracking. Outbound messages are kept for 30 days, so 30d is the longest window. Cached for up to 60 seconds (`generated_at`).

Path parameters

workspace_id*string · uuid

Workspace id.

Query parameters

windowenum (3)

Window for `in_window` and the length of `series`, in UTC days ending today. 30d is the maximum because outbound messages are kept for 30 days.

Response · 200 Mail stats.

window*enum (3)

Window the counts cover.

Example: "30d"

generated_at*string · date-time

When these numbers were computed. Responses are cached for up to 60 seconds.

Example: "2026-10-07T09:00:00.000Z"

last_activity_at*string · date-time

Most recent outbound message `created_at` still on record (30-day retention). Null when none.

Example: "2026-10-07T08:41:12.000Z"

totals*object

All-time counts.

in_window*object

Outbound counts in the window.

series*object

Daily sent messages.

Example

Request

GET /v1/workspaces/{workspace_id}/stats
Authorization: Bearer YOUR_TOKEN

Response

{
  "window": "30d",
  "generated_at": "2026-10-07T09:00:00.000Z",
  "last_activity_at": "2026-10-07T08:41:12.000Z",
  "totals": {},
  "in_window": {},
  "series": {}
}

Metrics

get/v1/workspaces/{workspace_id}/emails/metricsAuth

Email metrics for a workspace

Outbound email counts and rates over a range of up to 30 days, in hourly or daily buckets in your time zone, optionally per sending domain, filtered by domain, API key or source. Read-only. - **Emails** (`sent`): messages that left the queue. - **Deliverability rate**: delivered / emails sent with delivery tracking. Emails sent before delivery tracking started, or through a custom SMTP relay, are confirmed at hand-off only, so for them `delivered` is not measured and the rate is `null` (`measured.delivery = false`), never 0. - **Bounce rate**: bounced / emails. Suppressed recipients never count. - **Complaint rate**: complained / delivered, from mailbox providers' feedback loops. Rates are numbers from 0 to 1, or `null` when unmeasured or when the denominator is zero. Template test sends are excluded. Cached for up to 60 seconds.

Path parameters

workspace_id*string · uuid

Workspace id.

Query parameters

start_datestring

Start of the range, inclusive. ISO 8601. A bare date (`2026-10-12`) is that calendar day in `timezone`; a date-time without an offset is a wall time in `timezone`; one with `Z` or an offset is that instant. Default: midnight six days before today, so the default range is the last 7 days including today. Must be within the 30-day message retention.

end_datestring

End of the range, exclusive. ISO 8601. A bare date (`2026-10-12`) is that calendar day in `timezone`; a date-time without an offset is a wall time in `timezone`; one with `Z` or an offset is that instant. A bare date includes that whole day. Default: now. At most 30 days after `start_date`.

timezonestring

IANA time zone the buckets and bare dates follow (DST-aware). Default `UTC`.

granularityenum (2)

Bucket width. Default `hourly` when the range is 2 days or less, otherwise `daily`. `hourly` is limited to ranges of 7 days or less.

domain_idarray

Only mail sent from these domains (`domain.id`). Repeat the parameter (with or without `[]`) or comma-separate the values; at most 100.

api_key_idstring · uuid

Only mail sent with this Platform API Key. Keys are recorded from 2026-10-12; older mail has none.

sourceenum (3)

Only mail sent this way: `api_key` (a Platform API Key), `dashboard` (a signed-in person, in the console or through MCP), `platform` (in-cluster product mail, and every message sent before 2026-10-12). Template test sends are never counted.

dimensionsarray

How `data` is broken down. `period`: one item per bucket (dense, zero-filled). `domain`: one item per sending domain. Both: one item per bucket and domain. Default `period`. Repeat the parameter (with or without `[]`) or comma-separate the values.

Response · 200 Email metrics.

object*enum (1)

Object type.

Example: "metrics"

start_date*string · date-time

Resolved start of the range (inclusive), in `timezone`’s offset.

Example: "2026-10-06T00:00:00+01:00"

end_date*string · date-time

Resolved end of the range (exclusive), in `timezone`’s offset.

Example: "2026-10-12T15:00:00+01:00"

timezone*string

IANA time zone of the buckets.

Example: "Europe/Lisbon"

granularity*enum (2)

Bucket width used.

Example: "daily"

measured*object

Which rates this range can measure at all.

bounce_source*enum (4)

How bounces were captured for the sent mail: `ses` (delivery tracking; bounces matched by message id), `relay` (emails sent before delivery tracking started, or through a custom SMTP relay; partial capture), `mixed`, or `none` (nothing sent).

Example: "ses"

totals*object

Counts and rates over the whole range.

data*array

The breakdown `dimensions` asks for, oldest bucket first, then by domain name. With `period`, every bucket is present (zero-filled).

Example

Request

GET /v1/workspaces/{workspace_id}/emails/metrics
Authorization: Bearer YOUR_TOKEN

Response

{
  "object": "metrics",
  "start_date": "2026-10-06T00:00:00+01:00",
  "end_date": "2026-10-12T15:00:00+01:00",
  "timezone": "Europe/Lisbon",
  "granularity": "daily",
  "measured": {},
  "bounce_source": "ses",
  "totals": {},
  "data": [
    {
      "sent": 29037,
      "delivered": 1240,
      "deferred": 3,
      "bounced": 12,
      "complained": 1,
      "failed": 0,
      "suppressed": 5,
      "unconfirmed": 60,
      "tracked": 1273,
      "delivery_rate": 0.974,
      "bounce_rate": 0.0185,
      "complaint_rate": 0.0001,
      "period": "2026-10-12T00:00:00+01:00",
      "domain_id": "3f6b2a1e-8c4d-4e5f-9a0b-1c2d3e4f5a6b",
      "domain_name": "mail.example.com"
    }
  ]
}

Smtp-Config

get/v1/workspaces/{workspace_id}/smtp-configAuth

Get the workspace-default SMTP config (returns null if unset). Per-domain overrides live under /workspaces/:id/domains/:domainId/smtp-config.

Path parameters

workspace_id*string · uuid

Response · 200

data*object

Null when this workspace has not configured SMTP.

Example

Request

GET /v1/workspaces/{workspace_id}/smtp-config
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": {}
}
put/v1/workspaces/{workspace_id}/smtp-configAuth

Create or update the workspace-default SMTP config (resets verification)

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

host*string

SMTP server hostname.

Example: "smtp.sendgrid.net"

port*number

SMTP server port (commonly 587 for STARTTLS, 465 for implicit TLS).

Example: 587

secure*boolean

Whether to use implicit TLS (true → port 465 style). For STARTTLS on 587, set false.

Example: false

usernamestring

SMTP username. May be omitted alongside password for anonymous SMTP (e.g. postfix on a private network); otherwise both are required.

passwordstring

SMTP password. Stored encrypted at rest; never returned by GET endpoints.

from_name*string

Default From name on outbound messages.

Example: "Acme Notifications"

from_email*string · email

Default From email address. Must be authorized to send on the chosen domain.

Example: "noreply@acme.com"

Response · 200

workspace_id*string · uuid
domain_id*string · uuid

Null for the workspace-default config, set for a per-domain override.

host*string
port*number
secure*boolean
username*string
password*string

Always null on the wire — the stored ciphertext is never returned.

password_set*boolean
from_name*string
from_email*string · email
status*enum (3)
last_verified_at*string · date-time
last_verify_error*string
created_at*string · date-time
updated_at*string · date-time

Example

Request

PUT /v1/workspaces/{workspace_id}/smtp-config
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "host": "smtp.sendgrid.net",
  "port": 587,
  "secure": false,
  "username": "string",
  "password": "string",
  "from_name": "Acme Notifications",
  "from_email": "noreply@acme.com"
}

Response

{
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "host": "string",
  "port": 0,
  "secure": false,
  "username": "string",
  "password": "string",
  "password_set": false,
  "from_name": "string",
  "from_email": "user@example.com",
  "status": "pending",
  "last_verified_at": "2026-01-01T00:00:00.000Z",
  "last_verify_error": "string",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/workspaces/{workspace_id}/smtp-configAuth

Revert the workspace to ProductCraft Mail's own sending

Path parameters

workspace_id*string · uuid

Response · 204

Workspace-default SMTP config deleted.

post/v1/workspaces/{workspace_id}/smtp-config/verifyAuth

Test arbitrary SMTP creds without persisting

Path parameters

workspace_id*string · uuid

Request body

host*string

SMTP server hostname.

Example: "smtp.sendgrid.net"

port*number

SMTP server port (commonly 587 for STARTTLS, 465 for implicit TLS).

Example: 587

secure*boolean

Whether to use implicit TLS (true → port 465 style). For STARTTLS on 587, set false.

Example: false

usernamestring

SMTP username. May be omitted alongside password for anonymous SMTP (e.g. postfix on a private network); otherwise both are required.

passwordstring

SMTP password. Stored encrypted at rest; never returned by GET endpoints.

from_name*string

Default From name on outbound messages.

Example: "Acme Notifications"

from_email*string · email

Default From email address. Must be authorized to send on the chosen domain.

Example: "noreply@acme.com"

Response · 200

ok*boolean
errorobject

Example

Request

POST /v1/workspaces/{workspace_id}/smtp-config/verify
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "host": "smtp.sendgrid.net",
  "port": 587,
  "secure": false,
  "username": "string",
  "password": "string",
  "from_name": "Acme Notifications",
  "from_email": "noreply@acme.com"
}

Response

{
  "ok": false,
  "error": {}
}
post/v1/workspaces/{workspace_id}/smtp-config/verify-savedAuth

Test the persisted workspace-default SMTP config; updates status

Path parameters

workspace_id*string · uuid

Response · 200

ok*boolean
errorobject

Example

Request

POST /v1/workspaces/{workspace_id}/smtp-config/verify-saved
Authorization: Bearer YOUR_TOKEN

Response

{
  "ok": false,
  "error": {}
}
get/v1/workspaces/{workspace_id}/smtp-configsAuth

List every SMTP config in the workspace (default + per-domain overrides)

Path parameters

workspace_id*string · uuid

Response · 200

data*array

Example

Request

GET /v1/workspaces/{workspace_id}/smtp-configs
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "workspace_id": "00000000-0000-0000-0000-000000000000",
      "domain_id": "00000000-0000-0000-0000-000000000000",
      "host": "string",
      "port": 0,
      "secure": false,
      "username": "string",
      "password": "string",
      "password_set": false,
      "from_name": "string",
      "from_email": "user@example.com",
      "status": "pending",
      "last_verified_at": "2026-01-01T00:00:00.000Z",
      "last_verify_error": "string",
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}
get/v1/workspaces/{workspace_id}/domains/{domain_id}/smtp-configAuth

Get the per-domain SMTP override (returns null if unset; the workspace default applies in that case).

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Response · 200

data*object

Null when this workspace has not configured SMTP.

Example

Request

GET /v1/workspaces/{workspace_id}/domains/{domain_id}/smtp-config
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": {}
}
put/v1/workspaces/{workspace_id}/domains/{domain_id}/smtp-configAuth

Create or update the per-domain SMTP override (resets verification)

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

host*string

SMTP server hostname.

Example: "smtp.sendgrid.net"

port*number

SMTP server port (commonly 587 for STARTTLS, 465 for implicit TLS).

Example: 587

secure*boolean

Whether to use implicit TLS (true → port 465 style). For STARTTLS on 587, set false.

Example: false

usernamestring

SMTP username. May be omitted alongside password for anonymous SMTP (e.g. postfix on a private network); otherwise both are required.

passwordstring

SMTP password. Stored encrypted at rest; never returned by GET endpoints.

from_name*string

Default From name on outbound messages.

Example: "Acme Notifications"

from_email*string · email

Default From email address. Must be authorized to send on the chosen domain.

Example: "noreply@acme.com"

Response · 200

workspace_id*string · uuid
domain_id*string · uuid

Null for the workspace-default config, set for a per-domain override.

host*string
port*number
secure*boolean
username*string
password*string

Always null on the wire — the stored ciphertext is never returned.

password_set*boolean
from_name*string
from_email*string · email
status*enum (3)
last_verified_at*string · date-time
last_verify_error*string
created_at*string · date-time
updated_at*string · date-time

Example

Request

PUT /v1/workspaces/{workspace_id}/domains/{domain_id}/smtp-config
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "host": "smtp.sendgrid.net",
  "port": 587,
  "secure": false,
  "username": "string",
  "password": "string",
  "from_name": "Acme Notifications",
  "from_email": "noreply@acme.com"
}

Response

{
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "host": "string",
  "port": 0,
  "secure": false,
  "username": "string",
  "password": "string",
  "password_set": false,
  "from_name": "string",
  "from_email": "user@example.com",
  "status": "pending",
  "last_verified_at": "2026-01-01T00:00:00.000Z",
  "last_verify_error": "string",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/workspaces/{workspace_id}/domains/{domain_id}/smtp-configAuth

Drop the per-domain override; sends from this domain fall back to the workspace default (or ProductCraft Mail's own sending).

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Response · 204

Per-domain override deleted.

post/v1/workspaces/{workspace_id}/domains/{domain_id}/smtp-config/verify-savedAuth

Test the per-domain SMTP override; updates status

Path parameters

workspace_id*string · uuid
domain_id*string · uuid

Response · 200

ok*boolean
errorobject

Example

Request

POST /v1/workspaces/{workspace_id}/domains/{domain_id}/smtp-config/verify-saved
Authorization: Bearer YOUR_TOKEN

Response

{
  "ok": false,
  "error": {}
}

Suppression

get/v1/workspaces/{workspace_id}/suppressionAuth

List suppressed recipients in the workspace (most-recent first), cursor-paginated.

Path parameters

workspace_id*string · uuid

Query parameters

limitinteger

Page size. Defaults to 20; values above 100 are clamped to 100.

cursorstring

Opaque cursor from the previous page's `pagination.next_cursor`. Omit for the first page.

searchstring

Case-insensitive substring match on the suppressed email address.

sourceenum (3)

Filter by where the row originated. Unrecognised values are rejected with a 400.

Response · 200

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /v1/workspaces/{workspace_id}/suppression
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "workspace_id": "00000000-0000-0000-0000-000000000000",
      "email": "user@example.com",
      "reason": "string",
      "source": "manual",
      "added_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
post/v1/workspaces/{workspace_id}/suppressionAuth

Add an email to the workspace suppression list

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

email*string · email

Address to suppress. Once listed, sends to it are dropped before delivery.

Example: "bounced@example.com"

reasonstring

Why it was suppressed. Free text, for the operator reading the list later.

Example: "hard bounce"

Response · 201

id*string · uuid

Suppression row id.

workspace_id*string · uuid

Workspace this suppression belongs to.

email*string · email

Suppressed recipient (lowercased on insert).

Example: "user@example.com"

reason*string

Free-text reason; null when unspecified.

source*enum (3)

Where the row originated.

added_at*string · date-time

Insertion (or last-touch) timestamp.

Example

Request

POST /v1/workspaces/{workspace_id}/suppression
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "email": "bounced@example.com",
  "reason": "hard bounce"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "email": "user@example.com",
  "reason": "string",
  "source": "manual",
  "added_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/workspaces/{workspace_id}/suppression/{email}Auth

Remove an address from the workspace suppression list.

Path parameters

workspace_id*string · uuid
email*string · email

The suppressed address (URL-encoded).

Response · 204

Suppression removed.


Mailboxes

get/v1/workspaces/{workspace_id}/mailboxesAuth

List mailboxes in this workspace. 20 per page by default (max 200) — paginate with the cursor returned in `pagination.next_cursor` while `pagination.has_more` is true.

Path parameters

workspace_id*string · uuid

Query parameters

limitnumber
cursorstring

Opaque pagination cursor

Response · 200

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /v1/workspaces/{workspace_id}/mailboxes
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "seq_id": "7",
      "workspace_id": "00000000-0000-0000-0000-000000000000",
      "domain_id": "00000000-0000-0000-0000-000000000000",
      "local_part": "string",
      "display_name": "string",
      "owner_account_id": "00000000-0000-0000-0000-000000000000",
      "retention_days": 14,
      "status": "active",
      "created_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
post/v1/workspaces/{workspace_id}/mailboxesAuth

Create a mailbox under a verified domain. Local-part + domain combination must be globally unique.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

local_part*string

Local part of the address (portion before @)

Example: "claude-qa-alpha"

domain_id*string · uuid

Domain ID.

retention_daysnumber

Retention period in days (1–30)

display_namestring

Human-readable display name

Response · 201

id*string · uuid
seq_id*string

Sequential id per workspace (stable, monotonic).

Example: "7"

workspace_id*string · uuid
domain_id*string · uuid
local_part*string
display_name*string
owner_account_id*string · uuid
retention_days*number

Retention period in days.

Example: 14

status*enum (2)

Example: "active"

created_at*string · date-time

Example

Request

POST /v1/workspaces/{workspace_id}/mailboxes
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "local_part": "claude-qa-alpha",
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "retention_days": 0,
  "display_name": "string"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "seq_id": "7",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "local_part": "string",
  "display_name": "string",
  "owner_account_id": "00000000-0000-0000-0000-000000000000",
  "retention_days": 14,
  "status": "active",
  "created_at": "2026-01-01T00:00:00.000Z"
}
get/v1/workspaces/{workspace_id}/mailboxes/{id}Auth

Get one mailbox by id.

Path parameters

workspace_id*string · uuid
id*string · uuid

Response · 200

id*string · uuid
seq_id*string

Sequential id per workspace (stable, monotonic).

Example: "7"

workspace_id*string · uuid
domain_id*string · uuid
local_part*string
display_name*string
owner_account_id*string · uuid
retention_days*number

Retention period in days.

Example: 14

status*enum (2)

Example: "active"

created_at*string · date-time

Example

Request

GET /v1/workspaces/{workspace_id}/mailboxes/{id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "seq_id": "7",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "local_part": "string",
  "display_name": "string",
  "owner_account_id": "00000000-0000-0000-0000-000000000000",
  "retention_days": 14,
  "status": "active",
  "created_at": "2026-01-01T00:00:00.000Z"
}
patch/v1/workspaces/{workspace_id}/mailboxes/{id}Auth

Update mailbox display name, retention, or status. Status transitions are restricted (active ↔ suspended; archived is terminal).

Path parameters

workspace_id*string · uuid
id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

display_namestring

Display name

retention_daysnumber

Retention period in days (1–30)

statusenum (2)

Mailbox status

Response · 200

id*string · uuid
seq_id*string

Sequential id per workspace (stable, monotonic).

Example: "7"

workspace_id*string · uuid
domain_id*string · uuid
local_part*string
display_name*string
owner_account_id*string · uuid
retention_days*number

Retention period in days.

Example: 14

status*enum (2)

Example: "active"

created_at*string · date-time

Example

Request

PATCH /v1/workspaces/{workspace_id}/mailboxes/{id}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "display_name": "string",
  "retention_days": 0,
  "status": "active"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "seq_id": "7",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "domain_id": "00000000-0000-0000-0000-000000000000",
  "local_part": "string",
  "display_name": "string",
  "owner_account_id": "00000000-0000-0000-0000-000000000000",
  "retention_days": 14,
  "status": "active",
  "created_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/workspaces/{workspace_id}/mailboxes/{id}Auth

Soft-delete a mailbox: its status becomes `disabled` and it stops accepting new mail; existing messages are kept until retention purges them, and `PATCH` with `status: active` restores it. A deleted mailbox no longer blocks deleting its domain, and deleting the domain erases it and its remaining mail.

Path parameters

workspace_id*string · uuid
id*string · uuid

Response · 204

Mailbox soft-deleted.


Messages

get/v1/workspaces/{workspace_id}/mailboxes/{id}/messagesAuth

List messages in a mailbox

Path parameters

workspace_id*string · uuid
id*string · uuid

Query parameters

fromstring

Filter by From address (substring match)

subjectstring

Filter by subject (substring match)

sincestring · date-time

Only include messages received on or after this ISO-8601 timestamp.

untilstring · date-time

Only include messages received on or before this ISO-8601 timestamp.

unreadboolean

Filter to only unread (true) or only read (false) messages

limitnumber
cursorstring

Opaque pagination cursor

Response · 200

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /v1/workspaces/{workspace_id}/mailboxes/{id}/messages
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "mailbox_id": "00000000-0000-0000-0000-000000000000",
      "seq_id": "42",
      "raw_object_key": "string",
      "envelope_to": "string",
      "from_address": "string",
      "from_local": "string",
      "from_domain": "string",
      "to_addresses": [
        "string"
      ],
      "cc_addresses": [
        "string"
      ],
      "subject": "string",
      "body_preview": "string",
      "rfc822_message_id": "string",
      "size_bytes": 0,
      "spam_score": "string",
      "spam_verdict": "string",
      "spf_result": "string",
      "dkim_result": "string",
      "dmarc_result": "string",
      "quarantined": false,
      "received_at": "2026-01-01T00:00:00.000Z",
      "read_at": "2026-01-01T00:00:00.000Z",
      "deleted_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
get/v1/workspaces/{workspace_id}/mailboxes/{id}/messages/{mid}Auth

Get a single message (metadata + attachment manifest; `body_preview` is null without `mail.message.body.read`). Use /:mid/raw for content.

Path parameters

workspace_id*string · uuid
id*string · uuid
mid*string · uuid

Response · 200

id*string · uuid
mailbox_id*string · uuid
seq_id*string

Sequential ID per mailbox.

Example: "42"

raw_object_key*string

Storage object key for the raw RFC5322 .eml.

envelope_to*string
from_address*string
from_local*string
from_domain*string
to_addresses*array
cc_addresses*array
subject*string
body_preview*string

First 512 bytes of the plaintext body. This is message content, not metadata, so it is only populated for callers holding `mail.message.body.read`; everyone else sees null. The full body is at /:mid/raw, behind the same action.

rfc822_message_id*string
size_bytes*number
spam_score*string
spam_verdict*string
spf_result*string
dkim_result*string
dmarc_result*string
quarantined*boolean
received_at*string · date-time
read_at*string · date-time
deleted_at*string · date-time
attachments*array

Example

Request

GET /v1/workspaces/{workspace_id}/mailboxes/{id}/messages/{mid}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "mailbox_id": "00000000-0000-0000-0000-000000000000",
  "seq_id": "42",
  "raw_object_key": "string",
  "envelope_to": "string",
  "from_address": "string",
  "from_local": "string",
  "from_domain": "string",
  "to_addresses": [
    "string"
  ],
  "cc_addresses": [
    "string"
  ],
  "subject": "string",
  "body_preview": "string",
  "rfc822_message_id": "string",
  "size_bytes": 0,
  "spam_score": "string",
  "spam_verdict": "string",
  "spf_result": "string",
  "dkim_result": "string",
  "dmarc_result": "string",
  "quarantined": false,
  "received_at": "2026-01-01T00:00:00.000Z",
  "read_at": "2026-01-01T00:00:00.000Z",
  "deleted_at": "2026-01-01T00:00:00.000Z",
  "attachments": [
    {
      "position": 0,
      "filename": "string",
      "content_id": "string",
      "content_type": "string",
      "size_bytes": 0,
      "sha256": "string"
    }
  ]
}
delete/v1/workspaces/{workspace_id}/mailboxes/{id}/messages/{mid}Auth

Soft-delete a message

Path parameters

workspace_id*string · uuid
id*string · uuid
mid*string · uuid

Response · 204

Message soft-deleted

get/v1/workspaces/{workspace_id}/mailboxes/{id}/messages/{mid}/rawAuth

Stream the raw RFC5322 .eml for a message. Gated by `mail.message.body.read` (admin+ by default; not granted to plain members) — the outbound lane's /messages/:id/body action.

Path parameters

workspace_id*string · uuid
id*string · uuid
mid*string · uuid

Response · 200

Raw RFC5322 message stream (`Content-Disposition: attachment`, `.eml`).

get/v1/workspaces/{workspace_id}/mailboxes/{id}/messages/{mid}/attachments/{position}Auth

Stream a single attachment by its position. Gated by `mail.message.body.read`: an attachment is message content, and /:mid already names it without handing over the bytes.

Path parameters

workspace_id*string · uuid
id*string · uuid
mid*string · uuid
position*number

Response · 200

Binary attachment stream. `Content-Type` reflects the stored attachment media type; `Content-Disposition: attachment`.

post/v1/workspaces/{workspace_id}/mailboxes/{id}/messages/{mid}/readAuth

Mark a message as read

Path parameters

workspace_id*string · uuid
id*string · uuid
mid*string · uuid

Response · 204

Marked read

get/v1/workspaces/{workspace_id}/messages/timelineAuth

Day-bucketed inbound message volume for a workspace

Counts INBOUND mail only: messages received by the workspace's mailboxes, in dense day buckets in [since, until). `metric=sent` (default, a historical name) counts received messages, not outbound sends; `metric=bounced` counts MX-time rejections matched to this workspace by envelope-to. For outbound counts and rates use `GET /emails/metrics`.

Path parameters

workspace_id*string · uuid

Query parameters

granularity*enum (1)

Bucket size. Only `day` is supported today; the param is here so future hour/week additions don’t break clients.

since*string · date-time

Inclusive start of the window (ISO-8601). Treated in UTC for bucket alignment.

until*string · date-time

Exclusive end of the window (ISO-8601).

metricenum (2)

Which counter to bucket. `sent` (default) reads from received messages; `bounced` reads from MX-time rejections matching this workspace's mailbox addresses.

Response · 200

granularity*enum (1)
metric*enum (2)
points*array

Dense day-bucketed counts for the requested window. Days with zero messages still appear with `count: 0`.

Example

Request

GET /v1/workspaces/{workspace_id}/messages/timeline
Authorization: Bearer YOUR_TOKEN

Response

{
  "granularity": "day",
  "metric": "sent",
  "points": [
    {
      "date": "2026-04-02",
      "count": 42
    }
  ]
}

Received

get/v1/workspaces/{workspace_id}/receivedAuth

List received email across every mailbox in the workspace

Newest first, cursor-paged, deleted email excluded. Filters combine with AND: `q` (sender, subject, envelope recipient or To address as a substring; or an exact id or Message-ID), `mailbox_id`, `domain_id`, `received_after` / `received_before` (at most 30 days apart), `spam`, `unread`. `GET /received/export` takes exactly the same filters and returns the same rows. No body content.

Path parameters

workspace_id*string · uuid

Query parameters

qstring

Free-text search, case-insensitive. Matches a substring of the sender, the subject, the envelope recipient or a To address, or exactly a received email id (uuid) or its RFC 822 Message-ID (with or without the angle brackets).

mailbox_idstring · uuid

Only email received by this mailbox.

domain_idstring · uuid

Only email received by mailboxes on this domain (`domain.id`).

received_afterstring · date-time

Only email received at or after this instant. With `received_before`, the window may span at most 30 days.

received_beforestring · date-time

Only email received before this instant. Without `received_after`, the window is the 30 days before it.

spamboolean

`true`: only email the spam filter quarantined (verdict `junk` or `reject`). `false`: only email it did not. Omit for both.

unreadboolean

`true`: only unread email. `false`: only email already marked read. Omit for both.

limitnumber

Page size, 1–100

cursorstring

Opaque cursor from the previous page's `pagination.next_cursor`. Omit for the first page.

Response · 200

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /v1/workspaces/{workspace_id}/received
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "7d9c1e2a-3b4c-4d5e-8f6a-7b8c9d0e1f2a",
      "mailbox_id": "00000000-0000-0000-0000-000000000000",
      "mailbox_address": "support@in.example.com",
      "from": "jane@example.org",
      "to": [
        "support@in.example.com"
      ],
      "cc": [],
      "subject": "Invoice #42",
      "rfc822_message_id": "<CAF1234@mail.gmail.com>",
      "received_at": "2026-01-01T00:00:00.000Z",
      "read_at": "2026-01-01T00:00:00.000Z",
      "spam_verdict": "ham",
      "spam": false,
      "spf_result": "pass",
      "dkim_result": "pass",
      "dmarc_result": "pass",
      "size_bytes": 5120,
      "attachment_count": 1
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
get/v1/workspaces/{workspace_id}/received/exportAuth

Stream the filtered received email as CSV. Same filters as GET /received. Rate-limited 1 concurrent + 5/hour per workspace.

Columns: id, received_at, mailbox, from, to (`; `-separated), subject, spam_verdict, spf, dkim, dmarc, size_bytes, attachments (count). No body content. A cell starting with `=`, `+`, `-` or `@` is prefixed with `'` so a spreadsheet does not run it as a formula. A rejected filter (400) does not count against the hourly cap, which is separate from the sent-email export's.

Path parameters

workspace_id*string · uuid

Query parameters

qstring

Free-text search, case-insensitive. Matches a substring of the sender, the subject, the envelope recipient or a To address, or exactly a received email id (uuid) or its RFC 822 Message-ID (with or without the angle brackets).

mailbox_idstring · uuid

Only email received by this mailbox.

domain_idstring · uuid

Only email received by mailboxes on this domain (`domain.id`).

received_afterstring · date-time

Only email received at or after this instant. With `received_before`, the window may span at most 30 days.

received_beforestring · date-time

Only email received before this instant. Without `received_after`, the window is the 30 days before it.

spamboolean

`true`: only email the spam filter quarantined (verdict `junk` or `reject`). `false`: only email it did not. Omit for both.

unreadboolean

`true`: only unread email. `false`: only email already marked read. Omit for both.

Response · 200

text/csv stream: one header row, then one row per email. Content-Disposition: attachment.

get/v1/workspaces/{workspace_id}/received/{id}Auth

Get one received email

Metadata, the authentication results, the envelope recipient and the attachment manifest. `body_preview` is null without `mail.message.body.read`; the full message is at `/received/{id}/raw`.

Path parameters

workspace_id*string · uuid
id*string · uuid

Received email id.

Response · 200

id*string · uuid

Example: "7d9c1e2a-3b4c-4d5e-8f6a-7b8c9d0e1f2a"

mailbox_id*string · uuid

The mailbox that received it.

mailbox_address*string · email

The receiving mailbox address (`local_part@domain`).

Example: "support@in.example.com"

from*string

Sender address (the From header).

Example: "jane@example.org"

to*array

Addresses in the To header.

Example: ["support@in.example.com"]

cc*array

Addresses in the Cc header.

Example: []

subject*string

Example: "Invoice #42"

rfc822_message_id*string

The RFC 822 Message-ID header, as received.

Example: "<CAF1234@mail.gmail.com>"

received_at*string · date-time
read_at*string · date-time

When it was marked read; null while unread.

spam_verdict*enum (3)

The spam filter's verdict; null when the filter did not run.

Example: "ham"

spam*boolean

True when the spam filter quarantined it (verdict `junk` or `reject`).

Example: false

spf_result*string

SPF result for the sender, e.g. `pass`, `fail`, `softfail`, `none`.

Example: "pass"

dkim_result*string

DKIM result, e.g. `pass`, `fail`, `none`.

Example: "pass"

dmarc_result*string

DMARC result, e.g. `pass`, `fail`, `none`.

Example: "pass"

size_bytes*number

Size of the raw message in bytes.

Example: 5120

attachment_count*number

Number of attachments.

Example: 1

envelope_to*string

The SMTP envelope recipient (RCPT TO).

Example: "support@in.example.com"

body_preview*string

First 512 bytes of the plain-text body. Message content, so it is only filled for callers holding `mail.message.body.read`; everyone else gets null. The full message is at `/received/{id}/raw`, behind the same action.

attachments*array

Example

Request

GET /v1/workspaces/{workspace_id}/received/{id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "7d9c1e2a-3b4c-4d5e-8f6a-7b8c9d0e1f2a",
  "mailbox_id": "00000000-0000-0000-0000-000000000000",
  "mailbox_address": "support@in.example.com",
  "from": "jane@example.org",
  "to": [
    "support@in.example.com"
  ],
  "cc": [],
  "subject": "Invoice #42",
  "rfc822_message_id": "<CAF1234@mail.gmail.com>",
  "received_at": "2026-01-01T00:00:00.000Z",
  "read_at": "2026-01-01T00:00:00.000Z",
  "spam_verdict": "ham",
  "spam": false,
  "spf_result": "pass",
  "dkim_result": "pass",
  "dmarc_result": "pass",
  "size_bytes": 5120,
  "attachment_count": 1,
  "envelope_to": "support@in.example.com",
  "body_preview": "string",
  "attachments": [
    {
      "position": 0,
      "filename": "invoice.pdf",
      "content_id": "string",
      "content_type": "application/pdf",
      "size_bytes": 48213,
      "sha256": "string"
    }
  ]
}
delete/v1/workspaces/{workspace_id}/received/{id}Auth

Delete a received email

Soft delete: it disappears from every list at once; retention purges the stored message later.

Path parameters

workspace_id*string · uuid
id*string · uuid

Received email id.

Response · 204

Deleted.

get/v1/workspaces/{workspace_id}/received/{id}/rawAuth

Download the raw RFC 5322 message (.eml)

Every header, the full body and every attachment inline. Gated by `mail.message.body.read` (admin and up by default), like `/mailboxes/{id}/messages/{mid}/raw`.

Path parameters

workspace_id*string · uuid
id*string · uuid

Received email id.

Response · 200

Raw RFC 5322 message stream (`Content-Disposition: attachment`, `.eml`).

get/v1/workspaces/{workspace_id}/received/{id}/attachments/{position}Auth

Download one attachment of a received email

By its `position` from the detail's attachment manifest. Unscanned, untrusted bytes from a third party. Gated by `mail.message.body.read`.

Path parameters

workspace_id*string · uuid
id*string · uuid

Received email id.

position*number

Attachment position (0-based).

Response · 200

Binary stream. `Content-Type` is the stored attachment media type; `Content-Disposition: attachment`.

post/v1/workspaces/{workspace_id}/received/{id}/readAuth

Mark a received email as read

Path parameters

workspace_id*string · uuid
id*string · uuid

Received email id.

Response · 204

Marked read (idempotent).


Templates

get/v1/workspaces/{workspace_id}/templatesAuth

List templates / layouts / partials in a workspace, name-ascending and cursor-paginated. Filter via ?kind=template|layout|partial (default: all), ?tags=foo,bar (comma-separated), ?search=<substring of the name>.

Path parameters

workspace_id*string · uuid

Query parameters

tagsstring

Comma-separated tag list. A template matches when any of its tags appears in the list.

kindenum (3)

Restrict to one kind. Unrecognised values are ignored (no kind filter applied), preserving the pre-pagination behaviour.

searchstring

Case-insensitive substring match on the template name.

limitinteger

Page size. Defaults to 20; values above 100 are clamped to 100.

cursorstring

Opaque cursor from the previous page's `pagination.next_cursor`. Omit for the first page.

Response · 200

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /v1/workspaces/{workspace_id}/templates
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "workspace_id": "00000000-0000-0000-0000-000000000000",
      "name": "string",
      "kind": "template",
      "subject": "string",
      "body_html": "string",
      "body_text": "string",
      "variables_schema": {},
      "tags": [
        "string"
      ],
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z",
      "source": "manual",
      "source_ref": "string",
      "source_synced_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
post/v1/workspaces/{workspace_id}/templatesAuth

Create or update a template, layout, or partial. Idempotent on (workspace_id, name); `kind` cannot change after the first upsert.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

name*string

Unique template name within the app (lowercase letters, digits, underscore, hyphen)

Example: "order-confirmation"

kindenum (3)

Kind: "template" (sendable email; default), "layout" (chrome wrapper referenced via {{> layout.<name>}}), "partial" (reusable block referenced via {{> partial.<name>}}). Cannot be changed after create — delete + recreate to switch kinds.

subject*string

Subject line (Handlebars). Layouts and partials ignore this field — they are not standalone-sendable. Pass an empty string for those.

body_html*string

HTML body (Handlebars)

body_textstring

Plain-text body (Handlebars). Auto-derived if omitted.

variables_schemaobject

Declared variable schema. Shape: `{version:1, vars:{<name>:{type, required?, default?, options?}}}`. When set, render + send validate the payload against the schema and 400 with descriptive errors on mismatch.

tagsarray

Workspace-scoped labels for organizing templates. Free-form strings; the editor surfaces existing tags as autocomplete.

Response · 201

id*string · uuid
workspace_id*string · uuid
name*string
kind*enum (3)
subject*string
body_html*string
body_text*string
variables_schema*object

Declared variable schema; arbitrary JSON or null if not configured.

tags*array
created_at*string · date-time
updated_at*string · date-time
source*enum (2)

Where this row came from. 'manual' for UI-authored, 'github' for rows maintained by the workspace GitHub integration sync. Migration 0020.

source_ref*string

Stable provider pointer used by the editor to build "Open on GitHub →" links. Shape: `github:<owner>/<repo>@<branch>:<path-in-repo>`.

source_synced_at*string · date-time

When the sync service last wrote this row. null for manual rows.

Example

Request

POST /v1/workspaces/{workspace_id}/templates
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "name": "order-confirmation",
  "kind": "template",
  "subject": "string",
  "body_html": "string",
  "body_text": "string",
  "variables_schema": {},
  "tags": [
    "string"
  ]
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "name": "string",
  "kind": "template",
  "subject": "string",
  "body_html": "string",
  "body_text": "string",
  "variables_schema": {},
  "tags": [
    "string"
  ],
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z",
  "source": "manual",
  "source_ref": "string",
  "source_synced_at": "2026-01-01T00:00:00.000Z"
}
get/v1/workspaces/{workspace_id}/templates/tagsAuth

Distinct tag list across all templates in this workspace (for autocomplete)

Path parameters

workspace_id*string · uuid

Response · 200

data*array

Example

Request

GET /v1/workspaces/{workspace_id}/templates/tags
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    "string"
  ]
}
get/v1/workspaces/{workspace_id}/templates/{name}Auth

Get a template, layout, or partial by name.

Path parameters

workspace_id*string · uuid
name*string

Response · 200

id*string · uuid
workspace_id*string · uuid
name*string
kind*enum (3)
subject*string
body_html*string
body_text*string
variables_schema*object

Declared variable schema; arbitrary JSON or null if not configured.

tags*array
created_at*string · date-time
updated_at*string · date-time
source*enum (2)

Where this row came from. 'manual' for UI-authored, 'github' for rows maintained by the workspace GitHub integration sync. Migration 0020.

source_ref*string

Stable provider pointer used by the editor to build "Open on GitHub →" links. Shape: `github:<owner>/<repo>@<branch>:<path-in-repo>`.

source_synced_at*string · date-time

When the sync service last wrote this row. null for manual rows.

Example

Request

GET /v1/workspaces/{workspace_id}/templates/{name}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "name": "string",
  "kind": "template",
  "subject": "string",
  "body_html": "string",
  "body_text": "string",
  "variables_schema": {},
  "tags": [
    "string"
  ],
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z",
  "source": "manual",
  "source_ref": "string",
  "source_synced_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/workspaces/{workspace_id}/templates/{name}Auth

Delete a template, layout, or partial by name.

Path parameters

workspace_id*string · uuid
name*string

Response · 204

Template deleted.

post/v1/workspaces/{workspace_id}/templates/{name}/renderAuth

Render a saved template against a data payload. Returns subject + html + text without sending.

Path parameters

workspace_id*string · uuid
name*string

Request body

data*object

Data object to render into the template

Example: {"name":"Alice","order_id":"ord_123"}

Response · 200

subject*string
html*string

Fully-rendered HTML body.

text*string

Plain-text body (derived if not authored).

Example

Request

POST /v1/workspaces/{workspace_id}/templates/{name}/render
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "data": {
    "name": "Alice",
    "order_id": "ord_123"
  }
}

Response

{
  "subject": "string",
  "html": "string",
  "text": "string"
}
post/v1/workspaces/{workspace_id}/templates/previewAuth

Preview an unsaved draft. Renders subject + bodyHtml against the workspace brand tokens + supplied data, with all workspace layouts/partials registered. Used by the editor for live preview.

Path parameters

workspace_id*string · uuid

Request body

subjectstring

Subject template (Handlebars).

body_html*string

HTML body to render (Handlebars).

dataobject

Sample data to render variables with.

Response · 200

subject*string
html*string

Fully-rendered HTML body.

text*string

Plain-text body (derived if not authored).

Example

Request

POST /v1/workspaces/{workspace_id}/templates/preview
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "subject": "string",
  "body_html": "string",
  "data": {}
}

Response

{
  "subject": "string",
  "html": "string",
  "text": "string"
}
post/v1/workspaces/{workspace_id}/templates/lintAuth

Lint a draft for deliverability red flags. Same render pipeline as /preview; returns a 0-100 score (higher is better) and a sorted list of findings (errors first). Self-developed heuristic linter, no external classifier — runs in-process.

Path parameters

workspace_id*string · uuid

Request body

subjectstring

Subject template (Handlebars).

body_html*string

HTML body to render (Handlebars).

dataobject

Sample data to render variables with.

Response · 200

score*number

0-100, higher is better. ≥90 "looks great"; <70 "likely to spam-folder".

findings*array

Example

Request

POST /v1/workspaces/{workspace_id}/templates/lint
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "subject": "string",
  "body_html": "string",
  "data": {}
}

Response

{
  "score": 0,
  "findings": [
    {
      "id": "string",
      "severity": "error",
      "message": "string",
      "suggestion": "string"
    }
  ]
}
get/v1/workspaces/{workspace_id}/templates/{name}/variablesAuth

Variables auto-detected from the saved template body + subject (Handlebars expressions, dotted paths preserved, dedup'd in first-occurrence order).

Path parameters

workspace_id*string · uuid
name*string

Response · 200

data*array

Variables auto-detected from the saved template body + subject (Handlebars expressions, dotted paths preserved).

Example

Request

GET /v1/workspaces/{workspace_id}/templates/{name}/variables
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    "string"
  ]
}
post/v1/workspaces/{workspace_id}/templates/{name}/test-sendAuth

Send a test rendering of the template to an arbitrary recipient. Bypasses the suppression list and the ordinary send limits (test sends have their own: 30 per hour per workspace); subject is auto-prefixed with [TEST]; missing variables render as [varname] placeholders.

Path parameters

workspace_id*string · uuid
name*string

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

from*string

Sender. Either a bare address (`hello@acme.com`) or a display name plus the address in angle brackets (`Acme <hello@acme.com>`; quote a name that contains a comma: `"Acme, Inc." <hello@acme.com>`). The display name is at most 200 characters. The address's domain must itself be an active (verified) sending domain of the workspace; a verified subdomain does not cover its parent (with only `mail.acme.com` verified, `hello@acme.com` is refused with 422 `from_domain_not_verified`). Same rules as a normal send.

Example: "Acme <hello@acme.com>"

to*string · email

Recipient address for the test email. Suppression-list and rate-limit checks are bypassed for test sends, so a recently-bounced address is still reachable for QA.

dataobject

Variable values to render into the template. Keys correspond to the dotted paths returned by the /variables endpoint.

Example: {"name":"Alice","code":"123456"}

Response · 202

id*string · uuid

Id of the stored message (`outbound_message.id`). Pass it to `GET /messages/{id}` and match it against `data.email_id` on webhook events. Null only if the message log write failed; the send itself was still queued.

Example: "4f8b2c1e-9a3d-4e57-b6f0-2d1c8e7a9b05"

accepted*boolean

Always true on a 202.

Example: true

from*string

The From field as sent, display name included.

Example: "Acme <hello@acme.com>"

to*object

The `to` field as sent: one address, or the array.

Example: "user@example.com"

subject*string
email_id*string · uuid

The email this send created (`GET /emails/{id}`): one email, one message per recipient. Equals `id` for a send whose first message was recorded.

Example: "4f8b2c1e-9a3d-4e57-b6f0-2d1c8e7a9b05"

Example

Request

POST /v1/workspaces/{workspace_id}/templates/{name}/test-send
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "from": "Acme <hello@acme.com>",
  "to": "user@example.com",
  "data": {
    "name": "Alice",
    "code": "123456"
  }
}

Response

{
  "id": "4f8b2c1e-9a3d-4e57-b6f0-2d1c8e7a9b05",
  "accepted": true,
  "from": "Acme <hello@acme.com>",
  "to": "user@example.com",
  "subject": "string",
  "email_id": "4f8b2c1e-9a3d-4e57-b6f0-2d1c8e7a9b05"
}
post/v1/workspaces/{workspace_id}/templates/{name}/sendAuth

Send a template-rendered message. Pass an `Idempotency-Key` header (1..256 chars, [A-Za-z0-9_\-:]) to make retries safe — replays return the original response with `Idempotent-Replay: true` for 24h. Same key + different request body returns 409 IDEMPOTENCY_KEY_REUSE.

Path parameters

workspace_id*string · uuid
name*string

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window.

Request body

data*object

Data object to render into the template

Example: {"name":"Alice","order_id":"ord_123"}

fromstring

Sender. Either a bare address (`hello@acme.com`) or a display name plus the address in angle brackets (`Acme <hello@acme.com>`; quote a name that contains a comma: `"Acme, Inc." <hello@acme.com>`). The display name is at most 200 characters. The address's domain must itself be an active (verified) sending domain of the workspace; a verified subdomain does not cover its parent (with only `mail.acme.com` verified, `hello@acme.com` is refused with 422 `from_domain_not_verified`). Optional: omit it to send from the workspace default sender (`GET /v1/workspaces/{id}/sending-defaults`); with no default set the send is refused with 422 `no_default_sender`.

Example: "Acme <hello@acme.com>"

to*object

Recipient: one address, or an array of up to 50 (tasks/mail-api/002). Each recipient gets its own copy (its own suppression check, message row and delivery status) showing the full `To` list. One address is the historical shape and behaves exactly as before.

Example: "user@example.com"

ccobject

Cc recipients: one address or an array. Shown in the `Cc` header to every recipient.

Example: ["finance@example.com"]

bccobject

Bcc recipients: one address or an array. Each gets the message; no header names them.

Example: ["audit@example.com"]

reply_toobject

Reply-To address(es): one address or an array. This is the only way to set `Reply-To`.

Example: "support@acme.com"

headersobject

Custom headers, as a map of header name to value, kept exactly as sent. Allowed: `X-*` (except `X-SES-*`), `List-Unsubscribe`, `List-Unsubscribe-Post`, `List-Id`, `In-Reply-To` and `References`; at most 50, values up to 998 characters with no line breaks. Any other header (`From`, `To`, `Cc`, `Bcc`, `Subject`, `Date`, `Message-ID`, `DKIM-Signature`, `Return-Path`, `Reply-To`, …) is a 400 `MAIL_HEADER_NOT_ALLOWED`: use the matching field (`reply_to` for `Reply-To`).

Example: {"x-entity-ref-id":"order-1234"}

tagsarray

Up to 10 `{ name, value }` labels, stored on every message of the send. Filter sends by them with `GET /emails?tag[name]=value`.

Example: [{"name":"category","value":"confirm_email"}]

subjectstring

Subject override (optional; uses template subject otherwise)

topic_idstring · uuid

Send as mail on this topic (tasks/mail-api/016). The recipient must be a contact subscribed to the topic: one who opted in, or, for a topic whose `default_subscription` is `opt_in`, one with a recorded consent who has not opted out. Anyone else is refused with 422 `RECIPIENT_SUPPRESSED` and `details.reason` `unsubscribe` (opted out of the topic or unsubscribed from everything) or `no_consent` (never opted in, or not a contact), and an `email.suppressed` event. A topic send carries a one-click unsubscribe link (`{{{unsubscribe_url}}}` in the template) and the `List-Unsubscribe` + `List-Unsubscribe-Post` headers, and goes out on the separate marketing stream. A `topic_id` that is not a topic of this workspace is a 422 `TOPIC_NOT_FOUND`.

Example: "3f2a1b0c-9d8e-4f7a-8b6c-5d4e3f2a1b0c"

Response · 202 Send accepted; queued for delivery.

id*string · uuid

Id of the stored message (`outbound_message.id`). Pass it to `GET /messages/{id}` and match it against `data.email_id` on webhook events. Null only if the message log write failed; the send itself was still queued.

Example: "4f8b2c1e-9a3d-4e57-b6f0-2d1c8e7a9b05"

accepted*boolean

Always true on a 202.

Example: true

from*string

The From field as sent, display name included.

Example: "Acme <hello@acme.com>"

to*object

The `to` field as sent: one address, or the array.

Example: "user@example.com"

subject*string
email_id*string · uuid

The email this send created (`GET /emails/{id}`): one email, one message per recipient. Equals `id` for a send whose first message was recorded.

Example: "4f8b2c1e-9a3d-4e57-b6f0-2d1c8e7a9b05"

Example

Request

POST /v1/workspaces/{workspace_id}/templates/{name}/send
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "data": {
    "name": "Alice",
    "order_id": "ord_123"
  },
  "from": "Acme <hello@acme.com>",
  "to": "user@example.com",
  "cc": [
    "finance@example.com"
  ],
  "bcc": [
    "audit@example.com"
  ],
  "reply_to": "support@acme.com",
  "headers": {
    "x-entity-ref-id": "order-1234"
  },
  "tags": [
    {
      "name": "category",
      "value": "confirm_email"
    }
  ],
  "subject": "string",
  "topic_id": "3f2a1b0c-9d8e-4f7a-8b6c-5d4e3f2a1b0c"
}

Response

{
  "id": "4f8b2c1e-9a3d-4e57-b6f0-2d1c8e7a9b05",
  "accepted": true,
  "from": "Acme <hello@acme.com>",
  "to": "user@example.com",
  "subject": "string",
  "email_id": "4f8b2c1e-9a3d-4e57-b6f0-2d1c8e7a9b05"
}
post/v1/workspaces/{workspace_id}/templates/{name}/send-batchAuth

Send up to 100 messages with the same template in one request. Per-row partial-success: each row carries its own status (`accepted` | `rejected`).

Two ways to make retries safe. An `Idempotency-Key` header covers the whole request: a retry with the same key and body within 24 h returns the first response (with `Idempotent-Replay: true`) and sends nothing. A per-row `idempotency_key` covers one message: a later batch, or a `/send`, that repeats a row key replays that row instead of sending it again. Both share one dedupe namespace, so the header key must differ from every row key (400 otherwise).

Path parameters

workspace_id*string · uuid
name*string

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

messages*array

Up to 100 messages to render and enqueue in a single call. Each is processed independently — partial successes are allowed.

Response · 200

data*array
summary*object
accepted*number
rejected*number

Example

Request

POST /v1/workspaces/{workspace_id}/templates/{name}/send-batch
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "messages": [
    {
      "from": "Acme <noreply@acme.com>",
      "to": "alice@example.com",
      "cc": [
        "finance@example.com"
      ],
      "bcc": [
        "audit@example.com"
      ],
      "reply_to": "support@acme.com",
      "headers": {
        "x-entity-ref-id": "order-1234"
      },
      "tags": [
        {
          "name": "category",
          "value": "confirm_email"
        }
      ],
      "subject": "string",
      "data": {
        "name": "Alice",
        "code": "A1B2C3"
      },
      "topic_id": "3f2a1b0c-9d8e-4f7a-8b6c-5d4e3f2a1b0c",
      "idempotency_key": "string"
    }
  ]
}

Response

{
  "data": [
    {
      "index": 0,
      "id": "00000000-0000-0000-0000-000000000000",
      "status": "accepted",
      "error": {}
    }
  ],
  "summary": {
    "accepted": 0,
    "rejected": 0
  }
}

Contacts

get/v1/workspaces/{workspace_id}/contacts/importsAuth

List contact imports, newest first

Path parameters

workspace_id*string · uuid

Query parameters

limitinteger

Page size. Defaults to 20; values above 100 are clamped to 100.

cursorstring

Opaque cursor from the previous page's `pagination.next_cursor`. Omit for the first page.

Response · 200

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /v1/workspaces/{workspace_id}/contacts/imports
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "status": "queued",
      "on_conflict": "upsert",
      "filename": "string",
      "size_bytes": 0,
      "consent_source": "string",
      "topic_ids": [
        "00000000-0000-0000-0000-000000000000"
      ],
      "total_rows": 0,
      "processed_rows": 0,
      "created_count": 0,
      "updated_count": 0,
      "skipped_count": 0,
      "error_count": 0,
      "consented_count": 0,
      "errors": [
        {
          "line": 0,
          "error": "invalid or missing email"
        }
      ],
      "failure_reason": "string",
      "created_at": "2026-01-01T00:00:00.000Z",
      "started_at": "2026-01-01T00:00:00.000Z",
      "completed_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
post/v1/workspaces/{workspace_id}/contacts/importsAuth

Import contacts from a CSV file

Queues the file and returns the import (`status: queued`); poll `GET .../contacts/imports/{id}` for progress. At most 10 MB and 100,000 rows, two imports running per workspace. Rows that assert consent (`consent` true or a `consented_at` date) are recorded with it and opted in to `topic_ids`; every other row starts without consent. An import emits no per-contact webhook events.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

csv*string

The CSV file content, UTF-8, at most 10 MB and 100,000 rows. The first line is a header with an `email` column. Optional columns: `first_name`, `last_name`, `unsubscribed` (true/false), `consent` (true/false), `consented_at` (ISO 8601), `consent_source`, and any defined contact property key. Headers are case-insensitive (`First Name` works). Other columns are ignored and reported. A row is consented only when it says so (`consent` true, or a `consented_at` date); every other imported contact starts without consent and receives only topics it later opts in to.

Example: "email,first_name,consent,consented_at\nada@example.com,Ada,true,2026-10-01T09:00:00Z\n"

on_conflict*enum (2)

`upsert` updates an existing contact (provided fields; properties merge); `skip` leaves existing contacts unchanged. Either way an import never re-subscribes a contact that unsubscribed and never overrides a topic opt-out.

consent_sourcestring

Recorded as `consent_source` on consented rows that have no `consent_source` column value. Defaults to `import`.

Example: "webinar-2026-10"

topic_idsarray

Opt the consented rows in to these topics (existing opt-outs win).

filenamestring

The file name, for display.

Example: "newsletter.csv"

Response · 202 Queued.

id*string · uuid
status*enum (4)
on_conflict*enum (2)
filename*string
size_bytes*number
consent_source*string
topic_ids*array
total_rows*number

Data rows in the file (header excluded); 0 until processing starts.

processed_rows*number
created_count*number
updated_count*number
skipped_count*number

Existing contacts left alone (`skip`), and duplicate rows.

error_count*number

Rows that could not be imported.

consented_count*number

Rows that asserted consent.

errors*array

The first 100 problems, with line numbers.

failure_reason*string

Why a `failed` import stopped.

created_at*string · date-time
started_at*string · date-time
completed_at*string · date-time

Example

Request

POST /v1/workspaces/{workspace_id}/contacts/imports
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "csv": "email,first_name,consent,consented_at\nada@example.com,Ada,true,2026-10-01T09:00:00Z\n",
  "on_conflict": "upsert",
  "consent_source": "webinar-2026-10",
  "topic_ids": [
    "00000000-0000-0000-0000-000000000000"
  ],
  "filename": "newsletter.csv"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "status": "queued",
  "on_conflict": "upsert",
  "filename": "string",
  "size_bytes": 0,
  "consent_source": "string",
  "topic_ids": [
    "00000000-0000-0000-0000-000000000000"
  ],
  "total_rows": 0,
  "processed_rows": 0,
  "created_count": 0,
  "updated_count": 0,
  "skipped_count": 0,
  "error_count": 0,
  "consented_count": 0,
  "errors": [
    {
      "line": 0,
      "error": "invalid or missing email"
    }
  ],
  "failure_reason": "string",
  "created_at": "2026-01-01T00:00:00.000Z",
  "started_at": "2026-01-01T00:00:00.000Z",
  "completed_at": "2026-01-01T00:00:00.000Z"
}
get/v1/workspaces/{workspace_id}/contacts/imports/{import_id}Auth

Get a contact import and its progress

Path parameters

workspace_id*string · uuid
import_id*string · uuid

Response · 200

id*string · uuid
status*enum (4)
on_conflict*enum (2)
filename*string
size_bytes*number
consent_source*string
topic_ids*array
total_rows*number

Data rows in the file (header excluded); 0 until processing starts.

processed_rows*number
created_count*number
updated_count*number
skipped_count*number

Existing contacts left alone (`skip`), and duplicate rows.

error_count*number

Rows that could not be imported.

consented_count*number

Rows that asserted consent.

errors*array

The first 100 problems, with line numbers.

failure_reason*string

Why a `failed` import stopped.

created_at*string · date-time
started_at*string · date-time
completed_at*string · date-time

Example

Request

GET /v1/workspaces/{workspace_id}/contacts/imports/{import_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "status": "queued",
  "on_conflict": "upsert",
  "filename": "string",
  "size_bytes": 0,
  "consent_source": "string",
  "topic_ids": [
    "00000000-0000-0000-0000-000000000000"
  ],
  "total_rows": 0,
  "processed_rows": 0,
  "created_count": 0,
  "updated_count": 0,
  "skipped_count": 0,
  "error_count": 0,
  "consented_count": 0,
  "errors": [
    {
      "line": 0,
      "error": "invalid or missing email"
    }
  ],
  "failure_reason": "string",
  "created_at": "2026-01-01T00:00:00.000Z",
  "started_at": "2026-01-01T00:00:00.000Z",
  "completed_at": "2026-01-01T00:00:00.000Z"
}
get/v1/workspaces/{workspace_id}/contactsAuth

List contacts, newest first

Path parameters

workspace_id*string · uuid

Query parameters

limitinteger

Page size. Defaults to 20; values above 100 are clamped to 100.

cursorstring

Opaque cursor from the previous page's `pagination.next_cursor`. Omit for the first page.

searchstring

Case-insensitive substring match on the email address.

unsubscribedboolean

Only contacts that are (`true`) or are not (`false`) globally unsubscribed.

Response · 200

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /v1/workspaces/{workspace_id}/contacts
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "email": "ada@example.com",
      "first_name": "string",
      "last_name": "string",
      "unsubscribed": false,
      "unsubscribed_at": "2026-01-01T00:00:00.000Z",
      "properties": {
        "company": "Acme"
      },
      "consented_at": "2026-01-01T00:00:00.000Z",
      "consent_source": "signup-form-2026",
      "created_via": "api",
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
post/v1/workspaces/{workspace_id}/contactsAuth

Create a contact

Emits `contact.created`. Without `consent`, the contact receives only topics it is explicitly opted in to.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

email*string · email

Unique per workspace; stored lowercased.

Example: "ada@example.com"

first_namestring

Example: "Ada"

last_namestring

Example: "Lovelace"

unsubscribedboolean

Global unsubscribe: the contact receives no topic mail and no mail with an unsubscribe link. Transactional mail is unaffected.

propertiesobject

Custom properties, keyed by a defined contact property (`POST /contact-properties`). Values must match its type.

Example: {"company":"Acme","seats":12}

consentobject

A marketing consent the contact gave you. Without one, the contact receives only topics it explicitly opted in to (`topics`, or `PATCH .../topics`).

topicsarray

Initial topic subscriptions.

Response · 201

id*string · uuid
email*string · email

Example: "ada@example.com"

first_name*string
last_name*string
unsubscribed*boolean

Global unsubscribe.

unsubscribed_at*string · date-time
properties*object

Example: {"company":"Acme"}

consented_at*string · date-time

When marketing consent was recorded; null when none was.

consent_source*string

Example: "signup-form-2026"

created_via*enum (4)

How the contact was added.

created_at*string · date-time
updated_at*string · date-time

Example

Request

POST /v1/workspaces/{workspace_id}/contacts
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "email": "ada@example.com",
  "first_name": "Ada",
  "last_name": "Lovelace",
  "unsubscribed": false,
  "properties": {
    "company": "Acme",
    "seats": 12
  },
  "consent": {},
  "topics": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "subscription": "opt_in"
    }
  ]
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "email": "ada@example.com",
  "first_name": "string",
  "last_name": "string",
  "unsubscribed": false,
  "unsubscribed_at": "2026-01-01T00:00:00.000Z",
  "properties": {
    "company": "Acme"
  },
  "consented_at": "2026-01-01T00:00:00.000Z",
  "consent_source": "signup-form-2026",
  "created_via": "api",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
get/v1/workspaces/{workspace_id}/contacts/{contact}Auth

Get a contact by id or email

Path parameters

workspace_id*string · uuid
contact*string

The contact id, or its email address (URL-encoded).

Response · 200

id*string · uuid
email*string · email

Example: "ada@example.com"

first_name*string
last_name*string
unsubscribed*boolean

Global unsubscribe.

unsubscribed_at*string · date-time
properties*object

Example: {"company":"Acme"}

consented_at*string · date-time

When marketing consent was recorded; null when none was.

consent_source*string

Example: "signup-form-2026"

created_via*enum (4)

How the contact was added.

created_at*string · date-time
updated_at*string · date-time

Example

Request

GET /v1/workspaces/{workspace_id}/contacts/{contact}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "email": "ada@example.com",
  "first_name": "string",
  "last_name": "string",
  "unsubscribed": false,
  "unsubscribed_at": "2026-01-01T00:00:00.000Z",
  "properties": {
    "company": "Acme"
  },
  "consented_at": "2026-01-01T00:00:00.000Z",
  "consent_source": "signup-form-2026",
  "created_via": "api",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
patch/v1/workspaces/{workspace_id}/contacts/{contact}Auth

Update a contact

The email cannot change (delete and re-create). Emits `contact.updated` when something changed.

Path parameters

workspace_id*string · uuid
contact*string

The contact id, or its email address (URL-encoded).

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

first_namestring
last_namestring
unsubscribedboolean

Set or clear the global unsubscribe.

propertiesobject

Merged into the stored properties; a `null` value removes that key.

consentobject

Record a consent, or `null` to withdraw it.

Response · 200

id*string · uuid
email*string · email

Example: "ada@example.com"

first_name*string
last_name*string
unsubscribed*boolean

Global unsubscribe.

unsubscribed_at*string · date-time
properties*object

Example: {"company":"Acme"}

consented_at*string · date-time

When marketing consent was recorded; null when none was.

consent_source*string

Example: "signup-form-2026"

created_via*enum (4)

How the contact was added.

created_at*string · date-time
updated_at*string · date-time

Example

Request

PATCH /v1/workspaces/{workspace_id}/contacts/{contact}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "first_name": "string",
  "last_name": "string",
  "unsubscribed": false,
  "properties": {},
  "consent": {}
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "email": "ada@example.com",
  "first_name": "string",
  "last_name": "string",
  "unsubscribed": false,
  "unsubscribed_at": "2026-01-01T00:00:00.000Z",
  "properties": {
    "company": "Acme"
  },
  "consented_at": "2026-01-01T00:00:00.000Z",
  "consent_source": "signup-form-2026",
  "created_via": "api",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/workspaces/{workspace_id}/contacts/{contact}Auth

Delete a contact

Removes the contact, its properties and its topic choices. Emits `contact.deleted`. To stop mailing someone, unsubscribe them instead: a deleted contact that later receives a topic send is refused (`no_consent`), but its opt-out is forgotten.

Path parameters

workspace_id*string · uuid
contact*string

The contact id, or its email address (URL-encoded).

Response · 204

Deleted.

get/v1/workspaces/{workspace_id}/contacts/{contact}/topicsAuth

List a contact's topic subscriptions

Every topic of the workspace, with the contact's own choice and whether a topic send would reach it now (and why).

Path parameters

workspace_id*string · uuid
contact*string

The contact id, or its email address (URL-encoded).

Response · 200

contact_id*string · uuid
data*array

Example

Request

GET /v1/workspaces/{workspace_id}/contacts/{contact}/topics
Authorization: Bearer YOUR_TOKEN

Response

{
  "contact_id": "00000000-0000-0000-0000-000000000000",
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "name": "string",
      "description": "string",
      "default_subscription": "opt_in",
      "visibility": "public",
      "subscription": "opt_in",
      "subscribed": false,
      "reason": "opted_in"
    }
  ]
}
patch/v1/workspaces/{workspace_id}/contacts/{contact}/topicsAuth

Opt a contact in to or out of topics

Records the contact's choice per topic (an explicit `opt_in` is consent for that topic). A global unsubscribe still wins: clear it with `PATCH .../contacts/{contact}` `unsubscribed: false`. Emits `contact.updated`.

Path parameters

workspace_id*string · uuid
contact*string

The contact id, or its email address (URL-encoded).

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

topics*array

Response · 200

contact_id*string · uuid
data*array

Example

Request

PATCH /v1/workspaces/{workspace_id}/contacts/{contact}/topics
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "topics": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "subscription": "opt_in"
    }
  ]
}

Response

{
  "contact_id": "00000000-0000-0000-0000-000000000000",
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "name": "string",
      "description": "string",
      "default_subscription": "opt_in",
      "visibility": "public",
      "subscription": "opt_in",
      "subscribed": false,
      "reason": "opted_in"
    }
  ]
}
get/v1/workspaces/{workspace_id}/contact-propertiesAuth

List contact properties, newest first

Path parameters

workspace_id*string · uuid

Query parameters

limitinteger

Page size. Defaults to 20; values above 100 are clamped to 100.

cursorstring

Opaque cursor from the previous page's `pagination.next_cursor`. Omit for the first page.

Response · 200

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /v1/workspaces/{workspace_id}/contact-properties
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "key": "company",
      "type": "string",
      "fallback_value": {},
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
post/v1/workspaces/{workspace_id}/contact-propertiesAuth

Define a contact property

At most 50 per workspace.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

key*string

The property key: lowercase letters, digits and underscores, starting with a letter, up to 50 characters. Use it as a CSV column header and as a key of `properties` on a contact. Contact fields (`email`, `first_name`, …) are reserved.

Example: "company"

type*enum (2)

Value type. Fixed once created.

fallback_valueobject

The value to use when a contact has none. Must match `type`.

Example: "Acme"

Response · 201

id*string · uuid
key*string

Example: "company"

type*enum (2)
fallback_value*object
created_at*string · date-time
updated_at*string · date-time

Example

Request

POST /v1/workspaces/{workspace_id}/contact-properties
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "key": "company",
  "type": "string",
  "fallback_value": "Acme"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "key": "company",
  "type": "string",
  "fallback_value": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
get/v1/workspaces/{workspace_id}/contact-properties/{property_id}Auth

Get a contact property

Path parameters

workspace_id*string · uuid
property_id*string · uuid

Response · 200

id*string · uuid
key*string

Example: "company"

type*enum (2)
fallback_value*object
created_at*string · date-time
updated_at*string · date-time

Example

Request

GET /v1/workspaces/{workspace_id}/contact-properties/{property_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "key": "company",
  "type": "string",
  "fallback_value": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
patch/v1/workspaces/{workspace_id}/contact-properties/{property_id}Auth

Change a contact property's fallback value

The key and type are fixed once created.

Path parameters

workspace_id*string · uuid
property_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

fallback_valueobject

The new fallback value (null clears it). The key and type cannot change.

Response · 200

id*string · uuid
key*string

Example: "company"

type*enum (2)
fallback_value*object
created_at*string · date-time
updated_at*string · date-time

Example

Request

PATCH /v1/workspaces/{workspace_id}/contact-properties/{property_id}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "fallback_value": {}
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "key": "company",
  "type": "string",
  "fallback_value": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/workspaces/{workspace_id}/contact-properties/{property_id}Auth

Delete a contact property

Values already stored on contacts stay, but are no longer validated; define the key again to use it.

Path parameters

workspace_id*string · uuid
property_id*string · uuid

Response · 204

Deleted.


Segments

get/v1/workspaces/{workspace_id}/contacts/{contact}/segmentsAuth

List a contact's segments

Static segments it was added to and rule segments it matches now.

Path parameters

workspace_id*string · uuid
contact*string

The contact id, or its email address (URL-encoded).

Response · 200

contact_id*string · uuid
data*array

Every segment the contact is in now (static ones it was added to, rule ones it matches), oldest segment first. At most 100: one page.

Example

Request

GET /v1/workspaces/{workspace_id}/contacts/{contact}/segments
Authorization: Bearer YOUR_TOKEN

Response

{
  "contact_id": "00000000-0000-0000-0000-000000000000",
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "name": "Pro customers",
      "description": "string",
      "type": "static",
      "rule": {},
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}
post/v1/workspaces/{workspace_id}/contacts/{contact}/segments/{segment_id}Auth

Add a contact to a static segment

201 when added, 200 when it was already a member.

Path parameters

workspace_id*string · uuid
contact*string

The contact id, or its email address (URL-encoded).

segment_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Response · 200 Already a member; nothing changed.

segment_id*string · uuid
contact_id*string · uuid
added_at*string · date-time

When the contact was added (the first time, if it was added twice).

Example

Request

POST /v1/workspaces/{workspace_id}/contacts/{contact}/segments/{segment_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "segment_id": "00000000-0000-0000-0000-000000000000",
  "contact_id": "00000000-0000-0000-0000-000000000000",
  "added_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/workspaces/{workspace_id}/contacts/{contact}/segments/{segment_id}Auth

Remove a contact from a static segment

204 whether or not it was a member.

Path parameters

workspace_id*string · uuid
contact*string

The contact id, or its email address (URL-encoded).

segment_id*string · uuid

Response · 204

Not a member (any more).

get/v1/workspaces/{workspace_id}/segmentsAuth

List segments, newest first

Path parameters

workspace_id*string · uuid

Query parameters

limitinteger

Page size. Defaults to 20; values above 100 are clamped to 100.

cursorstring

Opaque cursor from the previous page's `pagination.next_cursor`. Omit for the first page.

Response · 200

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /v1/workspaces/{workspace_id}/segments
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "name": "Pro customers",
      "description": "string",
      "type": "static",
      "rule": {},
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
post/v1/workspaces/{workspace_id}/segmentsAuth

Create a segment

Without `rule`, a static segment: add and remove contacts one at a time. With `rule`, a rule segment: its members are the contacts whose fields and properties match, evaluated every time it is read. At most 100 segments per workspace.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

name*string

Unique per workspace.

Example: "Pro customers"

descriptionstring

Example: "Everyone on the pro plan"

ruleobject

Omit for a static segment (add contacts with `POST .../contacts/{contact}/segments/{segment_id}`). Set it for a rule segment: its members are the contacts that match, evaluated whenever the segment is read. The type is fixed once created.

Response · 201

id*string · uuid
name*string

Example: "Pro customers"

description*string
type*enum (2)

`static`: an explicit member list. `rule`: the contacts matching `rule`.

rule*object

Null for a static segment.

created_at*string · date-time
updated_at*string · date-time

Example

Request

POST /v1/workspaces/{workspace_id}/segments
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "name": "Pro customers",
  "description": "Everyone on the pro plan",
  "rule": {}
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "name": "Pro customers",
  "description": "string",
  "type": "static",
  "rule": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
post/v1/workspaces/{workspace_id}/segments/previewAuth

Preview a segment rule

Counts the contacts a rule selects and returns the newest few, without saving anything.

Path parameters

workspace_id*string · uuid

Request body

rule*object
match*enum (2)

`all`: every condition must hold (AND). `any`: at least one (OR).

conditions*array

Response · 200

metrics*object
segment_id*string · uuid

Null on a preview.

contact_count*number

Contacts in the segment now.

Example: 1240

unsubscribed_count*number

Of those, globally unsubscribed.

Example: 31

unsubscribed_share*number

`unsubscribed_count / contact_count`, 0 to 1 (4 decimals); 0 for an empty segment.

Example: 0.025

consented_count*number

Of those, with a recorded marketing consent.

Example: 1100

computed_at*string · date-time

When the counts were taken (rule segments change as contacts do).

sample*array

Up to 5 matching contacts, newest first.

Example

Request

POST /v1/workspaces/{workspace_id}/segments/preview
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "rule": {
    "match": "all",
    "conditions": [
      {
        "field": "plan",
        "operator": "equals",
        "value": "pro"
      }
    ]
  }
}

Response

{
  "metrics": {
    "segment_id": "00000000-0000-0000-0000-000000000000",
    "contact_count": 1240,
    "unsubscribed_count": 31,
    "unsubscribed_share": 0.025,
    "consented_count": 1100,
    "computed_at": "2026-01-01T00:00:00.000Z"
  },
  "sample": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "email": "ada@example.com",
      "first_name": "string",
      "last_name": "string",
      "unsubscribed": false,
      "unsubscribed_at": "2026-01-01T00:00:00.000Z",
      "properties": {
        "company": "Acme"
      },
      "consented_at": "2026-01-01T00:00:00.000Z",
      "consent_source": "signup-form-2026",
      "created_via": "api",
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}
get/v1/workspaces/{workspace_id}/segments/{segment_id}Auth

Get a segment

Path parameters

workspace_id*string · uuid
segment_id*string · uuid

Response · 200

id*string · uuid
name*string

Example: "Pro customers"

description*string
type*enum (2)

`static`: an explicit member list. `rule`: the contacts matching `rule`.

rule*object

Null for a static segment.

created_at*string · date-time
updated_at*string · date-time

Example

Request

GET /v1/workspaces/{workspace_id}/segments/{segment_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "name": "Pro customers",
  "description": "string",
  "type": "static",
  "rule": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
patch/v1/workspaces/{workspace_id}/segments/{segment_id}Auth

Update a segment

Rename it, or change a rule segment's rule. A segment's type (static or rule) is fixed.

Path parameters

workspace_id*string · uuid
segment_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

namestring
descriptionstring
ruleobject

A new rule (rule segments only; a static segment cannot take one).

Response · 200

id*string · uuid
name*string

Example: "Pro customers"

description*string
type*enum (2)

`static`: an explicit member list. `rule`: the contacts matching `rule`.

rule*object

Null for a static segment.

created_at*string · date-time
updated_at*string · date-time

Example

Request

PATCH /v1/workspaces/{workspace_id}/segments/{segment_id}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "name": "string",
  "description": "string",
  "rule": {}
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "name": "Pro customers",
  "description": "string",
  "type": "static",
  "rule": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/workspaces/{workspace_id}/segments/{segment_id}Auth

Delete a segment

Contacts are not touched; a static segment's member list goes with it.

Path parameters

workspace_id*string · uuid
segment_id*string · uuid

Response · 204

Deleted.

get/v1/workspaces/{workspace_id}/segments/{segment_id}/contactsAuth

List a segment's contacts

Newest contact first. For a rule segment, the contacts matching its rule right now.

Path parameters

workspace_id*string · uuid
segment_id*string · uuid

Query parameters

limitinteger

Page size. Defaults to 20; values above 100 are clamped to 100.

cursorstring

Opaque cursor from the previous page's `pagination.next_cursor`. Omit for the first page.

unsubscribedboolean

Only members that are (`true`) or are not (`false`) globally unsubscribed.

Response · 200

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /v1/workspaces/{workspace_id}/segments/{segment_id}/contacts
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "email": "ada@example.com",
      "first_name": "string",
      "last_name": "string",
      "unsubscribed": false,
      "unsubscribed_at": "2026-01-01T00:00:00.000Z",
      "properties": {
        "company": "Acme"
      },
      "consented_at": "2026-01-01T00:00:00.000Z",
      "consent_source": "signup-form-2026",
      "created_via": "api",
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
get/v1/workspaces/{workspace_id}/segments/{segment_id}/metricsAuth

A segment's size and unsubscribed share

Counted on request.

Path parameters

workspace_id*string · uuid
segment_id*string · uuid

Response · 200

segment_id*string · uuid

Null on a preview.

contact_count*number

Contacts in the segment now.

Example: 1240

unsubscribed_count*number

Of those, globally unsubscribed.

Example: 31

unsubscribed_share*number

`unsubscribed_count / contact_count`, 0 to 1 (4 decimals); 0 for an empty segment.

Example: 0.025

consented_count*number

Of those, with a recorded marketing consent.

Example: 1100

computed_at*string · date-time

When the counts were taken (rule segments change as contacts do).

Example

Request

GET /v1/workspaces/{workspace_id}/segments/{segment_id}/metrics
Authorization: Bearer YOUR_TOKEN

Response

{
  "segment_id": "00000000-0000-0000-0000-000000000000",
  "contact_count": 1240,
  "unsubscribed_count": 31,
  "unsubscribed_share": 0.025,
  "consented_count": 1100,
  "computed_at": "2026-01-01T00:00:00.000Z"
}

Topics

get/v1/workspaces/{workspace_id}/topicsAuth

List topics, newest first

Path parameters

workspace_id*string · uuid

Query parameters

limitinteger

Page size. Defaults to 20; values above 100 are clamped to 100.

cursorstring

Opaque cursor from the previous page's `pagination.next_cursor`. Omit for the first page.

Response · 200

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /v1/workspaces/{workspace_id}/topics
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "name": "Product updates",
      "description": "string",
      "default_subscription": "opt_in",
      "visibility": "public",
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
post/v1/workspaces/{workspace_id}/topicsAuth

Create a topic

Send on a topic with `topic_id` on a template send. Recipients manage their topics on the hosted unsubscribe page. At most 50 topics per workspace.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

name*string

Shown to recipients on the unsubscribe page.

Example: "Product updates"

descriptionstring

One line under the name on the unsubscribe page.

default_subscription*enum (2)

`opt_in`: contacts receive the topic unless they opt out — but only contacts with a recorded consent (`consent` on the contact, or an import row that asserted it). `opt_out`: contacts receive it only after opting in. Fixed once created.

visibilityenum (2)

`public` topics are listed on the unsubscribe page; `private` ones are not (a recipient can still leave one from its own link).

Response · 201

id*string · uuid
name*string

Example: "Product updates"

description*string
default_subscription*enum (2)
visibility*enum (2)
created_at*string · date-time
updated_at*string · date-time

Example

Request

POST /v1/workspaces/{workspace_id}/topics
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "name": "Product updates",
  "description": "string",
  "default_subscription": "opt_in",
  "visibility": "public"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "name": "Product updates",
  "description": "string",
  "default_subscription": "opt_in",
  "visibility": "public",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
get/v1/workspaces/{workspace_id}/topics/{topic_id}Auth

Get a topic

Path parameters

workspace_id*string · uuid
topic_id*string · uuid

Response · 200

id*string · uuid
name*string

Example: "Product updates"

description*string
default_subscription*enum (2)
visibility*enum (2)
created_at*string · date-time
updated_at*string · date-time

Example

Request

GET /v1/workspaces/{workspace_id}/topics/{topic_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "name": "Product updates",
  "description": "string",
  "default_subscription": "opt_in",
  "visibility": "public",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
patch/v1/workspaces/{workspace_id}/topics/{topic_id}Auth

Update a topic

`default_subscription` cannot change: flipping it would silently subscribe or unsubscribe everyone who never chose.

Path parameters

workspace_id*string · uuid
topic_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

namestring
descriptionstring
visibilityenum (2)

Response · 200

id*string · uuid
name*string

Example: "Product updates"

description*string
default_subscription*enum (2)
visibility*enum (2)
created_at*string · date-time
updated_at*string · date-time

Example

Request

PATCH /v1/workspaces/{workspace_id}/topics/{topic_id}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "name": "string",
  "description": "string",
  "visibility": "public"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "name": "Product updates",
  "description": "string",
  "default_subscription": "opt_in",
  "visibility": "public",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/workspaces/{workspace_id}/topics/{topic_id}Auth

Delete a topic

Also deletes every contact's choice for it. Sends naming it are refused afterwards (`TOPIC_NOT_FOUND`).

Path parameters

workspace_id*string · uuid
topic_id*string · uuid

Response · 204

Deleted.


Sending-Defaults

get/v1/workspaces/{workspace_id}/sending-defaultsAuth

Get the workspace default sender

The From used by template sends that name none, with the live verification status of its domain. `default_sender` is null when none is set.

Path parameters

workspace_id*string · uuid

Response · 200

default_sender*object

The workspace default sender, or null when none is set. With none set, a template send with no `from` is skipped: the HTTP route answers 422 `no_default_sender`, and an `email.skipped` webhook event is logged.

skipped_sends*object

Sends skipped for want of a sender (`no_default_sender`). A non-zero `count` means some product emails did not go out: set a default sender.

updated_at*string · date-time

When the settings last changed (set, cleared, or first created by a skipped send); null when there are none.

Example: "2026-10-17T09:30:00.000Z"

Example

Request

GET /v1/workspaces/{workspace_id}/sending-defaults
Authorization: Bearer YOUR_TOKEN

Response

{
  "default_sender": {},
  "skipped_sends": {},
  "updated_at": "2026-10-17T09:30:00.000Z"
}
put/v1/workspaces/{workspace_id}/sending-defaultsAuth

Set or clear the workspace default sender

Replaces the default sender (`address: null` clears it). The change applies to the next send of every product at once, and emits a `sending_defaults.updated` webhook event. Repeating the same request changes nothing, so it takes no `Idempotency-Key`.

Path parameters

workspace_id*string · uuid

Request body

address*string

The new default From, or null to clear it. A bare address or `Name <address>` (display name up to 200 characters). Its domain must be a sending domain of this workspace (it may still be pending verification; `verified` in the response says whether it sends yet).

Example: "Acme <hello@acme.com>"

Response · 200

default_sender*object

The workspace default sender, or null when none is set. With none set, a template send with no `from` is skipped: the HTTP route answers 422 `no_default_sender`, and an `email.skipped` webhook event is logged.

skipped_sends*object

Sends skipped for want of a sender (`no_default_sender`). A non-zero `count` means some product emails did not go out: set a default sender.

updated_at*string · date-time

When the settings last changed (set, cleared, or first created by a skipped send); null when there are none.

Example: "2026-10-17T09:30:00.000Z"

Example

Request

PUT /v1/workspaces/{workspace_id}/sending-defaults
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "address": "Acme <hello@acme.com>"
}

Response

{
  "default_sender": {},
  "skipped_sends": {},
  "updated_at": "2026-10-17T09:30:00.000Z"
}

Emails

get/v1/workspaces/{workspace_id}/emailsAuth

List emails

One entry per email (send call), newest first, with its recipients and `last_event`, the worst status across its messages. Filter by tags with `tag[name]=value` (repeat for several; all must match). Emails sent before 2026-11-06 appear as one-recipient emails.

Path parameters

workspace_id*string · uuid

Query parameters

limitinteger

Page size. Defaults to 20; values above 100 are clamped to 100.

cursorstring

Opaque cursor from the previous page's `pagination.next_cursor`. Omit for the first page.

tagobject

Tag filter: `tag[category]=confirm_email`. Up to 10 pairs; an email matches when it carries every one.

Response · 200

data*array
pagination*object
has_more*boolean
next_cursor*string

Example

Request

GET /v1/workspaces/{workspace_id}/emails
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "from": "user@example.com",
      "to": [
        "string"
      ],
      "cc": [
        "string"
      ],
      "bcc": [
        "string"
      ],
      "reply_to": [
        "string"
      ],
      "subject": "string",
      "template_name": "string",
      "tags": [
        {
          "name": "category",
          "value": "confirm_email"
        }
      ],
      "last_event": "queued",
      "recipient_count": 0,
      "created_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": "string"
  }
}
post/v1/workspaces/{workspace_id}/emailsAuth

Send an email

Send your own `html` and/or `text`, or a saved `template`, to up to 50 recipients across `to`, `cc` and `bcc`, with `reply_to`, custom `headers` and `tags`. Every recipient gets its own copy (its own message in `GET /messages`, suppression check and delivery status) showing the same `To` and `Cc` headers; `bcc` recipients appear in no header. A recipient on the suppression list (or unsubscribed, for a template with an unsubscribe link) is left out with a `suppressed` message, and the email is still accepted. Counts one send per recipient against the workspace limits. Pass an `Idempotency-Key` header to make retries safe.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

from*string

Sender. Either a bare address (`hello@acme.com`) or a display name plus the address in angle brackets (`Acme <hello@acme.com>`; quote a name that contains a comma: `"Acme, Inc." <hello@acme.com>`). The display name is at most 200 characters. The address's domain must itself be an active (verified) sending domain of the workspace; a verified subdomain does not cover its parent (with only `mail.acme.com` verified, `hello@acme.com` is refused with 422 `from_domain_not_verified`).

Example: "Acme <hello@mail.acme.com>"

to*object

Recipient: one address, or an array. At most 50 distinct recipients across `to`, `cc` and `bcc` (400 `RECIPIENT_LIMIT`). Each recipient gets its own copy, with its own suppression check, message (`GET /messages`) and delivery status, showing the full `To` and `Cc` lists.

Example: ["success@simulator.amazonses.com"]

ccobject

Cc recipients: one address or an array. Shown in the `Cc` header to every recipient.

Example: ["finance@example.com"]

bccobject

Bcc recipients: one address or an array. Each gets the message; no header names them.

Example: ["audit@example.com"]

reply_toobject

Reply-To address(es): one address or an array. This is the only way to set `Reply-To`.

Example: "support@acme.com"

subjectstring

Subject line. Required with `html`/`text`; with a `template` it overrides the template's subject.

Example: "Your order has shipped"

htmlstring

HTML body, sent exactly as given (no templating). With no `text`, the plain-text part is derived from it.

Example: "<p>Your order has shipped.</p>"

textstring

Plain-text body, sent exactly as given.

Example: "Your order has shipped."

templateobject

Send a saved template instead of `html`/`text` (400 `EMAIL_CONTENT_CONFLICT` with both, or with neither).

headersobject

Custom headers, as a map of header name to value, kept exactly as sent. Allowed: `X-*` (except `X-SES-*`), `List-Unsubscribe`, `List-Unsubscribe-Post`, `List-Id`, `In-Reply-To` and `References`; at most 50, values up to 998 characters with no line breaks. Any other header (`From`, `To`, `Cc`, `Bcc`, `Subject`, `Date`, `Message-ID`, `DKIM-Signature`, `Return-Path`, `Reply-To`, …) is a 400 `MAIL_HEADER_NOT_ALLOWED`: use the matching field (`reply_to` for `Reply-To`).

Example: {"x-entity-ref-id":"order-1234"}

tagsarray

Up to 10 `{ name, value }` labels, stored on every message of the send. Filter sends by them with `GET /emails?tag[name]=value`.

Example: [{"name":"category","value":"confirm_email"}]

Response · 202 Accepted; queued for delivery.

id*string · uuid

The email's id. Pass it to `GET /emails/{id}`; it is also the id of the first recipient's message (`GET /messages/{id}`), and webhook events of every recipient carry their own message id as `email_id`.

Example: "4f8b2c1e-9a3d-4e57-b6f0-2d1c8e7a9b05"

Example

Request

POST /v1/workspaces/{workspace_id}/emails
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "from": "Acme <hello@mail.acme.com>",
  "to": [
    "success@simulator.amazonses.com"
  ],
  "cc": [
    "finance@example.com"
  ],
  "bcc": [
    "audit@example.com"
  ],
  "reply_to": "support@acme.com",
  "subject": "Your order has shipped",
  "html": "<p>Your order has shipped.</p>",
  "text": "Your order has shipped.",
  "template": {},
  "headers": {
    "x-entity-ref-id": "order-1234"
  },
  "tags": [
    {
      "name": "category",
      "value": "confirm_email"
    }
  ]
}

Response

{
  "id": "4f8b2c1e-9a3d-4e57-b6f0-2d1c8e7a9b05"
}
post/v1/workspaces/{workspace_id}/emails/batchAuth

Send up to 100 emails in one request

Each row is a `POST /emails` body plus an optional `idempotency_key`, processed independently: the response reports `accepted` (with the email `id`) or `rejected` (with `error.code`) per row. An `Idempotency-Key` header covers the whole request; a row `idempotency_key` covers one row, in the same dedupe namespace (so the header must differ from every row key).

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

emails*array

Up to 100 emails, each processed independently: partial success is allowed and every row reports its own `accepted` / `rejected` status.

Response · 200

data*array
summary*object
accepted*number
rejected*number

Example

Request

POST /v1/workspaces/{workspace_id}/emails/batch
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "emails": [
    {
      "from": "Acme <hello@mail.acme.com>",
      "to": [
        "success@simulator.amazonses.com"
      ],
      "cc": [
        "finance@example.com"
      ],
      "bcc": [
        "audit@example.com"
      ],
      "reply_to": "support@acme.com",
      "subject": "Your order has shipped",
      "html": "<p>Your order has shipped.</p>",
      "text": "Your order has shipped.",
      "template": {},
      "headers": {
        "x-entity-ref-id": "order-1234"
      },
      "tags": [
        {
          "name": "category",
          "value": "confirm_email"
        }
      ],
      "idempotency_key": "order-1234-shipped"
    }
  ]
}

Response

{
  "data": [
    {
      "index": 0,
      "id": "00000000-0000-0000-0000-000000000000",
      "status": "accepted",
      "error": {}
    }
  ],
  "summary": {
    "accepted": 0,
    "rejected": 0
  }
}
get/v1/workspaces/{workspace_id}/emails/{id}Auth

Retrieve an email

The email as sent (from, to, cc, bcc, reply_to, subject, tags, custom headers) with one entry per recipient message and `last_event`, the worst status across them. Bodies are on `GET /messages/{id}/body`.

Path parameters

workspace_id*string · uuid
id*string · uuid

Response · 200

id*string · uuid
from*string · email

The From address (bare address).

to*array

The `To` recipients.

cc*array
bcc*array
reply_to*array
subject*string
template_name*string

The template it was rendered from; null for html/text sends.

tags*array
last_event*enum (8)

The worst status across the email's messages, worst first: complained, bounced, failed, suppressed, deferred, queued, sent, delivered. One message still queued keeps an otherwise delivered email `queued`.

recipient_count*number

Recipients, one message each.

created_at*string · date-time
headers*object

The custom headers as sent. Values of headers whose names suggest a credential (auth, token, secret, key, signature, cookie, session) read `[redacted]`.

messages*array

One message per recipient, in send order.

Example

Request

GET /v1/workspaces/{workspace_id}/emails/{id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "from": "user@example.com",
  "to": [
    "string"
  ],
  "cc": [
    "string"
  ],
  "bcc": [
    "string"
  ],
  "reply_to": [
    "string"
  ],
  "subject": "string",
  "template_name": "string",
  "tags": [
    {
      "name": "category",
      "value": "confirm_email"
    }
  ],
  "last_event": "queued",
  "recipient_count": 0,
  "created_at": "2026-01-01T00:00:00.000Z",
  "headers": {},
  "messages": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "to": "user@example.com",
      "status": "queued"
    }
  ]
}

Broadcasts

get/v1/workspaces/{workspace_id}/broadcastsAuth

List broadcasts, newest first

Path parameters

workspace_id*string · uuid

Query parameters

limitinteger

Page size. Defaults to 20; values above 100 are clamped to 100.

cursorstring

Opaque cursor from the previous page's `pagination.next_cursor`. Omit for the first page.

statusenum (5)

Only broadcasts in this status.

Response · 200

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /v1/workspaces/{workspace_id}/broadcasts
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "name": "October product update",
      "from": "Acme <news@acme.com>",
      "subject": "What shipped in October",
      "template_name": "october-update",
      "html": "string",
      "text": "string",
      "segment_id": "00000000-0000-0000-0000-000000000000",
      "topic_id": "00000000-0000-0000-0000-000000000000",
      "status": "draft",
      "scheduled_at": "2026-01-01T00:00:00.000Z",
      "started_at": "2026-01-01T00:00:00.000Z",
      "completed_at": "2026-01-01T00:00:00.000Z",
      "canceled_at": "2026-01-01T00:00:00.000Z",
      "rate_per_minute": 0,
      "failure_reason": "string",
      "counts": {},
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
post/v1/workspaces/{workspace_id}/broadcastsAuth

Create a broadcast (draft)

Content is a saved template (`template_name`) or inline Handlebars (`html`, `subject`, optional `text`). The audience is a segment, a topic, or both. Nothing is sent until `POST .../send`.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

name*string

Example: "October product update"

fromstring

Sender: `ada@example.com` or `Ada <ada@example.com>`, on an active sending domain of this workspace. Omit to use the workspace default sender (resolved and fixed when the broadcast is sent).

Example: "Acme <news@acme.com>"

subjectstring

Subject (Handlebars). Required with `html`; with `template_name`, overrides the template subject.

template_namestring

A saved template of this workspace, instead of `html`.

Example: "october-update"

htmlstring

Inline HTML body (Handlebars), instead of `template_name`. Rendered per recipient with the contact as data (`{{first_name}}`, `{{email}}`, property keys, or `{{contact.plan}}`). Must contain `{{{unsubscribe_url}}}` before the broadcast can be sent.

textstring

Optional plain-text part (Handlebars). Derived from the HTML when omitted. When given, it must contain `{{{unsubscribe_url}}}` too.

segment_idstring · uuid

Target segment. With `topic_id` too, the audience is both.

topic_idstring · uuid

Target topic: only contacts subscribed to it (opted in, or default `opt_in` with recorded consent).

scheduled_atstring · date-time

Default send time used by `POST .../send` when it gives none.

rate_per_minutenumber

Messages per minute for this broadcast, 1 to 300. Omit to use the workspace setting (`GET /broadcast-settings`, default 120). Campaign mail has its own limit, separate from the transactional send limit.

Response · 201

id*string · uuid
name*string

Example: "October product update"

from*string

Null on a draft that uses the workspace default sender.

Example: "Acme <news@acme.com>"

subject*string

Example: "What shipped in October"

template_name*string

Example: "october-update"

html*string

Inline HTML body; null when a template is used.

text*string
segment_id*string · uuid

Null when only a topic targets it, or the segment was deleted.

topic_id*string · uuid
status*enum (5)

`draft`: editable. `scheduled`: waiting for `scheduled_at`. `sending`: going out. `sent`: every recipient handled. `canceled`: stopped before the end.

scheduled_at*string · date-time
started_at*string · date-time

When the audience froze and sending began.

completed_at*string · date-time
canceled_at*string · date-time
rate_per_minute*number

Null: the workspace setting.

failure_reason*string

Why the broadcast stopped on its own (its segment or topic was deleted).

counts*object

Null on a draft or a broadcast still scheduled.

created_at*string · date-time
updated_at*string · date-time

Example

Request

POST /v1/workspaces/{workspace_id}/broadcasts
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "name": "October product update",
  "from": "Acme <news@acme.com>",
  "subject": "string",
  "template_name": "october-update",
  "html": "string",
  "text": "string",
  "segment_id": "00000000-0000-0000-0000-000000000000",
  "topic_id": "00000000-0000-0000-0000-000000000000",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "rate_per_minute": 0
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "name": "October product update",
  "from": "Acme <news@acme.com>",
  "subject": "What shipped in October",
  "template_name": "october-update",
  "html": "string",
  "text": "string",
  "segment_id": "00000000-0000-0000-0000-000000000000",
  "topic_id": "00000000-0000-0000-0000-000000000000",
  "status": "draft",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "started_at": "2026-01-01T00:00:00.000Z",
  "completed_at": "2026-01-01T00:00:00.000Z",
  "canceled_at": "2026-01-01T00:00:00.000Z",
  "rate_per_minute": 0,
  "failure_reason": "string",
  "counts": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
get/v1/workspaces/{workspace_id}/broadcasts/audienceAuth

Count a broadcast audience

How many contacts a broadcast to this segment and/or topic would reach if it were sent now: members of the segment, subscribed to the topic, minus anyone unsubscribed or on the suppression list.

Path parameters

workspace_id*string · uuid

Query parameters

segment_idstring · uuid
topic_idstring · uuid

Response · 200

recipients*number

Who a send started now would reach: segment ∩ topic subscribers − unsubscribed − suppressed.

Example: 1180

segment_contacts*number

Everyone in the segment, before the exclusions. Null without a segment.

Example: 1240

computed_at*string · date-time

Example

Request

GET /v1/workspaces/{workspace_id}/broadcasts/audience
Authorization: Bearer YOUR_TOKEN

Response

{
  "recipients": 1180,
  "segment_contacts": 1240,
  "computed_at": "2026-01-01T00:00:00.000Z"
}
get/v1/workspaces/{workspace_id}/broadcasts/{broadcast_id}Auth

Get a broadcast

Includes `counts` once sending has started: recipients, sent, delivered, bounced, complained, opened, clicked, unsubscribed.

Path parameters

workspace_id*string · uuid
broadcast_id*string · uuid

Response · 200

id*string · uuid
name*string

Example: "October product update"

from*string

Null on a draft that uses the workspace default sender.

Example: "Acme <news@acme.com>"

subject*string

Example: "What shipped in October"

template_name*string

Example: "october-update"

html*string

Inline HTML body; null when a template is used.

text*string
segment_id*string · uuid

Null when only a topic targets it, or the segment was deleted.

topic_id*string · uuid
status*enum (5)

`draft`: editable. `scheduled`: waiting for `scheduled_at`. `sending`: going out. `sent`: every recipient handled. `canceled`: stopped before the end.

scheduled_at*string · date-time
started_at*string · date-time

When the audience froze and sending began.

completed_at*string · date-time
canceled_at*string · date-time
rate_per_minute*number

Null: the workspace setting.

failure_reason*string

Why the broadcast stopped on its own (its segment or topic was deleted).

counts*object

Null on a draft or a broadcast still scheduled.

created_at*string · date-time
updated_at*string · date-time

Example

Request

GET /v1/workspaces/{workspace_id}/broadcasts/{broadcast_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "name": "October product update",
  "from": "Acme <news@acme.com>",
  "subject": "What shipped in October",
  "template_name": "october-update",
  "html": "string",
  "text": "string",
  "segment_id": "00000000-0000-0000-0000-000000000000",
  "topic_id": "00000000-0000-0000-0000-000000000000",
  "status": "draft",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "started_at": "2026-01-01T00:00:00.000Z",
  "completed_at": "2026-01-01T00:00:00.000Z",
  "canceled_at": "2026-01-01T00:00:00.000Z",
  "rate_per_minute": 0,
  "failure_reason": "string",
  "counts": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
patch/v1/workspaces/{workspace_id}/broadcasts/{broadcast_id}Auth

Update a draft broadcast

Only drafts can change; null clears an optional field.

Path parameters

workspace_id*string · uuid
broadcast_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

namestring
fromstring

Sender: `ada@example.com` or `Ada <ada@example.com>`, on an active sending domain of this workspace. Omit to use the workspace default sender (resolved and fixed when the broadcast is sent).

subjectstring
template_namestring

Set to null to switch to inline `html`.

htmlstring

Inline HTML body (Handlebars), instead of `template_name`. Rendered per recipient with the contact as data (`{{first_name}}`, `{{email}}`, property keys, or `{{contact.plan}}`). Must contain `{{{unsubscribe_url}}}` before the broadcast can be sent.

textstring

Optional plain-text part (Handlebars). Derived from the HTML when omitted. When given, it must contain `{{{unsubscribe_url}}}` too.

segment_idstring · uuid
topic_idstring · uuid
scheduled_atstring · date-time
rate_per_minutenumber

Messages per minute for this broadcast, 1 to 300. Omit to use the workspace setting (`GET /broadcast-settings`, default 120). Campaign mail has its own limit, separate from the transactional send limit.

Response · 200

id*string · uuid
name*string

Example: "October product update"

from*string

Null on a draft that uses the workspace default sender.

Example: "Acme <news@acme.com>"

subject*string

Example: "What shipped in October"

template_name*string

Example: "october-update"

html*string

Inline HTML body; null when a template is used.

text*string
segment_id*string · uuid

Null when only a topic targets it, or the segment was deleted.

topic_id*string · uuid
status*enum (5)

`draft`: editable. `scheduled`: waiting for `scheduled_at`. `sending`: going out. `sent`: every recipient handled. `canceled`: stopped before the end.

scheduled_at*string · date-time
started_at*string · date-time

When the audience froze and sending began.

completed_at*string · date-time
canceled_at*string · date-time
rate_per_minute*number

Null: the workspace setting.

failure_reason*string

Why the broadcast stopped on its own (its segment or topic was deleted).

counts*object

Null on a draft or a broadcast still scheduled.

created_at*string · date-time
updated_at*string · date-time

Example

Request

PATCH /v1/workspaces/{workspace_id}/broadcasts/{broadcast_id}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "name": "string",
  "from": "string",
  "subject": "string",
  "template_name": "string",
  "html": "string",
  "text": "string",
  "segment_id": "00000000-0000-0000-0000-000000000000",
  "topic_id": "00000000-0000-0000-0000-000000000000",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "rate_per_minute": 0
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "name": "October product update",
  "from": "Acme <news@acme.com>",
  "subject": "What shipped in October",
  "template_name": "october-update",
  "html": "string",
  "text": "string",
  "segment_id": "00000000-0000-0000-0000-000000000000",
  "topic_id": "00000000-0000-0000-0000-000000000000",
  "status": "draft",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "started_at": "2026-01-01T00:00:00.000Z",
  "completed_at": "2026-01-01T00:00:00.000Z",
  "canceled_at": "2026-01-01T00:00:00.000Z",
  "rate_per_minute": 0,
  "failure_reason": "string",
  "counts": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/workspaces/{workspace_id}/broadcasts/{broadcast_id}Auth

Delete a broadcast

Drafts, sent and canceled broadcasts, with their recipient list. The messages themselves stay in the message log.

Path parameters

workspace_id*string · uuid
broadcast_id*string · uuid

Response · 204

Deleted.

post/v1/workspaces/{workspace_id}/broadcasts/{broadcast_id}/sendAuth

Send or schedule a broadcast

Now, or at `scheduled_at`. When sending starts the audience is frozen (segment ∩ topic subscribers − unsubscribed − suppression list) and the messages go out at the broadcast rate (default 120 per minute; separate from the transactional limit), on the marketing stream, each with a one-click unsubscribe link and `List-Unsubscribe` headers. The send survives restarts: it continues from the recipients not yet sent.

Path parameters

workspace_id*string · uuid
broadcast_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

scheduled_atstring · date-time

Send at this time instead of now (the draft `scheduled_at` when omitted; null forces now). The audience is frozen when sending starts, so people who unsubscribe meanwhile are left out.

Example: "2026-11-02T09:00:00Z"

Response · 202 `status` is `sending` or `scheduled`.

id*string · uuid
name*string

Example: "October product update"

from*string

Null on a draft that uses the workspace default sender.

Example: "Acme <news@acme.com>"

subject*string

Example: "What shipped in October"

template_name*string

Example: "october-update"

html*string

Inline HTML body; null when a template is used.

text*string
segment_id*string · uuid

Null when only a topic targets it, or the segment was deleted.

topic_id*string · uuid
status*enum (5)

`draft`: editable. `scheduled`: waiting for `scheduled_at`. `sending`: going out. `sent`: every recipient handled. `canceled`: stopped before the end.

scheduled_at*string · date-time
started_at*string · date-time

When the audience froze and sending began.

completed_at*string · date-time
canceled_at*string · date-time
rate_per_minute*number

Null: the workspace setting.

failure_reason*string

Why the broadcast stopped on its own (its segment or topic was deleted).

counts*object

Null on a draft or a broadcast still scheduled.

created_at*string · date-time
updated_at*string · date-time

Example

Request

POST /v1/workspaces/{workspace_id}/broadcasts/{broadcast_id}/send
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "scheduled_at": "2026-11-02T09:00:00Z"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "name": "October product update",
  "from": "Acme <news@acme.com>",
  "subject": "What shipped in October",
  "template_name": "october-update",
  "html": "string",
  "text": "string",
  "segment_id": "00000000-0000-0000-0000-000000000000",
  "topic_id": "00000000-0000-0000-0000-000000000000",
  "status": "draft",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "started_at": "2026-01-01T00:00:00.000Z",
  "completed_at": "2026-01-01T00:00:00.000Z",
  "canceled_at": "2026-01-01T00:00:00.000Z",
  "rate_per_minute": 0,
  "failure_reason": "string",
  "counts": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
post/v1/workspaces/{workspace_id}/broadcasts/{broadcast_id}/cancelAuth

Cancel a scheduled or sending broadcast

Recipients not sent yet are marked `canceled`; messages already handed off are not recalled.

Path parameters

workspace_id*string · uuid
broadcast_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Response · 200

id*string · uuid
name*string

Example: "October product update"

from*string

Null on a draft that uses the workspace default sender.

Example: "Acme <news@acme.com>"

subject*string

Example: "What shipped in October"

template_name*string

Example: "october-update"

html*string

Inline HTML body; null when a template is used.

text*string
segment_id*string · uuid

Null when only a topic targets it, or the segment was deleted.

topic_id*string · uuid
status*enum (5)

`draft`: editable. `scheduled`: waiting for `scheduled_at`. `sending`: going out. `sent`: every recipient handled. `canceled`: stopped before the end.

scheduled_at*string · date-time
started_at*string · date-time

When the audience froze and sending began.

completed_at*string · date-time
canceled_at*string · date-time
rate_per_minute*number

Null: the workspace setting.

failure_reason*string

Why the broadcast stopped on its own (its segment or topic was deleted).

counts*object

Null on a draft or a broadcast still scheduled.

created_at*string · date-time
updated_at*string · date-time

Example

Request

POST /v1/workspaces/{workspace_id}/broadcasts/{broadcast_id}/cancel
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "name": "October product update",
  "from": "Acme <news@acme.com>",
  "subject": "What shipped in October",
  "template_name": "october-update",
  "html": "string",
  "text": "string",
  "segment_id": "00000000-0000-0000-0000-000000000000",
  "topic_id": "00000000-0000-0000-0000-000000000000",
  "status": "draft",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "started_at": "2026-01-01T00:00:00.000Z",
  "completed_at": "2026-01-01T00:00:00.000Z",
  "canceled_at": "2026-01-01T00:00:00.000Z",
  "rate_per_minute": 0,
  "failure_reason": "string",
  "counts": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
post/v1/workspaces/{workspace_id}/broadcasts/{broadcast_id}/duplicateAuth

Duplicate a broadcast

A new draft with the same content and audience.

Path parameters

workspace_id*string · uuid
broadcast_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Response · 201

id*string · uuid
name*string

Example: "October product update"

from*string

Null on a draft that uses the workspace default sender.

Example: "Acme <news@acme.com>"

subject*string

Example: "What shipped in October"

template_name*string

Example: "october-update"

html*string

Inline HTML body; null when a template is used.

text*string
segment_id*string · uuid

Null when only a topic targets it, or the segment was deleted.

topic_id*string · uuid
status*enum (5)

`draft`: editable. `scheduled`: waiting for `scheduled_at`. `sending`: going out. `sent`: every recipient handled. `canceled`: stopped before the end.

scheduled_at*string · date-time
started_at*string · date-time

When the audience froze and sending began.

completed_at*string · date-time
canceled_at*string · date-time
rate_per_minute*number

Null: the workspace setting.

failure_reason*string

Why the broadcast stopped on its own (its segment or topic was deleted).

counts*object

Null on a draft or a broadcast still scheduled.

created_at*string · date-time
updated_at*string · date-time

Example

Request

POST /v1/workspaces/{workspace_id}/broadcasts/{broadcast_id}/duplicate
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "name": "October product update",
  "from": "Acme <news@acme.com>",
  "subject": "What shipped in October",
  "template_name": "october-update",
  "html": "string",
  "text": "string",
  "segment_id": "00000000-0000-0000-0000-000000000000",
  "topic_id": "00000000-0000-0000-0000-000000000000",
  "status": "draft",
  "scheduled_at": "2026-01-01T00:00:00.000Z",
  "started_at": "2026-01-01T00:00:00.000Z",
  "completed_at": "2026-01-01T00:00:00.000Z",
  "canceled_at": "2026-01-01T00:00:00.000Z",
  "rate_per_minute": 0,
  "failure_reason": "string",
  "counts": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
get/v1/workspaces/{workspace_id}/broadcasts/{broadcast_id}/recipientsAuth

List a broadcast's recipients

In send order, with each message status.

Path parameters

workspace_id*string · uuid
broadcast_id*string · uuid

Query parameters

limitinteger

Page size. Defaults to 20; values above 100 are clamped to 100.

cursorstring

Opaque cursor from the previous page's `pagination.next_cursor`. Omit for the first page.

statusenum (6)

Only recipients in this status.

Response · 200

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /v1/workspaces/{workspace_id}/broadcasts/{broadcast_id}/recipients
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "email": "ada@example.com",
      "contact_id": "00000000-0000-0000-0000-000000000000",
      "status": "pending",
      "message_id": "00000000-0000-0000-0000-000000000000",
      "message_status": "delivered",
      "error_code": "RECIPIENT_SUPPRESSED",
      "error": "string",
      "sent_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
get/v1/workspaces/{workspace_id}/broadcast-settingsAuth

Get the broadcast settings

The messages-per-minute pace broadcasts go out at.

Path parameters

workspace_id*string · uuid

Response · 200

rate_per_minute*number

The workspace campaign pace; null uses the default.

effective_rate_per_minute*number

What a broadcast without its own rate is sent at.

Example: 120

default_rate_per_minute*number

Example: 120

Example

Request

GET /v1/workspaces/{workspace_id}/broadcast-settings
Authorization: Bearer YOUR_TOKEN

Response

{
  "rate_per_minute": 0,
  "effective_rate_per_minute": 120,
  "default_rate_per_minute": 120
}
put/v1/workspaces/{workspace_id}/broadcast-settingsAuth

Update the broadcast settings

Set the workspace campaign pace (1 to 300 messages per minute; null for the default, 120). It never affects transactional sends, which have their own limit.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

rate_per_minute*number

Null restores the default (120 per minute).

Response · 200

rate_per_minute*number

The workspace campaign pace; null uses the default.

effective_rate_per_minute*number

What a broadcast without its own rate is sent at.

Example: 120

default_rate_per_minute*number

Example: 120

Example

Request

PUT /v1/workspaces/{workspace_id}/broadcast-settings
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "rate_per_minute": 0
}

Response

{
  "rate_per_minute": 0,
  "effective_rate_per_minute": 120,
  "default_rate_per_minute": 120
}

Integrations

get/v1/workspaces/{workspace_id}/integrationsAuth

List connected workspace integrations.

Returns one entry per connected provider (GitHub, …). No secrets in the response — only public identifiers + status + sync stamps.

Path parameters

workspace_id*string · uuid

Response · 200

data*array

Example

Request

GET /v1/workspaces/{workspace_id}/integrations
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "provider": "github",
      "display_name": "GitHub",
      "account_login": "octocat",
      "account_avatar_url": "https://avatars.githubusercontent.com/u/1?v=4",
      "status": "active",
      "last_error": "string",
      "last_synced_at": "2026-01-01T00:00:00.000Z",
      "settings": {},
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}
get/v1/workspaces/{workspace_id}/integrations/{provider}Auth

Get a single integration detail with redacted credentials.

Includes the provider's `credentials_redacted` view. Secret fields (access tokens, signing keys) are never included.

Path parameters

workspace_id*string · uuid
provider*string

Response · 200

provider*string

Example: "github"

display_name*string

Example: "GitHub"

account_login*string

Example: "octocat"

account_avatar_url*string · uri

Example: "https://avatars.githubusercontent.com/u/1?v=4"

status*enum (3)

Example: "active"

last_error*string

Surfaced when status is 'error' — last failure message.

last_synced_at*string · date-time

When the last successful sync ran. null until first success.

settings*object

Provider-specific non-secret settings (e.g. selected repo / branch / path).

updated_at*string · date-time
credentials_redacted*object

Provider's redacted credential view — public identifiers + a 'token_present' boolean. Secrets NEVER round-trip.

Example

Request

GET /v1/workspaces/{workspace_id}/integrations/{provider}
Authorization: Bearer YOUR_TOKEN

Response

{
  "provider": "github",
  "display_name": "GitHub",
  "account_login": "octocat",
  "account_avatar_url": "https://avatars.githubusercontent.com/u/1?v=4",
  "status": "active",
  "last_error": "string",
  "last_synced_at": "2026-01-01T00:00:00.000Z",
  "settings": {},
  "updated_at": "2026-01-01T00:00:00.000Z",
  "credentials_redacted": {}
}
delete/v1/workspaces/{workspace_id}/integrations/{provider}Auth

Disconnect an integration. Deletes the credential row.

Hard delete — the encrypted blob is removed. The customer must reconnect via the provider-specific OAuth flow to restore access.

Path parameters

workspace_id*string · uuid
provider*string

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Response · 204

Disconnected.

get/v1/workspaces/{workspace_id}/integrations/github/reposAuth

List repos visible to the connected GitHub account.

Path parameters

workspace_id*string · uuid

Response · 200

Array of object

full_name*string
default_branch*string
private*boolean
html_url*string · uri
updated_at*string · date-time

Example

Request

GET /v1/workspaces/{workspace_id}/integrations/github/repos
Authorization: Bearer YOUR_TOKEN

Response

[
  {
    "full_name": "string",
    "default_branch": "string",
    "private": false,
    "html_url": "string",
    "updated_at": "2026-01-01T00:00:00.000Z"
  }
]
get/v1/workspaces/{workspace_id}/integrations/github/repos/{owner}/{repo}/branchesAuth

List branches for the given repo.

Path parameters

workspace_id*string · uuid
owner*string
repo*string

Response · 200

Array of object

name*string

Example

Request

GET /v1/workspaces/{workspace_id}/integrations/github/repos/{owner}/{repo}/branches
Authorization: Bearer YOUR_TOKEN

Response

[
  {
    "name": "string"
  }
]
put/v1/workspaces/{workspace_id}/integrations/github/settingsAuth

Set the repo + branch + templates path to sync from.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

repo_full_name*string

Example: "octocat/templates"

branch*string

Example: "main"

templates_path*string

Example: "/"

Response · 204

Saved.

Example

Request

PUT /v1/workspaces/{workspace_id}/integrations/github/settings
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "repo_full_name": "octocat/templates",
  "branch": "main",
  "templates_path": "/"
}
post/v1/workspaces/{workspace_id}/integrations/github/syncAuth

Trigger an immediate sync from GitHub. Returns a sync id to poll.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Response · 202

sync_id*string

Opaque sync-run id (base64url).

started_at*string · date-time

Example

Request

POST /v1/workspaces/{workspace_id}/integrations/github/sync
Authorization: Bearer YOUR_TOKEN

Response

{
  "sync_id": "string",
  "started_at": "2026-01-01T00:00:00.000Z"
}
get/v1/workspaces/{workspace_id}/integrations/github/sync/{sync_id}Auth

Poll a sync run's status.

Path parameters

workspace_id*string · uuid
sync_id*string

Response · 200

status*enum (3)
started_at*string · date-time
ended_at*string · date-time
summary*object
error*string

Example

Request

GET /v1/workspaces/{workspace_id}/integrations/github/sync/{sync_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "status": "running",
  "started_at": "2026-01-01T00:00:00.000Z",
  "ended_at": "2026-01-01T00:00:00.000Z",
  "summary": {},
  "error": "string"
}
post/v1/workspaces/{workspace_id}/integrations/github/connectAuth

Mint a GitHub OAuth state token and return the authorize URL.

The frontend redirects the user to `authorize_url`; GitHub bounces back to the `/callback` endpoint with a `code` + `state` pair. state is single-use, 5-minute TTL.

Path parameters

workspace_id*string · uuid

Headers

Idempotency-Keystring

Opaque client-generated string (1–256 chars, `[A-Za-z0-9_-:]`) used to deduplicate retries within a 24h window. A retry with the same key and body returns the original response with `Idempotent-Replay: true`.

Request body

return_to*string

URL the workspace user should land on after the GitHub OAuth flow completes. Must be a same-origin or productcraft.co URL — anything else is rejected to keep the callback from being an open-redirect.

Example: "https://console.productcraft.co/mail/workspaces/acme/integrations"

Response · 200

authorize_url*string · uri

Browser redirects here to start the GitHub OAuth flow.

Example: "https://github.com/login/oauth/authorize?client_id=…&state=…&redirect_uri=…"

Example

Request

POST /v1/workspaces/{workspace_id}/integrations/github/connect
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "return_to": "https://console.productcraft.co/mail/workspaces/acme/integrations"
}

Response

{
  "authorize_url": "https://github.com/login/oauth/authorize?client_id=…&state=…&redirect_uri=…"
}