// COMING FROM STATHAT

Coming from StatHat? You're about five minutes from a live chart.

Same /ez, /c and /v wire format — same params, same response. Only the base URL and your key change; stats auto-create on the first POST, so there's nothing to re-instrument.

// THE MIGRATOR DEAL

Switching is the risk. So we removed it — no discount games.

  • 1.See it live before your first dollar. Start free (no card) — point a stat at EzStat and watch your chart render before you pay anything. Start free →
  • 2.Your price is locked for 12 months. The tier price you join at today is your price for the next 12 months, whatever we ship or change later.
  • 3.First 25 migrations are white-glove. Send us your StatHat CSV/JSON export and we run the import with you until every chart is back. Migrations also get a 12-month price lock — the price you migrate at is your price for a year. Claim a slot →
  • 4.Monthly billing, no lock-in. Cancel any time; CSV/JSON export stays available through your paid period. Your data is yours on the way in and on the way out.
// WHAT ACTUALLY CHANGES

Exactly two things:

  • 1. The base URL: api.stathat.com api.ezstat.dev.
  • 2. The key: your EzStat API key instead of your StatHat one.

Nothing else.

// ONE PRECONDITION

Before you flip the base URL, search your stat list for &. The sanitizer strips & before it validates the rest of the name, so signups&trials stores as signupstrials — your history continues under a new name with no error raised. Every other disallowed character (<, >, quotes, %, /, parentheses, etc.) is rejected loudly as stat name contains invalid characters and fails safe; & is the only one that silently renames. Grep before cut-over:

# in your code
grep -rn '&' your-code/

# in your StatHat export (after the header line)
grep -n '&' your-stathat-export.csv

1. Get your EzStat API key

Sign up, then open the dashboard Settings page and copy your API key (about 30 seconds). The legacy /ez, /c and /v endpoints use this key; the newer v1 REST API uses a Bearer token generated in the same place.

2. Change one base URL

Most StatHat client libraries let you override the base URL, so this is often a one-line config change rather than a find-and-replace. If you hardcoded the host, the blunt swap is:

sed -i 's#api.stathat.com#api.ezstat.dev#g' your-code/

Or override the base URL in your client — the same languages StatHat shipped SDKs in:

# curl
curl -X POST https://api.ezstat.dev/ez -d "ezkey=KEY" -d "stat=signups" -d "count=1"

// Go — set the base URL to https://api.ezstat.dev
// Python — requests.post("https://api.ezstat.dev/ez", data={...})
// Node — fetch("https://api.ezstat.dev/ez", { method: "POST", body })
// Ruby — Net::HTTP.post_form(URI("https://api.ezstat.dev/ez"), ...)

Then swap the StatHat key for your EzStat key — your old StatHat identity is not a credential here.

3. POST one point and watch it land

# BEFORE — your existing StatHat call
curl -X POST https://api.stathat.com/ez \
  -d "ezkey=YOUR_STATHAT_KEY" -d "stat=signups" -d "count=1"

# AFTER — EzStat. Change the host, use your EzStat key.
curl -X POST https://api.ezstat.dev/ez \
  -d "ezkey=YOUR_EZSTAT_KEY" -d "stat=signups" -d "count=1"

# → {"status":200,"msg":"ok"}   <- the verdict is in the BODY. Read it. See step 4.

Refresh your EzStat dashboard — your stat auto-created and your first point is on the chart. That's it — you're live.

4. Check the response BODY, not the status code

This is the one place drop-in compatibility can bite you.

/ez, /c and /v mirror StatHat's original wire behaviour, and that includes its rejections: a write we refuse — a stale key, the wrong key, a quota you have hit — still comes back HTTP 200. The verdict is in the body: {"status":200,"msg":"ok"} when the point was recorded, {"status":"error","msg":"..."} when it was not. StatHat behaved this way: the verdict is in the body, not the status code — so a client that checks only the HTTP status will report every one of those rejections to you as a success.

The failure that follows is quiet. Your charts keep rendering the history you already have, so the dashboard looks alive while nothing new is being recorded, and you find out when someone asks about a number that stopped moving. Check the body on the first write of the migration — before you roll it out anywhere.

