Menu

Transactions

Bank transactions: ingest, categorise, match to invoices, reconcile.

Endpoints


GET /api/v1/companies/:companyId/transactions

transactions.list · scope transactions:read

List transactions for a company.

Cursor-paginated transaction list ordered by created_at DESC, id ASC (newest-imported first; the date column is the transaction date and is filterable but not the sort key). Filter by ?status=booked|unbooked, ?currency, ?date_from / ?date_to, ?search (description or merchant name, case-insensitive), ?cash_account_id.

Use when: You need to walk a company's bank ledger: building a categorization queue, reconciling against external statements, or sampling for audit.

Don't use for: Looking up one transaction by id (use the detail endpoint). Reconciliation status (use /reconciliation/bank/status).

Pitfalls

  • Default page size is 50. Pass ?limit=100 for the maximum. Cursor pagination: pass ?cursor=<next_cursor> from the previous response.
  • A booked transaction has a non-null journal_entry_id. is_business / category live on the transaction row even before booking.
  • reverse-charge or storno entries can leave a transaction with journal_entry_id pointing at a cancelled JE: check status on the JE separately.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no

Query parameters

NameTypeRequiredDescription
status"booked" | "unbooked"nobooked: linked to a verifikat (journal_entry_id set). unbooked: not yet booked. Default: both.
currencystringnoOnly transactions in this currency code (e.g. SEK).
date_fromstringnoYYYY-MM-DD. Transactions dated on or after this date.
date_tostringnoYYYY-MM-DD. Transactions dated on or before this date.
searchstringnoCase-insensitive match anywhere in the description or merchant name, 1-200 characters.
cash_account_idstringnoOnly transactions on this bank account (id from GET /cash-accounts).
cursorstringnoOpaque cursor from the previous page's meta.next_cursor. Omit for the first page.
limitnumbernoPage size, 1-100 (default 50). Larger values are clamped to 100.

Response fields

NameType
[].idstring
[].datestring
[].descriptionstring | null
[].amountnumber
[].currencystring
[].referencestring | null
[].merchant_namestring | null
[].journal_entry_idstring | null
[].invoice_idstring | null
[].supplier_invoice_idstring | null
[].is_businessboolean | null
[].categorystring | null
[].import_sourcestring | null
[].cash_account_idstring | null
[].created_atstring

Example response

{
  "data": [
    {
      "id": "a8f1…",
      "date": "2026-05-12",
      "description": "ICA MAXI",
      "amount": -349.5,
      "currency": "SEK",
      "merchant_name": "ICA MAXI",
      "journal_entry_id": null,
      "is_business": null,
      "category": null
    }
  ],
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12",
    "next_cursor": null
  }
}

GET /api/v1/companies/:companyId/transactions/:id

transactions.get · scope transactions:read

Retrieve a single transaction by id.

Returns the full transaction record including match state, booking state, and import metadata.

Use when: You have a transaction id (from the list or a webhook) and need the full record before deciding to categorize, match, or attach a document.

Don't use for: Walking the ledger: use the list endpoint with a cursor. Fetching the linked invoice/journal entry: separate endpoints.

Pitfalls

  • Both invoice_id (matched) and potential_invoice_id (suggested) can be set independently. The matched id is authoritative for accounting.
  • reconciliation_method is null for transactions that have never been auto-reconciled. journal_entry_id may still be set via manual categorize.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no

Response fields

NameType
idstring
datestring
descriptionstring | null
amountnumber
currencystring
amount_seknumber | null
referencestring | null
merchant_namestring | null
counterparty_accountstring | null
journal_entry_idstring | null
invoice_idstring | null
supplier_invoice_idstring | null
potential_invoice_idstring | null
is_businessboolean | null
categorystring | null
receipt_idstring | null
document_idstring | null
external_idstring | null
import_sourcestring | null
reconciliation_methodstring | null
cash_account_idstring | null
created_atstring
updated_atstring

Example response

