# Read a document

`POST https://api.paxalabs.com/v1/ocr`

POST /v1/ocr reads a PDF or image and returns its text as Markdown or typed layout blocks, one entry per page. Charged in credits per page before reading, refunded on failure.

Send the file as base64 in `document`. An image counts as one page. The [OCR guide](https://paxalabs.com/docs/ocr) covers the two output shapes, the block types, and the page and size limits.

## Headers

- `authorization` (Bearer pxa_..., required): Authorization: Bearer pxa_... (recommended).
- `x-api-key` (pxa_..., optional): Alternative to the Authorization header. Ignored when Authorization is present.
- `idempotency-key` (string, optional, 1 to 200 characters, pattern ^[!-~]+$): Makes a retry safe. Two requests carrying the same key charge once and synthesize the same content. One request per key runs at a time. A concurrent duplicate answers 409 idempotency_in_flight. Reuse a key only to retry an identical request. A changed payload is rejected with 422. Accepts up to 200 printable ASCII characters.

See authentication at https://paxalabs.com/docs/authentication.

## Body

- `document` (string, required, 1 to 15,900,000 characters): The document to read, as the base64 encoding of a PDF, PNG, JPEG, or WebP file. An image counts as one page. Cost is 6.5 credits per page, charged before reading and refunded automatically when reading fails. A PDF may carry up to 50 pages, and more answers 400 too_many_pages. The decoded file may be up to 10,485,760 bytes, and larger answers 413 document_too_large. A file that cannot be read as one of the four formats answers 400 document_invalid, and a PDF that needs a password to open answers 400 document_password_required. GET /v1/models reports the ceilings as max_pages and max_bytes.
- `model` (string, required, 1 to 100 characters): OCR model id, for example paxa-ocr-lite-v1. GET /v1/models lists the served catalog.
- `output` ("markdown" or "structured", optional, default "markdown"): Shape of the result. "markdown", the default, returns each page as GitHub-flavored Markdown. "structured" returns each page as typed layout blocks, for callers that feed the reading into a pipeline.

## Response

- `pages` (array of objects, required): One entry per page of the document, in page order.
  - `pages[].page` (integer, required): Page number, starting at 1. An image request has exactly one page.
  - `pages[].markdown` (string, optional): The page's content as GitHub-flavored Markdown, in reading order. Present when output is "markdown". Join pages with a blank line to rebuild the document.
  - `pages[].blocks` (array of objects, optional): The page's content as typed blocks in reading order. Present when output is "structured".
    - `pages[].blocks[].type` (string, required): What the block is: "heading", "paragraph", "list", "table", or "figure". Blocks arrive in reading order.
    - `pages[].blocks[].text` (string, optional): The block's text. Present on "heading", "paragraph", and "figure" blocks. A figure's text is its caption or nearby label, and is empty when it has none.
    - `pages[].blocks[].level` (integer, optional): Heading depth, starting at 1 for the most prominent. Present on "heading" blocks.
    - `pages[].blocks[].items` (array of strings, optional): The list entries in order. Present on "list" blocks.
    - `pages[].blocks[].rows` (array of array of stringss, optional): The table cells as rows of column values, first row first. Present on "table" blocks.
- `usage` (object, required): What the request was billed for.
  - `usage.pages` (integer, required): Pages this request was billed for.
  - `usage.credits` (number, required): What this delivery cost, in credits, exact to a hundredth. An idempotent replay reports the ORIGINAL request's charge, since that one charge is what paid for this delivery too; your balance moves only once.

## Errors

- `validation` (400): The request body or headers failed validation against the endpoint schema.
- `unknown_model` (400): The model field does not name a served model.
- `document_invalid` (400): The document field could not be read as a PDF, PNG, JPEG, or WebP file. A damaged or truncated PDF answers this code. Nothing was charged.
- `document_password_required` (400): The PDF needs a password to open. A PDF that carries permissions-only encryption, the kind that opens without being asked for a password, is read normally. Nothing was charged.
- `document_too_large` (413): The decoded document exceeds the model's size ceiling. Nothing was charged.
- `too_many_pages` (400): The document has more pages than the model's per-request ceiling. Nothing was charged.
- `unauthorized` (401): The request carried no API key, or the key is invalid or disabled.
- `insufficient_credits` (402): The account does not have enough credits for this request. Nothing was charged.
- `key_limit` (403): This API key reached its spending cap. Nothing was charged.
- `idempotency_in_flight` (409): Another request with this Idempotency-Key is in flight right now.
- `idempotency_refunded` (409): The original request under this Idempotency-Key failed and was refunded.
- `idempotency_mismatch` (422): This Idempotency-Key was already used for a different request.
- `content_blocked` (422): The upstream safety system declined to process this content. The charge was refunded.
- `rate_limited` (429): Requests per minute for the plan are exhausted. One window covers the whole account, across every product and every key.
- `concurrency_limited` (429): The account holds the plan's full count of concurrent requests for this product. Nothing was charged. Each product is limited separately, and an open live connection holds one speech slot.
- `internal` (500): Request state was inconsistent on the server.
- `provider_error` (502): Model inference failed after the request was charged.
- `provider_unavailable` (503): The model behind this endpoint is not available right now. Nothing was charged.

## Example

curl:

```bash
# Encode without line wrapping: wrapped base64 breaks the JSON string.
DOC=$(base64 < invoice.pdf | tr -d '\n')
# --max-time covers a multi-page document; curl defaults to no limit.
curl -X POST https://api.paxalabs.com/v1/ocr \
  --max-time 300 \
  -H "Authorization: Bearer $PAXA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"document\": \"$DOC\", \"model\": \"paxa-ocr-lite-v1\"}"
```

TypeScript:

```typescript
import { readFile } from "node:fs/promises";

const document = (await readFile("invoice.pdf")).toString("base64");

const response = await fetch("https://api.paxalabs.com/v1/ocr", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PAXA_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ document, model: "paxa-ocr-lite-v1" }),
  // A multi-page document can run for minutes; give it room.
  signal: AbortSignal.timeout(300_000),
});

if (!response.ok) throw new Error(`OCR failed: ${response.status}`);
const { pages, usage } = await response.json();
console.log(pages[0].markdown, `${usage.credits} credits`);
```

Python:

```python
import base64
import os

import requests

with open("invoice.pdf", "rb") as file:
    document = base64.b64encode(file.read()).decode()

response = requests.post(
    "https://api.paxalabs.com/v1/ocr",
    headers={"Authorization": f"Bearer {os.environ['PAXA_API_KEY']}"},
    json={"document": document, "model": "paxa-ocr-lite-v1"},
    # A multi-page document can run for minutes; give it room.
    timeout=300,
)
response.raise_for_status()
body = response.json()
print(body["pages"][0]["markdown"], body["usage"]["credits"], "credits")
```

Response:

```json
{
  "pages": [
    {
      "page": 1,
      "markdown": "# ใบเสร็จรับเงิน\n\nร้านข้าวแกงบ้านสวน สาขาสีลม\n\n| รายการ | จำนวน | ราคา |\n| --- | --- | --- |\n| ข้าวแกงเขียวหวานไก่ | 1 | 60 |\n| น้ำเปล่า | 1 | 10 |\n\nรวมทั้งสิ้น 70 บาท"
    }
  ],
  "usage": {
    "pages": 1,
    "credits": 6.5
  }
}
```
