Skip to main content
Process thousands of documents without managing thousands of requests. You upload your files, hand us the list as one batch, and poll a single endpoint until it’s done. Same engine as sync. Same response schema. Same credit billing as the sync API. The only difference is how you submit and how you fetch results.

When to use batch

Use async batch when:
  • You’re processing more than ~50 documents in a single workflow.
  • You’d otherwise have to write a retry loop around POST /v1/parse/file.
  • The documents are large (long PDFs) and you’d rather poll than hold a connection open.
Stick with sync when:
  • You need the result inline with the request (interactive agent loops, screenshots).
  • The document is small (under 10 pages) and a multi-second response is fine.
  • You’re already at human-perceptible latency on the user side.

How it works

Two ways to hand us documents. Use whichever fits how your data already lives. By upload — you control the bytes end to end:
By URL — skip steps 1-2 entirely; we fetch:
See Submitting by URL below — best when the files already live behind a URL (your storage, a customer’s presigned link). For PHI and other sensitive workloads, this is also the path where source documents are never written to our storage; only the extraction result is retained (same 3-day window as uploads). A few properties worth knowing before you build:
  • Same input formats as sync. PDF, PPTX, DOCX, and raster images (PNG, JPEG, WebP, TIFF, HEIC/HEIF, BMP). Images bill one page each; multi-frame TIFFs bill one page per frame.
  • Uploads go straight to storage. POST /v1/files gives you a presigned URL and you PUT the file bytes to it directly, so uploads run at your connection’s full speed.
  • No separate “confirm upload” step. Hand the file_ids straight to POST /v1/batches once your PUTs return; a finished upload is always accepted, even if you submit immediately. (Want to confirm a single upload landed first? GET /v1/files/{file_id} returns its status.)
  • 3-day retention. Uploaded inputs and result blobs both auto-expire after 3 days. The clock starts at upload (for inputs) or at item completion (for results). Need longer? Email hello@hanji.dev. We’re happy to bump it to a week or more on request. URL-sourced items skip the input side of this entirely — see below.
  • Retries are safe. Send an Idempotency-Key header with POST /v1/batches. Retrying with the same key within 3 days returns the same batch instead of creating a duplicate, so you’re never double-billed.

End-to-end example

This loops over a local directory, uploads everything in parallel, submits one batch, polls until all items reach a terminal state, and writes each result to disk.
An item’s result JSON is exactly what the sync endpoint would have returned for that document, including the usage object (pages, credits, credits_per_page) on successfully billed items. DOCX/PPTX items also include pdf_rendition_url; that link lasts as long as the result JSON (3 days), so copy the PDF when you fetch the item.

Submitting by URL

Pass source: {"type": "urls", "urls": [...]} instead of file_ids and skip the upload step — no POST /v1/files, no PUT. Each URL becomes one item; we fetch it when that item starts processing, not when you submit the batch, so a slow or large download doesn’t block the submit call. (Per-file limits are the same as elsewhere: 150 MB / 2,000 pages.)
curl
Poll and fetch results exactly as you would for uploaded files — everything downstream of submission (status lifecycle, webhooks, pagination, item errors) is identical. A few things specific to URL input:
  • Public and presigned URLs both work. A presigned URL’s signature lives in the query string; we use the URL exactly as given, so pass it through unmodified. Give it enough TTL to survive queue time — if the signature expires before we fetch it, the fetch fails the same way an already-expired URL would (see url_fetch_failed below).
  • Source bytes are never written to our storage. The document is held only for the length of the extraction; only the result JSON is persisted (3-day retention, same as the upload path). If keeping PHI or other sensitive source documents off our storage is a requirement, this is the path to use.
  • HTTP(S) only, up to 100 URLs per batch (upload batches allow up to 10,000 file_ids).
  • GET /v1/batches/{id} echoes back url on each item (query string stripped, so a presigned signature never shows up in a poll response) instead of file_id, which is null for these items. items[].error.message gets the same treatment for url_fetch_failed items.
  • Failed fetches aren’t retried when the failure is permanent — a bad URL, a 403/404, or an expired presigned signature all fail the item immediately as url_fetch_failed rather than retrying a failure that can’t succeed. Transient failures (5xx, timeouts) retry exactly like any other item.

