batchwatch

batchwatch › Privacy Policy

Privacy Policy

Version 0.1 · draft of 25 August 2026

Draft. Not legal advice. Written by the developer who wrote the service, not by a lawyer. Every factual claim below was checked against src/index.js, src/trial.js, src/keys.js, src/tiers.js and schema.sql, and where the code and the marketing copy disagree, that is said out loud rather than smoothed over. It has not been reviewed for legal sufficiency. Have a qualified lawyer review this before it is published.

Placeholders in [SQUARE BRACKETS] must be filled in before publication.

Version 0.1 · draft of 25 August 2026


1. The short version

2. Everything we store, field by field

This section is the schema, in English. If a field is not listed, we do not store it.

call — one row per measured job

FieldContentsSource
idopaque row id, c_ + 20 hex charsus
key_idwhich API key submitted it, or NULL if anonymousus
modebatch or syncyou
provideropenai, anthropic, google, mistral, azure, otheryou
modelthe model name, verbatimyou
endpointe.g. /v1/chat/completionsyou
requestsnumber of requests in the batchyou
input_tokens, output_tokenstoken countsyou
started_client, ended_clientyour timestamps — advisory onlyyou
started_server, ended_serverour timestamps — authoritativeus
statuscompleted, failed, expired, cancelled, abandonedyou
ttfb_mstime to first token, synchronous calls onlyyou
regiontwo-letter country code only, from Cloudflare's CF-IPCountry headerCloudflare edge
sourceuser or probe (our own measurement jobs)us
excludedquality flag; excluded rows are removed from every published figureus
clock_skew_sdifference between your clock and ours, for diagnosticsus
provider_job_idthe provider's own job id — only ever set on our own prober's rows, never on yoursus

We do not store the IP address on this row. Only the country code derived from it, at the edge, by Cloudflare.

Two fields carry free text you control, and they are the only place personal data could reach us by accident:

api_key

FieldContents
idinteger
token_hashSHA-256 of the bearer token. The token itself is never stored
labelyour own note, e.g. prod-pipeline. Control characters stripped, trimmed to 60 characters, never shown to anyone else
created_at, revoked_attimestamps
tierfree or paid

Do not put a personal name or an email address in label. It is free text and we do not scrub it.

trial — the free-trial counter

FieldContents
identityeither key:<id> or ip:<salted SHA-256 hash>never a raw IP address
usedhow many free calls have been consumed
first_seen, last_seentimestamps

A row is written only while the trial is running — at most 20 times per identity, ever — and never for a caller who is already contributing.

usage — the rolling quota counter

FieldContents
identitykey:<id> for quota, or newkey:<salted SHA-256 hash> for the key-creation limit
dayunix day, floor(ts / 86400)
callscount for that day

Note the second form: the daily key-creation limit is also counted against a salted hash of your IP address, under the prefix newkey:. Same salt, same construction, same reasoning as the trial counter.

Your browser

The dashboard stores exactly one item in localStorage, under the key bw.spend: the monthly spend figure and batchable-share percentage you typed into the savings calculator. It stays on your device and is never sent to us. There is no other client-side storage. We set no cookies.

3. How the IP hash works, and why the salt is mandatory

identity = "ip:" + SHA256(TRIAL_SALT + "|" + client_ip)

The client IP is read only from Cloudflare's CF-Connecting-IP header, which Cloudflare writes at the edge and which a caller cannot forge. X-Forwarded-For is deliberately not read: Cloudflare appends to that header, so a caller's own value would come first, and reading it would let anyone reset their trial with a header flag.

TRIAL_SALT is a Cloudflare secret. It is not in the repository, not in wrangler.toml, and not in any deployment config — deliberately, and with a comment explaining that wrangler deploy will happily overwrite a secret with a [vars] entry of the same name, which is how this went wrong once before.

Why the salt is not optional. An unsalted SHA-256 of an IPv4 address is effectively clear text: there are only about four billion of them, so the whole space can be enumerated on a laptop in an afternoon. A hash you can reverse by brute force is not a protective measure — it is the address with extra steps.

What happens without the salt. Keyless callers get no free trial at all. The service does not silently fall back to storing hashes that pretend to be anonymous. This is a deliberate fail-closed choice in src/trial.js, and it is the correct thing to fail loudly on.

4. Is a salted IP hash personal data?

Our position: yes. We treat it as pseudonymised personal data, not anonymous data, and it is in scope of the GDPR.

The reasoning:

  1. An IP address is personal data (Breyer, C-582/14), at least where the controller has means reasonably likely to be used to identify the subscriber.
  2. Hashing does not change that on its own. The mapping is deterministic: we hold the salt, so given a candidate IP address we can compute the hash and confirm a match. That is exactly the "singling out and verification" capability that the EDPB and the former Article 29 Working Party (WP216 on anonymisation) treat as leaving data pseudonymised rather than anonymised.
  3. Recital 26 asks whether identification is reasonably likely. Because we hold the salt, for us it is trivially likely. The salt reduces the risk for anyone who steals the database without it — which is real and worth having — but it does not put the data outside the Regulation for us.

