# Analytics

Sends, deliveries, bounces, complaints, and clicks over a window, with the rates and the honest caveats.

One endpoint answers "how is our mail doing". It returns totals over a window, a series broken into days, weeks, or months, and an optional breakdown by domain, tag, mail class, or template.

GET /v1/analytics:
```bash
curl "https://emails.sh/v1/analytics?from=2026-07-01&to=2026-07-31&group_by=day&breakdown=domain" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

200 OK:
```json
{
  "range": { "from": "2026-07-01", "to": "2026-07-31", "group_by": "day" },
  "totals": {
    "sent": 128400,
    "queued": 12,
    "rejected": 318,
    "in_flight": 40,
    "delivered": 126902,
    "bounced": 1180,
    "complained": 46,
    "delivered_rate": 0.9883,
    "bounce_rate": 0.0092,
    "complaint_rate": 0.0004,
    "clicks_tracked": 41200,
    "clicked": 5104,
    "clicks": 7318,
    "click_rate": 0.1239,
    "opens_tracked": 41200,
    "opened": 18422,
    "opens": 26104
  },
  "series": [
    { "period": "2026-07-01", "sent": 4102, "delivered": 4061, "bounced": 33, "complained": 1, "delivered_rate": 0.99, "bounce_rate": 0.008, "complaint_rate": 0.0002, "clicks_tracked": 1300, "clicked": 160, "clicks": 214, "click_rate": 0.123, "opens_tracked": 1300, "opened": 590, "opens": 812, "queued": 0, "rejected": 8, "in_flight": 0 }
  ],
  "breakdown": {
    "by": "domain",
    "tag_key": null,
    "rows": [
      { "key": "gmail.com", "label": "gmail.com", "sent": 61200, "delivered": 60800, "bounced": 310, "complained": 22, "delivered_rate": 0.9935, "bounce_rate": 0.0051, "complaint_rate": 0.0004, "clicks_tracked": 20100, "clicked": 2600, "clicks": 3700, "click_rate": 0.1294, "opens_tracked": 20100, "opened": 9400, "opens": 13200, "queued": 0, "rejected": 140, "in_flight": 20 },
      { "key": null, "label": "Other (312)", "sent": 8200, "delivered": 8080, "bounced": 96, "complained": 3, "delivered_rate": 0.9854, "bounce_rate": 0.0117, "complaint_rate": 0.0004, "clicks_tracked": 2600, "clicked": 300, "clicks": 402, "click_rate": 0.1154, "opens_tracked": 2600, "opened": 1100, "opens": 1520, "queued": 0, "rejected": 20, "in_flight": 0 }
    ]
  },
  "notes": {
    "opens": "Open tracking undercounts. Image blocking and proxy prefetching both distort it.",
    "tracking": "Only messages sent with tracking on are counted in opens_tracked and clicks_tracked.",
    "sources": "Counts come from delivery events, not from the send call."
  }
}
```

### Every metric

The same object appears in totals, in every series entry, and in every breakdown row, so a chart can read them the same way wherever it found them.

| Field | What it counts |
| --- | --- |
| sent | Messages handed to a receiving mail server. |
| queued | Accepted and not yet sent at the moment you asked. |
| rejected | Refused before sending: suppression, topic opt-out, an unverified domain. |
| in_flight | Sent, with no delivery or bounce event yet. |
| delivered | The receiving server confirmed it took the message. |
| bounced | Refused by the receiving server. |
| complained | Reported as spam through a feedback loop. |
| delivered_rate | delivered over sent. |
| bounce_rate | bounced over sent. The number that decides whether a workspace gets paused. |
| complaint_rate | complained over sent. Keep it under 0.001. |
| clicks_tracked | Messages sent with click tracking on. The denominator for click_rate. |
| clicked | Distinct messages that had at least one click. |
| clicks | Total clicks, including repeats by the same person. |
| click_rate | clicked over clicks_tracked. |
| opens_tracked | Messages sent with open tracking on. |
| opened | Distinct messages with at least one recorded open. |
| opens | Total recorded opens, including repeats. |

### There is no open_rate

Deliberately. Every other number here would let you compute one, and we do not publish it, because an open rate is not a measurement of anything stable.

An open is a one-pixel image loading. Every mail client that blocks images by default records no open from somebody who read the whole message. Apple Mail Privacy Protection and several corporate gateways go further and fetch the pixel on delivery whether or not anybody looked, so the same number is inflated for one part of your list and deflated for another. Dividing two distortions gives a rate that moves when Apple ships an update and not when your writing changes.

opened and opens are here because a floor is still information: an open that was recorded did probably happen. Use clicks for anything you intend to act on, and read the deliverability numbers, which are measured at the receiving server and are not guesses.

### Rates are null, not zero

When the denominator is 0, a rate comes back as null rather than 0. A day on which you sent nothing has a bounce_rate of null, because "no bounces out of no sends" is not a zero percent bounce rate, and a chart that draws it as one is drawing a line that says your deliverability was perfect on a day you were not sending.

Handle the null:
```ts
// Every rate is number | null. Render the gap rather than a zero.
type Point = { period: string; bounce_rate: number | null };

