# Broadcasts

> Part of the Cowliss docs. Markdown for every page: append `index.md` to its URL. Site map: /llms.txt

<!-- GENERATED by apps/docs/scripts/generate-api-reference.ts. Do not edit. Regenerate with `pnpm -F cowliss-docs generate`. -->

A broadcast is one email sent to the subscribers of one [list](/reference/api/lists/). Its content is a Maily document, written in the dashboard's editor or converted from a markdown file by the CLI, and it renders in its app's look (logo, colours and font, set in the app's [Brand](/tracking/apps/#brand) tab or with `apps.look.set`) with the unsubscribe footer and the app's postal address below it. A variable prints the subscriber's trait of that name, or its fallback when they have none. Any member can draft, preview and send a test to their own address; sending to the list happens in the dashboard only. All routes require session-token authentication.

## POST /v1/broadcasts

Create a broadcast draft

`from` falls back to the app's default sender, and is refused when there is neither; it must be on a verified sending domain. `replyTo` falls back the same way. A `doc` left out starts the draft blank, and a `subject` can wait until it is sent.

**Auth:** Session token.

**Request body**

```json
{
  "data": {
    "appId": "string",
    "list": "string",
    "subject": "string",
    "previewText": "string",
    "from": "string",
    "replyTo": "ada@example.com",
    "doc": {
      "type": "doc",
      "attrs": {
        "key": {}
      },
      "content": [
        {}
      ]
    },
    "docHash": "string",
    "header": "logo_and_name"
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.appId` | string | yes | length 0–200 |
| `data.list` | string | yes | length 1–∞ |
| `data.subject` | string | no | length 0–200 |
| `data.previewText` | string \| null | no |  |
| `data.from` | string | no | length 0–200 |
| `data.replyTo` | string (email) \| null | no |  |
| `data.doc` | object | no |  |
| `data.doc.type` | "doc" | yes |  |
| `data.doc.attrs` | record\<string, any> | no |  |
| `data.doc.content` | any[] | yes |  |
| `data.docHash` | string | no |  |
| `data.header` | "logo_and_name" \| "name" \| "logo" \| "none" | no |  |

**Response 201**

```json
{
  "data": {
    "id": "string",
    "appId": "string",
    "list": "string",
    "subject": "string",
    "previewText": "string",
    "from": "string",
    "replyTo": "string",
    "doc": {
      "type": "doc",
      "attrs": {
        "key": {}
      },
      "content": [
        {}
      ]
    },
    "docHash": "string",
    "header": "logo_and_name",
    "status": "draft",
    "scheduledAt": "2026-01-15T09:30:00.000Z",
    "startedAt": "2026-01-15T09:30:00.000Z",
    "finishedAt": "2026-01-15T09:30:00.000Z",
    "failureReason": "string",
    "counts": {
      "recipients": 42,
      "sent": 42,
      "delivered": 42,
      "skipped": 42,
      "failed": 42
    },
    "createdBy": "string",
    "createdAt": "2026-01-15T09:30:00.000Z",
    "updatedAt": "2026-01-15T09:30:00.000Z",
    "url": "string"
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.id` | string | yes |  |
| `data.appId` | string | yes |  |
| `data.list` | string | yes |  |
| `data.subject` | string | yes |  |
| `data.previewText` | string \| null | yes |  |
| `data.from` | string | yes |  |
| `data.replyTo` | string \| null | yes |  |
| `data.doc` | object | yes |  |
| `data.doc.type` | "doc" | yes |  |
| `data.doc.attrs` | record\<string, any> | no |  |
| `data.doc.content` | any[] | yes |  |
| `data.docHash` | string \| null | yes |  |
| `data.header` | "logo_and_name" \| "name" \| "logo" \| "none" | yes |  |
| `data.status` | "draft" \| "scheduled" \| "sending" \| "sent" \| "cancelled" \| "failed" | yes |  |
| `data.scheduledAt` | string (date-time) \| null | yes |  |
| `data.startedAt` | string (date-time) \| null | yes |  |
| `data.finishedAt` | string (date-time) \| null | yes |  |
| `data.failureReason` | string \| null | yes |  |
| `data.counts` | object | yes |  |
| `data.counts.recipients` | integer | yes |  |
| `data.counts.sent` | integer | yes |  |
| `data.counts.delivered` | integer | yes |  |
| `data.counts.skipped` | integer | yes |  |
| `data.counts.failed` | integer | yes |  |
| `data.createdBy` | string | yes |  |
| `data.createdAt` | string (date-time) | yes |  |
| `data.updatedAt` | string (date-time) | yes |  |
| `data.url` | string | yes |  |
| `meta` | object | no |  |
| `meta.requestId` | string | no |  |
| `meta.nextCursor` | string \| null | no |  |
| `meta.hasMore` | boolean | no |  |
| `meta.warnings` | object[] | no |  |
| `meta.warnings[]` | object | no |  |
| `meta.warnings[].code` | string | yes |  |
| `meta.warnings[].message` | string | yes |  |
| `meta.warnings[].suggestion` | string | no |  |

Errors follow the [shared envelope and code table](/api-conventions/#errors).

## GET /v1/broadcasts

List broadcasts

Newest first.

**Auth:** Session token.

**Query parameters**

```json
{
  "cursor": "string",
  "limit": 50,
  "appId": "string",
  "status": "draft"
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `cursor` | string | no |  |
| `limit` | integer | no | default `50`; 1 ≤ x ≤ 100 |
| `appId` | string | no | length 0–200 |
| `status` | "draft" \| "scheduled" \| "sending" \| "sent" \| "cancelled" \| "failed" | no |  |

**Response 200**

```json
{
  "data": [
    {
      "id": "string",
      "appId": "string",
      "list": "string",
      "subject": "string",
      "previewText": "string",
      "from": "string",
      "replyTo": "string",
      "doc": {
        "type": "doc",
        "attrs": {
          "key": {}
        },
        "content": [
          {}
        ]
      },
      "docHash": "string",
      "header": "logo_and_name",
      "status": "draft",
      "scheduledAt": "2026-01-15T09:30:00.000Z",
      "startedAt": "2026-01-15T09:30:00.000Z",
      "finishedAt": "2026-01-15T09:30:00.000Z",
      "failureReason": "string",
      "counts": {
        "recipients": 42,
        "sent": 42,
        "delivered": 42,
        "skipped": 42,
        "failed": 42
      },
      "createdBy": "string",
      "createdAt": "2026-01-15T09:30:00.000Z",
      "updatedAt": "2026-01-15T09:30:00.000Z",
      "url": "string"
    }
  ],
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object[] | yes |  |
| `data[]` | object | yes |  |
| `data[].id` | string | yes |  |
| `data[].appId` | string | yes |  |
| `data[].list` | string | yes |  |
| `data[].subject` | string | yes |  |
| `data[].previewText` | string \| null | yes |  |
| `data[].from` | string | yes |  |
| `data[].replyTo` | string \| null | yes |  |
| `data[].doc` | object | yes |  |
| `data[].doc.type` | "doc" | yes |  |
| `data[].doc.attrs` | record\<string, any> | no |  |
| `data[].doc.content` | any[] | yes |  |
| `data[].docHash` | string \| null | yes |  |
| `data[].header` | "logo_and_name" \| "name" \| "logo" \| "none" | yes |  |
| `data[].status` | "draft" \| "scheduled" \| "sending" \| "sent" \| "cancelled" \| "failed" | yes |  |
| `data[].scheduledAt` | string (date-time) \| null | yes |  |
| `data[].startedAt` | string (date-time) \| null | yes |  |
| `data[].finishedAt` | string (date-time) \| null | yes |  |
| `data[].failureReason` | string \| null | yes |  |
| `data[].counts` | object | yes |  |
| `data[].counts.recipients` | integer | yes |  |
| `data[].counts.sent` | integer | yes |  |
| `data[].counts.delivered` | integer | yes |  |
| `data[].counts.skipped` | integer | yes |  |
| `data[].counts.failed` | integer | yes |  |
| `data[].createdBy` | string | yes |  |
| `data[].createdAt` | string (date-time) | yes |  |
| `data[].updatedAt` | string (date-time) | yes |  |
| `data[].url` | string | yes |  |
| `meta` | object | no |  |
| `meta.requestId` | string | no |  |
| `meta.nextCursor` | string \| null | no |  |
| `meta.hasMore` | boolean | no |  |
| `meta.warnings` | object[] | no |  |
| `meta.warnings[]` | object | no |  |
| `meta.warnings[].code` | string | yes |  |
| `meta.warnings[].message` | string | yes |  |
| `meta.warnings[].suggestion` | string | no |  |

Errors follow the [shared envelope and code table](/api-conventions/#errors).

## POST /v1/broadcasts/preview

Render a doc without saving it

Renders a Maily doc as a broadcast would be, in the look of the app named (or the default look), with every variable at its fallback and the unsubscribe footer. Writes nothing.

**Auth:** Session token.

**Request body**

```json
{
  "data": {
    "doc": {
      "type": "doc",
      "attrs": {
        "key": {}
      },
      "content": [
        {}
      ]
    },
    "appId": "string",
    "subject": "string",
    "previewText": "string",
    "header": "logo_and_name"
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.doc` | object | yes |  |
| `data.doc.type` | "doc" | yes |  |
| `data.doc.attrs` | record\<string, any> | no |  |
| `data.doc.content` | any[] | yes |  |
| `data.appId` | string | no | length 0–200 |
| `data.subject` | string | no | length 0–200 |
| `data.previewText` | string | no | length 0–200 |
| `data.header` | "logo_and_name" \| "name" \| "logo" \| "none" | no |  |

**Response 200**

```json
{
  "data": {
    "subject": "string",
    "html": "string",
    "text": "string"
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.subject` | string | yes |  |
| `data.html` | string | yes |  |
| `data.text` | string | yes |  |
| `meta` | object | no |  |
| `meta.requestId` | string | no |  |
| `meta.nextCursor` | string \| null | no |  |
| `meta.hasMore` | boolean | no |  |
| `meta.warnings` | object[] | no |  |
| `meta.warnings[]` | object | no |  |
| `meta.warnings[].code` | string | yes |  |
| `meta.warnings[].message` | string | yes |  |
| `meta.warnings[].suggestion` | string | no |  |

Errors follow the [shared envelope and code table](/api-conventions/#errors).

## GET /v1/broadcasts/{id}

Fetch one broadcast

**Auth:** Session token.

**Response 200**

```json
{
  "data": {
    "id": "string",
    "appId": "string",
    "list": "string",
    "subject": "string",
    "previewText": "string",
    "from": "string",
    "replyTo": "string",
    "doc": {
      "type": "doc",
      "attrs": {
        "key": {}
      },
      "content": [
        {}
      ]
    },
    "docHash": "string",
    "header": "logo_and_name",
    "status": "draft",
    "scheduledAt": "2026-01-15T09:30:00.000Z",
    "startedAt": "2026-01-15T09:30:00.000Z",
    "finishedAt": "2026-01-15T09:30:00.000Z",
    "failureReason": "string",
    "counts": {
      "recipients": 42,
      "sent": 42,
      "delivered": 42,
      "skipped": 42,
      "failed": 42
    },
    "createdBy": "string",
    "createdAt": "2026-01-15T09:30:00.000Z",
    "updatedAt": "2026-01-15T09:30:00.000Z",
    "url": "string"
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.id` | string | yes |  |
| `data.appId` | string | yes |  |
| `data.list` | string | yes |  |
| `data.subject` | string | yes |  |
| `data.previewText` | string \| null | yes |  |
| `data.from` | string | yes |  |
| `data.replyTo` | string \| null | yes |  |
| `data.doc` | object | yes |  |
| `data.doc.type` | "doc" | yes |  |
| `data.doc.attrs` | record\<string, any> | no |  |
| `data.doc.content` | any[] | yes |  |
| `data.docHash` | string \| null | yes |  |
| `data.header` | "logo_and_name" \| "name" \| "logo" \| "none" | yes |  |
| `data.status` | "draft" \| "scheduled" \| "sending" \| "sent" \| "cancelled" \| "failed" | yes |  |
| `data.scheduledAt` | string (date-time) \| null | yes |  |
| `data.startedAt` | string (date-time) \| null | yes |  |
| `data.finishedAt` | string (date-time) \| null | yes |  |
| `data.failureReason` | string \| null | yes |  |
| `data.counts` | object | yes |  |
| `data.counts.recipients` | integer | yes |  |
| `data.counts.sent` | integer | yes |  |
| `data.counts.delivered` | integer | yes |  |
| `data.counts.skipped` | integer | yes |  |
| `data.counts.failed` | integer | yes |  |
| `data.createdBy` | string | yes |  |
| `data.createdAt` | string (date-time) | yes |  |
| `data.updatedAt` | string (date-time) | yes |  |
| `data.url` | string | yes |  |
| `meta` | object | no |  |
| `meta.requestId` | string | no |  |
| `meta.nextCursor` | string \| null | no |  |
| `meta.hasMore` | boolean | no |  |
| `meta.warnings` | object[] | no |  |
| `meta.warnings[]` | object | no |  |
| `meta.warnings[].code` | string | yes |  |
| `meta.warnings[].message` | string | yes |  |
| `meta.warnings[].suggestion` | string | no |  |

Errors follow the [shared envelope and code table](/api-conventions/#errors).

## PATCH /v1/broadcasts/{id}

Change a broadcast draft

Only a draft changes. With `ifDocHash`, the change is refused with `conflict` when the stored doc is not the one that hash names, which is the case once someone edited it in the dashboard.

**Auth:** Session token.

**Request body**

```json
{
  "data": {
    "list": "string",
    "subject": "string",
    "previewText": "string",
    "from": "string",
    "replyTo": "ada@example.com",
    "doc": {
      "type": "doc",
      "attrs": {
        "key": {}
      },
      "content": [
        {}
      ]
    },
    "docHash": "string",
    "header": "logo_and_name",
    "ifDocHash": "string"
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.list` | string | no | length 1–∞ |
| `data.subject` | string | no | length 0–200 |
| `data.previewText` | string \| null | no |  |
| `data.from` | string | no | length 0–200 |
| `data.replyTo` | string (email) \| null | no |  |
| `data.doc` | object | no |  |
| `data.doc.type` | "doc" | yes |  |
| `data.doc.attrs` | record\<string, any> | no |  |
| `data.doc.content` | any[] | yes |  |
| `data.docHash` | string | no |  |
| `data.header` | "logo_and_name" \| "name" \| "logo" \| "none" | no |  |
| `data.ifDocHash` | string | no |  |

**Response 200**

```json
{
  "data": {
    "id": "string",
    "appId": "string",
    "list": "string",
    "subject": "string",
    "previewText": "string",
    "from": "string",
    "replyTo": "string",
    "doc": {
      "type": "doc",
      "attrs": {
        "key": {}
      },
      "content": [
        {}
      ]
    },
    "docHash": "string",
    "header": "logo_and_name",
    "status": "draft",
    "scheduledAt": "2026-01-15T09:30:00.000Z",
    "startedAt": "2026-01-15T09:30:00.000Z",
    "finishedAt": "2026-01-15T09:30:00.000Z",
    "failureReason": "string",
    "counts": {
      "recipients": 42,
      "sent": 42,
      "delivered": 42,
      "skipped": 42,
      "failed": 42
    },
    "createdBy": "string",
    "createdAt": "2026-01-15T09:30:00.000Z",
    "updatedAt": "2026-01-15T09:30:00.000Z",
    "url": "string"
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.id` | string | yes |  |
| `data.appId` | string | yes |  |
| `data.list` | string | yes |  |
| `data.subject` | string | yes |  |
| `data.previewText` | string \| null | yes |  |
| `data.from` | string | yes |  |
| `data.replyTo` | string \| null | yes |  |
| `data.doc` | object | yes |  |
| `data.doc.type` | "doc" | yes |  |
| `data.doc.attrs` | record\<string, any> | no |  |
| `data.doc.content` | any[] | yes |  |
| `data.docHash` | string \| null | yes |  |
| `data.header` | "logo_and_name" \| "name" \| "logo" \| "none" | yes |  |
| `data.status` | "draft" \| "scheduled" \| "sending" \| "sent" \| "cancelled" \| "failed" | yes |  |
| `data.scheduledAt` | string (date-time) \| null | yes |  |
| `data.startedAt` | string (date-time) \| null | yes |  |
| `data.finishedAt` | string (date-time) \| null | yes |  |
| `data.failureReason` | string \| null | yes |  |
| `data.counts` | object | yes |  |
| `data.counts.recipients` | integer | yes |  |
| `data.counts.sent` | integer | yes |  |
| `data.counts.delivered` | integer | yes |  |
| `data.counts.skipped` | integer | yes |  |
| `data.counts.failed` | integer | yes |  |
| `data.createdBy` | string | yes |  |
| `data.createdAt` | string (date-time) | yes |  |
| `data.updatedAt` | string (date-time) | yes |  |
| `data.url` | string | yes |  |
| `meta` | object | no |  |
| `meta.requestId` | string | no |  |
| `meta.nextCursor` | string \| null | no |  |
| `meta.hasMore` | boolean | no |  |
| `meta.warnings` | object[] | no |  |
| `meta.warnings[]` | object | no |  |
| `meta.warnings[].code` | string | yes |  |
| `meta.warnings[].message` | string | yes |  |
| `meta.warnings[].suggestion` | string | no |  |

Errors follow the [shared envelope and code table](/api-conventions/#errors).

## DELETE /v1/broadcasts/{id}

Delete a broadcast draft

Only a draft can be deleted.

**Auth:** Session token.

**Response 200**

```json
{
  "data": {
    "deleted": true
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.deleted` | true | yes |  |
| `meta` | object | no |  |
| `meta.requestId` | string | no |  |
| `meta.nextCursor` | string \| null | no |  |
| `meta.hasMore` | boolean | no |  |
| `meta.warnings` | object[] | no |  |
| `meta.warnings[]` | object | no |  |
| `meta.warnings[].code` | string | yes |  |
| `meta.warnings[].message` | string | yes |  |
| `meta.warnings[].suggestion` | string | no |  |

Errors follow the [shared envelope and code table](/api-conventions/#errors).

## POST /v1/broadcasts/{id}/duplicate

Copy a broadcast into a new draft

The copy keeps the list, sender and content, and its subject starts with "Copy of " (a draft without one copies without one).

**Auth:** Session token.

**Response 201**

```json
{
  "data": {
    "id": "string",
    "appId": "string",
    "list": "string",
    "subject": "string",
    "previewText": "string",
    "from": "string",
    "replyTo": "string",
    "doc": {
      "type": "doc",
      "attrs": {
        "key": {}
      },
      "content": [
        {}
      ]
    },
    "docHash": "string",
    "header": "logo_and_name",
    "status": "draft",
    "scheduledAt": "2026-01-15T09:30:00.000Z",
    "startedAt": "2026-01-15T09:30:00.000Z",
    "finishedAt": "2026-01-15T09:30:00.000Z",
    "failureReason": "string",
    "counts": {
      "recipients": 42,
      "sent": 42,
      "delivered": 42,
      "skipped": 42,
      "failed": 42
    },
    "createdBy": "string",
    "createdAt": "2026-01-15T09:30:00.000Z",
    "updatedAt": "2026-01-15T09:30:00.000Z",
    "url": "string"
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.id` | string | yes |  |
| `data.appId` | string | yes |  |
| `data.list` | string | yes |  |
| `data.subject` | string | yes |  |
| `data.previewText` | string \| null | yes |  |
| `data.from` | string | yes |  |
| `data.replyTo` | string \| null | yes |  |
| `data.doc` | object | yes |  |
| `data.doc.type` | "doc" | yes |  |
| `data.doc.attrs` | record\<string, any> | no |  |
| `data.doc.content` | any[] | yes |  |
| `data.docHash` | string \| null | yes |  |
| `data.header` | "logo_and_name" \| "name" \| "logo" \| "none" | yes |  |
| `data.status` | "draft" \| "scheduled" \| "sending" \| "sent" \| "cancelled" \| "failed" | yes |  |
| `data.scheduledAt` | string (date-time) \| null | yes |  |
| `data.startedAt` | string (date-time) \| null | yes |  |
| `data.finishedAt` | string (date-time) \| null | yes |  |
| `data.failureReason` | string \| null | yes |  |
| `data.counts` | object | yes |  |
| `data.counts.recipients` | integer | yes |  |
| `data.counts.sent` | integer | yes |  |
| `data.counts.delivered` | integer | yes |  |
| `data.counts.skipped` | integer | yes |  |
| `data.counts.failed` | integer | yes |  |
| `data.createdBy` | string | yes |  |
| `data.createdAt` | string (date-time) | yes |  |
| `data.updatedAt` | string (date-time) | yes |  |
| `data.url` | string | yes |  |
| `meta` | object | no |  |
| `meta.requestId` | string | no |  |
| `meta.nextCursor` | string \| null | no |  |
| `meta.hasMore` | boolean | no |  |
| `meta.warnings` | object[] | no |  |
| `meta.warnings[]` | object | no |  |
| `meta.warnings[].code` | string | yes |  |
| `meta.warnings[].message` | string | yes |  |
| `meta.warnings[].suggestion` | string | no |  |

Errors follow the [shared envelope and code table](/api-conventions/#errors).

## POST /v1/broadcasts/{id}/test

Send test to me

Sends the broadcast as rendered to your own address, with every variable at its fallback. Costs nothing, counts toward nothing, and shows as a test. Its subject starts with `[test] `, and reads `[test] No subject set yet` while the draft has none.

**Auth:** Session token.

**Response 201**

```json
{
  "data": {
    "id": "string",
    "orgId": "string",
    "profileId": "string",
    "automation": "string",
    "executionId": "string",
    "appId": "string",
    "broadcastId": "string",
    "kind": "automation",
    "step": "string",
    "channel": "email",
    "status": "sent",
    "dryRun": true,
    "recipient": "string",
    "providerId": "string",
    "error": "string",
    "createdAt": "2026-01-15T09:30:00.000Z",
    "updatedAt": "2026-01-15T09:30:00.000Z",
    "sentAt": "2026-01-15T09:30:00.000Z",
    "settledAt": "2026-01-15T09:30:00.000Z",
    "openedAt": "2026-01-15T09:30:00.000Z",
    "openCount": 42,
    "spamScore": 42,
    "spamSymbols": [
      "string"
    ]
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.id` | string | yes |  |
| `data.orgId` | string | yes |  |
| `data.profileId` | string \| null | yes |  |
| `data.automation` | string \| null | yes |  |
| `data.executionId` | string \| null | yes |  |
| `data.appId` | string \| null | yes |  |
| `data.broadcastId` | string \| null | yes |  |
| `data.kind` | "automation" \| "test" \| "api" \| "broadcast" | yes |  |
| `data.step` | string | yes |  |
| `data.channel` | "email" \| "webhook" | yes |  |
| `data.status` | "sent" \| "delivered" \| "bounced" \| "complained" \| "failed" \| "skipped_disabled" \| "skipped_paused" \| "skipped_suspended" \| "skipped_suppressed" \| "skipped_quota" \| "skipped_unknown_purpose" \| "skipped_frequency_cap" \| "skipped_consent" \| "skipped_domain" \| "skipped_ssrf" \| "skipped_spam" \| "would_send" \| "would_skip_disabled" \| "would_skip_paused" \| "would_skip_suspended" \| "would_skip_suppressed" \| "would_skip_quota" \| "would_skip_unknown_purpose" \| "would_skip_frequency_cap" \| "would_skip_consent" \| "would_skip_domain" \| "would_skip_ssrf" \| "would_skip_spam" | yes |  |
| `data.dryRun` | boolean | yes |  |
| `data.recipient` | string \| null | yes |  |
| `data.providerId` | string \| null | yes |  |
| `data.error` | string \| null | yes |  |
| `data.createdAt` | string (date-time) | yes |  |
| `data.updatedAt` | string (date-time) | yes |  |
| `data.sentAt` | string (date-time) \| null | yes |  |
| `data.settledAt` | string (date-time) \| null | yes |  |
| `data.openedAt` | string (date-time) \| null | yes |  |
| `data.openCount` | integer | yes | -2147483648 ≤ x ≤ 2147483647 |
| `data.spamScore` | number \| null | yes |  |
| `data.spamSymbols` | string[] \| null | yes |  |
| `meta` | object | no |  |
| `meta.requestId` | string | no |  |
| `meta.nextCursor` | string \| null | no |  |
| `meta.hasMore` | boolean | no |  |
| `meta.warnings` | object[] | no |  |
| `meta.warnings[]` | object | no |  |
| `meta.warnings[].code` | string | yes |  |
| `meta.warnings[].message` | string | yes |  |
| `meta.warnings[].suggestion` | string | no |  |

Errors follow the [shared envelope and code table](/api-conventions/#errors).

## POST /v1/broadcasts/{id}/preview

Render a broadcast

The broadcast in its app's look, with every variable at its fallback and the unsubscribe footer.

**Auth:** Session token.

**Response 200**

```json
{
  "data": {
    "subject": "string",
    "html": "string",
    "text": "string"
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.subject` | string | yes |  |
| `data.html` | string | yes |  |
| `data.text` | string | yes |  |
| `meta` | object | no |  |
| `meta.requestId` | string | no |  |
| `meta.nextCursor` | string \| null | no |  |
| `meta.hasMore` | boolean | no |  |
| `meta.warnings` | object[] | no |  |
| `meta.warnings[]` | object | no |  |
| `meta.warnings[].code` | string | yes |  |
| `meta.warnings[].message` | string | yes |  |
| `meta.warnings[].suggestion` | string | no |  |

Errors follow the [shared envelope and code table](/api-conventions/#errors).

## POST /v1/broadcasts/{id}/send

Send or schedule a broadcast

Admin only, from the dashboard. Sends the draft to everyone on its list now, or at `at` when given (a time in the future). Who is on the list is decided when sending starts, and it does not start unless the organization's credit covers all of them. Refused while the draft has no subject.

**Auth:** Session token.

**Request body**

```json
{
  "data": {
    "at": "2026-01-15T09:30:00.000Z"
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.at` | string (date-time) | no |  |

**Response 200**

```json
{
  "data": {
    "id": "string",
    "appId": "string",
    "list": "string",
    "subject": "string",
    "previewText": "string",
    "from": "string",
    "replyTo": "string",
    "doc": {
      "type": "doc",
      "attrs": {
        "key": {}
      },
      "content": [
        {}
      ]
    },
    "docHash": "string",
    "header": "logo_and_name",
    "status": "draft",
    "scheduledAt": "2026-01-15T09:30:00.000Z",
    "startedAt": "2026-01-15T09:30:00.000Z",
    "finishedAt": "2026-01-15T09:30:00.000Z",
    "failureReason": "string",
    "counts": {
      "recipients": 42,
      "sent": 42,
      "delivered": 42,
      "skipped": 42,
      "failed": 42
    },
    "createdBy": "string",
    "createdAt": "2026-01-15T09:30:00.000Z",
    "updatedAt": "2026-01-15T09:30:00.000Z",
    "url": "string"
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.id` | string | yes |  |
| `data.appId` | string | yes |  |
| `data.list` | string | yes |  |
| `data.subject` | string | yes |  |
| `data.previewText` | string \| null | yes |  |
| `data.from` | string | yes |  |
| `data.replyTo` | string \| null | yes |  |
| `data.doc` | object | yes |  |
| `data.doc.type` | "doc" | yes |  |
| `data.doc.attrs` | record\<string, any> | no |  |
| `data.doc.content` | any[] | yes |  |
| `data.docHash` | string \| null | yes |  |
| `data.header` | "logo_and_name" \| "name" \| "logo" \| "none" | yes |  |
| `data.status` | "draft" \| "scheduled" \| "sending" \| "sent" \| "cancelled" \| "failed" | yes |  |
| `data.scheduledAt` | string (date-time) \| null | yes |  |
| `data.startedAt` | string (date-time) \| null | yes |  |
| `data.finishedAt` | string (date-time) \| null | yes |  |
| `data.failureReason` | string \| null | yes |  |
| `data.counts` | object | yes |  |
| `data.counts.recipients` | integer | yes |  |
| `data.counts.sent` | integer | yes |  |
| `data.counts.delivered` | integer | yes |  |
| `data.counts.skipped` | integer | yes |  |
| `data.counts.failed` | integer | yes |  |
| `data.createdBy` | string | yes |  |
| `data.createdAt` | string (date-time) | yes |  |
| `data.updatedAt` | string (date-time) | yes |  |
| `data.url` | string | yes |  |
| `meta` | object | no |  |
| `meta.requestId` | string | no |  |
| `meta.nextCursor` | string \| null | no |  |
| `meta.hasMore` | boolean | no |  |
| `meta.warnings` | object[] | no |  |
| `meta.warnings[]` | object | no |  |
| `meta.warnings[].code` | string | yes |  |
| `meta.warnings[].message` | string | yes |  |
| `meta.warnings[].suggestion` | string | no |  |

Errors follow the [shared envelope and code table](/api-conventions/#errors).

## POST /v1/broadcasts/{id}/cancel

Cancel a scheduled broadcast

Admin only, from the dashboard. Works until sending starts; after that the broadcast runs to the end of its list.

**Auth:** Session token.

**Response 200**

```json
{
  "data": {
    "id": "string",
    "appId": "string",
    "list": "string",
    "subject": "string",
    "previewText": "string",
    "from": "string",
    "replyTo": "string",
    "doc": {
      "type": "doc",
      "attrs": {
        "key": {}
      },
      "content": [
        {}
      ]
    },
    "docHash": "string",
    "header": "logo_and_name",
    "status": "draft",
    "scheduledAt": "2026-01-15T09:30:00.000Z",
    "startedAt": "2026-01-15T09:30:00.000Z",
    "finishedAt": "2026-01-15T09:30:00.000Z",
    "failureReason": "string",
    "counts": {
      "recipients": 42,
      "sent": 42,
      "delivered": 42,
      "skipped": 42,
      "failed": 42
    },
    "createdBy": "string",
    "createdAt": "2026-01-15T09:30:00.000Z",
    "updatedAt": "2026-01-15T09:30:00.000Z",
    "url": "string"
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.id` | string | yes |  |
| `data.appId` | string | yes |  |
| `data.list` | string | yes |  |
| `data.subject` | string | yes |  |
| `data.previewText` | string \| null | yes |  |
| `data.from` | string | yes |  |
| `data.replyTo` | string \| null | yes |  |
| `data.doc` | object | yes |  |
| `data.doc.type` | "doc" | yes |  |
| `data.doc.attrs` | record\<string, any> | no |  |
| `data.doc.content` | any[] | yes |  |
| `data.docHash` | string \| null | yes |  |
| `data.header` | "logo_and_name" \| "name" \| "logo" \| "none" | yes |  |
| `data.status` | "draft" \| "scheduled" \| "sending" \| "sent" \| "cancelled" \| "failed" | yes |  |
| `data.scheduledAt` | string (date-time) \| null | yes |  |
| `data.startedAt` | string (date-time) \| null | yes |  |
| `data.finishedAt` | string (date-time) \| null | yes |  |
| `data.failureReason` | string \| null | yes |  |
| `data.counts` | object | yes |  |
| `data.counts.recipients` | integer | yes |  |
| `data.counts.sent` | integer | yes |  |
| `data.counts.delivered` | integer | yes |  |
| `data.counts.skipped` | integer | yes |  |
| `data.counts.failed` | integer | yes |  |
| `data.createdBy` | string | yes |  |
| `data.createdAt` | string (date-time) | yes |  |
| `data.updatedAt` | string (date-time) | yes |  |
| `data.url` | string | yes |  |
| `meta` | object | no |  |
| `meta.requestId` | string | no |  |
| `meta.nextCursor` | string \| null | no |  |
| `meta.hasMore` | boolean | no |  |
| `meta.warnings` | object[] | no |  |
| `meta.warnings[]` | object | no |  |
| `meta.warnings[].code` | string | yes |  |
| `meta.warnings[].message` | string | yes |  |
| `meta.warnings[].suggestion` | string | no |  |

Errors follow the [shared envelope and code table](/api-conventions/#errors).

## GET /v1/broadcasts/{id}/audience

Who a broadcast would reach now

The list's subscribers who would be mailed if it started now, and the rest counted by why not: `suppressed` (the address bounced, complained or was removed by hand) and `no_email`.

**Auth:** Session token.

**Response 200**

```json
{
  "data": {
    "willReceive": 42,
    "skipped": {
      "suppressed": 42
    }
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.willReceive` | integer | yes |  |
| `data.skipped` | record\<suppressed \| no_email, integer> | yes |  |
| `meta` | object | no |  |
| `meta.requestId` | string | no |  |
| `meta.nextCursor` | string \| null | no |  |
| `meta.hasMore` | boolean | no |  |
| `meta.warnings` | object[] | no |  |
| `meta.warnings[]` | object | no |  |
| `meta.warnings[].code` | string | yes |  |
| `meta.warnings[].message` | string | yes |  |
| `meta.warnings[].suggestion` | string | no |  |

Errors follow the [shared envelope and code table](/api-conventions/#errors).

## GET /v1/broadcasts/{id}/stats

What a broadcast did

Sent, delivered, opens (approximate: a tracking pixel that mail privacy features pre-load), clicks, unsubscribes from this broadcast's link, bounces and complaints, skips by reason, opens and clicks per hour over the first 48 hours, and the clicked links.

**Auth:** Session token.

**Response 200**

```json
{
  "data": {
    "sent": 42,
    "delivered": 42,
    "opens": 42,
    "clicks": 42,
    "unsubscribed": 42,
    "bounced": 42,
    "complained": 42,
    "skipped": {
      "paused": 42
    },
    "hourly": [
      {
        "hour": "2026-01-15T09:30:00.000Z",
        "opens": 42,
        "clicks": 42
      }
    ],
    "links": [
      {
        "url": "string",
        "clicks": 42
      }
    ]
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.sent` | integer | yes |  |
| `data.delivered` | integer | yes |  |
| `data.opens` | integer | yes |  |
| `data.clicks` | integer | yes |  |
| `data.unsubscribed` | integer | yes |  |
| `data.bounced` | integer | yes |  |
| `data.complained` | integer | yes |  |
| `data.skipped` | record\<paused \| suppressed \| quota \| frequency_cap \| consent \| domain \| spam \| no_email \| gone, integer> | yes |  |
| `data.hourly` | object[] | yes |  |
| `data.hourly[]` | object | yes |  |
| `data.hourly[].hour` | string (date-time) | yes |  |
| `data.hourly[].opens` | integer | yes |  |
| `data.hourly[].clicks` | integer | yes |  |
| `data.links` | object[] | yes |  |
| `data.links[]` | object | yes |  |
| `data.links[].url` | string | yes |  |
| `data.links[].clicks` | integer | yes |  |
| `meta` | object | no |  |
| `meta.requestId` | string | no |  |
| `meta.nextCursor` | string \| null | no |  |
| `meta.hasMore` | boolean | no |  |
| `meta.warnings` | object[] | no |  |
| `meta.warnings[]` | object | no |  |
| `meta.warnings[].code` | string | yes |  |
| `meta.warnings[].message` | string | yes |  |
| `meta.warnings[].suggestion` | string | no |  |

Errors follow the [shared envelope and code table](/api-conventions/#errors).
