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.
- 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:- 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/filesgives 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 toPOST /v1/batchesonce 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 itsstatus.) - 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-Keyheader withPOST /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.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
Passsource: {"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
- 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_failedbelow). - 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 backurlon each item (query string stripped, so a presigned signature never shows up in a poll response) instead offile_id, which isnullfor these items.items[].error.messagegets the same treatment forurl_fetch_faileditems.- 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_failedrather 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.
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 — pollingGET /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:
batch.update event — ids, status, counts, your echoed metadata, no results inline:
GET /v1/batches/{batch_id} → per-item result_url). The signature is Svix-wire-compatible, so you verify it with the standard svix library.
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: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: badfile_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:
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 inpending 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.