Files
accounted/skills/accounted-api/references/suppliers.md
T
8265b5d166 feat(invoices): disclose invoice-register coverage gaps + net-amount search (#2122)
* feat(invoices): disclose invoice-register coverage gaps + amount search

After a SIE migration or verifikat backfill, customer invoices exist only
as journal entries: the invoice list, kundreskontran, /api/invoices, v1
invoices.list, and MCP list_invoices all looked complete while silently
omitting everything before the register's first invoice (user report:
two invoiced fees nearly re-invoiced as "uninvoiced").

- lib/invoices/invoice-register-coverage.ts: coverage boundary = earliest
  register invoice; flags posted non-invoice-engine AR verifikat
  (1510/1513) before it. AR-keyed, not source_type='import'-keyed, so
  manual/API backfills are caught too.
- Invoice list page: one attn line disclosing the boundary (sv+en).
- Kundreskontra: register_coverage in the report payload, rendered in the
  summary card and as an explanation under "Ej avstamd".
- /api/invoices GET: invoice_register_coverage in the response.
- v1 invoices.list: meta.coverage + registry pitfall documenting it.
- MCP gnubok_list_invoices: invoice_register_coverage + coverage_note on
  the first page, pointing agents at gnubok_query_journal.
- Search: lib/invoices/invoice-search.ts matches net (subtotal) and gross
  amounts with sv-SE formatting, alongside number/customer matching; a
  known net amount like 14 000 now finds the 17 500 kr row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF

* fix(invoices): harden register-coverage probe, period-gate reconciliation note, regen api skill

Skeptic + CI findings folded into one pass:

- Coverage probe: a failed AR lookup now degrades to UNKNOWN
  (NO_INVOICE_REGISTER_COVERAGE), never to a confident "complete".
- Probe driven from journal_entries (company-indexed) with the AR line
  condition as an inner embed, instead of the lines-table-with-embed-filters
  shape that lateral-scans every tenant (lib/bookkeeping/entry-lines.ts).
- DEBIT-only 1510/1513 lines; excludes every invoice-engine source type
  (invoice_created, invoice_paid, invoice_cash_payment, credit_note,
  reminder_fee, rot_rut_payout, storno, correction): an advance payment
  crediting 1510 or a re-dated rattelse of an engine entry no longer flags.
- covers_from ignores drafts so a backdated draft cannot move the boundary.
- Kundreskontra "Ej avstamd" explanation is now gated on pre-register AR
  debits existing IN the reconciled period (new
  ARReconciliationResult.pre_register_ar_in_period): prior-period migration
  history cannot explain this period's difference and must not excuse a
  real felbokning. Wording no longer says "snarare an felbokning".
- MCP coverage_note states the earliest register invoice date rather than
  claiming the register "covers" from it.
- Amount search compares magnitudes so credit notes (negative totals) are
  findable; "-17500" parses; null amounts never match "0".
- skills/accounted-api regenerated from the registry (apiskill:check).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF

* chore(api-skill): regenerate accounted-api skill after merging origin/main

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF

* fix(invoices): round-2 review fixes for register-coverage disclosure

- covers_from now anchors on real invoices only (document_type='invoice',
  non-draft): proformas/delivery notes cannot move the boundary.
- INVOICE_ENGINE_SOURCE_TYPES exported + a test scans the engine writers
  (invoice-entries, reminder-fee, rot-rut, storno-service) so a future
  source_type cannot silently become false pre-register evidence.
- Kundreskontra guidance names both 1510 and 1513.
- MCP gnubok_list_invoices outputSchema declares invoice_register_coverage
  and coverage_note.
- v1 reports.ar-ledger documents data.register_coverage; invoices.list
  example made internally consistent; api skill regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF

* fix(mcp): keep gnubok_list_invoices outputSchema minimal to hold the tools/list token budget

The expanded schema from the round-2 review pushed tools/list to 61 726
tokens against the held 61 600 ceiling (payload-size.bench.test.ts). The
ceiling is policy, not a baseline to bump: the description already tells
agents to read invoice_register_coverage/coverage_note, and paginatedSchema
has no additionalProperties:false, so the fields stay schema-valid.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
2026-09-04 09:39:14 +02:00

37 KiB

Suppliers (AP) endpoints

Accounts payable: supplier register and received supplier invoices (register -> approve -> mark-paid, or credit).

Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors) are in SKILL.md and are not repeated per endpoint.

GET /api/v1/companies/{companyId}/supplier-invoices

List supplier invoices for a company. scope:suppliers:read · risk:low · idempotent

