DEVELOPER DOCUMENTATION

Keepando API

Stable REST endpoints for financial workspaces, imports, reconciliation, VAT reporting, and accountant access.

← Back to Keepando
GET/api/v1/overview

Summary metrics for the active financial period.

GET/api/v1/search?q=

Universal search across orders, line items, payments, fees, refunds, payouts, adjustments, bank transactions, documents and VAT periods. Every term must match; amounts such as €1,250.00, 58.79 or 58 are normalised. Filters: type, currency, from, to, amount_min, amount_max, limit, offset. Each result has a match_reason, reconciliation_status and related_count.

GET/api/v1/records/:type:id

Open a record with its overview, relationships, a bounded two-level relationship graph, timeline, reconciliation summary (matches, missing, uncertain, requires review) and VAT trace.

GET/api/v1/records/:type:id/relationships

List explicit and derived relationships with status, confidence, matching method and explanation.

GET/api/v1/records/:type:id/timeline

Chronological events: order created, payment processed, fee recorded, payout created, bank entry, VAT period.

GET/api/v1/records/:type:id/vat

VAT traceability: imported tax, line items with implied rates, refunds, supplier treatment indication, recoverability, VAT period and return status. Calculated values are never presented as submitted returns.

GET/api/v1/records/:type:id/candidates

Suggested bank payments for a document (or documents for a payment) with equal amounts. Candidates are not relationships until a user confirms one.

GET/api/v1/records/:type:id/history

Relationship audit trail: created, status changes, manual confirmations and unlinks, with actor.

GET · POST/api/v1/records/:type:id/comments

List or add notes on any record. Adding is owner-only.

PATCH/api/v1/documents/:id

Correct document details (including addressee, the name the invoice is issued to). Every change is audited with its source (user, ai_assistant, import). An AI-sourced change can update fields but cannot confirm a VAT treatment. Owner-only.

GET/api/v1/documents/:id/history

Field-level audit trail of document changes.

GET/api/v1/documents/queue?missing=1

Documents across all periods with the fields still missing (the AI assistant work queue).

GET/api/v1/documents/:id/file

The uploaded file itself, with its original content type.

GET/api/v1/documents/:id/file?download=1

The file inline (framable only by Keepando) or as a download.

POST/api/v1/documents/:id/replace

Multipart file + reason. Replaces the file with a corrected one; the old file becomes a version, links and notes stay, VAT decision returns to review.

GET/api/v1/documents/:id/versions · /versions/:versionId/file

Previous versions of a document and their files.

GET/api/v1/documents/:id/content

Extracted content: CSV/TXT text, XLSX sheets as CSV, PDF text layer (when pdftotext is installed).

POST/api/v1/documents/process · /api/v1/documents/:id/process

Deterministic processing: fills empty invoice fields that reconcile and prepares import proposals for recognised tables. Never imports records.

GET/POST/api/v1/import-proposals

List (status=pending|approved|rejected|superseded|all) or propose an import from a document: { document_id, type: bank|orders|payouts, column_mapping | rows, sheet, header_row, skip_rows, constants, date_format, decimal_separator, invert_amounts, notes }. Invalid rows return 422 with every error.

GET/POST/api/v1/import-layouts

List your layouts (status=active|pending|rejected|retired|superseded|all) and the built-in templates, or propose a layout {name, import_type, match_headers, spec, summary, example_document_id, replaces_id?}. Tried on the example document (422 with errors if it does not read it); returns a preview and waits for the owner. Assistants may propose.

POST/api/v1/import-layouts/:id/approve · /reject · /retire

Owner only. Once approved, every CSV/XLSX with the layout's match headers is read with it; the example document is processed again straight away.

GET/api/v1/import-proposals/:id

A proposal with all its rows.

POST/api/v1/import-proposals/:id/approve · /reject

Owner only. Approve runs the import pipeline (likely duplicates skipped unless include_likely_duplicates) and links records to the document.

GET/POST/api/v1/records/:type:id/issues

Issues on a record; raise one with { title, details, severity: info|warning|error, category }. Same open title is not duplicated.

GET/PATCH/api/v1/issues · /api/v1/issues/:id

List issues (status, period_id, severity); the owner sets status open|resolved|dismissed with a resolution.

GET · POST/api/v1/auth/2fa · /2fa/setup · /2fa/enable · /2fa/disable · /2fa/recovery-codes

Two-factor sign-in with an authenticator app (TOTP: 6 digits, 30 seconds). GET /2fa gives {available, enabled, enabled_at, recovery_codes_left}. setup {password} returns {secret, otpauth_url, qr_svg}; enable {code} turns it on with a code from the app, signs out other sessions and returns ten recovery_codes, shown once. disable and recovery-codes need {password, code} (an app code or a recovery code). Each app code is accepted once. Signed-in people only, not preview or assistants.

