Docs·API reference
Extract
POST /v1/extract reads up to 20 fields from one document in one call, each with its evidence span, or a clear DECLINE.
POST https://platform.xerj.ai/v1/extract
Authorization: Bearer $XERJ_API_KEY
Content-Type: application/jsonExtract is many questions about one document in one request. Each field is read separately, with its own confidence and its own evidence.
Request #
| Field | Type | Required | Description |
|---|---|---|---|
document |
string | yes | The text to read. Up to 1,000,000 characters. state is accepted as another name for it. |
fields |
object | yes | 1 to 20 fields. Each key is a field name; each value defines the field (below). |
min_confidence |
number | no | The bar for every field, with 0 < x < 1. Default 0.5. |
model |
string | no | Default xerj-reader-1. |
Defining a field #
A field value is one of three things:
| You write | It means |
|---|---|
"total": "What is the total amount due?" |
A text field with this question. |
"paid": {"question": "Has the invoice been paid?", "type": "boolean"} |
An object with an optional question and an optional type. |
"due_date": {} |
No question: it becomes “What is the due date?”, from the name with _ and - read as spaces. |
typeis"text"(the default) or"boolean". A boolean field turns Yes/No intotrue/false.- Field names match
^[A-Za-z_][A-Za-z0-9_.-]{0,63}$: a letter or_first, then letters, digits,_,.or-, up to 64 characters. - Questions are up to 300 characters, like in
/v1/answers.
Response #
| Field | Type | Description |
|---|---|---|
id |
string | ext_… |
object |
string | Always "extraction". |
created |
integer | Unix time, in seconds. |
model |
string | The model that read the document. |
fields |
object | One result per field name, below. |
usage |
object | input_tokens, output_tokens, total_tokens, summed over all fields. |
latency_ms |
integer | Time spent on our side, in milliseconds. |
A field result #
| Field | Type | Description |
|---|---|---|
status |
string | "ANSWER" or "DECLINE". |
value |
string, boolean or null | Text field: the span text. Boolean field: true or false. null on DECLINE. |
confidence |
number | Between 0 and 1. |
evidence |
array | On ANSWER: the spans the value comes from, [{start, end, quote}], where quote == document[start:end]. One span for a text field; possibly none for a boolean. |
best_guess |
object or null | On DECLINE: the closest candidate {text, start, end, confidence}, or null. |
A DECLINE means the document does not answer the field above your bar. A boolean field that the model answers with a span instead of Yes/No is also a DECLINE, with that span as best_guess.
Example: an invoice #
The document:
INVOICE
Invoice number: INV-20931
Invoice date: 14 October 2025
Due date: 30 November 2025
From: Northwind Logistics B.V., Havenstraat 12, 3011 AB Rotterdam, Netherlands
Bill to: Acme Retail Ltd, 4 Park Row, Leeds LS1 5HD, United Kingdom
Purchase order: PO-5512
Item Qty Unit price Amount
Model X40, Q4 shipment 1 1,200 40.00 EUR 48,000.00 EUR
Sea freight surcharge 1 200.00 EUR 200.00 EUR
Total due: 48,200.00 EUR
Pay by bank transfer to IBAN NL91 ABNA 0417 1643 00, reference INV-20931.
Status: unpaidThe request, with six fields:
from xerj import XERJ
client = XERJ()
invoice = open("invoice.txt", encoding="utf-8").read()
r = client.extract.create(
document=invoice,
fields={
"invoice_number": "What is the invoice number?",
"total": {"question": "What is the total amount due?"},
"due_date": {},
"purchase_order": "Which purchase order does this invoice refer to?",
"paid": {"question": "Has the invoice been paid?", "type": "boolean"},
"vat_number": {},
},
)
for name, f in r.fields.items():
if f.status == "ANSWER":
where = f"at {f.evidence[0].start}-{f.evidence[0].end}" if f.evidence else ""
print(f"{name}: {f.value!r} {where}")
else:
print(f"{name}: not in the document")import { readFile } from "node:fs/promises";
import XERJ from "xerj";
const client = new XERJ({ apiKey: process.env.XERJ_API_KEY });
const invoice = await readFile("invoice.txt", "utf8");
const r = await client.extract.create({
document: invoice,
fields: {
invoice_number: "What is the invoice number?",
total: { question: "What is the total amount due?" },
due_date: {},
purchase_order: "Which purchase order does this invoice refer to?",
paid: { question: "Has the invoice been paid?", type: "boolean" },
vat_number: {},
},
});
for (const [name, f] of Object.entries(r.fields)) {
if (f.status === "ANSWER") console.log(name, f.value, f.evidence[0] ?? "");
else console.log(name, "not in the document");
}curl https://platform.xerj.ai/v1/extract \
-H "Authorization: Bearer $XERJ_API_KEY" \
-H "Content-Type: application/json" \
-d @request.jsonThe response (values for illustration; id, created and latency_ms left out):
{
"object": "extraction",
"model": "xerj-reader-1",
"fields": {
"invoice_number": {"status": "ANSWER", "value": "INV-20931", "confidence": 0.98,
"evidence": [{"start": 25, "end": 34, "quote": "INV-20931"}]},
"total": {"status": "ANSWER", "value": "48,200.00 EUR", "confidence": 0.96,
"evidence": [{"start": 459, "end": 472, "quote": "48,200.00 EUR"}]},
"due_date": {"status": "ANSWER", "value": "30 November 2025", "confidence": 0.97,
"evidence": [{"start": 75, "end": 91, "quote": "30 November 2025"}]},
"purchase_order": {"status": "ANSWER", "value": "PO-5512", "confidence": 0.95,
"evidence": [{"start": 256, "end": 263, "quote": "PO-5512"}]},
"paid": {"status": "ANSWER", "value": false, "confidence": 0.9,
"evidence": [{"start": 555, "end": 561, "quote": "unpaid"}]},
"vat_number": {"status": "DECLINE", "value": null, "confidence": 0.07,
"best_guess": {"text": "NL91 ABNA 0417 1643 00", "start": 502, "end": 524, "confidence": 0.07}}
},
"usage": {"input_tokens": 892, "output_tokens": 14, "total_tokens": 906}
}Read it field by field:
- Four text fields are answered, each with the exact characters it came from.
paidisfalse, and the evidence points atunpaid.vat_numberis a DECLINE: the invoice has no VAT number. The best guess is the IBAN, which is exactly why a DECLINE must never be filled with its best guess.totalis the text48,200.00 EUR. Turn it into a number in your own code if you need one.
Show the evidence #
Every ANSWER carries offsets, so a reviewer can see each value in place. One way, in the browser, building on the quickstart:
// Highlight every answered field in the invoice text. Spans do not overlap here.
const spans = Object.entries(r.fields)
.filter(([, f]) => f.status === "ANSWER" && f.evidence.length)
.map(([name, f]) => ({ name, ...f.evidence[0] }))
.sort((a, b) => a.start - b.start);
const parts = [];
let at = 0;
for (const s of spans) {
parts.push(invoice.slice(at, s.start));
const mark = document.createElement("mark");
mark.textContent = invoice.slice(s.start, s.end);
mark.title = s.name;
parts.push(mark);
at = s.end;
}
parts.push(invoice.slice(at));
document.querySelector("#invoice").replaceChildren(...parts);Billing #
Each field is one read of the document. Six fields on this invoice are six reads: input_tokens is six times the document, plus the six questions. That is the same as six calls to /v1/answers, in one round trip. Output is free.
Errors #
The errors of /v1/answers, plus:
| Status | Code | When |
|---|---|---|
| 400 | invalid_fields |
fields is not an object, a name breaks the naming rule, a value is not a string or an object, or a type is not text or boolean. |
| 400 | too_many_fields |
More than 20 fields. |