batchwatch

batchwatch › Keys

Keys

A key is self-service: one request, no password, and no email needed to get started. You get a key by asking for one — and everything the free and contributor tiers offer works from that key alone.

A key is self-service: one request, no password, and no email needed to get started. You get a key by asking for one — and everything the free and contributor tiers offer works from that key alone.

That is safe because a key does not by itself carry any weight in the statistics: a vote has to be earned by measuring on at least three separate days (see interpreting.md). Ten fresh keys count for exactly as much as one — nothing.

An email is never required, but it is offered: confirming one is the free upgrade to the verified-contributor tier — fresher figures and double the weekly volume, on top of the same key. The routes for that are below.

Provenance. The POST /v1/keys success response and the /v1/keys/current responses below were captured from a local wrangler dev instance running this code. Key creation from the machine used to write these docs hit the per-IP daily limit on production, so the production success path could not be exercised. The 429 limit response was captured from production and is marked as such.


Lost your key after paying?

A key is shown once, and we store only a hash of it — never the key itself — so we genuinely cannot look yours up and send it to you. That is a deliberate security property, not an oversight: a key we could recover is a key an attacker could ask us to recover.

Losing one costs you almost nothing, and that is the point:

What a new key does not carry over is your contribution history, so the tier it starts on is free until you have sent measurements again. Sending them is the same request you were already making.

Keys issued before 2026-09-02, while batchwatch still sold subscriptions, keep working exactly as they did. If one of those is lost, the recovery path below still applies: write to hello@batchwatch.dev from the email you used at checkout, we verify you against Stripe (which holds that address — we do not), and we issue you a fresh key with the same access on it. We do not hand out a key on request without that check.

Either way we re-issue; we never resend the original key, because we never had it to begin with.


POST /v1/keys

No authentication. Runs before the authentication step in the router, for the obvious reason.

Body (JSON, optional)

FieldTypeRequiredDefaultNotes
labelstringnounnamedYour own note, e.g. prod-pipeline. Control characters are stripped, then trimmed to 60 characters. Never shown to anyone else.

A body that is absent or unparseable is treated as {}.

Response

201. The token is shown once; only a SHA-256 hash is stored.

curl -X POST https://batchwatch.dev/v1/keys \
     -H 'content-type: application/json' \
     -d '{"label":"prod-pipeline"}'

Captured from a local wrangler dev instance (the token below is from a local throwaway key and does not work anywhere):

{
  "token": "bw_Tp28Sh0rR9d4INTka1X1St7LXw_7DY3knYrtr3TTtK4",
  "label": "prod-pipeline",
  "tier": "free",
  "warning": "This is the only time you will see this token. We store a hash, not the token, so we cannot recover it for you.",
  "next": {
    "contribute": "POST /v1/calls when a job starts, PATCH it when it finishes. That is all it takes to be a contributor.",
    "unlocks": "5 measurements in the last 7 days unlocks the full API.",
    "voting": "Measuring on 3 separate days makes your figures count towards the published percentiles. Until then your data is stored and shown, but pooled with everyone else's so that a new account cannot move the numbers."
  }
}

Token format: bw_ followed by base64url of 32 random bytes.

Rate limit

At most KEYS_PER_IP_PER_DAY keys per IP per day — 5 by default. This is a noise limit, not a security boundary, and the response says so.

The limit is only enforced when the deployment has a TRIAL_SALT and Cloudflare supplied a cf-connecting-ip. Without either, keys are still issued: not being able to get started is considered worse than a runaway loop.

Captured from https://batchwatch.dev, 2026-08-25 14:06 UTC — 429:

{
  "error": "too many keys from this address today",
  "made_today": 5,
  "limit": 5,
  "note": "This is a noise limit, not a security one - a key does not by itself carry any weight in the statistics. If you genuinely need more, one key per service is plenty; keys are not per-machine."
}

GET /v1/keys/current

Your tier, contribution status and quota. Requires a key — 401 {"error":"no key given"} without one.

Captured from a local wrangler dev instance, immediately after creating the key:

{
  "label": "prod-pipeline",
  "tier": "free",
  "contributing": false,
  "recent_measurements": 0,
  "required": 5,
  "window_days": 7,
  "email_verified": false,
  "delayed_by_s": 900,
  "live": false,
  "quota": { "calls_used": 0, "calls_limit": 0, "calls_left": 0, "window": "7 days" },
  "status_note": "Contribute 5 measurements in the last 7 days to open the full API (0 so far), and confirm an email while you are at it to get double the volume and fresher figures for free."
}

And after the same key had sent 7 completed measurements:

