Menu

Changelog

Reverse-chronological release notes for the Accounted REST API. Versions follow Stripe's dated format (YYYY-MM-DD). The current version is 2026-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-Id on 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_rate overrides 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).

Expense claims (2026-10)

  • Reverse charge on an utlägg (crm#1028, 2026-10-09): POST /expense-claims accepts reverse_charge (eu_services | non_eu_services) for a service under huvudregeln (SaaS, licences, ads, consulting) from a seller abroad that charged no VAT. Hotel, restaurant, taxi, travel and event costs abroad are taxed where performed and take no reverse_charge. The verifikat then also carries the fiktiv moms 2645 D / 2614 K on the SEK amount and the basis pair 4535 (ruta 21) or 4531 (ruta 22) D / 4598 K, left out when expense_account reports that ruta itself. It needs vat_amount 0, no lines and an expense_account that reports no other box (400 otherwise). Additive: nothing changes for a request without it.

API keys: inspect a key's grant (2026-10)

  • GET /api/v1/api-key (2026-10-07): returns the calling key's mode (live | test), its scopes, company_access (all_memberships or allowlist) and every company it reaches with role, access (read_write | read_only) and read_only_code (ROLE_READ_ONLY | CONNECTION_READ_ONLY, the codes a refused write answers with). Any valid key may call it, whatever its scopes, including keys issued through MCP OAuth; no key gives 401. Check a key when a customer connects it instead of waiting for the first 403: a document upload to company X needs documents:write in scopes, X with access read_write, and mode live (a test key gets 403 TEST_KEY_WRITE_BLOCKED on uploads, which cannot be dry-run). Additive.
  • The reference pages said live keys start gnubok_sk_live_. They start gnubok_sk_ followed directly by the random part; only test keys carry an infix (gnubok_sk_test_). The OpenAPI bearerFormat now says the same.

Invoices (2026-10)

  • Domestic reverse charge for construction services (crm#1007, 2026-10-05): POST /invoices and PATCH /invoices/{id} accept vat_treatment=reverse_charge_domestic, omvänd betalningsskyldighet under ML 16 kap. 13 § (the buyer is a construction business and the supply a construction service). The invoice gets 0 % on every line, moms_ruta 41, revenue on 3231 when sent, and the notice "Omvänd betalningsskyldighet, byggtjänster, ML 16 kap. 13 §" with the buyer's VAT number on the PDF. It needs customer_type=swedish_business with a Swedish vat_number (SE + 12 digits) and takes no delivery_country: 400 INVOICE_VAT_TREATMENT_DOMESTIC_BUYER_REQUIRED (details.reason) or _DELIVERY_COUNTRY_MISMATCH otherwise. Additive: nothing changes for a request without it. Not accepted by POST /invoices/bulk-create.

Webhooks (2026-10)

  • Behaviour change: journal_entry.committed fires for bulk-booked bank transactions (2026-10-07): a samlingsverifikat posted by POST /transactions/bulk-book, the dashboard bulk booking or an approved agent (MCP) bulk booking now emits journal_entry.committed, like every other posted voucher. Before, those vouchers were posted without the event. Linking rows to an existing voucher (existing_journal_entry_id) posts nothing new and still emits no journal_entry.committed; a dry run emits nothing. The payload and the API version date are unchanged.
  • Behaviour change: journal_entry.committed fires for samlingsbetalningar, utlägg payouts and kursdifferens vouchers (2026-10-08): the verifikat posted by POST /transactions/{id}/match-batch (and its dashboard and agent twins), by an expense payout (POST /expense-claims/payouts, POST /transactions/{id}/match-expense-payout), and the kursdifferens verifikat (3960/7960) a link of a foreign-currency invoice or supplier invoice to an existing payment voucher can post, now emit journal_entry.committed. A link without a kursdifferens posts nothing new and emits no journal_entry.committed; a dry run emits nothing. SIE imports, their undo and provider migrations stay silent on purpose: they bring in history that was posted elsewhere. The payload and the API version date are unchanged.

Webhooks: endpoint verification (2026-09)

  • Behaviour change: webhook URLs must pass an ownership handshake before events are delivered (#3191, 2026-09-29). Accounted POSTs a signed webhook.verification event whose data.object.challenge the endpoint returns as {"challenge": "..."} with a 2xx within 10 seconds (contract). New webhooks start pending and receive nothing until they pass; a changed webhook_url starts over. Run the handshake with the new POST /webhooks/{id}/verify (422 WEBHOOK_VERIFICATION_FAILED with details.reason on a failure, 429 inside a 10-second cooldown); Accounted also retries on its own.
  • Existing webhooks keep receiving events for 30 days (verification_status: "grace_period", end in verification_grace_ends_at) while Accounted attempts the handshake daily. A webhook still unverified after that is paused: nothing is delivered until it passes.
  • Every webhook read and write response carries verification_status, verified_at, verification_grace_ends_at, verification_attempts, verification_last_attempt_at, verification_last_error and verification_next_attempt_at. POST /webhooks/{id}/test and POST /webhook-deliveries/{id}/retry answer 409 WEBHOOK_NOT_VERIFIED for pending and paused webhooks.

Invoices (2026-09)

  • Behaviour change: invoice.paid and supplier_invoice.paid webhooks (2026-09-30): both now fire once, when an invoice becomes fully paid, on every path that settles it: /mark-paid, a bank-transaction match (/transactions/{id}/match-invoice, /match-supplier-invoice, the batch match), linking a bank transaction or an invoice to an existing voucher, the Stripe payment sync and the agent (MCP) equivalents. Before, a bank match emitted neither event (only *.match_confirmed internally), so an invoice paid through the bank never reached subscribers; and /mark-paid also fired them for a partial payment. A partial payment now fires neither event. The payload is unchanged and the API version date is unchanged.
  • Per-invoice VAT treatment (#2906, 2026-09-27): POST /invoices and PATCH /invoices/{id} accept vat_treatment (standard | export | reverse_charge) and delivery_country (ISO 3166-1 alpha-2, the country the goods are transported to), so an invoice can state its own supply instead of taking the customer's treatment. A Swedish buyer with goods shipped to Norway is now an export: vat_treatment=export, delivery_country=NO gives 0 %, moms_ruta 36, revenue on 3105 and the export notice. reverse_charge with a delivery country in another member state is an intra-EU supply of goods (0 %, moms_ruta 35, revenue on 3108, the Article 138 notice) and needs the customer's VIES-validated VAT number from a member state other than Sweden. standard is Swedish VAT at the line rates. The treatment is refused, never guessed, when the facts do not support it: 400 INVOICE_VAT_TREATMENT_DELIVERY_COUNTRY_REQUIRED, _DELIVERY_COUNTRY_MISMATCH, _BUYER_VAT_NUMBER_REQUIRED or _NOT_VAT_REGISTERED. The dry run and the invoice detail, create and update responses carry vat_treatment_override and delivery_country next to vat_treatment, moms_ruta and reverse_charge_text. Nothing changes for a request without the fields. POST /invoices/bulk-create refuses them per item. Behaviour change: a vat_treatment key on these bodies used to be dropped silently; a value outside the three is now 400 VALIDATION_ERROR.
  • Behaviour change: Swedish VAT for an EU business established in Sweden (#2783, 2026-09-20): POST /invoices and PATCH /invoices/{id} (when items are replaced) now refuse reverse charge for a customer with customer_type=eu_business whose country is SE, even when its vat_number is 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 omit vat_rate default to 25 %. This is the rule the dashboard, the MCP tools and POST /invoices/bulk-create already 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.warnings carries EU_BUSINESS_COUNTRY_IS_SE with message_sv, message_en and a remediation, 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: set country to where the buyer is actually established (PATCH /customers/{id}), or change customer_type to swedish_business if 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) and POST /invoices/bulk-create (per item) now say why the VAT treatment is what it is, without changing it. Reverse charge (0 %, ruta 39) needs customer_type=eu_business, a VIES-validated vat_number and a country other than SE; when one of those is missing the draft gets Swedish VAT as before and the response carries meta.warnings (EU_BUSINESS_VAT_NUMBER_NOT_VALIDATED, EU_BUSINESS_VAT_NUMBER_MISSING, EU_BUSINESS_COUNTRY_IS_SE), each with message_sv, message_en and a remediation. 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 as SWEDISH_VAT_TO_REVERSE_CHARGE_CUSTOMER / SWEDISH_VAT_TO_EXPORT_CUSTOMER. Warnings never fail a request; dry runs return the same list. meta.warnings is a new optional field on every envelope, absent when there is nothing to say.

Transactions (2026-09)

  • Behaviour change: double-booking guards on categorize (2026-09-30): POST /transactions/{id}/categorize and POST /transactions/batch-categorize now run the two checks the dashboard already ran, on the live call and on a dry run, whenever the transaction has no verifikat yet. A bank line the ledger already books (a booked sibling transaction, or a verifikat booking the same amount on the bank account, such as a supplier invoice marked paid) is refused with 409 TRANSACTION_BOOK_POSSIBLE_DUPLICATE and the candidate in details.candidate. Link the transaction to that verifikat instead; only for a genuinely separate event, resend with force: true plus expected_duplicate_journal_entry_id (or expected_duplicate_transaction_id) echoing the candidate, which the live call logs in behandlingshistorik. A stale id returns 409 TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH. A plain 244x or 151x categorization that an open supplier or customer invoice covers is refused with 409 TX_CATEGORIZE_SUGGEST_SI_MATCH / TX_CATEGORIZE_SUGGEST_CI_MATCH: match the invoice with /match-supplier-invoice / /match-invoice, or pass confirm_no_match: true to keep the categorization. In batch-categorize each refusal is a per-item error and the other items still book; an item is never compared with a verifikat booked earlier in the same batch. Before, both routes accepted force, expected_duplicate_* and confirm_no_match but never ran the checks, so one bank payment could be booked twice. No field was added or removed, and the API version date is unchanged.
  • Behaviour change: the supplier-invoice bank match books like the dashboard (#3253, 2026-09-30): POST /transactions/{id}/match-supplier-invoice now plans and books the payment with the same code as the dashboard match, so the same bank row gives the same verifikat (line texts included) and the same ledger update on both. A same-currency row that pays more than the invoice's remaining balance settles the invoice in full: 2440 is cleared by the remaining balance only and the excess, up to 5 000 kr, is booked on 6570 as a bank fee, under both accounting methods. A SEK row less than 1 kr off the remaining balance of a SEK invoice (a whole-krona payment of an öre total) now settles it in full under faktureringsmetoden too, with the difference on 3740; it used to leave the invoice partially_paid with the öre on 2440. paid_amount, remaining_amount and the payment row record the debt settled and never exceed the invoice total. A same-currency excess above the fee cap is refused with 400 MATCH_SI_AMOUNT_EXCEEDS_REMAINING and books nothing; it used to clear the whole bank amount off 2440 and push paid_amount past the total. Allocate such a payment across invoices with POST /transactions/{id}/match-batch. A cross-currency match is not capped, as before: it settles the remaining balance and books the SEK difference as kursdifferens. The plan's refusals (the overshoot, a kontantmetoden partial, a missing exchange rate) and the check that custom lines balance now run before a conflicting categorization of the transaction is reversed; they used to run after it, so a refused request left the reversal posted. The response gains bank_fee_sek, the SEK booked on 6570 (0 when there is none, and with custom lines). The request shape is unchanged.

Customers (2026-09)

  • Sole traders (enskild firma) (#2367, 2026-09-13): an org_number shaped like a Swedish personnummer is now accepted on customer_type=swedish_business. A sole trader has no separate organisationsnummer: its owner's personnummer IS the firm's org number, so the old blanket 400 CUSTOMER_ORG_NUMBER_IS_PERSONAL made a real customer impossible to register as a business. The refusal now applies to eu_business and non_eu_business only, where the value cannot be an org number at all; individual is unchanged (a personnummer there is still moved into personal_number). The data-minimisation reason behind the old rule moved to the read paths where it belongs: GET /customers returns org_number and vat_number as null for any row whose org_number is 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 /accounts returns accounts in account_number order, the BAS sequence it always documented. It used to sort by the stored sort_order, which is 0 on every account seeded at company creation, so the seeded accounts came first and the rest followed.
  • Class filter: ?class accepts any digit 0-9, the first digit of account_number. Class 9 appears on internal accounts carried over from an imported chart and could not be filtered on before.
  • Schema: the response schema lists the account_type values (untaxed_reserves included), normal_balance and default_vat_treatment. No field was added or removed, and the API version date is unchanged.
  • Journal entry dry runs: POST /journal-entries?dry_run=true now resolves the lines' accounts against the chart and fails with 400 ACCOUNTS_NOT_IN_CHART for 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.json now lists every endpoint's query parameters (filters, pagination cursor / limit, report period_id and date ranges, expand) with type, requiredness and description, and dry_run on 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.

Reports and journal entries: dimensions (2026-09)

  • Line dimensions on journal-entry reads (2026-09-28): GET /journal-entries/{id} and the POST /journal-entries response carry each line's dimensions ({"<dim_no>": "<code>"}, {} when untagged), so dimension 2, 7-9 and custom dimensions 20+ are visible; cost_center and project stay as mirrors of keys 1 and 6. The dry-run preview echoes the bag the engine stores. PATCH /journal-entries/{id} (draft edit) answers dimensions on every line.
  • Dimension filter on reports (2026-09-28): GET /reports/income-statement, /reports/general-ledger and /reports/monthly-breakdown accept dim_no + dim_code (together) and then answer dimension_filter and partial_view (complete: false; on the general ledger opening_balances_included: false, every opening_balance is 0 under a filter). The trial balance and the statutory reports refuse the pair.
  • Fix: project opening balances on the filtered general ledger (2026-10-01): under dim_no + dim_code the general ledger's opening_balance was always 0, so a project's work in progress or receivable carried from last year read as zero. It is now that value's own IB: the IB lines tagged with it, or, when the year has no IB entry, its prior tagged history. The year-end close and the SIE import (#OIB) now split each balance-sheet account's IB per project (dimension 6), except the VAT accounts (26xx), whose IB stays one untagged line. Under a filter, dimensions that reset annually (kostnadsställe, dimension 1, and custom dimensions by default) and the 26xx accounts open at 0. partial_view now carries opening_balances: "dimension_scoped", and opening_balances_included stays in the body but is true (it was false). Unfiltered reports are unchanged. A bug fix: the API version date is unchanged.
  • Split an existing IB per project (#3313, 2026-10-03, additive): GET /fiscal-periods/{id}/opening-balances/split-per-project previews splitting the year's IB verifikat per project from the previous year's closing balances per object of the dimensions that carry across years (projekt, dimension 6): per balance-sheet account the current and the proposed lines (one per project plus an untagged remainder; the account total never changes), which accounts change, project codes missing from the registry, and blocked when the year does not allow it. POST on the same path applies it as an inline rättelse of the same verifikat (the struck lines stay in the rättelse log); pass the preview's fingerprint as expected_fingerprint (409 OB_SPLIT_PROPOSAL_CHANGED when it moved). Open, unlocked years without a bokslut only: 409 OB_SPLIT_PERIOD_CLOSED, OB_SPLIT_PERIOD_LOCKED, OB_COMPANY_LOCK_DATE, OB_CORRECT_YEAR_END_EXISTS. A split already in place answers applied: false (its ?dry_run=true answers 409 OB_SPLIT_NOTHING_TO_DO, so nothing is staged), and an account whose IB already carries a different project split, for example from SIE #OIB, is skipped (skip_reason: "existing_split"), never overwritten. The preview's changed_amount_sek is what an API key's unattended-commit ceiling prices. MCP: gnubok_preview_opening_balance_split and the staged gnubok_split_opening_balances_per_project.
  • Behaviour change, dimension filters only: every GET /reports/* endpoint and the årsredovisning reads under /fiscal-periods/{id}/arsredovisning answer 400 VALIDATION_ERROR (unknown_params + allowed_params in details) to a dimension filter they do not document (dim_no, dim_code, dimensions, project, cost_center). Before, they silently ignored it, so dim_no on the trial balance returned the unfiltered report as if it were filtered.
  • Ignored parameters are named (2026-09-28): any other query parameter a report does not document is still not applied and not refused; the response now names it in the X-Ignored-Query-Params header (comma-separated). Check it when a filter seems to have had no effect. If these are ever refused too, that change will be announced here first.

Reconciliation, account-keyed (2026-08)

  • Accounts: GET /reconciliation/accounts lists every account with an outside truth (bank accounts as bank:<cash_account_id>, the skattekonto as skattekonto) with status; GET .../accounts/{accountKey} is the bridge (outside balance, ledger, difference, unexplained, explanatory lines, counts, latest sign-off); GET .../accounts/{accountKey}/items the rows behind it, bucketed (proposed, unmatched_external, unmatched_ledger, matched, ignored, upcoming).
  • Links: POST .../accounts/{accountKey}/links (pairs or use_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 .../signoff history, POST .../signoff/{signoffId}/reopen.
  • New scopes reconciliation:read, reconciliation:write, reconciliation:signoff. The legacy /reconciliation/bank/* endpoints and their transactions:* 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-statement accepts optional from_date / to_date (YYYY-MM-DD, both inside the fiscal period named by period_id, from_date <= to_date). GET /reports/balance-sheet is a position, so it takes only to_date (or its alias as_of) and refuses from_date. Omit them for the whole period, as before. These routes now reject unknown query parameters with 400 VALIDATION_ERROR (unknown_params + allowed_params in details) instead of silently returning a full-period report.
  • Report PDFs (#1909): GET /reports/balance-sheet/pdf and GET /reports/income-statement/pdf return application/pdf, byte-equivalent to the dashboard export, with the same period_id and range parameters. Scope reports:read.
  • Company creation (#1864, 2026-08-25): POST /api/v1/companies (scope companies: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 send moms_period. Not idempotent, and Idempotency-Key is not honoured on this company-less route: list GET /api/v1/companies before retrying.
  • Filed VAT declarations (#1773, 2026-08-21): GET /skatteverket/vat-declarations?period_type=&year=&period= (scope compliance:read) reads one period's momsdeklaration as Skatteverket has it on file: submitted (SKV inlämnat) and decided (SKV beslutat), each null when 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 (scope companies:write, risk medium, dry-run supported) updates bank details (bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic), contact_person, email, phone, website and invoice_email_texts, with the same validation as the MCP tool.
  • Customer personal_number (#1724 2026-08-20, #1788 2026-08-21): personal_number on customer create, bulk-create, detail, PATCH and list. Accepted only for customer_type=individual, stored encrypted, always returned masked (********-1234); sending the masked form on PATCH means "leave unchanged", null clears it. A personnummer-shaped org_number on a business customer type is refused (400 CUSTOMER_ORG_NUMBER_IS_PERSONAL); on an individual it is moved into personal_number and org_number is cleared, or refused with 400 CUSTOMER_PERSONAL_NUMBER_CONFLICT when it differs from a personal_number in the same body.
  • Ignore a transaction (#1661, 2026-08-29): POST /transactions/{id}/ignore (scope transactions: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). DELETE on the same path restores the row. A booked transaction (directly, via a payment allocation, or via a voucher link) is refused with 409 TX_IGNORE_ALREADY_BOOKED. In the same change, /categorize and /batch-categorize answer is_business: false in a locked period with 400 TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED (details.suggested_action: "ignore") instead of a bare PERIOD_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-explanations per BFNAR 2013:2.
  • Fiscal-periods ops: /lock, /close and /opening-balances are synchronous and return 200 with the updated period; /year-end and /currency-revaluation are async and return 202 with operation_id (poll at GET /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-agi produces and persists the arbetsgivardeklaration XML: the response carries it as data.xml for the integrator to upload to Skatteverket Mina Sidor (or via the optional skatteverket extension). 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 via operations substrate. 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.* and agi.generated require payroll:read).
  • Delivery substrate: dispatched immediately after the event is enqueued, with a per-minute Vercel cron at /api/webhooks/dispatch/cron as the retry and sweep path. Due rows are claimed atomically via the claim_due_webhook_deliveries SQL function (FOR UPDATE SKIP LOCKED), with *.pg.test.ts coverage for the claim path and the webhook DB triggers. Exponential backoff 1m / 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.request agent (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 NULL on webhook_id). Webhook lifecycle events (create / update / delete, plus dispatcher auto-disable) each write a V16 audit_log entry.
  • Verbs: POST /webhooks/{id}/test enqueues a synthetic event; POST /webhook-deliveries/{id}/retry re-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_attributes for update-style webhook events