[relaybell docs › Developers › Analytics & reports](https://relaybell.com/docs/analytics-reports) · [All docs as Markdown](https://relaybell.com/docs.md) · [Index for AI assistants](https://relaybell.com/llms.txt)

# Analytics & reports

> Stats, daily numbers, the notification list and detail, and CSV export.

## Stats {#stats}

**GET** `/api/v1/stats` — totals for the website, plus its 20 most recent sends (newest first):

```json
{
  "subscribers": 1280,
  "subscriberDevices": { "desktop": 830, "android": 390, "ios": 60 },
  "totals": { "sent": 5400, "clicks": 310, "ctr": 5.7, "conversions": 12, "revenue": 940.5 },
  "notifications": [
    { "id": "5b0c2f7e-1d7a-4c3e-9a51-2f8e6b1d0c42", "title": "Flash sale 🔥", "body": "…", "url": "…",
      "status": "sent", "recipients": 1275, "sent": 1262, "failed": 3, "pruned": 10, "received": 1190,
      "clicks": 74, "conversions": 4, "revenue": 316,
      "devices": { "desktop": 820, "android": 384, "ios": 58 },
      "ab": null, "abStats": null, "error": null,
      "scheduled_at": null, "created_at": "2026-10-14T09:30:00.000Z" }
  ],
  "promptAb": null,
  "emailVerified": true,
  "sendingBlocked": false
}
```

| Field | Meaning |
| --- | --- |
| `subscribers` | Browsers currently subscribed (one person on two devices counts twice). `subscriberDevices` splits them by device: `desktop`, `android`, `ios`, `mobile`, `other`. |
| `totals` | All sends ever: `sent` (pushes the browsers' push services accepted), `clicks`, `ctr` (clicks ÷ sent, as a percentage with one decimal), `conversions` and `revenue` credited to a notification. |
| `status` | `queued`, `scheduled`, `held_for_review`, `sending`, `sent`, `failed` (reason in `error`) or `canceled`. |
| `recipients`, `sent`, `failed`, `pruned` | Browsers targeted; pushes accepted by the push service; pushes that failed; expired subscriptions found and removed during this send. |
| `received` | Pushes the browser confirmed it showed. Usually lower than `sent` (for example a device that stays offline until the push expires). |
| `devices` | `sent` split by device type. |
| `ab`, `abStats` | For an A/B send: the `ab` settings it was sent with, and `{ a: { delivered, clicks }, b: { delivered, clicks } }`. Otherwise `null`. |
| `promptAb` | Opt-in prompt A/B results, `{ a: { shown, subscribed }, b: { shown, subscribed } }`, or `null` if you haven't run one. |
| `emailVerified`, `sendingBlocked` | Whether the account's email is verified, and whether sends are refused until it is (`403 email_not_verified`). |

Also: `GET /api/v1/stats/growth?days=30` → `{ days, series: [{ date, subscribed, churned }], totals: { subscribed, churned, net } }` (1–365 days), and `GET /api/v1/stats/health` → `{ active, reachable, unsubscribed, new30, lost30, dormant, churnRate }`.

## Reports & export {#reports}

The numbers mean the same thing in every report: `sent`, pushes the browsers' push services accepted; `delivered`, pushes a device confirmed it received (what `/stats` calls `received`); `clicked`; `ctr`, clicked ÷ sent as a percentage with one decimal (`0` when nothing was sent); `conversions` and `revenue` credited to a notification. `from` and `to` are calendar days (`YYYY-MM-DD`, both included) in `tz`: an IANA time zone such as `Europe/London`. Left out, `tz` is your website's time zone (the one its quiet hours use), or UTC.

### Daily numbers {#timeseries}

**GET** `/api/v1/stats/timeseries?from=2026-10-01&to=2026-10-14&tz=Asia/Kolkata` — one point per day (days without sends are zeros), the totals, and the same totals for the period of the same length just before. Each notification counts on the day it went out: its scheduled time, or the time it was made for a send that started straight away. Without `from` and `to` it covers the last 30 days. A range can be at most 366 days (`400 invalid_range`).

```json
{
  "from": "2026-10-01", "to": "2026-10-14", "tz": "Asia/Kolkata", "interval": "day",
  "points": [
    { "date": "2026-10-01", "sent": 1262, "delivered": 1190, "clicked": 74, "conversions": 4, "revenue": 316 }
  ],
  "totals":   { "sent": 15400, "delivered": 14120, "clicked": 820, "ctr": 5.3, "conversions": 41, "revenue": 3120.5 },
  "previous": { "from": "2026-09-17", "to": "2026-09-30", "sent": 13900, "delivered": 12800, "clicked": 690, "ctr": 5, "conversions": 30, "revenue": 2210 },
  "currency": "USD"
}
```

`currency` is the revenue currency set for the website, or `null`.

### List of notifications {#notifications-list}

**GET** `/api/v1/notifications?from&to&status&q&limit&cursor` — newest first, 25 per page (`limit` up to 100). `status` is one or more of `draft`, `scheduled`, `queued`, `held_for_review`, `sending`, `sent`, `failed` and `canceled`, separated by commas; without it, everything except drafts is listed. `q` searches titles and texts, in any case. `from` and `to` filter on the day a notification was made. For the next page, send the `nextCursor` you got as `cursor`, with the same filters; it is `null` on the last page. Pages don't shift when new sends arrive.

```json
{
  "items": [
    { "id": "5b0c2f7e-1d7a-4c3e-9a51-2f8e6b1d0c42", "title": "Flash sale 🔥", "body": "…", "status": "sent",
      "created_at": "2026-10-14T09:30:00.000Z", "scheduled_at": null,
      "recipients": 1275, "sent": 1262, "delivered": 1190, "failed": 3, "pruned": 10,
      "clicked": 74, "ctr": 5.9, "conversions": 4, "revenue": 316,
      "ab": false, "targetSummary": "VIP buyers · Topic: Deals", "automation": false, "error": null }
  ],
  "nextCursor": "WyIyMDI2LTEwLTE0VDA5OjMwOjAwLjAwMDAwMFoiLCI1YjBj…"
}
```

| Field | Meaning |
| --- | --- |
| `scheduled_at` | When a scheduled send goes (or went) out; `null` for one that started straight away. |
| `recipients`, `failed`, `pruned` | Browsers targeted; pushes that failed; expired subscriptions found and removed during the send. |
| `targetSummary` | Who it went to, in words: `All subscribers`, a segment's name, `Topic: News`, `Specific users`, `Custom filter`, `One subscriber`, `Back-in-stock alert`, … |
| `ab`, `automation` | Whether it was an A/B test; whether an automation, a journey or a product alert made it. |
| `error` | Why a `failed` send never went out (`email_not_verified`, `suspended`, `segment_missing`, `review_rejected` or `dispatch_error`), otherwise `null`. |

A bad filter answers `400` with `invalid_status`, `invalid_range`, `invalid_tz` or `invalid_cursor`.

### One notification {#notification-detail}

**GET** `/api/v1/notifications/:id` — everything stored about one notification, and how it did. An id that isn't one of this website's answers `404 not_found`.

```json
{
  "notification": { "id": "5b0c2f7e-…", "title": "Flash sale 🔥", "status": "sent", "target": { "segmentId": "…" },
                    "targetSummary": "VIP buyers", "ab": null, "ab_test": null, "translations": null, "actions": null,
                    "image": null, "url": "https://shop.example/sale", "ttl": null, "scheduled_at": null, "stats": { … },
                    "sent": 1262, "delivered": 1190, "clicked": 74, "ctr": 5.9, … },
  "funnel":    { "recipients": 1275, "sent": 1262, "delivered": 1190, "clicked": 74, "converted": 4, "failed": 3, "expired": 10 },
  "devices":   [ { "device": "desktop", "sent": 820, "clicked": 51 } ],
  "variants":  [],
  "languages": [ { "language": "en", "sent": 900, "clicked": 55 } ],
  "errors":    [ { "code": "expired", "count": 10 }, { "code": "timeout", "count": 3 } ],
  "revenue": 316, "currency": "USD",
  "sendBody": { "title": "Flash sale 🔥", "url": "https://shop.example/sale", "segmentId": "…" }
}
```

| Field | Meaning |
| --- | --- |
| `devices`, `languages` | `sent` and `clicked` by device type and by the subscriber's language (two letters, or `unknown`). They come from the send's event log, so they are empty once your data retention has removed it; the funnel stays. |
| `variants` | For an A/B send, `{ variant, sent, clicked, ctr }` for versions `a` and `b`; otherwise `[]`. |
| `errors` | Why pushes didn't arrive: what the push service answered (an HTTP status, `timeout`, …), and `expired` for subscriptions found dead and removed. |
| `sendBody` | The `POST /api/v1/notifications` body that makes this send again, or a draft's saved fields. `null` when the people were picked at the time (automation, journey and product-alert sends). |

### Export to CSV {#export}

**GET** `/api/v1/notifications/export.csv` — the same filters as the list (no `cursor`), the same rows in the same order, up to 10,000 of them. Columns: `id, created_at, scheduled_at, status, title, target, recipients, sent, failed, clicked, ctr, conversions, revenue`, where `target` is the `targetSummary`. A title that starts with `=`, `+`, `-` or `@` gets a leading `'`, so a spreadsheet doesn't run it as a formula.

```bash
curl "https://relaybell.com/api/v1/notifications/export.csv?from=2026-10-01&to=2026-10-14&status=sent" \
  -H "Authorization: Bearer rb_live_xxxxx" -o notifications.csv
```