Batch options

POST /v1/batches accepts the same extraction options as sync, applied to every item in the batch, plus a metadata object for tying the batch back to your own system: On the upload side, POST /v1/files takes a filename and a required size_bytes, plus an optional content_type. After the bytes land, GET /v1/files/{file_id} reports a sha256 of what arrived, so you can verify the upload byte for byte.

Polling cursor

GET /v1/batches/{id} returns items in pages. To only fetch what changed since your last call, pass back the next_cursor from the previous response as ?cursor=.... Cursors are opaque; treat them as strings.
A typical client polls every 2-5 seconds without a cursor (always seeing the full current state) until terminal, then walks the cursor to drain the final list. For very large batches (10k+ items), pass a cursor so you only get the items that changed. limit defaults to 100 items per page and caps at 500. Beyond status, result_url, and error, each item carries file_id or url (whichever source type you submitted — the other is null), position (its index in the source list you submitted), page_count (the pages it billed, once parsed), attempts, and started_at / completed_at / updated_at timestamps. To see all your batches, GET /v1/batches lists them newest first with the same cursor pattern, an optional ?status= filter, and a limit of up to 200 (default 50).

Completion webhooks

Instead of polling, you can have us POST you a signed event the moment a batch finishes. This is the recommended production integration path — polling GET /v1/batches/{id} is limited to 200 requests per second per organization and may return 429 Too Many Requests when that limit is exceeded. Register an endpoint in the dashboard (you get a whsec_… signing secret), then opt any batch in with a webhook field:
When the batch reaches a terminal status we POST a thin batch.update event — ids, status, counts, your echoed metadata, no results inline:
Then fetch results exactly as you would after polling (GET /v1/batches/{batch_id} → per-item result_url). The signature is Svix-wire-compatible, so you verify it with the standard svix library.
status is one of completed, partially_failed, failed, cancelled. Treat both completed and partially_failed as “results are ready” — partially_failed means some items succeeded and some did not (failed or were cancelled; counts.failed may be 0), not a total failure. A handler that only branches on completed will silently drop the successful results of partially-failed batches.
Webhooks are opt-in per batch (omit the field and none fire), deliver at-least-once (dedup on the svix-id header), and retry for ~27 hours before giving up. The full setup — handler examples in Python and TypeScript, secret rotation, the test ping, direct (unsigned) mode, and the deliveries dashboard — is in the Webhooks guide.

Status lifecycle

A batch moves through:
An item moves through:
partially_failed means at least one item succeeded and at least one did not (failed or was cancelled); treat it the same as completed and inspect items[].error.code for any failures. Items aren’t retried for terminal errors: if a document is unsupported (unsupported_input), too large (page_limit_exceeded / document_too_large), or a URL couldn’t be fetched (url_fetch_failed), the same item won’t succeed on a re-poll. Fix the inputs and submit a new batch.

Errors

Batch errors come in two shapes, both documented here.

Request errors

The call itself was rejected: bad file_id, file not uploaded, result not ready. error is a flat string code you can switch on:

Item errors

These are item failures: the batch call itself succeeded, but a document failed during processing. The shape is nested. item.error is an object, distinct from the flat request-error string above:
When item.status == "failed", item.error.code is one of:

Cancelling

POST /v1/batches/{id}/cancel flips remaining pending items to cancelled. Items already running finish on their own; we don’t kill in-flight work. Cancelled items are not billed. The batch’s terminal status will be cancelled if no items succeeded; partially_failed or completed if some did.

Concurrency

By default we process up to 8 items concurrently per customer, so one large batch can’t starve other customers. Email us if you need higher concurrency for sustained workloads. The cap is server-side and nothing you manage: items simply wait in pending until capacity frees up.

Limits

Next steps

Completion webhooks

Stop polling. Get a signed batch.update event the moment a batch finishes.

Extract specific fields

Need structured fields instead of full-document chunks? Define a JSON schema and get cited values back.