# Broadcasts

One-off emails to an audience, sent now or at a time you choose.

## List broadcasts

`GET /projects/{project_id}/broadcasts`

Newest first, without each email’s body; get one broadcast for its html.

### Parameters

| Name | In | Type | Notes |
| --- | --- | --- | --- |
| `project_id` | path | string | Required |
| `status` | query | "draft" \| "scheduled" \| "sending" \| "sent" \| "canceled" \| null | Optional |
| `cursor` | query | string \| null | Optional |
| `limit` | query | integer | Optional, default 50 |

```sh Request
curl "https://sendocado.com/api/v1/projects/$PROJECT_ID/broadcasts?status=draft" \
  -H "Authorization: Bearer $SENDOCADO_API_KEY"
```

### Response

```json 200
{
  "broadcasts": [
    {
      "id": "06i8j76b2lajlj4h9du7794g9dpmrcg6",
      "project_id": "ujzde8gxd6ncf10epf91dhodzdoc9is0",
      "name": "October product update",
      "status": "draft",
      "filter_groups": [
        {
          "filters": [
            {
              "type": "tag",
              "op": "has",
              "tag": "customer"
            }
          ]
        }
      ],
      "subject": "What’s new in Acme this month",
      "preview_text": "Faster exports and a new dashboard.",
      "source_template_id": null,
      "scheduled_for": null,
      "has_open_sends": false,
      "created_at": 1790172800000
    }
  ],
  "next_cursor": null
}
```

### Errors

| Status | Type | Example message |
| --- | --- | --- |
| 400 | `invalid_data` | limit: Too big: expected number to be <=200 |
| 401 | `unauthorized` | Send a valid API key as "Authorization: Bearer sc_…", or sign in. |
| 404 | `project_not_found` | Project not found. |
| 429 | `rate_limited` | Too many requests. Try again in 12 seconds. |

## Create a broadcast

`POST /projects/{project_id}/broadcasts`

A draft. Its content is html, markdown, or a copy of a template. Send or schedule it with an update.

### Parameters

| Name | In | Type | Notes |
| --- | --- | --- | --- |
| `project_id` | path | string | Required |

### Body

Send JSON with every field.

| Field | Type | Notes |
| --- | --- | --- |
| `name` | string | Required |
| `filter_groups` | object[] | Required |
| `filter_groups[].filters` | object[] | Required |
| `filter_groups[].filters[].type` | "tag" | Required, when type is "tag" |
| `filter_groups[].filters[].op` | "has" \| "has_not" | Required, when type is "tag" |
| `filter_groups[].filters[].tag` | string | Required, when type is "tag" |
| `filter_groups[].filters[].type` | "attribute" | Required, when type is "attribute" |
| `filter_groups[].filters[].op` | "equals" \| "not_equals" | Required, when type is "attribute" |
| `filter_groups[].filters[].key` | string | Required, when type is "attribute" |
| `filter_groups[].filters[].value` | string | Required, when type is "attribute" |
| `filter_groups[].filters[].type` | "added_days_ago" | Required, when type is "added_days_ago" |
| `filter_groups[].filters[].op` | "less_than" \| "more_than" | Required, when type is "added_days_ago" |
| `filter_groups[].filters[].days` | integer | Required, when type is "added_days_ago" |
| `filter_groups[].filters[].type` | "opened" \| "clicked" | Required, when type is "opened" \| "clicked" |
| `filter_groups[].filters[].op` | "within" \| "not_within" | Required, when type is "opened" \| "clicked" |
| `filter_groups[].filters[].days` | integer | Required, when type is "opened" \| "clicked" |
| `filter_groups[].filters[].type` | "campaign" | Required, when type is "campaign" |
| `filter_groups[].filters[].op` | "is_enrolled" \| "is_not_enrolled" \| "was_enrolled" \| "was_never_enrolled" | Required, when type is "campaign" |
| `filter_groups[].filters[].campaign_id` | string | Required, when type is "campaign" |
| `subject` | string \| null | Required |
| `preview_text` | string \| null | Required |
| `html` | string \| null | Required |
| `markdown` | string \| null | Required |
| `template_id` | string \| null | Required |

