# List blasts

`GET /v1/blasts`

Returns your campaigns' email blasts with their main stats, newest first.

**Scope:** `blasts:read`

Blasts are ordered by `created_at` (then `id`), newest first, and rows are never repeated or skipped. The first page fixes the window: a blast created while you page does not join the walk; it heads the next list you start without a cursor.

List rows carry the main stats. `opened`, `clicked` and the day-by-day breakdown are only on `GET /v1/blasts/{blast_id}`.

Without `campaign_id` the list covers every campaign whose blasts the key can read; a `campaign_id` must be one of them, otherwise `404`.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `campaign_id` | `UUID` | No | Only this campaign. |
| `status` | `string` | No | Only blasts with this status. One of `draft`, `scheduled`, `sending`, `paused`, `completed`, `cancelled`, `failed`. |
| `since` | `timestamp` | No | Only blasts created at or after this time. ISO 8601; a time without an offset is read as UTC (URL-encode `+` as `%2B`). |
| `limit` | `integer` | No | Blasts per page. Every blast's stats are counted on each request, so pages are small. 1 to 20. Default: `20`. |
| `cursor` | `string` | No | The `next_cursor` of the previous page, unchanged. It carries the filters: send it alone, or with exactly the filters of the first page. Up to 4,096 characters. |

## Response

**Status:** `200 OK`

| Name | Type | Description |
| --- | --- | --- |
| `data` | `array of objects` | Blasts, newest first. |
| `data.id` | `UUID` | The blast. |
| `data.name` | `string` | Its name in DialEcho. |
| `data.campaign_id` | `UUID` | The blast's campaign. |
| `data.status` | `string` | The blast status. One of `draft`, `scheduled`, `sending`, `paused`, `completed`, `cancelled`, `failed`. |
| `data.created_at` | `timestamp` | When it was created (RFC 3339, UTC). |
| `data.scheduled_at` | `timestamp \| null` | When it is or was scheduled to start; `null` when it was not scheduled. |
| `data.subject_template` | `string \| null` | The subject as written, merge tags included. Each send's `subject` is the subject its recipient got. |
| `data.stats` | `object` | The main stats. |
| `data.stats.recipients_total` | `integer` | Every recipient of the blast, in any status. |
| `data.stats.delivered` | `integer` | Delivered sends: status `delivered`, plus `sent` through any sender but SendGrid, whose sends count once SendGrid confirms delivery. |
| `data.stats.bounced` | `integer` | Sends that bounced. |
| `data.stats.failed` | `integer` | Sends that failed. |
| `data.stats.suppressed` | `integer` | Recipients not emailed because the address is suppressed. |
| `data.stats.pending` | `integer` | Recipients not sent yet (`pending` or `processing`). Cancelled and skipped recipients count only in `recipients_total`. |
| `data.stats.replied` | `integer` | Delivered recipients who replied: a reply DialEcho matched to their send. |
| `data.stats.opt_outs` | `integer` | Recipients who opted out: their matched reply was an opt-out, or the provider reported an unsubscribe. Unsubscribe-link clicks cannot be tied to a blast and are not counted. |
| `data.stats.bounce_rate` | `number \| null` | `bounced / (delivered + bounced)`, rounded to 4 decimals; `null` while both are 0. |
| `data.stats.reply_rate` | `number \| null` | `replied / delivered`, rounded to 4 decimals and never above 1; `null` while nothing was delivered. |
| `next_cursor` | `string \| null` | Pass it back as `cursor` for the next page; `null` when there is none. |
| `has_more` | `boolean` | `true` while `next_cursor` is set. |

## Example request

**curl**

```bash
curl "https://development.agencydevworks.ai/v1/blasts?campaign_id=cccccccc-0000-4000-8000-000000000001&limit=20" \
  -H "Authorization: Bearer dei_your_key"
```

**JavaScript**

```js
const response = await fetch("https://development.agencydevworks.ai/v1/blasts?campaign_id=cccccccc-0000-4000-8000-000000000001&limit=20", {
  headers: {
    "Authorization": "Bearer dei_your_key",
  },
});
const data = await response.json();
```

## Example response

**Status:** `200 OK`

```json
{
  "data": [
    {
      "id": "22222222-0000-4000-8000-000000000001",
      "name": "Fall re-engagement",
      "campaign_id": "cccccccc-0000-4000-8000-000000000001",
      "status": "completed",
      "created_at": "2026-11-02T14:00:00Z",
      "scheduled_at": "2026-11-03T15:00:00Z",
      "subject_template": "{{first_name}}, your solar quote is ready",
      "stats": {
        "recipients_total": 1200,
        "delivered": 1100,
        "bounced": 30,
        "failed": 5,
        "suppressed": 40,
        "pending": 20,
        "replied": 25,
        "opt_outs": 4,
        "bounce_rate": 0.0265,
        "reply_rate": 0.0227
      }
    }
  ],
  "next_cursor": null,
  "has_more": false
}
```

## Errors

| HTTP | Code | When |
| --- | --- | --- |
| 403 | `insufficient_scope` | The key does not have `blasts:read`. Checked before the query is validated. |
| 404 | `not_found` | `campaign_id` is not a campaign whose blasts the key can read. |
| 409 | `resync_required` | The cursor expired (after 30 days) or the key's or its member's access changed since it was issued. Start again without a cursor. |
| 422 | `invalid_cursor` | The cursor was not issued to this key for this list, was changed, or was sent with different filters. |
| 422 | `invalid_request` | A parameter is invalid. `error.fields` names the first invalid filter (`campaign_id`, `status`, `since`), or `query.limit` (over 20) or `query.cursor`. |
| 503 | `dependency_unavailable` | Blast data or the access check is temporarily unavailable. Retry with backoff. |

Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview.

## Related endpoints

- `GET /v1/blasts/{blast_id}` · [Retrieve a blast](/api/blasts/get)
- `GET /v1/blasts/{blast_id}/sends` · [List a blast's sends](/api/blasts/list-sends)