Cursor-paginated supplier-invoice list ordered by created_at DESC, id ASC (newest-registered first; the invoice_date column is the seller's invoice date and is filterable via ?date_from / ?date_to but is not the sort key). Filters: status, supplier_id, currency, date_from / date_to (filter by invoice_date).

Use when: You need to enumerate registered supplier invoices for an AP dashboard, a payment run, or a leverantörsreskontra reconciliation. Do not use for: Fetching a single supplier invoice: use GET /supplier-invoices/{id}. Listing customer invoices (different resource).

Pitfalls:

  • Credit notes (is_credit_note=true) appear in the same list as the originals; filter by status=credited or check the flag to separate.
  • remaining_amount is the unpaid portion; a partially_paid SI has remaining_amount > 0.
  • arrival_number is internal book-keeping, not the seller's invoice number: use supplier_invoice_number for matching to received documents.
  • Ordering is by created_at (registration time), not invoice_date. A late-registered invoice appears where it was registered: filter on ?date_from / ?date_to when you care about the seller's invoice date.
  • Cursor pagination: pass ?cursor=<next_cursor> from the previous response. A stale or tampered cursor is ignored and the first page is returned again.
Parameter In Type Required Notes
companyId path string yes

Response 200:

{
  data: { id: string, supplier_id: string, supplier_name: string, arrival_number: number, supplier_invoice_number: string, invoice_date: string, due_date: string, status: "registered" | "approved" | "paid" | "partially_paid" | "overdue" | "disputed" | "credited" | "reversed", currency: string, subtotal: number, vat_amount: number, total: number, paid_amount: number, remaining_amount: number, is_credit_note: boolean, paid_at: string, created_at: string }[],
  meta: {
    request_id: string,
    api_version: string,
    next_cursor?: string,
    audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
    partial_expansions?: string[],
    coverage?: Record<string, unknown>
  }
}

Example response 200:

{
  "data": [
    {
      "id": "0e9c…",
      "supplier_id": "a8f1…",
      "supplier_name": "Office Depot AB",
      "arrival_number": 42,
      "supplier_invoice_number": "2026-1234",
      "invoice_date": "2026-05-10",
      "due_date": "2026-06-09",
      "status": "registered",
      "currency": "SEK",
      "subtotal": 1000,
      "vat_amount": 250,
      "total": 1250,
      "paid_amount": 0,
      "remaining_amount": 1250,
      "is_credit_note": false,
      "paid_at": null,
      "created_at": "2026-05-13T15:00:00Z"
    }
  ],
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12",
    "next_cursor": null
  }
}

POST /api/v1/companies/{companyId}/supplier-invoices

Register a new supplier invoice. scope:suppliers:write · risk:medium · idempotent · dry-run · reversible

Creates a supplier invoice in registered status and posts the registration journal entry under faktureringsmetoden (Debit expense + Debit 2641 Ingående moms / Credit 2440 Leverantörsskulder). Under kontantmetoden no JE is posted at this stage. Idempotent (mandatory Idempotency-Key). Dry-runnable.

Use when: You're registering an incoming leverantörsfaktura. Use dry-run first to validate VAT calculations + period-lock state before committing. Do not use for: Marking an existing SI as paid (use POST /:id/mark-paid). Issuing a credit note (use POST /:id/credit). Customer invoices (different resource).

Pitfalls:

  • Idempotency-Key is mandatory.
  • invoice_date must fall within an open fiscal period: a date covered by a locked period or the company-wide bookkeeping lock returns 400 PERIOD_LOCKED.
  • Under faktureringsmetoden the registration JE is posted atomically with the SI row. JE failure aborts the whole call and no SI row is left behind (strict-mode).
  • supplier_id must reference an existing, non-archived supplier in the same company: 404 SUPPLIER_NOT_FOUND otherwise.
  • Duplicate (supplier_id, supplier_invoice_number) returns 409 SI_CREATE_DUPLICATE_INVOICE_NUMBER. Use the credit flow on the original instead of re-registering with a tweaked number.
  • Foreign currency: omit exchange_rate and the server fetches Riksbanken's rate for invoice_date (ML 8 kap 21-23 §). If no rate can be resolved the create is refused with 400 SI_FX_RATE_MISSING rather than stored unconverted: pass exchange_rate explicitly to proceed. A SEK invoice needs no rate and gets total_sek = total.
  • exchange_rate is SEK per 1 unit of the invoice currency and must satisfy 0 < rate < 100000, the same bounds the supplier_invoices CHECK enforces. Out-of-range values return 400 VALIDATION_ERROR; passing an invoice total where a rate belongs is the usual cause.
  • Project/cost-center tagging: pass default_dimensions ({"6":"P001"} = project, {"1":"KS01"} = kostnadsställe) for the whole invoice and/or items[].dimensions per line (per-line wins per key). The registration JE lines are tagged accordingly. When the company has the dimension registry enabled, unknown or archived codes are rejected with 400 DIMENSION_VALIDATION_FAILED — list valid codes via GET /dimensions.
  • Tjänstepension invoices (Avanza etc.): set items[].apply_slp=true on the 741x premium line and the registration JE also books särskild löneskatt (debit 7533 / credit 2514 at 24.26% of the line amount) beyond the payable: 2440 stays at the invoice total. apply_slp on a non-741x account returns 400 SI_CREATE_SLP_INVALID_ACCOUNT.
