METRICS API LIVEONE POST IN · CHART OUTNO AGENT · NO SDK · NO CONFIGFORM-ENCODED INGESTBEARER OR SESSION AUTHREAD-ONLY DASHBOARDCSV / JSON EXPORTMETRICS API LIVEONE POST IN · CHART OUTNO AGENT · NO SDK · NO CONFIGFORM-ENCODED INGESTBEARER OR SESSION AUTHREAD-ONLY DASHBOARDCSV / JSON EXPORT
docs · public api surface v1.0.6

API Documentation

Track metrics with plain HTTP. No SDK. No config files. Copy, paste, done.

Coming from StatHat? The /ez, /c and /v endpoints are wire-compatible — see the 5-minute migration guide.

integration

Quick Start

Works with any language via plain HTTP.

These examples use /ez, the StatHat-compatible endpoint, where a rejection still arrives as HTTP 200 with the verdict in the response body. So each one sends X-EzStat-Strict: 1 to get real status codes, and then checks the outcome. Writing a brand-new integration instead of migrating one? Prefer the v1 write endpoints — they return standard HTTP status codes with no header at all. Full reasoning under wire responses & strict mode.

Send a User-Agent your HTTP library did not pick for you — on every endpoint, ingest and read alike. Two library default User-Agent strings are blocked by Cloudflare's bot filtering before the request ever reaches us, and answered 403 with error code: 1010: Python-urllib/* (Python's stdlib urllib) and libwww-perl/*. The block is at the edge, so the 403 is not ours, carries no EzStat error body, and appears in no log you or we can read — it looks exactly like an outage and is not one. It applies to the v1 Bearer read APIs just as much as to /ez, so a script that reads your stats hits it too.

The fix is to set any other User-Agent — ideally naming your service, e.g. acme-billing/1.4. Measured 2026-08-14: curl, requests, wget, Go, Java, okhttp, axios, node-fetch, Postman and a custom string all pass, and so does no User-Agent header at all — it is those two specific library signatures that are rejected, not a missing header. We do not weaken the bot filtering to accommodate them, because it is load-bearing on a public ingest endpoint; we document it so an opaque 403 is a two-minute fix instead of a support ticket.

Python
import requests

r = requests.post("https://api.ezstat.dev/ez",
  headers={"X-EzStat-Strict": "1"},
  data={"ezkey": "YOUR_API_KEY",
        "stat": "signups", "count": 1})
r.raise_for_status()   # strict mode -> a rejection is a real HTTP error
# two different failures, two different meanings:
#   HTTPError (a response arrived)      -> the point did NOT land
#   ConnectionError / Timeout (no response) -> UNKNOWN: it may have landed
#     after your client gave up. A blind retry can double-count; retry only
#     with the same explicit t + ikey on every attempt (see the retry note above).
Node.js
const res = await fetch("https://api.ezstat.dev/ez", {
  method: "POST",
  headers: { "X-EzStat-Strict": "1" },
  body: new URLSearchParams({
    ezkey: "YOUR_API_KEY",
    stat: "page_views", count: "1"
  })
});
if (!res.ok) throw new Error((await res.json()).msg);
// res exists -> a response arrived -> its status IS the verdict (did not land).
// fetch() itself rejecting -> NO response -> the outcome is UNKNOWN, not
// "did not land": the request may have landed after your client gave up.
// A blind retry can double-count; retry only with the same explicit t + ikey
// on every attempt (see the retry note above).
Go
form := url.Values{"ezkey": {"YOUR_API_KEY"},
  "stat": {"requests"}, "count": {"1"},
  "t": {"1750000000"}, "ikey": {"req-9831"}}
req, _ := http.NewRequest("POST", "https://api.ezstat.dev/ez",
  strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-EzStat-Strict", "1")
res, err := http.DefaultClient.Do(req)
if err != nil {
  // NO response: a timeout or reset can happen AFTER the server stored
  // the point, so the outcome is UNKNOWN — never treat it as "did not land".
  // Retrying is safe HERE only because this request sends the same explicit
  // t + ikey on every attempt: the server recognizes the retry as the same
  // point and ignores it. Without t + ikey a blind retry can double-count.
  // (A small canary set of account/stat pairs does not honour ikey yet —
  // see the retries note above before relying on a retry.)
}
if err == nil && res.StatusCode != 200 {
  // A response arrived: this status IS the verdict — the point did NOT
  // land. Surface it, do not swallow it.
}
cURL
curl -fsS -X POST https://api.ezstat.dev/ez \
  -H "X-EzStat-Strict: 1" \
  -d "ezkey=YOUR_API_KEY" \
  -d "stat=deploys" -d "count=1"
# -f makes curl exit non-zero on an HTTP rejection (exit 22: a response
# arrived, the point did NOT land). Any OTHER non-zero exit (7 connection
# refused, 28 timed out, ...) means NO response — the outcome is UNKNOWN,
# and a blind retry can double-count; retry only with the same explicit
# t + ikey on every attempt (see the retry note above).
form endpoints

Form-encoded endpoints

Each successful form-encoded request records one data point for one stat. Send the body as application/x-www-form-urlencoded using your EzStat API key.

POST/ez

Record one counter or value stat per call via form fields.

Auth · API key (form field)Free + Paid
curl -X POST https://api.ezstat.dev/ez \
  -d "ezkey=YOUR_API_KEY" \
  -d "stat=api_requests" \
  -d "count=1"

parameters

  • ezkeyYour EzStat API key from Settings (required)
  • statStat name (required; max 128 chars — letters, numbers, spaces, dot, underscore, hyphen)
  • countCounter value (optional; if BOTH count and value are omitted, 1 is recorded)
  • valueGauge value (use instead of count)
  • tUnix timestamp in SECONDS (optional; omit and the server stamps now). Accepted window: 2000-01-01 through 5 minutes ahead of server time. Backfill any distance inside it; a t before 2000 (usually an uninitialised or null value sending 0) or more than 5 minutes ahead is rejected 400.
  • ikeyOptional idempotency key — dedup keys on stat + timestamp + ikey, so a retry is only recognised (ignored, first write wins) when you ALSO send t and repeat the SAME t and ikey on every attempt. Without t the server stamps each attempt's arrival time and a retry double-counts (stored and billed again). Quota on a deduped retry: a single-point retry is not billed; a retried JSON batch reserves its daily allowance per item again
POST/c

Record one counter stat per call via form fields.

Auth · API key (form field)Free + Paid
curl -X POST https://api.ezstat.dev/c \
  -d "key=api_requests" \
  -d "ukey=ss_live_xxx" \
  -d "count=1"

parameters

  • keyStat name (required)
  • ukeyYour EzStat API key (required)
  • countCounter value (required)
  • tUnix timestamp in SECONDS (optional; omit and the server stamps now). Accepted window: 2000-01-01 through 5 minutes ahead of server time. Backfill any distance inside it; a t before 2000 (usually an uninitialised or null value sending 0) or more than 5 minutes ahead is rejected 400.
POST/v

Record one gauge value per call via form fields.

Auth · API key (form field)Free + Paid
curl -X POST https://api.ezstat.dev/v \
  -d "key=response_time" \
  -d "ukey=ss_live_xxx" \
  -d "value=142"

parameters

  • keyStat name (required)
  • ukeyYour EzStat API key (required)
  • valueNumeric value (required)
  • tUnix timestamp in SECONDS (optional; omit and the server stamps now). Accepted window: 2000-01-01 through 5 minutes ahead of server time. Backfill any distance inside it; a t before 2000 (usually an uninitialised or null value sending 0) or more than 5 minutes ahead is rejected 400.
read this before copying between routes

/ez and /c use different names for the same two things

These endpoints reproduce two different StatHat APIs, and the two APIs disagreed. Copying a working /ez call onto /c therefore fails with missing required parameter, which does not say which one.

what you are sendingon /ezon /c and /v
your account API keyezkeyukey
the stat namestatkey
methods acceptedGET and POSTPOST only

The trap is the word key. On /c and /v, key is the stat name — it is not a credential, and there is no such thing as a per-stat key in EzStat. Your one account key goes in ukey there and in ezkey on /ez. Both accept email as an alias for the account key, inherited from the same StatHat era.

/c and /v are POST only — there is no GET form, so a browser-bar test of those two will always look broken. /ez is the one that also answers GET.

re-sending data · duplicate timestamps

Re-sending a point never overwrites — it merges, and the response says so

Every accepted submission is stored as an additional observation. Sending a second point with the same stat and the same timestamp does not replace the first one: both are kept, and every read merges the observations that fall in a chart bucket — value (gauge) stats average; counters sum. Concretely: send value=10 and then value=5 for the same timestamp and the chart shows 7.5 at that bucket (min/max still show 5 and 10); send count=10 then count=5 and the bucket shows 15. This is one product-wide semantic — /ez (single and batch), /c, /v and the v1 write endpoints all store this way.

When a merge actually happens, the response tells you. A single-point write whose explicit timestamp already holds a stored point answers with an extra replay field naming the semantic it landed in: "averages_with_existing" on a value stat, "sums_with_existing" on a counter, or "duplicate_ignored" when your ikey matched and the re-send was deduplicated. The field appears only on a genuine replay — a first write never carries it. Disclosure is best-effort: it covers exact-timestamp matches on single-point writes (batch responses and points recorded without an explicit t do not carry it), so its absence means "no merge observed", not a proof of uniqueness.

There is no overwrite flag. If you sent a bad datapoint, re-sending a corrected value pulls the bucket's merged average toward the correction (or adds to a counter) — it does not replace the original. To keep retries from double-counting in the first place, send an ikey together with an explicit t: the same stat + t + ikey is stored once, first write wins, and a later identical single-point send answers replay: "duplicate_ignored". The explicit t is what makes a retry the same point — without it each attempt gets the server's arrival time, so an ikey alone does not dedupe.

wire responses & strict mode

The v1 endpoints speak HTTP. The StatHat-compatible ones speak StatHat.

The 200-with-the-verdict-in-the-body behaviour described on this page is confined to /ez, /c and /v — the StatHat-compatible endpoints, which exist so that unmodified StatHat clients keep working. It is not how EzStat behaves generally, and it is not something you have to accept.

Writing a new integration? Use the v1 write endpoints. POST /api/v1/stats/:name/count and POST /api/v1/stats/:name/value already return standard HTTP status codes on every rejection — no header, no opt-in, nothing to remember: 401 unauthorized, 403 stat or daily-point limit reached, 429 rate limited, 400 malformed input, 503 transient, 5xx server-side. A success is 200 with {"status":"ok"}. Nothing else in this section applies to them. They are documented under Bearer Token API.

Why that is the stronger choice and not merely a preference: the status code is the part of a response your infrastructure reads. Uptime checks, CDN and proxy logs, API gateways, SLO dashboards and retry middleware classify by status and never open the body. So on the compatibility endpoints a mistyped key does not just fool your code — it produces successful-looking failures the length of your observability chain, in systems that have no way to know better. A header fixes the one client that knows to send it. A 401 fixes everything in the path.

Must stay on /ez but can set a header? Send X-EzStat-Strict: 1. With that header, every rejection gets a matching HTTP status alongside the same JSON body — 401 bad/missing key, 429 quota, 400 malformed input, 5xx server-side — so your HTTP client's ordinary error handling catches a failed write with no extra work. It is a header rather than the wire default only because flipping the default would break the clients described next. Use it when you control the client but not the endpoint — mid-migration, or behind a StatHat-shaped internal wrapper.

Reusing an unmodified StatHat client? Then you are on the compatibility path — and you MUST check the body. Those clients cannot set the header, so they get StatHat's original wire behaviour: a rejection still arrives as HTTP 200, with the verdict in the response body. Your HTTP library will report success. Nothing in your process will say otherwise. A stale or wrong API key fails exactly this way — the writes are accepted by your code and discarded by us — and because your existing charts keep rendering their old data, the dashboard looks alive while nothing new is being recorded. Read the body on every write, or send the header.

For StatHat drop-in compatibility, /ez, /c and /v mirror StatHat's original wire behavior: success is HTTP 200 with the exact body {"status":200,"msg":"ok"}, and most rejections (bad or missing key, plan quota reached, insert failure) are also HTTP 200 with {"status":"error","msg":"..."}. The body's status field is the verdict — a client that only checks the HTTP code will read those rejections as success, exactly as legacy StatHat clients did. Note the shape: on success status is the integer 200; on a rejection it is the string "error".

The one-line check, on the compatibility path:

# the HTTP code is 200 either way — the body is the verdict
# (if curl itself failed there is no body: the outcome is UNKNOWN, not "dropped")
curl -s -X POST https://api.ezstat.dev/ez \
  -d "ezkey=YOUR_API_KEY" -d "stat=signups" -d "count=1" \
  | grep -q '"status":200' && echo "recorded" || echo "NOT recorded — body verdict is error, or no response arrived (unknown)"

# or with jq, if you have it
curl -s -X POST https://api.ezstat.dev/ez \
  -d "ezkey=YOUR_API_KEY" -d "stat=signups" -d "count=1" \
  | jq -e '.status == 200' >/dev/null || echo "NOT recorded — or no response (outcome unknown)"

And once you have switched a real writer over, confirm the points are arriving rather than merely being accepted — that is what the verifier is for.

Always a real HTTP status, in both modes: 400 malformed input (non-numeric or out-of-range count/value, bad stat name, counter/gauge type conflict, out-of-range timestamp), 413 oversized body, 429 rate limit. Nothing is stored on any rejection — a bad value can never fabricate a stored 0 or auto-create a stat.

Why the two behave differently: EzStat returns HTTP 200 with the verdict in the body on the StatHat-compatible ingest endpoints because that is StatHat's documented behaviour and the official client libraries depend on it. Where StatHat had no defined behaviour — invalid values, type conflicts, out-of-range magnitudes, malformed names, out-of-range timestamps — we return a proper 4xx, because silently fabricating a data point was never StatHat behaviour and correctness wins there. Send X-EzStat-Strict: 1 to get real status codes for every rejection, including auth.

  • Numbers — the test is faithfulness, not type. A number is accepted when its written form converts to exactly the number it appears to be. So 5 and "5" are accepted identically: a plain decimal string is parsed at face value, because a form-encoded client has no other way to send a number and "42" is not ambiguous. What we reject is every conversion that would change the meaning — hex "0x10" (older parsers silently read 16), "42abc" (read as 42), true (read as 1), "" and whitespace-only strings (read as 0), arrays and objects ([5] stringifies to "5", so it is refused by type before any text exists), null, NaN, Infinity, and any magnitude above 1e15. Accepted grammar: optional sign, digits, optional fraction, optional e/E exponent — surrounding whitespace is trimmed.
  • Rejections are 400 and name the reason — invalid count: not a number or invalid count: out of range (magnitude above 1e15) — with nothing written and no stat created. count=0 stores 0: a genuine zero is never turned into a 1. This is one contract across the whole ingest surface, not a per-route quirk — /ez and /c (form-encoded) and the v1 JSON write routes were verified 2026-08-14 to agree on every case above, so you can rely on it wherever you write from.
  • Timestampst is whole Unix seconds (a fractional part is truncated). Omit it and the server stamps the moment it received the point. The accepted window is 2000-01-01T00:00:00Z (unix 946684800) through 5 minutes ahead of server time. Outside it, the point is a 400 and nothing is written — no stat is created either.
  • Backfill is fully supported anywhere inside that window: there is no distance limit and no retention cutoff, so a t from last week or from 2003 is equally accepted, which is what makes migrations and historical imports work. The window is not a retention policy; it is a bug detector on both ends.
  • Too far ahead — a t more than 5 minutes past server time is invalid timestamp: too far in the future. The 5 minutes covers ordinary unsynchronised-clock skew; beyond that a timestamp is a bug, and a single point dated far ahead would stall the rollups that power your dashboards, anomalies and alerts. If you are seeing this, check the sending host's clock — and check that you are sending seconds, not milliseconds, because milliseconds read as the year 57000-and-something.
  • Before 2000 — a t below the floor is invalid timestamp: before 2000-01-01 (check for uninitialised or null values). In practice this is never a real measurement and always the same bug: a variable that was never assigned, or a null that got coerced to zero, sends t=0 and dates the point 1970. We reject it rather than store it because a single 1970 point stretches that stat's auto-scaled charts across five decades, sits outside every retention window, and cannot be removed afterwards — there is no point-level delete. If you are seeing this, look for an unset timestamp variable in the sending code.
  • One name, one kind — a stat is either a counter (count) or a gauge (value). Sending the other field to an existing stat is a 400 type conflict, never a silent mix.
  • Neither count nor value — EzStat records 1. StatHat's manual required one of the two on every EZ call (its classic /c API defaulted count to 1); EzStat accepts the field-less ping as that classic increment instead of rejecting it.
  • Absent is not the same as empty. Leaving count out records 1, as above. Sending it present but emptycount=, a whitespace-only value, or JSON "" / null — is a 400 on /ez, /c and the v1 JSON write routes alike. "I didn't send a count" and "I sent an empty count" are different statements and we treat them differently on purpose: a client that stringifies an unset variable produces the second while meaning the first, and the 400 is the only thing that will ever tell it. Gauges have no default at all — an absent value on POST /api/v1/stats/:name/value is invalid value: missing, because there is no sensible "one" for a reading.
  • Browser posting (CORS) /ez, /c and /v send Access-Control-Allow-Origin: *, so you can post metrics straight from client-side JavaScript. Your API key travels in the request itself; treat a key you ship to browsers as public and rotate it if abused.
modern rest api

Bearer Token API

New API with Bearer token auth. Generate keys in Settings.

These endpoints return standard HTTP status codes on every rejection. The two write routes — POST /api/v1/stats/:name/count and /value — answer 401 unauthorized, 403 stat or daily-point limit reached, 429 rate limited, 400 malformed input, 503 transient and 5xx server-side, with no header and no opt-in. A success is 200 with {"status":"ok"}. The 200-with -the-verdict-in-the-body behaviour is confined to the StatHat-compatible /ez, /c and /v endpoints and does not apply here — see wire responses & strict mode for why the two families differ. If you are writing new code, this is the family to write against.

GET/api/v1/stats

List all stats for the authenticated user. Works with a dashboard session or your API key.

Auth · Session or BearerPaid
# With your API key:
curl -X GET https://api.ezstat.dev/api/v1/stats \
  -H "Authorization: Bearer $EZSTAT_API_KEY"

# …or with a logged-in dashboard session cookie:
curl -X GET https://api.ezstat.dev/api/v1/stats \
  --cookie "ezstat_session=YOUR_SESSION_COOKIE"
POST/api/v1/stats/:name/count

Increment a counter stat.

Auth · BearerFree + Paid
curl -X POST https://api.ezstat.dev/api/v1/stats/api_requests/count \
  -H "Authorization: Bearer $EZSTAT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"count": 1}'
POST/api/v1/stats/:name/value

Record a value for a gauge stat.

Auth · BearerFree + Paid
curl -X POST https://api.ezstat.dev/api/v1/stats/response_time/value \
  -H "Authorization: Bearer $EZSTAT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"value": 142}'
GET/api/v1/stats/:name

Get stat details with data points. Works with a dashboard session or your API key.

Auth · Session or BearerPaid
# With your API key:
curl -X GET "https://api.ezstat.dev/api/v1/stats/api_requests?from=2026-03-01&to=2026-03-07" \
  -H "Authorization: Bearer $EZSTAT_API_KEY"

# …or with a logged-in dashboard session cookie:
curl -X GET "https://api.ezstat.dev/api/v1/stats/api_requests?from=2026-03-01&to=2026-03-07" \
  --cookie "ezstat_session=YOUR_SESSION_COOKIE"
PATCH/api/v1/stats/:name

Update a stat's description (max 500 characters; null clears it). Works with a dashboard session or your API key.

Auth · Session or BearerPaid
curl -X PATCH https://api.ezstat.dev/api/v1/stats/api_requests \
  -H "Authorization: Bearer $EZSTAT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"description": "Total API requests served"}'

parameters

  • descriptionNew description, ≤500 characters (null clears)
DELETE/api/v1/stats/:name

Delete a stat and all its data points. Irreversible. Works with a dashboard session or your API key. Note: deleting a stat does not stop your writers — stats auto-create on first ingest, so any point sent under the same name afterwards re-creates the stat.

Auth · Session or BearerPaid
curl -X DELETE https://api.ezstat.dev/api/v1/stats/api_requests \
  -H "Authorization: Bearer $EZSTAT_API_KEY"
POST/api/v1/stats/:name/share

Generate a share token for public embedding. Works with a dashboard session or your API key.

Auth · Session or BearerPaid
# With your API key:
curl -X POST https://api.ezstat.dev/api/v1/stats/api_requests/share \
  -H "Authorization: Bearer $EZSTAT_API_KEY"

# …or with a logged-in dashboard session cookie:
curl -X POST https://api.ezstat.dev/api/v1/stats/api_requests/share \
  --cookie "ezstat_session=YOUR_SESSION_COOKIE"
DELETE/api/v1/stats/:name/share

Revoke a share token. Works with a dashboard session or your API key.

Auth · Session or BearerPaid
# With your API key:
curl -X DELETE https://api.ezstat.dev/api/v1/stats/api_requests/share \
  -H "Authorization: Bearer $EZSTAT_API_KEY"

# …or with a logged-in dashboard session cookie:
curl -X DELETE https://api.ezstat.dev/api/v1/stats/api_requests/share \
  --cookie "ezstat_session=YOUR_SESSION_COOKIE"
key placement

On the read APIs the key goes in the Authorization header — never the query string

The v1 read APIs — /api/v1/anomalies, /api/v1/correlations, /api/v1/thresholds/suggest and /api/v1/stats — accept your API key only as Authorization: Bearer YOUR_API_KEY (or an authenticated dashboard session). There is no ?ukey= or ?ezkey= query form on these routes, and adding one to the URL does not authenticate the request.

That is deliberate. A key in a query string is copied into places you do not control and cannot clear: web-server and proxy access logs, the Referer header sent to third parties, browser history, and shell history. A header is not. Keeping long-lived read credentials out of URLs is worth the small inconvenience.

The ingest endpoints are the deliberate exception, and you should know exactly what that costs. GET /ez?ezkey=YOUR_API_KEY&stat=… works and will keep working: it is the StatHat wire idiom, unmodified StatHat clients emit it, and removing it would defeat the entire point of a drop-in endpoint. /c and /v take ukey as a form field. So on the write path your key travels in the request itself, and on GET /ez specifically it travels in the URL — which is the exact placement the read APIs above refuse. That asymmetry is real. We are not going to describe the read-side hardening and stay quiet about it.

What it means in practice: a URL is not a private channel even over HTTPS. It is recorded by things between you and us that neither of us controls — your reverse proxy and load-balancer access logs, a corporate egress gateway, an APM or error tracker that captures request URLs, the Referer header if the URL is ever navigated to from a page, browser history, and your own shell history. TLS protects the URL in transit; it does not stop the endpoints of that chain from writing it down.

What we do about our end. We strip credential query parameters — ezkey, ukey, email — out of the request line before anything is written to our access logs, out of the logged Referer as well, and out of our application error logs. The rest of the query string (stat, count, t) is kept, so support can still debug a request without your key ever being written to one of our disks. That promise covers our side of the connection only — we cannot make it about yours, or about anything in between, which is the whole reason this paragraph exists.

So: for anything new, keep the key out of the URL. POST /ez with the key as a form field or in a JSON body carries the same wire contract with no URL exposure, and is a one-line change in most clients. For a brand-new integration prefer the v1 write endpoints with Authorization: Bearer — no key in the URL, and real HTTP status codes. Use GET /ez when you are migrating a client you cannot modify; that is what it is for.

If a key has ever appeared in a URL, treat it as exposed and rotate it in Settings.

data

Dashboard & Export

GET/api/v1/dashboard

Get dashboard data with aggregated stats. Requires a logged-in dashboard session.

Auth · SessionPaid
# Needs a logged-in dashboard session cookie, not the API key.
curl -X GET "https://api.ezstat.dev/api/v1/dashboard?range=24h" \
  --cookie "ezstat_session=YOUR_SESSION_COOKIE"

parameters

  • range1h, 6h, 24h, 7d, 30d, 90d or 180d (default: 24h). An unknown value is a 400 naming the valid set.
try itGET /api/v1/dashboard
curl -X GET "https://api.ezstat.dev/api/v1/dashboard?range=24h" \
  --cookie "ezstat_session=YOUR_SESSION_COOKIE"

Requires authentication (a logged-in dashboard session; some routes also accept your API key) — copy and run from your terminal.

GET/api/v1/export

Export user stats as CSV or JSON. Handles large datasets via chunking. With no range and no from/to you get ALL history within your plan's retention window — that is the default, and it is always bounded by retention. Every response states the window it actually applied: X-EzStat-Export-Requested-Range / -From / -To / -Retention-Days / -Range-Clamped response headers, requested_range and range_clamped_by_retention fields in JSON, and a trailing '# requested_range=… applied_from=… applied_to=…' comment line in CSV (trailing, so it never displaces the CSV header row). A range longer than your retention is clamped, and the clamp is reported rather than silent. The same parameters behave identically on /api/v1/stats/:name/export. Requires a logged-in dashboard session or a Bearer API key.

Auth · Session or BearerPaid
# With your API key:
curl -X GET "https://api.ezstat.dev/api/v1/export?format=json&stat=api_requests&range=24h" \
  -H "Authorization: Bearer $EZSTAT_API_KEY"

# …or with a logged-in dashboard session cookie:
curl -X GET "https://api.ezstat.dev/api/v1/export?format=json&stat=api_requests&range=24h" \
  --cookie "ezstat_session=YOUR_SESSION_COOKIE"

parameters

  • formatcsv or json (default: json)
  • statExport only this stat (exact name; unknown name → 404). Omit for all stats.
  • rangeRelative window: 1h, 6h, 24h, 7d, 30d, 90d or 180d (alias: window). An unknown, empty or repeated value is a 400 naming the valid set — it never falls back to all history. Cannot be combined with from/to.
  • fromISO start bound (optional; clamped to plan retention). Cannot be combined with range.
  • toISO end bound (optional). Cannot be combined with range.
GET/api/v1/stats

List all stats for the authenticated user. Works with a dashboard session or your API key.

Auth · Session or BearerPaid
# With your API key:
curl -X GET https://api.ezstat.dev/api/v1/stats \
  -H "Authorization: Bearer $EZSTAT_API_KEY"

# …or with a logged-in dashboard session cookie:
curl -X GET https://api.ezstat.dev/api/v1/stats \
  --cookie "ezstat_session=YOUR_SESSION_COOKIE"
try itGET /api/v1/stats
curl -X GET https://api.ezstat.dev/api/v1/stats \
  -H "Authorization: Bearer $EZSTAT_API_KEY"

Accepts Authorization: Bearer YOUR_API_KEY or a logged-in dashboard session — copy and run from your terminal.

GET/api/v1/stats/:name

Get stat details with data points. Works with a dashboard session or your API key.

Auth · Session or BearerPaid
# With your API key:
curl -X GET "https://api.ezstat.dev/api/v1/stats/api_requests?from=2026-03-01&to=2026-03-07" \
  -H "Authorization: Bearer $EZSTAT_API_KEY"

# …or with a logged-in dashboard session cookie:
curl -X GET "https://api.ezstat.dev/api/v1/stats/api_requests?from=2026-03-01&to=2026-03-07" \
  --cookie "ezstat_session=YOUR_SESSION_COOKIE"
try itGET /api/v1/stats/api_requests
curl -X GET "https://api.ezstat.dev/api/v1/stats/api_requests?range=24h" \
  -H "Authorization: Bearer $EZSTAT_API_KEY"

Accepts Authorization: Bearer YOUR_API_KEY or a logged-in dashboard session — copy and run from your terminal.

GET/api/v1/health

Health check endpoint.

Auth · None
curl -X GET https://api.ezstat.dev/api/v1/health
try itGET /api/v1/health
curl -X GET https://api.ezstat.dev/api/v1/health

Truly unauthenticated. Safe to run from anywhere — useful for uptime probes.

verify us yourself

Verify us yourself

You should not have to trust our arithmetic. EzStat ships a verifier you can run yourself: one Python file, standard library only, no pip install. Read it, then run it against your own API key and your own account. If our numbers disagree with arithmetic you can do on paper, it exits non-zero and tells you which surface lied.

Download and run

curl -fsSL https://ezstat.dev/tools/ezstat-verify.py -o ezstat-verify.py
python3 ezstat-verify.py --key YOUR_API_KEY

Add --dry-run to print the exact plan and exit without writing anything, or --json for one machine-readable object you can assert on in CI. The key can also come from EZSTAT_API_KEY. It reads no other file on your machine.

What it does

It writes the sequence [1, 1, 3] to a single counter stat — total 5, three points, checkable on paper — then sends a fourth POST that repeats the middle write with the same t and the same ikey. A correct server deduplicates that retry; a broken one reports sum 6 and count 4, which is exactly the failure this tool exists to catch. It then reads the numbers back and puts all three surfaces side by side: ground truth, the exported data, and the value our API reports.

=== THREE-SURFACE COMPARISON  (ground truth: sum=5 count=3) ===

  surface               sum      count   expected      verdict
  --------------------  -------  ------  ------------  --------
  CSV export            5        3       5, 3          PASS
  API reported value    5        3       5, 3          PASS
  ground truth          5        3       5, 3          PASS  (by construction)

=== VERDICT ===
  every surface reconciles with ground truth.
  the retry was deduplicated (sum=5 count=3, NOT sum=6 count=4).

Exit 0 means every surface reconciles. Exit 1 means a surface answered and its numbers disagree — that is us being wrong. Exit 2 means the run could not complete (no key, a network failure, a 401) and is deliberately not reported as disagreement: if we never returned a number, our arithmetic was never tested, and saying otherwise would be its own dishonesty.

claim boundary

What this proves — and what it does not

This proves: counter aggregation, idempotent retry, and agreement between ground truth, the CSV export and the API.

This does NOT prove: gauge percentile behaviour, long-range downsampling, or cross-range consistency.

Four data points cannot say anything honest about p50/p95/p99 on a gauge, about how an hour rolls up into a day, or about whether two ranges over the same series agree. So the tool does not claim it. The same two sentences are printed at the end of every run — pass, fail and could-not-complete alike — because the run most likely to be over-read is the one that failed.

This is not fine print. A verifier that overclaims is worse than no verifier, because it converts the one thing you could have checked into another thing you have to take on faith. The boundary is the reason the PASS above is worth anything.

cleanup

It writes one stat, and it never deletes

The tool writes to exactly one stat — verify.reconcile_check unless you pass --stat — and touches nothing else in your account. The leading verify. puts it in its own dashboard folder rather than among your real metrics.

It does not delete that stat when it finishes. Removing your data is your call, never a side effect of a tool you ran once. Delete it from the dashboard, or with your API key:

curl -X DELETE https://api.ezstat.dev/api/v1/stats/verify.reconcile_check \
  -H "Authorization: Bearer $EZSTAT_API_KEY"

Delete it before running the tool a second time. A second run against a stat that still holds the first run's points reads back sum 10 over 6 points, which is correct arithmetic on the wrong premise — the tool reports that as a mismatch and tells you to clear the stat and re-run.

embed

Embeddable Charts

Public embeddable endpoints. Requires share token.

GET/api/v1/embed/:statId

Get embeddable data for a stat.

Auth · None (token param)
curl -X GET "https://api.ezstat.dev/api/v1/embed/1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed?token=share_token&range=24h"

parameters

  • statIdStat UUID (must be valid UUID format)
  • tokenShare token (required)
  • range1h, 6h, 24h, 7d, 30d
authentication

API Keys

GET/api/v1/auth/api-keys

List all API keys. Requires a logged-in dashboard session.

Auth · SessionFree + Paid
# Needs a logged-in dashboard session cookie, not the API key.
curl -X GET https://api.ezstat.dev/api/v1/auth/api-keys \
  --cookie "ezstat_session=YOUR_SESSION_COOKIE"
POST/api/v1/auth/api-keys

Create a new API key. Requires a logged-in dashboard session.

Auth · SessionFree + Paid
# Needs a logged-in dashboard session cookie, not the API key.
curl -X POST https://api.ezstat.dev/api/v1/auth/api-keys \
  --cookie "ezstat_session=YOUR_SESSION_COOKIE" \
  -H "Content-Type: application/json" \
  -d '{"label": "My API Key"}'
billing

Billing

Checkout is open. Pick a plan on /pricing and subscribe with your card. The pricing page shows all three paid tiers and the applicable CZK, EUR, or USD amount before checkout. Billed monthly, cancel anytime — cancellation takes effect at the end of the current period. Subscriptions are non-refundable except where a statutory withdrawal right applies (EU/EEA).

Subscribe: POST /api/stripe/checkout is account-bound — sign in, then it returns a hosted card-checkout session for the chosen plan. Manage / cancel: GET /api/billing/portal redirects a signed-in account to the self-service billing portal to update payment method or cancel.

Need a higher limit or a custom plan? Get in touch.

ask-your-data

Ask-Your-Data (natural-language queries)

Ask a plain-English question about your own metrics; the answer is computed from your real series and every number is verified against the retrieved data before it is returned — a claim that cannot be grounded in your data is never emitted.

POST /api/v1/query
Authorization: Bearer YOUR_API_KEY   # or an authenticated dashboard session
Content-Type: application/json

{ "query": "Which of my stats was most active in the last 24 hours?" }

# 200 →
{
  "answer": "Top stats in the last 24 hours: ...",
  "data": [],
  "intent": {},
  "source": "template",
  "llm_used": false,
  "verifier": "passed"
}

# data       the exact retrieval set the answer is grounded in
# intent     the parsed structured intent
# source     "template" | "llm" | "deterministic"
# llm_used   whether a model narrated (vs pure deterministic)
# verifier   number-by-number grounding check result

Auth: dashboard session or your API key — agents ask with the same key they ingest with. Simple aggregations answer deterministically in ~100 ms; free-form "why/explain" questions may use an LLM whose output is bounded, exfiltration-filtered, and numerically verified. Optional "residency": "us" requests US-based inference (coming soon; fails closed to the deterministic engine, never a non-US model). AI agents: prefer the MCP server (npx ezstat-mcp-server) whose ask_ezstat tool wraps this endpoint with API-key auth.

alerts

Alerts (threshold, heartbeat, %-change)

Fire a webhook or Slack message when a stat crosses a threshold, changes too fast, or goes quiet. Deliveries are leased, retried with backoff, and carry a stable idempotency_key so your receiver can drop duplicates. Auth: dashboard session or your API key — agents manage alerts with the same key they ingest with.

# Create
POST /api/v1/alerts
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json

{
  "stat_name": "api.requests",
  "condition_type": "above",
  "threshold": 1000,
  "window_minutes": 15,
  "channel": "webhook",
  "channel_config": { "webhook_url": "https://your-receiver.example/hook" },
  "cooldown_minutes": 60
}

# stat_name       the stat's NAME (not its id) — see the create schema
# condition_type  above | below | pct_change | heartbeat | missing_data | sustained
# window_minutes  heartbeat/missing_data staleness, pct_change lookback
# channel         webhook | slack (email: coming soon, double opt-in)

# List           GET    /api/v1/alerts
# Delete         DELETE /api/v1/alerts/:id

# Webhook delivery payload (POST to your webhook_url):
{
  "alert_id": "…",
  "event": "fired",
  "status": "firing",
  "stat_name": "…",
  "observed_value": 1234,
  "condition": "above",
  "threshold": 1000,
  "triggered_at": "2026-08-14T02:00:00Z",
  "message": "…",
  "idempotency_key": "…",
  "incident_key": "…"
}

# event            "fired" | "resolved"  — what just happened
# status           "firing" | "resolved" — the state the alert is now IN
# idempotency_key  same key on every re-send of ONE delivery
# incident_key     SAME on the fire and on its resolve — correlate on this

# Also sent as headers: X-EzStat-Idempotency-Key, X-EzStat-Incident-Key

Thresholds are strict: exactly-at-threshold does not fire. An above 1000 alert fires at 1000.01, not at 1000; below 10 fires at 9.99, not at 10. The same rule holds for every condition: pct_change needs the trend to pass the percentage (so a threshold of 0 does not fire on an unchanged 0% trend), and heartbeat/missing_data need staleness to exceed the window. At exactly the threshold an alert counts as recovered, so one boundary value always means one unambiguous state.

Fires once, and always tells you when it recovers. An alert fires on the transition into breach — not once per breaching data point — and sends a matching resolved delivery the moment the value comes back. Both carry the same incident_key, so your receiver can close the incident it opened; idempotency_key is per-delivery and deliberately differs between the two, so a fire and a resolve never deduplicate against each other. The cooldown only suppresses repeat FIRES — it never withholds a resolve. A breach whose re-fire was suppressed by the cooldown notified nobody, so its recovery is silent too: you never receive a "recovered" for a page you never got. Deleting an alert is a hard stop — queued deliveries are dropped and no final resolve is sent, because the condition was not observed to clear.

Expect minutes, not seconds. Alerts are not evaluated on the ingest path. A periodic worker checks your alerts against precomputed rollups and then drains the delivery queue, so there is a gap between the data point that breaches a threshold and the webhook landing on your receiver — measured at roughly six minutes end to end, and longer if a breach arrives just after a rollup pass. This is monitoring and trend alerting, not sub-minute paging: do not put EzStat alerts on a path that needs to page a human in seconds. Timestamps in the payload (triggered_at) reflect the evaluation, so compare against your own event time if you need the exact lag.

Egress is SSRF-guarded (private/internal targets are refused). Alert counts are gated by paid plan. Email delivery ships after the double opt-in rail — an alert email is only ever sent to an address that explicitly confirmed it wants them.

observability

“Why didn’t this fire?”

Your metric went sideways and nothing fired. Is the detector broken, is your plan wrong, or is your data genuinely unscoreable? Ask it. GET /api/v1/anomalies/diagnostics returns the anomaly detector’s own account of every stat on your account: when it was last evaluated, whether its trailing window was usable, how many baseline samples it has, and — if it was skipped — which of five reasons applied. Auth: dashboard session or your API key, in the Authorization header. Everything it returns is scoped to your account.

GET /api/v1/anomalies/diagnostics            # up to 25 stats (?limit= up to 100)
GET /api/v1/anomalies/diagnostics?stat=api.requests   # one stat, by name
Authorization: Bearer YOUR_API_KEY

{
  "account": { "plan": "pro", "anomalies_entitled_by_plan": true,
               "stats_total": 42, "stats_reported": 25, "truncated": true },
  "evaluator": {
    "enabled": true,
    "window": "1h",
    "interval_seconds": 360,
    "interval_source": "timer_unit",
    "stale_after_seconds": 1080,
    "last_sweep_at": "…",
    "last_sweep_touching_account_at": "…",
    "never_swept_this_account": false
  },
  "counters": {
    "never_evaluated": 1, "evaluated": 20, "skipped": 4,
    "skipped_tier": 0, "skipped_no_rollup": 3, "skipped_stale": 1,
    "window_baseline_used": 18, "window_baseline_short": 4,
    "window_baseline_no_signal": 3,
    "can_fire": 19, "cannot_fire": 6
  },
  "stats": [{
    "stat_name": "api.requests",
    "state": "skipped",
    "reason": "no_signal",
    "explanation": "The trailing 120-bucket window is entirely zero: no spread and no magnitude, so no honest score exists…",
    "can_fire": false,
    "ever_evaluated": true,
    "last_evaluated_at": "…", "evaluation_stale": false,
    "ever_detected": false, "last_detected_at": null,
    "streaming_baseline": { "sample_count": 3, "min_samples_required": 8, "armed": false },
    "window_baseline": { "verdict": "no_signal", "sample_count": 120,
                         "detector_verdict": "no_signal", "detector_sample_count": 119 },
    "rollup": { "present": true, "computed_at": "…",
                "refreshed_since_last_evaluation": true }
  }],
  "omitted": [ "…what this endpoint cannot know, and why…" ]
}

# interval_seconds                derived from the shipped timer, never guessed
# interval_source                 env | timer_unit | unknown
# last_sweep_at                   the worker ran at all
# last_sweep_touching_account_at  …and reached YOUR stats
# counters                        YOUR stats only — never fleet totals
# state                           never_evaluated | evaluated | skipped
# reason                          tier | no_rollup | stale | no_signal | short | null

Three states, never collapsed into one. never_evaluated means the detector has not scored this stat even once — a new stat, or one the rotation has not reached yet. evaluated means it was scored, at last_evaluated_at, and found nothing anomalous — the healthy quiet state. skipped means the detector reached it and declined, and reason says which. A silent detector and a well-behaved metric used to look identical from outside; these three are what makes them different. The same rule applies to the fields: ever_evaluated stays true for a stat that was scored yesterday and is skipped today, and evaluation_stale is null — not false — when it is not knowable.

The five skip reasons. tier — your plan does not include anomaly detection (checked against evaluator.enabled, so “wrong plan” is never confused with “switched off for everyone”). no_rollup — nothing has been precomputed for this stat, so there is no value to score. stale — the rollup exists but has stopped refreshing, so every sweep skips it as already-consumed (a brief overlap right after a sweep is normal and is not reported as a skip). no_signal — the trailing window is entirely zero: no spread and no magnitude, so no honest score exists. short — fewer than 8 usable trailing hourly buckets. The last two are only ever reported as the stat’s reason when no detector is armed at all; the windowed and streaming baselines are independent, and a stat whose window cannot arm can still fire from a warm streaming baseline. can_fire is the bottom line.

Flat is not the same as empty. A gauge pinned at 100 has no measured spread but it does have magnitude, so it is scoreable — used — and a genuine step away from 100 will fire. A counter that has emitted nothing but zeros all week has neither, so it is no_signal and cannot fire until it emits something. That is a real limit of the detector, and this endpoint names it rather than staying quiet about it.

Two window verdicts, on purpose. verdict describes your trailing window as it stands right now. detector_verdict describes what the next sweep will actually score against — the same window minus its newest bucket, because a baseline must never contain the observation under test. Step a flat series and verdict moves immediately; detector_verdict moves once that bucket is no longer the newest. Both are reported so the difference is visible instead of being a surprise.

What it will not tell you. There is no per-stat “last skip reason” stored anywhere, so every reason here is recomputed from the same rows the sweep reads — it is what the next sweep will decide, not a replay of the last one. There are no fleet-wide totals: those describe every tenant at once. Anything genuinely unknowable comes back as null with a reason, and the response’s omitted array says so out loud.

migration

Switching from StatHat

EzStat accepts form-encoded POSTs to the public endpoints above. Move gradually so each step is independently rollback-ready.

  1. 1.Create an EzStat API key.
  2. 2.Take one noncritical count or value request and map it to the EzStat /ez fields in an isolated test path while retaining your current config.
  3. 3.Send one point and verify it in the authenticated dashboard.
  4. 4.Migrate more only after verification and retain rollback.