{
  "label": "prod-pipeline",
  "tier": "contributor",
  "contributing": true,
  "recent_measurements": 7,
  "required": 5,
  "window_days": 7,
  "email_verified": false,
  "delayed_by_s": 900,
  "live": false,
  "quota": { "calls_used": 0, "calls_limit": 5000, "calls_left": 5000, "window": "7 days" },
  "status_note": "You are contributing. Confirm an email (POST /v1/verify/start with your key) to double the volume to 10,000 calls a week and get fresher figures (10 min instead of 15) - free."
}

Note that tier here is the effective tier, derived from your measurements, not the tier column stored on the key. Only paid is assigned by the operator; free and contributor are computed from data. See tiers.md. A contributor reads live figures with no delay (delayed_by_s: 0) and a 5,000-call weekly quota; confirming an email doubles the quota — see Confirming an email below.

status_note is a plain-English summary of where you stand and the next step to the verified-contributor tier. email_verified reports whether a confirmed address is on the key — note that the verified tier only takes effect while you are also contributing, so this can read true while tier is still contributor or free; the note then says exactly how many measurements are still needed.

quota.calls_limit: 0 on the free tier is not a bug — the four gated routes are not on quota for a non-contributor, they are on the free trial instead.


Confirming an email

Confirming an email is the one free upgrade in the product: it lifts a contributor to the verified-contributor tier — fresher figures (10 minutes behind live instead of 15) and double the weekly volume (10,000 gated calls instead of 5,000). It is an unlock on top of contributing, never a replacement: the verified tier applies only while the account is actually contributing, and a verified account that stops measuring falls back exactly as an unverified one would. The email also makes an otherwise anonymous account contactable, so we can reach you when a queue you depend on breaks. The full tier logic is in tiers.md; the three routes are here.

The link never expires silently: a verification link is valid for EMAIL_VERIFY_TTL_S, one hour by default (DEFAULT_TTL_S = 3600 in src/email.js). If it lapses, just ask for a new one.

This step is inert on a deployment with no email provider configured (EMAIL_VERIFY_SECRET / RESEND_API_KEY unset): POST /v1/verify/start then answers 503 {"error":"email_verification_unavailable"} and promises nothing.

POST /v1/verify/start

Ask for a verification link. Requires a key — we tie the address to that key, so the request authenticates like any other (Authorization: Bearer bw_...).

Body (JSON)

FieldTypeRequiredNotes
emailstringyesThe address to confirm. Trimmed and lowercased; rough-form-validated only — the real test is that the link is opened.
curl -X POST https://batchwatch.dev/v1/verify/start \
     -H 'authorization: Bearer bw_your_key' \
     -H 'content-type: application/json' \
     -d '{"email":"you@example.com"}'

On success (200) we send the link and answer:

{
  "status": "sent",
  "note": "Check your inbox - the link is good for 60 minutes."
}

400 if the email is missing or malformed, 401 if no key was sent, 502 if the mail provider rejected the send, 503 if this deployment has no email provider configured.

GET /v1/verify/confirm

The link in the email. Opening it (a plain GET, so a click works) confirms the address and moves the key to contributor_verified while it is contributing. POST is also accepted, for a confirmation page that does not want a mail scanner to consume the link. The token authenticates the request — no key header is needed.

https://batchwatch.dev/v1/verify/confirm?token=<token>&email=<email>

On a valid, unexpired token (200) — the response states the resulting tier and, if the tier is not live yet, exactly what is still missing, so confirming before you have contributed is never a silent no-op:

{
  "status": "verified",
  "email_verified": true,
  "contributing": true,
  "recent_measurements": 7,
  "required": 5,
  "window_days": 7,
  "note": "You are on the verified-contributor plan: double the volume (10,000 calls a week) and fresher figures (10 min instead of 15), for as long as you keep contributing."
}

If the email is confirmed but the account has not contributed enough yet, the same 200 says so plainly rather than pretending the tier changed:

{
  "status": "verified",
  "email_verified": true,
  "contributing": false,
  "recent_measurements": 2,
  "required": 5,
  "window_days": 7,
  "note": "Your email is confirmed. The verified-contributor tier goes live the moment you are contributing: 2 of 5 measurements in the last 7 days so far. Send 3 more (POST /v1/calls, PATCH it when the job finishes) and the volume doubles and the figures get fresher, automatically."
}

400 {"error":"this link is invalid or has expired"} for a missing, wrong or expired token. The same message covers all three so the route cannot be used to probe whether an address exists.

DELETE /v1/verify/email

Remove the confirmed address. Requires a key — you remove your own address. The account falls back to plain contributor (while it contributes) without losing any data.

curl -X DELETE https://batchwatch.dev/v1/verify/email \
     -H 'authorization: Bearer bw_your_key'

200:

{
  "status": "removed",
  "note": "Your email is gone. You keep full contributor access."
}