Two honest consequences of taking that position:

The country code (region) is a two-letter code. On its own it does not identify anyone. Combined with a key_id it is one more attribute of a key holder, and it is treated as part of that record.

DataPurposeLegal basis
call rows submitted with a keyBuilding and publishing the aggregate; giving you your percentile back; contributor statusArt. 6(1)(b) performance of the arrangement you entered into by submitting, and Art. 6(1)(f) legitimate interest in operating and publishing a measurement dataset
call rows submitted anonymouslySameArt. 6(1)(f). In practice these rows are not attributable to any person by us at all — see §7
api_key.token_hash, label, tierAuthenticating you; deciding your tierArt. 6(1)(b)
trial.identity (ip: hash)Enforcing the 20-call free trialArt. 6(1)(f) — we have a legitimate interest in a trial that cannot be reset for free, and hashing with a secret salt is the least intrusive way we found to do it. The alternative was storing the address
usage.identity (newkey: hash)The 5-keys-per-IP-per-day noise limitArt. 6(1)(f) — preventing runaway key creation
usage.identity (key: form)Enforcing the rolling weekly quotaArt. 6(1)(b)
region country codeUnderstanding regional queue behaviourArt. 6(1)(f)

On the legitimate-interest balancing (Art. 6(1)(f)): the data is metadata about machine behaviour, not about a person's activity; we receive no content; the IP is hashed with a secret salt and the address itself is never written; the retention is bounded by the purpose; and there is no profiling of individuals. A formal Legitimate Interests Assessment should still be written up before launch — this paragraph is a sketch of one, not a substitute for it.

No cookies are set, so ePrivacy Art. 5(3) consent is not engaged for the API. The one localStorage item (bw.spend) holds values you typed into a calculator on your own screen and is read only to put them back in the fields; we consider that strictly necessary for functionality you explicitly requested, but this is a call a lawyer should confirm.

6. Retention

Stated plainly: the code implements no automatic deletion of anything. There is no TTL, no scheduled purge and no DELETE statement anywhere in the service. This was verified by reading every query in src/.

What that means in practice:

Before launch, a retention period must be set and implemented — at minimum for trial.identity and usage.identity, which are the rows that contain hashed personal data and which serve no purpose once their window has passed. A trial row whose last_seen is a year old is doing nothing except existing. This is an open item, not a policy.

7. Your rights, and what we can honestly do

You have the rights in Chapter III of the GDPR: access, rectification, erasure, restriction, objection and portability. Write to hello@batchwatch.dev. There is no self-service route for any of this except key revocation; requests are handled manually by the operator.

To act on a request we need to be able to tie the data to you. In practice that means authenticating with the API key in question — that is the only identifier that links rows to a submitter.

What we can delete or exclude

DataCan we?How
Your API keyYes, immediately, yourself. DELETE /v1/keys/currentSets revoked_at; the key stops authenticating
The api_key row entirelyYes, on requestManual database operation
Your trial row (the IP hash)Yes, on requestManual database operation
Your usage rowsYes, on requestManual database operation
Your call rowsYes, immediately, yourself. DELETE /v1/calls/mine sets excluded = 1 on every row from your keySee the correction below — it does not yet reach every published surface
Physical deletion of call rowsYes, on requestManual database operation

Correction, 25 August 2026. An earlier version of this section said that setting excluded = 1 removes the rows from every published figure "on the very next request", and that this "was verified across all of them". That was checked by reading the queries, not by running them, and it is wrong. It was re-tested against a running worker on 25 August 2026, and the result is:

So today, DELETE /v1/calls/mine removes your measurements from the API but not from the indexed public pages. Until that is fixed, an erasure request must be handled manually by the operator, who must also delete the corresponding rollup rows. The fix is described in docs/privacy-wiring.md §3 and is an open item below.

Note separately that exclusion is suppression, not erasure. The row still exists. For an Art. 17 erasure request, exclusion is not enough on its own; the row has to actually go, and that has no API route — it is a manual operation by the operator against the D1 database.

What we cannot do, and why

  1. We cannot delete anonymous submissions on request. If you submitted without a key, key_id is NULL and there is nothing in the row that links it to you. We are not able to identify which rows are yours, and we will not guess. Under Art. 11 GDPR, where we cannot identify the data subject, the erasure right does not oblige us to acquire additional information purely to be able to comply. If you may want your submissions removable later, submit them with a key.
  1. We cannot recall answers already given. A percentile that was computed and served to a caller last week cannot be un-served. Removing your rows changes future answers, not past ones.
  1. We cannot retract aggregates already published or exported. Once a figure is on the dashboard, in someone's screenshot, or in a third party's cache, it is out. The figures are aggregates across contributors, so this is not a disclosure of your submissions — but it is a limit on what deletion achieves.
  1. We cannot recover your API token. We only have the hash. This is a feature.

The conflict you should know about

There are, today, two inconsistent statements about deletion, and this document will not pretend otherwise:

These do not say the same thing. Our position, pending a decision: key revocation alone does not delete measurements, and the API's wording is correct about that. A separate, explicit erasure request naming your key will be honoured — we will delete or exclude those call rows.

