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.

Form parts for POST /v1/runway/async
PartRequiredWhat it does
fileYesThe document. The dashboard accepts PDF, PNG, JPEG and TIFF, up to 100 MB.
tagsNoYour own labels, as a JSON-encoded array in one form field, such as ["q3","acme"]. Not repeated fields.
enable_validationNoTurns 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.
Invoice extraction with curl
curl -X POST "$DOCREFINERY_API/v1/runway/async" \
  -H "Authorization: Bearer $DOCREFINERY_API_KEY" \
  -F "[email protected]" \
  -F 'tags=["q3","acme"]'
Python
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"]
JavaScript (Node.js 20 or later)
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();
Response (excerpt, fictional values)
{
  "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.

Status check with curl
curl "$DOCREFINERY_API/v1/runway/9f1c0a7e?include_results=false" \
  -H "Authorization: Bearer $DOCREFINERY_API_KEY"
Run in progress (excerpt, fictional values)
{
  "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".

What each run status means
statusMeaningWhat to do
fulfilled, completedThe result is ready.Read result.
failed, errorThe run stopped. Pages are given back.Read error. See errors and limits.
awaiting_reviewdocRefinery wasn't sure what type of document this is, so a person has to decide.Stop polling. Review it in the dashboard.
Anything elseStill 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.

Finished invoice run (excerpt, fictional values)
{
  "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": []
  }
}
Keys in result.fields
KeyWhat it holds
aliasThe field or table name: the Return as alias set on your account.
textThe value, as a string. Numbers and dates arrive as text too.
field_confidenceA score from 0 to 1 for this value.
locationWhere 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.
tableOn a table item instead of text: a list of rows, each a list of cells shaped like a field.
Turn the fields into a dictionary (Python)
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"]
The same in JavaScript
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.

A field in the query (excerpt)
{
  "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_centre becomes Cost 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.

A flagged field (excerpt, fictional values)
{
  "extraction_approval": "human-approval",
  "low_confidence_fields": [
    {
      "alias": "due_date",
      "text": "2026-11-30",
      "confidence": 0.61,
      "threshold": 0.8,
      "reason": "low_confidence"
    }
  ]
}
Values of reason
reasonMeaning
low_confidenceThe value scored below its threshold.
missingA value was expected and not found.
invalid_formatThe value doesn't match the field's format.
rows_missingA 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.

Export one run as CSV
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

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.