Linekeeper documentation
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. You're its owner; invite anyone else who needs in from Settings rather than sharing your own 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.
To see it working first, open the app and run one of the three demo documents. One press adds the matching order and checks the document against it. Demos are free and never count toward your allowance.
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. |
POST | /api/orders/import | Multipart. file is a CSV: one row per line item, columns po_number, supplier_name, currency, sku, description, quantity, unit_price. Rows sharing a po_number become one order; a row missing po_number, supplier_name, or both sku and description is skipped, not fatal to the rest. Always creates new orders, the same as POST /api/orders. Up to 5,000 rows. |
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. |
GET PUT | /api/documents/:id/receipt | Count a delivery against a confirmed document. GET returns the count sheet (template: each line with i, what the document says was sent as expected, on_document: false for an order line the document left off, and codes: box labels already linked to that item for this supplier) and the saved receipt, if any. PUT {lines: [{i, received, note?}], extras?: [{sku?, description?, received}], note?, version?} saves it: every line needs a number. The difference between what was counted and what the document said goes back on the order line, so an order that is now short reopens. A recount must send the version it was based on; 409 if someone saved in between. Needs the confirm permission. |
POST | /api/barcodes | Link a box label to an item for one supplier: {code, supplier_name, sku, description?}. Linking the same label again moves it. An all-digit code is stored without leading zeros, so the UPC and EAN forms of one barcode are the same label. Needs the confirm permission. |
GET | /api/documents/:id/price-history | For each item on this document, what the same supplier charged on earlier confirmed documents in the same currency: the first and last earlier price, the change since each in percent, whether it moved by more than your price tolerance, and whether it has only gone up. Items with no earlier confirmed price are left out. |
GET | /api/suppliers/price-drift | Every supplier item whose confirmed price has moved since the first document that carried it, by more than your price tolerance, biggest move first (up to 50): first and latest price with dates, the change in percent, and how many documents it is based on. |
GET POST | /api/memory | The workspace memory, newest first: ?supplier= to filter (any case), ?exclude_document= to leave out notes made from one document, ?limit= up to 500. Each note has a kind (price, quantity, missing, unexpected, correction, dispute, note), a source (linekeeper or you) and the document it came from. POST {body, supplier_name?} adds your own note, up to 1,000 characters. |
PATCH DELETE | /api/memory/:id | Rewrite any note, including one Linekeeper added (it is then marked edited), or delete it. An empty rewrite is refused: delete instead. |
GET | /api/documents/:id/deliveries | Every attempt to post this document to your webhook, newest first (up to 50): whether it was sent when confirmed or resent by hand, the attempt number, the receiving host, the result, and how long it took. Never the payload or the full URL, only the host. |
POST | /api/documents/:id/retry-webhook | Re-sends a confirmed document's payload to whatever webhook URL is configured now, using the same signature and payload the original send used. 409 if the document isn't confirmed yet, or no webhook URL is set. |
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. PATCH needs the settings permission. |
GET POST | /api/team | Owner only. List the account's users, or invite one: {email, can_confirm?, can_manage_settings?, can_manage_billing?}. Returns a one-time invite_link: there's no outgoing email, so this is copied and sent however you'd send anything else. Re-inviting a still-pending address updates its permissions and issues a fresh link. |
PATCH DELETE | /api/team/:id | Owner only. Change a member's permissions, or remove them (revokes their sessions immediately). Neither can target the owner's own row. |
GET | /api/audit | The account's audit log, newest first. |
POST | /api/billing/checkout | {plan}: returns a Stripe Checkout URL to redirect to. Needs the billing permission. |
POST | /api/billing/portal | Returns a Stripe billing-portal URL for cards, invoices, and cancellation. Needs the billing permission. |
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. POST/DELETE need the settings permission. |
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 when a document is confirmed. A failed delivery never undoes the confirm: the document is confirmed either way. A timeout, network error, or 5xx/429 gets one automatic retry a moment later. Anything else (a 4xx, or a second failure) is recorded against the document as is.
A confirmed document whose delivery failed can be sent again later with POST /api/documents/:id/retry-webhook: same payload, same signature, sent to whatever URL is set when you retry. The document's Review screen has a "Retry webhook" button whenever the last result wasn't a 2xx. Every attempt, automatic or by hand, is kept: expand "Delivery history" on the same screen, or call GET /api/documents/:id/deliveries.
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.
Verifying a request came from here
Generate a signing secret in Settings, shown once, and every request from then on carries a linekeeper-signature header shaped like Stripe's own: t=<unix seconds>,v1=<hex>. Recompute HMAC-SHA256 of {t}.{raw request body} with your secret and compare to v1; reject anything where they don't match, or where t is more than a few minutes old.
// Node, raw body as a string
const crypto = require("crypto");
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const expected = crypto.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
No secret generated yet: requests go out unsigned, exactly as before. Generating one doesn't require touching your existing webhook URL.
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 or session, so it uses an ingest token. Create one in Settings (it's shown once) and give it to the worker with wrangler secret put INGEST_TOKEN. A token belongs to one workspace and can only submit documents. It can't read anything and 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 don't hold purchase orders the way a purchasing system does, so pulling an order book from either isn't offered. What they do get is a confirmed document pushed the other way: see Accounting systems below.
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.
Accounting systems: they receive a confirmed Bill
Xero, QuickBooks, MYOB. Volume plan. Connect one and confirming a document also creates a Bill there, alongside your webhook. You sign in on the provider's own consent page (OAuth, like signing in to Linekeeper with Google or Microsoft), so no key is ever pasted. Nothing is sent until a document is confirmed.
The Bill lands as a draft wherever the provider supports one. Matching a document against a PO is a judgment this product already makes; approving a bill to pay is a different one, and stays yours.
If the provider stops accepting the saved sign-in, the document says its Bill wasn't sent and Settings shows Reconnect needed until someone reconnects. Disconnecting asks Xero or QuickBooks to revoke Linekeeper's access and stops future pushes. Bills already created stay where they are. MYOB has no way to revoke access from outside, so for MYOB also remove Linekeeper from its connected apps.
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.
Team & permissions
Whoever creates a workspace is its owner and can always do everything, including billing and managing the team. Everyone the owner invites is a member. Members can review and export, plus whichever of these the owner grants:
| Permission | Grants |
|---|---|
can_confirm | Confirm or reject a document: the action that fires the webhook, pushes to accounting, and draws down the order book. |
can_manage_settings | Change the webhook URL and price tolerances, and connect or disconnect purchasing, sales, and accounting systems. |
can_manage_billing | Start a subscription, buy a document pack, and open the Stripe billing portal. |
Inviting, removing and changing permissions is owner-only and can't be granted, so a member can never widen their own access. There's no invite email: inviting someone returns a one-time link (see the API table above) for the owner to pass on, the same way a forgotten-password link works.
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 |
What's worth knowing before you build against this:
- Multi-PO documentsa consolidated invoice can match more than one order: a line whose own PO differs from the document's is matched against its own order, and
po_number/matched_order_idin the payload still name the primary one. The rest show up assecondary_posat the top level andpo_numberon the affected lines, both absent entirely on the ordinary single-PO document. - Partial shipments and back-order splitsan order stays open across however many documents it takes to fill; each is compared against what's actually still owed, not the original quantity, and it closes itself once every line is fully delivered.
- Rolesone workspace, multiple users: an owner plus invited members, each with their own
can_confirm/can_manage_settings/can_manage_billingflags. A member withoutcan_confirmgets a 403 from confirm/reject; check the flags onGET /api/mebefore assuming write access.