Reporting
Per-broadcast performance, bounces, engagement and timing.
Broadcast performance summary
The headline numbers for one send. Start here; the other reporting endpoints break these down.
Rates are over total_recipients — the intended audience — not over what the provider accepted. That keeps them comparable with the list page and means a send with bounces reads slightly lower than a provider-side dashboard would show.
Three open counts, deliberately. apparent_opened is every contact with any open event, which is the number other tools report. verified_opened is the headline: non-bot openers plus anyone who clicked, since a person cannot click without opening — this is what guarantees open rate ≥ click rate. confirmed_opened is the strict high-confidence floor. total_opened and total_clicked are high-confidence only, so they already exclude bots.
pending_opened and pending_clicked are engagement the bot classifier has not judged yet, and they are forced to 0 once the send is no longer live — a late open on a finished campaign is organic tail, not a scanner flood, so do not read a zero as "nothing waiting". classifier_last_run_at and classifier_interval_ms are likewise only populated while the send is live; they are null otherwise.
The broadcast ID.
Every contact with any open event, bots included.
Contacts whose open was classified as a machine.
Only populated while the send is live.
Only populated while the send is live.
Strict high-confidence openers only.
Most recent recipient dispatched. Advances per spread batch. Null until the first send.
total_opened / total_recipients, to 4 decimal places.
Unclassified clicks. Zero once the send is no longer live.
Unclassified opens, held behind the verifying window. Zero once the send is no longer live.
The broadcast's scheduled_at — when the send was asked to start, not when it finished.
High-confidence unique clickers.
High-confidence unique openers — bots already excluded.
The intended audience, and the denominator for every rate here.
Headline openers: non-bot opens ∪ verified clickers.
Headline metrics for the send.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X GET "https://api.sonarsend.com/t/acme/api/broadcasts/9c21895d-57f0-4a15-a1df-4dc6835d4f80/report" \ -H "X-API-Key: $SONARSEND_API_KEY"
const res = await fetch('https://api.sonarsend.com/t/acme/api/broadcasts/9c21895d-57f0-4a15-a1df-4dc6835d4f80/report', {
method: 'GET',
headers: { 'X-API-Key': process.env.SONARSEND_API_KEY },
});
const data = await res.json();{
"broadcast_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"name": "Acme onboarding",
"status": "active",
"sent_at": "2026-01-15T09:30:00.000Z",
"last_send_at": "2026-01-15T09:30:00.000Z",
"total_recipients": 0
}Click performance per link
Every tracked destination in the email with its click counts, busiest first.
Counts are settled and bot-filtered, so they will be lower than a raw click log.
click_rate is unique_clicks over the broadcast's recipients, so the link rates do not sum to the broadcast's click rate — one reader clicking three links counts once in each row.
Links that were never clicked still appear, with zeroes and null timestamps: the universe comes from what was sent, not from what was clicked. That is what makes this usable as a "which call to action failed" view.
The broadcast ID.
No documented fields.
One entry per link: url, unique_clicks, total_clicks, click_rate, first_click_at, last_click_at.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X GET "https://api.sonarsend.com/t/acme/api/broadcasts/9c21895d-57f0-4a15-a1df-4dc6835d4f80/links" \ -H "X-API-Key: $SONARSEND_API_KEY"
const res = await fetch('https://api.sonarsend.com/t/acme/api/broadcasts/9c21895d-57f0-4a15-a1df-4dc6835d4f80/links', {
method: 'GET',
headers: { 'X-API-Key': process.env.SONARSEND_API_KEY },
});
const data = await res.json();[
{}
]KPIs for every broadcast, in one call
Headline metrics for all of the account's broadcasts at once — the endpoint to build a dashboard on, instead of calling /report in a loop.
Served from a cache with a long TTL. For a send that just started, or after an action you expect to have moved the numbers, pass ?refresh=true to invalidate and recompute before reading. Without it a freshly-started send can read stale for as long as the TTL.
Leaner than /report: opened and clicked here are the verified (bot-filtered) counts, there is no apparent/confirmed split, and there is no total_failed. sparkline is the first 6 hourly open counts from the broadcast's first open, for a trend glyph.
refresh is not declared as a parameter on purpose — this route has never had a querystring schema, and adding one would change what Fastify does with the request. It must be exactly true; any other value, 1 included, is ignored.
No documented fields.
One entry per broadcast: broadcast_id, total_recipients, delivered, opened, clicked, bounced, the matching *_rate fields, sparkline, pending_opened, pending_clicked, last_send_at.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X GET "https://api.sonarsend.com/t/acme/api/broadcasts/metrics-bulk" \ -H "X-API-Key: $SONARSEND_API_KEY"
const res = await fetch('https://api.sonarsend.com/t/acme/api/broadcasts/metrics-bulk', {
method: 'GET',
headers: { 'X-API-Key': process.env.SONARSEND_API_KEY },
});
const data = await res.json();[
{}
]