Linekeeper

Getting started

Three things have to happen before a document can be matched: an account, an order, and a PDF.

  1. 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.
  2. 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.
  3. 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.

FieldWhat it's for
nameThe supplier's name as it appears on their documents.
aliasesSemicolon-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.
hintsFree 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.

MethodPathPurpose
GET/api/healthLiveness 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/logoutEnds 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/meThe signed-in user, their account, and this month's document count.
GET POST/api/ordersList, or create from the JSON above.
GET POST/api/suppliersList, or upsert a supplier profile by name.
POST/api/documentsMultipart. file is the PDF, supplier_hint optional. Returns the extraction and the match.
GET/api/documentsHistory. Accepts q, status, flag, supplier, from, to, sort, limit, offset.
GET/api/documents/:idOne 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/:idThe normalised payload. Confirmed documents only; 409 otherwise.
GET PATCH/api/settingsWebhook URL, price tolerances, and your own Anthropic key.
GET/api/auditThe account's audit log, newest first.
POST/api/billing/checkout{plan}: returns a Stripe Checkout URL to redirect to.
POST/api/billing/portalReturns a Stripe billing-portal URL for cards, invoices, and cancellation.
POST/api/billing/webhookStripe only. Signature-verified; no session.
GET POST DELETE/api/tokensIngest tokens for the email worker. The value is shown once, at creation.
GET/api/account/exportEverything 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"
}
FieldValues
typeorder_confirmation, invoice, quote, unknown
lines[].matchOK, 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_statusOK 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_statuspending, 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:

CaseWhat it means
In your catalogueYour product, on the wrong order. Usually a supplier consolidating shipments, or the wrong PO quoted.
Not one of your SKUsRaises 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.

StateWhat happens
active, trialingEverything works.
past_dueStill works. Stripe retries the payment and emails you.
canceled, noneNew 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" }
StatusWhen
400Malformed body, missing required field, unsupported file type.
401No session, or it expired. Sign in again.
403Cross-origin state change, or the record belongs to another account.
402No active subscription. Start one in Settings.
404No such record in your account.
409The record isn't in a state that allows this: exporting a document nobody has confirmed, for instance.
413File over 20 MB.
429Rate limited. Applies to sign-in attempts and uploads.
502Extraction failed upstream. The document is kept with status error so you can retry it.

Limits

LimitValue
Maximum upload size20 MB per PDF
Accepted typesapplication/pdf only
Sign-in attempts10 per 15 minutes per address
Uploads60 per 5 minutes per account
Worked examples kept5 most recent per supplier
Session lifetime30 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.