# Extract fields from documents

Document Extraction reads a PDF or image and fills in a schema of typed fields you define, each value the span printed in the document or null with the reason. Billing is per page at a rate the schema's size selects, charged before reading and refunded automatically on failure.

[POST /v1/extract](https://paxalabs.com/docs/api/extract) reads a PDF, PNG, JPEG, or WebP file and fills in the schema you send. Send the file as base64 in `document`, the model id `paxa-doc-extract-v1`, and a `schema` of typed fields. Every value delivered is text printed in the document, read as the field's type, or null with the reason. Nothing is inferred or looked up. Like every endpoint, the request is charged before inference and refunded automatically when inference fails ([Credits](https://paxalabs.com/docs/credits)).

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/extract \
  --max-time 300 \
  -H "Authorization: Bearer $PAXA_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<EOF
{
  "document": "$DOC",
  "model": "paxa-doc-extract-v1",
  "schema": {
    "fields": {
      "seller": {
        "type": "string",
        "required": true,
        "description": "The shop name as printed at the top"
      },
      "total": {
        "type": "number",
        "required": true
      },
      "issued_on": {
        "type": "date"
      },
      "items": {
        "type": "array",
        "max_items": 5,
        "items": {
          "type": "object",
          "fields": {
            "name": {
              "type": "string"
            },
            "amount": {
              "type": "number"
            }
          }
        }
      }
    }
  }
}
EOF
```

TypeScript:

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

const document = (await readFile("invoice.pdf")).toString("base64");
const schema = {
  "fields": {
    "seller": {
      "type": "string",
      "required": true,
      "description": "The shop name as printed at the top"
    },
    "total": {
      "type": "number",
      "required": true
    },
    "issued_on": {
      "type": "date"
    },
    "items": {
      "type": "array",
      "max_items": 5,
      "items": {
        "type": "object",
        "fields": {
          "name": {
            "type": "string"
          },
          "amount": {
            "type": "number"
          }
        }
      }
    }
  }
};

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

if (!response.ok) throw new Error(`Extraction failed: ${response.status}`);
const { status, fields, missing, usage } = await response.json();
console.log(status, fields.seller, fields.total, missing);
console.log(`${usage.leaves} leaves, ${usage.credits} credits`);
```

Python:

```python
import base64
import os

import requests

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

schema = {
    "fields": {
        "seller": {
            "type": "string",
            "required": True,
            "description": "The shop name as printed at the top",
        },
        "total": {
            "type": "number",
            "required": True,
        },
        "issued_on": {
            "type": "date",
        },
        "items": {
            "type": "array",
            "max_items": 5,
            "items": {
                "type": "object",
                "fields": {
                    "name": {
                        "type": "string",
                    },
                    "amount": {
                        "type": "number",
                    },
                },
            },
        },
    },
}

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

Response:

```json
{
  "status": "complete",
  "fields": {
    "seller": "ร้านข้าวแกงบ้านสวน สาขาสีลม",
    "total": 70,
    "issued_on": null,
    "items": [
      {
        "name": "ข้าวแกงเขียวหวานไก่",
        "amount": 60
      },
      {
        "name": "น้ำเปล่า",
        "amount": 10
      }
    ]
  },
  "missing": [],
  "unverified": [],
  "truncated": [],
  "assumed": [],
  "usage": {
    "pages": 1,
    "leaves": 13,
    "credits": 13
  }
}
```

## Billing

A page costs 13 credits when the schema counts up to 50 leaf fields, and 19.5 credits when it counts 51 to 100. That is two and three times the [OCR rate](https://paxalabs.com/docs/ocr#billing) of 6.5. The reading is included. A page is one PDF page or one image file. The page count is read from the document and the leaf count from the schema, both before the charge. The cost is known before any reading starts. Evidence and the reading change no charge.

The leaf count is every leaf field once, plus every leaf inside an array once per element the array is sized for. A schema with fifteen header fields and three fields per line item over ten items counts 45 leaves. The same schema over twenty items counts 75. The response reports the count in `usage.leaves` beside `usage.pages` and `usage.credits`.

The charge is reported only in the body's `usage.credits`. This endpoint sends no `x-credits-charged` header. When extraction fails after the charge, the refund is automatic and the response is [502 provider_error](https://paxalabs.com/docs/errors#provider_error). A document that carries none of the fields is a delivery, with the fields null, and is charged.

## Write a schema

`schema` is an object with one key, `fields`, mapping each field name to its definition. A definition carries a `type`, an optional `required` flag, an optional `description` of up to 200 characters, and the keys its type offers. The description is the one piece of your prose the model reads. It is where a field's meaning goes, for example "Seller name as printed at the top". Names are 1 to 64 characters of letters, digits, and underscores.

Two containers nest fields. An `object` carries its own `fields`. An `array` carries `items`, a leaf or an object, and a required `max_items` of 1 to 200. At most 3 containers sit below the root. `items[].tax.rate` is allowed and a fourth level is refused. An array holds leaves or objects. An array inside an array is refused.

| Type | Keys | Value delivered |
| --- | --- | --- |
| string | pattern, min_length, max_length | The printed text, copied. Use it for identifiers that may start with a zero. |
| integer | min, max | The whole number printed in the span. |
| number | min, max, decimals | The amount printed in the span. |
| date | min, max, not_future, year | An ISO date, YYYY-MM-DD, read from the span under Thai document conventions: day first, either era. |
| time |  | A 24-hour time, HH:MM, with seconds when printed. |
| enum | values (1 to 50), aliases, strict | One of values, resolved from the printed span or chosen when nothing prints it. |
| id | digits, check | The digits, with the count and the check digit verified. |
| thai_id | format | The 13 digits of a personal, juristic, or tax identifier, check digit verified. |
| email |  | The address, domain lower-cased. |
| phone | format | A Thai number as E.164 or national. |
| postal_code |  | A five-digit code in use in Thailand. |
| province | format | One of Thailand's 77 provinces. |
| bank | format | One of the banks in Thailand. |
| insurer | format | One of the insurers licensed in Thailand. |
| card_scheme | format | A card scheme, such as Visa, Mastercard, JCB, or UnionPay. |
| payment_method | format | Cash, card, transfer, PromptPay, or a wallet. |
| legal_form | format | A Thai legal form, such as บจก., บมจ., หจก., or ร้าน. |
| currency | format | An ISO 4217 code from a symbol or a name. |
| unit | format | A unit of measure from a line item. |
| amount_words |  | An amount written in words, in Thai or another language, delivered as a number. |
| object | fields | An object of its fields. |
| array | items, max_items | The first max_items printed elements, in reading order. |

### Constraints

A constraint says what the field is. A printed value that fails one is delivered null with the reason, and the span stays in `evidence` when you asked for it. Bound a `number` with `min`, `max`, and `decimals`. Shape a `string` with `pattern`, a regular expression of up to 200 characters matched against the whole value, and with `min_length` and `max_length`. Bound a `date` with `min` and `max` as ISO dates and with `not_future`, judged against today in Thailand. An `id` takes `digits` as a count or a [min, max] pair from 1 to 64, and `check` as none, thai_mod11, or luhn. A check detects a wrong digit and never corrects it.

`year` says how a `date` reads a two-digit year. The default, recent, takes whichever era puts the date in the recent past. A bare 69 on a receipt is then BE 2569. strict delivers a year only when one era fits. be and ce name the era you know. Whichever rule applies, every two-digit year read is listed in the response's `assumed` with the digits as printed.

### Enums and built-in sets

An `enum` lists its `values` and may carry `aliases`, a map from a value to up to 20 other spellings, for example "bangkok" to กรุงเทพมหานคร, กทม., and BKK. The printed span is resolved against the set. An exact match, case folded, wins first. Then a span containing exactly one value's spellings of four characters or more. Then a span one edit from exactly one value, with no other value within two edits. A tie resolves to nothing and the field is null with `ambiguous_match`. Set `strict` to true for exact matches only. An enum is also the one field that may be a judgment: a classification nothing prints, such as the kind of document, is chosen from the listed values.

The closed-set types, province, bank, insurer, postal_code, card_scheme, payment_method, legal_form, currency, and unit, are enums whose tables the service ships, including the abbreviations documents print. A former bank name resolves to its successor. A bare currency sign such as $ names several currencies and is delivered as the sign. `format` picks the delivered form, always a lossless one:

| Type | Forms, default first |
| --- | --- |
| province | name_th, name_en, code (10), iso (TH-10) |
| bank | short (KBANK), code (004), name_th, name_en |
| insurer | short, code (2037), name_th, name_en |
| thai_id | digits (1234567890121), grouped (1-2345-67890-12-1) |
| phone | e164 (+66812345678), national (0812345678) |
| card_scheme | code (MASTERCARD), name |
| payment_method, legal_form, currency, unit | code, name_th, name_en |

### Schema refusals

A schema outside the dialect answers [400 schema_invalid](https://paxalabs.com/docs/errors#schema_invalid) before the document is read, uncharged. The problem body carries two fields beyond the minimum shape. `path` names the offending field in dot form, empty for the root. `reason` is one of the values below. A schema over 128 KB as JSON answers the same code with reason `too_large`.

| reason | Meaning |
| --- | --- |
| not_an_object | The schema, or the field definition at this path, is not a JSON object. |
| unknown_key | A key at this path is not part of the dialect. A field takes type, required, description, and the keys its type lists. |
| missing_fields | The object at this path has no fields key. |
| invalid_fields | The fields key at this path is not an object with at least one field. |
| invalid_name | The field name at this path is not 1 to 64 characters of letters, digits, and underscores. |
| missing_type | The field at this path has no type. |
| unknown_type | The type at this path is not one of the dialect's types. |
| too_deep | The field at this path sits under more than 3 containers. |
| missing_items | The array at this path has no items definition. |
| nested_array | The array at this path holds arrays. An array holds leaves or objects. |
| invalid_max_items | The array at this path has no max_items, or one outside 1 to 200. |
| too_many_leaves | The schema counts more leaf fields than the model's ceiling, arrays counted once per element they are sized for. |
| invalid_flag | The required, strict, or not_future key at this path is not a boolean. |
| invalid_format | The format at this path is not one the type offers. |
| invalid_bound | A min or max at this path is not a number, or not an ISO date on a date field. |
| min_exceeds_max | The min at this path is greater than its max. |
| invalid_count | A decimals, min_length, or max_length at this path is not a whole number in range. |
| invalid_pattern | The pattern at this path is not a valid regular expression of up to 200 characters. |
| invalid_digits | The digits at this path is not a count from 1 to 64, or a [min, max] pair in that range. |
| invalid_check | The check at this path is not none, thai_mod11, or luhn. |
| invalid_year | The year at this path is not recent, strict, be, or ce. |
| invalid_description | The description at this path is not a string of up to 200 characters. |
| invalid_enum_values | The values at this path is not a list of 1 to 50 strings. |
| duplicate_enum_value | Two values at this path are the same once case is folded. |
| invalid_aliases | The aliases at this path is not a map from a listed value to up to 20 spellings. |
| conflicting_alias | An alias at this path spells a listed value, or could name two of them. |
| too_large | The schema is larger than 131,072 bytes as JSON. |

## Read the response

`fields` is your schema's tree with plain values, null wherever nothing was read. `status` is "complete" when every required field carries a value and "incomplete" otherwise. It is computed from the fields alone. A file that is not what the schema describes comes back incomplete with most required fields in `missing`, which is how to detect the wrong document. There is no confidence score. A faithful copy of a misread digit is invisible to any score, and the types that carry a check digit are how such a misread is caught.

- `missing` lists every required field delivered as null, for any reason.
- `unverified` lists every field whose span could not be read as the field's type, or whose value the reading did not carry, each with a `reason` from the table below. Those fields are null.
- `truncated` lists every array whose document carried more elements than it was sized for. The first `max_items` elements are in `fields`, in reading order. Raise `max_items` to read the rest.
- `assumed` lists every date whose year was printed with two digits, with `printed` and `read_as`. It is always present and empty when nothing was assumed.
- `evidence`, when `include_evidence` is true, maps each field path to the printed span its value was read from, whether or not a value could be read from it.
- `pages`, when `include_pages` is true, is the text read from each page as Markdown, the same reading [POST /v1/ocr](https://paxalabs.com/docs/api/ocr) returns. The fields were extracted from it.

Paths are in dot form with array elements indexed, for example "items[2].amount". The `unverified` reasons:

| reason | Meaning |
| --- | --- |
| not_in_readout | The value does not appear in the text read from the document. |
| not_as_printed | The value differs from the printed span it was read from. |
| no_number | The span carries no number. |
| ambiguous_number | The span carries more than one number. |
| not_an_integer | The number in the span is not whole. |
| no_date | The span carries no date. |
| ambiguous_date | The span's date reads two ways, or its two-digit year fits no era under the strict rule. |
| no_time | The span carries no time of day. |
| ambiguous_time | The span carries more than one time. |
| not_an_enum_value | The span resolves to none of the listed values. |
| ambiguous_match | The span resolves to two listed values equally well. |
| not_13_digits | The identifier does not carry exactly thirteen digits. |
| bad_checksum | The identifier's check digit does not hold. |
| wrong_digit_count | The identifier's digit count is outside the field's digits. |
| not_an_email | The span is not an email address. |
| not_a_phone | The span is not a Thai telephone number. |
| not_a_postal_code | The span is not a postal code in use in Thailand. |
| no_amount_words | The span is not an amount written in words. |
| below_min | The number is below the field's min. |
| above_max | The number is above the field's max. |
| too_many_decimals | The number carries more decimals than the field allows. |
| too_short | The text is shorter than the field's min_length. |
| too_long | The text is longer than the field's max_length. |
| pattern_mismatch | The text does not match the field's pattern. |
| before_min | The date is before the field's min. |
| after_max | The date is after the field's max. |
| in_the_future | The date is after today in Thailand, and the field set not_future. |

> There is no boolean type. A checkbox's label is on the page and its tick is not. Model a checkbox as an enum over its printed labels.

## Page, size, and schema limits

One request reads at most 20 pages, and a longer document answers [400 too_many_pages](https://paxalabs.com/docs/errors#too_many_pages). The decoded file may be at most 10 MiB, and a larger one answers [413 document_too_large](https://paxalabs.com/docs/errors#document_too_large). A schema counts at most 100 leaves and is at most 128 KB as JSON. The document rules of the [OCR guide](https://paxalabs.com/docs/ocr#documents) apply unchanged: the format is detected from the bytes, an image is one page, and an unreadable or password-protected file is refused before the charge. One document per request. A file holding several documents gets one set of fields.

## Reliability and retries

The response is one buffered JSON body. A multi-page document can take minutes. Set the client timeout generously. The samples above allow 300 seconds.

A request that runs long enough writes its response headers before the outcome is known. Intermediate proxies abandon a connection that has been silent too long, and the early headers prevent that. Such a response always has status 200. It reports a failure in the body, as the same problem document a failed request returns: a `title` and a `status` where `fields` would be. Treat a body carrying `title` as the error it describes.

Retries are safe with an [Idempotency-Key](https://paxalabs.com/docs/idempotency). A replay of a delivered request serves the same fields again without a second charge. When extraction fails, the charge is refunded and a fresh request needs a new key. The model id is the contract version. The dialect, the response shape, and the leaf-counting rule are fixed under `paxa-doc-extract-v1`, and a change ships as a new id served beside it.

- [Extract fields from a document](https://paxalabs.com/docs/api/extract): Full request and response reference
- [Read documents with OCR](https://paxalabs.com/docs/ocr): Read documents with OCR
- [Credits, charges, and refunds](https://paxalabs.com/docs/credits): How credits and refunds work
- [Error codes and responses](https://paxalabs.com/docs/errors): Every error code
