Menu
Cookbooks
API reference
- Overview
- Companies
- Customers
- Invoices
- Articles
- Suppliers
- Supplier invoices
- Supplier payment files
- Expense claims
- Transactions
- Reconciliation
- Bank accounts
- Journal entries
- Voucher gap explanations
- Fiscal periods
- Accounts
- Fixed assets
- Documents
- Inbox items
- Dimensions
- Employees
- Salary runs
- Reports
- Imports
- Compliance check
- Skatteverket
- Peppol
- Webhooks
- Operations
- Health
Changelog
Reverse-chronological release notes for the Accounted REST API. Versions follow Stripe's dated format (
YYYY-MM-DD). The current version is2026-05-12.
2026-05-12 (current)
The first stable release of the public REST API. Six phases of development covering the full agent-native surface: authentication + discovery, invoicing vertical, transactions vertical, bookkeeping engine + suppliers + compliance check, payroll + reports + import, webhooks.
Authentication + discovery (Phase 1)
- API key auth via
Authorization: Bearer gnubok_sk_<random>(live keys) /gnubok_sk_test_<random>(test keys). 100 RPM rate limit per key. gnubok_sk_test_*keys: same company as a live key, every write forced into dry-run (X-Gnubok-Mode: test). There is no separate sandbox company or host.- Scope-based authorisation per endpoint (
invoices:read,payroll:write,webhooks:manage, ...). - Discovery:
GET /llms.txt,GET /api/v1/openapi.json,GET /.well-known/skills/index.json. - Health:
GET /api/v1/health. - Response envelope:
{ data, meta: { request_id, api_version, audit, next_cursor } }. X-Request-Idon every response; idempotency on every write.
Invoices vertical (Phase 2)
- Customers: GET list + detail, POST create + bulk-create, PATCH, DELETE.
- Invoices: GET list + detail, POST create, PATCH, lifecycle verbs
/mark-sent,/mark-paid,/credit,/send,/bulk-create. PDF download at/{id}/pdf. - VIES validation runs on commit for EU-business customers with a VAT number.
- Mixed-rate invoices supported: per-item
vat_rateoverrides the header rate. - ROT/RUT-avdrag flow and supplier-invoice fakturamodellen on the AP side.
Transactions vertical (Phase 3)
- Transactions: cursor-paginated GET list + detail. Single-tx verbs
/categorize,/uncategorize,/match-invoice,/match-supplier-invoice. Bulk/ingest(up to 500),/batch-categorize(up to 100). - Reconciliation:
POST /reconciliation/bank/run,GET /reconciliation/bank/status. - Reads:
GET /accounts,GET /fiscal-periods. - All write surfaces honour strict-mode (commit fully or error with no side effects).
Invoices (2026-09)
- Behaviour change: Swedish VAT for an EU business established in Sweden (#2783, 2026-09-20):
POST /invoicesandPATCH /invoices/{id}(when items are replaced) now refuse reverse charge for a customer withcustomer_type=eu_businesswhosecountryisSE, even when itsvat_numberis VIES-validated. Such an invoice used to be created at 0 % (vat_treatment=reverse_charge, ruta 39, with the reverse-charge notice); it is now created with Swedish VAT (vat_treatment=standard_25, ruta 05, no notice) and lines that omitvat_ratedefault to 25 %. This is the rule the dashboard, the MCP tools andPOST /invoices/bulk-createalready applied, and the one this page has described since the entry below: the two single-invoice routes never read the customer's country, so the same customer got 0 % here and 25 % everywhere else. A buyer established in Sweden owes Swedish VAT whatever foreign VAT number it holds (ML 6 kap. 34 §: a B2B service is taxed where the buyer is established, so reverse charge exists only when that is another member state). The response says why:meta.warningscarriesEU_BUSINESS_COUNTRY_IS_SEwithmessage_sv,message_enand aremediation, on the live call and on a dry run. Nothing is refused and no status code changed. If you relied on the old result, the customer row is what to fix: setcountryto where the buyer is actually established (PATCH /customers/{id}), or changecustomer_typetoswedish_businessif it is Swedish. A genuinely foreign customer with a validated number is unaffected and still gets reverse charge. Replacing the items of an existing draft re-decides the treatment from the customer's current row, so a draft created at 0 % under the old behaviour moves to Swedish VAT the next time its items are replaced. Invoices already sent are not touched. - VAT-treatment warnings (#2749, #2558, 2026-09-20):
POST /invoices,PATCH /invoices/{id}(when items are replaced) andPOST /invoices/bulk-create(per item) now say why the VAT treatment is what it is, without changing it. Reverse charge (0 %, ruta 39) needscustomer_type=eu_business, a VIES-validatedvat_numberand a country other than SE; when one of those is missing the draft gets Swedish VAT as before and the response carriesmeta.warnings(EU_BUSINESS_VAT_NUMBER_NOT_VALIDATED,EU_BUSINESS_VAT_NUMBER_MISSING,EU_BUSINESS_COUNTRY_IS_SE), each withmessage_sv,message_enand aremediation. A Swedish rate set explicitly on a line to a validated EU or non-EU business is still accepted (the ML 6 kap. supplies taxed where they are performed) but flagged asSWEDISH_VAT_TO_REVERSE_CHARGE_CUSTOMER/SWEDISH_VAT_TO_EXPORT_CUSTOMER. Warnings never fail a request; dry runs return the same list.meta.warningsis a new optional field on every envelope, absent when there is nothing to say.
Customers (2026-09)
- Sole traders (enskild firma) (#2367, 2026-09-13): an
org_numbershaped like a Swedish personnummer is now accepted oncustomer_type=swedish_business. A sole trader has no separate organisationsnummer: its owner's personnummer IS the firm's org number, so the old blanket400 CUSTOMER_ORG_NUMBER_IS_PERSONALmade a real customer impossible to register as a business. The refusal now applies toeu_businessandnon_eu_businessonly, where the value cannot be an org number at all;individualis unchanged (a personnummer there is still moved intopersonal_number). The data-minimisation reason behind the old rule moved to the read paths where it belongs:GET /customersreturnsorg_numberandvat_numberasnullfor any row whoseorg_numberis a personnummer, the masking individual rows already had.GET /customers/{id}still returns the full value.
Chart of accounts (2026-09)
- Order (2026-09-11):
GET /accountsreturns accounts inaccount_numberorder, the BAS sequence it always documented. It used to sort by the storedsort_order, which is0on every account seeded at company creation, so the seeded accounts came first and the rest followed. - Class filter:
?classaccepts any digit0-9, the first digit ofaccount_number. Class9appears on internal accounts carried over from an imported chart and could not be filtered on before. - Schema: the response schema lists the
account_typevalues (untaxed_reservesincluded),normal_balanceanddefault_vat_treatment. No field was added or removed, and the API version date is unchanged. - Journal entry dry runs:
POST /journal-entries?dry_run=truenow resolves the lines' accounts against the chart and fails with400 ACCOUNTS_NOT_IN_CHARTfor a deactivated account or a non-BAS number the chart does not contain, the same verdict as the live call. A standard BAS account that is not in the chart yet still passes: the live call adds it.
OpenAPI spec and reference pages (2026-09)
- Query parameters in the spec:
/api/v1/openapi.jsonnow lists every endpoint's query parameters (filters, paginationcursor/limit, reportperiod_idand date ranges,expand) with type, requiredness and description, anddry_runon every dry-run-capable endpoint. Before, the spec carried path parameters only. - Nullable fields: nullable fields are published as
type: [T, "null"](OpenAPI 3.1). Before, they read as non-null, so a strict generated client could reject a valid response. - Reference pages: each endpoint section shows its query parameters, request body fields and response fields. New pages: Bank accounts (
/cash-accounts,/bank-connections), Skatteverket (filed VAT declarations) and Health; company settings are on Companies, vacation-year close on Salary runs. - Documentation only: no request or response changed, and the API version date is unchanged.
Reconciliation, account-keyed (2026-08)
- Accounts:
GET /reconciliation/accountslists every account with an outside truth (bank accounts asbank:<cash_account_id>, the skattekonto asskattekonto) with status;GET .../accounts/{accountKey}is the bridge (outside balance, ledger, difference, unexplained, explanatory lines, counts, latest sign-off);GET .../accounts/{accountKey}/itemsthe rows behind it, bucketed (proposed, unmatched_external, unmatched_ledger, matched, ignored, upcoming). - Links:
POST .../accounts/{accountKey}/links(pairs oruse_proposals),DELETE .../links/{linkId},POST .../items/{itemId}/ignore. Links never touch the ledger. - Sign-off:
POST .../accounts/{accountKey}/signoff("avstämt t.o.m." a date; refused with an unexplained difference unless forced with a note),GET .../signoffhistory,POST .../signoff/{signoffId}/reopen. - New scopes
reconciliation:read,reconciliation:write,reconciliation:signoff. The legacy/reconciliation/bank/*endpoints and theirtransactions:*scopes are unchanged. - Webhooks: new event types
reconciliation.matched,reconciliation.unmatched,reconciliation.signed_off,reconciliation.reopened. Additive: existing subscriptions are unaffected and the API version date is unchanged.
Reports, companies, Skatteverket, customers, transactions (2026-08, additive)
Backfilled 2026-08-26 from merged PRs. Every item is additive (new endpoints, optional fields, optional filters): the API version date stays 2026-05-12.
- Custom date ranges on reports (#1909, 2026-08-25):
GET /reports/income-statementaccepts optionalfrom_date/to_date(YYYY-MM-DD, both inside the fiscal period named byperiod_id,from_date <= to_date).GET /reports/balance-sheetis a position, so it takes onlyto_date(or its aliasas_of) and refusesfrom_date. Omit them for the whole period, as before. These routes now reject unknown query parameters with400 VALIDATION_ERROR(unknown_params+allowed_paramsin details) instead of silently returning a full-period report. - Report PDFs (#1909):
GET /reports/balance-sheet/pdfandGET /reports/income-statement/pdfreturnapplication/pdf, byte-equivalent to the dashboard export, with the sameperiod_idand range parameters. Scopereports:read. - Company creation (#1864, 2026-08-25):
POST /api/v1/companies(scopecompanies:write) creates a company and sets it up in one call: owner membership, BAS chart of accounts for the company form, compliance settings, the first fiscal period and the automatic tax deadlines. A VAT-registered company must sendmoms_period. Not idempotent, andIdempotency-Keyis not honoured on this company-less route: listGET /api/v1/companiesbefore retrying. - Filed VAT declarations (#1773, 2026-08-21):
GET /skatteverket/vat-declarations?period_type=&year=&period=(scopecompliance:read) reads one period's momsdeklaration as Skatteverket has it on file:submitted(SKV inlämnat) anddecided(SKV beslutat), eachnullwhen nothing is on file. Live read, requires an active Skatteverket connection on the company. - Company settings write (#1405, 2026-08-04):
PATCH /companies/{companyId}/settings(scopecompanies:write, risk medium, dry-run supported) updates bank details (bank_name,clearing_number,account_number,bankgiro,plusgiro,swish,iban,bic),contact_person,email,phone,websiteandinvoice_email_texts, with the same validation as the MCP tool. - Customer
personal_number(#1724 2026-08-20, #1788 2026-08-21):personal_numberon customer create, bulk-create, detail, PATCH and list. Accepted only forcustomer_type=individual, stored encrypted, always returned masked (********-1234); sending the masked form on PATCH means "leave unchanged",nullclears it. A personnummer-shapedorg_numberon a business customer type is refused (400 CUSTOMER_ORG_NUMBER_IS_PERSONAL); on an individual it is moved intopersonal_numberandorg_numberis cleared, or refused with400 CUSTOMER_PERSONAL_NUMBER_CONFLICTwhen it differs from apersonal_numberin the same body. - Ignore a transaction (#1661, 2026-08-29):
POST /transactions/{id}/ignore(scopetransactions:write, risk low, idempotent, dry-run supported) marks an unbooked bank transaction as ignored without writing a verifikat, so it is allowed in a locked or closed period: the path for rows that are not business events (PSD2 ghost rows, duplicates, transfers that never executed).DELETEon the same path restores the row. A booked transaction (directly, via a payment allocation, or via a voucher link) is refused with409 TX_IGNORE_ALREADY_BOOKED. In the same change,/categorizeand/batch-categorizeansweris_business: falsein a locked period with400 TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED(details.suggested_action: "ignore") instead of a barePERIOD_LOCKED: a private marking is a real eget uttag / insättning booking, so the lock still applies to it. - Transactions by bank account (#1809, 2026-08-23): transaction list and detail carry
cash_account_id;GET /transactions?cash_account_id=<uuid>filters to one bank account.
Bookkeeping primitives + AP + compliance (Phase 4)
- Suppliers + supplier-invoices vertical (mirror of Phase 2 invoices on the AP side).
- Journal entries primitives:
POST /journal-entries(draft+commit),/{id}/commit,/{id}/reverse(storno) and/{id}/correct(rättelse): both satisfy BFL 5 kap 5 § (storno is the canonical method of rättelse),/batch-create. - Voucher gap explanations:
POST /voucher-gap-explanationsper BFNAR 2013:2. - Fiscal-periods ops:
/lock,/closeand/opening-balancesare synchronous and return 200 with the updated period;/year-endand/currency-revaluationare async and return 202 with operation_id (poll atGET /api/v1/operations/{id}). - Compliance check:
GET /compliance/check?type={year_end_readiness|voucher_gaps}: pre-flight findings before submission. - Documents:
POST /documents(multipart upload, magic-number-checked),GET /{id}/download(15-min signed URL),POST /{id}/link(attach to journal entry).
Payroll + reports + import (Phase 5)
- Employees: full CRUD with personnummer masking on list/create per GDPR Art.5(1)(c). Soft-delete via
is_active. - Salary runs: CRUD + lifecycle verbs
/calculate,/approve,/mark-paid,/book,/generate-agi. State machine: draft → review → approved → paid → booked./generate-agiproduces and persists the arbetsgivardeklaration XML: the response carries it asdata.xmlfor the integrator to upload to Skatteverket Mina Sidor (or via the optionalskatteverketextension). Accounted does NOT auto-submit; the AGI deadline: the 12th of the following month for every reporting period EXCEPT January and August, where companies with annual turnover ≤ 40 MSEK get the 17th: is the integrator's responsibility. - JSON reports (13): trial-balance, balance-sheet, income-statement, general-ledger, journal-register, vat-declaration, monthly-breakdown, ar-ledger, supplier-ledger, continuity-check, salary-journal, avgifter-basis, vacation-liability.
- Binary report:
GET /reports/sie-export(text/plain SIE4 file). Note: a SIE4 export alone does NOT satisfy BFL 7 kap archiving obligations: SIE captures account-level positions and verifikationer but lacks system documentation and behandlingshistorik. Treat SIE as a portability format (Fortnox/Visma/Bokio migration), not as a complete archive. - Async imports:
POST /imports/sie(multipart, 50 MB),POST /imports/bank(multipart, 10 MB, auto-format detection across 12 bank formats). Both async viaoperationssubstrate. Post-SIE-import warning: SIE files do NOT carry VAT codes or tax-rate-to-account mappings, AND they do NOT transfer behandlingshistorik (the source system's processing log required by BFNAR 2013:2 kap 8 §) or systemdokumentation. After importing from Fortnox / Visma / BL / SpeedLedger / Bokio you MUST manually reconfigure VAT codes (typically via/settings/tax-codes) before the first momsdeklaration; skipping this step is the most common source of incorrect VAT submissions in migrated bookkeeping. The behandlingshistorik gap must be preserved separately: under BFNAR 2013:2 kap 8 § the obligation attaches to the entire räkenskapsår, not from the import date forward. Best practice for a mid-year migration: export the source system's behandlingshistorik for the full fiscal year and archive it alongside the SIE file. Accounted starts a fresh behandlingshistorik from the import date forward; the pre-import portion of the year remains the source system's record.
2026-05-15: Webhooks (Phase 6 PR-1)
- Subscriptions:
POST /webhooks(HMAC secret returned exactly once), GET list + detail, PATCH, DELETE. Per-event-type elevated scope check (salary_run.*andagi.generatedrequirepayroll:read). - Delivery substrate: dispatched immediately after the event is enqueued, with a per-minute Vercel cron at
/api/webhooks/dispatch/cronas the retry and sweep path. Due rows are claimed atomically via theclaim_due_webhook_deliveriesSQL function (FOR UPDATE SKIP LOCKED), with*.pg.test.tscoverage for the claim path and the webhook DB triggers. Exponential backoff1m / 5m / 30m / 2h / 12h / 24h / 48h(7 retries, ~87h total). HTTP 410 from receiver auto-disables the webhook. - Signature:
X-Gnubok-Signature: t=<unix>,v1=<hex-HMAC-SHA256>. Stripe-format. Sample receivers in Node + Python. - SSRF protection: webhook_url must be HTTPS; resolved IPs in private/loopback/link-local/CGNAT/cloud-metadata ranges are rejected at create AND dispatch time. Dispatch pins the validated IP through a DNS-rebinding-safe
node:https.requestagent (lib/webhooks/pinned-fetch.ts); redirects are refused on every outbound POST (any 3xx is treated as a blocked redirect). - Audit + retention: webhook delivery rows are behandlingshistorik per BFNAR 2013:2 kap 8 §: immutable once terminal so the audit trail of what an integration was notified of stays intact. Delivery rows are NOT räkenskapsinformation themselves; the 7-year statutory retention under BFL 7 kap 1 § applies only to the underlying verifikation / faktura / AGI XML in its own table, NOT to the delivery envelope. Accounted keeps accounting-event delivery rows for 7 years as a voluntary operational policy (the duration aligns with BFL 7 kap on the underlying records but is not itself a statutory obligation on delivery rows). Webhook DELETE preserves the delivery audit trail (
ON DELETE SET NULLonwebhook_id). Webhook lifecycle events (create / update / delete, plus dispatcher auto-disable) each write a V16audit_logentry. - Verbs:
POST /webhooks/{id}/testenqueues a synthetic event;POST /webhook-deliveries/{id}/retryre-enqueues a dead/delivered delivery.
Coming soon (Phase 6 PR-2 hardening)
- 90-day TTL cleanup cron for non-accounting webhook deliveries
- Per-route rate limits on
:test,:retry, and webhook:create - Populated
previous_attributesfor update-style webhook events