Parameter In Type Required Notes
companyId path string yes

Request body:

{
  supplier_id: string,
  document_id?: string,
  supplier_invoice_number: string,
  invoice_date: string,
  due_date: string,
  delivery_date?: string | "",
  currency?: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK",
  exchange_rate?: number,
  vat_treatment?: "standard_25" | "reduced_12" | "reduced_6" | "reverse_charge" | "export" | "exempt",
  reverse_charge?: boolean,
  payment_reference?: string,
  notes?: string,
  ore_rounding?: boolean,
  paid_with_private_funds?: boolean,
  payment_date?: string,
  default_dimensions?: Record<string, string>,
  items: { description: string, amount?: number, account_number: string, vat_rate?: 0 | 0.06 | 0.12 | 0.25, vat_amount?: number, reverse_charge_rate?: number, apply_slp?: boolean, vat_code?: string, quantity?: number, unit?: string, unit_price?: number, accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record<string, string> }[]
}

Example request:

{
  "supplier_id": "a8f1…",
  "supplier_invoice_number": "2026-1234",
  "invoice_date": "2026-05-10",
  "due_date": "2026-06-09",
  "default_dimensions": {
    "6": "P001"
  },
  "items": [
    {
      "description": "Office supplies",
      "amount": 1000,
      "account_number": "5410",
      "vat_rate": 0.25
    }
  ]
}

Response 200:

{
  data: {
    id: string,
    supplier_id: string,
    arrival_number: number,
    supplier_invoice_number: string,
    invoice_date: string,
    due_date: string,
    status: string,
    currency: string,
    subtotal: number,
    vat_amount: number,
    total: number,
    remaining_amount: number,
    is_credit_note: boolean,
    registration_journal_entry_id: string,
    created_at: string
  },
  meta: {
    request_id: string,
    api_version: string,
    next_cursor?: string,
    audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
    partial_expansions?: string[],
    coverage?: Record<string, unknown>
  }
}

Example response 200:

{
  "data": {
    "id": "0e9c…",
    "supplier_id": "a8f1…",
    "arrival_number": 42,
    "supplier_invoice_number": "2026-1234",
    "status": "registered",
    "total": 1250,
    "registration_journal_entry_id": "7b3a…"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

GET /api/v1/companies/{companyId}/supplier-invoices/{id}

Retrieve a single supplier invoice by id. scope:suppliers:read · risk:low · idempotent

Returns the full supplier-invoice record. Pass ?expand=supplier,items,payments to embed the related rows in the same response.

Use when: You need the full record before approving, paying, or crediting it, or for audit trail / reconciliation. Do not use for: Listing supplier invoices (use the list endpoint). Customer-invoice lookups (different resource).

Pitfalls:

  • Credit notes return is_credit_note=true and a credited_invoice_id pointing at the original.
  • registration_journal_entry_id and payment_journal_entry_id let you trace the SI to its bokföring rows; they are null when no JE has been posted (e.g. on a kontantmetoden SI before payment).
Parameter In Type Required Notes
companyId path string yes
id path string yes

Response 200:

{
  data: {
    id: string,
    supplier_id: string,
    arrival_number: number,
    supplier_invoice_number: string,
    invoice_date: string,
    due_date: string,
    received_date: string,
    delivery_date: string,
    status: string,
    currency: string,
    exchange_rate: number,
    subtotal: number,
    vat_amount: number,
    total: number,
    vat_treatment: string,
    reverse_charge: boolean,
    paid_amount: number,
    remaining_amount: number,
    is_credit_note: boolean,
    credited_invoice_id: string,
    registration_journal_entry_id: string,
    payment_journal_entry_id: string,
    notes: string,
    created_at: string,
    updated_at: string
  },
  meta: {
    request_id: string,
    api_version: string,
    next_cursor?: string,
    audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
    partial_expansions?: string[],
    coverage?: Record<string, unknown>
  }
}

Example response 200:

{
  "data": {
    "id": "0e9c…",
    "supplier_id": "a8f1…",
    "arrival_number": 42,
    "supplier_invoice_number": "2026-1234",
    "status": "registered",
    "currency": "SEK",
    "subtotal": 1000,
    "vat_amount": 250,
    "total": 1250,
    "remaining_amount": 1250,
    "is_credit_note": false
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

PATCH /api/v1/companies/{companyId}/supplier-invoices/{id}

Update a registered supplier invoice. scope:suppliers:write · risk:low · idempotent · dry-run · reversible

Patches a supplier invoice with the supplied fields. Only allowed on registered status: once approved, paid, or credited, the record is effectively immutable from the API's perspective. Idempotent (mandatory Idempotency-Key). Dry-runnable.

Use when: You need to adjust due_date, or attach a payment reference / notes to a registered SI before approval. Use dry-run to confirm the merged state first. Do not use for: Editing line items (immutable: credit the SI and register a new one). Changing status (use action verbs). Approved/paid/credited SIs (returns 400 SI_NOT_DRAFT). invoice_date / supplier_invoice_number on an SI that already has a registration verifikat (returns 400 SI_EDIT_VERIFIKAT_LOCKED).

Pitfalls:

  • Returns 400 SI_NOT_DRAFT when current status !== "registered".
  • invoice_date and supplier_invoice_number are on the posted registration verifikat (entry_date and description). Once registration_journal_entry_id is set, patching them returns 400 SI_EDIT_VERIFIKAT_LOCKED: correct the entry via a rättelse (gnubok_correct_entry) or credit the SI and re-register. Resending the unchanged value is accepted.
  • Patching a field never re-posts the registration JE.
Parameter In Type Required Notes
companyId path string yes
id path string yes

Request body:

{
  supplier_invoice_number?: string,
  invoice_date?: string,
  due_date?: string,
  delivery_date?: string | "",
  payment_reference?: string,
  notes?: string
}

Example request:

{
  "payment_reference": "OCR-1234567890"
}

Response 200:

{
  data: {
    id: string,
    supplier_id: string,
    arrival_number: number,
    supplier_invoice_number: string,
    invoice_date: string,
    due_date: string,
    received_date: string,
    delivery_date: string,
    status: string,
    currency: string,
    exchange_rate: number,
    subtotal: number,
    vat_amount: number,
    total: number,
    vat_treatment: string,
    reverse_charge: boolean,
    paid_amount: number,
    remaining_amount: number,
    is_credit_note: boolean,
    credited_invoice_id: string,
    registration_journal_entry_id: string,
    payment_journal_entry_id: string,
    notes: string,
    created_at: string,
    updated_at: string
  },
  meta: {
    request_id: string,
    api_version: string,
    next_cursor?: string,
    audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
    partial_expansions?: string[],
    coverage?: Record<string, unknown>
  }
}

Example response 200:

{
  "data": {
    "id": "0e9c…",
    "payment_reference": "OCR-1234567890"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/{companyId}/supplier-invoices/{id}/approve

Approve a registered or overdue supplier invoice. scope:suppliers:write · risk:low · idempotent · dry-run

Attests a supplier invoice that has not been approved yet (status registered or overdue). The resulting status is approved, or overdue when the invoice is still past its due date. No journal entry is posted here: the registration JE was already booked at :create under accrual, or is deferred to :mark-paid under cash. Idempotent. Dry-runnable.

Use when: A registered SI has been reviewed and you want to mark it ready for payment. Many AP workflows gate :mark-paid behind an explicit approval step. Do not use for: Posting a journal entry (already done at :create under accrual). Paying the SI (use :mark-paid). Re-approving an already-approved SI (returns 400 SI_APPROVE_NOT_REGISTERED).

Pitfalls:

  • Idempotency-Key is mandatory.
  • Returns 400 SI_APPROVE_NOT_REGISTERED when the invoice is already approved (approved_at set) or sits in a settled status. Use the detail endpoint to inspect status first if unsure.
  • A still-past-due invoice comes back with status "overdue", not "approved": approved_at is the attest marker, the status is derived from the due date.
Parameter In Type Required Notes
companyId path string yes
id path string yes

Response 200:

{
  data: {
    id: string,
    status: "approved" | "overdue",
    arrival_number: number,
    supplier_invoice_number: string
  },
  meta: {
    request_id: string,
    api_version: string,
    next_cursor?: string,
    audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
    partial_expansions?: string[],
    coverage?: Record<string, unknown>
  }
}

Example response 200:

{
  "data": {
    "id": "0e9c…",
    "status": "approved",
    "arrival_number": 42,
    "supplier_invoice_number": "2026-1234"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/{companyId}/supplier-invoices/{id}/credit

Issue a credit note for a supplier invoice. scope:suppliers:write · risk:high · idempotent · dry-run

Creates a kreditfaktura that reverses the original supplier invoice. Under accrual the reversing JE is posted atomically (Debit 2440 / Credit expense + Credit 2641). The original status flips to credited. Strict-mode: any failure rolls back the credit-note row. Idempotent. Dry-runnable.

Use when: You need to nullify a registered, approved, partially_paid, or paid supplier invoice: for a returned shipment, an over-invoice, or a vendor dispute resolution. Use dry-run to confirm the totals first. Do not use for: Editing line items on an unchanged invoice (use PATCH on registered SIs). Crediting an already-credited SI (returns 409 SI_CREDIT_ALREADY_CREDITED). Reversing a v1-issued credit (no v1 endpoint today: use the dashboard).

Pitfalls:

  • Idempotency-Key is mandatory.
  • Today's date is used as the credit-note invoice_date. It must fall in an open fiscal period: locked period returns 400 SI_CREDIT_PERIOD_LOCKED.
  • Cash basis (kontantmetoden): no reversing JE is posted: recognition is deferred until a refund transaction is booked. The credit-note row is still created so the AP audit trail stays consistent.
  • The original SI is flipped to credited regardless of how much of it was already paid; reconcile the bank refund via the transactions endpoints.
Parameter In Type Required Notes
companyId path string yes
id path string yes

Response 200:

{
  data: {
    credit_note_id: string,
    original_id: string,
    arrival_number: number,
    supplier_invoice_number: string,
    registration_journal_entry_id: string
  },
  meta: {
    request_id: string,
    api_version: string,
    next_cursor?: string,
    audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
    partial_expansions?: string[],
    coverage?: Record<string, unknown>
  }
}

Example response 200:

{
  "data": {
    "credit_note_id": "4d2a…",
    "original_id": "0e9c…",
    "arrival_number": 43,
    "supplier_invoice_number": "KREDIT-2026-1234",
    "registration_journal_entry_id": "9c2f…"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/{companyId}/supplier-invoices/{id}/mark-paid

Record a payment against a supplier invoice. scope:suppliers:write · risk:medium · idempotent · dry-run

Books the payment journal entry (Debit 2440 / Credit 1930 under accrual; or Debit expense + Debit 2641 / Credit 1930 under cash) and flips the SI status to paid (full settlement) or partially_paid. Strict-mode: a JE failure aborts before any SI mutation. Idempotent. Dry-runnable.

Use when: You paid a registered or approved leverantörsfaktura through a channel other than the synced bank flow. For bank-matched payments use POST /transactions/{id}/match-supplier-invoice instead: that path also reconciles the bank line. Do not use for: Refunding a payment (the public API does not expose unmark-paid; credit the SI instead). Paying a credited or already-paid SI (returns 409 SI_PAID_ALREADY).

Pitfalls:

  • Idempotency-Key is mandatory.
  • payment_date must fall in an open fiscal period: locked period returns 400 PERIOD_LOCKED.
  • exchange_rate_difference (SEK delta vs the booked rate at registration) is required for foreign-currency SIs to book the FX gain/loss to 3960 / 7960. Omitting it on a non-SEK SI under accrual mis-books FX.
  • Strict-mode: a JE creation failure ABORTS before the status flip. There is no partial-state recovery banner: retry the call.
  • Cash basis (kontantmetoden) recognizes the expense + ingående moms HERE, not at :create.
Parameter In Type Required Notes
companyId path string yes
id path string yes

Request body:

{
  amount?: number,
  payment_date?: string,
  exchange_rate_difference?: number,
  notes?: string,
  force?: boolean,
  payment_account?: string,
  lines?: { account_number: string, debit_amount?: number, credit_amount?: number, line_description?: string, dimensions?: Record<string, string> }[]
}

Example request:

{
  "payment_date": "2026-05-13"
}

Response 200:

{
  data: {
    id: string,
    status: "paid" | "partially_paid",
    total: number,
    paid_amount: number,
    remaining_amount: number,
    paid_at: string,
    payment_journal_entry_id: string
  },
  meta: {
    request_id: string,
    api_version: string,
    next_cursor?: string,
    audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
    partial_expansions?: string[],
    coverage?: Record<string, unknown>
  }
}

Example response 200:

{
  "data": {
    "id": "0e9c…",
    "status": "paid",
    "total": 1250,
    "paid_amount": 1250,
    "remaining_amount": 0,
    "paid_at": "2026-05-13",
    "payment_journal_entry_id": "7b3a…"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

GET /api/v1/companies/{companyId}/suppliers

List suppliers for a company. scope:suppliers:read · risk:low · idempotent

Returns active suppliers in created-first order. Pass ?include_archived=true to include archived rows. Use ?search to match against name or org_number.

Use when: You need a supplier roster: for building a UI picker, resolving a supplier_id before registering a supplier invoice, or syncing an external AP system. Do not use for: Fetching a single supplier you already know the id of: use GET /api/v1/companies/{companyId}/suppliers/{id}. Customers are a separate resource.

Pitfalls:

  • Archived suppliers are hidden by default; the dashboard makes the same choice.
  • org_number identifies legal entities only: suppliers currently have no individual type, so the field is Bolagsverket public-record data when present.
  • vat_number is stored as supplied; unlike customers, suppliers are not auto-validated against VIES on create. Validate externally if the integration requires it.
Parameter In Type Required Notes
companyId path string yes

Response 200:

{
  data: { id: string, name: string, supplier_type: "swedish_business" | "eu_business" | "non_eu_business", email: string, org_number: string, vat_number: string, default_payment_terms: number, default_currency: string, archived_at: string, created_at: string }[],
  meta: {
    request_id: string,
    api_version: string,
    next_cursor?: string,
    audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
    partial_expansions?: string[],
    coverage?: Record<string, unknown>
  }
}

Example response 200:

{
  "data": [
    {
      "id": "a8f1…",
      "name": "Office Depot AB",
      "supplier_type": "swedish_business",
      "email": "invoices@officedepot.example",
      "org_number": "556677-8899",
      "vat_number": "SE556677889901",
      "default_payment_terms": 30,
      "default_currency": "SEK",
      "archived_at": null,
      "created_at": "2026-04-12T08:30:00Z"
    }
  ],
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12",
    "next_cursor": null
  }
}

POST /api/v1/companies/{companyId}/suppliers

Create a supplier. scope:suppliers:write · risk:low · idempotent · dry-run · reversible

Creates a new supplier for the company. Requires Idempotency-Key (UUID). Supports ?dry_run=true for input validation without committing: the dry-run response shows the would-be record minus id and timestamps.

Use when: You need to register a new supplier before booking supplier invoices against them. Use dry-run first to catch validation errors before committing. Do not use for: Updating an existing supplier (PATCH instead). Creating customers (different resource).

Pitfalls:

  • Idempotency-Key is mandatory: calls without it return 400 VALIDATION_ERROR.
  • org_number uniqueness is enforced at the database level; duplicate inserts return 409 SUPPLIER_DUPLICATE_ORG_NUMBER.
  • Unlike customers, suppliers carry no vat_number_validated flag: vat_number is stored as supplied without VIES verification. Validate externally if your workflow requires it.
  • default_expense_account is a BAS account number (e.g. "5410"); the value is stored as-is and used as the suggested debit account when supplier invoices are booked.
Parameter In Type Required Notes
companyId path string yes

Request body:

{
  name: string,
  supplier_type: "swedish_business" | "eu_business" | "non_eu_business",
  email?: string,
  phone?: string,
  address_line1?: string,
  address_line2?: string,
  postal_code?: string,
  city?: string,
  country?: string,
  org_number?: string,
  vat_number?: string,
  bankgiro?: string,
  plusgiro?: string,
  bank_account?: string,
  iban?: string,
  bic?: string,
  clearing_number?: string,
  account_number?: string,
  default_expense_account?: string,
  default_payment_terms?: number,
  default_currency?: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK",
  notes?: string
}

Example request:

{
  "name": "Office Depot AB",
  "supplier_type": "swedish_business",
  "email": "invoices@officedepot.example",
  "org_number": "556677-8899",
  "bankgiro": "123-4567",
  "default_expense_account": "5410",
  "default_payment_terms": 30,
  "default_currency": "SEK"
}

Response 200:

{
  data: {
    id: string,
    name: string,
    supplier_type: "swedish_business" | "eu_business" | "non_eu_business",
    email: string,
    phone: string,
    address_line1: string,
    address_line2: string,
    postal_code: string,
    city: string,
    country: string,
    org_number: string,
    vat_number: string,
    bankgiro: string,
    plusgiro: string,
    bank_account: string,
    iban: string,
    bic: string,
    default_expense_account: string,
    default_payment_terms: number,
    default_currency: string,
    notes: string,
    archived_at: string,
    created_at: string,
    updated_at: string
  },
  meta: {
    request_id: string,
    api_version: string,
    next_cursor?: string,
    audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
    partial_expansions?: string[],
    coverage?: Record<string, unknown>
  }
}

Example response 200:

{
  "data": {
    "id": "0e9c…",
    "name": "Office Depot AB",
    "supplier_type": "swedish_business",
    "email": "invoices@officedepot.example",
    "org_number": "556677-8899",
    "bankgiro": "123-4567",
    "default_expense_account": "5410",
    "default_payment_terms": 30,
    "default_currency": "SEK",
    "archived_at": null,
    "created_at": "2026-05-13T15:00:00Z",
    "updated_at": "2026-05-13T15:00:00Z"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

GET /api/v1/companies/{companyId}/suppliers/{id}

Retrieve a single supplier by id. scope:suppliers:read · risk:low · idempotent

Returns the full supplier record. Pass ?expand=supplier_invoices to embed any open supplier invoices (registered / approved / partially_paid / overdue / disputed) for the supplier in the same response.

Use when: You need the full supplier record: address, payment terms, banking details, default expense account: before booking a supplier invoice or syncing to an external AP system. Do not use for: Listing suppliers (use the list endpoint). Looking up customer or employee records (different resources).

Pitfalls:

  • archived_at is non-null when the supplier has been soft-deleted; the supplier is still queryable by id but excluded from default lists.
  • Banking fields (bankgiro / plusgiro / iban / bic) are stored as supplied; no Luhn or IBAN check is performed at this layer.
Parameter In Type Required Notes
companyId path string yes
id path string yes

Response 200:

{
  data: {
    id: string,
    name: string,
    supplier_type: string,
    email: string,
    phone: string,
    address_line1: string,
    address_line2: string,
    postal_code: string,
    city: string,
    country: string,
    org_number: string,
    vat_number: string,
    bankgiro: string,
    plusgiro: string,
    bank_account: string,
    iban: string,
    bic: string,
    default_expense_account: string,
    default_payment_terms: number,
    default_currency: string,
    notes: string,
    archived_at: string,
    created_at: string,
    updated_at: string
  },
  meta: {
    request_id: string,
    api_version: string,
    next_cursor?: string,
    audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
    partial_expansions?: string[],
    coverage?: Record<string, unknown>
  }
}

Example response 200:

{
  "data": {
    "id": "a8f1…",
    "name": "Office Depot AB",
    "supplier_type": "swedish_business",
    "email": "invoices@officedepot.example",
    "org_number": "556677-8899",
    "bankgiro": "123-4567",
    "default_expense_account": "5410",
    "default_payment_terms": 30,
    "default_currency": "SEK",
    "archived_at": null,
    "created_at": "2026-04-12T08:30:00Z",
    "updated_at": "2026-04-30T11:22:09Z"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

PATCH /api/v1/companies/{companyId}/suppliers/{id}

Partially update a supplier. scope:suppliers:write · risk:low · idempotent · dry-run · reversible

Patches the supplier with the supplied fields. All fields optional. Idempotent (mandatory Idempotency-Key). Dry-runnable.

Use when: You need to change a supplier's contact details, payment terms, banking info, default expense account, or VAT number. Use dry-run first to confirm the merged record before committing. Do not use for: Archiving a supplier (use DELETE: sets archived_at). Replacing the entire record (no PUT verb is exposed; PATCH is partial).

Pitfalls:

  • Idempotency-Key is mandatory; calls without it return 400.
  • org_number uniqueness is enforced at DB level: 23505 → 409 SUPPLIER_DUPLICATE_ORG_NUMBER.
  • Changing default_expense_account does not retroactively rebook prior supplier invoices: only future bookings pick up the new default.
Parameter In Type Required Notes
companyId path string yes
id path string yes

Request body:

{
  name?: string,
  supplier_type?: "swedish_business" | "eu_business" | "non_eu_business",
  email?: string,
  phone?: string,
  address_line1?: string,
  address_line2?: string,
  postal_code?: string,
  city?: string,
  country?: string,
  org_number?: string,
  vat_number?: string,
  bankgiro?: string,
  plusgiro?: string,
  bank_account?: string,
  iban?: string,
  bic?: string,
  clearing_number?: string,
  account_number?: string,
  default_expense_account?: string,
  default_payment_terms?: number,
  default_currency?: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK",
  notes?: string
}

Example request:

{
  "default_payment_terms": 14,
  "notes": "New payment terms agreed 2026-05-12."
}

Response 200:

{
  data: {
    id: string,
    name: string,
    supplier_type: string,
    email: string,
    phone: string,
    address_line1: string,
    address_line2: string,
    postal_code: string,
    city: string,
    country: string,
    org_number: string,
    vat_number: string,
    bankgiro: string,
    plusgiro: string,
    bank_account: string,
    iban: string,
    bic: string,
    default_expense_account: string,
    default_payment_terms: number,
    default_currency: string,
    notes: string,
    archived_at: string,
    created_at: string,
    updated_at: string
  },
  meta: {
    request_id: string,
    api_version: string,
    next_cursor?: string,
    audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
    partial_expansions?: string[],
    coverage?: Record<string, unknown>
  }
}

Example response 200:

{
  "data": {
    "id": "0e9c…",
    "name": "Office Depot AB",
    "default_payment_terms": 14,
    "notes": "New payment terms agreed 2026-05-12."
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

DELETE /api/v1/companies/{companyId}/suppliers/{id}

Archive a supplier (soft-delete). scope:suppliers:write · risk:medium · idempotent · dry-run · reversible

Sets archived_at on the supplier; the record is preserved (supplier invoices and audit history remain intact) but excluded from default list responses. To un-archive, PATCH archived_at back to null. Idempotent: archiving an already-archived supplier is a no-op. Dry-runnable.

Use when: You want to remove a supplier from active rosters without losing their history. Idempotent: re-archiving is safe. Do not use for: Permanently deleting a supplier with all history: the public API does not expose hard-delete. GDPR erasure requests go through a dedicated workflow.

Pitfalls:

  • Idempotency-Key is mandatory.
  • A supplier with any open supplier invoice (registered / approved / partially_paid / overdue / disputed) cannot be archived: returns 409 SUPPLIER_HAS_INVOICES. Close the invoices first. This protects BFL 7 kap audit: the supplier record is the canonical source of seller name/address for invoice reissuance.
  • 204 No Content is returned on success: there is no response body to parse.
Parameter In Type Required Notes
companyId path string yes
id path string yes

Response 204.


POST /api/v1/companies/{companyId}/suppliers/bulk-create

Create up to 50 suppliers in one call (partial-success). scope:suppliers:write · risk:low · idempotent · dry-run · reversible

Bulk-create endpoint mirroring /customers/bulk-create. Each supplier is validated and inserted independently: per-item failures do not roll back items that succeeded. Returns a results array plus a summary. Idempotent over the whole batch. Dry-runnable.

Use when: You're importing a roster of suppliers from another AP system, or seeding a fresh company with its existing vendor list. Use dry-run first to validate the batch. Do not use for: Updating existing suppliers: PATCH /suppliers/{id} once per supplier. Bulk uploads of > 50 suppliers: split into pages of 50. Transactional all-or-nothing imports: passing all_or_nothing: true returns 501 NOT_IMPLEMENTED.

Pitfalls:

  • Idempotency-Key is mandatory and covers the WHOLE batch. A retried bulk-create returns the cached full response: it does not retry only the failed items.
  • Passing all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist; omit the flag or pass false.
  • org_number uniqueness is enforced at the DB level: items with duplicates fail individually with SUPPLIER_DUPLICATE_ORG_NUMBER.
  • No VIES validation runs per item; vat_number is stored as supplied. Validate externally if your workflow requires it.
Parameter In Type Required Notes
companyId path string yes

Request body:

{
  suppliers: { name: string, supplier_type: "swedish_business" | "eu_business" | "non_eu_business", email?: string, phone?: string, address_line1?: string, address_line2?: string, postal_code?: string, city?: string, country?: string, org_number?: string, vat_number?: string, bankgiro?: string, plusgiro?: string, bank_account?: string, iban?: string, bic?: string, clearing_number?: string, account_number?: string, default_expense_account?: string, default_payment_terms?: number, default_currency?: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK", notes?: string }[],
  all_or_nothing?: boolean
}

Example request:

{
  "suppliers": [
    {
      "name": "Office Depot AB",
      "supplier_type": "swedish_business",
      "org_number": "556677-8899"
    },
    {
      "name": "Cloud Hosting GmbH",
      "supplier_type": "eu_business",
      "vat_number": "DE123456789"
    }
  ]
}

Response 200:

{
  data: {
    results: { ok: boolean, request_index: number, data?: unknown, error?: { code: string, message: string, details?: unknown } }[],
    summary: { total: number, succeeded: number, failed: number }
  },
  meta: {
    request_id: string,
    api_version: string,
    next_cursor?: string,
    audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
    partial_expansions?: string[],
    coverage?: Record<string, unknown>
  }
}

Example response 200:

{
  "data": {
    "results": [
      {
        "ok": true,
        "request_index": 0,
        "data": {
          "id": "0e9c…",
          "name": "Office Depot AB"
        }
      },
      {
        "ok": true,
        "request_index": 1,
        "data": {
          "id": "4d2a…",
          "name": "Cloud Hosting GmbH"
        }
      }
    ],
    "summary": {
      "total": 2,
      "succeeded": 2,
      "failed": 0
    }
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}