# batch.update webhook Source: https://docs.hanji.dev/api-reference/batch-update-webhook openapi.json webhook batch.update Delivered when an async batch reaches a terminal status. # Cancel a batch Source: https://docs.hanji.dev/api-reference/v1/cancel-a-batch /openapi.json post /v1/batches/{batch_id}/cancel Flip the batch's remaining `pending` items to `cancelled`. Items already `running` finish on their own. Cancelled items are not billed. # Check upload status Source: https://docs.hanji.dev/api-reference/v1/check-upload-status /openapi.json get /v1/files/{file_id} Fetch a file's metadata and upload `status`. Useful to confirm a presigned PUT landed before submitting a batch, though `POST /v1/batches` re-checks S3 on submit, so this call is optional. # Create a batch Source: https://docs.hanji.dev/api-reference/v1/create-a-batch /openapi.json post /v1/batches Submit uploaded files as one batch job. Returns immediately with `status: "pending"`; poll `GET /v1/batches/{batch_id}` for per-item progress. Pass an `Idempotency-Key` header to make retries safe: the same key within 3 days returns the same batch instead of creating a duplicate. # Download an item result Source: https://docs.hanji.dev/api-reference/v1/download-an-item-result /openapi.json get /v1/batches/{batch_id}/items/{item_id}/result Redirects (`302`) to a presigned S3 GET for the item's result JSON, so follow redirects. Results expire 3 days after item completion. # Fill a schema from a document by URL Source: https://docs.hanji.dev/api-reference/v1/fill-a-schema-from-a-document-by-url /openapi.json post /v1/extract/schema Fill an arbitrary user JSON schema from a document URL, with citations. # Fill a schema from an uploaded document Source: https://docs.hanji.dev/api-reference/v1/fill-a-schema-from-an-uploaded-document /openapi.json post /v1/extract/schema/file Schema extraction from a ``multipart/form-data`` upload (PHI-capable). # List your batches Source: https://docs.hanji.dev/api-reference/v1/list-your-batches /openapi.json get /v1/batches Your account's batches, newest first, with cursor pagination and an optional `status` filter. # Parse a document by URL Source: https://docs.hanji.dev/api-reference/v1/parse-a-document-by-url /openapi.json post /v1/parse Download the document at `url`, parse it, and return text, table, and image chunks in reading order. Every chunk carries its page number and bounding box. Billed per page. # Parse an uploaded document Source: https://docs.hanji.dev/api-reference/v1/parse-an-uploaded-document /openapi.json post /v1/parse/file Accept a document as ``multipart/form-data`` and return chunks. Mirrors ``POST /v1/parse`` in every way except how the document arrives: same auth, same billing, same response schema, same error mapping. The filename is advisory; magic bytes drive kind detection. ``POST /v1/extract/file`` is a compatibility alias. # Poll a batch Source: https://docs.hanji.dev/api-reference/v1/poll-a-batch /openapi.json get /v1/batches/{batch_id} Batch status plus paginated per-item statuses, ordered by last update. Pass back `next_cursor` as `cursor` to fetch only the items that changed since your previous poll. # Register a file for batch upload Source: https://docs.hanji.dev/api-reference/v1/register-a-file-for-batch-upload /openapi.json post /v1/files Reserve a file slot and get back a `file_id` plus a presigned S3 PUT URL. PUT the file bytes to that URL (they go directly to S3, never through this API), then reference the `file_id` in `POST /v1/batches`. Uploads expire after 3 days. # Batch processing Source: https://docs.hanji.dev/guides/batch Async batch extraction. Upload many files, submit one batch, poll for results. 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](/introduction#quickstart)** 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: ``` 1. POST /v1/files (per file) → file_id + upload URL 2. PUT (per file) → upload the file bytes 3. POST /v1/batches (once) → batch_id, status="pending" 4. GET /v1/batches/{id} (poll loop) → per-item status as items finish 5. GET /v1/batches/{id}/items/{id}/result → 302 redirect to the result JSON ``` **By URL** — skip steps 1-2 entirely; we fetch: ``` 1. POST /v1/batches (once) → {"source": {"type": "urls", "urls": [...]}} 2. GET /v1/batches/{id} (poll loop) → per-item status as items finish 3. GET /v1/batches/{id}/items/{id}/result → 302 redirect to the result JSON ``` See **[Submitting by URL](#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_id`s 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](mailto: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. ```python python theme={null} import asyncio, os, time from pathlib import Path import httpx API = "https://api.hanji.dev" HEADERS = {"X-API-KEY": os.environ["HANJI_API_KEY"]} async def upload(client: httpx.AsyncClient, path: Path) -> str: meta = (await client.post( f"{API}/v1/files", json={"filename": path.name, "size_bytes": path.stat().st_size}, )).json() async with httpx.AsyncClient() as raw: await raw.put( meta["upload"]["url"], content=path.read_bytes(), headers={"Content-Type": "application/octet-stream"}, timeout=600, ) return meta["id"] async def main(input_dir: str, output_dir: str) -> None: files = sorted(p for p in Path(input_dir).rglob("*.pdf") if p.is_file()) Path(output_dir).mkdir(parents=True, exist_ok=True) async with httpx.AsyncClient(headers=HEADERS, timeout=60) as client: sem = asyncio.Semaphore(10) async def _bound(p): async with sem: return await upload(client, p) file_ids = await asyncio.gather(*[_bound(p) for p in files]) batch = (await client.post( f"{API}/v1/batches", headers={"Idempotency-Key": f"my-run-{int(time.time())}"}, json={"source": {"type": "files", "file_ids": file_ids}}, )).json() print("submitted batch", batch["id"], "with", batch["total_items"], "items") while True: state = (await client.get(f"{API}/v1/batches/{batch['id']}")).json() print(state["status"], state["counts"]) if state["status"] in {"completed", "partially_failed", "failed", "cancelled", "expired"}: break await asyncio.sleep(3) for item in state["items"]: if item["status"] != "succeeded": continue r = await client.get( f"{API}{item['result_url']}", follow_redirects=True ) (Path(output_dir) / f"{item['id']}.json").write_bytes(r.content) asyncio.run(main("./pdfs", "./extracted")) ``` ```javascript javascript theme={null} import { readFile, stat, mkdir, writeFile, readdir } from "node:fs/promises"; import { join } from "node:path"; const API = "https://api.hanji.dev"; const HEADERS = { "X-API-KEY": process.env.HANJI_API_KEY }; async function upload(path) { const size = (await stat(path)).size; const meta = await (await fetch(`${API}/v1/files`, { method: "POST", headers: { ...HEADERS, "Content-Type": "application/json" }, body: JSON.stringify({ filename: path.split("/").pop(), size_bytes: size }), })).json(); await fetch(meta.upload.url, { method: "PUT", body: await readFile(path) }); return meta.id; } async function main(inputDir, outputDir) { await mkdir(outputDir, { recursive: true }); const entries = await readdir(inputDir); const pdfs = entries.filter(f => f.endsWith(".pdf")).map(f => join(inputDir, f)); const fileIds = await Promise.all(pdfs.map(upload)); const batch = await (await fetch(`${API}/v1/batches`, { method: "POST", headers: { ...HEADERS, "Content-Type": "application/json", "Idempotency-Key": `my-run-${Date.now()}` }, body: JSON.stringify({ source: { type: "files", file_ids: fileIds } }), })).json(); console.log("submitted batch", batch.id, "with", batch.total_items, "items"); let state = batch; const terminal = new Set(["completed", "partially_failed", "failed", "cancelled", "expired"]); while (!terminal.has(state.status)) { await new Promise(r => setTimeout(r, 3000)); state = await (await fetch(`${API}/v1/batches/${batch.id}`, { headers: HEADERS })).json(); console.log(state.status, state.counts); } for (const item of state.items) { if (item.status !== "succeeded") continue; const r = await fetch(`${API}${item.result_url}`, { headers: HEADERS, redirect: "follow" }); await writeFile(join(outputDir, `${item.id}.json`), Buffer.from(await r.arrayBuffer())); } } main("./pdfs", "./extracted"); ``` ```bash curl theme={null} # 1. Reserve a slot, get a presigned PUT URL. curl -sS -X POST https://api.hanji.dev/v1/files \ -H "X-API-KEY: $HANJI_API_KEY" \ -H "Content-Type: application/json" \ -d '{"filename": "chart.pdf", "size_bytes": 18342}' # → { "id": "file_xxx", "upload": { "url": "https://...?X-Amz-Signature=...", ... } } # 2. Upload the file bytes to the presigned URL. curl -X PUT --data-binary @chart.pdf "$UPLOAD_URL" # 3. Submit the batch (one or many file_ids). curl -sS -X POST https://api.hanji.dev/v1/batches \ -H "X-API-KEY: $HANJI_API_KEY" \ -H "Idempotency-Key: my-run-2026-05-07" \ -H "Content-Type: application/json" \ -d '{"source": {"type": "files", "file_ids": ["file_xxx"]}}' # → { "id": "batch_yyy", "status": "pending", "total_items": 1 } # 4. Poll until terminal. curl -sS "https://api.hanji.dev/v1/batches/batch_yyy" -H "X-API-KEY: $HANJI_API_KEY" # 5. Fetch a successful item's result JSON. curl -L "https://api.hanji.dev/v1/batches/batch_yyy/items/item_zzz/result" \ -H "X-API-KEY: $HANJI_API_KEY" ``` An item's result JSON is exactly what the sync endpoint would have returned for that document, including the [`usage`](/introduction#pricing-and-credits) object (`pages`, `credits`, `credits_per_page`) on successfully billed items. DOCX/PPTX items also include [`pdf_rendition_url`](/introduction#pdf-rendition); 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.) ```bash curl theme={null} curl -sS -X POST https://api.hanji.dev/v1/batches \ -H "X-API-KEY: $HANJI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "source": { "type": "urls", "urls": [ "https://example.com/report.pdf", "https://your-bucket.s3.amazonaws.com/inbox/report.pdf?X-Amz-Signature=..." ] } }' # → { "id": "batch_yyy", "status": "pending", "total_items": 2 } ``` 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: | Field | Type | Default | Description | | --------------------- | --------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `source` | object | required | `{"type": "files", "file_ids": [...]}` (up to 10,000 ids), or `{"type": "urls", "urls": [...]}` (up to 100) — see [Submitting by URL](#submitting-by-url). | | `extract_text` | boolean | `true` | Set `false` to skip text spans in every item. | | `extract_images` | boolean | `true` | Set `false` to skip figure extraction in every item. | | `table_output_format` | `"markdown" \| "cell_grid"` | `"markdown"` | Same as sync: `"cell_grid"` returns a true bounding box for every table cell in each item's result. | | `chunking` | `"none" \| "semantic"` | `"none"` | Same as sync: `"semantic"` adds RAG-ready [`segments`](/introduction#chunking) to every item's result. | | `chunk_size` | integer | `1000` | Same as sync: target segment size in characters (±25% band); validated 200–8000 only when chunking is enabled. | | `metadata` | object | `null` | Arbitrary JSON stored with the batch and echoed back on every poll and in the batch list. Put your own run ids, tenant ids, or job references here. | 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. ```bash theme={null} # First page curl ".../v1/batches/batch_yyy?limit=100" # → { "items": [...], "next_cursor": "MjAyNi0wNS0wN1QxMjowMDowMC..." } # Next page (or "what's changed since I last polled") curl ".../v1/batches/batch_yyy?limit=100&cursor=MjAyNi0wNS0wN1QxMjowMDowMC..." ``` 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](https://hanji.dev/dashboard/webhooks) (you get a `whsec_…` signing secret), then opt any batch in with a `webhook` field: ```bash theme={null} curl -sS -X POST https://api.hanji.dev/v1/batches \ -H "X-API-KEY: $HANJI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "source": { "type": "files", "file_ids": ["file_xxx"] }, "webhook": { "mode": "svix" } }' ``` When the batch reaches a terminal status we POST a thin `batch.update` event — ids, `status`, `counts`, your echoed `metadata`, no results inline: ```json theme={null} { "type": "batch.update", "batch_id": "batch_yyy", "status": "partially_failed", "counts": { "pending": 0, "running": 0, "succeeded": 4, "failed": 1, "cancelled": 0 }, "total_items": 5, "metadata": { "prior_auth_id": "PA-1234" }, "completed_at": "2026-08-05T12:34:56Z", "results_expires_at": "2026-08-08T12:34:56Z" } ``` Then fetch results exactly as you would after polling (`GET /v1/batches/{batch_id}` → per-item `result_url`). The signature is [Svix](https://www.svix.com/)-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](/guides/webhooks)**. ## Status lifecycle A **batch** moves through: ``` pending → running → completed | partially_failed | failed | cancelled | expired ``` An **item** moves through: ``` pending → running → succeeded | failed | cancelled ``` `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: ```json theme={null} { "error": "file_not_uploaded", "file_ids": ["file_abc123"] } ``` | `error` | Status | Endpoint | Meaning + fix | | ------------------- | ------ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `file_not_found` | `404` | `POST /v1/batches` | A `file_id` doesn't exist for your account, or its 3-day TTL lapsed. The offending ids come back in `file_ids`. | | `file_not_uploaded` | `409` | `POST /v1/batches` | A `file_id`'s bytes aren't in storage. A *finished* upload won't hit this: it means the PUT never completed or the upload expired. Re-upload the ids in `file_ids` and resubmit. | | `result_not_ready` | `409` | `GET …/items/{id}/result` | The item hasn't reached `succeeded` yet (the `status` field tells you the current state). Keep polling `GET /v1/batches/{id}`. | ### 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: ```json theme={null} { "error": { "code": "unsupported_input", "message": "…" } } ``` When `item.status == "failed"`, `item.error.code` is one of: | Code | Meaning | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `payment_required` | Customer is out of credits. The whole batch will hit this once it triggers. Top up and re-submit a fresh batch. | | `unsupported_input` | The file is empty, or isn't a supported format. We check the file itself, not its name. | | `document_too_large` | Source bigger than 150 MB. | | `page_limit_exceeded` | Source has more than 2,000 pages. | | `extraction_failed` | Generic extraction error (corrupted PDF, missing fonts, etc.). | | `ocr_provider_error` | Transient extraction outage. The item was retried automatically before failing; re-submit it in a new batch. | | `upload_missing` | The file was never uploaded, or the 3-day retention window passed before processing started. Re-upload and re-submit. | | `url_fetch_failed` | URL-sourced item only. The URL was invalid, forbidden, not found, or a presigned signature had expired before we fetched it. Not retried — fix the URL (or reissue the presigned link with more TTL) and re-submit. | | `internal_error` | Unexpected server error. Re-submit the item; contact support if it repeats. | ## 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 | Limit | Default | | ------------------------------------------------------------ | ----------------------------------------------------------------------------- | | Max files per batch (`file_ids`) | 10,000 | | Max URLs per batch (`urls`) | 100 | | Max page count per file | 1,000 | | Max file size | 150 MB | | Result + upload retention | 3 days (input clock starts at upload; output clock starts at item completion) | | Idempotency key dedup window | 3 days | | Submission rate limit (`POST /v1/batches`, `POST /v1/files`) | 60/min per key | ## Next steps Stop polling. Get a signed `batch.update` event the moment a batch finishes. Need structured fields instead of full-document chunks? Define a JSON schema and get cited values back. # Schema extraction Source: https://docs.hanji.dev/guides/schema-extraction Fill an arbitrary JSON schema from a document, with a per-field citation for every value. Tell us which fields you want (`invoice_number`, `total`, `line_items[]`) and get back **just those fields, filled from the document**. Every value arrives with proof: the page it came from, a bounding box you can highlight, and the verbatim source text. That's the difference from [parsing](/introduction): `POST /v1/parse` gives you the whole document as structured chunks. Schema extraction reads the document *for* you and answers in exactly the shape your application expects. ## Request `POST /v1/extract/schema` (JSON body with a `url`) or `POST /v1/extract/schema/file` (multipart upload; required for PHI keys). The document can be any supported input: PDF, PPTX, DOCX, or an image (PNG, JPEG, WebP, TIFF, HEIC/HEIF, BMP). The `schema` is standard JSON Schema, and it is the whole interface: the fields you define are the request, and their descriptions are instructions the extractor follows. A one-line description is the cheapest accuracy lever you have. Say what the field looks like on the page, not just what it means: ```json theme={null} { "invoice_number": { "type": "string", "description": "Invoice ID as printed." }, "total": { "type": "number", "description": "Amount due, in dollars." }, "line_items": { "type": "array", "items": { "type": "object", "properties": { "description": { "type": "string" }, "amount": { "type": "number" } }} } } ``` **Supported subset:** `string` / `number` / `integer` / `boolean`, `object` (with `properties`), `array` (with `items`), plus `enum` and `description` on leaves. Nesting up to **5 levels**. Wide schemas are capped (a few hundred fields) and return `422` with a "split your schema" message, so keep schemas focused on one document type. Use `enum` for fields with a known set of values (status codes, document types, states). It stops casing and phrasing drift: you'll get `"approved"` every time instead of `"Approved"`, `"APPROVED"`, and `"approved ✓"` across documents. ### Request options The schema is usually all you send. Four more fields tune a run: | Field | Type | Default | What it does | | ------------------ | ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `strict` | boolean | `true` | Controls what happens to a value whose citation can't be verified. See [Response](#response). | | `auto_schema` | boolean | `false` | No schema yet? Omit `schema` and set this to `true`: we design a flat schema from the document, fill it, and return the schema we used as `generated_schema`. Good for exploring a new document type before you commit to a shape. | | `extract_images` | boolean | `true` | Include figures from the parse stage in the extraction context. Set `false` to extract from text and tables only. | | `include_ocr_text` | boolean | `false` | `true` additionally returns [`ocr_text`](#response): the whole document as a single text string, in reading order. The default response is unchanged. | ## Response `values` is your schema, filled in, with `null` where the document genuinely lacks a field. `evidence` is a parallel map keyed by **field path**: each value's source page, box, verified text, and a `confidence` score. ```json theme={null} { "values": { "invoice_number": "INV-42", "total": 100.0, "line_items": [{ "description": "Widget A", "amount": 80.0 }] }, "evidence": { "invoice_number": [{ "page": 1, "bbox": [120, 80, 240, 96], "text": "Invoice #INV-42", "confidence": 0.99 }], "total": [{ "page": 1, "bbox": [410, 540, 470, 556], "text": "Total due $100.00", "confidence": 1.0 }], "line_items[0].amount":[{ "page": 1, "bbox": [410, 300, 470, 316], "text": "Widget A ... 80.00", "confidence": 0.98 }] }, "ungrounded_fields": [], "ocr_text": "ACME Supply Co.\nInvoice #INV-42\nWidget A ... 80.00\nTotal due $100.00", "page_count": 1, "usage": { "pages": 1, "credits": 4.0, "credits_per_page": 4.0 } } ``` Three fields do the heavy lifting: * **`strict`** (default `true`): a non-null value whose quote can't be verified against the page is **nulled out** and listed in `ungrounded_fields`. This is the safe default: a fabricated value never reaches you. Set `strict: false` to keep such values but still see them flagged. * **`bbox`**: `[x0, y0, x1, y1]` normalized to **0-1000**, page-relative, top-left origin. Overlay it directly on a page render without fetching page dimensions. It can be `null` when the cited text has no box to point at (converted office formats like DOCX and PPTX don't always carry one). Handle that case before overlaying. * **`confidence`**: `0` to `1`, scored on the *hardest-to-read character* of the cited text, so one shaky digit lowers the whole score instead of hiding behind an otherwise clear value. It's `null` when a citation doesn't have a score. Route low-confidence values to human review. Some citations may also carry `needs_review: true` and a `suggested_value`: a hint that an identifier was hard to read and is worth confirming. The value in `values` is never overwritten. These fields are additive; treat their absence as `false` / `null`. On `auto_schema` runs the response also includes `generated_schema`: the schema we designed and filled. Save it and pass it as `schema` on later calls to lock the shape in. ## Example ```python python theme={null} import json, os, httpx API = "https://api.hanji.dev" schema = { "invoice_number": {"type": "string", "description": "Invoice ID as printed."}, "total": {"type": "number", "description": "Amount due, in dollars."}, } with open("invoice.pdf", "rb") as f: r = httpx.post( f"{API}/v1/extract/schema/file", headers={"X-API-KEY": os.environ["HANJI_API_KEY"]}, files={"file": ("invoice.pdf", f, "application/pdf")}, data={"schema": json.dumps(schema)}, timeout=300, ) r.raise_for_status() out = r.json() print(out["values"]) # {'invoice_number': 'INV-42', 'total': 100.0} print(out["evidence"]["total"][0]) # {'page': 1, 'bbox': [...], 'text': 'Total due $100.00'} ``` ```bash curl theme={null} curl -X POST https://api.hanji.dev/v1/extract/schema/file \ -H "X-API-KEY: $HANJI_API_KEY" \ -F "file=@invoice.pdf" \ -F 'schema={"invoice_number":{"type":"string"},"total":{"type":"number"}}' ``` ## Billing **4 credits per page** — \$12 per 1,000 pages, all-in. The parse is included, so you're not paying the 1-credit parsing rate on top. Billed responses carry a [`usage`](/introduction#pricing-and-credits) object with the exact `pages`, `credits`, and `credits_per_page` charged. See [Pricing](https://hanji.dev/pricing). ## Next steps Need every chunk, table, and figure instead of specific fields? Start with the parse quickstart. Processing thousands of documents? Use the async batch lane and keep the same response schema. # Webhooks Source: https://docs.hanji.dev/guides/webhooks Get a signed event when an async batch finishes, instead of polling. Register an endpoint once, and we'll POST a signed `batch.update` event to it the moment a batch reaches a terminal state. This is the production alternative to polling `GET /v1/batches/{id}` in a loop. The signature scheme is **wire-compatible with [Svix](https://www.svix.com/)**, so if you already verify Svix or Reducto webhooks, your verification code works here unchanged — you verify with the standard open-source `svix` library. Prefer webhooks for production workflows. Polling remains available, but `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. ## Setup 1. In the [dashboard](https://hanji.dev/dashboard/webhooks), add an endpoint (an HTTPS URL). We show you a signing secret (`whsec_…`) once at creation — store it as a secret in your app. You can re-reveal or rotate it anytime from the dashboard. 2. On any batch you want notified, pass `webhook: { "mode": "svix" }`: ```bash theme={null} curl -X POST https://api.hanji.dev/v1/batches \ -H "X-API-KEY: $HANJI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "source": { "type": "files", "file_ids": ["file_abc"] }, "metadata": { "prior_auth_id": "PA-1234" }, "webhook": { "mode": "svix" } }' ``` That's it. Webhooks are **opt-in per batch** — a batch with no `webhook` field never fires one, even if you have endpoints registered. When you do opt in, every enabled endpoint on your org receives the event. ## The event When the batch finishes we POST this body (and only this — no results inline): ```json theme={null} { "type": "batch.update", "batch_id": "batch_abc", "status": "partially_failed", "counts": { "pending": 0, "running": 0, "succeeded": 4, "failed": 1, "cancelled": 0 }, "total_items": 5, "metadata": { "prior_auth_id": "PA-1234" }, "completed_at": "2026-08-05T12:34:56Z", "results_expires_at": "2026-08-08T12:34:56Z" } ``` The payload is deliberately thin: ids, status, counts, and your echoed `metadata`. To get the actual extraction, call `GET /v1/batches/{batch_id}` and fetch each item's result. `results_expires_at` is the deadline after which those results are deleted (3-day default retention) — fetch before then. Need longer? Email [hello@hanji.dev](mailto:hello@hanji.dev). **`status` has four terminal values, not two.** Unlike a single-document webhook, a batch can partially succeed: | `status` | Meaning | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `completed` | Every item succeeded. | | `partially_failed` | **Terminal and done** — some items succeeded and some did **not** (failed **or** were cancelled). Check `counts` for the breakdown; `failed` may be `0` if the non-successes were cancellations. Results for the succeeded items are ready. | | `failed` | No item succeeded and there were no cancellations. | | `cancelled` | No item succeeded and at least one item was cancelled (may also include failures). | If you're porting a handler that branches `if status == "Completed"`, it will silently ignore `partially_failed` batches and drop the results of every document that *did* succeed. Treat **both** `completed` and `partially_failed` as "results are ready — inspect `counts` for per-status breakdown." ## Handling the webhook Verify the signature, branch on `status`, return `2xx` fast, and do the real work after. Headers: `svix-id`, `svix-timestamp`, `svix-signature` (the Standard-Webhooks aliases `webhook-id` / `webhook-timestamp` / `webhook-signature` are also sent). ```python python theme={null} import os from flask import Flask, request, jsonify from svix.webhooks import Webhook, WebhookVerificationError app = Flask(__name__) WEBHOOK_SECRET = os.environ["HANJI_WEBHOOK_SECRET"] # whsec_… from the dashboard @app.post("/webhooks/hanji") def handle(): # Pass the RAW body and the untouched headers object — signature # verification is sensitive to any change to the body bytes. try: payload = Webhook(WEBHOOK_SECRET).verify(request.get_data(), request.headers) except WebhookVerificationError: return jsonify(error="invalid signature"), 401 # Ping events are health checks — acknowledge and stop. if payload["type"] == "webhook.ping": return jsonify(received=True), 200 svix_id = request.headers["svix-id"] # stable dedup key across retries status = payload["status"] # `completed` AND `partially_failed` both mean "results are ready". if status in ("completed", "partially_failed"): enqueue_fetch(payload["batch_id"], idempotency_key=svix_id) elif status in ("failed", "cancelled"): # No results to fetch — record the terminal outcome in your system. mark_terminal(payload["batch_id"], status=status, idempotency_key=svix_id) return jsonify(received=True), 200 ``` ```typescript typescript theme={null} import express from "express"; import { Webhook } from "svix"; const app = express(); const WEBHOOK_SECRET = process.env.HANJI_WEBHOOK_SECRET!; // whsec_… from the dashboard app.post( "/webhooks/hanji", express.raw({ type: "application/json" }), (req, res) => { let payload: any; try { payload = new Webhook(WEBHOOK_SECRET).verify(req.body, { "svix-id": req.header("svix-id")!, "svix-timestamp": req.header("svix-timestamp")!, "svix-signature": req.header("svix-signature")!, }); } catch { return res.status(401).json({ error: "invalid signature" }); } if (payload.type === "webhook.ping") { return res.status(200).json({ received: true }); } const svixId = req.header("svix-id")!; // stable dedup key across retries // `completed` AND `partially_failed` both mean "results are ready". if (["completed", "partially_failed"].includes(payload.status)) { enqueueFetch(payload.batch_id, svixId); } else if (["failed", "cancelled"].includes(payload.status)) { // No results to fetch — record the terminal outcome in your system. markTerminal(payload.batch_id, payload.status, svixId); } return res.status(200).json({ received: true }); }, ); ``` ### Coming from Reducto? Your `svix` verification call ports **unchanged**. Two lines in the handler *after* verification change: * The id field is `batch_id`, not `job_id`. * Statuses are lowercase (`completed` / `partially_failed` / `failed` / `cancelled`), and you fetch with `GET /v1/batches/{batch_id}`. Remember `partially_failed` — Reducto's binary `Completed`/`Failed` has no equivalent. ## Delivery, retries, idempotency * **Return `2xx` within 15 seconds.** Do slow work asynchronously; we only read the status code. * **Retries.** A non-`2xx` (or a timeout) is retried on an escalating schedule — 8 attempts over \~27 hours — so a receiver that's briefly down still gets the event. After the ladder is exhausted the delivery is marked failed and is resendable from the dashboard. * **Be idempotent.** `svix-id` is stable across every retry of one event — use it as your dedup key. At-least-once delivery means you may occasionally see the same event twice. * **Redirects are not followed.** Point the endpoint at the final URL. ## Testing * **Send a test ping** from the dashboard — it delivers a `{"type": "webhook.ping"}` event so you can confirm your endpoint and signature verification work before wiring up a real batch. Short-circuit on the `webhook.ping` type as shown above. * **Inspect payloads** with a throwaway endpoint from [webhook.site](https://webhook.site) while you build. * **Watch deliveries** in the dashboard: every attempt, its status code, and a per-row **Resend** (available while the batch's results are still within their 3-day window). ## Direct mode (prototyping) For quick tests or a dynamic per-request destination, pass the URL **inline** instead of registering an endpoint. Direct deliveries are **unsigned** — best for prototyping or internal integrations. Use signed registered endpoints (above) for production. ```bash theme={null} curl -X POST https://api.hanji.dev/v1/batches \ -H "X-API-KEY: $HANJI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "source": { "type": "files", "file_ids": ["file_abc"] }, "metadata": { "secret": "your-shared-token", "prior_auth_id": "PA-1234" }, "webhook": { "mode": "direct", "url": "https://your-app.com/webhook" } }' ``` The URL must be HTTPS and resolve to a public host. The event body is identical to the signed one above — same four terminal statuses (including `partially_failed`). Since there's no signature, authenticate by round-tripping a secret token through `metadata` (we echo it back verbatim) and checking it in your handler: ```python theme={null} if request.json.get("metadata", {}).get("secret") != SHARED_SECRET: abort(401) ``` Direct deliveries share everything else with signed ones — the same retry ladder, the same 15-second `2xx` deadline, and the same SSRF protections (private, loopback, and cloud-metadata destinations are refused). Don't put PHI in `metadata` unless the receiving endpoint is inside your compliance boundary; it's echoed verbatim to whatever URL the request names. # Introduction Source: https://docs.hanji.dev/introduction Text, tables, and figures in one call, at least 2x faster than other parsers. Fill your own JSON schema with cited values, grounded to the exact spot on the page. Hanji turns documents into structured JSON. Send a PDF, PPTX, DOCX, or image; get back every run of text, every table as structured cells (plus a markdown rendering), and every figure, in reading order, in one API call. Everything you get back is grounded. Each chunk carries its page number and bounding box, so you can always point back to the exact spot on the page it came from. Get the whole document back as structured chunks. Start here. Your first call takes about five minutes. Hand us a JSON schema and get back just those fields, with a citation for every value. Upload thousands of files, submit one batch, poll one endpoint. All of it runs on the same engine and shares one response schema. A chunk from a sync call looks identical to a chunk from a batch item, so learn the response shape once and it works everywhere. **Path rename (compatibility).** Sync parse is `POST /v1/parse` and `POST /v1/parse/file`. The previous paths `POST /v1/extract` and `POST /v1/extract/file` still work and return the same responses; prefer `/v1/parse` for new integrations. ## Quickstart Sign up and create a key at [hanji.dev/dashboard](https://hanji.dev/dashboard). The Free plan includes **1,000 credits** (one parsed page = 1 credit), enough to parse a few hundred real documents. The key is shown once on creation, so store it somewhere safe. ```bash theme={null} export HANJI_API_KEY="your-key-here" ``` Pick the path that matches where your document lives. Pass a `url` if it's already reachable over HTTP (an S3 presigned URL, a public doc, a CDN). Upload the file directly if you have the bytes in hand (agent output, local file, webhook payload). ```bash curl theme={null} curl -X POST https://api.hanji.dev/v1/parse \ -H "X-API-KEY: $HANJI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://arxiv.org/pdf/1706.03762.pdf" }' ``` ```python python theme={null} import os, requests r = requests.post( "https://api.hanji.dev/v1/parse", headers={"X-API-KEY": os.environ["HANJI_API_KEY"]}, json={"url": "https://arxiv.org/pdf/1706.03762.pdf"}, timeout=120, ) r.raise_for_status() doc = r.json() print(len(doc["chunks"]), "chunks") ``` ```javascript javascript theme={null} const r = await fetch("https://api.hanji.dev/v1/parse", { method: "POST", headers: { "X-API-KEY": process.env.HANJI_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://arxiv.org/pdf/1706.03762.pdf" }), }); const doc = await r.json(); console.log(doc.chunks.length, "chunks"); ``` ```bash curl theme={null} curl -X POST https://api.hanji.dev/v1/parse/file \ -H "X-API-KEY: $HANJI_API_KEY" \ -F "file=@paper.pdf" ``` ```python python theme={null} import os, requests with open("paper.pdf", "rb") as f: r = requests.post( "https://api.hanji.dev/v1/parse/file", headers={"X-API-KEY": os.environ["HANJI_API_KEY"]}, files={"file": ("paper.pdf", f, "application/pdf")}, timeout=120, ) r.raise_for_status() doc = r.json() print(len(doc["chunks"]), "chunks") ``` ```javascript javascript theme={null} import { readFileSync } from "node:fs"; const form = new FormData(); form.set("file", new Blob([readFileSync("paper.pdf")]), "paper.pdf"); const r = await fetch("https://api.hanji.dev/v1/parse/file", { method: "POST", headers: { "X-API-KEY": process.env.HANJI_API_KEY }, body: form, }); const doc = await r.json(); console.log(doc.chunks.length, "chunks"); ``` The filename doesn't matter; we look at the file itself. A `.docx` name on PDF bytes is treated as a PDF. The form accepts the same `extract_text`, `extract_images`, `ocr`, `table_output_format`, `chunking`, and `include_content` fields as the JSON route; send them as individual form fields. You'll get back a `chunks` array, the whole document in reading order: ```json theme={null} { "chunks": [ { "page_content": "Attention Is All You Need", "page_no": 1, "bbox": [176.6, 88.7, 438.3, 107.2], "chunk_type": "text" }, { "page_content": "| Model | BLEU |\n|---|---|\n| Transformer | 28.4 |", "page_no": 3, "bbox": [110.0, 200.4, 500.0, 320.1], "chunk_type": "table", "n_rows": 2, "n_cols": 2, "cells": [ { "text": "Model", "row": 0, "col": 0 }, { "text": "BLEU", "row": 0, "col": 1 }, { "text": "Transformer", "row": 1, "col": 0 }, { "text": "28.4", "row": 1, "col": 1 } ] }, { "page_content": "", "page_no": 4, "bbox": [108.0, 281.4, 504.0, 531.4], "chunk_type": "image", "image_url": "https://...", "image_mime": "image/webp", "image_width": 1188, "image_height": 750 } ] } ``` That's it. You've parsed your first document. The rest of this page covers the response in detail, the request options, and the operational stuff (billing, limits, errors). ## Understanding the response Every chunk is one of three types: * **`text`**: a short run of text in one style. Not a full paragraph; a paragraph usually splits into several text chunks. * **`table`**: a table. `cells` is the structured representation (0-based `row`/`col`, with `row_span`/`col_span` for merged cells), and `page_content` carries a markdown rendering so plain-text consumers still get readable output — request `table_output_format: "html"` to get that same table as HTML instead. By default, cell `bbox` values on scanned tables share the whole table's box; request `table_output_format: "cell_grid"` to get a true box per cell. * **`image`**: a figure extracted from the page, delivered as a URL or inline base64. And every chunk carries: * **`bbox`**: `[x0, y0, x1, y1]` in PDF points, so you can highlight the source region on the page. * **`page_no`**: the 1-based page the chunk came from. * **`confidence`**: `0` to `1` for OCR'd content, scored on the hardest-to-read character of the chunk, so one shaky digit lowers the whole score. `null` for text read from the document's own text layer, where recognition isn't a factor. Route low-confidence chunks to review.

