Extract data from an invoice with the API
To extract an invoice with the docRefinery API, POST the file to /v1/runway/async, poll /v1/runway/{trace_id} until the run settles, then read result.fields. Each value comes with a confidence score, and values that need checking are listed in low_confidence_fields. The same two calls work for every document type on your account, and the result has the same shape each time.
Updated
Submit a document
POST /v1/runway/async takes one document per request as multipart form data and queues it. You don't say what kind of document it is or which fields you want: docRefinery works out the type and returns the fields your account has set for that type under Schemas & thresholds.
| Part | Required | What it does |
|---|---|---|
file | Yes | The document. The dashboard accepts PDF, PNG, JPEG and TIFF, up to 100 MB. |
tags | No | Your own labels, as a JSON-encoded array in one form field, such as ["q3","acme"]. Not repeated fields. |
enable_validation | No | Turns confidence scoring and the review flag on or off for this run only. Leave it out to use the saved setting, which is on by default. |
curl -X POST "$DOCREFINERY_API/v1/runway/async" \
-H "Authorization: Bearer $DOCREFINERY_API_KEY" \
-F "[email protected]" \
-F 'tags=["q3","acme"]'import json
import os
import requests
API = os.environ["DOCREFINERY_API"]
HEADERS = {"Authorization": f"Bearer {os.environ['DOCREFINERY_API_KEY']}"}
with open("invoice.pdf", "rb") as document:
response = requests.post(
f"{API}/v1/runway/async",
headers=HEADERS,
files={"file": document},
data={"tags": json.dumps(["q3", "acme"])},
timeout=120,
)
response.raise_for_status()
trace_id = response.json()["trace_id"]import { openAsBlob } from "node:fs";
const API = process.env.DOCREFINERY_API;
const headers = { Authorization: "Bearer " + process.env.DOCREFINERY_API_KEY };
const form = new FormData();
form.append("file", await openAsBlob("invoice.pdf"), "invoice.pdf");
form.append("tags", JSON.stringify(["q3", "acme"]));
const response = await fetch(API + "/v1/runway/async", {
method: "POST",
headers,
body: form,
});
if (!response.ok) throw new Error("Submit failed: " + response.status);
const { trace_id } = await response.json();{
"trace_id": "9f1c0a7e",
"status": "queued",
"wait_time_ms": 1800,
"estimated_execution_time_utc": "2026-11-04T10:31:04Z"
}Keep trace_id: it is the handle for everything that follows. These examples use fictional values. Responses carry other keys too, so build only on the ones described here.
Poll for the result
GET /v1/runway/{trace_id} reports where a run has got to. Every long-running job is submit-then-poll, with no webhooks or websockets. Polling every 2 seconds matches our dashboard. Add ?include_results=false for a lighter status check that leaves the result out, then make one full call when the run settles.
curl "$DOCREFINERY_API/v1/runway/9f1c0a7e?include_results=false" \
-H "Authorization: Bearer $DOCREFINERY_API_KEY"{
"status": "in_progress",
"progress": { "percent": 42, "remaining_ms": 11000 }
}progress.percent stays at 99 or below until the run is fulfilled, and remaining_ms is an estimate. A run that hasn't started yet reports status: "queued".
status | Meaning | What to do |
|---|---|---|
fulfilled, completed | The result is ready. | Read result. |
failed, error | The run stopped. Pages are given back. | Read error. See errors and limits. |
awaiting_review | docRefinery wasn't sure what type of document this is, so a person has to decide. | Stop polling. Review it in the dashboard. |
| Anything else | Still queued or running. | Keep polling. |
Read the extracted fields
A finished run has the document's type at the top level and the data under result. result.fields is a list with one item per field or table you've set for that type, and each alias is the name of that field on your account.
{
"status": "fulfilled",
"files": ["invoice.pdf"],
"document_type": "invoice",
"document_confidence": 0.97,
"result": {
"fields": [
{
"text": "INV-2291",
"alias": "invoice_number",
"field_confidence": 0.97,
"location": { "page": 1, "bbox": [0.62, 0.08, 0.81, 0.11] }
},
{
"alias": "line_items",
"table": [
[
{ "text": "Bolts M6", "alias": "description", "field_confidence": 0.95 },
{ "text": "12.50", "alias": "unit_price", "field_confidence": 0.93 }
]
]
}
],
"extraction_approval": "auto",
"low_confidence_fields": []
}
}| Key | What it holds |
|---|---|
alias | The field or table name: the Return as alias set on your account. |
text | The value, as a string. Numbers and dates arrive as text too. |
field_confidence | A score from 0 to 1 for this value. |
location | Where the value sits, when scoring is on: page counts from 1, and bbox is [left, top, right, bottom] as fractions (0 to 1) of the page, from the top-left corner. It is null when the value is empty, isn't on the page, or is printed more than once with nothing to say which copy it is. |
table | On a table item instead of text: a list of rows, each a list of cells shaped like a field. |
record = {}
for item in run["result"]["fields"]:
if "table" in item:
record[item["alias"]] = [
{cell["alias"]: cell["text"] for cell in row} for row in item["table"]
]
else:
record[item["alias"]] = item["text"]const record = {};
for (const item of run.result.fields) {
record[item.alias] = item.table
? item.table.map((row) => Object.fromEntries(row.map((cell) => [cell.alias, cell.text])))
: item.text;
}Name your output with Return as
Each field on your account has a Return as alias, which you set in the dashboard under Schemas & thresholds. It becomes Alias on the query docRefinery generates for that document type, and that value comes back as alias in result.fields. So the key your code reads is the one you chose.
{
"Text": "The invoice number",
"Alias": "invoice_number",
"Key": "invoice_number",
"Type": "string",
"IsRequired": true
}Text is the plain-language instruction and Alias is the output name. The finished run above shows this field coming back with "alias": "invoice_number".
- Changing an alias never changes what is extracted, only the label. It does change the key your code reads, so update your mapping at the same time.
- An alias must be unique within its document type, ignoring case, and table columns share that namespace with top-level fields. The maximum is 100 characters.
- With no alias set, docRefinery derives a label from the field's name, so
cost_centrebecomesCost Centre. - API keys can't change settings, so aliases are managed in the dashboard.
One result shape for every document type
The shape of a result doesn't depend on the document. A field is an object with text, alias and field_confidence, plus location when scoring is on, which it is by default. location is null for a value that can't be placed. A table is an object with alias and table, whose rows are lists of cells shaped like fields. An invoice, a bank statement and a passport differ only in which fields they carry, so the loops above work on all of them without a branch per type.
Check low-confidence fields
Don't post every value straight into your system. result.extraction_approval says whether a run can be trusted as it stands: auto means nothing needs checking, and human-approval means a person should review it. result.low_confidence_fields lists what was flagged and why. The confidence scores guide covers how to use them.
{
"extraction_approval": "human-approval",
"low_confidence_fields": [
{
"alias": "due_date",
"text": "2026-11-30",
"confidence": 0.61,
"threshold": 0.8,
"reason": "low_confidence"
}
]
}reason | Meaning |
|---|---|
low_confidence | The value scored below its threshold. |
missing | A value was expected and not found. |
invalid_format | The value doesn't match the field's format. |
rows_missing | A table came back with fewer rows than the document prints. extracted_rows and document_rows give the two counts. |
A flag on a table cell also carries table and row. Once someone has reviewed the run, extraction_approval becomes human-approved or human-overridden.
When a document needs a person
There are two kinds of review, and both happen in the dashboard. Flagged values on a finished run are corrected there. A run at awaiting_review stops until someone confirms the document type, then carries on as a new run with its own trace_id, so the first trace_id won't go on to finish. Your code should treat awaiting_review as settled and hand the document to whoever owns the review queue.
Download a run with corrections applied
To collect corrected values, use GET /v1/runway/runs/{trace_id}/export. It always returns the extraction as it stands after corrections, as format=json, csv or xlsx, and suggests a file name in the Content-Disposition header.
curl -OJ "$DOCREFINERY_API/v1/runway/runs/9f1c0a7e/export?format=csv" \
-H "Authorization: Bearer $DOCREFINERY_API_KEY"Documents the API won't extract
A document outside the supported types, such as a CV or a letter, ends with status: "failed" and error_code: "unsupported_document_type". Its error text is written for you to show as it is, and its pages are given back. Other failures carry a short generic message. Errors and limits lists the rest.
Common questions
How do I extract invoice data with curl?
POST the file to /v1/runway/async with curl -F "[email protected]" and an Authorization: Bearer header carrying your API key. Take trace_id from the response, then GET /v1/runway/{trace_id} until status is fulfilled and read result.fields.
Can I send several documents in one request?
No. POST /v1/runway/async takes one file in the file part and returns one trace_id. To process a batch, submit each document separately and poll each trace_id, within your account's rate limit.
Does the API tell me where a value appears on the page?
Yes, when scoring is on. Each field and table cell carries location, with a 1-based page and a bbox of fractions of the page measured from the top-left corner. It is null when a value can't be placed.
Why did my run stop at awaiting_review?
docRefinery wasn't sure what type of document it was, so a person has to decide in the dashboard. The run doesn't finish by itself. Once the type is confirmed it carries on under a new trace_id.
Do different document types return different JSON?
No. Every type returns the same shape in result.fields: fields with text, alias and field_confidence, plus location when scoring is on, and tables as table rows of the same cells. Only the set of fields differs.
How do I rename a field in the API response?
Set its Return as alias in the dashboard under Schemas & thresholds. The alias comes back as alias in the result. Renaming changes only the label, never what is extracted, and an alias must be unique within its document type.
Related
- IntegrationsConnect docRefinery to your systems: REST API keys, workflows from one sample document, a Chrome extension, JSON, CSV and XLSX export, and on-premises options.
- QuickstartSend your first document to the docRefinery extraction API: get a key, submit a PDF, poll for the result and read checked fields. curl, Python and JavaScript.
- Errors and limitsdocRefinery API errors and limits: error shapes, status codes, the monthly page quota, submissions per minute, the 100 MB upload limit and when to retry.
- InvoiceExtract data from invoice PDFs and scans: 62 fields including line items and VAT, as JSON with confidence scores and page and box citations on values.
- Invoice data extractionWhat invoice data extraction should return, why line items are the hard part, where it sits in accounts payable, and what to test in an invoice extraction API.
- Confidence scoresWhat a confidence score in document extraction means, how to set thresholds per field, when to send values to human review, and why null beats a guess.
Send your first document.
Make a free account and try the API on a file of your own.
100 pages a month free. No credit card, no sales call.