```sh Request
curl -X POST "https://sendocado.com/api/v1/projects/$PROJECT_ID/broadcasts" \
  -H "Authorization: Bearer $SENDOCADO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "October product update",
  "filter_groups": [
    {
      "filters": [
        {
          "type": "tag",
          "op": "has",
          "tag": "customer"
        }
      ]
    }
  ],
  "subject": "What’s new in Acme this month",
  "preview_text": "Faster exports and a new dashboard.",
  "html": null,
  "markdown": "# What’s new\n\nHi {{ first_name | there }}, exports are now twice as fast.",
  "template_id": null
}'
```

### Response

```json 201
{
  "id": "06i8j76b2lajlj4h9du7794g9dpmrcg6",
  "project_id": "ujzde8gxd6ncf10epf91dhodzdoc9is0",
  "name": "October product update",
  "status": "draft",
  "filter_groups": [
    {
      "filters": [
        {
          "type": "tag",
          "op": "has",
          "tag": "customer"
        }
      ]
    }
  ],
  "subject": "What’s new in Acme this month",
  "preview_text": "Faster exports and a new dashboard.",
  "html": "<h1>What’s new</h1>\n<p>Hi {{ first_name | there }}, exports are now twice as fast.</p>\n",
  "text": "What’s new\n\nHi {{ first_name | there }}, exports are now twice as fast.",
  "merge_tags": ["first_name"],
  "source_template_id": null,
  "scheduled_for": null,
  "has_open_sends": false,
  "created_at": 1790172800000
}
```

### Errors

| Status | Type | Example message |
| --- | --- | --- |
| 400 | `invalid_data` | Send a JSON request body. |
| 401 | `unauthorized` | Send a valid API key as "Authorization: Bearer sc_…", or sign in. |
| 404 | `project_not_found` | Project not found. |
| 422 | `invalid_reference` | No template with that id in this project. |
| 429 | `rate_limited` | Too many requests. Try again in 12 seconds. |

## Get a broadcast

`GET /broadcasts/{broadcast_id}`

has_open_sends is true while emails of the broadcast still wait for the provider’s answer.

### Parameters

| Name | In | Type | Notes |
| --- | --- | --- | --- |
| `broadcast_id` | path | string | Required |

```sh Request
curl "https://sendocado.com/api/v1/broadcasts/$BROADCAST_ID" \
  -H "Authorization: Bearer $SENDOCADO_API_KEY"
```

### Response

```json 200
{
  "id": "06i8j76b2lajlj4h9du7794g9dpmrcg6",
  "project_id": "ujzde8gxd6ncf10epf91dhodzdoc9is0",
  "name": "October product update",
  "status": "draft",
  "filter_groups": [
    {
      "filters": [
        {
          "type": "tag",
          "op": "has",
          "tag": "customer"
        }
      ]
    }
  ],
  "subject": "What’s new in Acme this month",
  "preview_text": "Faster exports and a new dashboard.",
  "html": "<h1>What’s new</h1>\n<p>Hi {{ first_name | there }}, exports are now twice as fast.</p>\n",
  "text": "What’s new\n\nHi {{ first_name | there }}, exports are now twice as fast.",
  "merge_tags": ["first_name"],
  "source_template_id": null,
  "scheduled_for": null,
  "has_open_sends": false,
  "created_at": 1790172800000
}
```

### Errors

| Status | Type | Example message |
| --- | --- | --- |
| 401 | `unauthorized` | Send a valid API key as "Authorization: Bearer sc_…", or sign in. |
| 404 | `broadcast_not_found` | Broadcast not found. |
| 429 | `rate_limited` | Too many requests. Try again in 12 seconds. |

## Update, schedule or send a broadcast

`PUT /broadcasts/{broadcast_id}`

Send every field, status included. draft → scheduled needs scheduled_for in the future; draft or scheduled → sending starts it within seconds; scheduled → draft unschedules; sending → canceled stops it (409 for any other move). Scheduling or sending first passes the readiness checks (422). Once it is sending, only the status can change.

### Parameters

| Name | In | Type | Notes |
| --- | --- | --- | --- |
| `broadcast_id` | path | string | Required |

### Body

Send JSON with every field.