401 if no key was sent.


Recovering a paid key

Available for paid keys that have a recovery address on file — that is, a key whose subscription was bought with Email me a replacement if I ever lose this key left ticked. Free, trial and contributor keys have no address stored and are not recoverable this way; that is the deliberate scope, so that data minimisation holds for the tier almost everyone is on.

Not switched on for every deployment. Where it is not, POST /v1/keys/recover and POST /v1/keys/recover/confirm both answer 503 {"error":"key_recovery_unavailable"} and promise nothing — write to hello@batchwatch.dev instead and we will verify you against Stripe by hand. DELETE /v1/keys/recovery-email always works, so an address can always be removed.

POST /v1/keys/recover

Ask for a replacement link. No key is required — you have lost the only one you had.

Recovery request body

FieldTypeRequiredNotes
emailstringyesThe address you used at checkout. Trimmed and lowercased.
curl -X POST https://batchwatch.dev/v1/keys/recover          -H 'content-type: application/json'          -d '{"email":"you@example.com"}'
{
  "status": "sent",
  "note": "If a paid key is registered to that address, a replacement link is on its way - good for 60 minutes. Check the inbox you used at checkout."
}

The answer is the same whether or not we hold that address — the same if you asked a moment ago, and the same if our mail provider is failing. It has to be: the route is unauthenticated, and an endpoint that answered differently for a real customer would be a way to ask us who our customers are. For the same reason the mail is sent after the response, so the reply takes the same time either way.

400 only if the address is malformed, and 429 if one caller makes too many requests in a day.

There is also a short cooldown per key, so that nobody who knows your address can use this route to fill your inbox. Like the rest, it is invisible in the response.

GET /v1/keys/recover/confirm

The link in the email. Opening it in a browser is safe — it verifies the link and prints the command that issues the key, and issues nothing itself.

That is on purpose. Corporate mail scanners follow links before a human sees them, and a link that re-issued on a click would let a scanner spend your recovery for you.

{
  "status": "ready",
  "key_id": 41,
  "issue_with": "curl -X POST 'https://batchwatch.dev/v1/keys/recover/confirm?token=...&key=41'",
  "note": "This link is good. Sending it as a POST issues your replacement key - we keep that behind a POST so that a mail scanner following the link cannot spend your recovery for you."
}

POST /v1/keys/recover/confirm

Issues the replacement. The token in the link authenticates the request — no key header is needed, which is the point.

curl -X POST 'https://batchwatch.dev/v1/keys/recover/confirm?token=<token>&key=<id>'
{
  "token": "bw_...",
  "label": "prod-pipeline",
  "key_id": 87,
  "tier": "paid",
  "recovered": true,
  "warning": "This is the only time you will see this token. We store a hash, not the token, so we cannot recover it for you.",
  "note": "You are back in on the same subscription: your plan, your billing, your contribution history and your quota all moved to this key. The old key no longer authenticates."
}

A link is single-use. Issuing your replacement consumes it, so a second POST of the same link is refused — as is a link whose address you have since erased, or whose subscription has ended.

400 {"error":"this recovery link is invalid or has expired"} covers all of those plus a missing or wrong token — one message for every case, so the route cannot be used to read someone's subscription status.

DELETE /v1/keys/recovery-email

Remove the stored address. Requires the key, because you remove your own address. Your subscription and your key are untouched; what you give up is the ability to recover this key by email.

curl -X DELETE https://batchwatch.dev/v1/keys/recovery-email          -H 'authorization: Bearer bw_your_key'
{
  "status": "removed",
  "note": "Your recovery address is gone. Your subscription and your key are untouched - but we can no longer email you a replacement if this key is lost, so keep it somewhere safe."
}

DELETE /v1/keys/current

Revokes the key you authenticate with. There is deliberately no /v1/keys/{id}: without accounts there is no answer to "who may revoke what", and being able to kill your own key is enough to make a leaked one harmless.

Requires a key — 401 {"error":"authenticate with the key you want to revoke"} without one.

Captured from a local wrangler dev instance:

{
  "revoked": true,
  "label": "prod-pipeline",
  "note": "The key no longer authenticates. Measurements you already sent stay in the dataset - they were true when they were taken, and a record that can be deleted backwards is not a record."
}

Measurements are not removed. If you want them out of the aggregates, call DELETE /v1/calls/mine before revoking — after revocation the key can no longer authenticate, so that route becomes unreachable for it.

A revoked token no longer authenticates, and the caller is treated as anonymous from then on. Verified on a local instance: reusing the revoked token against GET /v1/keys/current returned 401 {"error":"no key given"} — the same message an unauthenticated caller gets, which is slightly misleading when a token was supplied. See ops.md#known-discrepancies.