# The HTTP code is 200 either way. The BODY is the verdict.
curl -s -X POST https://api.ezstat.dev/ez \
  -d "ezkey=YOUR_EZSTAT_KEY" -d "stat=signups" -d "count=1"

# → {"status":200,"msg":"ok"}                   recorded
# → {"status":"error","msg":"invalid ezkey"}    NOT recorded — and still HTTP 200

# Make it fail loudly in a script:
curl -s -X POST https://api.ezstat.dev/ez \
  -d "ezkey=YOUR_EZSTAT_KEY" -d "stat=signups" -d "count=1" \
  | grep -q '"status":200' || { echo "EzStat DROPPED the point"; exit 1; }

# Same check with jq:
curl -s -X POST https://api.ezstat.dev/ez \
  -d "ezkey=YOUR_EZSTAT_KEY" -d "stat=signups" -d "count=1" \
  | jq -e '.status == 200' >/dev/null || { echo "EzStat DROPPED the point"; exit 1; }

In code — the difference is one line (Python shown; the shape is the same in every language):

# WRONG — 200 means "the request reached us", not "the point was recorded"
requests.post(EZSTAT_URL, data=payload).raise_for_status()

# RIGHT — read the body; on a rejection status is the string "error"
r = requests.post(EZSTAT_URL, data=payload)
if r.json().get("status") != 200:
    raise RuntimeError(f"EzStat dropped the point: {r.json().get('msg')}")

Writing new code instead of reusing a StatHat client? Send the header X-EzStat-Strict: 1 on /ez and every rejection comes back with a real HTTP status — 401 bad key, 429 quota, 400 malformed — so your client's ordinary error handling is enough. That is the recommended default for anything new; the 200-with-the-verdict-in-the-body path exists so unmodified StatHat clients keep working. Full contract in the docs.

// THE GRADUATION PATH

Migrating the client itself, eventually? Move to the v1 endpoints.

Checking the body protects your code. It cannot protect the systems in front of your code: uptime checks, CDN and proxy logs, API gateways, SLO dashboards and retry middleware all classify by status code and never open the body, so on the compatibility endpoints a mistyped key looks like success to your whole observability chain, not just to your writer.

The v1 write endpoints — POST /api/v1/stats/:name/count and /value, with Authorization: Bearer — return standard HTTP status codes on every rejection: 401 unauthorized, 403 stat or daily-point limit reached, 429 rate limited, 400 malformed input, 5xx server-side. No header, no opt-in. Your monitoring, proxies and retry logic then behave normally, with no cooperation from anyone.

There is no deadline and nothing is deprecated — /ez, /c and /v exist precisely so an unmodified StatHat client keeps working, and they stay. But when you next touch that writer, this is the endpoint to move it to. v1 reference →

5. Confirm the points are arriving — not just accepted

A 200 tells you the request reached us. Even an {"status":200,"msg":"ok"} body tells you only that we accepted that one write. After you switch a real writer over, prove the round trip: run our verifier against your own key. It writes a known sequence to a single stat, reads it back through the CSV export and the API, and exits non-zero if the numbers disagree. One Python file, standard library only, no pip install — read it before you run it.

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

Exit 0 means every surface reconciles. Exit 1 means the numbers disagree — that is us being wrong, and we want to hear about it. Exit 2 means the run could not complete (no key, a network failure, a 401) and is deliberately not reported as disagreement. What the tool proves — and what it does not — is written out at /docs#verify, along with how to delete the stat it writes.

6. Endpoint compatibility

The StatHat wire format maps one-to-one. Stats auto-create on the first POST — no pre-registration.

EndpointPurposeParams
/ezEZ counter or valueezkey = your EzStat API key · stat = name · count or value · t = unix timestamp (optional) · ikey = idempotency key (only dedupes with an explicit t)
/cClassic counterkey = stat name · ukey = your EzStat API key · count · t = unix timestamp (optional)
/vClassic valuekey = stat name · ukey = your EzStat API key · value · t = unix timestamp (optional)

The one gotcha: Classic /c and /v take the API key in ukey — EzStat maps ukey to your EzStat API key. Full reference in the docs.

