Point a client generator at https://nanoparse.app/openapi.json and it emits one method: a POST /fetch that returns a job id. The NanoParse endpoint behind that method answers five routes, has two success shapes, and reports an exhausted free tier on a status code the spec assigns to something else.
A generated client therefore works on the slow path and breaks on the fast one — not because anything is broken, but because a spec is a snapshot of what someone remembered to write down, and the response body is regenerated on every request.
Every figure below was measured against the live origin on 2026-10-28. Every request is unpaid: refusals and price quotes cost nothing, and the free tier was already spent before the first probe. The response bodies are committed beside this post.
Seven documents, one path
The service publishes a stack of machine-readable documents about one endpoint. Seven were measured today, with their served size:
document bytes what a reader gets
/openapi.json 3,904 1 path, 2 request fields, 6 responses
/llms.txt 11,576 prose + curl, 14 other documents referenced
/auth.md 4,427 how an agent pays, without an account
/.well-known/agent.json 1,953 identity card, name, description, contact
/mcp 4,361 JSON-RPC tools/list, 2 tools
/quickstart.md 7,978 the human walkthrough
/litmus.md 8,780 the 20 signal groups, described
Seven documents is not a problem. The problem is that the machine-readable one — the one a code generator consumes without a human reading it — is the narrowest of the set, and the narrowest one is the one that gets compiled into a client.
The spec is not careless. Its price block is exactly right: amount: "0.005", network: "eip155:8453", the USDC asset address, the payTo treasury, maxTimeoutSeconds: 60, assetTransferMethod: "eip3009". Its error taxonomy names the failures worth naming. What it does not contain is the shape of the answers that do not fit its one path.
The spec has one path
curl -s https://nanoparse.app/openapi.json | jq -r '.paths | keys[]'
# /fetch
One line. The endpoint also serves GET /status?jobId= (the poll a 202 requires), GET /status/account?address= (a free pre-flight that reports whether the next call will succeed), GET /health, and POST /mcp. None of the four is a path in the spec. Four of them answer 200 with no key, no signature and no payment.
The poll endpoint is not undocumented in prose — the 202 response's own description says "Poll statusUrl (GET /status?jobId=…, free, no payment)". It is simply not a path a generator can emit, so the statusUrl string arrives as an untyped string property and the GET has to be hand-written. There is no schema for what comes back, and statusUrl carries no description of its own.
sync has no success response
The request body schema is two fields: url, required, and sync, a boolean described as "Wait briefly for the parsed result inline instead of returning immediately".
The responses list six codes — 202, 400, 402, 403, 422, 429 — and no 200. Read that pair together and the field is unimplementable from the document: asking for the inline result changes the success shape, and the new shape is absent.
Here is that path in the deployed source. On sync: true (or ?sync=true), the route polls the result store every 700 ms against a 9,000 ms deadline:
const SYNC_POLL_TIMEOUT_MS = 9000;
const SYNC_POLL_INTERVAL_MS = 700;
A render that lands inside that window answers 200 with the document inline:
{ "status": "completed", "markdown": "...", "metadata": { "...": "..." }, "litmus": { "...": "..." } }
A render that does not answers 202 with the queued contract and one extra field, sync_timeout: true, which is the only part of the fast path the spec does model:
{ "jobId": "…", "status": "queued", "statusUrl": "…", "sync_timeout": true }
So the field is documented, the fallback is documented, and the successful case — the one a caller asks for — is not. A client typed from this spec declares fetch() as returning 202 and will throw on the 200, or silently drop the document it just paid for.
The quota wall arrives as 402, not 429
The spec's 429 is described as "Free-tier quota exhausted or automated abuse detected. Never charged." That is a reasonable place to put a quota wall. It is not where the wall is.
A live POST /fetch with no payment header, from an address whose allowance is spent, answers HTTP 402. The body, with three objects elided (free_tier_claim, payment_required, catalog) so the shape is readable:
{
"error": "payment_required",
"error_code": "free_tier_exhausted",
"message": "This IP has used 10 of 10 free calls. Claim 10 free parses a month with one wallet signature (no payment, no account): https://nanoparse.app/quickstart#free-tier Or send $0.005 USDC on Base per parse.",
"free_calls_used": 10,
"free_calls_limit": 10,
"docs_url": "https://nanoparse.app/quickstart"
}
Nine top-level keys in the full body: catalog, docs_url, error, error_code, free_calls_limit, free_calls_used, free_tier_claim, message, payment_required.
A client that switches on HTTP status alone cannot separate "you have not paid yet" from "your free allowance is gone" — both are 402. The only discriminator is error_code, which the spec's 402 description does not mention as a field at all. Meanwhile the 429 branch a generated client emits is dead code for this wall.
Which of these refusals are worth retrying is a separate question, worked through in Terminal or Transient. The question here is narrower: the document that a generator reads describes a status code the service does not send.
The prose document gets this right. /llms.txt carries a 402 example whose body includes "error_code": "free_tier_exhausted". That is the unusual shape of the problem: the human-readable document is correct about the status code and the machine-readable document is not.
The free door is in no machine document
The same 402 carries a free_tier_claim object: how many parses a signature is worth, the exact string to sign, the headers to send, the MCP argument names, and where the explanation lives. It is the only field that lets an exhausted caller unblock itself without paying, and it appears in none of the documents a generator reads:
curl -s https://nanoparse.app/openapi.json | grep -c free_tier_claim # 0
curl -s https://nanoparse.app/llms.txt | grep -c free_tier_claim # 0
Zero and zero. It exists in the human quickstart, in the MCP tool descriptions, and in the response body of the refusal that mentions it. So the one document compiled into a client is the one that cannot tell the client the free option exists.
The prose document drifts too
/llms.txt is more accurate about the status code and still not the body. Its sample 402 message reads:
"This IP has used 10 of 10 free calls. Claim 10 more by signing a wallet (no payment), or send $0.005 USDC on Base per parse to continue."
The live message, quoted verbatim above, is longer and different in its middle clause — it names the monthly allowance, the account-less path, and the anchor that explains it. The sample was true when it was written. The refusal was improved and the sample was not, which is the ordinary lifecycle of every document that quotes a response instead of being one.
That is the whole argument in one example: the copy improved, the documentation aged, and only the response knows which is current.
The rule that survives all of this
Read the body. Branch on the fields inside it. A status code is a category; the body is the contract, because the body is generated by the same code path that decides what happened.
Three checks make that concrete, and all three cost nothing:
# 1. how much of the API is typed?
curl -s https://nanoparse.app/openapi.json | jq '.paths | keys | length'
# 2. what does the wall actually answer?
curl -s -X POST https://nanoparse.app/fetch -H 'Content-Type: application/json' -d '{"url":"https://example.com"}' | jq -r '.error_code'
# 3. is the free door in the body?
curl -s -X POST https://nanoparse.app/fetch -H 'Content-Type: application/json' -d '{"url":"https://example.com"}' | jq 'keys'
One path, free_tier_exhausted, and nine keys — none of which a generated client will look for.
A client that reads the body
This is the whole client, in TypeScript, with no generated types and no assumptions from the spec:
const BASE = "https://nanoparse.app";
export async function parse(url: string, paymentSignature?: string) {
const res = await fetch(`${BASE}/fetch`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(paymentSignature ? { "Payment-Signature": paymentSignature } : {}),
},
body: JSON.stringify({ url, sync: true }),
});
const body: any = await res.json();
// Fast path: sync landed inside the window. The spec has no schema for this.
if (res.status === 200 && body.status === "completed") {
return { markdown: body.markdown, litmus: body.litmus };
}
// Both refusals are 402. Only error_code separates them.
if (res.status === 402) {
if (body.error_code === "free_tier_exhausted") {
const claim = body.free_tier_claim;
throw new Error(`quota spent - sign to claim ${claim?.free_parses_per_month}: ${claim?.docs_url}`);
}
throw new Error("payment required - resend with a Payment-Signature header");
}
// Named refusals: terminal, and never charged.
if (res.status === 403 || res.status === 422) {
throw new Error(`${body.error_code ?? body.error}: ${body.message}`);
}
// 202, including sync_timeout: poll the URL the body handed back.
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 1000));
const s: any = await fetch(body.statusUrl).then((r) => r.json());
if (s.status === "completed") return { markdown: s.markdown, litmus: s.litmus };
if (s.status === "failed") throw new Error(`parse failed: ${s.error_code ?? s.status}`);
}
throw new Error(`still processing - job ${body.jobId}`);
}
Three details in there are worth naming, because each one is a place a spec-generated client goes wrong. The 200 branch exists even though the spec has no 200. The 402 branch reads error_code before it decides anything. And body.statusUrl is used as an opaque string, which is the only treatment the spec supports.
The price is the part that is stable and it is stable in one place: $0.005 per successful parse, and a refusal settles nothing. That number is in the spec's price block, on the pricing page, and in the 402 body, and the three agree because they are generated from one constant rather than copied. Everything else in this post is an example of what happens when the same fact is copied instead.
What the measurement is for
A spec that describes one of five routes is not a lie, it is a scope decision someone made once. The damage is quiet: the generated client compiles, the slow path works, the fast path throws, and the quota wall lands in the branch labelled "payment required".
The fix at the call site is one rule — treat the response as the source of truth and the document as a hint — and it holds whether the document is current or six weeks stale, which is the only kind of rule worth adopting. /quickstart and /llms.txt remain the right places to learn the endpoint, /openapi.json remains the right place to get the price block, and the refusal body remains the only place the endpoint describes itself in the present tense.
The MCP surface at nanoparse.app/mcp has the same property from the other direction: two tools, nanoparse_fetch with four arguments and nanoparse_status with one, and the tool result carries the same markdown and the same signal groups as the REST body. Same data, same fields, same absence of a schema that describes every branch. A client that reads the body does not need to know which transport it is on.