POST/api/v1/auth/login/verify

With two-factor on, /auth/login answers {two_factor_required: true, challenge} instead of signing in (or send code with the password). Send {challenge, code} here within five minutes: five tries per challenge. A recovery code works once and the answer includes recovery_codes_left. Accepting an invitation with an existing account that has two-factor also needs code.

POST/api/v1/auth/password

Signed-in person only (not preview or an assistant). {current_password, new_password} (10–200 characters). Signs out every other session of the account and returns {ok, other_sessions_signed_out}. Wrong current passwords are limited to 10 per 15 minutes (429 with Retry-After).

POST/api/v1/auth/verify-email · /auth/resend-verification

On a server that sends email, a new account confirms its address before anything opens: until then company endpoints answer 403 with code: "email_unverified", and user.email_verified is false in /auth/status. The emailed link KEEPANDO_PUBLIC_URL/app#verify=<token> works once, for 24 hours; /verify-email takes {token} (no sign-in). /resend-verification (signed in) sends a new link, replacing the old one; at most 3 an hour. A password reset or an accepted invitation also confirms the address.

GET/api/v1/storage/usage

{used_bytes, limit_bytes}: the company's stored documents, earlier versions included, against its limit. An upload that would pass the limit, or leave the server with too little free disk, answers 413 and saves nothing.

POST/api/v1/auth/forgot-password · /auth/reset-password

{email} always answers 202 with the same message, whether or not the account exists; if it does, a link KEEPANDO_PUBLIC_URL/#reset=<token> is emailed (30 minutes, once; a new request replaces it; at most 3 emails an hour per address). /reset-password takes {token, password} and signs out every session of the account. Two-factor is still asked at the next sign-in.

POST/api/v1/team/invites · /auth/invite-info · /auth/accept-invite

Owner: {email, role: "accountant"} creates an invitation (7 days) and emails the link KEEPANDO_PUBLIC_URL/#invite=<token>. Answers {email, role, expires_at, email_sent, email_error?, invite_url, invite_token}; pass the link on yourself when email_sent is false. /auth/invite-info {token} answers {email, role, company_name, expires_at, has_account, two_factor} (404 when invalid, used or expired). /auth/accept-invite {token, password, display_name?, code?}: a new account needs a name and a 10–200 character password; an existing account its password (and code with two-factor on). Tokens are stored hashed.

POST/api/v1/auth/login

{email, password}. After 10 wrong passwords for an email in 15 minutes, or 30 failed sign-ins from one address, it answers 429 with Retry-After. Writes signed in by cookie must come from Keepando's own pages (an Origin from another site gets 403); Bearer-token clients are not affected.

GET/api/v1/storage/check

Every stored file of the current company (documents and earlier versions) checked against the SHA-256 fingerprint taken when it was stored: {healthy, ok, missing, changed, not_fingerprinted}. An upload of a file the company already has returns same_file_as and raises a warning issue on the new document.

GET/POST/api/v1/businesses

The companies you can open, with your role in each (owner or accountant), and the current one. Every other endpoint works on the current company: the X-Keepando-Business: <id> header, else the company chosen in the app, else your first. A header naming a company you cannot open returns 404. POST {name, country, vat_frequency, financial_year_end_month} starts another company with you as its owner. Assistant tokens belong to one company and cannot start companies.

POST/api/v1/businesses/:id/select

Switch the app to another company you can open. Team, invitations and assistant tokens are per company; an accountant invited by several companies accepts each invitation with the same account.

POST/mcp

MCP over HTTP (JSON-RPC, JSON responses). Takes an OAuth access token or a hand-made assistant token as Authorization: Bearer …. Without a valid one it answers 401 with WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource", so an MCP client finds how to sign in.

GET/.well-known/oauth-protected-resource · /.well-known/oauth-authorization-server

OAuth for MCP. The resource is …/mcp and Keepando is its own authorization server: authorization code with PKCE (S256 only), public clients (no secret), refresh tokens replaced at every use. Access tokens last an hour; a connection unused for 60 days must be approved again.

POST/oauth/register · GET /oauth/authorize · POST /oauth/token · POST /oauth/revoke

Dynamic client registration {client_name, redirect_uris} (https, a loopback address or an app scheme; 20 an hour per address). /oauth/authorize is the page where the owner signs in (with two-factor) and approves one company they own; it answers the client with code, state and iss. /oauth/token takes authorization_code (code, redirect_uri, client_id, code_verifier) or refresh_token, form-encoded. A code works once; presenting it again ends the connection made with it. Each approval is a connection listed with the assistant tokens; approving the same client again for the same company replaces it.

GET/POST/DELETE/api/v1/assistant-tokens

