Endpoint stats & status page
Emithook already counts everything that happens to your webhooks: what arrived, what was delivered, what failed and how long delivery took. Two calls let you put those numbers in front of your own users, so you don't have to keep a second set of counters.
- Stats JSON:
GET /v1/endpoints/{id}/statsreturns one endpoint's numbers, for you to render however you like. - Status page:
GET /v1/endpoints/{id}/stats/embedreturns a short-lived signed link to a ready-made HTML page. Anyone can open it or put it in an<iframe>without an API key.
Both need a read-scoped key.
Get the numbers
bash
curl -s "https://api.emithook.com/v1/endpoints/<endpoint-id>/stats?window=24h" \
-H "Authorization: Bearer $EMITHOOK_API_KEY"json
{
"endpoint": { "id": "ep_01JX9", "name": "Shopify orders", "url": "https://wh.emhk.in/acme/shopify/orders", "status": "active" },
"window": "24h",
"from": "2026-09-18T10:25:00.000Z",
"to": "2026-09-19T10:25:00.000Z",
"bucket_seconds": 3600,
"generated_at": "2026-09-19T10:25:00.000Z",
"rollup_fresh_at": "2026-09-19T10:23:00.000Z",
"last_received_at": "2026-09-19T10:24:51.000Z",
"totals": {
"received": 13714, "delivered": 13542, "failed": 52, "dlq": 0, "retrying": 3, "unrouted": 0,
"success_rate": 0.9962,
"verification": { "passed": 13714, "failed": 0, "none": 0 }
},
"latency_ms": { "p50": 91, "p95": 244, "p99": 480 },
"series": [{ "start": "2026-09-18T10:25:00.000Z", "received": 571, "delivered": 566, "failed": 0, "dlq": 0 }],
"destinations": [
{ "id": "dst_01JX9SQS", "name": "Orders queue", "type": "sqs", "attempted": 13594, "delivered": 13542,
"failed": 52, "retrying": 0, "success_rate": 0.9962, "latency_ms": { "p50": 60, "p95": 180, "p99": 390 } }
]
}| Field | Meaning |
|---|---|
window | 24h (the default, 24 one-hour buckets), 7d or 30d (one-day buckets), measured back from now. These are the same numbers the console shows for the endpoint and window. |
totals.received | Every event the endpoint accepted in the window. |
totals.delivered / failed / dlq | Events that ended delivered, failed, or dead-lettered. retrying are still being retried. unrouted had no route to forward them to. |
totals.success_rate | delivered / (delivered + failed + dlq). null when nothing has settled yet, never a made-up 0. |
latency_ms | Delivery round-trip to your destinations. Over more than one hour it is a volume-weighted mean of the hourly percentiles, not an exact window percentile. |
destinations | This endpoint's traffic per destination, even when a destination is shared with other endpoints. attempted counts events actually sent to it: a route filter's skips and mirror copies are left out. Destinations the endpoint routes to now but that saw nothing in the window are listed with zeros. |
rollup_fresh_at | The numbers come from rollups that update every few minutes; this says when they last moved. |
last_received_at | Live: the newest request that reached this endpoint's URL, including requests held while it was paused. |
With the SDK:
ts
const stats = await emithook.getEndpointStats('ep_01JX9', { window: '7d' })
console.log(`${(stats.totals.success_rate ?? 1) * 100}% delivered`)Show a status page
Mint a link on your server, then give only the URL to the browser. The API key never leaves your server.
bash
curl -s "https://api.emithook.com/v1/endpoints/<endpoint-id>/stats/embed?ttl=3600&theme=auto" \
-H "Authorization: Bearer $EMITHOOK_API_KEY"
# → { "url": "https://api.emithook.com/embed/stats/v1.eyJ…", "expires_at": "2026-09-19T11:24:00.000Z", "window": "24h", "theme": "auto" }ts
// e.g. in the handler that renders your customer's integrations page
const { url } = await emithook.getEndpointStatsEmbed(endpointId, { ttl: 3600 })
res.render('integration', { statsUrl: url })html
<iframe src="{{ statsUrl }}" title="Webhook status" style="width:100%;height:720px;border:0"></iframe>| Parameter | Default | Meaning |
|---|---|---|
window | 24h | The tab the page opens on. Viewers can switch between 24h, 7d and 30d. |
theme | auto | auto follows the viewer's light/dark setting, or force light / dark. |
ttl | 3600 | Link lifetime in seconds, from 60 up to 604800 (7 days). |
What the page shows, and what it never shows:
- Shows: the endpoint's name, a health badge, received / delivered / success rate, latency (p50, p95, p99), when it last received a request, a per-hour or per-day chart, and each destination by name and type.
- Never shows: the endpoint's receive URL (anyone who knows it can post to it), destination URLs or queue ARNs, API keys, or any payload.
- Runs no script and loads nothing from anywhere else. It is a single HTML document with inline styles, and it refreshes itself every few minutes, so it stays current on a wall display until the link expires (7 days at most, so a permanent display needs a fresh link at least weekly).
- The health badge reads the window's success rate, and the last ~2 hours override it once at least 5 deliveries settled or are retrying there. Recent retries count as not yet delivered, so a destination outage shows while it is happening; a single failure on a quiet endpoint doesn't turn it red.
Treat the link like a password
- Anyone holding the URL can view the page until
expires_at. Nothing is stored server-side, so a single link can't be revoked. Keepttlshort and mint a fresh link each time you render the page. - A link keeps working after the API key that minted it is revoked, until it expires.
- The token is readable, not secret in itself: it carries your org and endpoint ids (it can't be altered). Like any URL, it can also land in proxy and access logs.
- Error pages carry no data: "This link has expired" (
410), "Link not recognised" or "Endpoint unavailable" (404, a malformed link or a deleted endpoint), "Too many requests" (429, beyond 300 views of one endpoint's page per minute — the limit is per endpoint, so anyone holding the link can use it up for a minute; another reason to keep links short-lived), "Stats unavailable" (503, a temporary read failure). - Self-hosting: links are signed with
EMBED_SIGNING_SECRET(at least 32 characters; when unset, a key derived fromCONSOLE_JWT_SECRET). Changing it revokes every outstanding link, for every org, at once; there is no per-org revocation. SetAPI_BASE_URLto your public API origin: without it, links minted from inside the server (the hosted MCP server) are refused with501rather than pointing at an internal host.
From the CLI or an agent
bash
emithook endpoints stats ep_01JX9 --window 7d
emithook endpoints stats-embed ep_01JX9 --ttl 86400 --theme darkOver MCP: get_endpoint_stats { endpoint, window? } and get_endpoint_stats_embed { endpoint, window?, theme?, ttl? }. Over MCP a link lasts at most 1 day.
Related
- The raw inbound requests, with filters: Requests & replay (
GET /v1/requests?endpoint=&outcome=&since=&until=). - The events behind these numbers:
GET /v1/events?endpoint=&status=&since=&until=. - Hourly rollup rows by id:
GET /v1/metrics?key=<endpoint-or-destination-id>.