batchwatch

batchwatch › Tiers, delay, quota and the free trial

Tiers, delay, quota and the free trial

Two things are sold separately, and the split is the whole design:

Two things are sold separately, and the split is the whole design:

Source: src/tiers.js, src/trial.js, src/robust.js.


The four tiers

TierWhoDelayWeekly calls to gated routeslive
anonymousNo key900s0 (uses the free trial)false
freeHas a key, but is not contributing right now900s0 (uses the free trial)false
contributor5+ completed measurements in the last 7 days300s10000false
paidAssigned by the operator0s1000000true

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:

VariableAffects
PUBLIC_DELAY_Sanonymous and free delay
CONTRIB_DELAY_Scontributor delay
CONTRIB_WEEKLY_CALLScontributor weekly calls
PAID_WEEKLY_CALLSpaid weekly calls
TRIAL_CALLSFree trial size (0 disables the trial)
MIN_N, MIN_KEYS/v1/distribution and /v1/conditions thresholds
KEYS_PER_IP_PER_DAYKey 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):

  1. No key at all → anonymous.
  2. The key's stored tier column is paidpaid.
  3. 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.

/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):

RequirementValue
Has a key (not anonymous)required
Measurements from that keyat least 5 (STEMME_MIN_N)
Distinct days measured onat least 3 (STEMME_MIN_DAGE)
Voters needed before per-contributor mode engages3 (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

  1. 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 from cf-connecting-ip, which Cloudflare writes at the edge and which a caller cannot forge. X-Forwarded-For is deliberately not used: Cloudflare appends to it, so a caller-supplied value would come first.
  2. 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. The 402 then says reason: "no_salt_configured".
  3. Order of consumption. Contribution is checked first. A loyal contributor never spends free calls, so they are still there if they take a break.
  4. 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 422 did not move the counter.
  5. The counter is atomic; the gate is not. used = used + 1 cannot 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.