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
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
{
  "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.

FieldWhat it counts
sentMessages handed to a receiving mail server.
queuedAccepted and not yet sent at the moment you asked.
rejectedRefused before sending: suppression, topic opt-out, an unverified domain.
in_flightSent, with no delivery or bounce event yet.
deliveredThe receiving server confirmed it took the message.
bouncedRefused by the receiving server.
complainedReported as spam through a feedback loop.
delivered_ratedelivered over sent.
bounce_ratebounced over sent. The number that decides whether a workspace gets paused.
complaint_ratecomplained over sent. Keep it under 0.001.
clicks_trackedMessages sent with click tracking on. The denominator for click_rate.
clickedDistinct messages that had at least one click.
clicksTotal clicks, including repeats by the same person.
click_rateclicked over clicks_tracked.
opens_trackedMessages sent with open tracking on.
openedDistinct messages with at least one recorded open.
opensTotal 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
// 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

ParameterWhat it takes
from, toYYYY-MM-DD in UTC, and to is inclusive. Defaults to the last 30 days, and the window may be at most 400 days.
group_byday, week, or month. Defaults to day.
breakdowndomain, tag, mail_class, or template.
tag_keyRequired when breakdown is tag: it names which tag key to split on.
mail_classtransactional or marketing, as a filter.
domainFilter to one recipient domain.
template_idFilter to one template.
tagFilter 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
# 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"
RefusalWhat it means
422 invalid_rangefrom or to is not a date, or from is after to.
422 range_too_longThe window is more than 400 days.
422 invalid_group_bygroup_by is not day, week, or month.
422 invalid_breakdownbreakdown is not one of the four.
422 tag_key_requiredbreakdown=tag without tag_key.
422 invalid_mail_classmail_class is not transactional or marketing.
422 invalid_tag_filtertag 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

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

Arguments

from string
YYYY-MM-DD in UTC. Defaults to 30 days ago.
to string
YYYY-MM-DD in UTC, inclusive. The window may be at most 400 days.
group_by "day" | "week" | "month"
Defaults to day.
breakdown "domain" | "tag" | "mail_class" | "template"
tag_key string
Required when breakdown is tag.
mail_class "transactional" | "marketing"
domain string
template_id string
tag string
Filter to one tag, written key:value.

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

analytics.tags

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

Arguments

days number
Defaults to 30, clamped to 1 through 400.

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