Getting started
Three things have to happen before a document can be matched: an account, an order, and a PDF.
- Create an account. Email and a password. Your account is one workspace: everything you add lives in it and is visible to anyone you invite by sharing the login.
- Add an order. Paste one as JSON in the app, or post it to the API. This is what the document gets compared against, so it has to exist first.
- Send a document. Drop a PDF on the app, or set up email intake so suppliers reach you directly.
If you just want to see the shape of the thing, the app has a demo document and a sample order that pair with each other. Load the sample order, run the demo document, and you'll get one clean line, a price mismatch, and a missing line: enough to see what review actually feels like.
The order book
An order is a PO number, a supplier, a currency, and lines. The expected total is computed from the lines: you don't supply it.
{
"po_number": "PO-10432",
"supplier_name": "Kaimai Components Ltd",
"currency": "NZD",
"lines": [
{ "sku": "BRK-14A", "description": "Bracket, 14mm, zinc", "quantity": 40, "unit_price": 6.50 },
{ "sku": "GSK-22", "description": "Gasket set, viton", "quantity": 12, "unit_price": 35.00 },
{ "sku": "PLT-09", "description": "Cover plate, powder-coated", "quantity": 8, "unit_price": 69.50 },
{ "sku": "SHM-03", "description": "Shim, 0.5mm stainless", "quantity": 100, "unit_price": 0.80 }
]
}
sku or description is required on each line: a line with neither is dropped. Everything else is optional, but a line without a price can't produce a price mismatch, and a line without a quantity can't produce a quantity mismatch.
PO numbers are compared with punctuation and case removed, so PO-10432 in your system and po 10432 on their document are the same order.
Supplier hints
Optional, and worth doing for any supplier you hear from more than once.
| Field | What it's for |
|---|---|
name | The supplier's name as it appears on their documents. |
aliases | Semicolon-separated. Other spellings, abbreviations, and the domain they send from: kaimai.co.nz; KCL. This is how an emailed document gets matched to a profile. |
hints | Free text about their layout. "PO number is top-right under Your ref. Prices are ex-GST. Line codes in the first column." Goes into the prompt verbatim. |
On top of what you write, the five most recent extractions a person corrected and confirmed are kept and used as worked examples for that supplier.
HTTP API
All endpoints are under /api and return JSON. Everything except /api/health requires a signed-in session: the app uses a cookie set at login, so calls from your own browser are already authenticated. Requests that change state must come from the site's own origin.
| Method | Path | Purpose |
|---|---|---|
GET | /api/health | Liveness plus a database round-trip. No auth. |
POST | /api/auth/signup | {email, password, account_name?}: creates the account and signs you in. |
POST | /api/auth/login | {email, password} |
POST | /api/auth/logout | Ends the current session. |
POST | /api/auth/forgot | {email}: always returns 200, whether or not the address exists. |
POST | /api/auth/reset | {token, password}: consumes the token and signs you in. |
GET | /api/me | The signed-in user, their account, and this month's document count. |
GET POST | /api/orders | List, or create from the JSON above. |
GET POST | /api/suppliers | List, or upsert a supplier profile by name. |
POST | /api/documents | Multipart. file is the PDF, supplier_hint optional. Returns the extraction and the match. |
GET | /api/documents | History. Accepts q, status, flag, supplier, from, to, sort, limit, offset. |
GET | /api/documents/:id | One document, with the parsed extraction and match. |
POST | /api/documents/:id/confirm | {decision, extraction?}. Passing a corrected extraction re-matches and stores it as a worked example. |
POST | /api/documents/:id/feedback | {rating, note?}: was the read right. |
GET | /api/export/:id | The normalised payload. Confirmed documents only; 409 otherwise. |
GET PATCH | /api/settings | Webhook URL, price tolerances, and your own Anthropic key. |
GET | /api/audit | The account's audit log, newest first. |
POST | /api/billing/checkout | {plan}: returns a Stripe Checkout URL to redirect to. |
POST | /api/billing/portal | Returns a Stripe billing-portal URL for cards, invoices, and cancellation. |
POST | /api/billing/webhook | Stripe only. Signature-verified; no session. |
GET POST DELETE | /api/tokens | Ingest tokens for the email worker. The value is shown once, at creation. |
GET | /api/account/export | Everything in the account as one JSON file. |
Export payload
This is the contract. It doesn't change shape between suppliers, document types, or layouts: that's the entire point of the product. Fields that couldn't be read are null rather than absent.
{
"document_id": "doc_beeec89c59774bcba243",
"type": "order_confirmation",
"po_number": "PO-10432",
"supplier": "Kaimai Components Ltd",
"matched_order_id": "ord_e0185346d66f47a0bf7a",
"currency": "NZD",
"order_date": "2026-09-01",
"promised_ship_date": "2026-09-09",
"totals": {
"subtotal": 1284.00, "tax": 192.60, "shipping": 45.00, "grand_total": 1521.60
},
"lines": [
{
"sku": "GSK-22",
"description": "Gasket set, viton",
"quantity": 12,
"unit_price": 39.00,
"line_total": 468.00,
"match": "PRICE_MISMATCH",
"expected": { "quantity": 12, "unit_price": 35.00 },
"deltas": { "quantity": 0, "unit_price": 4.00 }
}
],
"exceptions": ["TOTAL_MISMATCH"],
"match_status": "NEEDS_REVIEW",
"review_status": "confirmed",
"confirmed_at": "2026-09-06T11:03:32.567Z"
}
| Field | Values |
|---|---|
type | order_confirmation, invoice, quote, unknown |
lines[].match | OK, QTY_MISMATCH, PRICE_MISMATCH, MISSING_IN_DOC, UNEXPECTED_IN_DOC |
exceptions[] | PO_UNMATCHED, TOTAL_MISMATCH, CURRENCY_MISMATCH, LOW_CONFIDENCE, LOW_CONFIDENCE_LINE, UNKNOWN_SKU |
match_status | OK or NEEDS_REVIEW: what the matching found. A confirmed document can still be NEEDS_REVIEW: that means a person looked at the exceptions and accepted them. |
review_status | pending, confirmed, or rejected: what the person decided. |
Read review_status to decide whether to act on the record. Read match_status and exceptions to decide whether to tell somebody about it.
Webhooks
Set a URL in settings and the payload above is posted there as application/json the moment a document is confirmed. The request is made once, inline with the confirm: a non-2xx response is recorded against the document but doesn't block the confirmation or get retried.
Your endpoint should be idempotent on document_id. If you need the record again later, fetch it from /api/export/:id rather than waiting for a replay.
Email intake
A separate Cloudflare Email Worker takes delivery of a supplier's reply, parses the MIME, and posts any PDF attachments in with the sender's address as the supplier hint. Documents that arrive this way are tagged source: "email" so you can tell them apart in history.
The worker has no browser and no session, so it authenticates with an ingest token instead. Create one in Settings, it is shown once, and give it to the worker with wrangler secret put INGEST_TOKEN. A token is scoped to one workspace and may only submit documents; it cannot read anything, and it is refused on every other route. Revoke it in Settings and delivery stops immediately.
You need a domain on Cloudflare with Email Routing turned on. Route a custom address, intake@yourdomain, to the worker. Mail that arrives without a PDF, or that fails to post, is forwarded to a fallback address instead of being dropped.
Connections
Two kinds, doing two different jobs.
Purchasing systems: they fill your order book
Cin7 Core, Katana, Unleashed. Connect one and your open purchase orders arrive on their own. Nothing to paste, nothing to keep in step by hand: press Sync and the order book matches what your purchasing system says.
Re-syncing updates an order rather than duplicating it, and a PO that changed upstream shows the change. Orders you entered by hand are never touched. Disconnecting keeps your imported orders; it only stops new ones arriving.
Xero and QuickBooks are not here yet. Both require an app registration rather than a pasted key, so they are their own piece of work.
Sales platforms: they lend their catalogue
Shopify, BigCommerce, WooCommerce, Ecwid, Squarespace, Wix, or Adobe Commerce. These hold no purchase order to import, but they know every SKU you stock.
These are sales systems, so there is no purchase order in them to import. What they have is every SKU you stock, and that changes what a supplier's document can tell you. A line that isn't on the order splits in two:
| Case | What it means |
|---|---|
| In your catalogue | Your product, on the wrong order. Usually a supplier consolidating shipments, or the wrong PO quoted. |
| Not one of your SKUs | Raises UNKNOWN_SKU. Either the reader misread the code, or you are being sent something you never ordered. |
Connect a store in Settings. You supply a read-only API credential, which is encrypted before it is stored and never shown again. Catalogue syncs are manual: press Sync after your product list changes.
Removing a connection removes its catalogue with it, so nothing keeps matching against data you thought you disconnected.
Billing
Subscriptions run on Stripe. Card details are entered on Stripe's own pages and never reach Linekeeper: the app only ever holds a customer id and a subscription status.
Choosing a plan in Settings creates a Checkout session and redirects you there. Everything afterwards, changing card, downloading invoices, switching plan, cancelling, happens in the Stripe billing portal, reachable from the same place.
Stripe is the source of truth. The plan and status stored against your account are written by webhook, not by the browser coming back from Checkout, so a closed tab or a failed redirect changes nothing. Events are verified by signature, deduplicated by id, and tolerant of arriving out of order.
| State | What happens |
|---|---|
active, trialing | Everything works. |
past_due | Still works. Stripe retries the payment and emails you. |
canceled, none | New documents are refused with 402. Everything already in the account stays readable and exportable: cancelling does not take your data away. |
Going over the plan allowance does not switch anything off. You will see it in Settings and we will talk about the tier.
Errors
Every failure returns the same shape, with an HTTP status that means what it says.
{ "error": "at least one line required", "code": "invalid_input" }
| Status | When |
|---|---|
400 | Malformed body, missing required field, unsupported file type. |
401 | No session, or it expired. Sign in again. |
403 | Cross-origin state change, or the record belongs to another account. |
402 | No active subscription. Start one in Settings. |
404 | No such record in your account. |
409 | The record isn't in a state that allows this: exporting a document nobody has confirmed, for instance. |
413 | File over 20 MB. |
429 | Rate limited. Applies to sign-in attempts and uploads. |
502 | Extraction failed upstream. The document is kept with status error so you can retry it. |
Limits
| Limit | Value |
|---|---|
| Maximum upload size | 20 MB per PDF |
| Accepted types | application/pdf only |
| Sign-in attempts | 10 per 15 minutes per address |
| Uploads | 60 per 5 minutes per account |
| Worked examples kept | 5 most recent per supplier |
| Session lifetime | 30 days, extended on use |
And the two product limits worth knowing before you build against this:
- One order per documentNo partial shipments, multi-PO documents, or back-order splits.
- Single workspaceNo multi-tenant accounts or per-user roles.