| Field | Type | Notes |
| --- | --- | --- |
| `name` | string | Required |
| `filter_groups` | object[] | Required |
| `filter_groups[].filters` | object[] | Required |
| `filter_groups[].filters[].type` | "tag" | Required, when type is "tag" |
| `filter_groups[].filters[].op` | "has" \| "has_not" | Required, when type is "tag" |
| `filter_groups[].filters[].tag` | string | Required, when type is "tag" |
| `filter_groups[].filters[].type` | "attribute" | Required, when type is "attribute" |
| `filter_groups[].filters[].op` | "equals" \| "not_equals" | Required, when type is "attribute" |
| `filter_groups[].filters[].key` | string | Required, when type is "attribute" |
| `filter_groups[].filters[].value` | string | Required, when type is "attribute" |
| `filter_groups[].filters[].type` | "added_days_ago" | Required, when type is "added_days_ago" |
| `filter_groups[].filters[].op` | "less_than" \| "more_than" | Required, when type is "added_days_ago" |
| `filter_groups[].filters[].days` | integer | Required, when type is "added_days_ago" |
| `filter_groups[].filters[].type` | "opened" \| "clicked" | Required, when type is "opened" \| "clicked" |
| `filter_groups[].filters[].op` | "within" \| "not_within" | Required, when type is "opened" \| "clicked" |
| `filter_groups[].filters[].days` | integer | Required, when type is "opened" \| "clicked" |
| `filter_groups[].filters[].type` | "campaign" | Required, when type is "campaign" |
| `filter_groups[].filters[].op` | "is_enrolled" \| "is_not_enrolled" \| "was_enrolled" \| "was_never_enrolled" | Required, when type is "campaign" |
| `filter_groups[].filters[].campaign_id` | string | Required, when type is "campaign" |
| `subject` | string \| null | Required |
| `preview_text` | string \| null | Required |
| `html` | string \| null | Required |
| `markdown` | string \| null | Required |
| `template_id` | string \| null | Required |
| `status` | "draft" \| "scheduled" \| "sending" \| "sent" \| "canceled" | Required |
| `scheduled_for` | integer \| null | Required |

```sh Request
curl -X PUT "https://sendocado.com/api/v1/broadcasts/$BROADCAST_ID" \
  -H "Authorization: Bearer $SENDOCADO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "October product update",
  "filter_groups": [
    {
      "filters": [
        {
          "type": "tag",
          "op": "has",
          "tag": "customer"
        }
      ]
    }
  ],
  "subject": "What’s new in Acme this month",
  "preview_text": "Faster exports and a new dashboard.",
  "html": "<h1>What’s new</h1>\n<p>Hi {{ first_name | there }}, exports are now twice as fast.</p>\n",
  "markdown": null,
  "template_id": null,
  "status": "scheduled",
  "scheduled_for": 1790259200000
}'
```

### Response

```json 200
{
  "id": "06i8j76b2lajlj4h9du7794g9dpmrcg6",
  "project_id": "ujzde8gxd6ncf10epf91dhodzdoc9is0",
  "name": "October product update",
  "status": "scheduled",
  "filter_groups": [
    {
      "filters": [
        {
          "type": "tag",
          "op": "has",
          "tag": "customer"
        }
      ]
    }
  ],
  "subject": "What’s new in Acme this month",
  "preview_text": "Faster exports and a new dashboard.",
  "html": "<h1>What’s new</h1>\n<p>Hi {{ first_name | there }}, exports are now twice as fast.</p>\n",
  "text": "What’s new\n\nHi {{ first_name | there }}, exports are now twice as fast.",
  "merge_tags": ["first_name"],
  "source_template_id": null,
  "scheduled_for": 1790259200000,
  "has_open_sends": false,
  "created_at": 1790172800000
}
```

### Errors

| Status | Type | Example message |
| --- | --- | --- |
| 400 | `invalid_data` | Send a JSON request body. |
| 401 | `unauthorized` | Send a valid API key as "Authorization: Bearer sc_…", or sign in. |
| 402 | `plan_limit_reached` | Your plan allows 5,000 contacts. Upgrade the plan to add more. |
| 404 | `broadcast_not_found` | Broadcast not found. |
| 409 | `invalid_status_transition` | A draft campaign cannot become paused. Set it live first. |
| 409 | `conflict` | This campaign has enrollments, so it cannot be deleted. Pause it instead. |
| 422 | `campaign_incomplete` | Add at least one email with a subject and content before going live. |
| 422 | `sender_not_ready` | Verify the domain of your from address before you send email. |
| 422 | `postal_address_missing` | Add a postal address to the project before you send marketing email. |
| 422 | `invalid_reference` | No template with that id in this project. |
| 429 | `rate_limited` | Too many requests. Try again in 12 seconds. |