About the t param: /ez, /c and /v reject a t more than five minutes ahead of server time with a 400, and the check runs per write — one drifting host loses exactly that host's points while every other host looks healthy. Backfill any distance inside the accepted window; the past-side floor (2000-01-01) is a bug detector for uninitialised variables, not a backfill bound.

About the ikey param (optional, /ez): dedup keys on stat + timestamp + ikey (the sole index is migration 043), so a retry is only recognised as the same point when you send the same t and ikey on every attempt. An ikey alone is not enough: without t, the server stamps each attempt's arrival time, so a retry lands on a different timestamp and is stored — and billed — a second time. Same rule as /docs#replay-semantics.

# safe to retry: identical t + ikey on every attempt
curl -X POST https://api.ezstat.dev/ez \
  -d "ezkey=YOUR_EZSTAT_KEY" -d "stat=orders" -d "count=1" \
  -d "t=1750000000" -d "ikey=order-9831"

7. Bring your history (optional)

A live token-pull is not possible — StatHat's API is offline. But if you saved your CSV or JSON export before shutdown, upload it and EzStat recreates each stat and backfills its points. Use ?dryRun=1 to preview first.

  • POST your export to /api/v1/import/stathat or use the dashboard import UI.
  • Limits: 8MB / 500,000 points per file, quota-trimmed. Backfilling into an existing stat does not cost stat quota.
  • Or backfill live points by passing a unix t timestamp on ingest.
  • Not a one-click bulk importer — it recreates stats from the file you upload.
Open the importer →

Bringing a large history or a team? We'll help you migrate.

8. Troubleshooting

401 / invalid key — you're using your old StatHat key. Use your EzStat key (from Settings), not your StatHat one. On /ez you only see a 401 if you sent X-EzStat-Strict: 1; otherwise the same rejection arrives as HTTP 200 with {"status":"error","msg":"invalid ezkey"} in the body — see step 4.

200 but nothing recorded — read the response body. A rejected write returns HTTP 200 on the compatibility path, so "the POST succeeded" is not evidence a point landed. Step 4 has the one-liner; step 5 has the tool that proves arrival.

Stat not appearing — confirm you POSTed to api.ezstat.dev, not the app subdomain.

Rate limited — limits are per plan tier; move up a tier if you consistently hit them.

Building something new? — use the v1 REST API with Bearer auth.

No live StatHat pull — StatHat's API is offline, so history moves via your saved export, not a token.

Alerts / digest / AI — coming soon; don't wire alerting expecting them yet.

// LOW RISK

Month-to-month, cancel any time, CSV/JSON export any time — no lock-in. Try it against real traffic before you commit.

Migration questions

Will my existing code break?

No. EzStat's /ez, /c and /v endpoints are wire-compatible with StatHat — same parameters, same response shape. You change the base URL to api.ezstat.dev and use your EzStat API key; the rest of your client code is unchanged. Stats auto-create on the first POST.

Can I import my StatHat history?

If you saved your StatHat CSV or JSON export before it closed, upload it to the importer and EzStat recreates each stat and backfills its points within your plan quota (limits: 8MB / 500,000 points per file, quota-trimmed). It is not a one-click bulk importer, and a live token-pull is not possible because StatHat's API is offline. You can also backfill live points by passing a unix t timestamp on ingest.

How do I verify it worked?

Read the response body, not the HTTP status. The StatHat-compatible endpoints return HTTP 200 even for a rejected write, with the verdict in the body — {"status":200,"msg":"ok"} means recorded, {"status":"error","msg":"..."} means it was not. Then refresh your EzStat dashboard to see the point on the chart, and run the verifier at ezstat.dev/tools/ezstat-verify.py to confirm points are arriving end to end rather than merely being accepted. New code can send the X-EzStat-Strict: 1 header on /ez to get real HTTP status codes instead.

Is it really one URL?

Yes — the two things that change are the base URL (api.stathat.com to api.ezstat.dev) and the key. Most StatHat client libraries let you override the base URL, so it is often a one-line config change rather than a find-and-replace.

That's the whole migration — change one base URL and use your EzStat key. Most StatHat clients let you override the base URL in one line; hardcoded hosts need a find-and-replace.