Menu
Cookbooks
API reference
- Overview
- Companies
- Portfolio
- 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
- API key
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).
Expense claims (2026-10)
- Reverse charge on an utlägg (crm#1028, 2026-10-09):
POST /expense-claimsacceptsreverse_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 noreverse_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 whenexpense_accountreports that ruta itself. It needsvat_amount0, nolinesand anexpense_accountthat reports no other box (400otherwise). 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'smode(live|test), itsscopes,company_access(all_membershipsorallowlist) and every company it reaches withrole,access(read_write|read_only) andread_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 gives401. Check a key when a customer connects it instead of waiting for the first403: a document upload to company X needsdocuments:writeinscopes, X withaccessread_write, andmodelive(a test key gets403 TEST_KEY_WRITE_BLOCKEDon uploads, which cannot be dry-run). Additive.- The reference pages said live keys start
gnubok_sk_live_. They startgnubok_sk_followed directly by the random part; only test keys carry an infix (gnubok_sk_test_). The OpenAPIbearerFormatnow says the same.
Invoices (2026-10)
- Domestic reverse charge for construction services (crm#1007, 2026-10-05):
POST /invoicesandPATCH /invoices/{id}acceptvat_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_ruta41, 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 needscustomer_type=swedish_businesswith a Swedishvat_number(SE + 12 digits) and takes nodelivery_country:400 INVOICE_VAT_TREATMENT_DOMESTIC_BUYER_REQUIRED(details.reason) or_DELIVERY_COUNTRY_MISMATCHotherwise. Additive: nothing changes for a request without it. Not accepted byPOST /invoices/bulk-create.
Webhooks (2026-10)
- Behaviour change:
journal_entry.committedfires for bulk-booked bank transactions (2026-10-07): a samlingsverifikat posted byPOST /transactions/bulk-book, the dashboard bulk booking or an approved agent (MCP) bulk booking now emitsjournal_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 nojournal_entry.committed; a dry run emits nothing. The payload and the API version date are unchanged. - Behaviour change:
journal_entry.committedfires for samlingsbetalningar, utlägg payouts and kursdifferens vouchers (2026-10-08): the verifikat posted byPOST /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 emitjournal_entry.committed. A link without a kursdifferens posts nothing new and emits nojournal_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.verificationevent whosedata.object.challengethe endpoint returns as{"challenge": "..."}with a 2xx within 10 seconds (contract). New webhooks startpendingand receive nothing until they pass; a changedwebhook_urlstarts over. Run the handshake with the newPOST /webhooks/{id}/verify(422 WEBHOOK_VERIFICATION_FAILEDwithdetails.reasonon a failure,429inside a 10-second cooldown); Accounted also retries on its own. - Existing webhooks keep receiving events for 30 days (
verification_status: "grace_period", end inverification_grace_ends_at) while Accounted attempts the handshake daily. A webhook still unverified after that ispaused: 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_errorandverification_next_attempt_at.POST /webhooks/{id}/testandPOST /webhook-deliveries/{id}/retryanswer409 WEBHOOK_NOT_VERIFIEDforpendingandpausedwebhooks.
Invoices (2026-09)
- Behaviour change:
invoice.paidandsupplier_invoice.paidwebhooks (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_confirmedinternally), so an invoice paid through the bank never reached subscribers; and/mark-paidalso 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 /invoicesandPATCH /invoices/{id}acceptvat_treatment(standard|export|reverse_charge) anddelivery_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=NOgives 0 %,moms_ruta36, revenue on 3105 and the export notice.reverse_chargewith a delivery country in another member state is an intra-EU supply of goods (0 %,moms_ruta35, revenue on 3108, the Article 138 notice) and needs the customer's VIES-validated VAT number from a member state other than Sweden.standardis 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_REQUIREDor_NOT_VAT_REGISTERED. The dry run and the invoice detail, create and update responses carryvat_treatment_overrideanddelivery_countrynext tovat_treatment,moms_rutaandreverse_charge_text. Nothing changes for a request without the fields.POST /invoices/bulk-createrefuses them per item. Behaviour change: avat_treatmentkey on these bodies used to be dropped silently; a value outside the three is now400 VALIDATION_ERROR. - 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.
Transactions (2026-09)
- Behaviour change: double-booking guards on categorize (2026-09-30):
POST /transactions/{id}/categorizeandPOST /transactions/batch-categorizenow 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 with409 TRANSACTION_BOOK_POSSIBLE_DUPLICATEand the candidate indetails.candidate. Link the transaction to that verifikat instead; only for a genuinely separate event, resend withforce: trueplusexpected_duplicate_journal_entry_id(orexpected_duplicate_transaction_id) echoing the candidate, which the live call logs in behandlingshistorik. A stale id returns409 TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH. A plain 244x or 151x categorization that an open supplier or customer invoice covers is refused with409 TX_CATEGORIZE_SUGGEST_SI_MATCH/TX_CATEGORIZE_SUGGEST_CI_MATCH: match the invoice with/match-supplier-invoice//match-invoice, or passconfirm_no_match: trueto keep the categorization. Inbatch-categorizeeach 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 acceptedforce,expected_duplicate_*andconfirm_no_matchbut 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-invoicenow 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 invoicepartially_paidwith the öre on 2440.paid_amount,remaining_amountand the payment row record the debt settled and never exceed the invoice total. A same-currency excess above the fee cap is refused with400 MATCH_SI_AMOUNT_EXCEEDS_REMAININGand books nothing; it used to clear the whole bank amount off 2440 and pushpaid_amountpast the total. Allocate such a payment across invoices withPOST /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 customlinesbalance 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 gainsbank_fee_sek, the SEK booked on 6570 (0 when there is none, and with customlines). The request shape is unchanged.
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.
Reports and journal entries: dimensions (2026-09)
- Line dimensions on journal-entry reads (2026-09-28):
GET /journal-entries/{id}and thePOST /journal-entriesresponse carry each line'sdimensions({"<dim_no>": "<code>"},{}when untagged), so dimension 2, 7-9 and custom dimensions 20+ are visible;cost_centerandprojectstay as mirrors of keys1and6. The dry-run preview echoes the bag the engine stores.PATCH /journal-entries/{id}(draft edit) answersdimensionson every line. - Dimension filter on reports (2026-09-28):
GET /reports/income-statement,/reports/general-ledgerand/reports/monthly-breakdownacceptdim_no+dim_code(together) and then answerdimension_filterandpartial_view(complete: false; on the general ledgeropening_balances_included: false, everyopening_balanceis 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_codethe general ledger'sopening_balancewas 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_viewnow carriesopening_balances: "dimension_scoped", andopening_balances_includedstays in the body but istrue(it wasfalse). 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-projectpreviews 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, andblockedwhen the year does not allow it.POSTon 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'sfingerprintasexpected_fingerprint(409 OB_SPLIT_PROPOSAL_CHANGEDwhen 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 answersapplied: false(its?dry_run=trueanswers409 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'schanged_amount_sekis what an API key's unattended-commit ceiling prices. MCP:gnubok_preview_opening_balance_splitand the stagedgnubok_split_opening_balances_per_project. - Behaviour change, dimension filters only: every
GET /reports/*endpoint and the årsredovisning reads under/fiscal-periods/{id}/arsredovisninganswer400 VALIDATION_ERROR(unknown_params+allowed_paramsin details) to a dimension filter they do not document (dim_no,dim_code,dimensions,project,cost_center). Before, they silently ignored it, sodim_noon 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-Paramsheader (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/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