export function formatRate(point: Point): string {
  if (point.bounce_rate === null) return '\u2014';
  return `${(point.bounce_rate * 100).toFixed(2)}%`;
}
```

### Filters and breakdowns

| Parameter | What it takes |
| --- | --- |
| from, to | YYYY-MM-DD in UTC, and to is inclusive. Defaults to the last 30 days, and the window may be at most 400 days. |
| group_by | day, week, or month. Defaults to day. |
| breakdown | domain, tag, mail_class, or template. |
| tag_key | Required when breakdown is tag: it names which tag key to split on. |
| mail_class | transactional or marketing, as a filter. |
| domain | Filter to one recipient domain. |
| template_id | Filter to one template. |
| tag | Filter to one tag, written key:value. |

A breakdown returns the top 20 by sent, and everything below that is folded into one row whose key is null and whose label reads "Other (N)" with the number of things folded in. So the rows always add up to the totals, which they would not if the tail were dropped.

GET /v1/analytics/tags answers which tag keys are worth splitting on, with how much mail each one carried, up to 25 of them. Call it to populate a picker instead of asking somebody to remember what they tagged things with.

Find a tag key, then split by it:
```bash
# Which tag keys carried mail in the last 90 days
curl "https://emails.sh/v1/analytics/tags?days=90" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"

# Then break the last month down by one of them
curl "https://emails.sh/v1/analytics?breakdown=tag&tag_key=campaign&group_by=week" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

| Refusal | What it means |
| --- | --- |
| 422 invalid_range | from or to is not a date, or from is after to. |
| 422 range_too_long | The window is more than 400 days. |
| 422 invalid_group_by | group_by is not day, week, or month. |
| 422 invalid_breakdown | breakdown is not one of the four. |
| 422 tag_key_required | breakdown=tag without tag_key. |
| 422 invalid_mail_class | mail_class is not transactional or marketing. |
| 422 invalid_tag_filter | tag is not written key:value. |

- `GET /v1/analytics` ?from=&to=&group_by=&breakdown=&mail_class=&domain=&template_id=&tag=
- `GET /v1/analytics/tags` ?days= returns the tag keys worth breaking down by.

#### `analytics.get`

`{ from?: string, to?: string, group_by?: "day" | "week" | "month", breakdown?: "domain" | "tag" | "mail_class" | "template", tag_key?: string, mail_class?: "transactional" | "marketing", domain?: string, template_id?: string, tag?: string }`

Sends, deliveries, bounces, complaints, clicks, and opens over a window, as totals and as a series.

| Parameter | Type | Required |
| --- | --- | --- |
| from | `string` | no |
| to | `string` | no |
| group_by | `"day" | "week" | "month"` | no |
| breakdown | `"domain" | "tag" | "mail_class" | "template"` | no |
| tag_key | `string` | no |
| mail_class | `"transactional" | "marketing"` | no |
| domain | `string` | no |
| template_id | `string` | no |
| tag | `string` | no |

Returns: { range, totals, series[], breakdown, notes }

#### `analytics.tags`

`{ days?: number }`

Which tag keys are worth breaking down by, with how much mail each carried. Up to 25.

| Parameter | Type | Required |
| --- | --- | --- |
| days | `number` | no |

Returns: { tag_keys: [{ key, sent }] }

Counts come from delivery events rather than from the send call, so a message sent a minute ago sits in in_flight until its receiving server answers. Numbers for the current day keep moving for a few minutes after you read them.

---

Base URL: https://emails.sh/v1. Auth: `Authorization: Bearer esh_...`.
Whole API in one file: https://emails.sh/llms.txt. All documentation: https://emails.sh/docs.md.
