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:

  1. Create an account and confirm your email address.
  2. Create an API key on the account page. It is shown once, so copy it somewhere safe.
  3. Put your key in an environment variable, then send an order:
Bash
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:

Bash
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:

Bash
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:

Bash
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:

Bash
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:

Bash
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

extract.pyPython
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

extract.mjsJavaScript
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:

JSON
{
  "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

FieldTypeMeaning
is_purchase_orderbooleanFalse when the file does not look like an order. The other fields are still filled from what could be read.
pagesintegerPages read.
source_typestringpdf, png, jpeg, webp, tiff or gif.
text_layerbooleanTrue when the PDF had selectable text, which is read alongside the page images.

header

FieldTypeMeaning
po_numberstringThe order number as printed.
po_datedateYYYY-MM-DD.
revisionstringA revision or change number of the order, if printed.
currencystringISO 4217 code, such as GBP, EUR or USD. Taken from the printed currency or from the symbol on the amounts.
quote_referencestringThe supplier quote the order refers to.
requested_delivery_datedateThe delivery date for the whole order.
payment_termsstringAs printed, for example Net 30.
shipping_methodstringAs printed.
incotermsstringAs printed.
notesstringInstructions 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:

FieldTypeMeaning
namestringA name printed on the address, such as a department or site.
linesstring[]Street lines, in the order printed. The town and postcode are not repeated here.
citystringTown or city.
regionstringCounty, state or province, only when printed.
postal_codestringUK postcodes are put in the standard form, LS11 5QR.
countrystringISO 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.

FieldTypeMeaning
line_numberintegerPosition from 1, even when the document numbers lines 10, 20, 30.
buyer_part_numberstringThe buyer's own code for the item.
supplier_part_numberstringThe supplier's code for the item.
descriptionstringAs printed.
quantitynumber
unit_of_measurestringA UN/ECE Recommendation 20 code for common units (EA, BX, PR, CS, PK, KGM, LTR, MTR and others), otherwise as printed, in capitals.
unit_pricenumber
discount_percentnumber
tax_rate_percentnumberThe line's rate, or the order's rate when only one is printed.
line_totalnumberThe amount printed for the line, usually before tax. Null when none is printed.
delivery_datedateA 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:

NameWhat is checked
line_amountsQuantity times unit price, less any discount, matches each line amount, with or without the line's tax added.
lines_sum_to_subtotalThe line amounts add up to the subtotal.
total_adds_upSubtotal, 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.

JSON
"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:

JSON
{ "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:

Bash
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 busy and 503 model_offline: wait for the number of seconds in Retry-After, then send again.
  • 503 from our web server, which is not JSON: the API is restarting. Wait 30 seconds and send again.
  • 502 model_error and 500 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 for 402 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:

JSON
{
  "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:

JSON
{ "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:

JSON
{ "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": { "...": "..." } } }
StatusCodeMeaning
400empty_file, bad_multipartNothing usable was sent.
400file_requiredA multipart body with no field named file.
400bad_requestThe request URL could not be read.
401missing_api_key, invalid_api_keyNo key, or one that is wrong or revoked.
402quota_exceededNot enough pages left this period. error.usage holds the same fields as GET /v1/usage.
404not_foundNo such endpoint, or no asynchronous result with that id (they are kept for one hour).
405method_not_allowedThe endpoint exists but not with this method. The Allow header lists the ones it takes.
413file_too_large, too_many_pagesOver 20 MB (20,971,520 bytes), or more pages than your plan reads in one document.
415file_requiredA JSON body was sent instead of the document.
415unsupported_typeNot a PDF or a supported image.
422encrypted_pdf, unreadable_fileThe file could not be opened.
429too_many_in_flightYour 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.
500server_errorSomething went wrong on our side. Try again, and tell us if it keeps happening.
502model_errorThe document could not be read this time. Try again.
503busyEvery slot is in use. Wait for the number of seconds in Retry-After.
503model_offlineReading is paused for a while, for example for maintenance. Try again after the number of seconds in Retry-After.
502noneA 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.
503noneThe 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

FreeStarterBusiness
Pages a month505002,000
Longest document10 pages30 pages50 pages
Largest file20 MB20 MB20 MB
Documents being read at once248
Queueafter paid plansahead of Freefirst

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