Two things are sold separately, and the split is the whole design:
- Contributing buys access to the data. You helped build it.
- Money buys everything that is not data — freshness, volume, live operation.
Source: src/tiers.js, src/trial.js, src/robust.js.
The four tiers
| Tier | Who | Delay | Weekly calls to gated routes | live |
|---|---|---|---|---|
anonymous | No key | 900s | 0 (uses the free trial) | false |
free | Has a key, but is not contributing right now | 900s | 0 (uses the free trial) | false |
contributor | 5+ completed measurements in the last 7 days | 300s | 10000 | false |
paid | Assigned by the operator | 0s | 1000000 | true |
Verified against production 2026-08-25: an unauthenticated call to /v1/wait returned "live": false, "delayed_by_s": 900. Verified on a local instance: a key with 7 recent measurements reported "tier": "contributor", "delayed_by_s": 300, "calls_limit": 10000.
The numbers are configuration, not policy that has been decided. They can be overridden per deployment through environment variables:
| Variable | Affects |
|---|---|
PUBLIC_DELAY_S | anonymous and free delay |
CONTRIB_DELAY_S | contributor delay |
CONTRIB_WEEKLY_CALLS | contributor weekly calls |
PAID_WEEKLY_CALLS | paid weekly calls |
TRIAL_CALLS | Free trial size (0 disables the trial) |
MIN_N, MIN_KEYS | /v1/distribution and /v1/conditions thresholds |
KEYS_PER_IP_PER_DAY | Key creation limit |
wrangler.toml currently sets PUBLIC_DELAY_S=900, MIN_N=20, MIN_KEYS=3, TRIAL_CALLS=20, PROBE_INTERVAL_S=3600. The others fall back to the defaults in src/tiers.js.
How the tier is decided
tierOf(keyRow, contrib):
- No key at all →
anonymous. - The key's stored
tiercolumn ispaid→paid. - Otherwise: contributing →
contributor, not contributing →free.
Only paid is stored on the account. contributor is derived from the data, deliberately: a field on the account could be set by the account.
What the delay actually does
delayed_by_s is not a cosmetic label. It is a cut-off timestamp: a delayed caller is served measurements up to now - delay and nothing newer.
- On
/v1/conditions, the "last hour" window is3600 + delayseconds long and ends atnow - delay. An anonymous caller therefore never sees a live signal for free — which is the point, since that route answers "is the queue slow right now?". - On
/v1/wait,/v1/curve,/v1/should-i-batchand/v1/estimate-batchtime, the delay also decides whether a precomputed rollup may be used. A delayed caller has already accepted a figure that is minutes old, so a cron-computed rollup is exactly that. A live caller gets it recomputed. live: falsein a response is there so a machine can see that it is routing on stale data instead of doing so in good faith. The delay hurts most in precisely the quarter-hour when a queue collapses.
/v1/outages and /v1/outages.atom are never delayed for anyone, and /v1/coverage has no delay logic at all.
Becoming a contributor
Five completed measurements in a rolling seven days (CONTRIB_MIN = 5, CONTRIB_WINDOW_S = 7 days in src/index.js). The count is over rows where key_id is yours, status = 'completed', ended_server is set, and started_server is inside the window.
Five in seven days rather than one a day: the latter would punish someone who runs batch twice a week but contributes loyally every time.
Contribution is checked on every request. There is nothing to activate, and it lapses on its own when you stop measuring.
Earning a vote — a separate, higher bar
Being a contributor gets you access. Having your numbers count towards the published percentiles is a different threshold, and it cannot be bought with volume or with new keys (src/robust.js):
| Requirement | Value |
|---|---|
| Has a key (not anonymous) | required |
| Measurements from that key | at least 5 (STEMME_MIN_N) |
| Distinct days measured on | at least 3 (STEMME_MIN_DAGE) |
| Voters needed before per-contributor mode engages | 3 (STEMME_MIN_K) |
Sources that have not earned a vote — fresh keys and everything anonymous — share one vote between them. Ten new accounts are worth exactly as much as one.
Time cannot be rushed, which is the point: an attacker now has to keep several accounts running over several days with plausible-looking data before any of them counts, and they are visible in the dataset the whole time. The documentation in src/robust.js is explicit that this makes an attack expensive and slow rather than impossible.
The quota
Only the four gated routes count against it, and only for a contributor or paid caller. It is a rolling seven days: the sum of the last seven daily counters, so usage frees up gradually rather than resetting on a boundary.
A successful gated response carries a quota block and an x-batchwatch-quota-left header (verified locally: x-batchwatch-quota-left: 9999 on the first contributor call):
"quota": {
"tier": "contributor",
"calls_used": 2,
"calls_limit": 10000,
"calls_left": 9998,
"window": "7 days"
}
Over the limit gives 429 — see errors.md.
The free trial
Twenty gated calls before you have to contribute anything. It exists to break a chicken-and-egg problem: without it the order is "write the ingest code, run it for a week, then find out whether the answer was worth anything".
How the twenty are counted
- Identity. If you send a key, the trial is counted on the key, so it follows you across networks. Otherwise it is counted on the client IP — never in the clear, but as
SHA-256(TRIAL_SALT + "|" + ip). The IP comes only fromcf-connecting-ip, which Cloudflare writes at the edge and which a caller cannot forge.X-Forwarded-Foris deliberately not used: Cloudflare appends to it, so a caller-supplied value would come first. - No salt, no trial. If the deployment has no
TRIAL_SALT, key-less callers get no trial at all rather than having their IPs stored as effectively-plaintext hashes. The402then saysreason: "no_salt_configured". - Order of consumption. Contribution is checked first. A loyal contributor never spends free calls, so they are still there if they take a break.
- Served first, counted after — and only if a response actually came out. A typo in the query string must not cost a free call. Verified against production: a
422did not move the counter. - The counter is atomic; the gate is not.
used = used + 1cannot be lost, but two concurrent calls can both see 19 remaining and both pass. That is a deliberate choice: locking would mean a write lock on every read, and the error points the harmless way.
Every successful trial call carries a trial block and an x-batchwatch-trial-left header:
"trial": {
"calls_used": 4,
"calls_total": 20,
"calls_left": 16,
"note": "Free trial - no contribution needed yet."
}
The note changes to a warning at 5 or fewer left, and to "That was your last free call..." at zero.
What the trial is not
It is not a security boundary. Someone who wants to can change IP and get twenty more, and src/trial.js says so in as many words. The data is aggregated percentiles, not secrets, and the real defence is that sustained use requires contributing.
What stays open regardless
/v1/wait, /v1/curve, /v1/coverage, /v1/status, /v1/probe, /v1/outages, /v1/outages.atom and /health never touch the trial counter. Neither does contributing — the ingest routes are open on purpose, because without contributions the dataset does not exist.