Blog

Read Once: What a Parse Job Actually Promises

· NanoParse team

A parse endpoint does not hand back a page. It hands back a job id. The page appears later at a status URL, and the storage holding it is built to be read exactly once — the read is what deletes it.

Two things follow, and both are the client's problem. The status field cannot distinguish "still working" from "already read" or "never existed". And the result is the only copy there is.

Two answers to the same POST

The request body takes a sync flag. The public API reference describes it as: "Wait briefly for the parsed result inline instead of returning immediately."

When the render lands inside that window — nine seconds in the code today, checking the result store every 700 ms — the endpoint holds the connection and answers 200 with the finished envelope. When the render is slower, it stops waiting and answers the queued contract instead:

HTTP/1.1 202 Accepted
{
  "jobId": "9f2c1a3b...",
  "status": "queued",
  "statusUrl": "https://nanoparse.app/status?jobId=9f2c1a3b..."
}

A slow render that falls back to 202 also carries "sync_timeout": true, so the two cases stay separable.

Both responses are success. That matters, because a client that treats a 202 after asking for sync as a failure will re-POST a job that is already rendering — and pay for a second parse to watch the same page render twice. One code path has to accept either body, which is why the envelope's first field is status and not a bare Markdown string.

processing is one string for five situations

Everything below was captured against the live endpoint on 2026-09-28:

GET /status?jobId=9f2a1c3e-4b5d-4e6f-8a9b-0c1d2e3f4a5b
HTTP 200
{"status":"processing"}

GET /status?jobId=9f2c1a3b
HTTP 400
{"error":"invalid jobId"}

GET /status
HTTP 400
{"error":"jobId required"}

That first id was generated for the test. No such job was ever issued, no page was ever queued, and nothing was ever charged. The answer is byte-identical to a job that is mid-render.

So processing covers, without distinguishing between them:

The only "this does not exist" signal a client gets is the 400, and that fires only for ids that are not UUID v4 — which includes the abbreviated id in the docs, the string a developer copies when hand-testing. A UUID that was never issued is not an error. It is processing.

Every one of those answers is HTTP 200, completed and failed included. The status code tells a client nothing; the decision has to come from the body.

Two strings are worth pinning while writing that switch. The OpenAPI schema lists the status enum as queued, processing, completed, error; the endpoint answers failed. Accept both rather than letting a result fall through on a technicality.

The read is what deletes the result

When the status endpoint finds the object, it reads it, deletes it, and then answers. The published API reference says the same thing in one line: "results are single-read — the result object is deleted from R2 immediately after successful retrieval."

Three consequences:

A second poll of a finished job returns processing. That is the same body a poll loop reads as "keep waiting", so the loop does not fail — it waits. A loop whose only exit condition is completed never exits, and the parse it already paid for is gone.

Failures behave the other way. An error blob is left in place for one retry, deliberately: a retry should be able to see what went wrong. Completed results are readable once; failed results are readable again.

An unclaimed result is not racing a clock. In the code, cleanup for a job id object is the read itself; R2 has no per-put time limit, and the job id object carries none. The six-hour window on the product belongs to a different object — the URL-keyed render cache, keyed by a hash of the URL rather than by job id.

The recovery that falls out of that: a client that POSTs and then crashes before it reads has not lost the parse. The result is still sitting under the job id. Resume by polling the stored id. Re-POSTing is a new job and a new charge.

Read it, then write it down

// parse-once.ts — run a parse, survive a restart, never lose the result.
const ENDPOINT = "https://nanoparse.app/fetch";
const DEADLINE_MS = 90_000;
const POLL_MS = 3_000;

type Job = { jobId: string; statusUrl: string };

// Persist the job id BEFORE polling: the only copy of a finished parse
// lives under this id, and the read that fetches it also deletes it.
export async function requestParse(url: string, store: JobStore): Promise<void> {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ url }),
  });
  const body = await res.json();

  if (res.status === 200) return store.save(url, body);   // sync window won
  if (res.status !== 202) throw new Error(body.error_code); // 402, 4xx

  await store.saveJob(url, {
    jobId: body.jobId,
    statusUrl: body.statusUrl,
  });
}

// Resumable: call it again with the same stored job after a crash.
export async function collect(job: Job, store: JobStore, url: string) {
  const giveUpAt = Date.now() + DEADLINE_MS;

  while (Date.now() < giveUpAt) {
    const body = await (await fetch(job.statusUrl)).json();

    if (body.status === "completed") {
      await store.save(url, body);   // write it down first, everything else second
      await store.clearJob(url);
      return body;
    }
    if (body.status === "failed" || body.status === "error") {
      await store.clearJob(url);
      throw new Error(body.error_code);
    }
    await new Promise((r) => setTimeout(r, POLL_MS));
  }

  // A deadline is not a failure. The job id stays; the result may still be waiting.
  throw new Error("parse_deadline");
}

Two details in that shape are the whole point. The job id is persisted before the first poll, so a crash between the request and the result costs nothing. And the result is stored the moment it arrives, before any summarising, indexing, or handing it to a model — because there is no second copy to go back for.

One reader per job id

Nothing in this contract is a lock. Two processes polling the same job id race for the same object: one gets the payload, the other gets processing — permanently, since the winner's read was the delete.

Fan-out across workers on a single job id is therefore not a supported pattern. One reader per job id, writing into your own store for everyone else to read, is.

The hosted MCP server does exactly that internally — it polls the result store, consumes the object on read, and returns the whole envelope in one response. The rule does not change because the polling happens server-side; it only moves out of your code.

When the deadline passes

A deadline means the outcome is unknown, and there are exactly two situations behind it.

  1. The render finished and nothing has read it yet. Keep the job id and poll it again later; the result is still there.
  2. The render failed. Nothing was charged — settlement runs only after a successful render, so a failed, blocked, or truncated parse never bills. Re-POST if the page is worth another attempt; that is a new job, not a double charge.

Which makes the retry policy three branches rather than two: completed means store it, failed means handle the error_code, and a deadline means poll the same id again or re-request — but never discard the job id as if the parse had failed.

The checklist

Cost

One parse per page: $0.005 USDC on Base, paid over x402, with no account, no API key, and no subscription. The free tier is 10 parses per network (subnet), lifetime. Failed or blocked parses are never charged, which is what makes both "poll it again later" and "re-request after a deadline" unremarkable operations rather than decisions a client has to weigh.

Getting started

Point an MCP client at the hosted endpoint:

{
  "mcpServers": {
    "nanoparse": {
      "url": "https://nanoparse.app/mcp"
    }
  }
}

The agent gets nanoparse_fetch, which takes a URL and returns the Markdown plus the full envelope in one call — the job handling described above happens inside the server rather than in the client.

REST clients POST a URL to https://nanoparse.app/fetch and follow the returned statusUrl. Docs, response schemas, and the live demo: nanoparse.app · quickstart · MCP server repo

← All posts NanoParse home