## Delete a broadcast

`DELETE /broadcasts/{broadcast_id}`

Only a draft can be deleted (409 otherwise); a sent broadcast stays as the record of what went out.

### Parameters

| Name | In | Type | Notes |
| --- | --- | --- | --- |
| `broadcast_id` | path | string | Required |

```sh Request
curl -X DELETE "https://sendocado.com/api/v1/broadcasts/$BROADCAST_ID" \
  -H "Authorization: Bearer $SENDOCADO_API_KEY"
```

### Response

`204` with no body.

### Errors

| Status | Type | Example message |
| --- | --- | --- |
| 401 | `unauthorized` | Send a valid API key as "Authorization: Bearer sc_…", or sign in. |
| 404 | `broadcast_not_found` | Broadcast not found. |
| 409 | `conflict` | This campaign has enrollments, so it cannot be deleted. Pause it instead. |
| 429 | `rate_limited` | Too many requests. Try again in 12 seconds. |

## Check whether a broadcast can be sent

`GET /broadcasts/{broadcast_id}/readiness`

What would refuse scheduling or sending this broadcast right now. An empty list means it can go.

### Parameters

| Name | In | Type | Notes |
| --- | --- | --- | --- |
| `broadcast_id` | path | string | Required |

```sh Request
curl "https://sendocado.com/api/v1/broadcasts/$BROADCAST_ID/readiness" \
  -H "Authorization: Bearer $SENDOCADO_API_KEY"
```

### Response

```json 200
{
  "problems": []
}
```

### Errors

| Status | Type | Example message |
| --- | --- | --- |
| 401 | `unauthorized` | Send a valid API key as "Authorization: Bearer sc_…", or sign in. |
| 404 | `broadcast_not_found` | Broadcast not found. |
| 429 | `rate_limited` | Too many requests. Try again in 12 seconds. |

## Get broadcast stats

`GET /broadcasts/{broadcast_id}/stats`

Every day the broadcast sent on. Rates are fractions of sent_count, null before anything was sent; numbers trail events by about a minute.

### Parameters

| Name | In | Type | Notes |
| --- | --- | --- | --- |
| `broadcast_id` | path | string | Required |

```sh Request
curl "https://sendocado.com/api/v1/broadcasts/$BROADCAST_ID/stats" \
  -H "Authorization: Bearer $SENDOCADO_API_KEY"
```

### Response

```json 200
{
  "totals": {
    "sent_count": 1240,
    "delivered_count": 1221,
    "opened_count": 642,
    "clicked_count": 187,
    "replied_count": 41,
    "bounced_count": 6,
    "complained_count": 1,
    "unsubscribed_count": 9,
    "failed_count": 2
  },
  "rates": {
    "delivery_rate": 0.9847,
    "open_rate": 0.5177,
    "click_rate": 0.1508,
    "reply_rate": 0.0331,
    "bounce_rate": 0.0048,
    "complaint_rate": 0.0008,
    "unsubscribe_rate": 0.0073
  },
  "daily": [
    {
      "sent_count": 1240,
      "delivered_count": 1221,
      "opened_count": 642,
      "clicked_count": 187,
      "replied_count": 41,
      "bounced_count": 6,
      "complained_count": 1,
      "unsubscribed_count": 9,
      "failed_count": 2,
      "day": "2026-09-21"
    }
  ],
  "links": [
    {
      "url": "https://acme.example/changelog",
      "click_count": 203,
      "unique_click_count": 187
    }
  ]
}
```

### Errors

| Status | Type | Example message |
| --- | --- | --- |
| 401 | `unauthorized` | Send a valid API key as "Authorization: Bearer sc_…", or sign in. |
| 404 | `broadcast_not_found` | Broadcast not found. |
| 429 | `rate_limited` | Too many requests. Try again in 12 seconds. |