Office documents (DOCX / PPTX)

A `.docx` or `.pptx` is converted to PDF before parse, and every `bbox` is in **that** PDF's point space. The original Word/PowerPoint file does not paginate the same way, so drawing those boxes on your own render of the `.docx` will miss. For these inputs the response also includes `pdf_rendition_url`: an expiring link to the converted PDF. Download it onto your own storage and overlay `bbox` + `page_no` exactly as you would for a native PDF. The field is omitted (not `null`) for PDFs and images — you already have the pages. Type-specific fields: * `cells` / `n_rows` / `n_cols`: populated on table chunks. Each cell is `{ text, row, col, row_span, col_span, bbox, confidence }`. * `image_url` / `image_mime`: populated on image chunks. Some accounts receive the image inline as `image_b64` instead.

The whole document as one string

Set `include_content: true` and the response carries one extra field alongside `chunks`: `content` — the entire parsed document as a single string, concatenated in reading order. Text flows as paragraphs, tables render as markdown tables in place, and figures are omitted. It is the document's own text end to end, not reformatted with markdown headings or page markers. Use it when you want to hand the whole document to an LLM in one shot rather than iterate over chunks. The default response is unchanged. ## Request options For the URL route (`POST /v1/parse`), `url` is required and everything else is optional. For the upload route (`POST /v1/parse/file`), `file` is required and the remaining fields arrive as individual form fields instead of JSON. | Field | Type | Default | Description | | --------------------- | ------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `url` | string | - | URL route only. HTTP(S) URL to a supported document or image. The type is detected automatically. | | `file` | binary | - | Upload route only. Multipart file field. The type is detected from the file itself; the filename doesn't matter. | | `extract_text` | boolean | `true` | Set `false` to skip text spans. Table chunks (and figures, when `extract_images` is true) are still returned. | | `extract_images` | boolean | `true` | Set `false` to skip figure extraction; text and table chunks are still returned. | | `ocr` | `"auto" \| "never" \| "force"` | `"auto"` | *Deprecated; accepted but ignored.* This field no longer changes behavior. | | `table_output_format` | `"markdown" \| "html" \| "cell_grid"` | `"markdown"` | `"html"` puts an HTML table in `page_content`; `"cell_grid"` returns true per-cell boxes. Echoed on each table chunk. | | `chunking` | `"none" \| "semantic"` | `"none"` | `"semantic"` additionally returns [`segments`](#chunking), the document grouped into pieces sized for RAG. The default response is unchanged. | | `chunk_size` | integer | `1000` | Target segment size in characters. Segments land within ±25% of the target (default 750 to 1250). Sizing is approximate: an oversized element or a final remainder can fall outside the band. Only meaningful when `chunking` is enabled; validated (200 to 8000) only in that case and ignored otherwise. | | `include_content` | boolean | `false` | `true` additionally returns [`content`](#content): the entire parsed document as a single text string, concatenated in reading order. The default response is unchanged. | Schema extraction (`POST /v1/extract/schema`) has its own options: `schema`, `strict`, `auto_schema`, and `include_ocr_text` (the `include_content` of that route). See the [Schema extraction guide](/guides/schema-extraction#request-options). Batches take the same parsing options as sync, set once per batch (except `include_content`, which is sync-only); see [Batch options](/guides/batch#batch-options). **Supported input formats:** PDF, PPTX, DOCX, and images (PNG, JPEG, WebP, TIFF, HEIC/HEIF, and BMP). An image is treated as a one-page document, and the response looks the same as for a PDF. Animated GIF and animated WebP are rejected with `400`. ## Chunking Set `chunking: "semantic"` and the response carries two extra fields alongside the unchanged `chunks` array: `segments` and `page_dimensions`. Each segment is a group of chunks sized toward `chunk_size` characters, ready to embed, split at natural boundaries (headings, figures with their captions, page breaks). `content` is markdown. Tables come as markdown tables; images are not included. A table too large for one segment is split at row boundaries, and every part remains a valid table (`table_part` records the rows each part covers). ```json theme={null} { "chunks": [ /* the unchanged flat list; full payloads live here */ ], "page_dimensions": [ { "page_no": 1, "width": 612.0, "height": 792.0 } ], "segments": [ { "content": "3.1 Encoder and Decoder Stacks\n\nThe encoder is composed of...", "char_count": 987, "pages": [3], "chunks": [ { "source_index": 41, "chunk_type": "text", "page_no": 3, "bbox": [64.3, 585.2, 528.0, 601.9] }, { "source_index": 42, "chunk_type": "text", "page_no": 3, "bbox": [64.3, 610.0, 528.0, 719.5] } ] } ] } ``` * `content`: the segment's text, formatted as markdown. Use this field to pass the text to an embedding model. * `char_count`: the number of characters in `content`. * `pages`: the 1-based page numbers the segment spans. * `chunks`: the elements that make up the segment. Each includes its `chunk_type`, `page_no`, and `bbox`. * `source_index`: the element's index in the response's `chunks` array, where the full element lives. * `page_dimensions`: the width and height of each page, in the same units as `bbox`. ## Pricing and credits Billing is in **credits**, at **\$3 per 1,000 credits** (\$0.003 per credit). Each page you process costs the operation's credit rate: * **Parsing** (`/v1/parse`, `/v1/parse/file`, and the batch lane): **1 credit per page** — \$3 per 1,000 pages. * **[Schema extraction](/guides/schema-extraction)** (`/v1/extract/schema`): **4 credits per page** — \$12 per 1,000 pages, all-in. The 4 credits **include the parse**; it is not billed on top of the parsing rate. There are no configuration surcharges: chunking, images, and cell-level tables are included in those rates. Your remaining balance is always visible in the [dashboard](https://hanji.dev/dashboard). Every successfully billed response (sync and batch item results alike) includes a `usage` object telling you exactly what the request cost: ```json theme={null} { "usage": { "pages": 12, "credits": 12.0, "credits_per_page": 1.0 } } ``` `pages` is the source document's page count, `credits` is the total charged, and `credits_per_page` is the rate applied. On requests that aren't billed, the field is absent, not `null`. What counts as a page depends on the input: | Input | Page counting | | ---------------------------------------- | ------------------------------------------------------ | | PDF | One page per PDF page | | PPTX | One page per slide | | DOCX | Paginated on render; typically 250-400 words per page | | Images (PNG, JPEG, WebP, HEIC/HEIF, BMP) | One page per image | | TIFF | One page per frame; a 10-frame fax TIFF bills 10 pages | ## Limits and large documents Two server-side limits apply to every document: * **Max 2,000 pages per document.** Larger documents fail with `413`. * **Max 150 MB per document.** Larger downloads fail with `413`. For documents over those limits, split client-side and concatenate the `chunks` arrays; the `page_no` field lets you offset page numbers across splits. Need support for individual documents beyond 2,000 pages? Email [hello@hanji.dev](mailto:hello@hanji.dev). For bulk workloads (thousands of documents), don't loop `POST /v1/parse/file`. Use the **[async batch endpoints](/guides/batch)** instead: you upload each file once to a presigned URL, submit the whole set as one batch, and poll for completion. Same response schema, no per-doc HTTP round-trip overhead. ## Authentication Every request needs an `X-API-KEY` header. Keys are created and revoked from the [dashboard](https://hanji.dev/dashboard). Each key: * is bound to one customer account * carries a quota expressed in credits (default **1,000 credits** on the Free plan) * goes down with each request by the document's page count times the operation's [credit rate](#pricing-and-credits) You can rotate a key at any time; the new one is returned once on creation and never shown again. If your account is provisioned for PHI, your keys behave a little differently: extracted images stay inline in the response (never S3), logging is allowlisted, and filenames are not retained. URL and multipart parse both work (`/v1/parse`, `/v1/parse/file`). Email [hello@hanji.dev](mailto:hello@hanji.dev) to set up a PHI account. ## Data retention What we keep, lane by lane: | Data | Retention | | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | Sync documents (`/v1/parse*`, alias `/v1/extract*`) | Never stored. Processed in memory; gone when the response returns. | | Office PDF rendition (`pdf_rendition_url`) | DOCX/PPTX only. An expiring download link to the PDF we paginated; copy it to your storage. The object is deleted automatically after 3 days. | | Batch uploads (`POST /v1/files`) | Deleted automatically 3 days after upload. | | Batch results | Deleted automatically 3 days after item completion. | | URL-sourced batch inputs | Never stored. Fetched into memory at processing time. | | Extracted images (`extract_images: true`) | Only created when you request them. Stored so your `image_url` links keep working; deleted on request ([hello@hanji.dev](mailto:hello@hanji.dev)). | | Request log | Metadata only: ids, page counts, timings, cost. Never documents, results, filenames, or response bodies. Rows kept 90 days. | | Page-quality diagnostics | A page that fails an automated quality check may be kept as a rendered image for internal review and is deleted within 7 days. Never on PHI accounts. | We never train on customer data. Need custom retention terms (shorter windows, customer-managed encryption, dedicated regions)? Email [hello@hanji.dev](mailto:hello@hanji.dev). ## Errors Errors come back as standard HTTP status codes with a JSON body. The short version: `4xx` means fix the request, `5xx` means retry. | Status | Meaning | What to do | | ------ | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Unsupported input or extraction failed | Check the `url` points to a supported format (PDF, PPTX, DOCX, or a supported image: PNG, JPEG, WebP, TIFF, HEIC/HEIF, BMP; animated images are rejected) and that the document isn't corrupted | | `401` | Missing or invalid `X-API-KEY` | Check the header is set; re-create the key if revoked | | `402` | Quota exceeded | Top up from the dashboard or wait for plan refresh | | `404` | File or batch not found (async batch only) | Verify the `file_id` / `batch_id` is correct and not expired (3-day TTL) | | `409` | File not uploaded, or result not ready (async batch only) | See [Batch errors](/guides/batch#errors) | | `413` | Page limit or size limit exceeded | Split the document client-side | | `422` | Request body invalid | The error body says which field is wrong (`detail[*].loc`) and why (`detail[*].msg`); usually a missing `url` | | `429` | Rate limit exceeded | Back off and retry | | `500` | Server error | Retry with exponential backoff; contact support if persistent | | `503` | Temporarily unavailable | Retry with backoff; failed requests are not billed | Async-batch endpoints (`/v1/files`, `/v1/batches`) additionally return machine-readable `error` codes in the response body. See **[Batch errors](/guides/batch#errors)** for the full list, response shapes, and fixes. Pass your own `X-Request-Id` header if you want to correlate logs with us. It shows up on our side too, which makes support conversations much faster. ## Next steps You've made your first call. From here: Skip the chunks entirely: define a JSON schema and get back just the fields you care about, each with a citation you can verify. The async batch lane: presigned uploads, one submission, one polling loop. Same response schema as sync. Or open the **API Reference** in the sidebar for a live playground and the full request/response schema.