The API response text and the front-page wording need to be reconciled before launch. This is flagged as an open item, not resolved here.

8. Aggregation and disclosure risk

Published figures are aggregates. We never publish an individual submission.

However, an aggregate over a very thin bucket can leak. The old design intent — stated in wrangler.toml — was that a bucket should not be published below MIN_N measurements (20) from MIN_KEYS distinct keys (3), "otherwise you could read one competitor's usage out of a thin bucket". That threshold is enforced on /v1/distribution and nowhere else.

As of 25 August 2026 this was re-examined by measurement, not by reading, and the honest position is set out below. The full test, route by route, is in docs/privacy-wiring.md §2; the rule we intend to adopt is in src/privacy.js.

What a reader can actually learn today

A test dataset of 28 measurements from a single key, submitted through the ordinary ingest route, was queried through every public surface. With no API key at all, a reader could obtain:

/v1/wait returned percentiles with contributors: 1 and confidence: very_low, which we consider acceptable — a queue median is a property of the provider, not of the contributor.

The part that is not about thresholds at all

model is free text that the submitter sends, capped only at 100 characters. A fine-tune identifier such as ft:gpt-4o-mini:acme-corp::9xYz names its owner, and we build a public page per model and announce it in sitemap.xml. No contributor threshold protects against that: the disclosure is in the key of the aggregate, not in the value.

What we are changing

The rule we are adopting, and the reasoning, are in src/privacy.js:

Until that is wired into the routes, the position stated in the previous version of this section still holds and you should act on it: while a model has a single contributor, that contributor's queue-time distribution, weekday pattern and job-size mix are effectively public. If your batch timings are commercially sensitive, that is a reason to weigh before contributing — and a reason not to put a private model name in the model field.

9. Who else processes the data

Cloudflare, Inc. is our sole sub-processor. The service is a Cloudflare Worker with a Cloudflare D1 database, on Cloudflare's network. Cloudflare processes the request itself (including your IP address, at the edge, before it reaches our code) and stores the database.

The D1 region is not pinned in our configuration. wrangler.toml declares the database binding and id but no location_hint; the primary region was chosen by Cloudflare when the database was created. This must be established and stated here before publication — see DATA-PROCESSING.md §5. Do not assert an EU region in this document until it has been checked in the Cloudflare dashboard.

Cloudflare is a US company. Transfers are covered by Cloudflare's own Data Processing Addendum and Standard Contractual Clauses, which must be executed and referenced before launch.

Our prober submits tiny real jobs (8 tokens in, 5 out) to LLM providers using our own accounts. No user data of any kind is sent to those providers. We never see, hold or transmit your provider credentials.

We do not use analytics, advertising, tag managers, or any third-party script. The dashboard loads nothing from outside the origin.

10. Logging

The only application logging in the service is a one-line summary of each prober run: how many jobs were polled, finished, abandoned and submitted, plus any error strings. It contains no user data.

Cloudflare's own edge logging and analytics are Cloudflare's processing and are governed by their documentation and DPA. Assume request-level metadata, including IP addresses, exists there for Cloudflare's retention period.

11. Automated decision-making

The service returns a verdict (run_batch, run_sync, batch_at) about a queue. It makes no decision about a person, and produces no legal or similarly significant effect on anyone within the meaning of Art. 22.

12. Children

The service is not directed at children and is not usable in any meaningful way without an LLM provider account.

13. Complaints

You may complain to your supervisory authority. In Denmark that is Datatilsynet, Carl Jacobsens Vej 35, 2500 Valby, dt@datatilsynet.dk.

14. Changes to this policy

Material changes will be announced on the dashboard and in this repository, with the same 30 days' notice we commit to for terms changes that reduce what contributors get.


Open items for the reviewing lawyer

  1. Retention periods are not set and no deletion is implemented. §6. This is the largest gap.
  2. The deletion conflict between the API's revocation text and the front page. §7.
  3. The MIN_N/MIN_KEYS rule was the wrong rule and is enforced on one route. §8. A replacement is written and tested (src/privacy.js) but is not yet wired into any route. docs/privacy-wiring.md §4 says where each call goes. Until then §8's warning is live.
  4. DELETE /v1/calls/mine does not reach the indexed public pages, and never will on its own. §7. Measured, not inferred. Erasure requests must be handled manually, including the rollup rows, until the fix in docs/privacy-wiring.md §3 lands. This is the most urgent of the items here, because it is a promise we make in the API response text that the code does not keep.
  5. The D1 region is unverified. §9.
  6. Whether the salted-IP-hash analysis in §4 is the position we want on the record, and whether a formal LIA and (given profiling is absent, but large-scale IP handling is present) a DPIA screening are needed.
  7. Whether model / endpoint / label free-text fields need a contractual warranty from submitters that they contain no personal data. §8 gives this a second, sharper reason: a fine-tune name identifies its owner, and we publish a page per model name.
  8. Confirmation that no cookie banner is required, given §2's single localStorage item.
  9. Whether contributors should have to warrant that they are permitted to measure on the provider account they submit from. See docs/compliance.md §7.