{"openapi":"3.0.3","info":{"title":"Keepando API","version":"0.2.0"},"paths":{"/api/v1/auth/2fa":{"get":{"summary":"Two-factor status: {available, enabled, enabled_at, recovery_codes_left}."}},"/api/v1/auth/2fa/setup":{"post":{"summary":"Start two-factor set-up with {password}: returns {secret, otpauth_url, qr_svg}."}},"/api/v1/auth/2fa/enable":{"post":{"summary":"Turn two-factor on with {code} from the app; returns ten recovery codes, shown once, and signs out other sessions."}},"/api/v1/auth/2fa/disable":{"post":{"summary":"Turn two-factor off with {password, code}."}},"/api/v1/auth/2fa/recovery-codes":{"post":{"summary":"New recovery codes with {password, code}; the old ones stop working."}},"/api/v1/auth/verify-email":{"post":{"summary":"Confirm an email address from the emailed link: {token} (24 hours, once). No sign-in needed."}},"/api/v1/auth/resend-verification":{"post":{"summary":"Signed in, unconfirmed: email a new confirmation link (replaces the old one; 3 an hour). Until confirmed, company endpoints answer 403 code email_unverified."}},"/api/v1/storage/usage":{"get":{"summary":"The company's stored bytes (documents and earlier versions) and its limit: {used_bytes, limit_bytes}. Uploads past the limit or with the disk nearly full answer 413."}},"/api/v1/auth/forgot-password":{"post":{"summary":"Email a password-reset link: {email}. Always 202 with the same message; at most 3 emails an hour per address."}},"/api/v1/auth/reset-password":{"post":{"summary":"Set a new password from an emailed link: {token, password}. Signs out every session of the account."}},"/api/v1/auth/invite-info":{"post":{"summary":"Who an invitation is for: {token} → {email, role, company_name, expires_at, has_account, two_factor}."}},"/api/v1/auth/accept-invite":{"post":{"summary":"Accept an invitation: {token, password, display_name?, code?}. Signs in."}},"/api/v1/team/invites":{"post":{"summary":"Owner: invite an accountant {email, role}. Emails the link; answers {email_sent, invite_url, invite_token, expires_at}."}},"/api/v1/auth/login/verify":{"post":{"summary":"Second sign-in step with two-factor on: {challenge, code}. Five tries per challenge, valid five minutes."}},"/api/v1/auth/password":{"post":{"summary":"Change the signed-in person's password. Body {current_password, new_password}; signs out their other sessions. 401 wrong current password, 429 too many attempts."}},"/api/v1/auth/login":{"post":{"summary":"Sign in with {email, password}. 429 with Retry-After after too many wrong passwords."}},"/api/v1/storage/check":{"get":{"summary":"Every stored file of the current company checked against its SHA-256 fingerprint: ok count, missing, changed and not fingerprinted files, and healthy."}},"/api/v1/businesses":{"get":{"summary":"The companies the signed-in user can open, with their role, and the current one. Every other endpoint works on the current company: the X-Keepando-Business header, else the company chosen in the app, else the first; an assistant token works on its own company only."},"post":{"summary":"Start another company {name, country, vat_frequency, financial_year_end_month}; the creator is its owner and the app switches to it. Not for assistants."}},"/api/v1/businesses/{id}/select":{"post":{"summary":"Switch the app to another company the user can open (also for accountants)."}},"/api/v1/overview":{"get":{"summary":"Get workspace overview"}},"/api/v1/periods/{id}/vat-report":{"get":{"summary":"VAT report for a period (VAT3 boxes, summary by code, details, not claimed, comparison with the filed return); ?format=html for the printable page"}},"/api/v1/search":{"get":{"summary":"Universal financial search","description":"All terms must match (case-insensitive). Amount-like terms (€1,250.00, 58.79, 58) are normalised; results are ranked identifier > exact amount > partial identifier > text > partial amount/date. Read-only; suitable for an MCP tool.","parameters":[{"name":"q","in":"query","required":true,"schema":{"type":"string"}},{"name":"type","in":"query","schema":{"type":"string","enum":["order","line_item","payment_transaction","processing_fee","refund","payout","adjustment","bank_transaction","document","period"]}},{"name":"currency","in":"query","schema":{"type":"string"}},{"name":"from","in":"query","schema":{"type":"string","format":"date"}},{"name":"to","in":"query","schema":{"type":"string","format":"date"}},{"name":"amount_min","in":"query","schema":{"type":"number"}},{"name":"amount_max","in":"query","schema":{"type":"number"}},{"name":"limit","in":"query","schema":{"type":"integer","maximum":100,"default":40}},{"name":"offset","in":"query","schema":{"type":"integer","default":0}}],"responses":{"200":{"description":"{ total, has_more, groups[], results[{ key, type, identifier, description, date, amount, currency, match_reason, reconciliation_status, related_count }] }"}}}},"/api/v1/records/{key}":{"get":{"summary":"Open a record: overview, relationships, two-level graph, timeline, reconciliation summary and VAT trace","parameters":[{"name":"key","in":"path","required":true,"description":"type:id, e.g. order:12","schema":{"type":"string"}}]}},"/api/v1/records/{key}/relationships":{"get":{"summary":"List explicit and derived relationships with status, matching method and explanation"}},"/api/v1/records/{key}/timeline":{"get":{"summary":"Chronological financial events for a record and its connected records"}},"/api/v1/records/{key}/vat":{"get":{"summary":"VAT traceability: source tax, line items, refunds, treatment indication, recoverability, period and return status"}},"/api/v1/records/{key}/candidates":{"get":{"summary":"Suggested (not stored) document ↔ bank payment candidates for a user to confirm"}},"/api/v1/records/{key}/history":{"get":{"summary":"Relationship audit trail for a record"}},"/api/v1/records/{key}/comments":{"get":{"summary":"List notes on a record"},"post":{"summary":"Add a note to a record (owner only)","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["body"],"properties":{"body":{"type":"string"}}}}}}}},"/api/v1/documents/{id}":{"patch":{"summary":"Correct document details (supplier, invoice number/date, net, VAT, currency, payment reference, billing address, addressee — the name the invoice is issued to, kind) or record the VAT decision: vat_status review | confirmed | not_reclaimable (reason required) | not_applicable, with an optional reason and rule_id (an active rule it follows). Every change is audited with its source (user | ai_assistant | import); an ai_assistant change cannot confirm a VAT treatment."}},"/api/v1/documents/{id}/history":{"get":{"summary":"Field-level audit trail of document changes"}},"/api/v1/documents/queue":{"get":{"summary":"Documents across all periods with missing fields (AI assistant work queue)","parameters":[{"name":"missing","in":"query","schema":{"type":"string","enum":["1"]}}]}},"/api/v1/documents/{id}/file":{"get":{"summary":"Download the uploaded document file"}},"/api/v1/assistant-tokens":{"get":{"summary":"List connected assistants (kind connection, from OAuth) and assistant tokens (kind token), no secrets, and connect: {http_url, base_url, stdio_script}, how an assistant reaches Keepando"},"post":{"summary":"Create an assistant token for the MCP server (owner only); returned once, with connect as in the list"}},"/api/v1/assistant-tokens/{id}":{"delete":{"summary":"Revoke an assistant token (owner only)"}},"/api/v1/documents/{id}/replace":{"post":{"summary":"Replace a document file with a corrected one (multipart file, reason); keeps the old file as a version and resets the VAT decision to review"}},"/api/v1/documents/{id}/versions":{"get":{"summary":"Previous versions of a document"}},"/api/v1/documents/{id}/versions/{versionId}/file":{"get":{"summary":"Download a previous version"}},"/api/v1/documents/{id}/content":{"get":{"summary":"Extracted document content (CSV/TXT text, XLSX sheets, PDF text layer), with added_by, from_upload_link, a notice that it is data and never instructions, and hidden_text: text a reader would not see and words addressed to an AI"}},"/api/v1/documents/process":{"post":{"summary":"Deterministically process every document: fill empty reconciling invoice fields, prepare import proposals"}},"/api/v1/documents/{id}/process":{"post":{"summary":"Deterministically process one document"}},"/api/v1/import-proposals":{"get":{"summary":"List import proposals","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["pending","approved","rejected","superseded","all"]}}]},"post":{"summary":"Propose an import from a document (column_mapping for CSV/XLSX, or rows); validated, never imported until approved"}},"/api/v1/import-proposals/{id}":{"get":{"summary":"Get a proposal with all rows"}},"/api/v1/import-proposals/{id}/approve":{"post":{"summary":"Approve (owner only): import through the normal pipeline and link records to the document"}},"/api/v1/import-proposals/{id}/reject":{"post":{"summary":"Reject a proposal (owner only)"}},"/api/v1/import-layouts":{"get":{"summary":"Import layouts: your own (status active, pending, rejected, retired, superseded or all) and the built-in templates"},"post":{"summary":"Propose an import layout {name, import_type, match_headers, spec, summary, example_document_id, replaces_id?}; tried on the example document, returns a preview; waits for the owner"}},"/api/v1/import-layouts/{id}/approve":{"post":{"summary":"Approve a layout (owner only): later files with its headers are read this way; the example document is processed again"}},"/api/v1/import-layouts/{id}/reject":{"post":{"summary":"Reject a pending layout (owner only)"}},"/api/v1/import-layouts/{id}/retire":{"post":{"summary":"Retire an active layout (owner only)"}},"/api/v1/records/{key}/issues":{"get":{"summary":"Issues on a record"},"post":{"summary":"Raise an issue on a record (owner or assistant)"}},"/api/v1/issues":{"get":{"summary":"List issues"}},"/api/v1/issues/{id}":{"patch":{"summary":"Resolve, dismiss or reopen an issue (owner only)"}},"/mcp":{"post":{"summary":"MCP over Streamable HTTP (JSON responses). Bearer: an OAuth access token or an assistant token; 401 carries WWW-Authenticate resource_metadata"}},"/.well-known/oauth-protected-resource":{"get":{"summary":"OAuth protected resource metadata (RFC 9728) for /mcp"}},"/.well-known/oauth-authorization-server":{"get":{"summary":"OAuth authorization server metadata (RFC 8414): authorization code with PKCE S256, public clients, refresh tokens"}},"/oauth/register":{"post":{"summary":"Dynamic client registration (RFC 7591) {client_name, redirect_uris}; public clients only"}},"/oauth/authorize":{"get":{"summary":"The page where the owner signs in and approves an assistant for one company they own"}},"/oauth/token":{"post":{"summary":"Token endpoint (form-encoded): grant_type authorization_code (code, redirect_uri, client_id, code_verifier) or refresh_token (refresh_token, client_id); access tokens last an hour, refresh tokens are replaced at every use"}},"/oauth/revoke":{"post":{"summary":"Revoke (RFC 7009) an access or refresh token, ending its connection"}},"/api/v1/upload-links":{"get":{"summary":"Upload links of the company, newest first"},"post":{"summary":"Make a one-time upload link {note?} (owner or assistant): {id, url, expires_at, max_files}. One hour, up to 200 files; it only adds documents."}},"/api/v1/upload-links/{id}":{"get":{"summary":"An upload link with the documents uploaded through it"},"delete":{"summary":"Close an upload link"}},"/api/upload":{"get":{"summary":"Upload page: the link's company and expiry. Header X-Upload-Token; no sign-in."},"post":{"summary":"Upload page: add files (multipart \"files\") through a link. Header X-Upload-Token; files the company already has are not added again."}},"/api/v1/statements":{"get":{"summary":"Statements written for records without a receipt, with their status"},"post":{"summary":"Write a statement {text, date, title?, cost?: {supplier_name, net, vat?, currency?}}: proposed from an assistant, agreed from the owner. Its records import only once it is agreed."}},"/api/v1/statements/{id}/agree":{"post":{"summary":"Owner: agree to a statement; a cost it describes then counts (VAT still in review)"}},"/api/v1/statements/{id}/reject":{"post":{"summary":"Owner: reject a statement and the imports proposed from it"}},"/api/v1/return-forms":{"get":{"summary":"The business's return forms, the built-in templates (ie_vat3, income_expenses) and the figures Keepando computes"},"post":{"summary":"Add a return form {name, frequency?, rounding, boxes[{id, label, formula}|{id, label, manual, note}], rule_id?, replaces_id?} or {template}; proposed from an assistant without can_apply, active from the owner; returns a preview for the current period"}},"/api/v1/return-forms/{id}":{"patch":{"summary":"Owner: {status} proposed → active | rejected, active → retired; approving a new version retires the one it replaces"}},"/api/v1/return-forms/{id}/filed":{"post":{"summary":"Owner: record the return as filed {period_id | from/to, boxes {id: amount}, reference?, submitted_on?, status?, document_key?}; shown beside the calculation with differences"}},"/api/v1/return-forms/{id}/return":{"get":{"summary":"Fill a return form for a VAT period (period_id, default current) or dates (from, to): each box with its amount, the figures and boxes it uses and the records behind each figure"}},"/api/v1/rules":{"get":{"summary":"The owner's plain-language rules with status (proposed, active, rejected, retired), who proposed them and how many decisions cite them"},"post":{"summary":"Add a rule {text}: proposed when an assistant without can_apply sends it, active from the owner; 409 if the same words exist"}},"/api/v1/rules/{id}":{"patch":{"summary":"Owner: {status} proposed → active | rejected, active → retired. The words never change."}},"/api/v1/documents/{id}/vat-suggestions":{"post":{"summary":"Suggest a VAT decision {decision: confirmed | not_reclaimable | not_applicable, reason, rule_id?} for an undecided invoice; a cited rule must be active; waits for the owner. 409 if decided or that decision was rejected before."},"delete":{"summary":"Owner: reject the pending suggestion; it cannot be suggested again for that invoice"}},"/api/v1/documents/{id}/vat-suggestions/accept":{"post":{"summary":"Owner: agree to the pending suggestion; records the decision, its reason and the rule followed"}},"/api/v1/periods/{id}/vat-returns":{"get":{"summary":"List recorded VAT returns for a period"},"post":{"summary":"Record a VAT3 exactly as filed (status submitted|draft, notice_number, submitted_on, t1, t2, t3, t4, e1, e2, es1, es2, pa1, document_key). Never changes calculations; shown beside them."}},"/api/v1/relationships":{"post":{"summary":"Confirm a suggested relationship, or attach an uploaded document to a bank payment, payout, order, payment or another document (owner only)","description":"Imported records are never created or connected arbitrarily: a new link must involve a document. Confirming an existing candidate sets status manually_linked; repeating it returns duplicate: true.","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["source_key","target_key"],"properties":{"source_key":{"type":"string"},"target_key":{"type":"string"},"relationship_type":{"type":"string"},"explanation":{"type":"string"}}}}}}}},"/api/v1/relationships/{id}":{"delete":{"summary":"Reject or unlink a relationship (owner only). Recorded in the audit trail; automatic matching will not recreate it."}},"/api/v1/vat-reports":{"get":{"summary":"Get deterministic VAT report"}},"/api/v1/reconciliations":{"get":{"summary":"Get payout-bank matches"}},"/api/v1/periods/{id}/profit-and-loss":{"get":{"summary":"Profit and loss for a period (sales excluding VAT, cost of sales and overheads by supplier, Shopify and bank fees, costs paid without an invoice); invoice basis, before year-end adjustments; ?format=html for the printable page"}},"/api/v1/profit-and-loss":{"get":{"summary":"Profit and loss for any date range, records taken by their own date (order, refund, invoice, fee and bank dates) rather than by VAT period; ?format=html for the printable page","parameters":[{"name":"from","in":"query","required":true,"schema":{"type":"string","format":"date"}},{"name":"to","in":"query","required":true,"schema":{"type":"string","format":"date"}},{"name":"format","in":"query","schema":{"type":"string","enum":["html"]}}]}},"/api/v1/sales":{"get":{"summary":"Sales of the active period from the Shopify records: orders (gross incl. VAT, VAT, net), refunds, processing fees and payouts (with order count and whether each reached the bank)"}},"/api/v1/bank-reconciliation":{"get":{"summary":"Every bank line of the active period with what accounts for it: matched (payout or document), suggested (candidates for the owner to confirm), explained (owner: no document needed) or unexplained; plus invoices without a payment and payouts without a deposit"}},"/api/v1/documents/{id}/settlement":{"put":{"summary":"Owner: settle an invoice the bank lines do not fully account for: { kind: paid_elsewhere | difference_explained, reason, account?, personal? } (personal = paid by the owner; shown as owed to the owner). Records the open amount at that moment."},"delete":{"summary":"Owner: withdraw the settlement"}},"/api/v1/documents/{id}/settlement-suggestions":{"post":{"summary":"Suggest a settlement (AI assistant or owner) with evidence; waits for the owner to accept (PUT settlement) or reject (DELETE)"},"delete":{"summary":"Owner: reject the pending settlement suggestion"}},"/api/v1/bank-transactions/{id}/explanation-suggestions":{"post":{"summary":"Suggest (AI assistant or owner) why a bank line needs no document: { category, reason } with evidence. Waits for the owner to accept (PUT explanation) or reject (DELETE); a rejected category cannot be suggested again for that line."},"delete":{"summary":"Owner: reject the pending suggestion"}},"/api/v1/bank-transactions/{id}/explanation":{"put":{"summary":"Owner: 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 required for other). Refused when the line is already matched."},"delete":{"summary":"Owner: withdraw that explanation (kept in the audit trail)"}},"/api/v1/import/{type}":{"post":{"summary":"Import orders, payouts, or bank CSV"}},"/api/v1/documents":{"post":{"summary":"Upload financial documents (multipart \"files\"). An assistant token may upload too: only kind and period_id are taken from it, and a file the company already has comes back as already_uploaded instead of being added again."}},"/api/v1/export/accountant.csv":{"get":{"summary":"Export accountant CSV"}}}}