Connected assistants (kind: "connection", made by OAuth) and owner-made tokens (kind: "token"), with connect (how to reach /mcp). DELETE ends either. PATCH /:id {can_apply} lets an assistant apply what the owner agrees to in the conversation (imports, VAT decisions, bank explanations, links, issues, layouts, filed returns), audited as assistant_for_owner; tokens, team and sign-in stay owner only. A token is shown once; an assistant can read, propose document fields and add notes, nothing else.

POST/api/v1/documents (assistant)

An assistant token may upload too (MCP upload_documents: paths on the owner's computer over stdio, file contents over HTTP). Only kind and period_id are taken from an assistant; details are read afterwards and audited. A file the company already has is not added again: it comes back as already_uploaded. Every document records added_by.

GET/POST · GET/DELETE/api/v1/upload-links · /upload-links/:id

A one-time link for the owner to drop files into the company: POST {note?} (owner or assistant) answers {id, url, expires_at, max_files}; the url is KEEPANDO_PUBLIC_URL/upload#<secret>, valid one hour, up to 200 files. GET /:id shows the files uploaded through it; DELETE /:id closes it. Only the secret's hash is stored. The page calls GET /api/upload (company name, expiry) and POST /api/upload (multipart files) with header X-Upload-Token, without signing in; a link only adds documents.

GET/POST · POST/api/v1/statements · /statements/:id/agree · /statements/:id/reject

For records with no receipt (a cash sale, mileage, a lost receipt, money lent to the company): POST {text, date, title?, cost?: {supplier_name, net, vat?, currency?}} writes the owner's words as a text document (kind owner_statement). From an assistant without permission to apply it is proposed; from the owner, agreed. Until agreed it cannot be edited, and import proposals from it cannot be approved (409). Agreeing a statement with a cost turns it into a cost (kind other_cost_invoice, VAT still in review); rejecting it rejects its pending imports. Documents and records show statement (status, text, cost) so records from statements are always marked as without a receipt.

GET/POST · PATCH · GET/api/v1/return-forms · /return-forms/:id · /return-forms/:id/return

Tax returns of any kind, for any country. Keepando computes fixed figures from the records (sales_tax, sales_net, purchases_tax_reclaimable, purchases_net, eu_goods_net, income, net_profit…; GET lists them as figures); a return form says how they fill a return's boxes: {name, frequency?, rounding: cents | whole_units, boxes: [{id, label, formula} | {id, label, manual: true, note}], rule_id?, replaces_id?} or {template: "ie_vat3" | "income_expenses"}. Formulas use figures, other box ids, + - * / ( ), max() min() abs() round(); they are parsed, never run as code, and may not loop. From an assistant without permission to apply a form is proposed; PATCH {status} (owner): proposed → active or rejected, active → retired (approving a new version retires the one it replaces). GET /:id/return?period_id=N|current or ?from&to fills every box with its amount, the figures and boxes it uses, and the records behind each figure. Keepando's calculation, not a filed return.

POST/api/v1/return-forms/:id/filed

Owner: record a return as filed, box by box: {period_id | from & to, boxes: {"T1": 474, …}, reference?, submitted_on?, status?: submitted | draft, document_key?}. GET /:id/return then shows filed and, on each box, filed and difference from Keepando's figure; it never changes the calculation. For the Ireland VAT3 form, a VAT3 recorded with /periods/:id/vat-returns counts as filed.

—Currency

Each company keeps its books in one currency (GET /api/v1/account: currency, currency_source: default, documents, owner or records). Unless the owner chooses it (POST /api/v1/businesses {currency}, or PUT /api/v1/account {currency} while the company has no records; 409 after), the first bank statement or sales export imported sets it. Amounts without a currency of their own are in it; foreign-currency invoices count at what the bank paid in it.

GET/POST · PATCH/api/v1/rules · /rules/:id

The owner's own rules for how the books are kept, in plain words (for example "Meals are not reclaimable"); Keepando stores them and does not interpret them. POST {text}: from an assistant without permission to apply it is proposed, from the owner active; the same words twice answer 409. PATCH {status} (owner): proposed → active or rejected, active → retired. The words never change; a retired rule keeps the decisions that followed it. GET lists each rule with its status, who proposed it and how many decisions cite it.

POST · DELETE/api/v1/documents/:id/vat-suggestions · /vat-suggestions/accept

An assistant suggests a VAT decision for an undecided invoice: {decision: confirmed | not_reclaimable | not_applicable, reason, rule_id?}. A cited rule must be active; the reason is required. The invoice stays in review until the owner agrees (POST …/accept, recorded with the reason and the rule) or rejects it (DELETE); a rejected decision cannot be suggested again for that invoice. PATCH /documents/:id also takes rule_id when the owner decides alone. Documents and records show vat_rule and vat_suggestion.

GET · POST/api/v1/periods/:id/vat-returns

Record a VAT3 exactly as filed (boxes T1–T4, E1, E2, ES1, ES2, PA1, notice number, submission date, source document). Shown beside Keepando's calculation; never alters it.

GET/api/v1/sales

Sales of the active period from the Shopify records: orders (gross incl. VAT, VAT, net), refunds, processing fees, and each payout with its order count and whether it reached the bank.

GET/api/v1/bank-reconciliation

Every bank line of the active period with its status: matched (payout or document), suggested (candidates to confirm), explained (owner: no document needed) or unexplained; plus a suggested “no document” category, invoices without a payment and payouts without a deposit.

PUT/DELETE/api/v1/bank-transactions/:id/explanation

Owner only. Mark a bank line as needing no document: { category: shopify_payout | no_vat | vat_not_reclaimable | bank_fee | director_funds | loan | tax | transfer | other, reason } (reason required for other; refused with 409 when the line is already matched). DELETE withdraws it; history is kept.

POST/DELETE/api/v1/bank-transactions/:id/explanation-suggestions

An AI assistant (or anyone) suggests why a bank line needs no document: { category, reason } with evidence. It shows on the line until the owner accepts it (PUT explanation) or rejects it (DELETE, owner); a rejected category cannot be suggested again for that line.

PUT/DELETE/api/v1/documents/:id/settlement

Owner only. Settle an invoice the bank lines do not fully account for: { kind: paid_elsewhere | difference_explained, reason, account?, personal? }. personal means the owner paid it, so it counts as owed to the owner; the open amount at that moment is recorded. DELETE withdraws it; history is kept.

POST/DELETE/api/v1/documents/:id/settlement-suggestions

An AI assistant suggests how the invoice was settled, with evidence; it waits for the owner to accept (PUT settlement) or reject (DELETE). A rejected kind cannot be suggested again for that invoice.

POST/api/v1/relationships

Body { source_key, target_key, relationship_type?, explanation? }. Confirms an existing suggested match, or attaches an uploaded document to a bank payment, payout, order, payment or another document. Imported records are never connected arbitrarily. Owner-only.

DELETE/api/v1/relationships/:id

Reject or unlink a relationship. Audited; automatic matching will not recreate it. Owner-only.

GET/api/v1/periods

List available periods. Activate one with POST /api/v1/periods/:id/activate.

POST/api/v1/import/:type

Import orders, payouts, or bank CSV data. Payouts may include optional order_ids references.

POST/api/v1/documents

Upload financial documents. Supports multiple files and ZIP folders with deterministic classification and invoice validation.

GET/api/v1/reconciliations

View deterministic Shopify payout to bank matches.

GET/api/v1/vat-reports

View source-traced VAT period calculations.

GET/api/v1/periods/:id/vat-report

VAT report for a period (current = active period), laid out like an accountant's VAT3 working paper: boxes (t1–t4, e1, e2, es1, es2, pa1), summary by VAT code, sales (orders; refunds with VAT as credit notes), purchases (confirmed VAT and zero-VAT invoices), not_claimed and undecided with reasons, eu_goods/eu_services, and comparison with a filed VAT3. ?format=html returns the printable page. Also ros (boxes in whole euros and the due date), settlement (the filed return's payment or repayment matched to a Revenue bank line) and revenue_lines. vies: the date the VAT number went on VIES (registered_on, set on the account as vies_registered_on) and the invoices from other EU countries that do not fit it: zero_vat_before (no VAT before that date) and foreign_vat_after (foreign VAT from that date).

