API documentation
How to send a purchase order to the VioPO API and read the JSON that comes back. Authentication, endpoints, the output schema, errors and limits.
Opens at launch VioPO is not open yet, so these requests will not work today. This is the full reference for the API as it will open, for planning an integration now. Keys come with an account, and accounts open at launch. Join the waitlist
The VioPO API takes one document, a purchase order as a PDF or an image, and returns one JSON object in a fixed format. There is one endpoint for the work, one to collect asynchronous results, and four small ones around them: usage, the schema, health and plans.
The base address is https://viopo.co.uk. Every response from the API is JSON. The two exceptions come from our web server and are listed under Errors.
Quick start
Once VioPO is open:
- Create an account and confirm your email address.
- Create an API key on the account page. It is shown once, so copy it somewhere safe.
- Put your key in an environment variable, then send an order:
export VIOPO_KEY=vpo_live_...
curl https://viopo.co.uk/v1/extract \
-H "Authorization: Bearer $VIOPO_KEY" \
-H "Content-Type: application/pdf" \
--data-binary @order.pdf
A one page order usually comes back in 20 to 40 seconds. Longer documents take longer, roughly in proportion to the number of pages.
To check that your key works without using a page, ask for your usage:
curl https://viopo.co.uk/v1/usage -H "Authorization: Bearer $VIOPO_KEY"
No order to hand? Download one of our sample orders and send that:
curl -o order.pdf https://viopo.co.uk/samples/sample-po-uk-fixings.pdf
You can see what comes back without a key, today. The saved result of a real run on that sample is a static file, and so are the results for the other samples:
curl -o result.json https://viopo.co.uk/samples/uk-fixings.json
Authentication
Send your key in the Authorization header as a bearer token:
Authorization: Bearer vpo_live_...
The X-Api-Key header is accepted as well. Keys start with vpo_live_. We store only a hash of each key, so a lost key cannot be shown again. Revoke it on the account page and create a new one.
Keep keys on your server. A key in a web page or a mobile app can be copied by anyone who uses it.
Sending a document
POST /v1/extract
The body is the document. There are two ways to send it.
As the raw request body, with the document's content type:
curl https://viopo.co.uk/v1/extract \
-H "Authorization: Bearer $VIOPO_KEY" \
-H "Content-Type: image/jpeg" \
--data-binary @photo-of-order.jpg
Or as multipart/form-data, with the file in a field named file:
curl https://viopo.co.uk/v1/extract \
-H "Authorization: Bearer $VIOPO_KEY" \
-F "file=@order.pdf"
The type is worked out from the file itself, not from its name or the declared content type. Accepted: PDF, PNG, JPEG, WebP, TIFF (each frame is a page) and GIF. A GIF or WebP counts as one page, and only its first frame is read. Password protected PDFs are refused; send them without the password.
From Python
import os
import requests
with open("order.pdf", "rb") as f:
r = requests.post(
"https://viopo.co.uk/v1/extract",
headers={"Authorization": f"Bearer {os.environ['VIOPO_KEY']}"},
files={"file": f},
timeout=300,
)
r.raise_for_status()
order = r.json()["data"]
print(order["header"]["po_number"], len(order["lines"]), "lines")
From Node
import { readFile } from 'node:fs/promises';
const res = await fetch('https://viopo.co.uk/v1/extract', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VIOPO_KEY}`,
'Content-Type': 'application/pdf',
},
body: await readFile('order.pdf'),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error.message);
console.log(body.data.header.po_number, body.data.totals.total);
Set a client timeout of at least five minutes for synchronous requests, or use asynchronous requests. For documents of more than a few pages, use asynchronous requests. A synchronous request that has no answer after 30 minutes gets 502 from our web server, with a page that is not JSON, and is not charged. A client that gives up sooner may still be charged for the document, because we cannot always tell that it has gone. The time of a synchronous request includes any time it waits in the queue.
The response
A successful request answers 200 with this envelope:
{
"id": "ext_7mQk2pX9aLr4vT8cWb3nHs6e",
"object": "extraction",
"status": "succeeded",
"created": "2026-10-04T09:12:44.120Z",
"model": "qwen3-vl:30b-a3b-instruct",
"pages": 1,
"processing_ms": 21480,
"data": { "schema": "viopo.po.v1", "...": "..." },
"usage": { "pages_charged": 1, "pages_remaining": 49 }
}
data is the order, in the format below. usage tells you what this request cost and what is left in the current period.
For a complete example, see the result for our UK sample order, saved from a real run.
The output format
data always has the same keys, in version viopo.po.v1. A value that is not on the document is null. A list with nothing in it is []. No key is ever left out, so you can read any field without checking that it exists first.
The machine-readable JSON Schema is published at /schema/po.v1.json, and the API serves the same file at GET /v1/schema. Here is what each part holds.
document
| Field | Type | Meaning |
|---|---|---|
is_purchase_order | boolean | False when the file does not look like an order. The other fields are still filled from what could be read. |
pages | integer | Pages read. |
source_type | string | pdf, png, jpeg, webp, tiff or gif. |
text_layer | boolean | True when the PDF had selectable text, which is read alongside the page images. |
header
| Field | Type | Meaning |
|---|---|---|
po_number | string | The order number as printed. |
po_date | date | YYYY-MM-DD. |
revision | string | A revision or change number of the order, if printed. |
currency | string | ISO 4217 code, such as GBP, EUR or USD. Taken from the printed currency or from the symbol on the amounts. |
quote_reference | string | The supplier quote the order refers to. |
requested_delivery_date | date | The delivery date for the whole order. |
payment_terms | string | As printed, for example Net 30. |
shipping_method | string | As printed. |
incoterms | string | As printed. |
notes | string | Instructions printed on the order. |
buyer and supplier
Both have the same fields: name, contact_name, email, phone, tax_id (VAT, GST, EIN and similar, as printed), company_number, account_reference (a vendor or customer number one party gives the other) and address.
Addresses
buyer.address, supplier.address, ship_to, bill_to and invoice.address are all address objects, or null:
| Field | Type | Meaning |
|---|---|---|
name | string | A name printed on the address, such as a department or site. |
lines | string[] | Street lines, in the order printed. The town and postcode are not repeated here. |
city | string | Town or city. |
region | string | County, state or province, only when printed. |
postal_code | string | UK postcodes are put in the standard form, LS11 5QR. |
country | string | ISO 3166-1 alpha-2 code, such as GB or US. When no country is printed it is taken from a UK postcode or a US state and ZIP code, and otherwise left null. |
invoice
Where the buyer wants invoices sent: email, address (the same as bill_to) and instructions, such as "quote the PO number on every invoice".
lines
One entry per item ordered, in the printed order. Subtotal, tax and carriage rows are not lines.
| Field | Type | Meaning |
|---|---|---|
line_number | integer | Position from 1, even when the document numbers lines 10, 20, 30. |
buyer_part_number | string | The buyer's own code for the item. |
supplier_part_number | string | The supplier's code for the item. |
description | string | As printed. |
quantity | number | |
unit_of_measure | string | A UN/ECE Recommendation 20 code for common units (EA, BX, PR, CS, PK, KGM, LTR, MTR and others), otherwise as printed, in capitals. |
unit_price | number | |
discount_percent | number | |
tax_rate_percent | number | The line's rate, or the order's rate when only one is printed. |
line_total | number | The amount printed for the line, usually before tax. Null when none is printed. |
delivery_date | date | A delivery date for this line only. |
totals
subtotal, discount, shipping, tax and total, all numbers or null.
Checks and warnings
VioPO does not trust its own reading. After every document it checks the figures against each other and reports the result in checks:
| Name | What is checked |
|---|---|
line_amounts | Quantity times unit price, less any discount, matches each line amount, with or without the line's tax added. |
lines_sum_to_subtotal | The line amounts add up to the subtotal. |
total_adds_up | Subtotal, less discount, plus shipping and tax, equals the total. |
A check is listed only when the figures it needs are on the document. A difference of a penny, or 0.1% on large amounts, is allowed for rounding.
"checks": [
{ "name": "line_amounts", "passed": true, "detail": "Quantity times unit price matches the line amount on 5 of 5 lines." },
{ "name": "total_adds_up", "passed": false, "detail": "Subtotal plus tax is 1357.85; the total printed is 1375.85." }
]
A failed check does not mean the reading is wrong. Sometimes the document itself does not add up. Either way, it is the order to show to a person before it goes into your system.
warnings lists anything else worth knowing: a date that could not be read, no order number found, a quantity that was worked out from the line amount rather than read.
A simple rule that works for most teams: post the order automatically when checks is not empty, every check passed and there are no warnings, and send everything else to a queue for review. An empty checks list means no figures could be checked, not that they agree.
Asynchronous requests
Add ?async=true to send a document and collect the result later. The value must be exactly true. The request answers 202 as soon as the document is in the queue, with a Location header that holds the address of the result:
{ "id": "ext_7mQk2pX9aLr4vT8cWb3nHs6e", "object": "extraction", "status": "processing", "pages": 3 }
Errors found before the document is queued, such as a missing key, too few pages left, too many pages or busy, are not a 202. They are answered straight away, with their own status and code, as in a synchronous request.
Then poll GET /v1/extractions/{id} every few seconds, with a key on the same account as the key that sent the document:
curl "https://viopo.co.uk/v1/extract?async=true" \
-H "Authorization: Bearer $VIOPO_KEY" \
-F "file=@order.pdf"
curl https://viopo.co.uk/v1/extractions/ext_7mQk2pX9aLr4vT8cWb3nHs6e \
-H "Authorization: Bearer $VIOPO_KEY"
While it runs the answer is 200 with "status": "processing". When it finishes the answer is the same envelope as a synchronous request. A failed one has "status": "failed" and an error with the same code and message the synchronous request would have given, and it is not charged.
Results are held in memory for one hour after they finish, and then dropped. They are never written to disk. Only asynchronous requests are held: a synchronous result cannot be fetched again. If our server restarts, documents still in the queue and results not yet collected are lost, and polling answers 404. Those documents are not charged, so send them again.
There are no webhooks. Poll for the result.
Retries
There is no idempotency key, so the API cannot tell a retry from a new request. Retry only when the error says nothing was read:
429 too_many_in_flight,503 busyand503 model_offline: wait for the number of seconds inRetry-After, then send again.503from our web server, which is not JSON: the API is restarting. Wait 30 seconds and send again.502 model_errorand500 server_error: send again once or twice. If it keeps failing, check that the file opens normally.- Any other
4xx: do not retry. Fix the request, or stop, as for402 quota_exceeded.
A synchronous request that your client gives up on may still be read and charged, so sending it again can charge twice. For long documents, or a slow connection, use ?async=true: the 202 comes back at once and polling costs nothing.
Usage
GET /v1/usage returns the allowance for the current period. It needs your key, and it does not use a page:
{
"plan": "starter",
"pages_included": 500,
"pages_used": 132,
"pages_remaining": 368,
"period_start": "2026-10-01T00:00:00.000Z",
"period_end": "2026-11-01T00:00:00.000Z"
}
pages_used includes documents that are still being read. If one of them fails, its pages are given back.
Health and plans
These two need no key.
GET /v1/health says whether the service is up and whether the model is ready to read documents:
{ "status": "ok", "model_ready": true, "queue": { "running": 1, "waiting": 0 } }
When model_ready is false, extraction requests answer 503 model_offline until it is back.
GET /v1/plans lists the plans, with id, name, price_gbp, pages_per_month and max_pages_per_document for each.
Errors
Errors use the HTTP status and a JSON body with a stable code and a sentence for a person:
{ "error": { "code": "quota_exceeded", "message": "This document needs 3 pages. You have 1 of your 50 pages left this period. Upgrade on the account page, or wait for the next period.", "usage": { "...": "..." } } }
| Status | Code | Meaning |
|---|---|---|
| 400 | empty_file, bad_multipart | Nothing usable was sent. |
| 400 | file_required | A multipart body with no field named file. |
| 400 | bad_request | The request URL could not be read. |
| 401 | missing_api_key, invalid_api_key | No key, or one that is wrong or revoked. |
| 402 | quota_exceeded | Not enough pages left this period. error.usage holds the same fields as GET /v1/usage. |
| 404 | not_found | No such endpoint, or no asynchronous result with that id (they are kept for one hour). |
| 405 | method_not_allowed | The endpoint exists but not with this method. The Allow header lists the ones it takes. |
| 413 | file_too_large, too_many_pages | Over 20 MB (20,971,520 bytes), or more pages than your plan reads in one document. |
| 415 | file_required | A JSON body was sent instead of the document. |
| 415 | unsupported_type | Not a PDF or a supported image. |
| 422 | encrypted_pdf, unreadable_file | The file could not be opened. |
| 429 | too_many_in_flight | Your account already has as many documents being read as its plan allows (see Limits). Wait for one to finish. Retry-After gives the seconds to wait. |
| 500 | server_error | Something went wrong on our side. Try again, and tell us if it keeps happening. |
| 502 | model_error | The document could not be read this time. Try again. |
| 503 | busy | Every slot is in use. Wait for the number of seconds in Retry-After. |
| 503 | model_offline | Reading is paused for a while, for example for maintenance. Try again after the number of seconds in Retry-After. |
| 502 | none | A synchronous request had no answer after 30 minutes. This answer comes from our web server and is not JSON. It was not charged. Use asynchronous requests for long documents. |
| 503 | none | The API is restarting. This answer comes from our web server, is not JSON and has no Retry-After. Wait 30 seconds and try again. |
Check the Content-Type of an error before you parse it. Only application/json answers have a code.
You are never charged for a request that fails.
Limits
| Free | Starter | Business | |
|---|---|---|---|
| Pages a month | 50 | 500 | 2,000 |
| Longest document | 10 pages | 30 pages | 50 pages |
| Largest file | 20 MB | 20 MB | 20 MB |
| Documents being read at once | 2 | 4 | 8 |
| Queue | after paid plans | ahead of Free | first |
A page is one page of a PDF or one image. The allowance resets at the start of each billing period, or on the first of the month on Free. Every page read is counted, blank pages and documents that are not orders included (is_purchase_order is then false). The largest file is 20 MB, which is 20,971,520 bytes.
Documents being read at once counts synchronous requests, asynchronous requests and test documents sent from the account page together. When the queue is long, a Free account may be asked to wait with busy while paid plans are still queued.
Privacy
Documents will be read by a model running on hardware we operate. They will be held in memory, and in a temporary file while their pages are rendered, and deleted as soon as they have been read. They will not be kept or used for training. For each request we will store its id, the number of pages, the file type, how long it took and whether it succeeded, so we can show you your usage. The privacy notice for the service will be published when VioPO opens.
Versions
The format is versioned by data.schema. Fields may be added to viopo.po.v1, but none will be removed or change type. A change that would break a client gets a new version and the old one stays available.
The published schema is strict: it lists every field of today and allows no others. When a field is added, the schema at /schema/po.v1.json and GET /v1/schema changes the same day. If you validate responses, fetch the schema rather than keeping a copy, or allow unknown fields.
Questions about the API: contact@viopo.co.uk.
Updated