batchwatch

batchwatch › Error model

Error model

Every error is JSON with an error field holding a human-readable message, plus route-specific fields. There is no error code enumeration — the status code and the message are the contract.

Every error is JSON with an error field holding a human-readable message, plus route-specific fields. There is no error code enumeration — the status code and the message are the contract.

All responses, errors included, carry content-type: application/json; charset=utf-8, cache-control: no-store and access-control-allow-origin: *. The Atom feed and the HTML pages are the exception (see ops.md).

Status codes

CodeWhere it comes fromMeaning
200everywhereAnswered. Note that a refusal to give a number is still a 200: /v1/distribution returns verdict: "insufficient_data" with 200, and /v1/wait returns verdict: "no_measurements_yet" with 200.
201POST /v1/keys, POST /v1/calls, POST /v1/calls/completeCreated. /v1/calls/complete returns 201 if anything was accepted, even with rejects alongside.
204OPTIONSCORS preflight. No body.
400query routes, PATCH /v1/calls/{id}, POST /v1/calls/completeMalformed request: model is required, body must be JSON, body must be an object or a non-empty array.
401key-required routesNo key, or a key that does not authenticate. Messages differ per route — see below.
402the four gated routesContribution required and the free trial cannot cover it. See below.
403PATCH /v1/calls/{id}this call belongs to another key.
404router, PATCH /v1/calls/{id}, HTML pagesunknown route (with see: "/v1/coverage"), or unknown call id. Also returned for a known path with the wrong method — there is no 405 anywhere.
409PATCH /v1/calls/{id}already finished. A measurement can only be closed once.
413POST /v1/calls/completeMore than 500 records in one request.
422ingest routes, /v1/estimate-batchtimeValidation failed. Body carries details (ingest) or a plain message (risk must be p50, p90 or p95). /v1/calls/complete returns 422 only when every record was rejected.
429POST /v1/keys, the four gated routesEither too many keys from this IP today, or the weekly call quota is used up.
503GET /v1/status onlyThe self-check came back fail. The body is the normal status body. Not observed live — see ops.md.

There is no 500 in the code. An unhandled exception in a Cloudflare Worker surfaces as a platform error page, not as one of these bodies.

401 — the messages differ, deliberately

RouteBody
GET /v1/calls/mine, DELETE /v1/calls/mine, POST /v1/rollup/refresh{"error":"api key required"}
GET /v1/keys/current{"error":"no key given"}
DELETE /v1/keys/current{"error":"authenticate with the key you want to revoke"}
POST /v1/calls/complete{"error":"api key required", "why": ..., "instead": ...} — explains why this one route is closed while the live ingest path is open.

Captured from https://batchwatch.dev, 2026-08-25 14:05 UTC:

curl https://batchwatch.dev/v1/calls/mine
{ "error": "api key required" }

402 — contribution required

This is the one status code worth handling specially. It comes from the four gated routes when the caller neither contributes nor has free trial calls left, and it names which situation you are in through reason:

reasonWhat happenedWhat to do
trial_exhaustedYou used all the free calls.Contribute 5 measurements in 7 days.
no_salt_configuredThe deployment has no TRIAL_SALT, so there is no trial at all.Nothing you can do as a caller — contribute, or ask the operator.
no_client_ipThe caller could not be identified, so no trial could be counted.Send a key, or contribute.
no_key / not_contributingFallthrough from the contribution check.Contribute.

When there was a trial to use up, trial reports the counter and how it was counted. When there was none, trial is {"available": false, "why": ...} instead.

Captured from a local wrangler dev instance, by deliberately spending all twenty free calls from one client IP and then making a twenty-first:

{
  "error": "contribution required",
  "reason": "trial_exhausted",
  "trial": {
    "calls_used": 20,
    "calls_total": 20,
    "calls_left": 0,
    "counted_by": "client ip"
  },
  "recent_measurements": 0,
  "required": 5,
  "window_days": 7,
  "note": "You have used all 20 free calls. Send 5 measurements in the last 7 days to unlock the full API - the data only exists because people do. /v1/wait stays open to everyone, delayed 15 minutes."
}

The no_salt_configured and no_client_ip variants were not captured; their bodies are described from trialDenied() in src/trial.js.

Quota and key limits (429)

Key creation. POST /v1/keys above KEYS_PER_IP_PER_DAY (5 by default). Captured from production — see keys.md.

Weekly quota. The four gated routes, for a contributor who has used up the tier's weekly calls. The window is a rolling seven days, so usage frees up gradually rather than resetting on a calendar boundary.

Captured from a local wrangler dev instance with CONTRIB_WEEKLY_CALLS set to 1 so the branch could be reached — the production contributor limit is 10000, not 1:

{
  "error": "weekly quota exceeded",
  "tier": "contributor",
  "quota": {
    "calls_used": 3,
    "calls_limit": 1,
    "window": "7 days",
    "note": "Rolling seven days, not a calendar week - the window moves with you, so usage frees up gradually."
  },
  "note": "Contributing buys the data, which you helped build. It does not buy unlimited volume or live figures - those are what a paid plan is for. If you are hitting this, the product is probably saving you more than the plan costs."
}

What is not an error

Failed calls do not cost you anything

A gated route that does not return 200 neither consumes a free trial call nor counts against the weekly quota — the counter is incremented only after a successful response is produced. Verified against production: a 422 from /v1/estimate-batchtime?risk=p99 left the trial counter at 7, and the next successful call reported calls_used: 8.