Health, self-check, prober state, outage feeds, the forced rollup, and the HTML pages the same worker serves.
GET /health
Liveness only. No key, no method check — POST /health also returns 200 (verified against production).
curl https://batchwatch.dev/health
Captured from https://batchwatch.dev, 2026-08-25 14:04 UTC:
{
"ok": true,
"ts": 1787666672
}
ts is unix seconds. A 200 here proves the worker is running and nothing else. That is what /v1/status is for.
GET /v1/status
The self-check. Open on purpose: a status page you can only see when logged in is marketing.
It measures the four ways this service can fail while still answering 200 to everything:
| Check | Status meanings |
|---|---|
cron | fail if the newest rollup is older than 1800s (cron runs every 10 minutes); warn if no rollup has ever been computed. |
ingest | warn if nothing has been measured for 86400s, or ever. |
probe | warn if more than 10 probe jobs are outstanding, or one has been outstanding for over a week. |
contributors | warn if the whole dataset comes from one source. |
The overall status is the worst of the four. The HTTP status code is 503 when status is fail, and 200 otherwise — including when it is warn.
Example
Captured from https://batchwatch.dev, 2026-08-25 14:04 UTC — HTTP 200 with status: "warn":
curl https://batchwatch.dev/v1/status
{
"status": "warn",
"checked_at": "2026-08-25T14:04:33.000Z",
"checks": [
{ "navn": "cron", "status": "ok", "alder_s": 229, "note": null },
{ "navn": "ingest", "status": "ok", "alder_s": 719, "note": null },
{ "navn": "probe", "status": "ok", "outstanding": 1, "oldest_age_s": 2634, "note": null },
{
"navn": "contributors",
"status": "warn",
"contributors": 1,
"probe_share": 1,
"note": "Everything in the dataset comes from one source. The numbers are true but they are not crowdsourced, and coverage says so."
}
],
"note": "One or more subsystems can fail without any request returning an error. That is what these checks exist to catch."
}
The field names inside checks are Danish (navn, alder_s) because they come straight from src/selftest.js. That is how the API emits them today; treat the names as part of the contract until they are changed.
The 503 case was not observed. Producing it would have required the live cron to stop, which is not something to arrange for a documentation example. From handleStatus in src/index.js, the response body is identical in shape with "status": "fail" and at least one check at fail; only tjekCron can return fail, which happens when the newest rollup is older than 1800 seconds. Anything relying on this should treat 503 as "the data may be stale but the service still answers", not as "the service is down".
GET /v1/probe
The project's own measurement jobs. Open: a product that asks for trust in a dataset should be able to show how the dataset is made.
Captured from https://batchwatch.dev, 2026-08-25 14:05 UTC (the recent array is truncated here to three of its six entries):
curl https://batchwatch.dev/v1/probe
{
"configured": [
"openai/gpt-5.6-luna",
"openai/gpt-5.6-sol",
"openai/gpt-5-nano",
"anthropic/claude-haiku-4-5",
"google/gemini-3.7-flash"
],
"not_configured": [],
"interval_s": 3600,
"outstanding": 1,
"recent": [
{
"provider": "google",
"model": "gemini-3.7-flash",
"status": "completed",
"started_at": "2026-08-25T13:50:39.000Z",
"duration_s": 115
},
{
"provider": "anthropic",
"model": "claude-haiku-4-5",
"status": "completed",
"started_at": "2026-08-25T13:30:39.000Z",
"duration_s": 64
},
{
"provider": "openai",
"model": "gpt-5.6-luna",
"status": null,
"started_at": "2026-08-25T13:20:39.000Z",
"duration_s": null
}
],
"note": "These are our own measurement jobs - real batch calls against the real queue, not simulations. They count in the statistics and are marked source=probe so coverage can tell you when a dataset is really just us."
}
configured lists provider/model pairs, not providers, so a provider with three models appears three times. not_configured lists providers with no usable credentials. recent is the last 20 probe rows by start time; status: null with duration_s: null means the job is still outstanding.
Probe rows are marked source=probe and do count in the statistics; that is what probes and crowdsourced in /v1/coverage exist to disclose.
GET /v1/outages
Outage state. Never delayed, for any tier — a feed that is fifteen minutes late is not a feed, and what is given away here is a boolean about a provider rather than the distribution that is the product.
status is one of:
| Value | Meaning |
|---|---|
degraded | At least one outage is open. |
operational | No open outage, and at least one provider/model can actually be watched. |
insufficient_sources | Nothing can be watched: no model has 3 established contributors. This is not a claim that the providers are healthy. |
A model is watchable when at least UDFALD.MIN_KILDER (3, same as the voting threshold) distinct keys have contributed in the rollup window. The contributors figure in coverage counts distinct keys and is an upper bound: it does not apply the five-measurements-over-three-days rule that earns a vote. The response says so itself.
Detection uses hysteresis: an outage opens when the fresh median is 3× the baseline, and closes at 1.5×, with an absolute floor of 900s so that a queue going from 20s to 70s is not called an outage.
Captured from https://batchwatch.dev, 2026-08-25 14:04 UTC:
curl https://batchwatch.dev/v1/outages
{
"schema": "batchwatch.status.v1",
"generated_at": "2026-08-25T14:04:39.000Z",
"status": "insufficient_sources",
"watching": 0,
"not_watchable": 0,
"open": [],
"recent": [],
"coverage": [],
"note": "Nothing here is a claim that the providers are healthy. We do not yet have enough independent contributors on any model to tell an outage from one account having a bad day.",
"live": true,
"delayed_by_s": 0,
"delay_note": "Outage state is never delayed, unlike the percentile API. A feed that is fifteen minutes late is not a feed - and what is given away here is a boolean about a provider, not the distribution that is the product.",
"coverage_note": "contributors counts distinct keys and is an upper bound: it does not apply the five-measurements-over-three-days rule that earns a vote."
}
coverage is empty above because the query behind it requires key_id IS NOT NULL, and production held no completed measurement from a keyed contributor in the window at capture time. (The dataset was not empty — /v1/coverage listed five models — so the inference is that the prober's rows carry no key_id on this deployment. That was inferred from the two responses, not read out of the database.) open and recent were both empty, so the shape of an outage entry was not observed. From udfaldTilJson in src/outage.js, each entry carries id, provider, model, mode, severity, started_at, ended_at, duration_s, peak_ratio, baseline_p50_s, ended_reason and ongoing. recent is capped at the 20 most recent closed outages.
GET /v1/outages.atom
The same state as an Atom feed, for anything that reads feeds. Content type application/atom+xml; charset=utf-8, and — unlike the JSON API — it is cacheable: cache-control: public, max-age=300.
Captured from https://batchwatch.dev, 2026-08-25 14:05 UTC, verbatim:
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>tag:batchwatch.dev,2026:status</id>
<title>batchwatch - batch queue status</title>
<subtitle>Measured queue time on LLM batch APIs. We report a slowdown only when a majority of established contributors see it.</subtitle>
<updated>2026-08-25T14:05:41.000Z</updated>
<link rel="self" href="https://batchwatch.dev/v1/outages.atom"/>
<link href="https://batchwatch.dev"/>
</feed>
The feed contains both open and closed outages as entries. There were none at capture time, so an entry was not observed.
Note: /v1/status.atom does not exist (verified: 404), even though it is the default selfUrl inside src/outage.js.
POST /v1/rollup/refresh
Forces a recomputation of the precomputed rollups instead of waiting for the ten-minute cron. Requires a key (401 {"error":"api key required"}), because it costs exactly what the rollups exist to avoid. Any valid key will do — no tier check.
Captured from a local wrangler dev instance:
{
"models": 2,
"rollups": 4,
"models_total": 2,
"deferred": 0,
"ms": 48
}
models_total is how many provider/model/mode combinations were eligible; models is how many were reached before the time budget ran out, and deferred is the remainder, which the next run picks up. One rollup is written per delay tier, which is why rollups is a multiple of models.
HTML and crawler routes
The same worker serves the site. These are GET/HEAD only and are matched before authentication, so a crawler hitting a thousand pages costs no key lookups.
| Path | Content type | Verified |
|---|---|---|
/, /index.html | text/html | 200 |
/m/{provider}/{model} | text/html | 200 for /m/openai/gpt-5-nano; 404 for /m/openai/ikke-en-model |
/p/{provider} | text/html | 200 for /p/openai; 404 for /p/ikkeenprovider |
/robots.txt | text/plain | 200 |
/sitemap.xml | application/xml | 200 |
A model page exists only when there is a usable rollup for it; otherwise the worker returns a 404 page rather than an empty one.
Model names containing a slash (meta-llama/Llama-3) are supported: the model segment is everything after the provider, and encodeURIComponent writes the slash as %2F.
Captured from https://batchwatch.dev, 2026-08-25 14:05 UTC:
curl https://batchwatch.dev/robots.txt
User-agent: *
Allow: /
Disallow: /v1/
Disallow: /health
Sitemap: https://batchwatch.dev/sitemap.xml
The sitemap lists the front page, one entry per provider and one per model, with lastmod and priority.
Known discrepancies
Found while writing this reference, listed so nobody has to find them twice. None of these have been changed — they are reported, not fixed.
docs/KRAVSPEC.mdnames six routes that do not exist:/v1/batches,/v1/batches/complete,/v1/forecast,/v1/tradeoff,/v1/anomalies,/v1/status.atom. All six returned404from production on 2026-08-25, in the same run in which/v1/coveragereturned200.
DELETEis missing from the CORS preflight response.OPTIONSreturnsaccess-control-allow-methods: GET,POST,PATCH,OPTIONS, butDELETE /v1/calls/mineandDELETE /v1/keys/currentboth exist. Browser calls to those two routes will fail preflight. Verified against production.
- A revoked key gets the "no key given" message. After
DELETE /v1/keys/current, reusing the same token onGET /v1/keys/currentreturns401 {"error":"no key given"}— but a key was given. The caller is sent looking for a missing header rather than told the key is revoked. Verified on a local instance.
README.mdsays the Anthropic and Google probers were "written, never run (no key yet)", but production/v1/probeon 2026-08-25 listedanthropic/claude-haiku-4-5andgoogle/gemini-3.7-flashas configured, with completed jobs 64s and 115s old. The README table is out of date.
riskis validated on one route and not the other./v1/estimate-batchtimerejects an unknownriskwith422;/v1/should-i-batchsilently falls back to p90. The defaults also differ (p50vsp90).
- Danish field names leak into the public JSON.
/v1/statusemitsnavnandalder_sinsidechecks. Every other route uses English field names.