{
  "data": {
    "id": "a8f1…",
    "date": "2026-05-12",
    "amount": -349.5,
    "currency": "SEK",
    "journal_entry_id": null,
    "is_business": null
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/:id/attach-document

transactions.attach-document · scope transactions:write

Pin a document (receipt, invoice) to a bank transaction as its underlag.

Pins the document to the transaction. On an unbooked transaction the pin rides along when it is categorized; on a booked one the document becomes the verifikat's underlag at once (BFL 5 kap 6 §). The inbox item the document came from is marked matched. Attaching another document replaces the pin and is logged as a rättelse. Idempotent. Dry-runnable.

Use when: A receipt or invoice in the archive belongs to a bank transaction (same date, amount, counterparty).

Don't use for: Linking a document to a verifikat with no bank transaction (POST /documents/{id}/link) or uploading a file (POST /documents).

Pitfalls

  • A document already underlag of ANOTHER verifikat returns 409 DOC_ATTACH_OTHER_VERIFIKAT.
  • Replacing a pinned document that is already linked to a verifikat returns 409 DOC_ATTACH_REPLACES_POSTED: reverse the entry first.
  • On a booked transaction in a locked period the pin is saved but the verifikat link is refused: 409 DOC_ATTACH_PERIOD_LOCKED.
  • Check date, amount and counterparty on both sides first: once the transaction is booked the link is immutable.

Risk: medium · Idempotent: yes · Reversible: yes · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Request body

NameTypeRequiredDescription
document_idstringyesThe document id (document_id from the list or an upload).

Response fields

NameTypeDescription
transaction_idstring
document_idstring
previous_document_idstring | nullThe document the pin replaced, if any.
journal_entry_idstring | nullThe verifikat the document now belongs to when the transaction is already booked.

Example request

{
  "document_id": "4f1c…"
}

Example response

{
  "data": {
    "transaction_id": "1f2e…",
    "document_id": "4f1c…",
    "previous_document_id": null,
    "journal_entry_id": null
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/:id/categorize

transactions.categorize · scope transactions:write

Categorize a transaction and create the journal entry.

Resolves the BAS account mapping for the transaction (via category, booking template, or counterparty template), creates the corresponding verifikation, and updates the transaction with is_business / category / journal_entry_id. Idempotent on (transaction, key). Dry-runnable.

Use when: You're categorizing a bank transaction. Pass is_business: true plus either category, template_id (booking template), counterparty_template_id, or account_override. For private transactions, is_business: false is enough.

Don't use for: Matching a payment to an invoice: use :match-invoice or :match-supplier-invoice, which storno any conflicting JE first. Uncategorizing: :uncategorize.

Pitfalls

  • A bank payment that looks like an invoice payment will be flagged via TX_CATEGORIZE_SUGGEST_SI_MATCH: pass confirm_no_match: true to override and force-categorize as direct expense (e.g. when the supplier invoice was already booked).
  • Already-categorized fast path: if the transaction already has a journal_entry_id, only flags get updated. The JE is immutable post-commit.
  • account_override must exist in the chart of accounts; an unknown account returns TX_CATEGORIZE_INVALID_ACCOUNT.

Risk: medium · Idempotent: yes · Reversible: yes · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Request body

NameTypeRequired
is_businessbooleanyes
category"income_services" | "income_products" | "income_other" | "expense_equipment" | "expense_software" | "expense_travel" | "expense_office" | "expense_marketing" | "expense_professional_services" | "expense_education" | "expense_representation" | "expense_consumables" | "expense_vehicle" | "expense_telecom" | "expense_bank_fees" | "expense_card_fees" | "expense_currency_exchange" | "expense_other" | "private" | "uncategorized"no
template_idstringno
vat_treatment"standard_25" | "reduced_12" | "reduced_6" | "reverse_charge" | "export" | "exempt"no
vat_amountnumberno
account_overridestringno
counterparty_template_idstringno
dimensionsobjectno
user_descriptionstringno
inbox_item_idstringno
confirm_no_matchbooleanno
forcebooleanno
expected_duplicate_transaction_idstringno
expected_duplicate_journal_entry_idstringno

Response fields

NameTypeDescription
successboolean
journal_entry_createdboolean
journal_entry_idstring | null
journal_entry_errorstring | nullAlways null: a verifikat that cannot be created is refused with 409 TX_CATEGORIZE_JOURNAL_ENTRY_FAILED and nothing is written (issue #1947). Kept for response-shape compatibility.
document_link_warningstring | null (optional)
categorystring
already_had_journal_entryboolean (optional)

Example request

{
  "is_business": true,
  "category": "expense_office"
}

Example response

{
  "data": {
    "success": true,
    "journal_entry_created": true,
    "journal_entry_id": "je_…",
    "category": "expense_office"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/:id/detach-document

transactions.detach-document · scope transactions:write

Take the pinned document off a bank transaction that is not booked against it.

Clears the transaction's document pin and releases the inbox item matched to it, so the next booking does not anchor the detached document. Refused once the document is linked to a verifikat (BFL 5 kap 6 §): only a storno undoes that. A transaction with no document answers success. Answers detached_document_id. Idempotent. Dry-runnable.

Use when: The wrong receipt was attached to a transaction that is not yet booked.

Don't use for: A booked transaction (reverse or uncategorize it first), deleting the document (DELETE /documents/{id}) or releasing an inbox item's match (POST /inbox-items/{id}/unmatch-transaction).

Pitfalls

  • A document linked to a verifikat returns 409 DOC_DETACH_POSTED.
  • A concurrent attach wins: the detach then answers 409 DOC_DETACH_CONCURRENT and changes nothing.
  • The document itself stays in the archive.

Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Response fields

NameTypeDescription
transaction_idstring
document_idunknown
detached_document_idstring | nullThe document that was pinned, or null when none was.

Example response

{
  "data": {
    "transaction_id": "1f2e…",
    "document_id": null,
    "detached_document_id": "4f1c…"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/:id/ignore

transactions.ignore · scope transactions:write

Ignore a bank transaction (no verifikat, allowed in locked periods).

Marks an unbooked bank transaction as ignored so it leaves the "to book" funnels and the reconciliation unmatched totals without creating a verifikat. Nothing is deleted and the flag is reversible with DELETE on the same path. Because no booking is written, a locked or closed fiscal period does not block it: this is the path for clearing rows that are not business events out of a closed period. A booked transaction (directly, via a payment allocation, or via a voucher link) is refused with 409 TX_IGNORE_ALREADY_BOOKED. Idempotent: ignoring an already-ignored row returns already_ignored: true. Dry-runnable.

Use when: The row is not an affärshändelse: a PSD2 ghost row, a duplicate from a bank reconnect, a transfer that never executed, rounding noise. Also the answer to TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED from /categorize when the row should not be booked at all.

Don't use for: Real purchases, payments or owner withdrawals: those must be booked (categorize, match-invoice, or is_business: false in an open period). Ignoring is triage, not bookkeeping.

Pitfalls

  • Idempotency-Key is mandatory.
  • A booked row cannot be ignored: reverse it first (POST /transactions/{id}/uncategorize) or unlink the payment/voucher.
  • Ignored rows still exist and are listed on the reconciliation bridge's ignored line; they never disappear silently.

Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Response fields

NameType
successboolean
transaction_idstring
is_ignoredtrue
already_ignoredboolean

Example response

{
  "data": {
    "success": true,
    "transaction_id": "tx_…",
    "is_ignored": true,
    "already_ignored": false
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/:id/link-journal-entry

transactions.link-journal-entry · scope transactions:write

Link a bank transaction to a verifikat that already books it (no new bookkeeping).

Anchors the row to an existing POSTED journal entry: the row counts as booked and leaves the to-book list, and nothing new is posted. With invoice_id the customer invoice is also settled against that same verifikat (an invoice_payments row, status paid or partially_paid), same currency only. A dry run answers the result the link would produce. Idempotent. Dry-runnable.

Use when: The affärshändelse was already booked by hand (a manual verifikat, a payment registered before the bank row arrived) and the bank row must point at it instead of being booked twice.

Don't use for: Booking the row (POST /transactions/{id}/categorize), matching it to an invoice with a new payment verifikat (POST /transactions/{id}/match-invoice), or one row against several vouchers (reconciliation links).

Pitfalls

  • A row already linked to a posted verifikat returns 409 LINK_TX_TX_ALREADY_LINKED; a pointer left by a storno does not count.
  • The verifikat must be posted: LINK_TX_JE_NOT_POSTED otherwise.
  • invoice_id: the invoice must be open (sent, overdue, partially_paid), not a credit note, and in the transaction currency (LINK_TX_INVOICE_CURRENCY_MISMATCH); cross-currency payments go through match-invoice.

Risk: medium · Idempotent: yes · Reversible: no · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Request body

NameTypeRequired
journal_entry_idstringyes
invoice_idstringno

Response fields

NameTypeDescription
transaction_idstring
journal_entry_idstring
voucher_labelstringVerifikat label, e.g. "A-12".
invoice_idstring | null
invoice_status"paid" | "partially_paid" | null
paid_amountnumber | null
remaining_amountnumber | null

Example request

{
  "journal_entry_id": "4d2a…"
}

Example response

{
  "data": {
    "transaction_id": "a8f1…",
    "journal_entry_id": "4d2a…",
    "voucher_label": "A-12",
    "invoice_id": null,
    "invoice_status": null,
    "paid_amount": null,
    "remaining_amount": null
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/:id/match-batch

transactions.match-batch · scope transactions:write

Book one bank payment against several customer invoices, or several supplier invoices, in one verifikat.

Allocates the transaction across N invoices of one kind: one samlingsverifikation (bank against 1510 or 2440, kursdifferens on 3960/7960 for foreign invoices, öresavrundning on 3740) and one payment row per invoice, atomically. The allocations must sum to the transaction amount. The dry run answers the exact lines (expected_lines) the verifikat would carry. Idempotent. Dry-runnable.

Use when: One incoming payment covers several customer invoices, or one outgoing transfer pays several supplier invoices.

Don't use for: One invoice (POST /transactions/{id}/match-invoice or match-supplier-invoice), mixing customer and supplier invoices, or invoices never booked under kontantmetoden.

Pitfalls

  • The allocation amounts must sum to |amount| of the transaction: BATCH_AMOUNT_EXCEEDS_TX / BATCH_AMOUNT_BELOW_TX otherwise.
  • A row that posted vouchers already explain (each invoice marked paid by hand) returns 409 BATCH_TX_POSSIBLE_DUPLICATE with the vouchers: link the row to them instead. force=true needs expected_journal_entry_ids naming exactly that set.
  • Under kontantmetoden an invoice with no booking yet returns 400 BATCH_CASH_METHOD_UNBOOKED_INVOICE.
  • Proformas and quotes return 400 MATCH_INVOICE_NOT_INVOICE_TYPE.

Risk: medium · Idempotent: yes · Reversible: no · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Request body

NameTypeRequired
allocationsobject[]yes
forcebooleanno
expected_journal_entry_idsstring[]no

Response fields

NameType
journal_entry_idstring
voucher_seriesstring
voucher_numbernumber
allocationsobject[]
total_allocatednumber
leftovernumber

Example request

{
  "allocations": [
    {
      "kind": "customer_invoice",
      "invoice_id": "2b1c…",
      "amount": 500
    },
    {
      "kind": "customer_invoice",
      "invoice_id": "3c2d…",
      "amount": 750
    }
  ]
}

Example response

{
  "data": {
    "journal_entry_id": "4d2a…",
    "voucher_series": "A",
    "voucher_number": 12,
    "allocations": [],
    "total_allocated": 1250,
    "leftover": 0
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/:id/match-invoice

transactions.match-invoice · scope transactions:write

Match a positive bank transaction to a customer invoice.

Confirms an invoice match for a transaction. Storno any conflicting auto-categorization JE, create the payment journal entry, update the invoice status (paid / partially_paid), insert into invoice_payments, and link the transaction. Idempotent.

Use when: You have a bank receipt and a known open invoice it pays. The transaction must be positive (income) and unlinked.

Don't use for: Categorizing a transaction without an invoice: use :categorize. Matching to a supplier invoice: use :match-supplier-invoice. Bulk auto-match: use POST /reconciliation/bank/run.

Pitfalls

  • Proforma + delivery notes are rejected (MATCH_INVOICE_NOT_INVOICE_TYPE): only document_type='invoice' can be matched.
  • Transaction must be positive (amount > 0): negative transactions return MATCH_INVOICE_NOT_INCOME.
  • Invoice must be in sent / overdue / partially_paid status: paid or draft invoices return MATCH_INVOICE_NOT_OPEN.
  • Idempotency-Key is mandatory.

Risk: high · Idempotent: yes · Reversible: no · Dry-run supported: no

Request body

NameTypeRequired
invoice_idstringyes
forcebooleanno
expected_journal_entry_idstringno
linesobject[]no
manual_exchange_ratenumberno

Response fields

NameType
successboolean
invoice_statusstring
paid_atstring | null
paid_amountnumber
remaining_amountnumber
journal_entry_idstring | null
categorystring | null

Example request

{
  "invoice_id": "inv_…"
}

Example response

{
  "data": {
    "success": true,
    "invoice_status": "paid",
    "paid_amount": 12500,
    "remaining_amount": 0,
    "journal_entry_id": "je_…",
    "category": null
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/:id/match-supplier-invoice

transactions.match-supplier-invoice · scope transactions:write

Match a negative bank transaction to a supplier invoice.

Confirms a supplier invoice payment match. Creates the payment journal entry (accrual: 2440 debit, credit on the transaction's own settlement account, 1930 when unlinked; cash-method: collapsed registration+payment), updates supplier_invoices, inserts a supplier_invoice_payments row, and links the transaction. Handles FX differences for cross-currency payments (7960 gain / 3960 loss).

Use when: You have a bank payment and a known open supplier invoice. The transaction must be negative (expense) and unlinked.

Don't use for: Categorizing a direct supplier expense without an invoice: use :categorize. Matching to a customer invoice: use :match-invoice. Bulk auto-match: POST /reconciliation/bank/run.

Pitfalls

  • Cash-method companies can settle a foreign invoice in full (booked at the payment-date rate); only a PARTIAL cash-method payment across currencies is rejected (MATCH_SI_CASH_FX_UNSUPPORTED): pay in full, switch to accrual, or book manually.
  • Cash-method öresavrundning: a SEK bank row less than 1 kr off a never-booked SEK invoice (a whole-krona payment of an öre total) settles it in full. The payment account is credited with the bank amount and the residual is booked on 3740 (no VAT); paid_amount records the debt settled, not the cash moved. A difference of 1 kr or more is a partial and still returns SI_CASH_PARTIAL_UNSUPPORTED.
  • Transaction must be negative (amount < 0). Positive returns MATCH_SI_NOT_EXPENSE.
  • Supplier invoice must NOT be paid/credited already. paid/credited returns MATCH_SI_ALREADY_PAID; registered/approved/partially_paid/overdue are matchable.
  • Idempotency-Key is mandatory.

Risk: high · Idempotent: yes · Reversible: no · Dry-run supported: no

Request body

NameTypeRequired
supplier_invoice_idstringyes
linesobject[]no

Response fields

NameType
successboolean
invoice_statusstring
paid_amountnumber
remaining_amountnumber
journal_entry_idstring | null

Example request

{
  "supplier_invoice_id": "si_…"
}

Example response

{
  "data": {
    "success": true,
    "invoice_status": "paid",
    "paid_amount": 5000,
    "remaining_amount": 0,
    "journal_entry_id": "je_…"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/:id/refresh-exchange-rate

transactions.refresh-exchange-rate · scope transactions:write

Fill in the Riksbanken rate and SEK amount of an unbooked foreign-currency transaction.

For an unbooked non-SEK row with no amount_sek/exchange_rate, fetches the Riksbanken rate for the transaction date and stores amount_sek, exchange_rate and exchange_rate_date. A SEK row, or one that already has both, is answered unchanged with refreshed=false. Idempotent. Dry-runnable (the dry run does not call Riksbanken).

Use when: A foreign-currency row shows no SEK amount (the rate lookup failed at ingest) and it is about to be booked.

Don't use for: Booked rows (the verifikat carries the rate; correct it with storno) or overriding a rate that is already set.

Pitfalls

  • A booked row returns 409 TX_EXCHANGE_RATE_BOOKED.
  • Riksbanken unavailable returns 502 TX_EXCHANGE_RATE_UNAVAILABLE (retryable).

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Response fields

NameTypeDescription
transaction_idstring
currencystring
amountnumber
amount_seknumber | null
exchange_ratenumber | null
exchange_rate_datestring | null
refreshedbooleanFalse when nothing was needed (SEK, or the rate was already there).

Example response

{
  "data": {
    "transaction_id": "a8f1…",
    "currency": "EUR",
    "amount": -100,
    "amount_sek": -1150.4,
    "exchange_rate": 11.504,
    "exchange_rate_date": "2026-05-12",
    "refreshed": true
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/:id/uncategorize

transactions.uncategorize · scope transactions:write

Reverse the categorization of a transaction (storno + reset).

Storno the transaction's journal entry (BFL 5 kap 5 §: posted entries are never deleted, only cancelled via a reversing entry) and reset is_business / category / journal_entry_id on the transaction row. Idempotent: a second call on an already-uncategorized transaction returns 400 TX_UNCATEGORIZE_NOT_BOOKED. Dry-runnable.

Use when: You categorized a transaction by mistake and want to redo it from scratch. The storno keeps the audit trail intact.

Don't use for: Changing the categorization of an already-booked transaction: categorize again instead (the second call sees journal_entry_id and only updates flags). Reversing a payment match: there is no v1 verb for that yet.

Pitfalls

  • Idempotency-Key is mandatory.
  • The storno creates a new (cancelling) journal entry. The original entry stays in the ledger marked as cancelled: voucher gaps are documented automatically.
  • A transaction without a journal_entry_id returns 400 TX_UNCATEGORIZE_NOT_BOOKED: there is nothing to reverse.

Risk: medium · Idempotent: yes · Reversible: no · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Response fields

NameType
successboolean
reversed_journal_entry_idstring

Example response

{
  "data": {
    "success": true,
    "reversed_journal_entry_id": "je_…"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/batch-categorize

transactions.batch-categorize · scope transactions:write

Categorize up to 100 transactions in one call (partial-success).

Per-item categorization mirroring the single :categorize endpoint. Same { results, summary } shape as the other bulk endpoints. all_or_nothing: true returns 501 NOT_IMPLEMENTED. Idempotent over the whole batch.

Use when: You have many transactions to categorize with the same logic (e.g. apply a booking template across a queue, mark a batch as private, override accounts on a series).

Don't use for: Categorizing transactions with mixed logic: make multiple :categorize calls. Auto-categorization via templates: handled inside ingest for matching rows, no separate endpoint needed.

Pitfalls

  • Max 100 items per call. Sequential processing.
  • Idempotency-Key covers the WHOLE batch: replays return the cached full response.
  • all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist.

Risk: medium · Idempotent: yes · Reversible: yes · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Request body

NameTypeRequired
itemsobject[]yes
all_or_nothingbooleanno

Response fields

NameType
resultsobject[]
summaryobject

Example request

{
  "items": [
    {
      "transaction_id": "tx_1",
      "categorization": {
        "is_business": true,
        "category": "expense_office"
      }
    }
  ]
}

Example response

{
  "data": {
    "results": [
      {
        "ok": true,
        "request_index": 0,
        "transaction_id": "tx_1",
        "data": {
          "journal_entry_id": "je_…"
        }
      }
    ],
    "summary": {
      "total": 1,
      "succeeded": 1,
      "failed": 0
    }
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/bulk-book

transactions.bulk-book · scope transactions:write

Book several same-day SEK bank transactions as one samlingsverifikat.

Books up to 200 transactions of the same date into ONE verifikat (samlingsverifikation, BFL 5 kap 6 §), in exactly one of three ways: existing_journal_entry_id links them to an already-posted voucher whose bank net equals their sum (nothing new is posted); template_id + mode + entry_description expands a booking template per row (one_line_per_tx) or on the sum (sum_per_account); manual_lines + entry_description posts caller-built balanced lines. SEK only. The dry run answers the lines and the signed sum (tx_sum). Idempotent. Dry-runnable.

Use when: Many small same-day rows of one kind (Swish sales, card fees, a daily settlement) should be one verifikat.

Don't use for: Rows on different dates, foreign-currency rows (book them one by one), or one row against invoices (POST /transactions/{id}/match-batch).

Pitfalls

  • All rows must share one date and direction, and currency SEK: BULK_BOOK_MIXED_CURRENCY / BULK_BOOK_FOREIGN_CURRENCY otherwise.
  • A row that looks already booked returns 409 TRANSACTION_BOOK_POSSIBLE_DUPLICATE naming it; resend with force=true only after reviewing the candidate (each dismissal is logged in behandlingshistorik).
  • manual_lines accounts must be active in the company's chart (BULK_BOOK_INVALID_ACCOUNT) and balance; amounts are kronor, account numbers strings.
  • A posted samlingsverifikat is permanent: undo with storno (POST /journal-entries/{id}/reverse).

Risk: high · Idempotent: yes · Reversible: no · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Request body

NameTypeRequired
tx_idsstring[]yes
existing_journal_entry_idstringno
template_idstringno
mode"one_line_per_tx" | "sum_per_account"no
entry_descriptionstringno
manual_linesobject[]no
default_dimensionsobjectno
forcebooleanno

Response fields

NameTypeDescription
mode"link_existing" | "create_new"
journal_entry_idstring
voucher_seriesstring | null
voucher_numbernumber | null
linked_tx_countnumber
tx_sumnumberSigned SEK sum of the booked rows.
docs_linkednumber

Example request

{
  "tx_ids": [
    "a8f1…",
    "b9e2…"
  ],
  "template_id": "5e4f…",
  "mode": "sum_per_account",
  "entry_description": "Swish-försäljning 2026-05-12"
}

Example response

{
  "data": {
    "mode": "create_new",
    "journal_entry_id": "4d2a…",
    "voucher_series": "A",
    "voucher_number": 57,
    "linked_tx_count": 2,
    "tx_sum": 1250,
    "docs_linked": 0
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/ingest

transactions.ingest · scope transactions:write

Bulk-ingest transactions (up to 500 per call).

Runs the same ingest pipeline as the dashboard CSV importer and the PSD2 bank sync: dedup, insert, invoice match, mapping-rule auto-categorize, auto-JE for high-confidence matches. Idempotent over the whole batch via Idempotency-Key. Dry-runnable.

Use when: You're importing transactions from a CSV, a custom bank feed, or an external accounting system. Each item must have a stable external_id: this is the primary dedup key.

Don't use for: Single ad-hoc transactions (use the dashboard). Documents/receipts (use the documents endpoint). Manually-created journal entries (Phase 4).

Pitfalls

  • external_id is the primary dedup key: make it stable for the same physical transaction across reruns.
  • Content-based dedup runs in addition: a row matching an already-booked transaction by date, amount AND description (prefix-containment, to survive PSD2 title enrichment) is skipped even if external_id differs.
  • raw_insert_only=true skips ALL post-insert pipeline steps (matching, categorization). Use for viewer-only imports.
  • Max 500 items per call. For larger imports, split into pages of 500.
  • Dry-run previews external_id + content dedup against BOOKED rows only; the live pipeline also dedups against unbooked bank-synced rows, so preview skips are a lower bound on the live skip count.

Risk: medium · Idempotent: yes · Reversible: no · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Request body

NameTypeRequired
transactionsobject[]yes
skip_auto_categorizationbooleanno
settlement_accountstringno
raw_insert_onlybooleanno

Response fields

NameType
importednumber
duplicatesnumber
reconcilednumber
auto_categorizednumber
auto_matched_invoicesnumber
errorsnumber
transaction_idsstring[]

Example request

{
  "transactions": [
    {
      "date": "2026-05-12",
      "description": "ICA MAXI",
      "amount": -349.5,
      "currency": "SEK",
      "external_id": "csv-line-42",
      "merchant_name": "ICA MAXI"
    }
  ]
}

Example response

{
  "data": {
    "imported": 1,
    "skipped_duplicates": 0
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

PATCH /api/v1/companies/:companyId/transactions/:id

transactions.update · scope transactions:write

Edit an unbooked transaction: its working title, or which bank account it belongs to.

description replaces the working title (the bank's original stays in original_description; sending it back restores the "not edited" state). account_number (a BAS 19xx account of one of the company's cash accounts, as a string) moves the row to that account, for rows that landed on the wrong account or on none; a disabled, unconnected target is turned back on. Only rows that are neither booked nor matched. Idempotent. Dry-runnable.

Use when: A bank label is cryptic and the user wants a readable title before booking, or a row sits under the wrong bank account and can never be reconciled there.

Don't use for: Booked rows (reverse the verifikat and rebook), changing the amount or date (bank data is never edited), or categorizing (POST /transactions/{id}/categorize).

Pitfalls

  • Send at least one of description or account_number.
  • A booked or matched row returns 409 TRANSACTION_TITLE_LOCKED (title) or TRANSACTION_MOVE_BOOKED (move); a row bulk-booked into a samlingsverifikat also returns TRANSACTION_MOVE_BOOKED for a move.
  • account_number is a STRING like "1931", never a number; an account that is not one of the company's cash accounts returns 404 TRANSACTION_MOVE_UNKNOWN_ACCOUNT, one in another currency 400 TRANSACTION_MOVE_CURRENCY_MISMATCH.

Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Request body

NameTypeRequiredDescription
descriptionstringnoNew working title (1-500 characters).
account_numberstringnoTarget cash account by its BAS 19xx ledger account, e.g. "1931".

Response fields

NameType
idstring
descriptionstring | null
title_edited_atstring | null
cash_account_idstring | null

Example request

{
  "description": "Lunch med kund"
}

Example response

{
  "data": {
    "id": "a8f1…",
    "description": "Lunch med kund",
    "title_edited_at": "2026-06-01T10:00:00Z",
    "cash_account_id": "7f3a…"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

DELETE /api/v1/companies/:companyId/transactions/:id

transactions.delete · scope transactions:write

Delete an unbooked transaction that was added by hand (e.g. a duplicate you created).

Hard-deletes one transaction the company created in Accounted (manual entry or POST /transactions/ingest). Bank-synced and bank-file rows are an external record of money that moved and are never deleted: ignore them (POST /transactions/{id}/ignore). A booked or matched row is räkenskapsinformation and is never deleted either: unlink it or reverse (storno) its verifikat. Idempotent. Dry-runnable.

Use when: A manually added or API-ingested row is a mistake or a duplicate and has not been booked.

Don't use for: Rows from the bank feed or a bank file (POST /transactions/{id}/ignore), booked rows (unlink, or reverse the verifikat), or undoing a whole bank file (POST /imports/bank/{id}/undo).

Pitfalls

  • A booked or matched row returns 409 TRANSACTION_DELETE_BOOKED.
  • A bank-synced or file-imported row returns 409 TRANSACTION_DELETE_IMPORTED: ignore it instead.
  • A row with payment match history returns 409 TRANSACTION_DELETE_HAS_AUDIT_TRAIL at commit (the history is append-only); the dry run cannot see it.

Risk: medium · Idempotent: yes · Reversible: no · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Response fields

NameType
transaction_idstring
deletedtrue

Example response

{
  "data": {
    "transaction_id": "a8f1…",
    "deleted": true
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

DELETE /api/v1/companies/:companyId/transactions/:id/ignore

transactions.unignore · scope transactions:write

Restore an ignored bank transaction to the "to book" list.

Clears the ignore flag set by POST on the same path. The row comes back into the unbooked list and the reconciliation unmatched totals; no verifikat was ever written, so there is nothing to reverse. Idempotent: restoring a row that is not ignored returns was_ignored: false. Dry-runnable.

Use when: A row was ignored by mistake and should be booked after all.

Don't use for: Undoing a booking: that is a storno via POST /transactions/{id}/uncategorize.

Pitfalls

  • Idempotency-Key is mandatory.

Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Response fields

NameType
successboolean
transaction_idstring
is_ignoredfalse
was_ignoredboolean

Example response

{
  "data": {
    "success": true,
    "transaction_id": "tx_…",
    "is_ignored": false,
    "was_ignored": true
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}