GET/api/v1/periods/:id/profit-and-loss

Profit and loss for a period (current = active period): income, cost_of_sales and overheads by supplier (VAT you reclaim excluded, VAT you cannot reclaim included), Shopify and bank fees, bank payments explained without an invoice, gross_profit, net_profit, flags. Invoice basis, before year-end adjustments. ?format=html returns the printable page.

GET?from=YYYY-MM-DD&to=YYYY-MM-DD

Accepted by /api/v1/bank-reconciliation, /api/v1/documents, /api/v1/sales, /api/v1/order-reviews, /api/v1/reconciliations and /api/v1/documents/queue (all periods without dates). They then cover those dates, records by their own date (a document without an invoice date while its VAT period overlaps them), instead of the selected VAT period. 400 for invalid dates.

GET/api/v1/profit-and-loss?from=YYYY-MM-DD&to=YYYY-MM-DD

The same profit and loss for any date range. Records are taken by their own date (order, refund, invoice, fee and bank dates) instead of by VAT period. An invoice with no date counts only when the range covers its whole VAT period, and flags says when one is left out. Whole months are named like VAT periods (Jul–Aug 2026), other ranges by their dates. 400 for invalid dates or from after to. ?format=html returns the printable page.

GET/api/v1/order-reviews

Review refunds, voids, cancellations, Shopify notes, and accountant explanations.

GET/api/v1/account

Read the company identity used for invoice validation.

Machine-readable specification: OpenAPI JSON