# gnubok API

> Swedish double-entry bookkeeping as a public REST API for agents and integrations. API version `2026-05-12`.

The gnubok API lets you do anything the dashboard can do — create invoices, ingest bank transactions, file VAT declarations, run payroll, and subscribe to webhooks for state changes. Every endpoint is designed for autonomous agents first: machine-readable schemas, dry-run previews, idempotent retries, and inline audit blocks on every write.

If you've used [Stripe's API](https://docs.stripe.com/api), the shape will feel familiar — bearer-token auth, dated API versions, webhook signature verification, idempotency keys. The accounting concepts are Swedish (BAS chart, BFL retention, K2/K3, momsdeklaration) but the surface is built for the same kind of integrator.

## Authentication

All requests authenticate with a bearer token in the `Authorization` header:

```bash
curl https://gnubok.app/api/v1/companies \
  -H "Authorization: Bearer gnubok_sk_live_..."
```

Create keys in the gnubok dashboard at **/settings/api**. Two key prefixes are available:

- `gnubok_sk_live_*` — hits real customer data. Use in production.
- `gnubok_sk_test_*` — bound to deterministic sandbox companies. Safe for evals, demos, and agent learning. Same surface, different blast radius.

Each key carries one or more **scopes** (`invoices:read`, `invoices:write`, `payroll:write`, `webhooks:manage`, ...) that gate which endpoints it can call. Scopes are listed on every endpoint reference page.

Rate limit: 100 requests per minute per key, returned in `X-RateLimit-*` headers.

## Base URL

```
https://gnubok.app/api/v1
```

URLs include the company id explicitly:

```
GET  /api/v1/companies/{companyId}/invoices
POST /api/v1/companies/{companyId}/invoices
```

A multi-company key can act on any company the underlying user is a member of — the URL is the source of truth, not a default. List the companies a key can access with:

```bash
curl https://gnubok.app/api/v1/companies \
  -H "Authorization: Bearer gnubok_sk_live_..."
```

## Core principles

These four invariants hold across the entire surface — once you've internalised them you can predict the shape of any endpoint without reading the reference.

**Dry-run on every write.** Append `?dry_run=true` (or send `X-Dry-Run: true`) to any POST/PATCH/DELETE to preview the effect — the response shows the journal lines, voucher number, account deltas, and any validation errors that would surface, but commits nothing. Use this in agent test-loops to validate inputs before paying the side-effect cost.

**Idempotency-Key on every write.** Pass a UUID in the `Idempotency-Key` header. Replays of the same key+body return the original response with `Idempotent-Replayed: true` (24h cache). Replays with a different body return `409 IDEMPOTENCY_KEY_REUSE`.

**Strict-mode write semantics.** A v1 mutation either commits fully or returns a structured error code with no side effects. The dashboard soft-fails on partial writes (a human is there to retry); the v1 surface aborts. This means you never see "the invoice was sent but the email failed" — either both happened or neither did.

**Inline audit on every write.** Every successful write response includes an `audit` block in `meta` with the voucher number, audit-trail URL, and immutability timestamp. No second round-trip needed to confirm what happened.

## Response envelope

Every response has the same shape:

```json
{
  "data": { ... },
  "meta": {
    "request_id": "req_...",
    "api_version": "2026-05-12",
    "next_cursor": "...",
    "audit": { "voucher_number": "A-2026-042", "voucher_url": "..." }
  }
}
```

Errors swap `data` for `error`:

```json
{
  "error": {
    "code": "PERIOD_LOCKED",
    "message": "Den valda perioden är låst.",
    "message_en": "The selected period is locked.",
    "remediation": { "description": "Unlock via /fiscal-periods/{id}/unlock or pick an open period.", "tool": "fiscal_periods.unlock" },
    "details": { "fiscal_period_id": "..." },
    "docs_url": "https://gnubok.app/docs/api/errors#period_locked"
  },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
```

Every error code is documented in the [error reference](/docs/api/errors).

## Where to go next

- **[Quickstart cookbook](/docs/api/cookbook/quickstart)** — send your first invoice in five minutes.
- **[API reference](/docs/api/reference)** — every endpoint, grouped by resource.
- **[Webhooks](/docs/api/webhooks)** — subscribe to events with HMAC-signed delivery.
- **[Errors](/docs/api/errors)** — every stable error code with remediation.
- **[Versioning](/docs/api/versioning)** — how API versions are pinned and upgraded.
- **[Changelog](/docs/api/changelog)** — what shipped when.

For LLM-based agents:
- **[`/llms.txt`](/llms.txt)** — concise agent-discovery index.
- **[`/llms-full.txt`](/llms-full.txt)** — full docs concatenated for ingestion.
- **[`/api/v1/openapi.json`](https://gnubok.app/api/v1/openapi.json)** — machine-readable OpenAPI 3.1 spec.
- **[`/.well-known/skills/index.json`](https://gnubok.app/.well-known/skills/index.json)** — gnubok-specific skill catalogue.

---

# Versioning + idempotency + dry-run

> Three guarantees that hold across the entire v1 surface: stable response shapes pinned per request, safe retries on every write, and previewable side effects on every mutation. Once you've internalised them you can predict the shape of any new endpoint without reading its reference.

## Versioning

The major version is encoded in the URL: `/api/v1/`. Within v1, the response shape is dated and pinned. The current version is **`2026-05-12`**.

Every response carries the active version in headers and the `meta` envelope:

```
Gnubok-Version: 2026-05-12
```
```json
{ "data": {...}, "meta": { "request_id": "...", "api_version": "2026-05-12" } }
```

### Pinning

Webhooks are pinned to the API version active at creation time (the `api_version_pinned` column on the `webhooks` row). Payload shapes for *your* webhook will not change until you explicitly upgrade — even if we ship a new dated version that breaks the shape for newly-created webhooks.

API requests pin per-request via the `Gnubok-Version` request header (planned for v1.x; today every request gets the current version):

```bash
curl https://gnubok.app/api/v1/companies \
  -H "Authorization: Bearer ..." \
  -H "Gnubok-Version: 2026-05-12"
```

### Deprecation policy

When we ship a new dated version that breaks an existing shape:

1. The new version is dated forward (e.g. `2026-08-01`) and made the default for newly-created keys + webhooks.
2. The previous version stays available for at least **6 months** after the new version ships.
3. Deprecation appears in the [changelog](/docs/api/changelog) with the retirement date and a migration guide.
4. Three months before retirement, every response from a deprecated version stamps `Gnubok-Deprecation: <ISO date>` in headers.
5. Calls to a retired version receive HTTP 410 with code `API_VERSION_RETIRED`.

We will not break a shape inside an active dated version. Additive changes (new optional response fields, new request fields with defaults, new endpoints) ship as patch updates and are always backwards-compatible.

### What counts as a breaking change

- Removing a response field
- Renaming a response field
- Changing the type of a response field
- Removing an endpoint
- Removing or narrowing a stable error code
- Tightening request validation in a way that rejects previously-accepted input
- Changing the URL of an existing endpoint

What does NOT count as a breaking change:

- Adding a new optional response field
- Adding a new optional request field with a default
- Adding a new endpoint
- Adding a new error code (we expand the catalogue freely; existing codes stay stable)
- Loosening request validation
- Performance improvements that don't change observable behaviour

---

## Idempotency

Every state-changing endpoint (POST, PATCH, DELETE) accepts an `Idempotency-Key` header. The key is a UUID you generate; the server caches the response keyed by `(api_key_id, company_id, idempotency_key, request_body_hash)` for 24 hours.

### How it works

- **First call with a fresh key** → executes normally; response is cached.
- **Replay with the same key + same body** → returns the cached response with `Idempotent-Replayed: true` header. The original side effects are NOT re-executed.
- **Replay with the same key + different body** → returns `409 IDEMPOTENCY_KEY_REUSE`. This indicates the key was reused incorrectly.
- **Two concurrent requests with the same key** → one wins, the other waits for the cached response.

### Required vs supported

Some endpoints **require** an Idempotency-Key (the create routes for resources that would be expensive to deduplicate after the fact: invoices, customers, supplier-invoices, webhooks). Calls without the header return `400 VALIDATION_ERROR` with field `Idempotency-Key`.

Other endpoints **support** but don't require it. Sending one is always safe.

### Pattern

```bash
curl https://gnubok.app/api/v1/companies/{cid}/invoices \
  -H "Authorization: Bearer ..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "...", "items": [...] }'
```

In an agent loop, generate the key once at the *start* of an attempt and reuse it across every retry of that single logical action — never on a fresh attempt with new inputs.

---

## Dry-run

Every state-changing endpoint that supports dry-run (`x-dry-run-supported: true` in the OpenAPI spec) accepts `?dry_run=true` query param **or** `X-Dry-Run: true` header. The endpoint executes its full validation pipeline (Zod, business rules, period-lock checks, VAT-rate compatibility, cross-tenant guards, ...) but does NOT commit. The response shape matches a successful commit:

- All `validation_error` shapes that a real commit would produce surface here.
- The response `data` shows the would-be record with `id: null`, timestamps `null`, and any auto-generated values (voucher number, invoice number) shown as `null` or as the value that *would* have been allocated.
- The response stamps `X-Dry-Run: true` in headers.

Use dry-run to:

- **Validate input shape** before paying the side-effect cost (especially in agent test loops).
- **Preview voucher lines** the engine would generate for a given invoice + VAT mix before committing.
- **Probe period-lock** on a date before scheduling work.

Dry-run **does not** call external providers (VIES VAT validation, BankID, Skatteverket submission). Those run only on commit.

---

## Strict-mode write semantics

A v1 mutation either commits fully or returns a structured error code with no side effects. The dashboard soft-fails on partial writes (a human is there to retry); the v1 surface aborts. This means you never see "the invoice was sent but the email failed" or "the journal entry posted but the payment row didn't" — either both happened or neither did.

When a multi-step write fails:

- **Pre-engine failure** (validation, missing FK, period locked) → no rows written, structured error returned.
- **Post-engine failure** (engine call succeeded, follow-up step failed) → the engine's writes are reversed via `reverseEntry()` (storno), the failure surfaces with code matching the failed step (e.g. `MATCH_INVOICE_TX_LINK_FAILED`).

Storno reversals are themselves immutable journal entries — the original audit trail remains visible per BFL 5 kap 5 §. `reversal_journal_entry_id` on the original row points at the storno.

---

## Inline audit on every write

Every successful write response carries an `audit` block in `meta`:

```json
{
  "data": {...},
  "meta": {
    "request_id": "req_...",
    "api_version": "2026-05-12",
    "audit": {
      "voucher_number": "A-2026-042",
      "voucher_url": "https://gnubok.app/bookkeeping/...",
      "audit_trail_url": "https://gnubok.app/audit/req_...",
      "immutable_at": "2026-05-15T12:00:00Z"
    }
  }
}
```

No second round-trip needed to confirm what happened — agents can chain follow-up work directly on the returned voucher number.

---

# Webhooks

> Receive HMAC-signed POST notifications when state changes in gnubok — invoices paid, journal entries committed, periods locked, salary runs booked, AGI files generated. At-least-once delivery with exponential backoff over ~72 hours.

If you've used [Stripe webhooks](https://docs.stripe.com/webhooks), the model is identical: subscribe a URL to an event type, gnubok POSTs each event with a signed JSON body, your receiver returns 2xx to acknowledge. The signature header format and retry policy are the same. The event types are gnubok-specific.

## Lifecycle

1. **Register a receiver** with [`POST /api/v1/companies/{companyId}/webhooks`](/docs/api/reference/webhooks#post-webhooks). The response includes an HMAC signing secret returned **exactly once** — store it on the receiver side immediately. If you lose it, delete the webhook and create a new one.
2. **gnubok emits events** internally (e.g. an invoice is marked paid via the dashboard or another API call). The webhook handler enqueues a delivery row.
3. **The dispatcher cron runs every minute**, signs the payload with HMAC-SHA256, and POSTs to your URL with a 10-second timeout.
4. **Your receiver verifies the signature**, processes the event idempotently, and returns 2xx.
5. **Failed deliveries retry** at `1m / 5m / 30m / 2h / 12h / 24h / 48h` (7 retries, ~72 hours total). After all attempts the delivery is marked `dead`. HTTP 410 from your receiver short-circuits to `dead` immediately and **auto-disables** the webhook.

## Event types

The following event types are deliverable as webhooks. Subscribing to a type that requires elevated scope (`salary_run.*` and `agi.*` need `payroll:read`) returns `INSUFFICIENT_SCOPE` at registration time.

**Invoicing**
- `invoice.created` — draft invoice created
- `invoice.sent` — invoice marked sent (email delivered or external)
- `invoice.paid` — invoice fully paid
- `credit_note.created` — credit note issued

**AP / suppliers**
- `supplier.created`
- `supplier_invoice.registered`
- `supplier_invoice.approved`
- `supplier_invoice.paid`
- `supplier_invoice.credited`
- `supplier_invoice.uncredited` — credit reversal

**Customers**
- `customer.created`

**Bookkeeping**
- `journal_entry.committed` — voucher posted (immutable from this point)
- `journal_entry.reversed` — storno entry posted
- `journal_entry.corrected` — rättelse via `correctEntry` (BFL 5 kap 5 §)

**Transactions**
- `transaction.categorized` — bank transaction assigned an account + tax code
- `transaction.reconciled` — transaction matched to a posted entry

**Periods**
- `period.locked` — fiscal period closed for writes
- `period.unlocked` — fiscal period reopened
- `period.year_closed` — full year-end procedure complete

**Payroll** *(requires `payroll:read` scope alongside `webhooks:manage`)*
- `salary_run.created`
- `salary_run.approved`
- `salary_run.booked` — journal entries posted
- `agi.generated` — AGI XML produced

**Documents**
- `document.uploaded`

## Payload shape

Every delivery wraps the event in a Stripe-style envelope:

```json
{
  "id": "wh_dlv_a8f1...",
  "type": "invoice.paid",
  "api_version": "2026-05-12",
  "created": 1715797800,
  "data": {
    "object": {
      "invoice": { "id": "...", "invoice_number": "2026-0042", "total": 12500.00, ... },
      "paymentAmount": 12500.00,
      "paymentDate": "2026-05-15",
      "companyId": "..."
    }
  },
  "previous_attributes": null
}
```

- `id` matches the `webhook_delivery_id` you can poll at [`GET /webhooks/{webhookId}/deliveries`](/docs/api/reference/webhooks#get-deliveries).
- `api_version` is the version pinned to your webhook at creation time. Payload shapes for *your* webhook will not change until you explicitly upgrade.
- `previous_attributes` carries the prior values of any fields that changed on update-style events (e.g. `invoice.paid` carries the prior invoice state). `null` for create-style events.

## Request headers

Every outbound POST carries:

```
POST /your-receiver-url HTTP/1.1
Content-Type: application/json
User-Agent: gnubok-webhook/1
X-Gnubok-Signature: t=1715797800,v1=2f5c...
X-Gnubok-Event: invoice.paid
X-Gnubok-Delivery: wh_dlv_a8f1...
X-Gnubok-Api-Version: 2026-05-12
X-Request-Id: whdel_a8f1...
```

The `X-Gnubok-Delivery` header is the canonical correlation id — log it on receipt and use it to deduplicate retries (deliveries are at-least-once, so the same delivery id may arrive more than once after a network blip).

## Verifying signatures

The signature header has the format `t=<unix-seconds>,v1=<hex-HMAC-SHA256>`. The signed payload is `${t}.${rawBody}` — the timestamp is included so receivers can implement a replay window (we recommend rejecting deliveries with `t` more than 5 minutes old).

You **must** verify the signature on every delivery before processing it. Without verification, anyone who learns your URL can forge events.

### Node.js

```javascript
import crypto from 'node:crypto'
import express from 'express'

const app = express()
const SECRET = process.env.GNUBOK_WEBHOOK_SECRET // whsec_...

// Important: capture the RAW body before any JSON parsing — the signature
// is computed against the exact bytes gnubok sent, not a re-serialised JSON.
app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sigHeader = req.header('x-gnubok-signature') ?? ''
    const rawBody = req.body.toString('utf8')

    if (!verifySignature(rawBody, sigHeader, SECRET)) {
      return res.status(400).send('invalid signature')
    }

    const event = JSON.parse(rawBody)
    // Idempotency: process the delivery id once.
    if (alreadyProcessed(event.id)) return res.status(200).send('ok')
    handleEvent(event)
    return res.status(200).send('ok')
  },
)

function verifySignature(body, header, secret) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('=', 2)),
  )
  const t = Number.parseInt(parts.t, 10)
  const v1 = parts.v1
  if (!t || !v1) return false

  // Reject deliveries older than 5 minutes — replay protection.
  const ageSec = Math.floor(Date.now() / 1000) - t
  if (Math.abs(ageSec) > 300) return false

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${body}`)
    .digest('hex')

  // Constant-time comparison.
  const expectedBuf = Buffer.from(expected, 'hex')
  const actualBuf = Buffer.from(v1, 'hex')
  if (expectedBuf.length !== actualBuf.length) return false
  return crypto.timingSafeEqual(expectedBuf, actualBuf)
}
```

### Python

```python
import hmac
import hashlib
import json
import os
import time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["GNUBOK_WEBHOOK_SECRET"].encode("utf-8")  # whsec_...

@app.post("/webhook")
def webhook():
    raw_body = request.get_data()  # bytes — must be the raw request body
    sig_header = request.headers.get("X-Gnubok-Signature", "")

    if not verify_signature(raw_body, sig_header, SECRET):
        abort(400, "invalid signature")

    event = json.loads(raw_body)
    if already_processed(event["id"]):
        return "", 200
    handle_event(event)
    return "", 200


def verify_signature(body: bytes, header: str, secret: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    try:
        t = int(parts["t"])
        v1 = parts["v1"]
    except (KeyError, ValueError):
        return False

    # Replay protection: 5-minute window.
    if abs(int(time.time()) - t) > 300:
        return False

    signed = f"{t}.".encode("utf-8") + body
    expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)
```

### Common pitfalls

- **Using parsed JSON instead of raw bytes.** Re-serialising the body (`JSON.stringify(req.body)`) produces different bytes than gnubok sent — the signature won't match. Capture the raw body before any framework parses it.
- **Forgetting the timestamp window.** Without checking `t`, an attacker who captured one signed payload can replay it forever. 5 minutes is our recommended window; tighten if your clock skew is small.
- **Treating retries as duplicates of failure.** Retries arrive when *we* didn't get a 2xx. A 200 response that arrives slowly may not reach us in time and we'll retry — your receiver sees the same `X-Gnubok-Delivery` twice. Idempotency is on you.
- **Returning 5xx for application errors.** A 5xx triggers the full retry policy (~72h of attempts). If your handler hit an application bug that won't resolve on retry, return 200 and queue the failure for internal investigation; only return 5xx for genuinely transient problems.
- **Missing `redirect: 'error'`-style refusal at receiver level.** If your receiver follows redirects, an attacker who can MITM the response could redirect re-tries to a malicious URL. Modern HTTP clients refuse redirects by default for POST; verify yours does.

## Delivery debugging

Use [`GET /api/v1/companies/{companyId}/webhooks/{webhookId}/deliveries`](/docs/api/reference/webhooks#get-deliveries) to list the recent delivery history for a webhook — every row has the response status, response body (truncated to 4 KB, only `text/plain` and `application/json` content types persisted), error message, and current state (`pending` / `in_flight` / `delivered` / `failed` / `dead`).

To replay a `dead` or `delivered` delivery, call [`POST /api/v1/webhook-deliveries/{deliveryId}/retry`](/docs/api/reference/webhooks#post-retry). The retry creates a fresh delivery row pointing at the same payload — the original audit row stays in place. Receivers must be idempotent on the `X-Gnubok-Delivery` header.

To send a synthetic test event without driving real state, call [`POST /webhooks/{webhookId}/test`](/docs/api/reference/webhooks#post-test). The dispatcher delivers a `webhook.test` event with a static payload on the next per-minute tick.

## Auto-disable behaviour

The dispatcher disables a webhook (sets `active=false` + `disabled_reason`) and stops attempting delivery when:

- The receiver returns **HTTP 410 Gone** — explicit "stop sending"
- The receiver returns **HTTP 3xx redirect** — refusing to follow redirects to internal IPs is a security policy; a stable receiver should not return 3xx
- The webhook URL **resolves to a private/loopback/link-local/cloud-metadata IP** at dispatch time (DNS rebinding refusal)

Re-enable with [`PATCH /webhooks/{webhookId}`](/docs/api/reference/webhooks#patch-webhooks) setting `active: true`. This clears `disabled_at` + `disabled_reason` but does NOT replay the deliveries that died while disabled — replay them individually with the retry endpoint.

## Audit + retention

Webhook delivery rows are *behandlingshistorik* (a system-event log) per BFNAR 2013:2 kap 8 § — they are immutable once they reach a terminal state (`delivered` or `dead`) so the audit trail of who-was-notified-when stays intact. The underlying *räkenskapsinformation* (the verifikation, the faktura, the AGI XML itself) lives in its own table with its own BFL 7 kap retention — webhook delivery rows are NOT räkenskapsinformation and the 7-year retention applies to the underlying record, not to the delivery envelope.

For accounting-event delivery rows (`journal_entry.*`, `period.*`, `salary_run.booked`, `agi.generated`, `invoice.paid`, `supplier_invoice.paid`), gnubok keeps the delivery rows for 7 years. **This is a voluntary operational audit-trail policy gnubok chose because the duration aligns conveniently with BFL 7 kap retention on the underlying records — it is NOT itself a statutory obligation.** The 7-year statutory retention under BFL 7 kap 1 § applies to the underlying verifikation / faktura / AGI XML in its own table, not to the delivery envelope. The integrator's own retention obligations likewise attach to the underlying records you receive (and any local copies you persist), not to the delivery-row metadata.

Deleting a webhook does not delete its delivery history; the FK is `ON DELETE SET NULL` so the audit trail survives.

---

# Errors

> Every error returned by the gnubok REST API uses a stable code from this catalogue. Codes never change once shipped — agents can pattern-match on them safely. The `docs_url` field on every error envelope points at the anchor for that specific code.

## Envelope shape

```json
{
  "error": {
    "code": "PERIOD_LOCKED",
    "message": "Den valda perioden är låst.",
    "message_en": "The selected period is locked.",
    "remediation": {
      "description": "Unlock via /fiscal-periods/{id}/unlock or pick an open period.",
      "tool": "fiscal_periods.unlock"
    },
    "details": { "fiscal_period_id": "..." },
    "docs_url": "https://gnubok.app/docs/api/errors#period_locked"
  },
  "meta": { "request_id": "req_...", "api_version": "..." }
}
```

The `message` field is Swedish (matches the dashboard); `message_en` is English (for agent and developer logs); `remediation` (when present) hints at the canonical fix and may include a `tool` reference into the MCP surface.

## Generic

*Cross-cutting codes returned by any endpoint.*

### COMPANY_CONTEXT_MISSING

**HTTP `400`** — Bad request

No active company context resolved for the request.

**Swedish:** Ingen aktiv företagskontext. Välj ett företag och försök igen.

### CONFLICT

**HTTP `409`** — Conflict

Conflict.

**Swedish:** En konflikt uppstod. Ladda om sidan och försök igen.

### FORBIDDEN

**HTTP `403`** — Forbidden

Insufficient permissions.

**Swedish:** Du har inte behörighet att utföra denna åtgärd.

### IDEMPOTENCY_KEY_REUSE

**HTTP `409`** — Conflict

Idempotency key was previously used with a different request body.

**Swedish:** Idempotensnyckeln har redan använts med en annan begäran.

**Remediation:** Use a fresh UUID for a new operation, or send the original request body to replay.

### INSUFFICIENT_SCOPE

**HTTP `403`** — Forbidden

The current API key does not have the required scope.

**Swedish:** API-nyckeln saknar behörighet för denna åtgärd.

**Remediation:** Mint a new key with the missing scope or grant it through the API key settings.
Related resource: `gnubok://capabilities`

### INTERNAL_ERROR

**HTTP `500`** — Server error

Internal server error.

**Swedish:** Ett oväntat serverfel uppstod. Försök igen senare.

### MFA_REQUIRED

**HTTP `403`** — Forbidden

MFA verification required.

**Swedish:** Tvåstegsverifiering krävs för att utföra åtgärden.

### NOT_FOUND

**HTTP `404`** — Not found

Resource not found.

**Swedish:** Resursen kunde inte hittas.

### NOT_IMPLEMENTED

**HTTP `501`** — Not implemented

This feature is accepted by the schema but not yet implemented.

**Swedish:** Funktionen är inte implementerad ännu.

### RATE_LIMITED

**HTTP `429`** — Rate limited

Rate limit exceeded.

**Swedish:** För många förfrågningar. Vänta en stund och försök igen.

### UNAUTHORIZED

**HTTP `401`** — Unauthorized

Authentication required.

**Swedish:** Din session har gått ut. Logga in igen.

### UNKNOWN_ERROR

**HTTP `500`** — Server error

An unexpected error occurred.

**Swedish:** Något gick fel. Försök igen.

### VALIDATION_ERROR

**HTTP `400`** — Bad request

Validation error.

**Swedish:** Förfrågan innehåller ogiltiga uppgifter.

## Bookkeeping engine

*Errors from the journal-entry lifecycle (create, commit, reverse, correct).*

### BOOKKEEPING_DATABASE_ERROR

**HTTP `500`** — Server error

Bookkeeping database operation failed.

**Swedish:** Verifikationen kunde inte sparas. Försök igen.

### JOURNAL_ENTRY_NOT_BALANCED

**HTTP `400`** — Bad request

Debits and credits do not match.

**Swedish:** Verifikationen balanserar inte.

**Remediation:** Recalculate the lines so totals are equal before retrying.

### JOURNAL_ENTRY_NOT_FOUND

**HTTP `404`** — Not found

Journal entry not found.

**Swedish:** Verifikationen kunde inte hittas.

## Periods + year-end

*Fiscal period locking, year-end closing, opening balances, FX revaluation.*

### FX_FAILED

**HTTP `400`** — Bad request

Currency revaluation failed.

**Swedish:** Valutaomvärderingen misslyckades.

### FX_PERIOD_CLOSED

**HTTP `400`** — Bad request

Period is already closed; currency revaluation cannot be run.

**Swedish:** Perioden är redan stängd. Valutaomvärdering kan inte köras.

### FX_PERIOD_NOT_FOUND

**HTTP `404`** — Not found

Fiscal period not found.

**Swedish:** Räkenskapsperioden kunde inte hittas.

### PERIOD_HAS_UNBOOKED_TRANSACTIONS

**HTTP `400`** — Bad request

The period contains uncategorized business transactions.

**Swedish:** Perioden innehåller okategoriserade affärstransaktioner. Bokför eller markera dem som privata innan låsning.

**Remediation:** Categorize or mark uncategorized transactions before locking.
Related tool: `gnubok_list_uncategorized_transactions`

### PERIOD_LOCKED

**HTTP `400`** — Bad request

Period is locked or closed; entries cannot be added.

**Swedish:** Bokföringen är låst för denna period.

### PERIOD_LOCK_ALREADY_LOCKED

**HTTP `409`** — Conflict

Period is already locked.

**Swedish:** Perioden är redan låst.

### PERIOD_LOCK_FAILED

**HTTP `400`** — Bad request

Failed to lock period.

**Swedish:** Perioden kunde inte låsas.

### PERIOD_LOCK_HAS_DRAFTS

**HTTP `400`** — Bad request

Period contains draft journal entries.

**Swedish:** Perioden innehåller verifikationsutkast som måste bokföras eller raderas innan låsning.

### PERIOD_NOT_FOUND

**HTTP `404`** — Not found

Fiscal period not found.

**Swedish:** Räkenskapsperioden kunde inte hittas.

### PERIOD_NOT_LOCKED

**HTTP `400`** — Bad request

Period must be locked before it can be closed.

**Swedish:** Perioden måste först låsas innan den kan stängas.

**Remediation:** Call gnubok_lock_period before closing.
Related tool: `gnubok_lock_period`

### YEAR_END_FAILED

**HTTP `400`** — Bad request

Failed to execute year-end closing.

**Swedish:** Bokslutet kunde inte verkställas.

### YEAR_END_NOT_RUN

**HTTP `400`** — Bad request

Year-end closing must be executed before the period can be closed.

**Swedish:** Bokslutsåtgärder måste utföras innan perioden kan stängas.

### YEAR_END_PREVIEW_FAILED

**HTTP `400`** — Bad request

Failed to preview year-end closing.

**Swedish:** Bokslutsförhandsgranskningen misslyckades.

### YEAR_END_PRIOR_PERIOD_OPEN

**HTTP `400`** — Bad request

A prior fiscal period is still open.

**Swedish:** En tidigare period är fortfarande öppen. Stäng den först.

### YEAR_END_UNBALANCED_TRIAL

**HTTP `400`** — Bad request

Trial balance does not balance.

**Swedish:** Resultaträkningens debet och kredit balanserar inte. Granska verifikationerna innan bokslut.

## Invoices

*Customer invoice lifecycle: draft, send, mark paid, credit.*

### CUSTOMER_CREATE_FAILED

**HTTP `500`** — Server error

Failed to create customer.

**Swedish:** Kunden kunde inte skapas.

### CUSTOMER_DELETE_FAILED

**HTTP `500`** — Server error

Failed to delete customer.

**Swedish:** Kunden kunde inte tas bort.

### CUSTOMER_DUPLICATE_ORG_NUMBER

**HTTP `409`** — Conflict

A customer with that organisation number already exists.

**Swedish:** En kund med samma organisationsnummer finns redan.

### CUSTOMER_HAS_INVOICES

**HTTP `409`** — Conflict

Customer cannot be deleted while invoices reference it.

**Swedish:** Kunden har fakturor och kan inte tas bort.

### CUSTOMER_NOT_FOUND

**HTTP `404`** — Not found

Customer not found.

**Swedish:** Kunden kunde inte hittas.

### CUSTOMER_UPDATE_FAILED

**HTTP `500`** — Server error

Failed to update customer.

**Swedish:** Kunden kunde inte uppdateras.

### INVOICE_ALREADY_SENT

**HTTP `409`** — Conflict

The invoice is already sent or paid.

**Swedish:** Fakturan har redan skickats eller betalats.

### INVOICE_CANCEL_RACE

**HTTP `409`** — Conflict

Invoice was modified concurrently and could not be cancelled. Reload and retry.

**Swedish:** Fakturan ändrades samtidigt och kunde inte makuleras. Ladda om och försök igen.

### INVOICE_CREATE_INSERT_FAILED

**HTTP `500`** — Server error

Invoice insert failed.

**Swedish:** Fakturan kunde inte sparas.

### INVOICE_CREATE_ITEMS_FAILED

**HTTP `500`** — Server error

Invoice items insert failed.

**Swedish:** Fakturaraderna kunde inte sparas.

### INVOICE_CREATE_NUMBER_ASSIGN_FAILED

**HTTP `500`** — Server error

Failed to assign invoice number on create.

**Swedish:** Kunde inte tilldela fakturanummer vid skapande.

### INVOICE_CREATE_VAT_RULE_VIOLATION

**HTTP `400`** — Bad request

The VAT rate is not allowed for this customer type.

**Swedish:** Momssatsen är inte tillåten för denna kundtyp.

### INVOICE_CREDIT_ALREADY_CREDITED

**HTTP `400`** — Bad request

Invoice has already been credited.

**Swedish:** Fakturan har redan krediterats.

### INVOICE_CREDIT_NOT_INVOICE

**HTTP `400`** — Bad request

Credit notes can only be created from standard invoices.

**Swedish:** Kreditfakturor kan endast skapas från riktiga fakturor.

### INVOICE_CREDIT_NOT_SENT

**HTTP `400`** — Bad request

Only sent, paid, or overdue invoices can be credited.

**Swedish:** Endast skickade, betalda eller förfallna fakturor kan krediteras.

### INVOICE_CREDIT_ORIGINAL_NOT_FOUND

**HTTP `404`** — Not found

Original invoice not found.

**Swedish:** Ursprungsfakturan kunde inte hittas.

### INVOICE_CUSTOMER_NOT_FOUND

**HTTP `404`** — Not found

Customer not found.

**Swedish:** Kunden kunde inte hittas.

### INVOICE_DELETE_NOT_DRAFT

**HTTP `400`** — Bad request

Only draft invoices can be deleted; non-drafts must be credited.

**Swedish:** Endast utkast kan tas bort. Bokförda fakturor måste krediteras istället.

**Remediation:** Issue a credit note instead of deleting a posted invoice.

### INVOICE_PAID_BOOK_FAILED

**HTTP `500`** — Server error

Failed to create payment journal entry.

**Swedish:** Kunde inte bokföra betalningen.

### INVOICE_PAID_LIKELY_DUPLICATE

**HTTP `409`** — Conflict

A likely-matching unlinked inbound bank transaction was found for this customer. Suggest linking it instead of creating a new payment entry.

**Swedish:** Det finns redan en obokförd inkommande banktransaktion som kan vara denna betalning. Länka den istället, eller markera som betald ändå om du är säker.

**Remediation:** Match the candidate transaction via POST /api/transactions/{id}/match-invoice, or resend mark-paid with force: true to create the payment entry anyway. When using the v1 endpoint, the force retry requires a fresh Idempotency-Key (the original key is bound to the body hash).

### INVOICE_PAID_LINES_UNBALANCED

**HTTP `400`** — Bad request

Custom journal lines do not balance.

**Swedish:** Verifikationsraderna är inte balanserade (debet ≠ kredit).

### INVOICE_PAID_NOT_FOUND

**HTTP `404`** — Not found

Invoice not found.

**Swedish:** Fakturan kunde inte hittas.

### INVOICE_PAID_NOT_PAYABLE

**HTTP `400`** — Bad request

Invoice is not in a payable status.

**Swedish:** Fakturan kan inte markeras som betald i nuvarande status.

### INVOICE_PAID_NO_FISCAL_PERIOD

**HTTP `400`** — Bad request

No open fiscal period covers the payment date.

**Swedish:** Ingen öppen räkenskapsperiod för betalningsdatumet.

### INVOICE_PAID_RACE

**HTTP `409`** — Conflict

Invoice was already paid by another request.

**Swedish:** Fakturan har redan betalats av en annan förfrågan.

### INVOICE_PDF_RENDER_FAILED

**HTTP `500`** — Server error

Invoice PDF rendering failed.

**Swedish:** Fakturans PDF kunde inte skapas.

### INVOICE_SEND_CANCELLED

**HTTP `400`** — Bad request

Cancelled invoices cannot be sent; create a new invoice instead.

**Swedish:** Makulerade fakturor kan inte skickas. Skapa en ny faktura istället.

### INVOICE_SEND_COMPANY_SETTINGS_MISSING

**HTTP `404`** — Not found

Company settings are missing.

**Swedish:** Företagsinställningar saknas.

### INVOICE_SEND_EMAIL_NOT_CONFIGURED

**HTTP `503`**

Email service is not configured.

**Swedish:** E-posttjänsten är inte konfigurerad. Kontrollera att RESEND_API_KEY och RESEND_FROM_EMAIL är satta.

**Remediation:** Set RESEND_API_KEY and RESEND_FROM_EMAIL in the deployment environment.

### INVOICE_SEND_NO_CUSTOMER_EMAIL

**HTTP `400`** — Bad request

Customer has no email address.

**Swedish:** Kunden saknar e-postadress. Uppdatera kunduppgifterna först.

**Remediation:** Add an email address on the customer record before sending.

### INVOICE_SEND_NUMBER_ASSIGN_FAILED

**HTTP `500`** — Server error

Failed to assign invoice number on send.

**Swedish:** Kunde inte tilldela fakturanummer.

### INVOICE_SEND_PARTIAL

**HTTP `200`**

Invoice was sent but a follow-up step (journal entry or PDF) failed.

**Swedish:** Fakturan skickades men en efterföljande åtgärd misslyckades (verifikation eller PDF-bilaga).

### INVOICE_SEND_PDF_RENDER_FAILED

**HTTP `500`** — Server error

Failed to render invoice PDF before send; no invoice number was consumed.

**Swedish:** Fakturans PDF kunde inte skapas. Kontrollera fakturarader och kunduppgifter och försök igen.

### INVOICE_SEND_PROVIDER_FAILED

**HTTP `502`**

The email provider could not deliver the message.

**Swedish:** E-postleverantören kunde inte skicka meddelandet.

### INVOICE_UPDATE_NOT_DRAFT

**HTTP `409`** — Conflict

Only draft invoices can be updated. Issued invoices are immutable — issue a credit note instead.

**Swedish:** Endast utkast kan ändras. Bokförda fakturor är oföränderliga — utfärda en kreditfaktura istället.

**Remediation:** Issue a credit note via POST /invoices/{id}:credit and create a fresh invoice with the corrected details.

## Supplier invoices

*AP lifecycle: register, approve, mark paid, credit.*

### SUPPLIER_CREATE_FAILED

**HTTP `500`** — Server error

Failed to create supplier.

**Swedish:** Leverantören kunde inte skapas.

### SUPPLIER_DELETE_FAILED

**HTTP `500`** — Server error

Failed to delete supplier.

**Swedish:** Leverantören kunde inte tas bort.

### SUPPLIER_DUPLICATE_ORG_NUMBER

**HTTP `409`** — Conflict

A supplier with that organisation number already exists.

**Swedish:** En leverantör med samma organisationsnummer finns redan.

### SUPPLIER_HAS_INVOICES

**HTTP `409`** — Conflict

Supplier cannot be archived while open supplier invoices reference it.

**Swedish:** Leverantören kan inte arkiveras eftersom det finns öppna leverantörsfakturor som refererar till den.

**Remediation:** Close (credit / mark paid) every open supplier invoice before archiving the supplier. The dashboard exposes the same blocker.

### SUPPLIER_NOT_FOUND

**HTTP `404`** — Not found

Supplier not found.

**Swedish:** Leverantören kunde inte hittas.

### SUPPLIER_UPDATE_FAILED

**HTTP `500`** — Server error

Failed to update supplier.

**Swedish:** Leverantören kunde inte uppdateras.

## Transactions

*Bank transaction ingest, categorisation, matching.*

### MATCH_INVOICE_ALREADY_PAID

**HTTP `409`** — Conflict

Invoice has already been fully paid or is no longer matchable.

**Swedish:** Fakturan har redan slutbetalats av en annan förfrågan.

### MATCH_INVOICE_DUPLICATE_PAYMENT

**HTTP `409`** — Conflict

This transaction is already matched to this invoice.

**Swedish:** Den här transaktionen är redan matchad mot fakturan.

### MATCH_INVOICE_LINK_TX_FAILED

**HTTP `500`** — Server error

Failed to link transaction to invoice.

**Swedish:** Kunde inte koppla transaktionen till fakturan.

### MATCH_INVOICE_NOT_FOUND

**HTTP `404`** — Not found

Invoice not found.

**Swedish:** Fakturan kunde inte hittas.

### MATCH_INVOICE_NOT_INCOME

**HTTP `400`** — Bad request

Only income transactions can be matched to customer invoices.

**Swedish:** Endast intäktstransaktioner kan matchas mot kundfakturor.

### MATCH_INVOICE_NOT_INVOICE_TYPE

**HTTP `400`** — Bad request

Only invoices may be matched to a transaction; proforma and delivery notes have no VAT obligation.

**Swedish:** Endast fakturor kan matchas mot en transaktion. Proforma och följesedel saknar momsskyldighet.

### MATCH_INVOICE_NOT_OPEN

**HTTP `400`** — Bad request

Invoice is not in an unpaid state.

**Swedish:** Fakturan är inte i ett obetalt läge och kan inte matchas.

### MATCH_INVOICE_PARTIAL

**HTTP `200`**

Match recorded but the journal entry could not be created.

**Swedish:** Matchningen registrerades men verifikationen kunde inte skapas.

### MATCH_INVOICE_RECORD_PAYMENT_FAILED

**HTTP `500`** — Server error

Failed to record invoice payment.

**Swedish:** Kunde inte registrera fakturabetalningen.

### MATCH_INVOICE_TX_ALREADY_LINKED

**HTTP `400`** — Bad request

Transaction is already linked to an invoice.

**Swedish:** Transaktionen är redan kopplad till en faktura.

### MATCH_SI_ALREADY_PAID

**HTTP `400`** — Bad request

Supplier invoice is already paid or credited.

**Swedish:** Leverantörsfakturan är redan betald eller krediterad.

### MATCH_SI_CASH_FX_UNSUPPORTED

**HTTP `400`** — Bad request

Cash accounting does not support exchange-rate differences. Switch to accrual or book the FX difference manually.

**Swedish:** Kontantmetoden stödjer inte valutakursdifferenser. Byt till löpande bokföring eller bokför valutakursdifferensen manuellt.

### MATCH_SI_DUPLICATE_PAYMENT

**HTTP `409`** — Conflict

This transaction is already matched to this supplier invoice.

**Swedish:** Den här transaktionen är redan matchad mot leverantörsfakturan.

### MATCH_SI_LINK_TX_FAILED

**HTTP `500`** — Server error

Failed to link transaction to supplier invoice.

**Swedish:** Kunde inte koppla transaktionen till leverantörsfakturan.

### MATCH_SI_NOT_EXPENSE

**HTTP `400`** — Bad request

Only expense transactions can be matched to supplier invoices.

**Swedish:** Endast utgiftstransaktioner kan matchas mot leverantörsfakturor.

### MATCH_SI_NOT_FOUND

**HTTP `404`** — Not found

Supplier invoice not found.

**Swedish:** Leverantörsfakturan kunde inte hittas.

### MATCH_SI_NOT_OPEN

**HTTP `409`** — Conflict

Supplier invoice has already been fully paid or is no longer matchable.

**Swedish:** Leverantörsfakturan har redan slutbetalats av en annan förfrågan.

### MATCH_SI_RECORD_PAYMENT_FAILED

**HTTP `500`** — Server error

Failed to record supplier invoice payment.

**Swedish:** Kunde inte registrera leverantörsfakturabetalningen.

### MATCH_SI_TX_ALREADY_LINKED

**HTTP `400`** — Bad request

Transaction is already linked to a supplier invoice.

**Swedish:** Transaktionen är redan kopplad till en leverantörsfaktura.

### TRANSACTION_ALREADY_CATEGORIZED

**HTTP `409`** — Conflict

The transaction already has a journal entry.

**Swedish:** Transaktionen är redan bokförd. Ångra kategoriseringen om du vill ändra den.

**Remediation:** Use gnubok_uncategorize_transaction first if you need to recategorize.
Related tool: `gnubok_uncategorize_transaction`

## Reports

*Report generation: VAT declaration, periodisk sammanställning, SIE export, INK2.*

### PS_REPORT_CSV_BLOCKED_BY_ERRORS

**HTTP `400`** — Bad request

CSV download blocked by validation errors. Fix them first.

**Swedish:** CSV kan inte laddas ner. Åtgärda blockerande fel först.

### PS_REPORT_GENERATION_FAILED

**HTTP `500`** — Server error

Failed to generate periodisk sammanställning.

**Swedish:** Periodisk sammanställning kunde inte beräknas.

### PS_REPORT_INVALID_PERIOD

**HTTP `400`** — Bad request

period is invalid for the chosen period type.

**Swedish:** period är ogiltig för vald periodtyp.

### PS_REPORT_INVALID_PERIOD_TYPE

**HTTP `400`** — Bad request

periodType must be monthly or quarterly.

**Swedish:** periodType måste vara monthly eller quarterly.

### PS_REPORT_INVALID_YEAR

**HTTP `400`** — Bad request

year must be a number between 2000 and 2100.

**Swedish:** year måste vara ett giltigt årtal mellan 2000 och 2100.

### PS_REPORT_MISSING_FILER_INFO

**HTTP `400`** — Bad request

Tax contact information is missing on company_settings.

**Swedish:** Kontaktuppgifter saknas. Fyll i namn, telefon och e-post under Inställningar.

### PS_REPORT_MISSING_PARAMS

**HTTP `400`** — Bad request

periodType, year and period query parameters are required.

**Swedish:** periodType, year och period krävs.

### REPORT_GENERATION_FAILED

**HTTP `500`** — Server error

Failed to generate the report.

**Swedish:** Rapporten kunde inte genereras.

### REPORT_PERIOD_REQUIRED

**HTTP `400`** — Bad request

period_id query parameter is required.

**Swedish:** period_id krävs.

### SIE_EXPORT_COMPANY_NOT_FOUND

**HTTP `404`** — Not found

Company settings missing; SIE export cannot be generated.

**Swedish:** Företagsinställningar saknas — SIE-exporten kan inte skapas.

### SIE_EXPORT_FAILED

**HTTP `500`** — Server error

Failed to generate SIE export.

**Swedish:** SIE-exporten misslyckades.

### TAX_DECL_GENERATION_FAILED

**HTTP `500`** — Server error

Failed to generate tax declaration.

**Swedish:** Skattedeklarationen kunde inte genereras.

### VAT_REPORT_GENERATION_FAILED

**HTTP `500`** — Server error

Failed to calculate VAT declaration.

**Swedish:** Momsdeklarationen kunde inte beräknas.

### VAT_REPORT_INVALID_PERIOD

**HTTP `400`** — Bad request

period is invalid for the chosen period type.

**Swedish:** period är ogiltig för vald periodtyp.

### VAT_REPORT_INVALID_PERIOD_TYPE

**HTTP `400`** — Bad request

periodType must be one of monthly, quarterly, yearly.

**Swedish:** periodType måste vara monthly, quarterly eller yearly.

### VAT_REPORT_INVALID_YEAR

**HTTP `400`** — Bad request

year must be a number between 2000 and 2100.

**Swedish:** year måste vara ett giltigt årtal mellan 2000 och 2100.

### VAT_REPORT_MISSING_PARAMS

**HTTP `400`** — Bad request

periodType, year and period query parameters are required.

**Swedish:** periodType, year och period krävs.

## Imports

*SIE import, bank file import, opening-balance import, provider migration.*

### BANK_FILE_DUPLICATE

**HTTP `409`** — Conflict

Bank file has already been imported.

**Swedish:** Den här filen har redan importerats.

### BANK_FILE_EXECUTE_FAILED

**HTTP `500`** — Server error

Bank file import failed.

**Swedish:** Bankfilsimporten misslyckades.

### BANK_FILE_FORMAT_UNKNOWN

**HTTP `400`** — Bad request

Bank file format could not be identified.

**Swedish:** Bankfilens format kunde inte identifieras.

### BANK_FILE_IMPORT_RECORD_FAILED

**HTTP `500`** — Server error

Failed to create the bank file import record.

**Swedish:** Kunde inte skapa importpost.

### BANK_FILE_NO_FILE

**HTTP `400`** — Bad request

No file attached to the request.

**Swedish:** Ingen fil bifogad i förfrågan.

### BANK_FILE_NO_TRANSACTIONS

**HTTP `400`** — Bad request

No transactions to import.

**Swedish:** Bankfilen innehåller inga transaktioner att importera.

### BANK_FILE_PARSE_FAILED

**HTTP `500`** — Server error

Failed to parse the bank file.

**Swedish:** Kunde inte tolka bankfilen.

### BANK_FILE_TOO_LARGE

**HTTP `400`** — Bad request

File exceeds the 10 MB size limit.

**Swedish:** Filen är för stor. Maxstorlek är 10 MB.

### SIE_IMPORT_ACCOUNT_ACTIVATION_FAILED

**HTTP `500`** — Server error

Failed to activate mapped accounts in the chart of accounts.

**Swedish:** Kunde inte aktivera konton i kontoplanen. Kontrollera att kontona inte redan finns med andra inställningar.

### SIE_IMPORT_DUPLICATE

**HTTP `409`** — Conflict

This SIE file has already been imported.

**Swedish:** Den här SIE-filen har redan importerats.

### SIE_IMPORT_FAILED

**HTTP `400`** — Bad request

SIE import completed with errors.

**Swedish:** Importen slutfördes med fel. Se detaljerna nedan.

### SIE_IMPORT_UNEXPECTED

**HTTP `500`** — Server error

Unexpected error during SIE import; no data was committed.

**Swedish:** Importen avbröts oväntat. Ingen data har sparats.

### SIE_IMPORT_UNMAPPED_ACCOUNTS

**HTTP `400`** — Bad request

One or more accounts have no mapping target.

**Swedish:** Vissa konton saknar mappning. Gå tillbaka till kontomappningssteget och koppla alla konton.

**Remediation:** Map every source account to a BAS account before importing.

## Salary + AGI

*Payroll lifecycle, AGI generation, KU declarations.*

### AGI_FSKATT_VERIFICATION_FAILED

**HTTP `400`** — Bad request

F-skatt verification failed.

**Swedish:** F-skattekontrollen misslyckades. Kontrollera leverantörens F-skatt.

### AGI_GENERATE_NOT_BOOKABLE

**HTTP `400`** — Bad request

AGI can only be generated for salary runs in review, approved, paid, booked, or corrected status.

**Swedish:** AGI kan endast genereras för lönekörningar i status review, approved, paid, booked eller corrected.

### AGI_GENERATION_FAILED

**HTTP `500`** — Server error

Failed to generate AGI declaration.

**Swedish:** AGI-deklarationen kunde inte genereras.

### AGI_INCOMPLETE_DATA

**HTTP `400`** — Bad request

AGI data is incomplete — verify the company has org number, contact name, phone, and email.

**Swedish:** AGI-data ofullständig — kontrollera att företaget har organisationsnummer, kontaktnamn, telefon och e-post.

### AGI_NO_SALARY_RUN

**HTTP `400`** — Bad request

No salary run exists for the period.

**Swedish:** Det finns ingen lönekörning för perioden.

### EMPLOYEE_DUPLICATE_PERSONNUMMER

**HTTP `409`** — Conflict

An employee with that personnummer already exists.

**Swedish:** En anställd med samma personnummer finns redan.

### EMPLOYEE_NOT_FOUND

**HTTP `404`** — Not found

Employee not found.

**Swedish:** Den anställda kunde inte hittas.

### SALARY_RUN_APPROVE_NOT_REVIEW

**HTTP `400`** — Bad request

Salary run must be in review status to approve.

**Swedish:** Lönekörningen måste vara i status review för godkännande.

### SALARY_RUN_APPROVE_VALIDATION_FAILED

**HTTP `400`** — Bad request

Validation failed — fix issues before approving.

**Swedish:** Valideringsfel — korrigera innan godkännande.

### SALARY_RUN_BOOK_FAILED

**HTTP `500`** — Server error

Failed to book salary run.

**Swedish:** Lönekörningen kunde inte bokföras.

### SALARY_RUN_BOOK_NOT_PAID

**HTTP `400`** — Bad request

Salary run must be marked paid before booking.

**Swedish:** Lönekörningen måste vara markerad som betald för bokföring.

### SALARY_RUN_CALCULATE_FAILED

**HTTP `500`** — Server error

Failed to calculate salary run.

**Swedish:** Lönekörningen kunde inte beräknas.

### SALARY_RUN_CALCULATE_NOT_DRAFT

**HTTP `400`** — Bad request

Salary run must be in draft status to calculate.

**Swedish:** Lönekörningen måste vara i status draft för beräkning.

### SALARY_RUN_CREATE_FAILED

**HTTP `500`** — Server error

Failed to create salary run.

**Swedish:** Lönekörningen kunde inte skapas.

### SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY

**HTTP `400`** — Bad request

Salary run is linked to a journal entry and cannot be deleted (BFL 5 kap räkenskapsinformation).

**Swedish:** Lönekörningen är kopplad till en verifikation och kan inte raderas (BFL 5 kap räkenskapsinformation).

### SALARY_RUN_DELETE_NOT_DRAFT

**HTTP `400`** — Bad request

Only draft salary runs can be deleted.

**Swedish:** Endast utkast (draft) kan raderas.

### SALARY_RUN_DUPLICATE_PERIOD

**HTTP `409`** — Conflict

A salary run for that period already exists.

**Swedish:** En lönekörning för perioden finns redan.

### SALARY_RUN_MARK_PAID_NOT_APPROVED

**HTTP `400`** — Bad request

Salary run must be approved before it can be marked paid.

**Swedish:** Lönekörningen måste vara godkänd för att markeras som betald.

### SALARY_RUN_NOT_CALCULATED

**HTTP `400`** — Bad request

Salary run must be calculated before booking.

**Swedish:** Lönekörningen måste beräknas innan bokföring.

### SALARY_RUN_NOT_FOUND

**HTTP `404`** — Not found

Salary run not found.

**Swedish:** Lönekörningen kunde inte hittas.

### SALARY_RUN_NO_EMPLOYEES

**HTTP `400`** — Bad request

No active employees in the company.

**Swedish:** Inga aktiva anställda finns i företaget.

### SALARY_RUN_PATCH_NOT_DRAFT

**HTTP `400`** — Bad request

Only draft salary runs can be patched.

**Swedish:** Endast utkast (draft) kan uppdateras.

### SALARY_RUN_PERIOD_LOCKED

**HTTP `400`** — Bad request

Salary run cannot be processed in a locked period.

**Swedish:** Lönekörningen kan inte göras i en låst period.

### SALARY_RUN_TAX_TABLE_MISSING

**HTTP `400`** — Bad request

Tax table is missing for the period.

**Swedish:** Skattetabellen saknas för perioden. Importera skattetabellen först.

## Company + API keys

*Multi-tenant + auth lifecycle.*

### API_KEY_CREATE_FAILED

**HTTP `500`** — Server error

Failed to create API key.

**Swedish:** API-nyckeln kunde inte skapas.

### API_KEY_NOT_FOUND

**HTTP `404`** — Not found

API key not found.

**Swedish:** API-nyckeln kunde inte hittas.

### API_KEY_QUOTA_EXCEEDED

**HTTP `429`** — Rate limited

API key quota exceeded.

**Swedish:** Du har nått maxgränsen för antal API-nycklar.

### API_KEY_REVOKE_FAILED

**HTTP `500`** — Server error

Failed to revoke API key.

**Swedish:** API-nyckeln kunde inte återkallas.

### API_KEY_SCOPE_INVALID

**HTTP `400`** — Bad request

One or more requested scopes are invalid.

**Swedish:** En eller flera scopes är ogiltiga.

### COMPANY_CREATE_BAS_SEED_FAILED

**HTTP `500`** — Server error

Failed to seed the chart of accounts.

**Swedish:** Kontoplanen kunde inte skapas. Försök igen.

### COMPANY_CREATE_DUPLICATE_ORG_NUMBER

**HTTP `409`** — Conflict

A company with that organisation number already exists.

**Swedish:** Ett företag med samma organisationsnummer finns redan.

### COMPANY_CREATE_FAILED

**HTTP `500`** — Server error

Failed to create company.

**Swedish:** Företaget kunde inte skapas.

### COMPANY_NOT_FOUND

**HTTP `404`** — Not found

Company not found.

**Swedish:** Företaget kunde inte hittas.

## Provider connections

*External provider OAuth, sync, consent.*

### PROVIDER_ACCEPT_FAILED

**HTTP `500`** — Server error

Failed to accept consent.

**Swedish:** Kunde inte slutföra anslutningen.

### PROVIDER_AUTH_EXPIRED

**HTTP `401`** — Unauthorized

Provider authentication expired or refresh failed.

**Swedish:** Anslutningen till leverantören har gått ut. Återanslut för att fortsätta.

### PROVIDER_COMPANY_ID_REQUIRED

**HTTP `400`** — Bad request

companyId is required for this provider.

**Swedish:** companyId krävs för den här leverantören.

### PROVIDER_CONNECT_FAILED

**HTTP `500`** — Server error

Failed to start provider connection flow.

**Swedish:** Kunde inte starta anslutningen till leverantören.

### PROVIDER_CONSENT_NOT_FOUND

**HTTP `404`** — Not found

Provider consent not found.

**Swedish:** Anslutningen kunde inte hittas.

### PROVIDER_CONSENT_NOT_READY

**HTTP `400`** — Bad request

Provider consent is not ready; finish authentication first.

**Swedish:** Anslutningen är inte klar. Slutför inloggningen först.

### PROVIDER_DISCONNECT_FAILED

**HTTP `500`** — Server error

Provider disconnect failed.

**Swedish:** Frånkoppling från leverantören misslyckades.

### PROVIDER_INVALID

**HTTP `400`** — Bad request

Unknown provider.

**Swedish:** Okänd leverantör.

### PROVIDER_MIGRATE_FAILED

**HTTP `500`** — Server error

Provider migration failed.

**Swedish:** Migrationen från leverantören misslyckades.

### PROVIDER_PREVIEW_FAILED

**HTTP `500`** — Server error

Provider preview failed.

**Swedish:** Förhandsgranskningen från leverantören misslyckades.

### PROVIDER_RATE_LIMITED

**HTTP `429`** — Rate limited

Provider rate limit exceeded.

**Swedish:** Leverantören begränsar antalet anrop just nu. Vänta en stund och försök igen.

### PROVIDER_SIE_FETCH_FAILED

**HTTP `502`**

Failed to fetch SIE data from the provider.

**Swedish:** Kunde inte hämta SIE-data från leverantören.

### PROVIDER_SIE_NO_YEARS

**HTTP `404`** — Not found

No fiscal years available for 2024–2026.

**Swedish:** Inga räkenskapsår 2024–2026 hittades hos leverantören.

### PROVIDER_SIE_ONLY_FORTNOX

**HTTP `400`** — Bad request

SIE export is currently only supported for Fortnox.

**Swedish:** SIE-export stöds för närvarande endast för Fortnox.

### PROVIDER_STATUS_FAILED

**HTTP `500`** — Server error

Failed to fetch provider status.

**Swedish:** Kunde inte hämta status från leverantören.

### PROVIDER_TOKEN_REQUIRED

**HTTP `400`** — Bad request

apiToken is required for this provider.

**Swedish:** API-token krävs för den här leverantören.

### PROVIDER_TOKEN_SUBMIT_FAILED

**HTTP `500`** — Server error

Failed to submit provider token.

**Swedish:** Tokensubmissionen misslyckades.

### PROVIDER_UNREACHABLE

**HTTP `502`**

Provider service is unreachable (network/DNS error).

**Swedish:** Leverantörens tjänst är inte tillgänglig just nu. Försök igen om en stund.

### PROVIDER_UPSTREAM_ERROR

**HTTP `502`**

Provider returned an upstream 5xx error.

**Swedish:** Leverantören svarade med ett fel. Försök igen om en stund.

## Other

### ACCOUNTS_NOT_IN_CHART

**HTTP `400`** — Bad request

One or more BAS accounts are not active in the chart of accounts.

**Swedish:** Konton saknas i kontoplanen.

**Remediation:** Activate the missing accounts via bookkeeping settings, or use a different category.
Related resource: `gnubok://chart-of-accounts`

### BANK_IMPORT_DUPLICATE_OTHER_COMPANY

**HTTP `409`** — Conflict

This file has already been imported into another company by this user.

**Swedish:** Den här filen har redan importerats för ett annat företag av samma användare.

### BANK_IMPORT_FAILED

**HTTP `500`** — Server error

Bank file import failed.

**Swedish:** Bankfilsimporten misslyckades.

### CANNOT_CORRECT_NON_POSTED

**HTTP `400`** — Bad request

Only posted entries can be corrected.

**Swedish:** Endast bokförda verifikationer kan rättas.

### CANNOT_REVERSE_NON_POSTED

**HTTP `400`** — Bad request

Only posted entries can be reversed.

**Swedish:** Endast bokförda verifikationer kan stornas.

### CURRENCY_REVALUATION_ALREADY_EXISTS

**HTTP `409`** — Conflict

Currency revaluation already exists for this period.

**Swedish:** En valutaomvärdering finns redan för denna period.

### DOC_DOWNLOAD_FAILED

**HTTP `500`** — Server error

Failed to create signed download URL.

**Swedish:** Det gick inte att skapa nedladdningslänken.

### DOC_LINK_ALREADY_LINKED

**HTTP `409`** — Conflict

Document is already linked to a journal entry.

**Swedish:** Dokumentet är redan kopplat till en verifikation.

### DOC_LINK_ENTRY_NOT_FOUND

**HTTP `404`** — Not found

Journal entry not found.

**Swedish:** Verifikationen kunde inte hittas.

### DOC_LINK_FAILED

**HTTP `500`** — Server error

Failed to link document to journal entry.

**Swedish:** Kopplingen misslyckades.

### DOC_NOT_FOUND

**HTTP `404`** — Not found

Document not found.

**Swedish:** Dokumentet kunde inte hittas.

### DOC_UPLOAD_NO_FILE

**HTTP `400`** — Bad request

No file attached.

**Swedish:** Ingen fil bifogad.

### DOC_UPLOAD_STORAGE_FAILED

**HTTP `500`** — Server error

Document storage failed.

**Swedish:** Filen kunde inte sparas.

### DOC_UPLOAD_TOO_LARGE

**HTTP `400`** — Bad request

Uploaded file exceeds the size limit.

**Swedish:** Filen är för stor.

### DOC_UPLOAD_UNSUPPORTED_TYPE

**HTTP `400`** — Bad request

Unsupported file type.

**Swedish:** Filtypen stöds inte.

### ENTRY_ALREADY_REVERSED

**HTTP `409`** — Conflict

Entry was already reversed by a concurrent operation.

**Swedish:** Verifikationen har redan stornats av en annan användare. Ladda om sidan och försök igen.

### ENTRY_DATE_OUTSIDE_FISCAL_PERIOD

**HTTP `400`** — Bad request

Entry date is outside the active fiscal period.

**Swedish:** Datumet ligger utanför det valda räkenskapsåret.

**Remediation:** Use a date inside an open period or create one that covers it.
Related resource: `gnubok://period/active`

### FISCAL_PERIOD_NOT_FOUND

**HTTP `404`** — Not found

No fiscal period covers the entry date.

**Swedish:** Räkenskapsperioden kunde inte hittas.

**Remediation:** Create or extend the relevant fiscal period before retrying.
Related resource: `gnubok://period/active`

### INVALID_MAPPING_RESULT

**HTTP `400`** — Bad request

Mapping rules produced an invalid debit/credit account pair.

**Swedish:** Kontering saknas för transaktionen. Kontrollera bokföringsreglerna.

### OB_ACCOUNT_ACTIVATION_FAILED

**HTTP `500`** — Server error

Failed to activate accounts in the chart of accounts.

**Swedish:** Kunde inte aktivera konton i kontoplanen.

### OB_EXECUTE_FAILED

**HTTP `500`** — Server error

Opening balance import failed.

**Swedish:** Importen misslyckades.

### OB_FILE_TOO_LARGE

**HTTP `400`** — Bad request

File exceeds the 10 MB size limit.

**Swedish:** Filen är för stor. Maxstorlek är 10 MB.

### OB_INVALID_COLUMN_OVERRIDES

**HTTP `400`** — Bad request

Invalid column overrides JSON.

**Swedish:** Ogiltig kolumnmappning.

### OB_INVALID_FORMAT

**HTTP `400`** — Bad request

Unsupported file format.

**Swedish:** Filformatet stöds inte. Tillåtna format: .xlsx, .xls, .csv, .ods.

### OB_NO_FILE

**HTTP `400`** — Bad request

No file attached.

**Swedish:** Ingen fil bifogad.

### OB_PARSE_FAILED

**HTTP `500`** — Server error

Failed to parse the opening balance file.

**Swedish:** Kunde inte tolka filen.

### OB_PERIOD_ALREADY_HAS_BALANCES

**HTTP `409`** — Conflict

Fiscal period already has opening balances set.

**Swedish:** Räkenskapsperioden har redan ingående balanser.

### OB_PERIOD_CLOSED

**HTTP `400`** — Bad request

Fiscal period is closed.

**Swedish:** Räkenskapsperioden är stängd.

### OB_PERIOD_LOCKED

**HTTP `400`** — Bad request

Fiscal period is locked.

**Swedish:** Räkenskapsperioden är låst.

### OB_PERIOD_NOT_FOUND

**HTTP `404`** — Not found

Fiscal period not found.

**Swedish:** Räkenskapsperioden hittades inte.

### OB_PNL_ACCOUNT

**HTTP `400`** — Bad request

Profit & loss accounts (class 3-8) are not allowed in opening balances.

**Swedish:** Resultatkonton (klass 3-8) kan inte användas i ingående balanser.

### OB_TOO_FEW_LINES

**HTTP `400`** — Bad request

At least two lines with amounts are required.

**Swedish:** Minst två rader med belopp krävs.

### OB_UNBALANCED

**HTTP `400`** — Bad request

Opening balance debits and credits do not match.

**Swedish:** Debet och kredit balanserar inte.

### OPENING_BAL_PERIOD_NOT_FOUND

**HTTP `404`** — Not found

Fiscal period not found.

**Swedish:** Räkenskapsperioden kunde inte hittas.

### REG_IMPORT_EXECUTE_FAILED

**HTTP `500`** — Server error

Register import failed.

**Swedish:** Importen misslyckades.

### REG_IMPORT_FILE_TOO_LARGE

**HTTP `400`** — Bad request

File exceeds the 10 MB size limit.

**Swedish:** Filen är för stor. Maxstorlek är 10 MB.

### REG_IMPORT_INVALID_COLUMN_OVERRIDES

**HTTP `400`** — Bad request

Invalid column overrides JSON.

**Swedish:** Ogiltig kolumnmappning.

### REG_IMPORT_INVALID_FORMAT

**HTTP `400`** — Bad request

Unsupported file format.

**Swedish:** Filformatet stöds inte. Tillåtna format: .xlsx, .xls, .csv, .ods.

### REG_IMPORT_NO_FILE

**HTTP `400`** — Bad request

No file attached.

**Swedish:** Ingen fil bifogad.

### REG_IMPORT_NO_ROWS

**HTTP `400`** — Bad request

No valid rows found in the file.

**Swedish:** Inga giltiga rader hittades i filen.

### REG_IMPORT_PARSE_FAILED

**HTTP `500`** — Server error

Failed to parse the register file.

**Swedish:** Kunde inte tolka filen.

### SIE_DUPLICATE_FILE

**HTTP `409`** — Conflict

File has already been imported.

**Swedish:** Den här filen har redan importerats.

### SIE_DUPLICATE_PERIOD

**HTTP `409`** — Conflict

An SIE import for an overlapping fiscal period already exists.

**Swedish:** En SIE-import för ett överlappande räkenskapsår finns redan.

### SIE_PARSE_EMPTY

**HTTP `400`** — Bad request

File is empty.

**Swedish:** Filen är tom (0 bytes). Kontrollera exporten från bokföringsprogrammet.

### SIE_PARSE_FAILED

**HTTP `500`** — Server error

Failed to parse the SIE file.

**Swedish:** Kunde inte tolka SIE-filen. Filen kan vara skadad eller i ett format som inte stöds.

### SIE_PARSE_FILE_TOO_LARGE

**HTTP `400`** — Bad request

File exceeds the 50 MB size limit.

**Swedish:** Filen är för stor. Maxstorlek är 50 MB.

### SIE_PARSE_INVALID_TYPE

**HTTP `400`** — Bad request

Unsupported file type; upload a .sie or .se file.

**Swedish:** Filtypen stöds inte. Ladda upp en fil med ändelsen .sie eller .se.

### SIE_PARSE_NO_FILE

**HTTP `400`** — Bad request

No file attached to the request.

**Swedish:** Ingen fil bifogad i förfrågan.

### SIE_PARSE_VALIDATION_FAILED

**HTTP `400`** — Bad request

SIE file failed validation.

**Swedish:** SIE-filen innehåller valideringsfel som måste åtgärdas innan import.

### SIE_REPLACE_FAILED

**HTTP `400`** — Bad request

Failed to replace SIE import.

**Swedish:** SIE-importen kunde inte ersättas.

### SI_APPROVE_NOT_REGISTERED

**HTTP `400`** — Bad request

Only invoices in registered status can be approved.

**Swedish:** Endast registrerade fakturor kan godkännas.

### SI_APPROVE_UPDATE_FAILED

**HTTP `500`** — Server error

Failed to update supplier invoice status to approved.

**Swedish:** Kunde inte godkänna leverantörsfakturan.

### SI_CREATE_DUPLICATE_INVOICE_NUMBER

**HTTP `409`** — Conflict

A supplier invoice with that number already exists.

**Swedish:** En leverantörsfaktura med samma nummer finns redan.

### SI_CREATE_FAILED

**HTTP `500`** — Server error

Failed to create supplier invoice.

**Swedish:** Leverantörsfakturan kunde inte skapas.

### SI_CREATE_INVALID_INPUT

**HTTP `400`** — Bad request

Invalid combination of supplier invoice fields.

**Swedish:** Ogiltig kombination av fakturafält. Kontrollera formuläret och försök igen.

### SI_CREDIT_ALREADY_CREDITED

**HTTP `409`** — Conflict

Supplier invoice has already been credited.

**Swedish:** Leverantörsfakturan har redan krediterats.

### SI_CREDIT_FAILED

**HTTP `500`** — Server error

Failed to credit supplier invoice.

**Swedish:** Kunde inte kreditera leverantörsfakturan.

### SI_CREDIT_PERIOD_LOCKED

**HTTP `400`** — Bad request

Bookkeeping is locked; credit note cannot be created.

**Swedish:** Bokföringen är låst. Krediteringen kan inte skapas.

### SI_NOT_DRAFT

**HTTP `400`** — Bad request

Supplier invoice is not in `registered` status and cannot be updated or deleted.

**Swedish:** Leverantörsfakturan är inte längre i status "registrerad" och kan därför inte uppdateras eller tas bort.

### SI_NOT_FOUND

**HTTP `404`** — Not found

Supplier invoice not found.

**Swedish:** Leverantörsfakturan kunde inte hittas.

### SI_PAID_ALREADY

**HTTP `409`** — Conflict

Supplier invoice is already paid or credited.

**Swedish:** Leverantörsfakturan är redan betald eller krediterad.

### SI_PAID_FAILED

**HTTP `500`** — Server error

Failed to record supplier invoice payment.

**Swedish:** Kunde inte registrera betalningen.

### SI_PAID_LIKELY_DUPLICATE

**HTTP `409`** — Conflict

A likely-matching unlinked bank transaction was found for this supplier. Suggest linking it instead of creating a new payment entry.

**Swedish:** Det finns redan en obokförd banktransaktion som kan vara denna betalning. Länka den istället, eller markera som betald ändå om du är säker.

**Remediation:** Match the candidate transaction via POST /api/transactions/{id}/match-supplier-invoice, or resend mark-paid with force: true to create the payment entry anyway.

### SI_PAID_NOT_PAYABLE

**HTTP `400`** — Bad request

Supplier invoice is not in a payable state.

**Swedish:** Leverantörsfakturan kan inte markeras som betald i nuvarande status.

### SI_PAID_PERIOD_LOCKED

**HTTP `400`** — Bad request

Bookkeeping is locked; payment cannot be recorded.

**Swedish:** Bokföringen är låst. Betalningen kan inte registreras.

### TX_BATCH_CATEGORIZE_EMPTY

**HTTP `400`** — Bad request

Batch is empty — pass at least one item.

**Swedish:** Batchen är tom.

### TX_CATEGORIZE_INVALID_ACCOUNT

**HTTP `400`** — Bad request

The supplied account does not exist in the chart of accounts.

**Swedish:** Det valda kontot finns inte i kontoplanen.

**Remediation:** Activate the account in the chart of accounts or pick a different one.
Related resource: `gnubok://chart-of-accounts`

### TX_CATEGORIZE_INVALID_MAPPING

**HTTP `400`** — Bad request

Mapping result is missing a debit or credit account.

**Swedish:** Konteringen saknar debet- eller kreditkonto.

### TX_CATEGORIZE_INVALID_TEMPLATE

**HTTP `400`** — Bad request

The supplied booking template is invalid or does not match the entity type.

**Swedish:** Bokföringsmallen är ogiltig eller passar inte din bolagsform.

### TX_CATEGORIZE_RACE

**HTTP `409`** — Conflict

Transaction was already categorized by another request.

**Swedish:** Transaktionen kategoriserades av en annan förfrågan. Ladda om och försök igen.

### TX_CATEGORIZE_SUGGEST_CI_MATCH

**HTTP `409`** — Conflict

An unpaid customer invoice from the same customer matches this amount. Suggest matching to the invoice instead of a plain 151x categorization to avoid producing a duplicate verifikation (BFL 5 kap 5 §).

**Swedish:** Det finns en obetald kundfaktura från samma kund med samma belopp. Matcha mot fakturan istället för att bokföra direkt mot kundfordringskontot — annars skapas en dubblerad verifikation som måste stornas (BFL 5 kap 5 §).

**Remediation:** Match the transaction via POST /api/transactions/{id}/match-invoice, or resend with confirm_no_match: true to keep the plain 151x categorization.

### TX_CATEGORIZE_SUGGEST_SI_MATCH

**HTTP `409`** — Conflict

An open supplier invoice from the same supplier matches this amount. Suggest matching to the invoice instead of a plain 244x categorization to avoid producing a duplicate verifikation (BFL 5 kap 5 §).

**Swedish:** Det finns en öppen leverantörsfaktura från samma leverantör med samma belopp. Matcha mot fakturan istället för att bokföra direkt på leverantörsskuldskontot — annars skapas en dubblerad verifikation som måste stornas (BFL 5 kap 5 §).

**Remediation:** Match the transaction via POST /api/transactions/{id}/match-supplier-invoice, or resend with confirm_no_match: true to keep the plain 244x categorization.

### TX_CATEGORIZE_TX_NOT_FOUND

**HTTP `404`** — Not found

Transaction not found.

**Swedish:** Transaktionen kunde inte hittas.

### TX_EXCHANGE_RATE_UNAVAILABLE

**HTTP `502`**

Could not fetch the exchange rate from Riksbanken. The verifikation must be posted in SEK.

**Swedish:** Kunde inte hämta växelkursen från Riksbanken. Försök igen om en stund — verifikationen måste bokföras i SEK.

### TX_INGEST_INSERT_FAILED

**HTTP `500`** — Server error

Transaction ingest failed.

**Swedish:** Transaktionerna kunde inte importeras.

### TX_UNCATEGORIZE_JE_NOT_POSTED

**HTTP `400`** — Bad request

Journal entry is not in posted status; reversal is not possible.

**Swedish:** Verifikationen är inte bokförd. Reversal kan inte utföras.

### TX_UNCATEGORIZE_NOT_BOOKED

**HTTP `400`** — Bad request

Transaction has no journal entry — nothing to uncategorize.

**Swedish:** Transaktionen är inte bokförd. Det finns inget att av-kategorisera.

### TX_UNCATEGORIZE_NO_LINKED_ENTRY

**HTTP `400`** — Bad request

Transaction has no linked journal entry to reverse.

**Swedish:** Transaktionen har ingen kopplad verifikation att stornera.

---

# API reference

> Every endpoint exposed by the gnubok REST API, grouped by resource. Auto-generated from the same Zod registry that powers the [OpenAPI 3.1 spec](/api/v1/openapi.json), the MCP tool surface, and runtime validators — there is no separate doc-source to keep in sync.

## Resources

### [Companies](/docs/api/reference/companies)

List and read companies the API key can access.

### [Customers](/docs/api/reference/customers)

CRM-side: who you invoice. Business and individual (sole-trader) customers with VIES validation.

### [Invoices](/docs/api/reference/invoices)

Outbound invoicing — draft, send, mark paid, credit, PDF download. Mixed-rate VAT supported.

### [Suppliers](/docs/api/reference/suppliers)

AP-side counterparties. Mirrors customers on the supplier vertical.

### [Supplier invoices](/docs/api/reference/supplier-invoices)

AP lifecycle: register, approve, mark paid, credit. With ROT/RUT and reverse-charge support.

### [Transactions](/docs/api/reference/transactions)

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

### [Reconciliation](/docs/api/reference/reconciliation)

Run bank-to-ledger reconciliation and read the current matching status.

### [Journal entries](/docs/api/reference/journal-entries)

The bookkeeping engine surface — verifikation lifecycle (draft, commit, reverse, correct).

### [Voucher gap explanations](/docs/api/reference/voucher-gap-explanations)

Documented explanations for gaps in the voucher series, per BFNAR 2013:2.

### [Fiscal periods](/docs/api/reference/fiscal-periods)

Period lifecycle — lock, close, year-end, opening balances, FX revaluation. Async via the operations substrate.

### [Accounts](/docs/api/reference/accounts)

Read the chart of accounts (BAS).

### [Documents](/docs/api/reference/documents)

Multipart upload, signed-URL download (15-min TTL), link to journal entries.

### [Employees](/docs/api/reference/employees)

Payroll roster — CRUD with personnummer masking on list endpoints.

### [Salary runs](/docs/api/reference/salary-runs)

Payroll lifecycle — create, calculate, approve, mark paid, book, generate AGI XML.

### [Reports](/docs/api/reference/reports)

Read-only reports — trial balance, P&L, balance sheet, GL, VAT, salary journal, SIE export, +9 more.

### [Imports](/docs/api/reference/imports)

Bulk async ingest — SIE files (Fortnox/Visma/BL/SpeedLedger/Bokio migrations) and bank statements (11 formats).

### [Compliance check](/docs/api/reference/compliance)

Pre-flight verification — voucher gaps, year-end readiness, before submitting to Skatteverket.

### [Webhooks](/docs/api/reference/webhooks)

Subscribe to events with HMAC-signed delivery, exponential retries, and dead-letter replay.

### [Operations](/docs/api/reference/operations)

Poll long-running async operations (year-end closing, imports, currency revaluation).

---

# Companies

> List and read companies the API key can access.

## Endpoints

- [`GET` `/api/v1/companies`](#get-companies-list) — List companies the API key can access.

---

### `GET` /api/v1/companies {#get-companies-list}

**`companies.list`** · scope `companies:read`

List companies the API key can access.

Returns every non-archived company the API key user is a member of, together with their role. Use the returned `id` as `{companyId}` in subsequent endpoints.

**Use when:** You need to discover which company IDs an API key has access to before calling company-scoped endpoints.

**Don't use for:** Fetching a single company you already know the id of — use GET /api/v1/companies/{companyId} for that.

**Pitfalls**
- Multi-company keys (e.g. consultants) will see >1 result. Always pass the correct companyId in subsequent paths.
- Archived companies are excluded; if a company disappears the user has been removed from it or it was archived.

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

**Example response**

```json
{
  "data": [
    {
      "id": "8fd5b1f4-…",
      "name": "Acme AB",
      "org_number": "556677-8899",
      "entity_type": "aktiebolag",
      "role": "owner",
      "created_at": "2025-01-04T08:00:00Z"
    }
  ],
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12",
    "next_cursor": null
  }
}
```

---

---

# Customers

> CRM-side: who you invoice. Business and individual (sole-trader) customers with VIES validation.

## Endpoints

- [`GET` `/api/v1/companies/:companyId/customers`](#get-customers-list) — List customers for a company.
- [`GET` `/api/v1/companies/:companyId/customers/:id`](#get-customers-get) — Retrieve a single customer by id.
- [`POST` `/api/v1/companies/:companyId/customers`](#post-customers-create) — Create a customer.
- [`POST` `/api/v1/companies/:companyId/customers/bulk-create`](#post-customers-bulk-create) — Create up to 50 customers in one call (partial-success).
- [`PATCH` `/api/v1/companies/:companyId/customers/:id`](#patch-customers-update) — Partially update a customer.
- [`DELETE` `/api/v1/companies/:companyId/customers/:id`](#delete-customers-delete) — Archive a customer (soft-delete).

---

### `GET` /api/v1/companies/:companyId/customers {#get-customers-list}

**`customers.list`** · scope `customers:read`

List customers for a company.

Returns active customers 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 customer roster — for building a UI picker, syncing a CRM, or resolving a customer_id before creating an invoice.

**Don't use for:** Fetching a single customer you already know the id of — use GET /api/v1/companies/{companyId}/customers/{id}. Suppliers are a separate resource.

**Pitfalls**
- Archived customers are hidden by default; the dashboard makes the same choice.
- org_number is included so callers can match against external CRM identifiers; for sole traders (enskild firma) it equals the personnummer.

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

**Example response**

```json
{
  "data": [
    {
      "id": "a8f1…",
      "name": "Acme AB",
      "customer_type": "business",
      "email": "finance@acme.example",
      "org_number": "556677-8899",
      "vat_number": "SE556677889901",
      "default_payment_terms": 30,
      "archived_at": null,
      "created_at": "2025-04-12T08:30:00Z"
    }
  ],
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12",
    "next_cursor": null
  }
}
```

---

### `GET` /api/v1/companies/:companyId/customers/:id {#get-customers-get}

**`customers.get`** · scope `customers:read`

Retrieve a single customer by id.

Returns the full customer record. Pass ?expand=invoices to embed any open invoices (sent / partially_paid / overdue) for the customer in the same response.

**Use when:** You need the full customer record — address, payment terms, VAT validation status, contact details — before invoicing or syncing to another system.

**Don't use for:** Listing customers (use the list endpoint). Looking up arbitrary supplier or employee records (different resources).

**Pitfalls**
- archived_at is non-null when the customer has been soft-deleted; the customer is still queryable by id but excluded from default lists.
- vat_number_validated reflects the last successful VIES check; it can become stale if the EU registry revokes a number.

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

**Example response**

```json
{
  "data": {
    "id": "a8f1…",
    "name": "Acme AB",
    "customer_type": "business",
    "email": "finance@acme.example",
    "org_number": "556677-8899",
    "vat_number": "SE556677889901",
    "vat_number_validated": true,
    "country": "Sweden",
    "default_payment_terms": 30,
    "archived_at": null,
    "created_at": "2025-04-12T08:30:00Z",
    "updated_at": "2026-04-30T11:22:09Z"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/customers {#post-customers-create}

**`customers.create`** · scope `customers:write`

Create a customer.

Creates a new customer 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. EU-business customers with a VAT number are auto-validated against VIES on commit.

**Use when:** You need to register a new customer before invoicing them. Use dry-run first to catch validation errors before committing.

**Don't use for:** Updating an existing customer (PATCH instead). Creating suppliers (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 CUSTOMER_DUPLICATE_ORG_NUMBER.
- For Swedish sole traders (customer_type=individual), org_number IS the personnummer. List responses mask it; the create endpoint accepts it as input.
- VIES validation runs only on commit. Dry-run skips the external call and leaves vat_number_validated=false in the preview.

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

**Example request**

```json
{
  "name": "Acme AB",
  "customer_type": "swedish_business",
  "email": "finance@acme.test",
  "org_number": "556677-8899",
  "default_payment_terms": 30
}
```

**Example response**

```json
{
  "data": {
    "id": "0e9c…",
    "name": "Acme AB",
    "customer_type": "swedish_business",
    "email": "finance@acme.test",
    "org_number": "556677-8899",
    "vat_number_validated": false,
    "default_payment_terms": 30,
    "archived_at": null,
    "created_at": "2026-05-12T16:00:00Z",
    "updated_at": "2026-05-12T16:00:00Z"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/customers/bulk-create {#post-customers-bulk-create}

**`customers.bulk-create`** · scope `customers:write`

Create up to 50 customers in one call (partial-success).

Bulk-create endpoint mirroring /invoices/bulk-create. Each customer 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 customers from another CRM, or seeding a fresh company with its existing client list. Use dry-run first to validate the batch.

**Don't use for:** Updating existing customers — PATCH /customers/{id} once per customer. Bulk uploads of > 50 customers — 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 CUSTOMER_DUPLICATE_ORG_NUMBER.
- VIES validation for eu_business customers is best-effort per item; a VIES timeout leaves vat_number_validated=false but does NOT fail the item.

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

**Example request**

```json
{
  "customers": [
    {
      "name": "Acme AB",
      "customer_type": "swedish_business",
      "org_number": "556677-8899"
    },
    {
      "name": "Foo OY",
      "customer_type": "eu_business",
      "vat_number": "FI12345678"
    }
  ]
}
```

**Example response**

```json
{
  "data": {
    "results": [
      {
        "ok": true,
        "request_index": 0,
        "data": {
          "id": "0e9c…",
          "name": "Acme AB"
        }
      },
      {
        "ok": true,
        "request_index": 1,
        "data": {
          "id": "4d2a…",
          "name": "Foo OY"
        }
      }
    ],
    "summary": {
      "total": 2,
      "succeeded": 2,
      "failed": 0
    }
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `PATCH` /api/v1/companies/:companyId/customers/:id {#patch-customers-update}

**`customers.update`** · scope `customers:write`

Partially update a customer.

Patches the customer with the supplied fields. All fields optional. Idempotent (mandatory Idempotency-Key). Dry-runnable. When vat_number changes on an eu_business customer, VIES re-validation runs on commit (best-effort).

**Use when:** You need to change a customer's contact details, payment terms, address, or VAT registration. Use dry-run first to confirm the merged record before committing.

**Don't use for:** Archiving a customer (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 CUSTOMER_DUPLICATE_ORG_NUMBER.
- VIES re-validation is best-effort and runs only on commit. A VIES timeout does not fail the update.

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

**Example request**

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

**Example response**

```json
{
  "data": {
    "id": "0e9c…",
    "name": "Acme 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/customers/:id {#delete-customers-delete}

**`customers.delete`** · scope `customers:write`

Archive a customer (soft-delete).

Sets archived_at on the customer; the record is preserved (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 customer is a no-op. Dry-runnable.

**Use when:** You want to remove a customer from active rosters without losing their history. Idempotent: re-archiving is safe.

**Don't use for:** Permanently deleting a customer 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 customer with any open invoice (sent / partially_paid / overdue) cannot be archived — returns 409 CUSTOMER_HAS_INVOICES. Issue a kreditfaktura first if you need to close the relationship cleanly. This protects ML 17 kap 24§: the customer record is the canonical source of buyer name/address for invoice reissuance.
- 204 No Content is returned on success — there is no response body to parse.

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

**Example response**

```json
{
  "data": null,
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

---

# Invoices

> Outbound invoicing — draft, send, mark paid, credit, PDF download. Mixed-rate VAT supported.

## Endpoints

- [`GET` `/api/v1/companies/:companyId/invoices`](#get-invoices-list) — List invoices for a company.
- [`GET` `/api/v1/companies/:companyId/invoices/:id`](#get-invoices-get) — Retrieve a single invoice by id.
- [`GET` `/api/v1/companies/:companyId/invoices/:id/pdf`](#get-invoices-pdf) — Download the rendered invoice PDF.
- [`POST` `/api/v1/companies/:companyId/invoices`](#post-invoices-create) — Create a draft invoice, proforma, or delivery note.
- [`POST` `/api/v1/companies/:companyId/invoices/:id/credit`](#post-invoices-credit) — Issue a credit note (kreditfaktura) against an invoice.
- [`POST` `/api/v1/companies/:companyId/invoices/:id/mark-paid`](#post-invoices-mark-paid) — Record a payment against an invoice.
- [`POST` `/api/v1/companies/:companyId/invoices/:id/mark-sent`](#post-invoices-mark-sent) — Transition a draft invoice to sent (without emailing).
- [`POST` `/api/v1/companies/:companyId/invoices/:id/send`](#post-invoices-send) — Send a draft invoice to the customer by email.
- [`POST` `/api/v1/companies/:companyId/invoices/bulk-create`](#post-invoices-bulk-create) — Create up to 50 draft invoices in one call (partial-success).
- [`PATCH` `/api/v1/companies/:companyId/invoices/:id`](#patch-invoices-update) — Update a draft invoice (metadata fields only).

---

### `GET` /api/v1/companies/:companyId/invoices {#get-invoices-list}

**`invoices.list`** · scope `invoices:read`

List invoices for a company.

Returns invoices in most-recent-first order. Includes the customer name inline; pass ?expand=customer for the full customer record, ?expand=items for line items.

**Use when:** You need to enumerate invoices for a company — for AR reporting, payment matching, or building an invoice dashboard.

**Don't use for:** Fetching a single invoice you already know the id of — use GET /api/v1/companies/{companyId}/invoices/{id}. Supplier invoices are a different resource (supplier-invoices).

**Pitfalls**
- Draft invoices have invoice_number=null until they are sent.
- remaining_amount is the unpaid portion (total − paid_amount); use status=paid or remaining_amount=0 to filter for closed invoices.
- Credit notes appear with status=credited and a credited_invoice_id field on the detail endpoint.

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

**Example response**

```json
{
  "data": [
    {
      "id": "0e9c…",
      "invoice_number": "2026-0042",
      "customer_id": "a8f1…",
      "customer_name": "Acme AB",
      "invoice_date": "2026-05-01",
      "due_date": "2026-05-31",
      "status": "sent",
      "document_type": "invoice",
      "currency": "SEK",
      "subtotal": 10000,
      "vat_amount": 2500,
      "total": 12500,
      "remaining_amount": 12500,
      "paid_at": null,
      "created_at": "2026-05-01T09:14:33Z"
    }
  ],
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12",
    "next_cursor": null
  }
}
```

---

### `GET` /api/v1/companies/:companyId/invoices/:id {#get-invoices-get}

**`invoices.get`** · scope `invoices:read`

Retrieve a single invoice by id.

Returns the full invoice record with the customer embedded. Pass ?expand=items for line items, ?expand=payments for payment history, or ?expand=items,payments for both.

**Use when:** You have an invoice id (from a webhook, the list endpoint, or a customer transaction) and need the full record including amounts, dates, status, and the customer details.

**Don't use for:** Listing invoices (use GET /api/v1/companies/{companyId}/invoices). Bookkeeping verifikationer tied to the invoice (use the journal-entries endpoints in a later phase).

**Pitfalls**
- Returns 404 if the invoice does not belong to the company in the URL — does not leak existence across companies.
- paid_at and remaining_amount can lag behind the latest payment by a few seconds during high-volume reconciliation.

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

**Example response**

```json
{
  "data": {
    "id": "0e9c…",
    "invoice_number": "2026-0042",
    "customer_id": "a8f1…",
    "customer": {
      "id": "a8f1…",
      "name": "Acme AB"
    },
    "invoice_date": "2026-05-01",
    "due_date": "2026-05-31",
    "status": "sent",
    "total": 12500,
    "remaining_amount": 12500,
    "paid_at": null,
    "created_at": "2026-05-01T09:14:33Z"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/invoices/:id/pdf {#get-invoices-pdf}

**`invoices.pdf`** · scope `invoices:read`

Download the rendered invoice PDF.

Returns the invoice as application/pdf. The filename in Content-Disposition reflects the document type: faktura-<number>.pdf for sent invoices, kreditfaktura-<number>.pdf for credit notes, utkast-<id-slice>.pdf for drafts. This endpoint is byte-equivalent to the dashboard download.

**Use when:** You need to fetch an invoice PDF for archival, forwarding to a customer outside the gnubok send flow, or attaching to an external workflow.

**Don't use for:** Sending the invoice to the customer — use POST /invoices/{id}/send, which renders the PDF, emails it, and archives it as a verifikationsunderlag in one atomic step.

**Pitfalls**
- Drafts (no invoice_number yet) render with an "utkast" filename. The PDF carries no F-series number — do not treat it as a finalized invoice.
- PDF rendering can take several hundred milliseconds for invoices with many line items. Cache on the client if requesting repeatedly.
- Credit notes embed the original invoice's löpnummer per ML 17 kap 22–23§ — if the original was hard-deleted (not possible via gnubok but theoretically via a manual DB edit), the reference is omitted.

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

**Example response**

```json
{
  "_note": "Returns application/pdf binary stream."
}
```

---

### `POST` /api/v1/companies/:companyId/invoices {#post-invoices-create}

**`invoices.create`** · scope `invoices:write`

Create a draft invoice, proforma, or delivery note.

Creates an invoice in draft status. The F-series invoice_number is allocated atomically on the first send action (PR-B-2b). Per-item VAT rates are validated against the customer's allowed rates (mixed-rate invoices supported). Non-SEK invoices are converted to SEK at the Riksbanken exchange rate fetched at create time. Idempotent (mandatory Idempotency-Key). Dry-runnable — the preview returns the validated would-be invoice + items with computed totals; no journal entry is involved at draft stage (posting happens on :send).

**Use when:** You need to issue a new invoice, proforma, or delivery note. Use dry-run first to confirm VAT calculations and currency conversion before committing.

**Don't use for:** Updating an existing invoice (PATCH instead, drafts only). Issuing a credit note (use POST /:id:credit in PR-B-2b). Posting a previously-created draft to the journal (use POST /:id:send in PR-B-2b).

**Pitfalls**
- Idempotency-Key is mandatory; calls without it return 400.
- For mixed-rate invoices, set vat_rate per item explicitly. Items where vat_rate is omitted use the customer's default rate from getVatRules().
- Non-SEK currencies require an active Riksbanken exchange-rate fetch. Failure is non-fatal — the invoice is created with null SEK fields and the agent can recompute later.
- invoice_number is null on creation. The number is allocated atomically when the invoice transitions out of draft. Counting on a specific number at create time is a bug.
- document_type='delivery_note' produces no VAT and a different number sequence (D-series). Most use cases want the default document_type='invoice'.

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

**Example request**

```json
{
  "customer_id": "a8f1…",
  "invoice_date": "2026-05-12",
  "due_date": "2026-06-11",
  "currency": "SEK",
  "items": [
    {
      "description": "Konsultation",
      "quantity": 8,
      "unit": "tim",
      "unit_price": 1250
    }
  ]
}
```

**Example response**

```json
{
  "data": {
    "id": "0e9c…",
    "invoice_number": null,
    "customer_id": "a8f1…",
    "invoice_date": "2026-05-12",
    "due_date": "2026-06-11",
    "status": "draft",
    "currency": "SEK",
    "subtotal": 10000,
    "vat_amount": 2500,
    "total": 12500,
    "remaining_amount": 12500
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/invoices/:id/credit {#post-invoices-credit}

**`invoices.credit`** · scope `invoices:write`

Issue a credit note (kreditfaktura) against an invoice.

Creates a credit note referencing the original invoice. The credit note carries reversed-sign amounts (matching the original line for line) and gets invoice_number=KR-<original>. The original invoice transitions to status=credited. Under faktureringsmetoden, posts a reversing journal entry (Credit AR 1510 / Debit revenue + Debit output VAT). Under kontantmetoden the credit note still creates the row but defers the reversal entry until refund. Idempotent and dry-runnable. Emits invoice.credited.

**Use when:** You need to legally cancel an issued invoice (ML 17 kap 22–23§). The original invoice cannot be edited once issued — credit it and reissue corrected.

**Don't use for:** Cancelling a draft (DELETE the draft instead). Refunding a partial payment without invalidating the whole invoice (book the refund manually via the journal-entries API in a future PR).

**Pitfalls**
- Idempotency-Key is mandatory. Retried credits with the same key replay the cached response — no duplicate credit note is created.
- The original invoice must be in sent / paid / overdue status. Drafts, cancelled invoices, and already-credited invoices are rejected with specific error codes.
- Credit-note items mirror the original's lines with negated values. To credit only part of an invoice (line-level), credit the full invoice first then reissue with the corrected lines.
- Under kontantmetoden no journal entry is created here — refund booking is deferred. A `JOURNAL_ENTRY_NOT_POSTED` warning is NOT emitted in this case (the deferral is correct, not a failure).

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

**Example request**

```json
{
  "reason": "Felaktig kund"
}
```

**Example response**

```json
{
  "data": {
    "id": "ccccccc-c…",
    "invoice_number": "KR-2026-0042",
    "credited_invoice_id": "0e9c…",
    "status": "sent",
    "total": -12500,
    "journal_entry_id": "8b4b…"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/invoices/:id/mark-paid {#post-invoices-mark-paid}

**`invoices.mark-paid`** · scope `invoices:write`

Record a payment against an invoice.

Marks a sent / overdue invoice as paid (or partially_paid). Books the payment via Debit 1930 / Credit 1510 under faktureringsmetoden, or Debit 1930 / Credit revenue + Credit output VAT under kontantmetoden. Optional body supports partial payments via custom balanced journal lines and exchange-rate adjustments for foreign-currency invoices. Idempotent and dry-runnable. Emits invoice.paid.

**Use when:** A customer paid an invoice via a channel other than the synced bank account (cash, manual transfer, separate processor). Use dry-run to confirm the booking before committing.

**Don't use for:** Reverting a payment — the public API does not expose unmark-paid. Issue a credit note via POST /:id/credit to cancel the underlying invoice instead. Bank-matched payments — those flow through the transactions endpoints.

**Pitfalls**
- Idempotency-Key is mandatory. Retried marks with the same key replay the cached response.
- Custom `lines` must balance (sum of debits = sum of credits, both > 0). Otherwise returns 400 INVOICE_PAID_LINES_UNBALANCED.
- For foreign-currency invoices, supply `exchange_rate_difference` (SEK delta vs the invoice's booked rate) to book the FX adjustment correctly. Omitting it on a non-SEK invoice will mis-book the FX gain/loss.
- Cash basis (kontantmetoden) recognizes revenue HERE, not at :mark-sent. The dashboard tracks this via company_settings.accounting_method.
- Duplicate-payment guard: if an unlinked inbound bank transaction looks like this payment, returns 409 INVOICE_PAID_LIKELY_DUPLICATE with candidate transactions. Retry with `force: true` to bypass — but the retry MUST use a fresh Idempotency-Key (the original is body-hash bound; reusing it returns 400 IDEMPOTENCY_KEY_REUSE). The guard is also evaluated under dry-run, so a successful dry-run does not guarantee a successful commit.

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

**Example request**

```json
{
  "payment_date": "2026-05-12"
}
```

**Example response**

```json
{
  "data": {
    "id": "0e9c…",
    "invoice_number": "2026-0042",
    "status": "paid",
    "total": 12500,
    "paid_amount": 12500,
    "remaining_amount": 0,
    "paid_at": "2026-05-12",
    "journal_entry_id": "7b3a…"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/invoices/:id/mark-sent {#post-invoices-mark-sent}

**`invoices.mark-sent`** · scope `invoices:write`

Transition a draft invoice to sent (without emailing).

Marks a draft invoice as sent — for invoices delivered outside gnubok (Peppol, postal, manual email). Allocates the F-series invoice_number atomically (ML 17 kap 24§ p.2). On accounting_method=accrual, also posts the invoice journal entry (Debit AR 1510 / Credit revenue + output VAT). Emits invoice.sent. Idempotent and dry-runnable. The companion :send action (PR-B-2b-3) adds PDF rendering and email delivery on top of this same flow.

**Use when:** You delivered the invoice through a channel other than gnubok's email (Peppol, postal, your own SMTP) and need to record it as sent so the F-series number is allocated and the journal entry is posted.

**Don't use for:** Sending the invoice via gnubok email — use :send (PR-B-2b-3) for that. Marking an already-sent invoice as paid — use :mark-paid (PR-B-2b-2).

**Pitfalls**
- Only invoices in `status=draft` can be marked sent. Other states return 409 INVOICE_UPDATE_NOT_DRAFT (re-used; the action is structurally an update).
- Allocation is atomic. If a concurrent transition beats the agent's request to the same draft, the runner-up gets 409 INVOICE_UPDATE_NOT_DRAFT and no number is consumed.
- Delivery notes (document_type=delivery_note) don't transition to sent — they were never drafts in the f-series sense. This endpoint will reject them with 400 VALIDATION_ERROR.
- Idempotency-Key is mandatory. A retried mark-sent with the same key replays the cached response.

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

**Example response**

```json
{
  "data": {
    "id": "0e9c…",
    "invoice_number": "2026-0042",
    "status": "sent",
    "total": 12500,
    "journal_entry_id": "7b3a…"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/invoices/:id/send {#post-invoices-send}

**`invoices.send`** · scope `invoices:write`

Send a draft invoice to the customer by email.

The full send pipeline: preflight PDF render → allocate F-series number atomically → final PDF render → email via Resend (PDF attachment, copy to company) → flip status to sent → post journal entry (accrual + real invoice) → archive PDF as underlag → emit invoice.sent. Email failure is a hard 502 before state changes; post-email failures surface as warnings but the invoice IS marked sent.

**Use when:** You want gnubok to deliver the invoice to the customer via email. For invoices delivered through another channel (Peppol, postal, own SMTP) use :mark-sent instead.

**Don't use for:** Re-sending an already-sent invoice (returns 409 INVOICE_UPDATE_NOT_DRAFT). Sending a delivery note (no F-series lifecycle). Sending a credit note (use the :credit endpoint to issue the kreditfaktura; subsequent re-send of the credit note via :mark-sent is the supported path).

**Pitfalls**
- Idempotency-Key is mandatory.
- Email service must be configured — without RESEND_API_KEY + RESEND_FROM_EMAIL the endpoint returns 503 INVOICE_SEND_EMAIL_NOT_CONFIGURED.
- Customer must have an email address. 400 INVOICE_SEND_NO_CUSTOMER_EMAIL otherwise.
- A cancelled invoice is rejected (400 INVOICE_SEND_CANCELLED) — its F-series number is preserved for compliance but the document is not a valid faktura.
- Email failure before the status flip leaves the F-series number consumed but the invoice in `draft` status. Same orphan window as :mark-sent (architecturally tracked, matches internal route).
- After the email succeeds, journal-entry/archive/event failures become warnings on the response; the invoice IS marked sent regardless.

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

**Example response**

```json
{
  "data": {
    "id": "0e9c…",
    "invoice_number": "2026-0042",
    "status": "sent",
    "total": 12500,
    "message_id": "re_abc123",
    "sent_to": "finance@acme.test",
    "cc": "billing@gnubok-user.test",
    "journal_entry_id": "7b3a…"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/invoices/bulk-create {#post-invoices-bulk-create}

**`invoices.bulk-create`** · scope `invoices:write`

Create up to 50 draft invoices in one call (partial-success).

Bulk-creation endpoint. Each invoice in the request array is validated and inserted independently. By default, individual failures do not roll back successes — the response carries a per-item results array with ok/error markers and a summary. Idempotent (the whole batch is keyed by the single Idempotency-Key). Dry-runnable.

**Use when:** You're importing a batch of invoices from another system, or producing many invoices programmatically (e.g. monthly subscription billing). Use dry-run first to validate the whole batch before committing.

**Don't use for:** Sending the same invoice to multiple customers — POST /invoices once per customer. Long-running imports of > 50 invoices — split into pages. Transactional all-or-nothing imports — not yet supported (passing all_or_nothing: true returns 501 NOT_IMPLEMENTED; the flag is reserved for a future RPC).

**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).
- Each per-item invoice still goes through the same VAT-rule validation as POST /invoices. A mismatched per-item vat_rate produces a per-item failure, not a whole-batch failure.
- Currency conversion is best-effort PER ITEM. A failed Riksbanken fetch leaves that item's SEK columns null but does NOT fail the item.

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

**Example request**

```json
{
  "invoices": [
    {
      "customer_id": "a8f1…",
      "invoice_date": "2026-05-12",
      "due_date": "2026-06-11",
      "currency": "SEK",
      "items": [
        {
          "description": "A",
          "quantity": 1,
          "unit": "st",
          "unit_price": 1000
        }
      ]
    }
  ]
}
```

**Example response**

```json
{
  "data": {
    "results": [
      {
        "ok": true,
        "request_index": 0,
        "data": {
          "id": "0e9c…",
          "invoice_number": null,
          "status": "draft",
          "total": 1250
        }
      }
    ],
    "summary": {
      "total": 1,
      "succeeded": 1,
      "failed": 0
    }
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `PATCH` /api/v1/companies/:companyId/invoices/:id {#patch-invoices-update}

**`invoices.update`** · scope `invoices:write`

Update a draft invoice (metadata fields only).

Partial update for invoices in draft status. Allowed fields: invoice_date, due_date, delivery_date, your_reference, our_reference, notes. customer_id, currency, document_type, items, and computed totals are immutable — replace those by deleting the draft and recreating it. Returns 409 INVOICE_UPDATE_NOT_DRAFT if the invoice is no longer in draft status. Idempotent and dry-runnable.

**Use when:** You need to correct a typo, push the due date, or update a customer reference on a draft you have not sent yet. The invoice number stays null until the first :send action.

**Don't use for:** Updating a sent / paid / credited invoice (those are immutable per ML 17 kap; issue a credit note via POST /:id:credit in PR-B-2b). Changing items, currency, or customer — drafts are cheap to delete and recreate.

**Pitfalls**
- Idempotency-Key is mandatory.
- A 409 INVOICE_UPDATE_NOT_DRAFT means the invoice has been sent / paid / credited / cancelled. The error code name is shared with the DELETE handler.
- Items are immutable here — to change line items, delete the draft and POST a fresh one.

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

**Example request**

```json
{
  "due_date": "2026-07-15",
  "notes": "Förlängd förfallotid"
}
```

**Example response**

```json
{
  "data": {
    "id": "0e9c…",
    "status": "draft",
    "due_date": "2026-07-15",
    "notes": "Förlängd förfallotid"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

---

# Suppliers

> AP-side counterparties. Mirrors customers on the supplier vertical.

## Endpoints

- [`GET` `/api/v1/companies/:companyId/suppliers`](#get-suppliers-list) — List suppliers for a company.
- [`GET` `/api/v1/companies/:companyId/suppliers/:id`](#get-suppliers-get) — Retrieve a single supplier by id.
- [`POST` `/api/v1/companies/:companyId/suppliers`](#post-suppliers-create) — Create a supplier.
- [`POST` `/api/v1/companies/:companyId/suppliers/bulk-create`](#post-suppliers-bulk-create) — Create up to 50 suppliers in one call (partial-success).
- [`PATCH` `/api/v1/companies/:companyId/suppliers/:id`](#patch-suppliers-update) — Partially update a supplier.
- [`DELETE` `/api/v1/companies/:companyId/suppliers/:id`](#delete-suppliers-delete) — Archive a supplier (soft-delete).

---

### `GET` /api/v1/companies/:companyId/suppliers {#get-suppliers-list}

**`suppliers.list`** · scope `suppliers:read`

List suppliers for a company.

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.

**Don't 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.

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

**Example response**

```json
{
  "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
  }
}
```

---

### `GET` /api/v1/companies/:companyId/suppliers/:id {#get-suppliers-get}

**`suppliers.get`** · scope `suppliers:read`

Retrieve a single supplier by id.

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.

**Don't 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.

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

**Example response**

```json
{
  "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"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/suppliers {#post-suppliers-create}

**`suppliers.create`** · scope `suppliers:write`

Create a supplier.

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.

**Don't 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.

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

**Example request**

```json
{
  "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"
}
```

**Example response**

```json
{
  "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"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/suppliers/bulk-create {#post-suppliers-bulk-create}

**`suppliers.bulk-create`** · scope `suppliers:write`

Create up to 50 suppliers in one call (partial-success).

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.

**Don't 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.

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

**Example request**

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

**Example response**

```json
{
  "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"
  }
}
```

---

### `PATCH` /api/v1/companies/:companyId/suppliers/:id {#patch-suppliers-update}

**`suppliers.update`** · scope `suppliers:write`

Partially update a supplier.

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.

**Don't 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.

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

**Example request**

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

**Example response**

```json
{
  "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 {#delete-suppliers-delete}

**`suppliers.delete`** · scope `suppliers:write`

Archive a supplier (soft-delete).

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.

**Don't 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.

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

**Example response**

```json
{
  "data": null,
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

---

# Supplier invoices

> AP lifecycle: register, approve, mark paid, credit. With ROT/RUT and reverse-charge support.

## Endpoints

- [`GET` `/api/v1/companies/:companyId/supplier-invoices`](#get-supplier-invoices-list) — List supplier invoices for a company.
- [`GET` `/api/v1/companies/:companyId/supplier-invoices/:id`](#get-supplier-invoices-get) — Retrieve a single supplier invoice by id.
- [`POST` `/api/v1/companies/:companyId/supplier-invoices`](#post-supplier-invoices-create) — Register a new supplier invoice.
- [`POST` `/api/v1/companies/:companyId/supplier-invoices/:id/approve`](#post-supplier-invoices-approve) — Approve a registered supplier invoice.
- [`POST` `/api/v1/companies/:companyId/supplier-invoices/:id/credit`](#post-supplier-invoices-credit) — Issue a credit note for a supplier invoice.
- [`POST` `/api/v1/companies/:companyId/supplier-invoices/:id/mark-paid`](#post-supplier-invoices-mark-paid) — Record a payment against a supplier invoice.
- [`PATCH` `/api/v1/companies/:companyId/supplier-invoices/:id`](#patch-supplier-invoices-update) — Update a registered supplier invoice.

---

### `GET` /api/v1/companies/:companyId/supplier-invoices {#get-supplier-invoices-list}

**`supplier-invoices.list`** · scope `suppliers:read`

List supplier invoices for a company.

Returns supplier invoices in most-recent-first order. 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.

**Don't 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.

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

**Example response**

```json
{
  "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
  }
}
```

---

### `GET` /api/v1/companies/:companyId/supplier-invoices/:id {#get-supplier-invoices-get}

**`supplier-invoices.get`** · scope `suppliers:read`

Retrieve a single supplier invoice by id.

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.

**Don't 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).

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

**Example response**

```json
{
  "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"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/supplier-invoices {#post-supplier-invoices-create}

**`supplier-invoices.create`** · scope `suppliers:write`

Register a new supplier invoice.

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.

**Don't 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.

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

**Example request**

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

**Example response**

```json
{
  "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"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/supplier-invoices/:id/approve {#post-supplier-invoices-approve}

**`supplier-invoices.approve`** · scope `suppliers:write`

Approve a registered supplier invoice.

Flips a supplier invoice from `registered` to `approved`. 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.

**Don't 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 current status !== "registered". Use the detail endpoint to inspect status first if unsure.

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

**Example response**

```json
{
  "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 {#post-supplier-invoices-credit}

**`supplier-invoices.credit`** · scope `suppliers:write`

Issue a credit note for a supplier invoice.

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.

**Don't 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.

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

**Example response**

```json
{
  "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 {#post-supplier-invoices-mark-paid}

**`supplier-invoices.mark-paid`** · scope `suppliers:write`

Record a payment against a supplier invoice.

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.

**Don't 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.

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

**Example request**

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

**Example response**

```json
{
  "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"
  }
}
```

---

### `PATCH` /api/v1/companies/:companyId/supplier-invoices/:id {#patch-supplier-invoices-update}

**`supplier-invoices.update`** · scope `suppliers:write`

Update a registered supplier invoice.

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 fix a typo in supplier_invoice_number, adjust dates, or attach a payment reference / notes to a registered SI before approval. Use dry-run to confirm the merged state first.

**Don't 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).

**Pitfalls**
- Returns 400 SI_NOT_DRAFT when current status !== "registered".
- invoice_date / due_date changes do not re-post the registration JE; if the entry date needs to change, credit the SI and re-register.

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

**Example request**

```json
{
  "payment_reference": "OCR-1234567890"
}
```

**Example response**

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

---

---

# Transactions

> Bank transactions — ingest, categorise, match to invoices, reconcile.

## Endpoints

- [`GET` `/api/v1/companies/:companyId/transactions`](#get-transactions-list) — List transactions for a company.
- [`GET` `/api/v1/companies/:companyId/transactions/:id`](#get-transactions-get) — Retrieve a single transaction by id.
- [`POST` `/api/v1/companies/:companyId/transactions/:id/categorize`](#post-transactions-categorize) — Categorize a transaction and create the journal entry.
- [`POST` `/api/v1/companies/:companyId/transactions/:id/match-invoice`](#post-transactions-match-invoice) — Match a positive bank transaction to a customer invoice.
- [`POST` `/api/v1/companies/:companyId/transactions/:id/match-supplier-invoice`](#post-transactions-match-supplier-invoice) — Match a negative bank transaction to a supplier invoice.
- [`POST` `/api/v1/companies/:companyId/transactions/:id/uncategorize`](#post-transactions-uncategorize) — Reverse the categorization of a transaction (storno + reset).
- [`POST` `/api/v1/companies/:companyId/transactions/batch-categorize`](#post-transactions-batch-categorize) — Categorize up to 100 transactions in one call (partial-success).
- [`POST` `/api/v1/companies/:companyId/transactions/ingest`](#post-transactions-ingest) — Bulk-ingest transactions (up to 500 per call).

---

### `GET` /api/v1/companies/:companyId/transactions {#get-transactions-list}

**`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 ilike).

**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

**Example response**

```json
{
  "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 {#get-transactions-get}

**`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

**Example response**

```json
{
  "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/categorize {#post-transactions-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

**Example request**

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

**Example response**

```json
{
  "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/match-invoice {#post-transactions-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

**Example request**

```json
{
  "invoice_id": "inv_…"
}
```

**Example response**

```json
{
  "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 {#post-transactions-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 / 1930 credit; 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 cannot match across currencies (MATCH_SI_CASH_FX_UNSUPPORTED) — switch to accrual or book FX manually.
- 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

**Example request**

```json
{
  "supplier_invoice_id": "si_…"
}
```

**Example response**

```json
{
  "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/uncategorize {#post-transactions-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

**Example response**

```json
{
  "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 {#post-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

**Example request**

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

**Example response**

```json
{
  "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/ingest {#post-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 (date+amount) runs in addition: a CSV row that matches an already-booked transaction by date+amount 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 runs both dedup checks (external_id AND content-based date+amount against booked rows), matching the live pipeline. Numbers should agree barring concurrent imports between preview and commit.

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

**Example request**

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

**Example response**

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

---

---

# Reconciliation

> Run bank-to-ledger reconciliation and read the current matching status.

## Endpoints

- [`GET` `/api/v1/companies/:companyId/reconciliation/bank/status`](#get-reconciliation-bank-status) — Bank-reconciliation health snapshot.
- [`POST` `/api/v1/companies/:companyId/reconciliation/bank/run`](#post-reconciliation-bank-run) — Run the bank-reconciliation matcher.

---

### `GET` /api/v1/companies/:companyId/reconciliation/bank/status {#get-reconciliation-bank-status}

**`reconciliation.bank.status`** · scope `transactions:read`

Bank-reconciliation health snapshot.

Returns matched / unmatched counts and the balance delta between the bank ledger and the GL for the requested window. Optional ?date_from / ?date_to (default: company history).

**Use when:** You're building a dashboard widget, an audit report, or a pre-close check that needs to know how many bank transactions are still unbooked.

**Don't use for:** Running the matcher — that's POST `/reconciliation/bank/run`. Per-transaction detail — use the transaction list with `?status=unbooked`.

**Pitfalls**
- A non-zero difference is normal between sync runs (uncleared cheques, in-flight transfers). Investigate only if it persists across reconciliations.
- total_unmatched_amount is the absolute sum — positive even when the unmatched rows include both credits and debits.

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

**Example response**

```json
{
  "data": {
    "matched_transactions": 142,
    "unmatched_transactions": 3,
    "unmatched_gl_lines": 2,
    "total_unmatched_amount": 1850,
    "bank_balance": 50000,
    "gl_balance": 48150,
    "difference": 1850
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/reconciliation/bank/run {#post-reconciliation-bank-run}

**`reconciliation.bank.run`** · scope `transactions:write`

Run the bank-reconciliation matcher.

Walks all unbooked bank transactions in the requested date range and pairs them with open GL lines (1930-side) by amount + date proximity. Applies confirmed matches by setting transactions.journal_entry_id (the GL row already exists). Dry-runnable.

**Use when:** You want to auto-match outstanding bank transactions against existing journal entries — typically as the closing step of a sync. Dry-run first to inspect proposed matches.

**Don't use for:** Creating new journal entries — this only links bank transactions to existing GL lines. Matching to invoices — use `:match-invoice` or `:match-supplier-invoice` for explicit invoice payments.

**Pitfalls**
- date_from / date_to default to the company's full bank history if omitted. Specify a window for predictable performance.
- Idempotency-Key is mandatory.
- matches.confidence is between 0 and 1; the matcher only applies matches above the internal threshold (currently ~0.85).

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

**Example request**

```json
{
  "date_from": "2026-05-01",
  "date_to": "2026-05-31"
}
```

**Example response**

```json
{
  "data": {
    "matches": [],
    "applied": 0,
    "errors": []
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

---

# Journal entries

> The bookkeeping engine surface — verifikation lifecycle (draft, commit, reverse, correct).

## Endpoints

- [`GET` `/api/v1/companies/:companyId/journal-entries`](#get-journal-entries-list) — List journal entries (verifikationer).
- [`GET` `/api/v1/companies/:companyId/journal-entries/:id`](#get-journal-entries-get) — Retrieve a single verifikation by id.
- [`POST` `/api/v1/companies/:companyId/journal-entries`](#post-journal-entries-create-draft) — Create a draft journal entry (verifikation).
- [`POST` `/api/v1/companies/:companyId/journal-entries/:id/commit`](#post-journal-entries-commit) — Commit a draft journal entry.
- [`POST` `/api/v1/companies/:companyId/journal-entries/:id/correct`](#post-journal-entries-correct) — Correct a posted journal entry (BFL 5:5 storno-then-replace).
- [`POST` `/api/v1/companies/:companyId/journal-entries/:id/reverse`](#post-journal-entries-reverse) — Storno a posted journal entry.
- [`POST` `/api/v1/companies/:companyId/journal-entries/batch-create`](#post-journal-entries-batch-create) — Create up to 50 draft journal entries (partial-success).

---

### `GET` /api/v1/companies/:companyId/journal-entries {#get-journal-entries-list}

**`journal-entries.list`** · scope `reports:read`

List journal entries (verifikationer).

Cursor-paginated list of journal entries. Filters: fiscal_period_id, status, date_from, date_to. Excludes status=cancelled by default; pass status=cancelled to inspect storno-cancelled drafts.

**Use when:** You need to walk the verifikationsserie for a period (audit, SIE export, gap detection) or list recent activity for a UI.

**Don't use for:** Reading a single verifikation (use GET /{id}). Reading lines without the header (no separate endpoint — they ride in /{id}).

**Pitfalls**
- Cancelled drafts are hidden by default. They are NOT a löpnummer gap (no voucher_number is allocated for drafts); the filter is for noise reduction.
- voucher_number=0 indicates a draft that has not been committed. Posted entries always have voucher_number > 0.

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

**Example response**

```json
{
  "data": [
    {
      "id": "0e9c…",
      "fiscal_period_id": "a8f1…",
      "voucher_series": "A",
      "voucher_number": 142,
      "entry_date": "2026-05-12",
      "description": "Levfaktura 2026-1234, Office Depot AB (ankomst 42)",
      "status": "posted",
      "source_type": "supplier_invoice_registered",
      "created_at": "2026-05-13T15:00:00Z"
    }
  ],
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12",
    "next_cursor": null
  }
}
```

---

### `GET` /api/v1/companies/:companyId/journal-entries/:id {#get-journal-entries-get}

**`journal-entries.get`** · scope `reports:read`

Retrieve a single verifikation by id.

Returns the full journal entry including all lines, dimensions, and the storno chain (reverses_id, reversed_by_id, correction_of_id).

**Use when:** You need the full verifikation for audit / reconciliation, or to display the line-by-line breakdown.

**Don't use for:** Listing entries (use the list endpoint with filters).

**Pitfalls**
- Cancelled drafts are returned (no filter on status here); inspect status before assuming the entry is posted.
- Lines are sorted by sort_order; the order matters for display but not for accounting (the sum across lines is the meaningful quantity).

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

**Example response**

```json
{
  "data": {
    "id": "0e9c…",
    "voucher_series": "A",
    "voucher_number": 142,
    "entry_date": "2026-05-12",
    "status": "posted",
    "lines": [
      {
        "account_number": "6570",
        "debit_amount": 50,
        "credit_amount": 0,
        "sort_order": 0
      },
      {
        "account_number": "1930",
        "debit_amount": 0,
        "credit_amount": 50,
        "sort_order": 1
      }
    ]
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/journal-entries {#post-journal-entries-create-draft}

**`journal-entries.create-draft`** · scope `bookkeeping:write`

Create a draft journal entry (verifikation).

Creates a draft journal entry via the engine's createDraftEntry(). The draft has no voucher_number until /commit is called. Idempotent (mandatory Idempotency-Key). Dry-runnable: a dry-run validates balance + account-chart membership + period date constraints without inserting any row.

**Use when:** You're posting an arbitrary verifikation — manual journal entries, accrual reversals, period closing adjustments — outside the invoicing / supplier-invoice / transaction flows.

**Don't use for:** Bookkeeping flows that have a dedicated endpoint (invoices, supplier-invoices, transactions). Editing an existing posted entry — use /correct instead.

**Pitfalls**
- Idempotency-Key is mandatory.
- Lines must sum to zero (Σ debit = Σ credit). Engine rejects with JOURNAL_ENTRY_NOT_BALANCED on imbalance.
- entry_date must fall within fiscal_period_id's [period_start, period_end]; otherwise ENTRY_DATE_OUTSIDE_FISCAL_PERIOD.
- All account_numbers must be active in the chart_of_accounts; otherwise ACCOUNTS_NOT_IN_CHART.
- voucher_series defaults to "A" if omitted. Must be a single uppercase letter.
- This creates a DRAFT only — call POST /{id}/commit to assign the voucher_number and post atomically.

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

**Example request**

```json
{
  "fiscal_period_id": "a8f1…",
  "entry_date": "2026-05-12",
  "description": "Bankavgift maj 2026",
  "lines": [
    {
      "account_number": "6570",
      "debit_amount": 50,
      "credit_amount": 0,
      "line_description": "Bankavgift"
    },
    {
      "account_number": "1930",
      "debit_amount": 0,
      "credit_amount": 50,
      "line_description": "Företagskonto"
    }
  ]
}
```

**Example response**

```json
{
  "data": {
    "id": "0e9c…",
    "status": "draft",
    "voucher_series": "A",
    "voucher_number": 0
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/journal-entries/:id/commit {#post-journal-entries-commit}

**`journal-entries.commit`** · scope `bookkeeping:write`

Commit a draft journal entry.

Atomically advances the voucher series and flips the draft to posted. The voucher_number is the smallest integer not yet used in (fiscal_period_id, voucher_series); a failed commit does NOT burn the number.

**Use when:** You created a draft via POST /journal-entries and now want to post it to the books. After commit the entry is immutable per BFL 5 kap 2 §; corrections require /reverse or /correct.

**Don't use for:** Re-committing an already-posted entry (returns 409). Committing across companies — the URL companyId must match the draft's company.

**Pitfalls**
- Idempotency-Key is mandatory.
- Posted entries cannot be edited. Plan the lines carefully or call /correct after commit if you need to change them.
- Voucher numbers are sequential within (fiscal_period_id, voucher_series). A commit failure (e.g. period locked between draft creation and commit) does not advance the sequence.

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

**Example response**

```json
{
  "data": {
    "id": "0e9c…",
    "voucher_series": "A",
    "voucher_number": 143,
    "status": "posted",
    "entry_date": "2026-05-12"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/journal-entries/:id/correct {#post-journal-entries-correct}

**`journal-entries.correct`** · scope `bookkeeping:write`

Correct a posted journal entry (BFL 5:5 storno-then-replace).

Per Bokföringslagen 5 kap 5 §, posted entries cannot be modified. This endpoint creates the canonical correction trail: a storno reversing the original, then a new entry with the corrected lines. All three are visible in the verifikationsserie and linked via reverses_id / reversed_by_id / correction_of_id. Idempotent. Dry-runnable.

**Use when:** You need to amend a posted verifikation. Use this rather than /reverse when the entry is being REPLACED with new lines — /reverse just nullifies.

**Don't use for:** Drafts (no voucher_number — cancel via dashboard). Already-corrected entries (the chain only supports one correction; correct the latest in the chain).

**Pitfalls**
- Idempotency-Key is mandatory.
- The new lines must balance. JOURNAL_ENTRY_NOT_BALANCED if not.
- The original's entry_date and fiscal_period_id are inherited. If the original's period has been locked since posting, the call returns PERIOD_LOCKED.
- Three voucher numbers are advanced in this call: the original (already burned), the reversal, and the corrected. The series stays unbroken.

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

**Example request**

```json
{
  "lines": [
    {
      "account_number": "6570",
      "debit_amount": 75,
      "credit_amount": 0,
      "line_description": "Bankavgift (rättad)"
    },
    {
      "account_number": "1930",
      "debit_amount": 0,
      "credit_amount": 75,
      "line_description": "Företagskonto"
    }
  ]
}
```

**Example response**

```json
{
  "data": {
    "reversal_id": "4d2a…",
    "corrected_id": "7b3a…",
    "original_id": "0e9c…",
    "voucher_series": "A",
    "reversal_voucher_number": 144,
    "corrected_voucher_number": 145
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/journal-entries/:id/reverse {#post-journal-entries-reverse}

**`journal-entries.reverse`** · scope `bookkeeping:write`

Storno a posted journal entry.

Creates a reversing journal entry that nullifies the original. The original remains posted and visible — the reversal links via reverses_id and the original is annotated reversed_by_id. The reversal carries its own voucher_number in the same series so the löpnummer chain stays unbroken (BFL 5 kap 5–7 §§).

**Use when:** A posted entry needs to be cancelled and there is no replacement coming — e.g. a duplicate booking, an entry posted to the wrong period. Use /correct instead when you need to replace the entry with corrected lines.

**Don't use for:** Cancelling a draft (drafts have no voucher_number; cancel via the dashboard). Reversing an already-reversed entry (returns ENTRY_ALREADY_REVERSED).

**Pitfalls**
- Idempotency-Key is mandatory.
- reversal_date defaults to today; the reversal is posted in the fiscal period covering that date. If today's period is locked the call returns PERIOD_LOCKED.
- You cannot reverse a draft (status must be posted). Use /correct after commit if the original needs replacing.

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

**Example request**

```json
{
  "reversal_date": "2026-05-13"
}
```

**Example response**

```json
{
  "data": {
    "reversal_id": "4d2a…",
    "original_id": "0e9c…",
    "voucher_series": "A",
    "voucher_number": 144,
    "entry_date": "2026-05-13",
    "status": "posted"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/journal-entries/batch-create {#post-journal-entries-batch-create}

**`journal-entries.batch-create`** · scope `bookkeeping:write`

Create up to 50 draft journal entries (partial-success).

Bulk-create endpoint mirroring /invoices/bulk-create and /suppliers/bulk-create. Each entry is validated and inserted independently — per-item failures do not roll back items that succeeded. Returns DRAFTS only; commit each separately. Idempotent over the whole batch. Dry-runnable.

**Use when:** You're replaying historical bookkeeping from another system, or batching a set of manual verifikationer from a spreadsheet. Use dry-run first to validate the batch.

**Don't use for:** Committing posted entries — use POST /{id}/commit per entry. Transactional all-or-nothing imports — passing all_or_nothing: true returns 501 NOT_IMPLEMENTED.

**Pitfalls**
- Idempotency-Key is mandatory and covers the WHOLE batch.
- all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist.
- Each entry must balance independently. Per-item JOURNAL_ENTRY_NOT_BALANCED appears in the results array.

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

**Example request**

```json
{
  "journal_entries": [
    {
      "fiscal_period_id": "a8f1…",
      "entry_date": "2026-05-12",
      "description": "Bankavgift",
      "lines": [
        {
          "account_number": "6570",
          "debit_amount": 50,
          "credit_amount": 0
        },
        {
          "account_number": "1930",
          "debit_amount": 0,
          "credit_amount": 50
        }
      ]
    }
  ]
}
```

**Example response**

```json
{
  "data": {
    "results": [
      {
        "ok": true,
        "request_index": 0,
        "data": {
          "id": "0e9c…",
          "status": "draft"
        }
      }
    ],
    "summary": {
      "total": 1,
      "succeeded": 1,
      "failed": 0
    }
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

---

# Voucher gap explanations

> Documented explanations for gaps in the voucher series, per BFNAR 2013:2.

## Endpoints

- [`POST` `/api/v1/companies/:companyId/voucher-gap-explanations`](#post-voucher-gap-explanations-create) — Document a gap in the verifikationsserie (BFL 5 kap 6-7 §§).

---

### `POST` /api/v1/companies/:companyId/voucher-gap-explanations {#post-voucher-gap-explanations-create}

**`voucher-gap-explanations.create`** · scope `bookkeeping:write`

Document a gap in the verifikationsserie (BFL 5 kap 6-7 §§).

Records an explanation for one or more missing voucher numbers in a series. Required when a number is unaccounted for during audit. Statutory basis: BFL 5 kap 6-7 §§ (verifikationsnummer i löpande följd utan luckor); BFNAR 2013:2 kap 8 § governs the systemdokumentation that surfaces the gap. Idempotent. Dry-runnable.

**Use when:** You're responding to a voucher-gap audit finding and need to document the cause. Also used by migration flows that claim numbers without filling them.

**Don't use for:** Falsifying a series — every gap MUST have a genuine explanation. The dashboard surfaces these for auditor review.

**Pitfalls**
- Idempotency-Key is mandatory.
- gap_end must be >= gap_start; a single-number gap has gap_start = gap_end.
- voucher_series is a single uppercase letter (A–Z); the same series + period + numeric range must not already exist.

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

**Example request**

```json
{
  "fiscal_period_id": "a8f1…",
  "voucher_series": "A",
  "gap_start": 142,
  "gap_end": 145,
  "explanation": "Migration from previous bookkeeping system on 2026-05-12 — series A148-onwards corresponds to the new gnubok numbering; numbers A142-A145 were assigned in the legacy system to manual paper vouchers archived offline (BFL 7 kap retention applies). Paper vouchers are stored in the company archive under reference 2026-PAPER-Q2."
}
```

**Example response**

```json
{
  "data": {
    "id": "0e9c…",
    "voucher_series": "A",
    "gap_start": 142,
    "gap_end": 145
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

---

# Fiscal periods

> Period lifecycle — lock, close, year-end, opening balances, FX revaluation. Async via the operations substrate.

## Endpoints

- [`GET` `/api/v1/companies/:companyId/fiscal-periods`](#get-fiscal-periods-list) — List fiscal periods (räkenskapsår).
- [`POST` `/api/v1/companies/:companyId/fiscal-periods/:id/close`](#post-fiscal-periods-close) — Close a fiscal period (IRREVERSIBLE per BFL 5 kap 8 §).
- [`POST` `/api/v1/companies/:companyId/fiscal-periods/:id/currency-revaluation`](#post-fiscal-periods-currency-revaluation) — Run FX revaluation for the fiscal period.
- [`POST` `/api/v1/companies/:companyId/fiscal-periods/:id/lock`](#post-fiscal-periods-lock) — Lock a fiscal period (no new entries can be posted into it).
- [`POST` `/api/v1/companies/:companyId/fiscal-periods/:id/opening-balances`](#post-fiscal-periods-opening-balances) — Generate opening-balance verifikation for the next fiscal period.
- [`POST` `/api/v1/companies/:companyId/fiscal-periods/:id/year-end`](#post-fiscal-periods-year-end) — Execute year-end closing (currency revaluation + closing entry).

---

### `GET` /api/v1/companies/:companyId/fiscal-periods {#get-fiscal-periods-list}

**`fiscal-periods.list`** · scope `reports:read`

List fiscal periods (räkenskapsår).

Returns every fiscal period for the company ordered by period_start DESC. is_closed=true means bokslut has been signed; locked_at non-null means writes are blocked at the DB-trigger level.

**Use when:** You need to find the active period before booking, build a year-selector UI, or audit the period-lock history.

**Don't use for:** Creating, locking, or closing periods — those land in Phase 4 (`POST /fiscal-periods/{id}/lock`, `:close`, `:year-end`). Use the dashboard or wait for Phase 4.

**Pitfalls**
- previous_period_id chains the bokslut continuity (BFNAR 2013:2). A null value on a non-first period is a data-quality red flag.
- A period can be locked but not closed (löpande bokföring of the new year while bokslut work continues on the prior year — see BFL 5 kap 2 § for the löpande bokföring deadline).
- BFL 3 kap caps a single fiscal period at 18 months. First-year exceptions are allowed.

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

**Example response**

```json
{
  "data": [
    {
      "id": "fp_2026",
      "name": "Räkenskapsår 2026",
      "period_start": "2026-01-01",
      "period_end": "2026-12-31",
      "is_closed": false,
      "locked_at": null
    }
  ],
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/fiscal-periods/:id/close {#post-fiscal-periods-close}

**`fiscal-periods.close`** · scope `bookkeeping:write`

Close a fiscal period (IRREVERSIBLE per BFL 5 kap 8 §).

Sets is_closed=true + closed_at on the period. Pre-requisites: period must be locked (call /lock first) AND year-end closing must have been executed (call /year-end first). Sync. The DB blocks any subsequent JE inserts.

**Use when:** Final step in the year-end flow: lock → year-end → close. Closing freezes the period for BFL 7 kap retention.

**Don't use for:** Locking a period (use /lock). Running the year-end closing entry (use /year-end). UNDOING a close (not supported — irreversible).

**Pitfalls**
- Idempotency-Key is mandatory.
- IRREVERSIBLE. Once is_closed=true, the period is read-only forever (BFL 5 kap 8 § + 7 kap).
- Pre-conditions: locked + closing_entry_id present. Otherwise the call returns CONFLICT.

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

**Example response**

```json
{
  "data": {
    "id": "a8f1…",
    "is_closed": true,
    "closed_at": "2026-05-12T14:30:00Z"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/fiscal-periods/:id/currency-revaluation {#post-fiscal-periods-currency-revaluation}

**`fiscal-periods.currency-revaluation`** · scope `bookkeeping:write`

Run FX revaluation for the fiscal period.

Re-rates open foreign-currency AR (1510) and AP (2440) at the closing date's Riksbanken rate and posts the SEK delta to 3960 (valutakursvinst) / 7960 (valutakursförlust). Returns 202 with operation_id. Idempotent per-period: the engine throws if a revaluation has already been posted for the same fiscal_period_id.

**Use when:** Before /year-end if your books have open foreign-currency receivables or payables. /year-end also runs this internally, so you only need to call it separately when you want the FX-only entry without the full closing.

**Don't use for:** Re-running on the same period (CURRENCY_REVALUATION_ALREADY_EXISTS). Revaluing a closed period (the trigger blocks JE writes to closed periods).

**Pitfalls**
- Idempotency-Key is mandatory.
- Engine returns null if no open foreign-currency items exist — the operation succeeds with result.revaluation_entry_id=null.
- as_of_date defaults to period_end if omitted.

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

**Example response**

```json
{
  "data": {
    "operation_id": "0e9c…",
    "type": "fiscal_periods.currency_revaluation",
    "status": "succeeded",
    "poll_url": "/api/v1/operations/0e9c…",
    "webhook_event": "operation.completed"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/fiscal-periods/:id/lock {#post-fiscal-periods-lock}

**`fiscal-periods.lock`** · scope `bookkeeping:write`

Lock a fiscal period (no new entries can be posted into it).

Sets locked_at on the period. Refuses if uncategorised business transactions remain in the period — they must be bokfört first. The DB trigger blocks JE inserts into locked periods; locking is the application-level pre-step before /close. Sync.

**Use when:** Finishing a period and you want to stop new postings. Step 1 of a three-step year-end flow: lock → year-end → close.

**Don't use for:** Locking an already-closed period (no-op). Bypassing the uncategorised-transactions guard — categorise or mark-private first.

**Pitfalls**
- Idempotency-Key is mandatory.
- A period with uncategorised business transactions cannot be locked; the response surfaces the count.
- Locking is reversible until /close. The unlock endpoint is not in v1; use the dashboard.

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

**Example response**

```json
{
  "data": {
    "id": "a8f1…",
    "locked_at": "2026-05-12T14:00:00Z",
    "is_closed": false
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/fiscal-periods/:id/opening-balances {#post-fiscal-periods-opening-balances}

**`fiscal-periods.opening-balances`** · scope `bookkeeping:write`

Generate opening-balance verifikation for the next fiscal period.

Reads the closed period's trial balance, filters to BAS class 1–2 accounts with non-zero closing balance, and posts an opening verifikation (status=posted) onto the next_period_id. Sync. The path id is the CLOSED period; body.next_period_id is the target.

**Use when:** After /year-end + /close on a period, generate the IB into the next period so the new year starts with the correct balance sheet.

**Don't use for:** Posting opening balances on a manually-edited basis (use POST /journal-entries with source_type=manual). Re-running on the same target period (will produce duplicate IB entries).

**Pitfalls**
- Idempotency-Key is mandatory.
- next_period_id must reference the SAME company and must NOT already have an IB entry. The engine throws if it does.
- Only class 1 (assets) and 2 (equity/liabilities) flow into the IB; class 3-8 are zeroed by the closing entry.

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

**Example request**

```json
{
  "next_period_id": "7b3a…"
}
```

**Example response**

```json
{
  "data": {
    "opening_entry_id": "4d2a…",
    "voucher_series": "A",
    "voucher_number": 1,
    "next_period_id": "7b3a…"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/fiscal-periods/:id/year-end {#post-fiscal-periods-year-end}

**`fiscal-periods.year-end`** · scope `bookkeeping:write`

Execute year-end closing (currency revaluation + closing entry).

Async-operation endpoint. Runs the year-end closing flow: currency revaluation (FX gains/losses to 3960/7960), then posts the closing entry that zeroes class 3-8 onto årets resultat (2099 for AB, the relevant eget-kapital account in the 2010-2019 range for enskild firma — the engine resolves which based on company.entity_type). Returns 202 with operation_id; subscribe to operation.completed or poll /v1/operations/{id}.

**Use when:** After /lock and a passing /compliance/check?type=year_end_readiness, you want to run the closing entry. This is step 2 of the lock → year-end → close flow.

**Don't use for:** Re-running year-end (per-period idempotent — fails if closing_entry_id is already set). Closing the period (use /close after year-end succeeds).

**Pitfalls**
- Idempotency-Key is mandatory.
- Period must pass year_end_readiness checks (no drafts, no unexplained voucher gaps, trial balance balanced). The engine re-validates and aborts if not.
- Closing entry is itself a verifikation (posted) — the period must NOT already be closed.

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

**Example response**

```json
{
  "data": {
    "operation_id": "0e9c…",
    "type": "fiscal_periods.year_end",
    "status": "succeeded",
    "poll_url": "/api/v1/operations/0e9c…",
    "webhook_event": "operation.completed"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

---

# Accounts

> Read the chart of accounts (BAS).

## Endpoints

- [`GET` `/api/v1/companies/:companyId/accounts`](#get-accounts-list) — List chart-of-accounts entries (BAS chart).

---

### `GET` /api/v1/companies/:companyId/accounts {#get-accounts-list}

**`accounts.list`** · scope `reports:read`

List chart-of-accounts entries (BAS chart).

Returns every account in the company's chart of accounts, ordered by sort_order (the BAS canonical sequence). Filter by ?class=<1..8> (BAS account class — 1=assets, 2=equity/liabilities, 3=revenue, 4=cost of goods sold, 5=övriga externa kostnader (rents, supplies, services), 6=övriga externa kostnader (marketing, professional services, IT), 7=labour, 8=financial). Note: BAS 5xxx and 6xxx are both övriga externa kostnader but cover distinct subgroups — see the BAS chart for the canonical mapping. Pass ?active=false to include archived accounts.

**Use when:** You need account numbers and names to render verifikation tables, build a custom report, or look up the canonical BAS label for an account.

**Don't use for:** Fetching balances — use the trial-balance report. Creating new accounts — this endpoint is read-only in v1 (use the dashboard).

**Pitfalls**
- account_number is a STRING — "1930", not 1930. The leading character can be 0 in non-BAS plans.
- is_system_account=true means the account was seeded by gnubok and cannot be archived or renamed.
- Default filter excludes archived accounts; pass ?active=false to include them.

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

**Example response**

```json
{
  "data": [
    {
      "account_number": "1930",
      "account_name": "Företagskonto",
      "account_class": 1,
      "account_type": "asset",
      "normal_balance": "debit",
      "is_active": true
    }
  ],
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

---

# Documents

> Multipart upload, signed-URL download (15-min TTL), link to journal entries.

## Endpoints

- [`GET` `/api/v1/companies/:companyId/documents/:id/download`](#get-documents-download) — Get a time-limited signed download URL for a document.
- [`POST` `/api/v1/companies/:companyId/documents`](#post-documents-upload) — Upload a document to the WORM archive.
- [`POST` `/api/v1/companies/:companyId/documents/:id/link`](#post-documents-link) — Link a document to a journal entry.

---

### `GET` /api/v1/companies/:companyId/documents/:id/download {#get-documents-download}

**`documents.download`** · scope `documents:read`

Get a time-limited signed download URL for a document.

Returns a Supabase Storage signed URL valid for 15 minutes. The URL itself is the canonical download — fetch it with any HTTP client; no API key needed on the storage host. Verify file integrity client-side against the returned sha256_hash if your workflow requires it.

**Use when:** You need the bytes of an archived document (e.g. for OCR, attachment to an email, regulatory export). Always re-fetch the URL before each download — old URLs expire.

**Don't use for:** Persisting the URL anywhere — it expires. Storing the URL in a webhook payload or audit log makes the audit trail dependent on URL state.

**Pitfalls**
- The signed URL expires after 15 minutes. Don't cache it beyond the immediate transaction.
- The URL leaks the Supabase Storage origin; this is benign (the signature alone authorizes the read) but rate-limit any forwarding so you don't reveal the storage layout to untrusted callers.
- Each call emits a document.accessed event. Polling this endpoint produces audit noise; cache the URL for its full TTL.

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

**Example response**

```json
{
  "data": {
    "id": "0e9c…",
    "file_name": "kvitto-2026-05-12.pdf",
    "mime_type": "application/pdf",
    "sha256_hash": "8a7f…",
    "download_url": "https://…supabase.co/storage/v1/object/sign/…",
    "expires_in_seconds": 900
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/documents {#post-documents-upload}

**`documents.upload`** · scope `documents:write`

Upload a document to the WORM archive.

Multipart upload of a document (PDF / image) under the BFL 7 kap retention regime. The bytes are hashed (SHA-256), written to Supabase Storage, and recorded in document_attachments at version=1. Allowed MIME types: application/pdf, image/jpeg, image/png, image/webp. Max size: 10 MB.

**Use when:** You have a receipt, invoice scan, or supporting document for a posted verifikation and want it archived for the 7-year BFL retention period. Optionally link to a journal entry at upload time via journal_entry_id.

**Don't use for:** Updating an existing document (no v1 update endpoint; new versions go through the dashboard). Bulk uploads — call once per file.

**Pitfalls**
- Idempotency-Key is mandatory; multipart retries with the same key replay the cached response.
- Max size 10 MB enforced server-side — DOC_UPLOAD_TOO_LARGE on overrun.
- Only application/pdf / image/jpeg / image/png / image/webp accepted — DOC_UPLOAD_UNSUPPORTED_TYPE otherwise.
- WORM: once linked to a posted journal entry, the document row cannot be modified or deleted (DB trigger). Upload-then-link is reversible (the document exists with journal_entry_id=null until linked); once linked, treat as immutable.
- Dry-run is not supported on this endpoint — the engine hashes + stores + inserts in one atomic flow.

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

**Example request**

```json
{
  "file": "<binary>",
  "upload_source": "api",
  "journal_entry_id": "a8f1…"
}
```

**Example response**

```json
{
  "data": {
    "id": "0e9c…",
    "file_name": "kvitto-2026-05-12.pdf",
    "mime_type": "application/pdf",
    "file_size_bytes": 184320,
    "sha256_hash": "8a7f…",
    "version": 1,
    "is_current_version": true,
    "journal_entry_id": "a8f1…"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/documents/:id/link {#post-documents-link}

**`documents.link`** · scope `documents:write`

Link a document to a journal entry.

Sets journal_entry_id (and optionally journal_entry_line_id) on an existing document. Use this after /documents upload when the link target was unknown at upload time, or to re-link a stray document. Once the target JE is posted, the document row is effectively immutable per BFL 7 kap retention.

**Use when:** A document was uploaded without a journal_entry_id (e.g. bulk import) and you now want to attach it to a posted verifikation.

**Don't use for:** Unlinking — no v1 unlink endpoint. The dashboard exposes a manual override; v1 keeps the WORM contract by refusing to revert posted-JE links.

**Pitfalls**
- Idempotency-Key is mandatory.
- Both the document and the journal_entry_id must belong to the caller's company. NOT_FOUND on mismatch (enumeration hardening).
- Re-linking an already-linked document overwrites the previous journal_entry_id — confirm the old target is what you intend to break.

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

**Example request**

```json
{
  "journal_entry_id": "a8f1…"
}
```

**Example response**

```json
{
  "data": {
    "id": "0e9c…",
    "journal_entry_id": "a8f1…",
    "journal_entry_line_id": null,
    "file_name": "kvitto-2026-05-12.pdf"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

---

# Employees

> Payroll roster — CRUD with personnummer masking on list endpoints.

## Endpoints

- [`GET` `/api/v1/companies/:companyId/employees`](#get-employees-list) — List employees for a company.
- [`GET` `/api/v1/companies/:companyId/employees/:id`](#get-employees-get) — Get a single employee.
- [`POST` `/api/v1/companies/:companyId/employees`](#post-employees-create) — Create an employee.
- [`PATCH` `/api/v1/companies/:companyId/employees/:id`](#patch-employees-update) — Update an employee.
- [`DELETE` `/api/v1/companies/:companyId/employees/:id`](#delete-employees-delete) — Soft-delete an employee.

---

### `GET` /api/v1/companies/:companyId/employees {#get-employees-list}

**`employees.list`** · scope `payroll:read`

List employees for a company.

Returns active employees in created-first order. Pass ?include_inactive=true to include soft-deleted (is_active=false) rows. Use ?search to match against first or last name. Personnummer is masked (birthdate visible, last-4 hidden); use GET /employees/{id} for the full value.

**Use when:** You need a roster — for building a UI picker, resolving employee_id before adding to a salary run, or syncing an external HR system.

**Don't use for:** Fetching a single employee you already know the id of — use GET /api/v1/companies/{companyId}/employees/{id}. Salary calculations live on /salary-runs/{id}.

**Pitfalls**
- Inactive employees are hidden by default; soft-delete via DELETE sets is_active=false (BFL 7 kap retention).
- personnummer is masked in the list response (GDPR Art.5(1)(c) data minimisation). The detail endpoint returns the full value.
- salary_type drives which field is meaningful: monthly_salary for monthly, hourly_rate for hourly. The other is null.

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

**Example response**

```json
{
  "data": [
    {
      "id": "a8f1…",
      "first_name": "Anna",
      "last_name": "Andersson",
      "personnummer_masked": "YYYYMMDDXXXX",
      "employment_type": "employee",
      "employment_start": "2024-01-15",
      "employment_end": null,
      "salary_type": "monthly",
      "monthly_salary": 35000,
      "hourly_rate": null,
      "f_skatt_status": "a_skatt",
      "is_active": true,
      "created_at": "2024-01-15T08:00:00Z"
    }
  ],
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12",
    "next_cursor": null
  }
}
```

---

### `GET` /api/v1/companies/:companyId/employees/:id {#get-employees-get}

**`employees.get`** · scope `payroll:read`

Get a single employee.

Returns the full employee record including the 12-digit personnummer, bank details, tax configuration, and contact info. This is the deliberate drill-in for an id you already know — list calls mask personnummer.

**Use when:** You have an employee id and need every field (tax table, bank account, vacation rule) — typically to render an edit form or to construct a payroll calculation input.

**Don't use for:** Rosters or pickers (use the list endpoint — personnummer is masked there).

**Pitfalls**
- The response includes the full personnummer. Treat it as a national identifier (GDPR Art.5(1)(c)) — do not propagate it to logs or external systems beyond what your integration strictly requires.
- Inactive (soft-deleted) employees are returned by the detail endpoint; check `is_active` if your flow should skip them.

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

**Example response**

```json
{
  "data": {
    "id": "a8f1…",
    "first_name": "Anna",
    "last_name": "Andersson",
    "personnummer": "YYYYMMDDNNNN",
    "employment_type": "employee",
    "employment_start": "2024-01-15",
    "employment_end": null,
    "salary_type": "monthly",
    "monthly_salary": 35000,
    "f_skatt_status": "a_skatt",
    "is_active": true
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/employees {#post-employees-create}

**`employees.create`** · scope `payroll:write`

Create an employee.

Creates a new employee for the company. Requires Idempotency-Key (UUID). Supports ?dry_run=true for input validation without committing. The personnummer in the request body must be 12 digits (ÅÅÅÅMMDDNNNN); the response echoes a masked form (birthdate + XXXX) — GDPR Art.5(1)(c).

**Use when:** You need to register a new employee before adding them to a salary run. Use dry-run first to catch validation errors (missing tax table, salary amount, F-skatt mismatch) before committing.

**Don't use for:** Updating an existing employee (PATCH instead). Soft-deactivating (DELETE — sets is_active=false). Hard-deleting (the API does not expose hard delete; BFL 7 kap retention).

**Pitfalls**
- Idempotency-Key is mandatory — calls without it return 400 VALIDATION_ERROR.
- personnummer must be exactly 12 digits with the YYYYMMDD prefix (not the short 10-digit form).
- Duplicate personnummer within a company returns 409 EMPLOYEE_DUPLICATE_PERSONNUMMER. Personnummer is unique per (company_id, personnummer).
- For A-skatt employees who are not sidoinkomst, tax_table_number is required (29–42).
- salary_type drives which salary field is required: monthly_salary for monthly, hourly_rate for hourly.
- The response masks personnummer; never echo back the supplied value. Detail endpoint (deliberate drill-in) returns the full value.

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

**Example request**

```json
{
  "first_name": "Anna",
  "last_name": "Andersson",
  "personnummer": "YYYYMMDDNNNN",
  "employment_type": "employee",
  "employment_start": "2024-01-15",
  "salary_type": "monthly",
  "monthly_salary": 35000,
  "tax_table_number": 33,
  "tax_column": 1,
  "tax_municipality": "Stockholm"
}
```

**Example response**

```json
{
  "data": {
    "id": "a8f1…",
    "first_name": "Anna",
    "last_name": "Andersson",
    "personnummer_masked": "YYYYMMDDXXXX",
    "employment_type": "employee",
    "employment_start": "2024-01-15",
    "employment_end": null,
    "employment_degree": 100,
    "salary_type": "monthly",
    "monthly_salary": 35000,
    "hourly_rate": null,
    "tax_table_number": 33,
    "tax_column": 1,
    "tax_municipality": "Stockholm",
    "is_sidoinkomst": false,
    "f_skatt_status": "a_skatt",
    "vacation_rule": "procentregeln",
    "vacation_days_per_year": 25,
    "is_active": true,
    "created_at": "2024-01-15T08:00:00Z"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `PATCH` /api/v1/companies/:companyId/employees/:id {#patch-employees-update}

**`employees.update`** · scope `payroll:write`

Update an employee.

Partial update of an employee. Only the fields supplied in the body are changed. Supports ?dry_run=true to validate the merged record without committing. Personnummer changes are NOT permitted via this endpoint — the natural-person identity is immutable post-creation.

**Use when:** You need to change tax configuration, bank details, salary amount, or contact info on an existing employee.

**Don't use for:** Changing personnummer (not supported — create a new employee if the natural-person identity changes, which is a rare edge case). Soft-deleting (use DELETE).

**Pitfalls**
- personnummer in the body is ignored by this endpoint. To change it you must DELETE and recreate.
- salary_type changes require the matching salary field in the same request — switching to monthly without monthly_salary returns 400.
- tax_table_number changes only take effect on future salary runs; runs already in `review` or beyond use a frozen snapshot.

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

**Example request**

```json
{
  "monthly_salary": 38000,
  "tax_municipality": "Göteborg"
}
```

**Example response**

```json
{
  "data": {
    "id": "a8f1…",
    "monthly_salary": 38000
  }
}
```

---

### `DELETE` /api/v1/companies/:companyId/employees/:id {#delete-employees-delete}

**`employees.delete`** · scope `payroll:write`

Soft-delete an employee.

Sets `is_active=false`. The row is preserved because past salary runs reference it via salary_run_employees and those verifikationer are räkenskapsinformation under BFL 7 kap (BFL retention attaches to the verifikationer themselves, not strictly to the personnummer attribute on the master row). Hard delete is never exposed.

**Use when:** An employee has left the company and should no longer appear in active rosters or default to new salary runs.

**Don't use for:** Reactivating later (PATCH `is_active=true` instead). Hard-deleting (not supported — retention).

**Pitfalls**
- Idempotent: deleting an already-inactive employee returns 204 No Content (the same as the first call).
- The row is NOT removed from the database — re-creating with the same personnummer returns 409 EMPLOYEE_DUPLICATE_PERSONNUMMER even after soft-delete.
- Past salary runs still reference this employee; their data continues to surface in GET /salary-runs/{id} and SIE exports.

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

**Example response**

```json
{
  "data": null
}
```

---

---

# Salary runs

> Payroll lifecycle — create, calculate, approve, mark paid, book, generate AGI XML.

## Endpoints

- [`GET` `/api/v1/companies/:companyId/salary-runs`](#get-salary-runs-list) — List salary runs.
- [`GET` `/api/v1/companies/:companyId/salary-runs/:id`](#get-salary-runs-get) — Get a salary run.
- [`POST` `/api/v1/companies/:companyId/salary-runs`](#post-salary-runs-create) — Create a salary run.
- [`POST` `/api/v1/companies/:companyId/salary-runs/:id/approve`](#post-salary-runs-approve) — Approve a reviewed salary run.
- [`POST` `/api/v1/companies/:companyId/salary-runs/:id/book`](#post-salary-runs-book) — Post the verifikationer for a paid salary run.
- [`POST` `/api/v1/companies/:companyId/salary-runs/:id/calculate`](#post-salary-runs-calculate) — Calculate a draft salary run and advance it to review.
- [`POST` `/api/v1/companies/:companyId/salary-runs/:id/generate-agi`](#post-salary-runs-generate-agi) — Generate the Skatteverket AGI XML for a salary run.
- [`POST` `/api/v1/companies/:companyId/salary-runs/:id/mark-paid`](#post-salary-runs-mark-paid) — Mark an approved salary run as paid.
- [`PATCH` `/api/v1/companies/:companyId/salary-runs/:id`](#patch-salary-runs-update) — Update a draft salary run.
- [`DELETE` `/api/v1/companies/:companyId/salary-runs/:id`](#delete-salary-runs-delete) — Delete a draft salary run.

---

### `GET` /api/v1/companies/:companyId/salary-runs {#get-salary-runs-list}

**`salary-runs.list`** · scope `payroll:read`

List salary runs.

Returns salary runs in created-first order with their lifecycle status (draft|review|approved|paid|booked|corrected) and denormalised totals. Filters: ?period_year=YYYY, ?status=draft.

**Use when:** You need an overview of payroll activity — for building a list view, finding the current open run, or resolving a salary_run_id before invoking a lifecycle verb.

**Don't use for:** Per-employee details (those live on the detail endpoint). Salary journal report (use GET /reports/salary-journal in Phase 5 PR-3).

**Pitfalls**
- A company has at most one salary run per (period_year, period_month). The unique constraint is at the DB layer.
- Totals are denormalised: they are 0 until POST /calculate runs.
- `corrected` status is reached via the internal /correct route (not yet exposed on v1) — Phase 5 PR-1 ships create/calculate/approve/mark-paid/book/generate-agi only.

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

**Example response**

```json
{
  "data": [
    {
      "id": "run_a8f1…",
      "period_year": 2026,
      "period_month": 5,
      "payment_date": "2026-05-25",
      "status": "draft",
      "voucher_series": "A",
      "total_gross": 0,
      "total_tax": 0,
      "total_net": 0,
      "total_avgifter": 0,
      "total_employer_cost": 0
    }
  ],
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12",
    "next_cursor": null
  }
}
```

---

### `GET` /api/v1/companies/:companyId/salary-runs/:id {#get-salary-runs-get}

**`salary-runs.get`** · scope `payroll:read`

Get a salary run.

Returns the salary run's lifecycle state, denormalised totals (gross/tax/net/avgifter/vacation/employer_cost), and references to the journal entries it produced (once :book has run).

**Use when:** You have a salary_run_id and need its current status — typically to decide which lifecycle verb to call next, or to display the run header in a UI.

**Don't use for:** Per-employee breakdown (Phase 5 PR-1 does not expose the per-employee endpoint on v1; use the internal /api/salary/runs/{id} for that today). Salary journal report — use GET /reports/salary-journal in Phase 5 PR-3.

**Pitfalls**
- salary_entry_id / avgifter_entry_id / vacation_entry_id are null until POST /book has run. They reference the journal_entries table.
- total_* fields are 0 until POST /calculate has run.

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

**Example response**

```json
{
  "data": {
    "id": "run_a8f1…",
    "period_year": 2026,
    "period_month": 5,
    "payment_date": "2026-05-25",
    "status": "approved",
    "total_gross": 105000,
    "total_tax": -28500,
    "total_net": 76500,
    "total_avgifter": 32991,
    "total_employer_cost": 137991
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/salary-runs {#post-salary-runs-create}

**`salary-runs.create`** · scope `payroll:write`

Create a salary run.

Creates a draft salary run for the given period (period_year, period_month). The run starts empty — add employees via the internal /salary/runs/{id}/employees endpoints, then POST /salary-runs/{id}/calculate. Requires Idempotency-Key. Dry-runnable.

**Use when:** You are starting a new month's payroll. Use dry-run first to validate the period + voucher_series choice without committing.

**Don't use for:** Adding employees to an existing run (that is a separate surface — see internal /salary/runs/{id}/employees for Phase 5 PR-1; promoting it to v1 is deferred to a follow-up).

**Pitfalls**
- Idempotency-Key is mandatory.
- Duplicate (period_year, period_month) for the same company returns 409 SALARY_RUN_DUPLICATE_PERIOD.
- period_month is 1–12. The DB CHECK enforces this — a 0 or 13 returns 400 VALIDATION_ERROR before reaching the DB.
- voucher_series defaults to "A". If the company uses a dedicated salary voucher series, set it explicitly.
- A newly-created run has no employees — :calculate without employees returns 400 SALARY_RUN_NO_EMPLOYEES.

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

**Example request**

```json
{
  "period_year": 2026,
  "period_month": 5,
  "payment_date": "2026-05-25",
  "voucher_series": "L"
}
```

**Example response**

```json
{
  "data": {
    "id": "run_a8f1…",
    "period_year": 2026,
    "period_month": 5,
    "payment_date": "2026-05-25",
    "status": "draft",
    "voucher_series": "L"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/salary-runs/:id/approve {#post-salary-runs-approve}

**`salary-runs.approve`** · scope `payroll:write`

Approve a reviewed salary run.

Advances a salary run from `review` to `approved` after validating every employee has the data required for the payment step (bank account + clearing number for the bank transfer) and the booking step (`calculation_breakdown` proves `:calculate` ran). Records the approving user + timestamp. Strict-mode: validation errors return a complete list rather than failing on the first one.

**Use when:** You have a salary run in `review` status and want to authorize it for payment. This is the human (or agent) signoff step before money moves; the verifikation is still pending and won't exist until `:book` runs.

**Don't use for:** Posting journal entries (use `:book` after `:mark-paid`). Reverting an approval (the lifecycle has no `:unapprove` — call `:correct` once the run is booked if you need to undo).

**Pitfalls**
- Run must be in `review` — non-`review` runs return 400 SALARY_RUN_APPROVE_NOT_REVIEW.
- Every employee on the run needs a `clearing_number` + `bank_account_number`. Missing bank details return 400 SALARY_RUN_APPROVE_VALIDATION_FAILED with the per-employee list.
- Every employee on the run needs `calculation_breakdown` populated. If you skipped `:calculate` somehow, approve fails.
- Employees without email get a non-blocking warning (lönebesked can't be sent automatically).
- No period-lock check here — that lives on `:book` where the verifikation is posted. An agent can approve a run whose payment date falls in a now-locked period; `:book` will later refuse.

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

**Example response**

```json
{
  "data": {
    "id": "run_a8f1…",
    "status": "approved",
    "approved_at": "2026-05-14T12:00:00Z",
    "approved_by": "user_b73c…",
    "warnings": [
      "Anna Andersson: E-post saknas — lönebesked kan inte skickas"
    ]
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/salary-runs/:id/book {#post-salary-runs-book}

**`salary-runs.book`** · scope `payroll:write`

Post the verifikationer for a paid salary run.

Creates 2–4 journal entries (1: salary brutto/tax/net; 2: arbetsgivaravgifter; 3 if applicable: semesterlöneskuld accrual; 4 if applicable: pension + SLP from löneväxling), then advances status `paid` → `booked` with all the entry IDs recorded on the salary_runs row. Strict-mode: any engine failure aborts BEFORE the status flip — the run stays in `paid` so the caller can fix the cause (locked period, missing BAS account, etc.) and retry.

**Use when:** You've marked a salary run as paid and want to post the BFL-required verifikationer. This is the final lifecycle verb before AGI generation; after :book, the run can no longer be edited and corrections must use the (forthcoming) `:correct` verb.

**Don't use for:** Posting salary entries outside the salary-run lifecycle (use POST /journal-entries directly). Re-booking an already-booked run (returns 400 SALARY_RUN_BOOK_NOT_PAID).

**Pitfalls**
- Run must be in `paid` — non-`paid` runs return 400 SALARY_RUN_BOOK_NOT_PAID.
- payment_date must fall in an open fiscal period — locked period returns 400 PERIOD_LOCKED with `fiscal_period_id` and a hint of what unlock action is needed.
- BFL 5 kap immutability: once `:book` succeeds the verifikationer cannot be edited or deleted. Corrections require `:correct` (Phase 5 PR-3) which does a storno-then-rebook.
- The salary verifikation is the primary one; its voucher_number appears in the response audit block. The avgifter, vacation, and pension entries get separate voucher numbers (returned as `entry_ids`).
- Strict-mode: if the engine fails partway, the salary_runs row stays in `paid`. There is no "partial booking" — the engine either commits all entries or the entire booking fails.

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

**Example response**

```json
{
  "data": {
    "id": "run_a8f1…",
    "status": "booked",
    "booked_at": "2026-05-26T09:15:00Z",
    "booked_by": "user_b73c…",
    "salary_entry_id": "je_salary…",
    "avgifter_entry_id": "je_avg…",
    "vacation_entry_id": "je_vac…",
    "pension_entry_id": null,
    "entry_ids": [
      "je_salary…",
      "je_avg…",
      "je_vac…"
    ]
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12",
    "audit": {
      "voucher_number": "L2026-0023",
      "voucher_url": "/api/v1/companies/.../journal-entries/je_salary…",
      "immutable_at": "2026-05-26T09:15:00Z"
    }
  }
}
```

---

### `POST` /api/v1/companies/:companyId/salary-runs/:id/calculate {#post-salary-runs-calculate}

**`salary-runs.calculate`** · scope `payroll:write`

Calculate a draft salary run and advance it to review.

Runs the per-employee payroll calculation (tax withholding, employer contributions, vacation accrual) for every employee on a draft run, persists the line items + run totals + calculation_params snapshot, then promotes status from draft to review in a single atomic verb. Returns the updated run plus a `warnings` array surfacing non-blocking issues (Skatteverket tax-table fallback, läkarintyg day-8 transition, Försäkringskassan day-15 transition, F-skatt not-verified employees). Strict-mode: any failure (validation, tax-table unavailable, DB error) aborts before the status flip — the run stays in draft.

**Use when:** You have a draft salary run with employees added and want to compute the numbers + freeze them for approval. This is the first lifecycle verb after creating a run.

**Don't use for:** re-running a salary run already in review or later (only `draft` is accepted — call POST :correct in Phase 5 PR-3 once that ships to revise a booked run). Adding employees to the run (that surface is not yet on v1; use the dashboard).

**Pitfalls**
- Run must be in `draft` status — calculate on a non-draft run returns 400 SALARY_RUN_CALCULATE_NOT_DRAFT.
- Salary run must have at least one employee — empty runs return 400 SALARY_RUN_NO_EMPLOYEES.
- If Skatteverket's tax-table API is down and local fallback is missing the required table, calculate returns 503 SALARY_RUN_TAX_TABLE_MISSING. Retry is safe; the operation is idempotent at the helper level.
- F-skatt "not_verified" employees produce a non-blocking warning; an integrator should treat the warning as a hard signal that withholding will be wrong until F-skatt is verified.
- Warnings about tax-table fallback or läkarintyg / FK day-15 transitions are non-blocking; the run still advances to review. Surface them to a human reviewer before calling :approve.

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

**Example response**

```json
{
  "data": {
    "id": "run_a8f1…",
    "status": "review",
    "period_year": 2026,
    "period_month": 5,
    "total_gross": 105000,
    "total_tax": 28500,
    "total_net": 76500,
    "total_avgifter": 32991,
    "total_employer_cost": 137991,
    "warnings": [
      "Läkarintyg krävs från och med dag 8: Anna Andersson. Kontrollera att läkarintyg finns innan lönekörningen godkänns."
    ]
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/salary-runs/:id/generate-agi {#post-salary-runs-generate-agi}

**`salary-runs.generate-agi`** · scope `payroll:write`

Generate the Skatteverket AGI XML for a salary run.

Generates the arbetsgivardeklaration-på-individnivå XML for the run (HU section + per-employee IU + Frånvarouppgift for VAB/parental), upserts the agi_declarations row (correction-aware), stamps salary_runs.agi_generated_at, emits `agi.generated`, and auto-completes the `arbetsgivardeklaration` deadline. Returns the XML as a string field in the v1 envelope — agents extract `data.xml` and forward to Skatteverket directly (Mina Sidor upload or via a connected extension).

**Use when:** You've reviewed (or approved / paid / booked) a salary run and need to file AGI with Skatteverket. The Skatteverket filing deadline is the 12th of the following month (17th in Jan / Aug for companies ≤40 MSEK turnover).

**Don't use for:** Submitting the AGI to Skatteverket — this endpoint only generates and persists the XML. Submission is a separate flow via the (optional) `skatteverket` extension.

**Pitfalls**
- Run status must be one of review, approved, paid, booked, corrected — `draft` returns 400 AGI_GENERATE_NOT_BOOKABLE.
- Generating AGI from a `review`-status run risks submitting figures that will change at `:approve`. The dashboard allows this for flexibility; agents should prefer `approved+` unless an early-warning workflow specifically wants the preview.
- Subsequent calls for the same period UPDATE the agi_declarations row (is_correction=true) and overwrite the XML. The FK570 specifikationsnummer stays consistent per employee — different number = new record per Skatteverket spec.
- AGI_INCOMPLETE_DATA returns 400 when company contact info is missing (org_number, contact name, phone, email). Fix via /settings/company before retrying.
- The XML content is räkenskapsinformation — BFL 7 kap retention applies. The agi_declarations row is never auto-deleted.

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

**Example response**

```json
{
  "data": {
    "agi_declaration_id": "agi_a8f1…",
    "period_year": 2026,
    "period_month": 5,
    "employee_count": 3,
    "is_correction": false,
    "totals": {
      "totalTax": 28500,
      "totalAvgifterBasis": 105000,
      "totalAvgifterAmount": 32991,
      "totalSjuklonekostnad": 0,
      "avgifterByCategory": {
        "standard": {
          "basis": 105000,
          "amount": 32991
        }
      }
    },
    "xml": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Skatteverket omrade=\"Arbetsgivardeklaration\">…</Skatteverket>",
    "xml_filename": "AGI_5566778899_202605.xml"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/salary-runs/:id/mark-paid {#post-salary-runs-mark-paid}

**`salary-runs.mark-paid`** · scope `payroll:write`

Mark an approved salary run as paid.

Advances a salary run from `approved` to `paid` and stamps `paid_at`. This is the state-change verb after the bank transfer (or autogiro file) has been processed; it does NOT initiate payment, and does NOT post journal entries (use `:book` after this for that).

**Use when:** You've confirmed the salary payment hit employee bank accounts and want to advance the run's lifecycle so `:book` can post the verifikation.

**Don't use for:** Initiating the actual bank transfer (the v1 API does not yet expose payment-file generation; use the dashboard's payment-file endpoints). Posting journal entries (use `:book`). Reverting a paid run (no `:unpaid` exists — call `:correct` once booked if you need to undo).

**Pitfalls**
- Run must be in `approved` — non-`approved` runs return 400 SALARY_RUN_MARK_PAID_NOT_APPROVED.
- paid_at is set server-side to the current UTC timestamp; the API does not accept a body-supplied date to keep BFL audit clean.

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

**Example response**

```json
{
  "data": {
    "id": "run_a8f1…",
    "status": "paid",
    "paid_at": "2026-05-25T08:00:00Z"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `PATCH` /api/v1/companies/:companyId/salary-runs/:id {#patch-salary-runs-update}

**`salary-runs.update`** · scope `payroll:write`

Update a draft salary run.

Updates payment_date, voucher_series, or notes on a draft salary run. ONLY allowed when status === "draft" — once :calculate has advanced the run to review, these fields are frozen because they feed into the verifikation that :book will eventually post.

**Use when:** You created a draft, then noticed payment_date should be different (e.g. moved from the 25th to the 23rd) before running :calculate.

**Don't use for:** Changing period_year / period_month (immutable — DELETE the draft and create a new one). Modifying employees in the run (not in v1 PR-1 scope).

**Pitfalls**
- Returns 400 SALARY_RUN_PATCH_NOT_DRAFT if status !== "draft".
- period_year + period_month are immutable post-create.

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

**Example request**

```json
{
  "payment_date": "2026-05-23"
}
```

**Example response**

```json
{
  "data": {
    "id": "run_…",
    "payment_date": "2026-05-23",
    "status": "draft"
  }
}
```

---

### `DELETE` /api/v1/companies/:companyId/salary-runs/:id {#delete-salary-runs-delete}

**`salary-runs.delete`** · scope `payroll:write`

Delete a draft salary run.

Hard-deletes a salary run. ONLY allowed when status === "draft" — once the run has calculated numbers or posted a verifikation, BFL 5 kap immutability applies and storno is the only correction path. CASCADE deletes salary_run_employees and salary_line_items.

**Use when:** You created a run by mistake or want to recreate it with different period_month. Only draft runs can be deleted.

**Don't use for:** Reverting a booked run (use the internal /correct flow; v1 promotion deferred). Hiding a run from listings (no soft-delete on this table — drafts are truly removed).

**Pitfalls**
- Returns 400 SALARY_RUN_DELETE_NOT_DRAFT for any status other than draft.
- Hard delete: the salary_run_employees + salary_line_items rows cascade away.
- Idempotent in the absent-row sense: DELETE on a non-existent id returns 404 SALARY_RUN_NOT_FOUND rather than re-emitting a deletion event.

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

**Example response**

```json
{
  "data": null
}
```

---

---

# Reports

> Read-only reports — trial balance, P&L, balance sheet, GL, VAT, salary journal, SIE export, +9 more.

## Endpoints

- [`GET` `/api/v1/companies/:companyId/reports/ar-ledger`](#get-reports-ar-ledger) — AR ledger — unpaid customer invoices with aging.
- [`GET` `/api/v1/companies/:companyId/reports/avgifter-basis`](#get-reports-avgifter-basis) — Annual arbetsgivaravgifter basis per employee.
- [`GET` `/api/v1/companies/:companyId/reports/balance-sheet`](#get-reports-balance-sheet) — Balance sheet (balansräkning) for a fiscal period.
- [`GET` `/api/v1/companies/:companyId/reports/continuity-check`](#get-reports-continuity-check) — IB/UB continuity check — opening balances match prior closing.
- [`GET` `/api/v1/companies/:companyId/reports/general-ledger`](#get-reports-general-ledger) — General ledger (huvudbok) for a fiscal period.
- [`GET` `/api/v1/companies/:companyId/reports/income-statement`](#get-reports-income-statement) — Income statement (resultatrapport) for a fiscal period.
- [`GET` `/api/v1/companies/:companyId/reports/journal-register`](#get-reports-journal-register) — Journal register (verifikationsregister) for a fiscal period.
- [`GET` `/api/v1/companies/:companyId/reports/monthly-breakdown`](#get-reports-monthly-breakdown) — Income statement broken down by month for a fiscal period.
- [`GET` `/api/v1/companies/:companyId/reports/salary-journal`](#get-reports-salary-journal) — Salary journal (lönejournal) for a year and optional month range.
- [`GET` `/api/v1/companies/:companyId/reports/sie-export`](#get-reports-sie-export) — SIE4 export (.se file) for a fiscal period.
- [`GET` `/api/v1/companies/:companyId/reports/supplier-ledger`](#get-reports-supplier-ledger) — Supplier ledger — unpaid supplier invoices with aging.
- [`GET` `/api/v1/companies/:companyId/reports/trial-balance`](#get-reports-trial-balance) — Trial balance (huvudboksrapport) for a fiscal period.
- [`GET` `/api/v1/companies/:companyId/reports/vacation-liability`](#get-reports-vacation-liability) — Vacation liability (semesterlöneskuld) per employee at year-end.
- [`GET` `/api/v1/companies/:companyId/reports/vat-declaration`](#get-reports-vat-declaration) — Swedish VAT declaration (momsdeklaration) for a period.

---

### `GET` /api/v1/companies/:companyId/reports/ar-ledger {#get-reports-ar-ledger}

**`reports.ar-ledger`** · scope `reports:read`

AR ledger — unpaid customer invoices with aging.

Returns the customer-receivable ledger as of `as_of_date` (defaults to today). Each customer entry includes outstanding invoices grouped into aging buckets (0–30, 31–60, 61–90, 90+ days). Reconciles against BAS 1510.

**Use when:** Cash collection dashboards, dunning workflows, end-of-period reconciliation against the 1510 trial-balance figure.

**Don't use for:** Listing all invoices regardless of status (use /invoices). Sending dunning emails (the v1 surface does not yet expose dunning).

**Pitfalls**
- `as_of_date` is optional; format `YYYY-MM-DD`. Defaults to today (UTC).
- Only invoices in `sent`/`overdue`/`partially_paid` status appear. Drafts and credited invoices are excluded.

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

**Example response**

```json
{
  "data": {
    "as_of_date": "2026-05-31",
    "customers": [],
    "totals": {}
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/reports/avgifter-basis {#get-reports-avgifter-basis}

**`reports.avgifter-basis`** · scope `payroll:read`

Annual arbetsgivaravgifter basis per employee.

Returns the annual avgifter basis per employee for `year`, summed across booked salary runs. Each row shows the basis, applied rate, and computed avgifter amount — useful for reconciling against monthly AGI filings (HU sum across the year).

**Use when:** Annual reconciliation between the AGI declarations and the bookkeeping (BAS 7510). Year-end audit prep.

**Don't use for:** Real-time AGI generation (POST /salary-runs/{id}/generate-agi). Per-run breakdown (use /reports/salary-journal).

**Pitfalls**
- `year` is required.
- Only `booked` runs are included.

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

**Example response**

```json
{
  "data": {
    "year": 2026,
    "employees": [],
    "totals": {}
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/reports/balance-sheet {#get-reports-balance-sheet}

**`reports.balance-sheet`** · scope `reports:read`

Balance sheet (balansräkning) for a fiscal period.

Returns assets / liabilities / equity grouped into BAS sections, with the period's opening and closing balances. Sums match the income statement for the same period; the closing equity flows into next period's opening balance.

**Use when:** You need the company's balance position at period end — typically for management reporting, year-end review, or the K2/K3 årsredovisning uppställningsform.

**Don't use for:** Per-account drill-down (use /reports/general-ledger). Net result for the period (use /reports/income-statement).

**Pitfalls**
- `period_id` is required.
- Balance sheet equity includes the period's computed result — recalculation happens on every call, so a freshly-posted entry is reflected immediately (no caching).

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

**Example response**

```json
{
  "data": {
    "period": {
      "start": "2026-01-01",
      "end": "2026-12-31"
    },
    "sections": [],
    "totals": {}
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/reports/continuity-check {#get-reports-continuity-check}

**`reports.continuity-check`** · scope `reports:read`

IB/UB continuity check — opening balances match prior closing.

Validates that the target period's opening balances (IB) equal the prior period's closing balances (UB). The requirement derives from BFL 5 kap (löpande bokföring), BFNAR 2013:2 (systemdokumentation/behandlingshistorik), and the SIE4 spec's core invariant that #IB(year N) must equal #UB(year N-1). Returns per-account discrepancies so an operator can rectify them before period close.

**Use when:** Before locking or closing a period, or as part of an automated year-end readiness gate. Any discrepancy is a hard data-integrity issue.

**Don't use for:** Computing balances (use /reports/balance-sheet or /reports/trial-balance). Closing the period (POST /fiscal-periods/{id}/close).

**Pitfalls**
- `period_id` is required.
- A non-zero discrepancy means IB ≠ prior UB and indicates the opening-balance entry was edited or the prior period was changed after close. Investigate before posting any new entries.

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

**Example response**

```json
{
  "data": {
    "is_continuous": true,
    "discrepancies": []
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/reports/general-ledger {#get-reports-general-ledger}

**`reports.general-ledger`** · scope `reports:read`

General ledger (huvudbok) for a fiscal period.

Returns every posted journal line in the period grouped by account, with opening / running / closing balances. Supports optional `account_from` and `account_to` query parameters to limit the report to an account range (e.g. ?account_from=3000&account_to=3999 for revenue-only).

**Use when:** You're reconciling a specific account or range — bank account drilldown, revenue audit, expense investigation — and need every voucher-line that hit the account.

**Don't use for:** Period totals only (use /reports/trial-balance). Specific transaction lookup (use /journal-entries/{id}).

**Pitfalls**
- `period_id` is required.
- Account ranges are inclusive on both bounds. `account_from=3000` includes 3000; `account_to=3999` includes 3999.
- Lines with `status != 'posted'` (drafts, reversed) are excluded.

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

**Example response**

```json
{
  "data": {
    "period": {},
    "accounts": []
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/reports/income-statement {#get-reports-income-statement}

**`reports.income-statement`** · scope `reports:read`

Income statement (resultatrapport) for a fiscal period.

Returns the period's revenue and expenses grouped by BAS class with subtotals (gross margin, operating result, net result). The net result flows into the balance-sheet equity for the same period.

**Use when:** You need the company's profit/loss for a period — month-end management reporting, K2/K3 årsredovisning resultaträkning, or feeding KPI dashboards.

**Don't use for:** Per-account drill (use /reports/general-ledger). VAT figures (use /reports/vat-declaration). Balance position (use /reports/balance-sheet).

**Pitfalls**
- `period_id` is required.
- Net result on the income statement equals the period's equity-line delta on the balance sheet — they're derived from the same posted entries.

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

**Example response**

```json
{
  "data": {
    "period": {
      "start": "…",
      "end": "…"
    },
    "sections": [],
    "grossMargin": 0,
    "netResult": 0
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/reports/journal-register {#get-reports-journal-register}

**`reports.journal-register`** · scope `reports:read`

Journal register (verifikationsregister) for a fiscal period.

Returns every committed journal entry in the period with its voucher number, date, description, and complete debit/credit line set. The canonical compliance report — what an accountant or Skatteverket audit would pull as proof of every booking.

**Use when:** You need the BFL-required register of all verifikationer for a period — typically for an audit, year-end review, or feeding an external accountant's tooling.

**Don't use for:** Per-account drilldown (use /reports/general-ledger). Aggregate totals only (use /reports/trial-balance).

**Pitfalls**
- `period_id` is required.
- Output includes every line of every entry — large periods produce large responses. Consider paginating client-side or filtering by date range via /journal-entries list if you only need a slice.
- Reversed entries appear with status `reversed`; the original they reversed also remains.

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

**Example response**

```json
{
  "data": {
    "period": {},
    "entries": []
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/reports/monthly-breakdown {#get-reports-monthly-breakdown}

**`reports.monthly-breakdown`** · scope `reports:read`

Income statement broken down by month for a fiscal period.

Returns revenue + expenses + net result per calendar month inside the fiscal period. The sum across all months equals the period's full income-statement totals.

**Use when:** Building a trend chart, computing rolling KPIs, or producing a månadsrapport for management.

**Don't use for:** Single-month snapshot only (call /reports/income-statement with a month-sized period). Cash flow analysis (a dedicated cash-flow report is not yet on v1).

**Pitfalls**
- `period_id` is required.

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

**Example response**

```json
{
  "data": {
    "period": {},
    "months": []
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/reports/salary-journal {#get-reports-salary-journal}

**`reports.salary-journal`** · scope `payroll:read`

Salary journal (lönejournal) for a year and optional month range.

Returns per-employee salary figures (gross / tax / net / avgifter / vacation accrual) summed across booked salary runs in `year`. Optional `month_from` and `month_to` limit the window. The output mirrors the dashboard's lönejournal export. ⚠️ KU (kontrolluppgift) preparation requires the FULL annual paid amount per employee — if any salary runs are in paid-but-unbooked state at KU time, generating KU from this report will understate wages (an SFL obligation breach). Confirm all paid runs are booked before using this report for KU.

**Use when:** Year-end KU preparation, employee comp reviews, reconciliation against the 7xxx wage accounts.

**Don't use for:** Per-run drill-down (use /salary-runs/{id} once the per-employee endpoint ships). AGI declarations (POST /salary-runs/{id}/generate-agi).

**Pitfalls**
- `year` is required (integer 2020-2100).
- Only `booked` salary runs are included — `draft`/`review`/`approved`/`paid` runs are excluded as they aren't legally final.
- `paid`-but-unbooked runs are EXCLUDED. This means the report reconciles cleanly against BAS 7xxx (the ledger), but an AGI-vs-ledger cross-check will show a gap until the run is booked. The AGI is filed at `approved`/`paid` (Phase 5 PR-2 allows it from `review`), so reconciling AGI against this report requires waiting until every paid run is also booked.
- month_from/month_to are 1–12 inclusive.

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

**Example response**

```json
{
  "data": {
    "year": 2026,
    "employees": [],
    "totals": {}
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/reports/sie-export {#get-reports-sie-export}

**`reports.sie-export`** · scope `reports:read`

SIE4 export (.se file) for a fiscal period.

Returns the period's SIE4 export as text/plain UTF-8. Includes #FNAMN / #ORGNR header, #KONTO chart, #IB/#UB opening + closing balances, #RES result-account totals, and every #VER + #TRANS verifikation in the period. The byte stream matches what the dashboard's `/api/reports/sie-export` produces.

**Use when:** Year-end accountant handoff, migration to another bookkeeping system, audit archival, BFL 7 kap räkenskapsinformation backup.

**Don't use for:** JSON drilldown of period entries (use /reports/journal-register). Full archive including documents (use /reports/full-archive — not yet on v1).

**Pitfalls**
- `period_id` is required.
- The response is text/plain with Content-Disposition: attachment — clients should treat as a binary download. Filename uses the pattern `export_{period_id}.se`.
- Encoding is UTF-8 (modern systems accept it; some legacy Swedish bookkeeping software still expects CP437/Latin-1 — convert client-side if needed).
- Only `posted` entries are exported; drafts and reversed entries' originals are included but marked accordingly.

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

**Example response**

```json
{
  "_note": "Returns text/plain SIE4 content as binary download."
}
```

---

### `GET` /api/v1/companies/:companyId/reports/supplier-ledger {#get-reports-supplier-ledger}

**`reports.supplier-ledger`** · scope `reports:read`

Supplier ledger — unpaid supplier invoices with aging.

Returns the supplier-payable ledger as of `as_of_date` (defaults to today). Each supplier entry includes outstanding invoices grouped into aging buckets. Reconciles against BAS 2440.

**Use when:** AP workflow dashboards, due-date prioritisation, reconciliation against the 2440 trial-balance figure.

**Don't use for:** Listing all supplier invoices regardless of status (use /supplier-invoices). Initiating payment (the v1 surface does not expose payment files yet).

**Pitfalls**
- `as_of_date` is optional; format `YYYY-MM-DD`. Defaults to today (UTC).
- Only invoices with outstanding `remaining_amount > 0` appear. Credited and fully-paid invoices are excluded.

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

**Example response**

```json
{
  "data": {
    "as_of_date": "2026-05-31",
    "suppliers": [],
    "totals": {}
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/reports/trial-balance {#get-reports-trial-balance}

**`reports.trial-balance`** · scope `reports:read`

Trial balance (huvudboksrapport) for a fiscal period.

Returns the per-account opening balance + period debit/credit + closing balance plus run-level totals and an `isBalanced` flag. The numbers come from the same `lib/reports/trial-balance.ts` generator the dashboard uses.

**Use when:** You need a snapshot of every active account's movement during a period — typically the first report an accountant checks before running balance sheet or income statement.

**Don't use for:** Reconciliation against AR/AP (use /reports/ar-ledger or /supplier-ledger). Specific account drill-in (use /reports/general-ledger with account_from/account_to filters).

**Pitfalls**
- `period_id` is required as a query parameter.
- `isBalanced=false` means the period has unbalanced postings — a data-integrity red flag. The lib generator rounds at the source so a true imbalance is rare; investigate immediately.
- Closed/locked periods are still queryable — the report is read-only.

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

**Example response**

```json
{
  "data": {
    "rows": [
      {
        "account": "1930",
        "account_name": "Företagskonto",
        "opening_balance": 100000,
        "period_debit": 25000,
        "period_credit": 18000,
        "closing_balance": 107000
      }
    ],
    "totalDebit": 25000,
    "totalCredit": 25000,
    "isBalanced": true
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/reports/vacation-liability {#get-reports-vacation-liability}

**`reports.vacation-liability`** · scope `payroll:read`

Vacation liability (semesterlöneskuld) per employee at year-end.

Returns per-employee semesterlöneskuld balances as of year-end based on their vacation_rule (procentregeln / sammaloneregeln) and accrued days. For employees on procentregeln or sammaloneregeln the row total contributes to the BAS 2920 closing balance. Employees on `none` or `semesterersattning` are excluded because their cost is expensed immediately (no balance-sheet accrual) — the BAS 2920 reconciliation against this report is therefore CORRECT whether or not the company has semesterersättning employees, since those employees contribute zero to both the report and the 2920 balance. Feeds the K2/K3 årsredovisning notes.

**Use when:** Year-end reconciliation between the accrued liability on 2920 and the per-employee detail. Audit prep.

**Don't use for:** Real-time accrual posting (handled per salary run). Vacation request management (not in scope for v1).

**Pitfalls**
- `year` is required.
- Employees with vacation_rule = none or semesterersattning are excluded — they have no semesterlöneskuld liability.

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

**Example response**

```json
{
  "data": {
    "year": 2026,
    "employees": [],
    "total_liability": 0
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/reports/vat-declaration {#get-reports-vat-declaration}

**`reports.vat-declaration`** · scope `reports:read`

Swedish VAT declaration (momsdeklaration) for a period.

Computes momsdeklaration rutor for the given period_type / year / period. The result includes ruta 05 (domestic taxable sales), 10-12 (output VAT 25/12/6%), 20-24 (EU acquisitions of goods + tax on services from EU/non-EU), 30-32 (reverse-charge output VAT 25/12/6%), 39 (export), 40 (EU-services / momsfri försäljning), 48 (input VAT), 50 (import beskattningsunderlag), 60-62 (calculated output VAT on imports 25/12/6%), and 49 (moms att betala/återfå — the bottom line). Mapping rules match SKV 4700.

**Use when:** Submitting momsdeklaration to Skatteverket, reconciling VAT balances at month/quarter end, or building a VAT-payable dashboard.

**Don't use for:** Specific transaction VAT lookups (use /transactions/{id}). Period-mismatch reconciliation (use /reports/general-ledger filtered to 26xx accounts).

**Pitfalls**
- `period_type` (monthly|quarterly|yearly), `year`, and `period` are all required.
- For monthly: period is 1-12. For quarterly: period is 1-4. For yearly: period is 1.
- `accounting_method` defaults to accrual (faktureringsmetoden); pass cash for kontantmetoden to honor the VAT-on-payment rule per ML 15 kap 8–11 §§ (ML 2023:200, which replaced ML 1994:200 on 1 July 2023 — the prior ML 13 kap reference is outdated).
- Output ruta 49 = (10+11+12+30+31+32+60+61+62) − 48. Positive = pay; negative = refund.

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

**Example response**

```json
{
  "data": {
    "period_type": "monthly",
    "year": 2026,
    "period": 4,
    "rutor": {
      "ruta05": 0,
      "ruta10": 0,
      "ruta11": 0,
      "ruta12": 0,
      "ruta20": 0,
      "ruta21": 0,
      "ruta22": 0,
      "ruta23": 0,
      "ruta24": 0,
      "ruta30": 0,
      "ruta31": 0,
      "ruta32": 0,
      "ruta39": 0,
      "ruta40": 0,
      "ruta48": 0,
      "ruta50": 0,
      "ruta60": 0,
      "ruta61": 0,
      "ruta62": 0,
      "ruta49": 0
    }
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

---

# Imports

> Bulk async ingest — SIE files (Fortnox/Visma/BL/SpeedLedger/Bokio migrations) and bank statements (11 formats).

## Endpoints

- [`POST` `/api/v1/companies/:companyId/imports/bank`](#post-imports-bank) — Import a bank-file (CSV / XML / CAMT053).
- [`POST` `/api/v1/companies/:companyId/imports/sie`](#post-imports-sie) — Import a SIE4 file.

---

### `POST` /api/v1/companies/:companyId/imports/bank {#post-imports-bank}

**`imports.bank`** · scope `transactions:write`

Import a bank-file (CSV / XML / CAMT053).

Accepts a bank statement file (UTF-8 / Windows-1252, up to 10 MB) as multipart/form-data. Auto-detects the bank format (SEB, Swedbank, Handelsbanken, Nordea, Nordea Business, Lansforsakringar, Lunar, ICA Banken, Skandia, CAMT053, generic CSV) or honors a `format` override. Parses transactions, ingests them into the `transactions` table (NOT into journal entries — see BFL note in pitfalls), and emits `transaction.synced` events. Returns operation_id for polling.

**Use when:** Importing a bank statement export for a period. Common with PSD2 bank connections that don't auto-sync, or for legacy bank accounts.

**Don't use for:** SIE bookkeeping import (use /imports/sie). Auto-bank sync (use the enable-banking extension). Single-transaction creation (use POST /transactions/ingest with a 1-element array).

**Pitfalls**
- File size cap: 10 MB. Larger files require splitting client-side.
- `format` query parameter is optional; auto-detection works for all supported banks. Pass `format` only to force a specific format. Accepted values: seb, swedbank, handelsbanken, nordea, nordea_business, lansforsakringar, ica_banken, skandia, lunar, generic_csv, camt053.
- Duplicate detection is by external_id (composed from date + amount + counterparty); a re-import of the same file with the same flag set typically deduplicates rather than creating doubles.
- BFL 5 kap 6-7 §§ note: this endpoint creates `transactions` rows (the underlag for a verifikation), NOT verifikationer themselves. The verifikation content requirements are in BFL 5 kap 6-7 §§; until each transaction is matched to an invoice/supplier-invoice (POST /transactions/{id}/match-*) or categorised (POST /transactions/{id}/categorize), the bookkeeping obligation isn't discharged. A successful import here means the data is ingested — not booked.
- A successful import returns operation_id; poll /operations/{id} for the final ingested/duplicates/errors counts.

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

**Example response**

```json
{
  "data": {
    "operation_id": "op_a8f1…",
    "type": "import.bank",
    "status": "queued",
    "poll_url": "/api/v1/operations/op_a8f1…",
    "webhook_event": "operation.completed"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/imports/sie {#post-imports-sie}

**`imports.sie`** · scope `bookkeeping:write`

Import a SIE4 file.

Accepts a SIE4 file (CP437 / Windows-1252 / UTF-8 auto-detected, up to 50 MB) as the request body, parses it, checks for duplicate imports by file-hash, and replays every #VER + #TRANS into the company's bookkeeping. Returns an `operation_id` immediately — poll `GET /api/v1/operations/{id}` for status + final result. The byte-equivalent dashboard route at /api/import/sie/execute backs the same lib helper, so a SIE imported via v1 matches what the dashboard would produce.

**Use when:** Migrating bookkeeping data from another system (Fortnox, Bokio, Visma) into gnubok, restoring from a backup .se file, or recreating a period from an archive.

**Don't use for:** Bank transaction CSV/XML imports (use POST /imports/bank). Single-voucher creation (use POST /journal-entries). Importing into a period that already has posted entries — SIE imports run on a fresh period.

**Pitfalls**
- Body content-type must be multipart/form-data with a `file` field carrying the .se / .sie file (or a JSON body with `file_base64` for agents that can't do multipart).
- File size cap: 50 MB. Larger files require chunking client-side or a future streaming import endpoint.
- Duplicate-file detection is by SHA-256 hash — re-importing the same file returns 409 SIE_IMPORT_DUPLICATE without re-running the import.
- The operation can take 1–5 minutes for multi-year files. The HTTP response returns immediately with operation_id; poll /operations/{id} every ~2s for status.
- BFL 7 kap räkenskapsinformation: once a SIE import completes, the resulting verifikationer are immutable. Cancellation midway is not supported.

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

**Example response**

```json
{
  "data": {
    "operation_id": "op_a8f1…",
    "type": "import.sie",
    "status": "queued",
    "poll_url": "/api/v1/operations/op_a8f1…",
    "webhook_event": "operation.completed"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

---

# Compliance check

> Pre-flight verification — voucher gaps, year-end readiness, before submitting to Skatteverket.

## Endpoints

- [`GET` `/api/v1/companies/:companyId/compliance/check`](#get-compliance-check) — Run a structured compliance pre-flight check.

---

### `GET` /api/v1/companies/:companyId/compliance/check {#get-compliance-check}

**`compliance.check`** · scope `compliance:read`

Run a structured compliance pre-flight check.

Generalised pre-flight that consolidates the gnubok pre-close validators under one envelope. Supported check types: year_end_readiness (BFNAR 2017:3 + ÅRL 2:1 blockers), voucher_gaps (BFNAR 2013:2 kap 8 § series continuity). vat_close is planned for a follow-up PR (the underlying function currently lives in the MCP extension and core routes cannot import from extensions; it will be extracted into lib/reports/ then exposed here). New types can be added without changing the response shape.

**Use when:** Before committing to an irreversible action (VAT close, year-end close), or as a periodic audit sweep to surface blockers before they become urgent.

**Don't use for:** Executing the underlying action — this is read-only. After a passing check, call the corresponding async endpoint (POST /fiscal-periods/{id}/year-end, etc).

**Pitfalls**
- year_end_readiness and voucher_gaps require fiscal_period_id (UUID).
- A passing check is a SNAPSHOT — the state can change between the check and the action. The same blocker logic runs again on commit.
- vat_close is documented in the plan but NOT yet supported by this endpoint — call gnubok_vat_close_check via the MCP server until the function is extracted into lib/reports/.

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

**Example response**

```json
{
  "data": {
    "type": "year_end_readiness",
    "ready": false,
    "findings": [
      {
        "severity": "blocker",
        "code": "YEAR_END_DRAFTS_PRESENT",
        "message": "3 draft journal entries must be committed or cancelled before year-end.",
        "details": {
          "draft_count": 3
        }
      }
    ],
    "summary": "Period is NOT ready (1 blocker(s)).",
    "generated_at": "2026-05-12T14:00:00Z",
    "params": {
      "fiscal_period_id": "a8f1…"
    }
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

---

# Webhooks

> Subscribe to events with HMAC-signed delivery, exponential retries, and dead-letter replay.

## Endpoints

- [`GET` `/api/v1/companies/:companyId/webhooks`](#get-webhooks-list) — List webhook subscriptions for a company.
- [`GET` `/api/v1/companies/:companyId/webhooks/:id`](#get-webhooks-get) — Get a webhook subscription by id.
- [`GET` `/api/v1/companies/:companyId/webhooks/:id/deliveries`](#get-webhooks-deliveries-list) — List deliveries for a webhook subscription.
- [`POST` `/api/v1/companies/:companyId/webhooks`](#post-webhooks-create) — Register a webhook subscription.
- [`POST` `/api/v1/companies/:companyId/webhooks/:id/rotate-secret`](#post-webhooks-rotate_secret) — Rotate the HMAC signing secret on a webhook.
- [`POST` `/api/v1/companies/:companyId/webhooks/:id/test`](#post-webhooks-test) — Send a synthetic test event to a webhook.
- [`POST` `/api/v1/webhook-deliveries/:id/retry`](#post-webhook_deliveries-retry) — Retry a webhook delivery.
- [`PATCH` `/api/v1/companies/:companyId/webhooks/:id`](#patch-webhooks-update) — Update a webhook subscription.
- [`DELETE` `/api/v1/companies/:companyId/webhooks/:id`](#delete-webhooks-delete) — Delete a webhook subscription.

---

### `GET` /api/v1/companies/:companyId/webhooks {#get-webhooks-list}

**`webhooks.list`** · scope `webhooks:manage`

List webhook subscriptions for a company.

Returns all webhook subscriptions for the company. The HMAC signing secret is never exposed by this endpoint — it is returned exactly once when the webhook is created.

**Use when:** You need to enumerate the webhook subscriptions an integration has registered, e.g. to build a UI listing or sync state with an external system.

**Don't use for:** Reading delivery history (use GET /webhooks/{id}/deliveries). Reading the secret (it is unrecoverable after the create response — generate a new webhook if lost).

**Pitfalls**
- Disabled webhooks (auto-disabled after HTTP 410, or manually disabled via PATCH) appear in the list with active=false and a disabled_reason.

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

**Example response**

```json
{
  "data": {
    "webhooks": [
      {
        "id": "a8f1…",
        "name": "CRM sync",
        "event_type": "invoice.paid",
        "webhook_url": "https://example.com/hooks/gnubok",
        "active": true,
        "api_version_pinned": "2026-05-12",
        "disabled_at": null,
        "disabled_reason": null,
        "created_at": "2026-05-15T12:00:00Z"
      }
    ]
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/webhooks/:id {#get-webhooks-get}

**`webhooks.get`** · scope `webhooks:manage`

Get a webhook subscription by id.

Returns the webhook configuration. The HMAC signing secret is never exposed.

**Use when:** You need the current state of a single webhook (e.g. to render a settings page).

**Don't use for:** Reading the secret (returned only once on creation).

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

**Example response**

```json
{
  "data": {
    "id": "a8f1…",
    "name": "CRM sync",
    "description": null,
    "event_type": "invoice.paid",
    "webhook_url": "https://example.com/hooks/gnubok",
    "active": true,
    "api_version_pinned": "2026-05-12",
    "disabled_at": null,
    "disabled_reason": null,
    "created_at": "2026-05-15T12:00:00Z",
    "updated_at": "2026-05-15T12:00:00Z"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/webhooks/:id/deliveries {#get-webhooks-deliveries-list}

**`webhooks.deliveries.list`** · scope `webhooks:manage`

List deliveries for a webhook subscription.

Returns deliveries for the webhook in newest-first order. Each row carries the current status (pending / in_flight / delivered / failed / dead), the attempt count, the next scheduled retry time, and the captured response details from the last attempt.

**Use when:** You are debugging a flaky receiver, or building a delivery-history UI for a settings page.

**Don't use for:** Listing deliveries across multiple webhooks (this endpoint is single-webhook scoped).

**Pitfalls**
- response_body is truncated to 4 KB — receivers returning long error pages have their response truncated.
- A delivery in `failed` status is non-terminal — the dispatcher will retry it at next_attempt_at. `dead` is terminal.

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

**Example response**

```json
{
  "data": [
    {
      "id": "wh_dlv_…",
      "webhook_id": "a8f1…",
      "event_type": "invoice.paid",
      "status": "delivered",
      "attempts": 1,
      "next_attempt_at": "2026-05-15T12:00:00Z",
      "response_status": 200,
      "response_body": "ok",
      "error": null,
      "request_id": "whdel_…",
      "created_at": "2026-05-15T12:00:00Z",
      "delivered_at": "2026-05-15T12:00:01Z"
    }
  ],
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12",
    "next_cursor": null
  }
}
```

---

### `POST` /api/v1/companies/:companyId/webhooks {#post-webhooks-create}

**`webhooks.create`** · scope `webhooks:manage`

Register a webhook subscription.

Creates a webhook subscription for one event type. The response includes a freshly generated HMAC signing secret, returned EXACTLY ONCE — store it on the receiver side immediately. The webhook is pinned to the current API version on creation; payload shapes for this webhook will not change until you explicitly upgrade.

**Use when:** You are wiring a downstream integration that needs push notifications instead of polling.

**Don't use for:** Subscribing to internal MCP telemetry events (mcp.tool_called etc. are not delivered as webhooks). Replacing an existing webhook URL — use PATCH instead.

**Pitfalls**
- The secret is returned exactly once. If lost, delete and recreate the webhook.
- Delivery is at-least-once with exponential backoff (1m / 5m / 30m / 2h / 12h / 24h / 48h). Receivers MUST be idempotent.
- HTTP 410 from your receiver auto-disables the webhook (sets active=false + disabled_reason).

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

**Example request**

```json
{
  "event_type": "invoice.paid",
  "webhook_url": "https://example.com/hooks/gnubok",
  "name": "CRM sync"
}
```

**Example response**

```json
{
  "data": {
    "id": "a8f1…",
    "name": "CRM sync",
    "event_type": "invoice.paid",
    "webhook_url": "https://example.com/hooks/gnubok",
    "active": true,
    "api_version_pinned": "2026-05-12",
    "disabled_at": null,
    "disabled_reason": null,
    "secret": "whsec_…",
    "description": null,
    "created_at": "2026-05-15T12:00:00Z"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/webhooks/:id/rotate-secret {#post-webhooks-rotate_secret}

**`webhooks.rotate_secret`** · scope `webhooks:manage`

Rotate the HMAC signing secret on a webhook.

Generates a fresh HMAC signing secret for the webhook and returns it EXACTLY ONCE. The previous secret is invalidated immediately. There is no grace period — coordinate the rotation on the receiver side BEFORE calling this endpoint, or temporarily disable the webhook (PATCH active=false) to pause delivery while you swap secrets.

**Use when:** After a suspected secret leak, on a routine rotation cadence (Stripe pattern: every 90 days for compliance-grade integrations), or when changing the receiver implementation and you want to invalidate the old secret deliberately.

**Don't use for:** Routine integration setup — the secret returned at create time is the canonical one. Recovering a lost secret (rotation does not recover the prior value; it issues a fresh one).

**Pitfalls**
- The secret is returned exactly once. If you lose this response, the recovery path is to rotate again.
- In-flight deliveries between the rotation and the receiver-side update may fail signature verification on the new secret. Pause the webhook (PATCH active=false) first if your tolerance for that window is zero.

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

**Example response**

```json
{
  "data": {
    "id": "a8f1…",
    "secret": "whsec_…",
    "rotated_at": "2026-05-15T12:00:00Z"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/webhooks/:id/test {#post-webhooks-test}

**`webhooks.test`** · scope `webhooks:manage`

Send a synthetic test event to a webhook.

Enqueues a webhook.test delivery against the configured receiver. The dispatcher delivers it on the next per-minute cron tick. Use the returned webhook_delivery_id to poll GET /webhooks/{id}/deliveries for the outcome.

**Use when:** After creating or modifying a webhook, before relying on it in production — to validate that the receiver is reachable and that signature verification works on the receiver side.

**Don't use for:** Smoke-testing the dispatcher itself (use a real event). Replaying a failed delivery (use POST /webhook-deliveries/{id}/retry).

**Pitfalls**
- Test deliveries follow the same retry policy as real events — a 500 from your receiver will retry 7 times over ~72h. Use a 2xx ack-only handler if you want a clean signal.

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

**Example response**

```json
{
  "data": {
    "webhook_delivery_id": "wh_dlv_…",
    "status": "pending"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/webhook-deliveries/:id/retry {#post-webhook_deliveries-retry}

**`webhook_deliveries.retry`** · scope `webhooks:manage`

Retry a webhook delivery.

Re-enqueues a dead (or delivered) delivery as a fresh pending row. The new delivery references the same webhook + payload; the dispatcher picks it up at the next per-minute cron tick. The original row is preserved in the audit log.

**Use when:** After a receiver outage you want to replay deliveries that died, or after fixing a receiver-side bug you want to redeliver a successful one.

**Don't use for:** Retrying live deliveries (pending / in_flight / failed) — the dispatcher is already managing them.

**Pitfalls**
- Retrying a delivered delivery causes the receiver to see the event twice. Receivers MUST be idempotent (check the X-Gnubok-Delivery header).

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

**Example response**

```json
{
  "data": {
    "webhook_delivery_id": "wh_dlv_NEW",
    "status": "pending"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `PATCH` /api/v1/companies/:companyId/webhooks/:id {#patch-webhooks-update}

**`webhooks.update`** · scope `webhooks:manage`

Update a webhook subscription.

Update the URL, name, description, or active flag. event_type is immutable — delete and recreate to change it. Setting active=false manually pauses delivery without deleting; setting active=true clears any disabled_at/disabled_reason set by the auto-disable on HTTP 410.

**Use when:** You need to point an existing webhook at a new URL or temporarily pause delivery.

**Don't use for:** Rotating the signing secret (delete and recreate). Changing event_type.

**Pitfalls**
- Re-enabling a webhook (active: true) does NOT replay deliveries that went to dead status while it was disabled — those need POST /webhook-deliveries/{id}/retry.

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

**Example request**

```json
{
  "active": true
}
```

**Example response**

```json
{
  "data": {
    "id": "a8f1…",
    "name": "CRM sync",
    "description": null,
    "event_type": "invoice.paid",
    "webhook_url": "https://example.com/hooks/gnubok",
    "active": true,
    "api_version_pinned": "2026-05-12",
    "disabled_at": null,
    "disabled_reason": null,
    "created_at": "2026-05-15T12:00:00Z",
    "updated_at": "2026-05-15T12:05:00Z"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `DELETE` /api/v1/companies/:companyId/webhooks/:id {#delete-webhooks-delete}

**`webhooks.delete`** · scope `webhooks:manage`

Delete a webhook subscription.

Hard-deletes the webhook. The delivery audit trail SURVIVES — both terminal (delivered, dead) and non-terminal (pending, failed) delivery rows persist with webhook_id = NULL so the BFNAR 2013:2 kap 8 § behandlingshistorik (7-year retention) for accounting-event deliveries is preserved. Non-terminal rows go dormant (the dispatcher skips them).

**Use when:** You no longer want this webhook to receive events.

**Don't use for:** Temporarily pausing delivery — use PATCH with active=false instead so the configuration survives.

**Pitfalls**
- Audit history survives DELETE; only the receiver subscription is removed. To suppress future events without retaining the registration use PATCH active=false.

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

**Example response**

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

---

---

# Operations

> Poll long-running async operations (year-end closing, imports, currency revaluation).

## Endpoints

- [`GET` `/api/v1/operations/:id`](#get-operations-get) — Poll a long-running operation by id.

---

### `GET` /api/v1/operations/:id {#get-operations-get}

**`operations.get`** · scope `operations:read`

Poll a long-running operation by id.

Returns the current snapshot of a v1 async operation: status (queued / running / succeeded / failed / cancelled), progress (jsonb, free-form), result (on success), and error (on failure). The operation_id is returned by the POST endpoints that initiate async work (period close, year-end, currency revaluation, SIE import).

**Use when:** You started an async operation and need to know whether it has finished. Poll every 5–30 seconds; switch to the `operation.completed` webhook for production integrations.

**Don't use for:** Fetching the resource the operation produced — once status=succeeded, read the result field or call the resource-specific GET endpoint. Cancelling a running operation (no cancel endpoint exists in v1).

**Pitfalls**
- Terminal statuses (`succeeded`, `failed`, `cancelled`) are final; the row never transitions out of them.
- progress is free-form jsonb; agents should treat it as opaque except for the documented fields `phase` (string), `current` / `total` (numbers for percent calculation).
- started_at is null while status=queued (the work has not begun yet); completed_at is null until a terminal status is reached.

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

**Example response**

```json
{
  "data": {
    "operation_id": "0e9c-…",
    "type": "fiscal_periods.year_end",
    "status": "succeeded",
    "progress": {
      "phase": "committed",
      "current": 142,
      "total": 142
    },
    "result": {
      "journal_entries_created": 4,
      "opening_balances_set": 138
    },
    "error": null,
    "started_at": "2026-05-12T10:01:23Z",
    "completed_at": "2026-05-12T10:01:48Z",
    "poll_url": "/api/v1/operations/0e9c-…",
    "webhook_event": "operation.completed"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

---

# Quickstart — send your first invoice

> Five minutes from a fresh sandbox to an emailed invoice. Demonstrates the auth, dry-run, idempotency, and audit-block patterns you'll use everywhere.

## What you'll need

- A test API key (`gnubok_sk_test_*`) from the gnubok dashboard at **/settings/api**. Test keys are bound to a deterministic sandbox company seeded with realistic data — safe for evals.
- `curl` or any HTTP client.

## 1. List the companies the key can access

Test keys are scoped to a single sandbox company by default; this call confirms the auth works and returns the `companyId` you'll use in the rest of the cookbook.

```bash
curl https://gnubok.app/api/v1/companies \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

Response (truncated):

```json
{
  "data": [{ "id": "00000000-0000-0000-0000-000000000001", "name": "Sandbox AB", "org_number": "556677-8899", ... }],
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
```

Save the `id` as `COMPANY_ID` for the next steps.

## 2. Create a customer (dry-run first)

Every write supports `?dry_run=true` — the response shows the would-be record without committing. Use it in agent test loops to validate inputs before paying the side-effect cost.

```bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/customers?dry_run=true" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme AB",
    "customer_type": "swedish_business",
    "email": "ap@acme.test",
    "org_number": "556677-8899",
    "default_payment_terms": 30
  }'
```

Response (`X-Dry-Run: true` header, no row written):

```json
{
  "data": {
    "id": null,
    "name": "Acme AB",
    "customer_type": "swedish_business",
    "vat_number_validated": false,
    "default_payment_terms": 30,
    "created_at": null,
    ...
  },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
```

Drop `?dry_run=true` to commit. The response now carries a real `id` and `created_at`.

## 3. Draft an invoice

Invoices are typed (B2B, EU-business, individual) and support mixed-rate VAT (per-item `vat_rate` overrides). The minimum body:

```bash
INVOICE_IDEMP=$(uuidgen)
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/invoices" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $INVOICE_IDEMP" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": "'$CUSTOMER_ID'",
    "invoice_date": "2026-05-15",
    "due_date": "2026-06-14",
    "items": [
      { "description": "Konsultation, maj 2026", "quantity": 8, "unit_price": 1200, "vat_rate": 25 }
    ]
  }'
```

Response includes the auto-allocated invoice number, the computed VAT lines, and the audit block (the verifikation hasn't been posted yet — drafts are not yet räkenskapsinformation):

```json
{
  "data": {
    "id": "...",
    "invoice_number": "2026-0001",
    "subtotal": 9600.00,
    "vat_total": 2400.00,
    "total": 12000.00,
    "status": "draft",
    "items": [...]
  },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12", "audit": {...} }
}
```

## 4. Send it

`POST /invoices/{id}/send` posts the verifikation, generates the PDF, and emails the customer in a single transaction. Strict-mode: if any step fails, none of them commit.

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/send" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)"
```

Response carries the now-posted voucher number:

```json
{
  "data": {
    "id": "...",
    "status": "sent",
    "sent_at": "2026-05-15T12:00:00Z",
    ...
  },
  "meta": {
    "request_id": "req_...",
    "audit": {
      "voucher_number": "F-2026-001",
      "voucher_url": "https://gnubok.app/bookkeeping/...",
      "immutable_at": "2026-05-15T12:00:00Z"
    }
  }
}
```

## 5. Mark it paid

When the customer pays, mark the invoice paid. The engine generates the payment voucher (debit 1930 bank, credit 1510 AR) and links it to the invoice.

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/mark-paid" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "payment_date": "2026-05-22", "payment_amount": 12000.00 }'
```

## What just happened

You created a customer, drafted an invoice with one mixed-VAT line item, posted the verifikation, sent the PDF, and recorded the payment. Five API calls; the engine handled BAS account selection, voucher numbering, period-lock checks, audit-trail entries, and PDF rendering.

The rendered PDF that the customer received contains every field required by ML 17 kap 24 § (the Swedish faktura mandate) — including `beskattningsunderlag per skattesats` (taxable amount per VAT rate; one line per distinct rate on multi-rate invoices), the supplier's organisationsnummer, sequential invoice number, per-line VAT rate, and the supply date. **Pass `delivery_date` explicitly** when goods or services are delivered on a different date than the invoice date — ML 17 kap 24 § field 7 requires the supply date and the API does NOT default it to `invoice_date`; a faktura with no supply date is non-compliant.

The "Godkänd för F-skatt" note is a **legal requirement** on every faktura issued by a Swedish momsregistrerad seller that holds F-skatt registration. The buyer uses this note to determine whether they must withhold preliminary tax (A-skatt) — omitting it can shift liability onto the buyer and triggers a FATAL Peppol BIS 3.0 validation failure (SE-R-005) on B2G invoices. The requirement applies equally to PDF/paper and Peppol/e-invoice formats; B2G is just where the validation is automated. The PDF includes it automatically when `company_settings.has_f_skatt` is true. **The integrator is responsible for keeping `has_f_skatt` in sync with the company's live Skatteverket registration status.** Update via `PATCH /api/v1/companies/{companyId}/settings` or the settings page — a flag that's false while the company is actually F-skatt-registered produces non-compliant invoices, not merely a missing optional note.

The summary fields in the JSON response (`subtotal`, `vat_total`, `total`) are convenience aggregates for the integration; the binding faktura content is the PDF itself.

## Next steps

- **[Subscribe to invoice events](/docs/api/cookbook/webhooks)** — get notified when invoices are paid via webhooks instead of polling.
- **[Ingest bank transactions](/docs/api/cookbook/ingest-bank-transactions)** — push CAMT/CSV into the engine and auto-categorise.
- **[Run a VAT declaration](/docs/api/cookbook/file-vat-declaration)** — compute momsdeklaration rutor and submit to Skatteverket.
- **[Full Invoices reference](/docs/api/reference/invoices)** — every endpoint, all the optional fields.

## Common pitfalls

- **Idempotency keys must be UUIDs.** Calls with non-UUID keys are rejected with `VALIDATION_ERROR`. Generate one per logical action and reuse it across retries of that same action — never on a fresh attempt.
- **Test keys can't email real addresses.** `gnubok_sk_test_*` short-circuits external providers — `/send` returns success but no email goes out. The PDF is still generated and the voucher posted.
- **Period locks block writes.** If you try to invoice into a closed period (`invoice_date` falls inside a locked fiscal period), the response is `PERIOD_LOCKED` (400). Use `GET /fiscal-periods` to check before backdating.
- **VIES VAT validation runs on commit only.** Dry-run skips the external VIES call; the real commit will block on slow VIES responses (we time out after 5s, but that's still 5s added to the request). Pre-validate via `POST /api/v1/vat/validate` if you want a fast first pass.

---

# Quickstart — send your first invoice

> Five minutes from a fresh sandbox to an emailed invoice. Demonstrates the auth, dry-run, idempotency, and audit-block patterns you'll use everywhere.

## What you'll need

- A test API key (`gnubok_sk_test_*`) from the gnubok dashboard at **/settings/api**. Test keys are bound to a deterministic sandbox company seeded with realistic data — safe for evals.
- `curl` or any HTTP client.

## 1. List the companies the key can access

Test keys are scoped to a single sandbox company by default; this call confirms the auth works and returns the `companyId` you'll use in the rest of the cookbook.

```bash
curl https://gnubok.app/api/v1/companies \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

Response (truncated):

```json
{
  "data": [{ "id": "00000000-0000-0000-0000-000000000001", "name": "Sandbox AB", "org_number": "556677-8899", ... }],
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
```

Save the `id` as `COMPANY_ID` for the next steps.

## 2. Create a customer (dry-run first)

Every write supports `?dry_run=true` — the response shows the would-be record without committing. Use it in agent test loops to validate inputs before paying the side-effect cost.

```bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/customers?dry_run=true" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme AB",
    "customer_type": "swedish_business",
    "email": "ap@acme.test",
    "org_number": "556677-8899",
    "default_payment_terms": 30
  }'
```

Response (`X-Dry-Run: true` header, no row written):

```json
{
  "data": {
    "id": null,
    "name": "Acme AB",
    "customer_type": "swedish_business",
    "vat_number_validated": false,
    "default_payment_terms": 30,
    "created_at": null,
    ...
  },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
```

Drop `?dry_run=true` to commit. The response now carries a real `id` and `created_at`.

## 3. Draft an invoice

Invoices are typed (B2B, EU-business, individual) and support mixed-rate VAT (per-item `vat_rate` overrides). The minimum body:

```bash
INVOICE_IDEMP=$(uuidgen)
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/invoices" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $INVOICE_IDEMP" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": "'$CUSTOMER_ID'",
    "invoice_date": "2026-05-15",
    "due_date": "2026-06-14",
    "items": [
      { "description": "Konsultation, maj 2026", "quantity": 8, "unit_price": 1200, "vat_rate": 25 }
    ]
  }'
```

Response includes the auto-allocated invoice number, the computed VAT lines, and the audit block (the verifikation hasn't been posted yet — drafts are not yet räkenskapsinformation):

```json
{
  "data": {
    "id": "...",
    "invoice_number": "2026-0001",
    "subtotal": 9600.00,
    "vat_total": 2400.00,
    "total": 12000.00,
    "status": "draft",
    "items": [...]
  },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12", "audit": {...} }
}
```

## 4. Send it

`POST /invoices/{id}/send` posts the verifikation, generates the PDF, and emails the customer in a single transaction. Strict-mode: if any step fails, none of them commit.

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/send" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)"
```

Response carries the now-posted voucher number:

```json
{
  "data": {
    "id": "...",
    "status": "sent",
    "sent_at": "2026-05-15T12:00:00Z",
    ...
  },
  "meta": {
    "request_id": "req_...",
    "audit": {
      "voucher_number": "F-2026-001",
      "voucher_url": "https://gnubok.app/bookkeeping/...",
      "immutable_at": "2026-05-15T12:00:00Z"
    }
  }
}
```

## 5. Mark it paid

When the customer pays, mark the invoice paid. The engine generates the payment voucher (debit 1930 bank, credit 1510 AR) and links it to the invoice.

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/mark-paid" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "payment_date": "2026-05-22", "payment_amount": 12000.00 }'
```

## What just happened

You created a customer, drafted an invoice with one mixed-VAT line item, posted the verifikation, sent the PDF, and recorded the payment. Five API calls; the engine handled BAS account selection, voucher numbering, period-lock checks, audit-trail entries, and PDF rendering.

The rendered PDF that the customer received contains every field required by ML 17 kap 24 § (the Swedish faktura mandate) — including `beskattningsunderlag per skattesats` (taxable amount per VAT rate; one line per distinct rate on multi-rate invoices), the supplier's organisationsnummer, sequential invoice number, per-line VAT rate, and the supply date. **Pass `delivery_date` explicitly** when goods or services are delivered on a different date than the invoice date — ML 17 kap 24 § field 7 requires the supply date and the API does NOT default it to `invoice_date`; a faktura with no supply date is non-compliant.

The "Godkänd för F-skatt" note is a **legal requirement** on every faktura issued by a Swedish momsregistrerad seller that holds F-skatt registration. The buyer uses this note to determine whether they must withhold preliminary tax (A-skatt) — omitting it can shift liability onto the buyer and triggers a FATAL Peppol BIS 3.0 validation failure (SE-R-005) on B2G invoices. The requirement applies equally to PDF/paper and Peppol/e-invoice formats; B2G is just where the validation is automated. The PDF includes it automatically when `company_settings.has_f_skatt` is true. **The integrator is responsible for keeping `has_f_skatt` in sync with the company's live Skatteverket registration status.** Update via `PATCH /api/v1/companies/{companyId}/settings` or the settings page — a flag that's false while the company is actually F-skatt-registered produces non-compliant invoices, not merely a missing optional note.

The summary fields in the JSON response (`subtotal`, `vat_total`, `total`) are convenience aggregates for the integration; the binding faktura content is the PDF itself.

## Next steps

- **[Subscribe to invoice events](/docs/api/cookbook/webhooks)** — get notified when invoices are paid via webhooks instead of polling.
- **[Ingest bank transactions](/docs/api/cookbook/ingest-bank-transactions)** — push CAMT/CSV into the engine and auto-categorise.
- **[Run a VAT declaration](/docs/api/cookbook/file-vat-declaration)** — compute momsdeklaration rutor and submit to Skatteverket.
- **[Full Invoices reference](/docs/api/reference/invoices)** — every endpoint, all the optional fields.

## Common pitfalls

- **Idempotency keys must be UUIDs.** Calls with non-UUID keys are rejected with `VALIDATION_ERROR`. Generate one per logical action and reuse it across retries of that same action — never on a fresh attempt.
- **Test keys can't email real addresses.** `gnubok_sk_test_*` short-circuits external providers — `/send` returns success but no email goes out. The PDF is still generated and the voucher posted.
- **Period locks block writes.** If you try to invoice into a closed period (`invoice_date` falls inside a locked fiscal period), the response is `PERIOD_LOCKED` (400). Use `GET /fiscal-periods` to check before backdating.
- **VIES VAT validation runs on commit only.** Dry-run skips the external VIES call; the real commit will block on slow VIES responses (we time out after 5s, but that's still 5s added to the request). Pre-validate via `POST /api/v1/vat/validate` if you want a fast first pass.

---

# Cookbook — set up webhooks and verify signatures end-to-end

> Subscribe a receiver to invoice events, verify HMAC signatures correctly, handle the at-least-once retry semantics, and build idempotency around the delivery id.

This is the operational companion to the [Webhooks concept page](/docs/api/webhooks) — that page explains *what* webhooks are; this one walks through *how* to wire one up correctly the first time.

## What you'll need

- A test API key with `webhooks:manage` scope (and `payroll:read` if you intend to subscribe to payroll events).
- A receiver URL that gnubok can POST to. For local development use [smee.io](https://smee.io) or `ngrok` — gnubok refuses webhook URLs that resolve to private IPs (SSRF protection), so localhost won't work directly.
- HTTPS only — `http://` URLs are rejected at registration.

## 1. Register the webhook

The response includes the HMAC signing secret **exactly once**. Capture it immediately and store it on the receiver side as an environment variable.

```bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/webhooks" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "invoice.paid",
    "webhook_url": "https://my-receiver.example.com/gnubok",
    "name": "CRM sync — invoice paid"
  }'
```

Response:

```json
{
  "data": {
    "id": "wh_a8f1...",
    "name": "CRM sync — invoice paid",
    "event_type": "invoice.paid",
    "webhook_url": "https://my-receiver.example.com/gnubok",
    "active": true,
    "api_version_pinned": "2026-05-12",
    "secret": "whsec_b3a7c9e2...",
    "created_at": "2026-05-15T12:00:00Z"
  },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
```

> ⚠️ The `secret` field is returned only on creation. Subsequent GETs never include it. If you lose it, the recovery path is to delete the webhook and create a new one (which generates a fresh secret); receivers must re-deploy with the new value.

**Store the secret in a secrets manager** (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, Doppler, 1Password Connect, ...) rather than a plaintext `.env` file or a config commit. The secret is signing material — anyone who reads it can forge events that will pass your signature check. Treat it with the same care as a database password.

## 2. Implement signature verification

Use the [Node](https://gnubok.app/docs/api/webhooks#nodejs) or [Python](https://gnubok.app/docs/api/webhooks#python) sample on the concept page. The critical detail: capture the **raw request body** before any framework JSON-parses it. Re-serialising the body produces different bytes and the signature won't match.

For an Express handler, that means `express.raw({ type: 'application/json' })` — NOT the default `express.json()` middleware. For FastAPI / Flask use `request.get_data()`. For Cloudflare Workers use `await request.text()` BEFORE `request.json()`.

## 3. Send a test event

The `:test` verb enqueues a synthetic `webhook.test` delivery without driving real state. The dispatcher sends it on the next per-minute cron tick.

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/webhooks/$WEBHOOK_ID/test" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

Response:

```json
{
  "data": { "webhook_delivery_id": "wh_dlv_...", "status": "pending" },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
```

Wait up to 60s, then check the receiver logs. The delivery should arrive with:

```
POST /gnubok HTTP/1.1
Content-Type: application/json
X-Gnubok-Signature: t=1715797800,v1=2f5c...
X-Gnubok-Event: webhook.test
X-Gnubok-Delivery: wh_dlv_...
X-Gnubok-Api-Version: 2026-05-12

{"id":"wh_dlv_...","type":"webhook.test","api_version":"2026-05-12","created":1715797800,"data":{"object":{"hello":"from gnubok","tested_at":"2026-05-15T12:00:00Z"}},"previous_attributes":null}
```

If your receiver returns 2xx, the delivery moves to `delivered`. If it returns 4xx (other than 410) or 5xx, it goes to `failed` and retries on the schedule `1m / 5m / 30m / 2h / 12h / 24h / 48h`.

## 4. Inspect the delivery

```bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/webhooks/$WEBHOOK_ID/deliveries?delivery_id=$DELIVERY_ID" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

Response carries the captured response status and body (truncated to 4 KB), which is invaluable when debugging a 4xx from the receiver:

```json
{
  "data": [{
    "id": "wh_dlv_...",
    "event_type": "webhook.test",
    "status": "delivered",
    "attempts": 1,
    "next_attempt_at": "2026-05-15T12:00:00Z",
    "response_status": 200,
    "response_body": "ok",
    "error": null,
    "request_id": "whdel_...",
    "created_at": "2026-05-15T12:00:00Z",
    "delivered_at": "2026-05-15T12:00:01Z"
  }]
}
```

## 5. Drive a real event

Now mark a real invoice paid (or use any of the [event-emitting endpoints](/docs/api/webhooks#event-types)). The webhook handler picks up the emission and enqueues a delivery within the same request cycle.

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/mark-paid" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "payment_date": "2026-05-22", "payment_amount": 12000.00 }'
```

The next dispatcher tick (within 60s) delivers an `invoice.paid` event to your receiver carrying the full invoice payload + payment details.

## Idempotency on the receiver side

Deliveries are at-least-once. The same `X-Gnubok-Delivery` may arrive twice when the network drops a 200 response or your receiver times out after processing. Build idempotency around that header:

```javascript
// Pseudo-code — adapt to your storage layer.
async function handleEvent(event) {
  const inserted = await db.processedDeliveries.insertIfMissing({
    delivery_id: event.id,
    event_type: event.type,
    received_at: new Date(),
  })
  if (!inserted) {
    console.log('duplicate delivery, skipping', event.id)
    return
  }
  await processBusinessLogic(event)
}
```

This pattern: a unique constraint on `delivery_id`, an INSERT-on-conflict-do-nothing, and short-circuit when nothing was inserted. Every gnubok delivery passes through that gate at most once even if the dispatcher retries.

## Replaying a dead delivery

When a delivery exhausts its retries it's marked `dead`. After fixing the receiver, replay individual deliveries with:

```bash
curl -X POST "https://gnubok.app/api/v1/webhook-deliveries/$DELIVERY_ID/retry" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

The retry creates a fresh delivery row pointing at the same payload — the original audit row stays in place. Receivers see the same `X-Gnubok-Delivery` (the new row's id, not the original's), so the idempotency table needs no special handling.

## Auto-disable

After:
- HTTP 410 Gone from your receiver, OR
- HTTP 3xx redirect (refused to follow — SSRF policy), OR
- The webhook URL resolves to a private/loopback/link-local/cloud-metadata IP at dispatch time

…the webhook is automatically disabled (`active=false`, `disabled_reason` set). Re-enable with:

```bash
curl -X PATCH "https://gnubok.app/api/v1/companies/$COMPANY_ID/webhooks/$WEBHOOK_ID" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{ "active": true }'
```

This clears `disabled_at` and `disabled_reason` but does NOT replay the deliveries that died while disabled — replay them individually with the retry endpoint.

## Common pitfalls

- **Re-serialising the body.** `JSON.parse(rawBody); JSON.stringify(parsed)` produces different bytes than gnubok sent. Always sign-check against the raw bytes.
- **Forgetting the timestamp window.** Without a `t` check, an attacker who captured one signed payload can replay it forever. 5 minutes is the recommended tolerance.
- **Returning 5xx for application errors.** A 5xx triggers full retries (~72h). If a payload is malformed-but-stable, return 200 and queue for internal investigation.
- **Treating `failed` as terminal.** `failed` rows will retry; only `delivered` and `dead` are terminal. Don't alert on `failed` — alert when retries exhaust to `dead`.

---

# Cookbook — set up webhooks and verify signatures end-to-end

> Subscribe a receiver to invoice events, verify HMAC signatures correctly, handle the at-least-once retry semantics, and build idempotency around the delivery id.

This is the operational companion to the [Webhooks concept page](/docs/api/webhooks) — that page explains *what* webhooks are; this one walks through *how* to wire one up correctly the first time.

## What you'll need

- A test API key with `webhooks:manage` scope (and `payroll:read` if you intend to subscribe to payroll events).
- A receiver URL that gnubok can POST to. For local development use [smee.io](https://smee.io) or `ngrok` — gnubok refuses webhook URLs that resolve to private IPs (SSRF protection), so localhost won't work directly.
- HTTPS only — `http://` URLs are rejected at registration.

## 1. Register the webhook

The response includes the HMAC signing secret **exactly once**. Capture it immediately and store it on the receiver side as an environment variable.

```bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/webhooks" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "invoice.paid",
    "webhook_url": "https://my-receiver.example.com/gnubok",
    "name": "CRM sync — invoice paid"
  }'
```

Response:

```json
{
  "data": {
    "id": "wh_a8f1...",
    "name": "CRM sync — invoice paid",
    "event_type": "invoice.paid",
    "webhook_url": "https://my-receiver.example.com/gnubok",
    "active": true,
    "api_version_pinned": "2026-05-12",
    "secret": "whsec_b3a7c9e2...",
    "created_at": "2026-05-15T12:00:00Z"
  },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
```

> ⚠️ The `secret` field is returned only on creation. Subsequent GETs never include it. If you lose it, the recovery path is to delete the webhook and create a new one (which generates a fresh secret); receivers must re-deploy with the new value.

**Store the secret in a secrets manager** (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, Doppler, 1Password Connect, ...) rather than a plaintext `.env` file or a config commit. The secret is signing material — anyone who reads it can forge events that will pass your signature check. Treat it with the same care as a database password.

## 2. Implement signature verification

Use the [Node](https://gnubok.app/docs/api/webhooks#nodejs) or [Python](https://gnubok.app/docs/api/webhooks#python) sample on the concept page. The critical detail: capture the **raw request body** before any framework JSON-parses it. Re-serialising the body produces different bytes and the signature won't match.

For an Express handler, that means `express.raw({ type: 'application/json' })` — NOT the default `express.json()` middleware. For FastAPI / Flask use `request.get_data()`. For Cloudflare Workers use `await request.text()` BEFORE `request.json()`.

## 3. Send a test event

The `:test` verb enqueues a synthetic `webhook.test` delivery without driving real state. The dispatcher sends it on the next per-minute cron tick.

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/webhooks/$WEBHOOK_ID/test" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

Response:

```json
{
  "data": { "webhook_delivery_id": "wh_dlv_...", "status": "pending" },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
```

Wait up to 60s, then check the receiver logs. The delivery should arrive with:

```
POST /gnubok HTTP/1.1
Content-Type: application/json
X-Gnubok-Signature: t=1715797800,v1=2f5c...
X-Gnubok-Event: webhook.test
X-Gnubok-Delivery: wh_dlv_...
X-Gnubok-Api-Version: 2026-05-12

{"id":"wh_dlv_...","type":"webhook.test","api_version":"2026-05-12","created":1715797800,"data":{"object":{"hello":"from gnubok","tested_at":"2026-05-15T12:00:00Z"}},"previous_attributes":null}
```

If your receiver returns 2xx, the delivery moves to `delivered`. If it returns 4xx (other than 410) or 5xx, it goes to `failed` and retries on the schedule `1m / 5m / 30m / 2h / 12h / 24h / 48h`.

## 4. Inspect the delivery

```bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/webhooks/$WEBHOOK_ID/deliveries?delivery_id=$DELIVERY_ID" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

Response carries the captured response status and body (truncated to 4 KB), which is invaluable when debugging a 4xx from the receiver:

```json
{
  "data": [{
    "id": "wh_dlv_...",
    "event_type": "webhook.test",
    "status": "delivered",
    "attempts": 1,
    "next_attempt_at": "2026-05-15T12:00:00Z",
    "response_status": 200,
    "response_body": "ok",
    "error": null,
    "request_id": "whdel_...",
    "created_at": "2026-05-15T12:00:00Z",
    "delivered_at": "2026-05-15T12:00:01Z"
  }]
}
```

## 5. Drive a real event

Now mark a real invoice paid (or use any of the [event-emitting endpoints](/docs/api/webhooks#event-types)). The webhook handler picks up the emission and enqueues a delivery within the same request cycle.

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/mark-paid" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "payment_date": "2026-05-22", "payment_amount": 12000.00 }'
```

The next dispatcher tick (within 60s) delivers an `invoice.paid` event to your receiver carrying the full invoice payload + payment details.

## Idempotency on the receiver side

Deliveries are at-least-once. The same `X-Gnubok-Delivery` may arrive twice when the network drops a 200 response or your receiver times out after processing. Build idempotency around that header:

```javascript
// Pseudo-code — adapt to your storage layer.
async function handleEvent(event) {
  const inserted = await db.processedDeliveries.insertIfMissing({
    delivery_id: event.id,
    event_type: event.type,
    received_at: new Date(),
  })
  if (!inserted) {
    console.log('duplicate delivery, skipping', event.id)
    return
  }
  await processBusinessLogic(event)
}
```

This pattern: a unique constraint on `delivery_id`, an INSERT-on-conflict-do-nothing, and short-circuit when nothing was inserted. Every gnubok delivery passes through that gate at most once even if the dispatcher retries.

## Replaying a dead delivery

When a delivery exhausts its retries it's marked `dead`. After fixing the receiver, replay individual deliveries with:

```bash
curl -X POST "https://gnubok.app/api/v1/webhook-deliveries/$DELIVERY_ID/retry" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

The retry creates a fresh delivery row pointing at the same payload — the original audit row stays in place. Receivers see the same `X-Gnubok-Delivery` (the new row's id, not the original's), so the idempotency table needs no special handling.

## Auto-disable

After:
- HTTP 410 Gone from your receiver, OR
- HTTP 3xx redirect (refused to follow — SSRF policy), OR
- The webhook URL resolves to a private/loopback/link-local/cloud-metadata IP at dispatch time

…the webhook is automatically disabled (`active=false`, `disabled_reason` set). Re-enable with:

```bash
curl -X PATCH "https://gnubok.app/api/v1/companies/$COMPANY_ID/webhooks/$WEBHOOK_ID" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{ "active": true }'
```

This clears `disabled_at` and `disabled_reason` but does NOT replay the deliveries that died while disabled — replay them individually with the retry endpoint.

## Common pitfalls

- **Re-serialising the body.** `JSON.parse(rawBody); JSON.stringify(parsed)` produces different bytes than gnubok sent. Always sign-check against the raw bytes.
- **Forgetting the timestamp window.** Without a `t` check, an attacker who captured one signed payload can replay it forever. 5 minutes is the recommended tolerance.
- **Returning 5xx for application errors.** A 5xx triggers full retries (~72h). If a payload is malformed-but-stable, return 200 and queue for internal investigation.
- **Treating `failed` as terminal.** `failed` rows will retry; only `delivered` and `dead` are terminal. Don't alert on `failed` — alert when retries exhaust to `dead`.

---

# Cookbook — ingest and categorise bank transactions

> Push a bank statement file into gnubok, get AI-assisted category suggestions, commit the categorisations, and match payments against open invoices. End-to-end transaction-to-booking pipeline.

This is the operational companion to the [Transactions reference](/docs/api/reference/transactions) and the [Imports reference](/docs/api/reference/imports). Use it for the first integration where transactions enter the system from a bank source.

## What you'll need

- A test API key with `transactions:write`, `transactions:read`, and `imports:write` scopes.
- A bank statement file in one of the supported formats: CSV (SEB / Swedbank / Handelsbanken / Nordea / Danske / ICA / Lendo / Ålandsbanken / SBAB / Marginalen / others auto-detected), CAMT.053 XML, or a plain account-statement CSV with at minimum date + amount + description columns.
- The settlement account for the bank — typically `'1930'` for an SEK business account. Check via `GET /accounts`.

## 1. Upload the bank file

`POST /imports/bank` accepts multipart upload. Format detection is automatic; the response includes the matched parser. The endpoint kicks off an async operation — you'll poll for the result.

```bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/imports/bank" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -F "file=@statement-2026-04.csv" \
  -F 'settlement_account="1930"'
```

Response is a 202 with the operation handle:

```json
{
  "data": {
    "operation_id": "op_a8f1...",
    "status": "queued",
    "poll_url": "/api/v1/operations/op_a8f1...",
    "webhook_event": "operation.completed"
  },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
```

## 2. Poll until the import completes

Polling is the simplest pattern; subscribe to the `operation.completed` event ([cookbook](/docs/api/cookbook/webhooks)) for the push variant. The operation lifecycle is `queued → running → succeeded | failed | cancelled`.

```bash
curl "https://gnubok.app/api/v1/operations/$OPERATION_ID" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

On `succeeded`:

```json
{
  "data": {
    "operation_id": "op_a8f1...",
    "type": "import.bank",
    "status": "succeeded",
    "progress": { "current": 187, "total": 187, "phase": "complete" },
    "result": {
      "rows_inserted": 165,
      "rows_skipped_duplicate": 22,
      "format_detected": "seb_csv",
      "earliest_date": "2026-04-01",
      "latest_date": "2026-04-30"
    },
    "started_at": "2026-05-01T08:00:00Z",
    "completed_at": "2026-05-01T08:00:04Z"
  }
}
```

Note the dedup: rows that match an existing transaction on `(date, amount, description_hash)` are skipped, not inserted twice. Re-uploading the same file is safe.

## 3. List uncategorised transactions

After ingest the rows are in `transactions` but uncategorised (`account_number: null`, `category: null`). List them:

```bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/transactions?status=uncategorized&period=2026-04&limit=50" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

Response (cursor-paginated, oldest-first):

```json
{
  "data": [
    {
      "id": "tx_...",
      "transaction_date": "2026-04-03",
      "description": "SEB CARD - SJ 25-...",
      "amount": -487.00,
      "currency": "SEK",
      "category": null,
      "account_number": null,
      "vat_treatment": null,
      "document_id": null,
      "journal_entry_id": null
    },
    ...
  ],
  "meta": { "request_id": "req_...", "next_cursor": "eyJ0cyI6Ij..." }
}
```

## 4. Get category suggestions

`POST /transactions/{id}/suggest-categories` returns ranked guesses based on the description, counterparty history, and your booking-template library:

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/transactions/$TX_ID/suggest-categories" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

```json
{
  "data": {
    "suggestions": [
      {
        "category": "expense_travel",
        "account_number": "5800",
        "vat_treatment": "standard_25",
        "confidence": 0.92,
        "reason": "Counterparty 'SJ' matched booking template 'Tågresor' (12 prior matches)"
      },
      {
        "category": "expense_representation",
        "account_number": "6071",
        "vat_treatment": "standard_25",
        "confidence": 0.15,
        "reason": "Fallback — SJ has occasionally been booked as kund-representation"
      }
    ]
  }
}
```

Confidence ≥ 0.85 is generally safe to auto-apply; below that surface to the user.

## 5. Commit the categorisation

`POST /transactions/{id}/categorize` stages the booking. Dry-run first to see the verifikation preview:

```bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/transactions/$TX_ID/categorize?dry_run=true" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "category": "expense_travel",
    "account_number": "5800",
    "vat_treatment": "standard_25"
  }'
```

Response includes the would-be journal entry lines:

```json
{
  "data": {
    "staged_operation_id": "po_...",
    "preview": {
      "journal_lines": [
        { "account": "5800", "debit": 389.60, "credit": 0, "label": "Reskostnader" },
        { "account": "2641", "debit": 97.40, "credit": 0, "label": "Ingående moms 25%" },
        { "account": "1930", "debit": 0, "credit": 487.00, "label": "Företagskonto" }
      ],
      "voucher_number_assigned_on_commit": "auto",
      "account_deltas": { "5800": -389.60, "2641": -97.40, "1930": +487.00 }
    }
  }
}
```

Drop `?dry_run=true` and reuse the same `Idempotency-Key` to commit. The response carries the audit block with the now-posted voucher number.

## 6. Batch categorise

For a backlog, use `POST /transactions/batch-categorize` (up to 100 transactions per call, dry-runnable, partial-success on commit):

```bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/transactions/batch-categorize" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      { "transaction_id": "tx_1", "category": "expense_travel", "account_number": "5800", "vat_treatment": "standard_25" },
      { "transaction_id": "tx_2", "category": "income_services", "account_number": "3001", "vat_treatment": "standard_25" },
      ...
    ]
  }'
```

Response shape — every item has its own `ok` flag:

```json
{
  "data": {
    "results": [
      { "ok": true,  "request_index": 0, "data": { "voucher_number": "A2026-0042" } },
      { "ok": false, "request_index": 1, "error": { "code": "PERIOD_LOCKED", "message": "Perioden är låst." } }
    ],
    "summary": { "total": 2, "succeeded": 1, "failed": 1 }
  }
}
```

## 7. Match a payment against an invoice

When a transaction is a customer payment, match it to the open invoice via `POST /transactions/{id}/match-invoice` instead of `categorize`. The engine posts the payment voucher (debit 1930, credit 1510) AND marks the invoice paid in a single transaction.

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/transactions/$TX_ID/match-invoice" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "invoice_id": "inv_...", "payment_date": "2026-04-15" }'
```

For supplier-invoice payments use `POST /transactions/{id}/match-supplier-invoice` — same shape, different counterparty side.

## Multicurrency

Bank statements that include non-SEK transactions are imported with the foreign amount preserved in `amount_foreign` + `currency_foreign`. When you categorise, the engine looks up the Riksbanken FX rate for the transaction date and books the SEK equivalent on the GL side. The FX delta (rate at booking vs rate at month-end revaluation) is later picked up by the currency-revaluation job.

If you import a multi-currency statement, ensure the company has `base_currency` set (defaults to SEK) and that the relevant FX rates are available — fetch via `GET /currency/rate?date=...&from=...&to=...` or rely on the cached daily snapshot.

## Common pitfalls

- **Re-running the same file is safe; date-only overlap is also safe.** The dedup keys on `(date, amount, description_hash)` so partial overlap of two statements doesn't double-import.
- **Settlement account selection matters.** Importing into the wrong settlement account silently breaks bank reconciliation later. `1930` (företagskonto) is the default for SEK; a foreign-currency bank account uses its own asset account (e.g. `1932` for USD).
- **Cash-method companies and partial payments don't mix.** If `company_settings.accounting_method = 'cash'` and you try to match a partial payment, the response is `VALIDATION_ERROR` rather than booking accrual entries — cash-method cannot model the per-installment moms event correctly (ML 13 kap 8 §). Either book the partial payment as a separate categorisation or switch to accrual.
- **Batch-categorize is partial-success by default.** If one item hits a locked period, the others still commit. The summary block tells you the totals; check per-item `ok` flags.

## Next steps

- **[Set up webhooks](/docs/api/cookbook/webhooks)** — get notified of `transaction.categorized` events without polling.
- **[File a VAT declaration](/docs/api/cookbook/file-vat-declaration)** — compute the rutor 05–62 from your now-categorised transactions.
- **[Transactions reference](/docs/api/reference/transactions)** — every parameter, every filter.
- **[Imports reference](/docs/api/reference/imports)** — full bank-file format coverage.

---

# Cookbook — compute and review a VAT declaration

> Compute the Swedish momsdeklaration rutor 05–49 from your committed transactions, reconcile against the general ledger, and prepare the numbers for manual submission to Skatteverket.

This is the operational companion to the [Reports reference](/docs/api/reference/reports) and the [Skatteverket integration notes](/docs/api/webhooks#operation-events). v1 does NOT submit the declaration to Skatteverket directly — that path exists via the BankID-gated Skatteverket extension, not the public REST API. v1 produces the numbers and the receipt-quality JSON for manual submission via Skatteverket Mina Sidor.

## What you'll need

- A test API key with `reports:read` scope.
- All transactions for the period categorised and posted (see [ingest-bank-transactions cookbook](/docs/api/cookbook/ingest-bank-transactions)).
- The company's `moms_redovisning` cycle configured — monthly (kvartalsvis is supported for small companies with omsättning ≤ 1M SEK; the API doesn't dictate cadence, your bookkeeping does).

## 1. Compute the declaration

`GET /reports/vat-declaration` returns rutor 05–62 plus the reconciliation block:

```bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/reports/vat-declaration?period=2026-04" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

Response (abbreviated):

```json
{
  "data": {
    "period": { "year": 2026, "month": 4, "label": "april 2026" },
    "company": { "org_number": "556677-8899", "vat_registration_no": "SE556677889901" },
    "rutor": {
      "05": { "label": "Momspliktig försäljning",                  "amount":  124300.00 },
      "06": { "label": "Momspliktig försäljning som inte ingår i ruta 05", "amount":   0.00 },
      "07": { "label": "Momspliktig inköp omv. skattskyldighet",   "amount":       0.00 },
      "10": { "label": "Utgående moms 25% (på ruta 05)",           "amount":   31075.00 },
      "11": { "label": "Utgående moms 12%",                         "amount":     720.00 },
      "12": { "label": "Utgående moms 6%",                          "amount":     180.00 },
      "30": { "label": "Inköp av varor från EU (omv. skatt)",      "amount":       0.00 },
      "31": { "label": "Inköp av tjänster från EU (omv. skatt)",   "amount":    5200.00 },
      "32": { "label": "Inköp utanför EU (omv. skatt)",            "amount":       0.00 },
      "39": { "label": "Försäljning tjänster EU",                  "amount":    3450.00 },
      "40": { "label": "Export utanför EU",                         "amount":       0.00 },
      "48": { "label": "Ingående moms (avdragsgill)",              "amount":   12347.00 },
      "49": { "label": "Moms att betala (+) eller återfå (−)",     "amount":   19628.00 }
    },
    "reconciliation": {
      "gl_balance_2611": 31075.00,
      "gl_balance_2614":     0.00,
      "gl_balance_2615":     0.00,
      "gl_balance_2621":   720.00,
      "gl_balance_2631":   180.00,
      "gl_balance_2641": 12347.00,
      "gl_balance_2645":     0.00,
      "rutor_match_gl": true
    },
    "warnings": []
  },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
```

Ruta 49 = (utgående moms 10+11+12 + utländsk omv. 30+31+32 + utländsk försäljning 60+61+62) − ingående moms (ruta 48). Positive → moms att betala. Negative → moms att återfå.

## 2. Reconcile against the GL

The `reconciliation` block compares the rutor against the actual general-ledger balances on the moms accounts:

- `2611` — Utgående moms 25% (matches ruta 10)
- `2614` — Utgående moms vid omvänd skattskyldighet (matches ruta 30 / reverse-charge output)
- `2615` — Utgående moms vid import (matches ruta 60)
- `2621` — Utgående moms 12% (matches ruta 11)
- `2631` — Utgående moms 6% (matches ruta 12)
- `2641` — Ingående moms (matches ruta 48)
- `2645` — Beräknad ingående moms vid EU-förvärv (rolls into rutor 30/31/32 → ruta 48)

`rutor_match_gl: true` means every figure on the declaration ties to the GL — the declaration is self-consistent. `false` triggers a per-rate `warnings` entry pointing at the offending account; investigate before submitting.

## 3. The 2026-04-01 livsmedel rate change

**Important compliance moment in April 2026.** The VAT rate on livsmedel (groceries) drops from 12% → 6% effective 2026-04-01 under the regeringens vårproposition 2025. The decisive date under ML (2023:200) 1 kap 3 § is the *tidpunkt för skattskyldighetens inträde* — for goods this is the **supply date** (delivery), not the invoice date.

- **Always pass `delivery_date` explicitly when it differs from `invoice_date`.** The engine routes the booking by supply date: food delivered ≥ 2026-04-01 books to `2631` (6%), food delivered before that books to `2621` (12%), regardless of when the invoice was issued. For continuous or subscription food supplies (e.g. a weekly grocery box), the trigger point is the date when each individual delivery's skattskyldighet inträder — confirm against ML 1 kap 3 § rather than assuming the rule equals a single delivery date.
- The classic edge case: food delivered in March, invoiced in April. Without an explicit `delivery_date` the engine falls back to `invoice_date` and would mis-book at 6%. **Set `delivery_date` for every food-line item in March-April 2026 invoices** — the cost of explicit data is zero; the cost of a mis-booked verifikation is a manual rectification + a momsdeklaration adjustment.
- When `delivery_date` is omitted, the engine uses `invoice_date` as the fallback supply date. This is correct for **one-off** service supplies where delivery and invoice coincide; long-running service contracts (subscriptions, ongoing maintenance) have per-delprestation skattskyldighet under ML 1 kap 3 § and require an explicit `delivery_date` per billing cycle. Goods that straddle the cutover always need an explicit `delivery_date`.

The VAT declaration for April 2026 onwards will show split balances on rutor 11/12: pre-2026-04-01 food sales remain on ruta 11 (12%), post-cutover food sales appear on ruta 12 (6%). The reconciliation block surfaces both; warnings flag any post-cutover transaction still booked at 12%.

## 4. Pre-flight: voucher gaps

BFNAR 2013:2 kap 6–7 §§ requires every voucher gap to have a documented explanation. Skatteverket may ask why `F-2026-0042` exists when no `F-2026-0041` is on the books. Check before declaring:

```bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/compliance/check?type=voucher_gaps&period=2026-04" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

If gaps exist, file an explanation via `POST /voucher-gap-explanations` BEFORE submitting the declaration — gaps without explanations are a compliance audit finding.

## 5. Pre-flight: locked period

The declaration is computed from posted entries in the period. If the period is still open and you have draft entries that should be in this declaration, commit them before declaring. After declaring, lock the period:

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/fiscal-periods/$PERIOD_ID/lock" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)"
```

Locking is reversible (via `PATCH /fiscal-periods/{id}` with a clear reason in the audit log). Closing the period is irreversible per BFL 5 kap 8 §; only close after the declaration is submitted AND any audit-period grace window has passed.

## 6. Manual submission to Skatteverket

v1 does not submit the declaration. The receipt-quality JSON above is what you transcribe into Skatteverket Mina Sidor (or feed into your own Skatteverket-integration tooling, gated by BankID — handled by the optional `skatteverket` extension, not the public REST API).

For audit-trail completeness, capture the submission confirmation number from Skatteverket and store it on the period via `PATCH /fiscal-periods/{id}`:

```bash
curl -X PATCH "https://gnubok.app/api/v1/companies/$COMPANY_ID/fiscal-periods/$PERIOD_ID" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{ "submission_reference": "SKV-2026-04-AB123456" }'
```

## EU and reverse-charge handling

Sales of services to other EU businesses (`vat_treatment: 'reverse_charge_eu'`) appear on ruta 39 and bypass the output-moms accounts (no entry on 26xx). The customer accounts for moms in their own country.

Purchases of services from other EU businesses (supplier_invoice with `vat_treatment: 'reverse_charge_eu'`) appear on ruta 31. The engine books both an output-moms entry on 2614 (calculated 25% reverse) AND an input-moms entry on 2645 — net zero impact on cash flow when full avdragsrätt applies, full traceability on the declaration. **For blandad verksamhet (mixed-activity companies with partial avdragsrätt per HFD 2023 ref. 45),** the `2645` leg must be proportionally restricted before it reaches ruta 48 — set `company_settings.vat_deduction_percent` so the engine applies the correct restriction automatically; otherwise the input-moms reaches ruta 48 unrestricted and over-declares the deduction.

Imports from outside EU (`vat_treatment: 'import'`) book through a customs-clearance flow — the customs invoice is what posts the moms, not the supplier invoice itself. Coverage of this is in the [Supplier invoices reference](/docs/api/reference/supplier-invoices).

## Common pitfalls

- **Decimals vs hela kronor — truncate öre, do not round.** The API returns rutor as decimal numbers (öre preserved). Skatteverket Mina Sidor and SRU filings accept only hela kronor; the rule per SFL 22 kap 1 § is **truncation** (drop öre), NOT half-up rounding. Use `Math.floor` for positive amounts when transcribing. Truncate at the rendering / submission boundary, not in storage.
- **Don't compute mid-month.** The figures are only meaningful for a complete month; calling `?period=2026-04` mid-April returns the partial state. The endpoint doesn't refuse partial periods, so this is on the integrator.
- **Mixed-rate invoices.** A single invoice with both 25% and 12% items lands on both `2611` and `2621`. The declaration handles this correctly because the per-line VAT rate is preserved in the engine; integrations that flatten to a single header rate will mis-declare.
- **Reverse-charge invoices in the wrong ruta.** A B2B sale to an EU customer with a missing/unvalidated VAT number does NOT qualify for reverse charge — those go to ruta 05 with normal 25% moms. Validate via `POST /vat/validate` (VIES) before issuing the invoice.

## Next steps

- **[Year-end closing](/docs/api/cookbook/year-end-closing)** — once all 12 monthly declarations are filed, close the fiscal year.
- **[Run payroll](/docs/api/cookbook/run-payroll-and-agi)** — moms and AGI are independent; both need to be filed monthly.
- **[Reports reference](/docs/api/reference/reports)** — every report, every parameter.

---

# Cookbook — run payroll and generate the AGI XML

> Drive a Swedish salary run from draft to booked, then generate the arbetsgivardeklaration på individnivå (AGI) XML for manual submission to Skatteverket. Five-step lifecycle, every transition idempotent and dry-runnable.

This is the operational companion to the [Salary-runs reference](/docs/api/reference/salary-runs). The route surface mirrors the dashboard exactly — anything you can do in the UI is callable from the API.

## What you'll need

- A test API key with `payroll:read` AND `payroll:write` scopes. `payroll:write` is required for every state transition; `payroll:read` covers the read paths plus the elevated-scope gate on the webhook `salary_run.*` subscription.
- At least one employee on file with `payroll_config` set (`grundlön`, `skattetabell`, `tax_column`, `F_skatt` flag).
- An open fiscal period covering the salary date.

## 1. Create a salary run (draft)

`POST /salary-runs` opens a run in `draft` status. Personnummer in the response is masked to `ÅÅÅÅMMDDXXXX` per GDPR Art.5(1)(c) — the full value only appears on `GET /employees/{id}` (deliberate drill-in).

```bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/salary-runs" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "period_year": 2026,
    "period_month": 5,
    "payment_date": "2026-05-25",
    "employees": [
      { "employee_id": "emp_...", "grundlön": 38000 },
      { "employee_id": "emp_...", "grundlön": 42000, "övertidstillägg": 2400 }
    ]
  }'
```

Response:

```json
{
  "data": {
    "id": "sr_...",
    "status": "draft",
    "period_year": 2026,
    "period_month": 5,
    "payment_date": "2026-05-25",
    "employee_count": 2,
    "total_brutto": null,
    "total_avgifter": null,
    "total_netto": null
  }
}
```

Totals are null until you calculate.

## 2. Calculate (math + draft → review)

`POST /salary-runs/{id}/calculate` runs the full Swedish tax engine: skattetabell lookup per employee, sociala avgifter at the current rate (31.42% for 2026), age-adjusted reductions per Prop. 2025/26:66 (the youth-reduction band is **18–22 years old at the start of 2026** — i.e. employees **born 2003–2007** for the 2026 income year, NOT a blanket "under-25"; the elder reduction applies at **67+ from 2026**, not 66+), förmånsbeskattning, semesterlöneskuld, OB-tillägg, traktamente.

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/salary-runs/$SR_ID/calculate" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)"
```

Response transitions `draft → review`:

```json
{
  "data": {
    "id": "sr_...",
    "status": "review",
    "total_brutto":     80000.00,
    "total_skatt":      24300.00,
    "total_avgifter":   25136.00,
    "total_netto":      55700.00,
    "lines": [
      {
        "employee_id": "emp_...",
        "personnummer": "19800401XXXX",
        "brutto": 38000,
        "preliminär_skatt": 11400,
        "arbetsgivaravgifter": 11940,
        "netto": 26600,
        ...
      },
      ...
    ]
  }
}
```

The `review` status is a soft hold — the math is done but no journal entries are posted yet. Treat this as the human-review step.

## 3. Approve (review → approved)

`POST /salary-runs/{id}/approve` validates and locks the math. After this point you can't `PATCH` per-employee `grundlön` etc. — corrections require reverting to draft (only possible if no payment is recorded).

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/salary-runs/$SR_ID/approve" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)"
```

Response shows `status: 'approved'`. The engine validates:
- Every employee has a valid `skattetabell` reference
- No employee's bank account is missing where required
- Sociala avgifter total matches per-employee sum to the öre
- No double-booking against a prior approved run for the same `period_year, period_month`

Failures return `SALARY_RUN_APPROVE_VALIDATION_FAILED` with a per-employee breakdown in `details`.

## 4. Mark paid (approved → paid)

After the bank transfer settles (or you mark it on the same day for cash-method shops), tell gnubok:

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/salary-runs/$SR_ID/mark-paid" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "payment_date": "2026-05-25", "settlement_account": "1930" }'
```

This step records the payment event but does NOT post the journal entry yet — that's step 5. The split is deliberate: the `mark-paid` step gives integrators a hook to confirm the bank-side leg landed before locking the GL side.

## 5. Book (paid → booked)

`POST /salary-runs/{id}/book` is the engine-touching step. It generates 2–4 verifikationer atomically (the count depends on whether OB/övertid/traktamente have separate journals):

- Verifikation A: Bruttolön debit → 7010 (or per-employee subkonto), credit → 2710 (preliminärskatt) + 1930 (utbetalning)
- Verifikation B: Arbetsgivaravgifter debit → 7510 (lagstadgade sociala avgifter), credit → 2731 (Avräkning sociala avgifter — payable to Skatteverket, cleared when arbetsgivardeklaration is paid)
- Optional: separate verifikationer for förmånsbeskattning (förmånsvärde → 7385 cost + 2731 avräkning), traktamente (7321 inrikes / 7322 utrikes), löneväxling (1.058 factor on 7390)

The 2731 series is the **employer-contributions-payable** liability per BAS 2026 — not to be confused with 2615 (utgående moms vid import, unrelated to payroll). The arbetsgivardeklaration cycle posts the payable on book day and clears it via 1930 when the bank transfer to Skatteverket settles.

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/salary-runs/$SR_ID/book" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)"
```

Response:

```json
{
  "data": {
    "id": "sr_...",
    "status": "booked",
    "journal_entries": [
      { "id": "je_a", "voucher_number": "L-2026-005", "kind": "bruttolön" },
      { "id": "je_b", "voucher_number": "L-2026-006", "kind": "arbetsgivaravgifter" }
    ]
  },
  "meta": {
    "request_id": "req_...",
    "audit": {
      "voucher_numbers": ["L-2026-005", "L-2026-006"],
      "immutable_at": "2026-05-25T16:00:00Z"
    }
  }
}
```

If `book` fails partway (e.g. period locked while waiting for the bank-side confirmation), the route is strict-mode v1 — no partial commits. The state stays at `paid` and the response carries the `PERIOD_LOCKED` error code with the offending period.

## 6. Generate the AGI XML

`POST /salary-runs/{id}/generate-agi` produces the arbetsgivardeklaration på individnivå XML for the period. Skatteverket requires AGI monthly; the XML is embedded in the JSON response — no separate file endpoint.

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/salary-runs/$SR_ID/generate-agi" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)"
```

Response:

```json
{
  "data": {
    "agi_xml": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Skatteverket ...>",
    "agi_id": "agi_...",
    "period": { "year": 2026, "month": 5 },
    "total_brutto":  80000.00,
    "total_avgifter": 25136.00,
    "employee_count": 2,
    "generated_at": "2026-05-25T16:02:00Z"
  }
}
```

Save the XML to disk and upload it to **Skatteverket Mina Sidor → Tjänster → Arbetsgivardeklaration**. Mina Sidor accepts the file directly; no manual transcription needed. (Direct API submission requires BankID and goes through the `skatteverket` extension, not the public REST API.)

After Skatteverket confirms acceptance, store the confirmation number on the AGI:

```bash
curl -X PATCH "https://gnubok.app/api/v1/companies/$COMPANY_ID/salary-runs/$SR_ID/agi" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{ "submission_reference": "SKV-AGI-2026-05-A1B2C3" }'
```

## State machine summary

```
draft ──calculate──► review ──approve──► approved ──mark-paid──► paid ──book──► booked ──generate-agi──► (AGI XML)
```

Each transition is idempotent on `Idempotency-Key`. Retrying a transition that has already completed returns the same response with `Idempotent-Replayed: true`. Failed transitions don't advance the state — fix and retry.

## Förmånsbeskattning

When an employee has bilförmån / fri kost / friskvård, declare the förmånsvärde on the run-creation request:

```json
{
  "employee_id": "emp_...",
  "grundlön": 42000,
  "förmåner": {
    "bilförmån_värde": 4250,
    "kostförmån_dagar": 12
  }
}
```

The engine adds the förmånsvärde to bruttolön for the avgifts-basis (2731) and produces a separate `förmåner` line on the AGI. `bilförmån_värde` follows Skatteverkets schablon for 2026; pass the figure directly — the API does not compute it from car make/model/year.

## Common pitfalls

- **Don't `PATCH` after approve.** PATCH is draft-only. To correct an approved run, revert to draft (only possible before payment) or void the run and create a new one.
- **AGI period vs run period.** The AGI declaration covers `(period_year, period_month)` — the same period as the run, not the payment date. A run paid on 2026-06-02 for May still files as the May AGI.
- **F-skatt verification is the integrator's job.** The API trusts `employee.payroll_config.F_skatt` to be in sync with the employee's live Skatteverket registration. A wrong flag produces a non-compliant AGI; check the F-skattsedel before payroll runs.
- **Sociala avgifter age reduction.** Per Prop. 2025/26:66, employees who are **18–22 years old at the start of the 2026 income year (born 2003–2007)** AND employees who **have turned 67 at the start of the income year (1 January 2026)** get reduced satser. The "at the start of" boundary matters — a 66-year-old whose 67th birthday falls in February 2026 does NOT qualify for the elder reduction in 2026. The engine reads `employee.birthdate` and applies the correct sats automatically — don't override unless you've consulted [Skatteverkets table](https://www.skatteverket.se/foretagochorganisationer/skatter/arbetsgivareochinkomstuppgifter/arbetsgivaravgifteroch_skatteavdrag.4.18e1b10334ebe8bc80003392.html). The old "under 26" rule from 2024 does NOT apply for 2026 and later.
- **Bruttolöneavdrag vs nettolöneavdrag order.** Bruttolöneavdrag reduces both lön och avgifter; nettolöneavdrag only affects the employee's payout. Pass either explicitly in the run; don't mix them.

## Next steps

- **[Set up webhooks](/docs/api/cookbook/webhooks)** — subscribe to `salary_run.booked` and `agi.generated` events to drive downstream payroll integrations.
- **[Year-end closing](/docs/api/cookbook/year-end-closing)** — payroll's annual cap is the kontrolluppgift season (january of the following year).
- **[Salary-runs reference](/docs/api/reference/salary-runs)** — every parameter, every error code.

---

# Cookbook — year-end closing (bokslut)

> Lock a Swedish fiscal year, run the year-end procedures, set opening balances for the new year. Built around BFL 5 kap and 7 kap requirements (verifikationskedja, balanskontinuitet, 7-year retention).

This is the operational companion to the [Fiscal-periods reference](/docs/api/reference/fiscal-periods). Year-end is the single most consequential lifecycle event in a Swedish bookkeeping system — closing is irreversible per BFL 5 kap 8 §. Treat the steps below as a checklist, not a script to copy-paste.

## What you'll need

- A test API key with `bookkeeping:write`, `bookkeeping:read`, and `reports:read` scopes.
- All transactions for the year posted (no drafts).
- All VAT declarations for the year filed (12 monthly, 4 quarterly, or 1 annual — see the [VAT cookbook](/docs/api/cookbook/file-vat-declaration)).
- All AGI declarations filed and kontrolluppgift (KU) generated.
- The bokslut date — usually 31 december for calendar-year companies (kalenderår), or the last day of the räkenskapsår for off-calendar (brutet räkenskapsår).

## 1. Pre-flight: continuity check (IB/UB per BFL 5 kap)

Before locking anything, verify the period's continuity. BFL 5 kap requires that the closing balance (UB) of year N equals the opening balance (IB) of year N+1 on every BAS 1xxx, 2xxx account.

```bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/reports/continuity-check?from=2025-01-01&to=2025-12-31" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

Response:

```json
{
  "data": {
    "checks": [
      { "account": "1930", "year_end_ub": 156432.00, "next_year_ib": 156432.00, "match": true },
      { "account": "1510", "year_end_ub":  47100.00, "next_year_ib":  47100.00, "match": true },
      { "account": "2440", "year_end_ub":  -8200.00, "next_year_ib":  -8200.00, "match": true },
      ...
    ],
    "ib_ub_continuity_holds": true,
    "discrepancy_count": 0
  }
}
```

`ib_ub_continuity_holds: false` is a BFL violation — investigate before proceeding. A discrepancy on `1930` (bank) usually means a missed reconciliation; on `2611-2641` (moms) means a VAT declaration disagrees with the GL.

## 2. Pre-flight: voucher gaps (BFNAR 2013:2)

BFNAR 2013:2 kap 6–7 §§ requires explanations for missing voucher numbers. Run the check:

```bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/compliance/check?type=voucher_gaps&period=2025" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

For every gap returned, file an explanation via `POST /voucher-gap-explanations` before the year is closed. Skatteverket can ask for these years later under the 7-year retention rule.

## 3. Pre-flight: missing documents on posted entries

For aktiebolag, BFL 7 kap requires every verifikation to have its underlag (receipt, faktura, kontrakt) attached. The check:

```bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/compliance/check?type=unmatched_documents&period=2025" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

Attach missing documents via `POST /journal-entries/{id}/documents` before locking. After locking, the document-immutability trigger prevents detaching but allows attaching (first-link is treated as completing the audit trail, not modifying it).

## 4. Lock the period

`POST /fiscal-periods/{id}/lock` blocks all writes to the period while leaving it reversible. Use this when the year's books are "done" but you may still need to add a year-end accrual entry under supervision.

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/fiscal-periods/$PERIOD_ID/lock" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)"
```

Locked periods can be unlocked via `PATCH` with a clear reason that lands in the audit log. After year-end procedures are complete (step 6), close instead — closing is irreversible.

## 5. Run year-end procedures

`POST /fiscal-periods/{id}/year-end` is the engine-touching step. It:

1. Posts the resultatdisposition — closes every 3xxx, 7xxx, 8xxx account into `8910` (årets resultat), then transfers to `2099` (årets resultat in equity).
2. Posts the periodiseringsfond adjustment if `company_settings.use_periodiseringsfond` is true.
3. Posts överavskrivningar för fastigheter och inventarier if the depreciation differential exists.
4. Computes bolagsskatten on the taxable result (currently 20.6% for 2026) and posts the `8811` (skatt på årets resultat) ↔ `2512` (beräknad skatt) entry.
5. Generates the opening-balance journal for year N+1 in a single atomic batch — every IB entry on the new period referencing the UB of the closing period.

This is an async operation:

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/fiscal-periods/$PERIOD_ID/year-end" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "next_period_id": "fp_...",
    "result_disposition": {
      "to_periodiseringsfond": 120000,
      "to_balanserat_resultat": 380000
    }
  }'
```

Response is a 202 with an operation handle (year-end can take minutes for large books):

```json
{
  "data": {
    "operation_id": "op_...",
    "status": "queued",
    "poll_url": "/api/v1/operations/op_...",
    "webhook_event": "operation.completed"
  }
}
```

Poll the operation; on `succeeded` the result block lists every voucher posted:

```json
{
  "data": {
    "operation_id": "op_...",
    "status": "succeeded",
    "result": {
      "year_end_voucher_numbers": ["A-2025-9001", "A-2025-9002", "A-2025-9003"],
      "opening_balance_voucher_number": "A-2026-0001",
      "årets_resultat_amount": 580000.00,
      "bolagsskatt_amount": 119480.00,
      "periodiseringsfond_set_aside": 120000.00
    }
  }
}
```

## 6. Verify opening balances on the new year

After year-end runs, the new period (`next_period_id`) has IB on every balance-sheet account matching the prior period's UB. Verify:

```bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/reports/trial-balance?period=2026" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

The `opening_balance` column on every 1xxx/2xxx row should equal the `closing_balance` on the same row for 2025. `3xxx-8xxx` accounts have zero opening balance — the year-end procedure cleared them into `2099`.

If opening balances are wrong (rare; the engine validates before posting), use `POST /fiscal-periods/{id}/opening-balances` with explicit values — but this is a backstop, not a routine path. The year-end procedure should produce correct IB without manual intervention.

## 7. Close the period (irreversible)

After the declaration, the auditor's review (if applicable), and any year-end accruals are settled, close the period. **BFL 5 kap 8 §: closing is irreversible.** No code path can re-open a closed period.

```bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/fiscal-periods/$PERIOD_ID/close" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "confirmation_phrase": "close period 2025 irrevocably" }'
```

The `confirmation_phrase` is a forced typed acknowledgment. The request fails with `VALIDATION_ERROR` unless the literal phrase matches.

## 8. Generate the årsredovisning (aktiebolag only)

For an AB, the annual report (årsredovisning) is filed with Bolagsverket within 7 months of the fiscal-year end. v1 produces the K2/K3-formatted source data; you typeset it externally and submit via Bolagsverket Mina Sidor.

```bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/reports/annual-report?year=2025" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

The response carries the resultaträkning, balansräkning, kassaflödesanalys (K3 only), the noter pre-populated from the GL, and the förvaltningsberättelse template. The signing flow (every styrelseledamot must sign) is outside the API surface.

## 9. Generate INK2 / NE for the tax declaration

The tax declaration (INK2 for AB, NE-bilaga for enskild firma) is due in March/May depending on entity type and fiscal-year shape. The endpoints:

```bash
# Aktiebolag — INK2
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/reports/ink2?year=2025" \
  -H "Authorization: Bearer gnubok_sk_test_..."

# Enskild firma — NE-bilaga
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/reports/ne-bilaga?year=2025" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

These produce **two files** — `INFO.SRU` (metadata header) plus `BLANKETTER.SRU` (the declaration body) — uploaded together as a single submission to Skatteverket. **The SRU format is plain text encoded in ISO 8859-1 (NOT XML)** — a tagged record-line shape per Skatteverket's SRU specification. A single-file upload is rejected by Skatteverket's validation. This is a separate artefact from Bolagsverket's digital årsredovisning filing, which uses **iXBRL** (an XML-based standard). SRU goes to Skatteverket for INK2/INK2R/INK2S declarations; iXBRL goes to Bolagsverket for the public annual report. Don't conflate them.

> **Note:** `/reports/ink2` and `/reports/ne-bilaga` are queued as deferred endpoints — see the API changelog for current availability. Until they ship, generate the inputs via `/reports/trial-balance?year=...` and feed your tax-software of choice.

## Brutet räkenskapsår (off-calendar year)

For a company on a non-calendar fiscal year (e.g. 2024-07-01 → 2025-06-30), the entire flow is identical — substitute the actual period dates everywhere. The IB/UB continuity check, the year-end procedure, and the lock/close lifecycle all operate on the period regardless of its alignment with the calendar.

The one exception: the VAT declaration cadence is monthly/kvartalsvis/årlig regardless of the räkenskapsår shape, so a brutet räkenskapsår company files moms on calendar months while closing books on its own fiscal calendar.

## Common pitfalls

- **Don't year-end before the last month's moms is declared.** The year-end procedure expects every moms-account balance to reconcile. A pending declaration leaves dangling balances on 2611-2641.
- **Periodiseringsfond reserve cap.** Per IL 30 kap 5 § and 30 kap 6 a §, AB can set aside max 25% of the **taxable profit AFTER schablonintäkt has been added back and BEFORE the periodiseringsfond deduction itself**. The schablonintäkt rate is **(SLR + 1%) × outstanding prior-year periodiseringsfonder balance**, where SLR is Skatteverket's statslåneränta as published on 30 November of the preceding income year — for 2026 SLR is 2.55%, so the rate is **3.55%**. The engine reads the canonical rate from `tax_rates` and surfaces both the schablonintäkt amount and the resulting cap on the year-end result block; pass `to_periodiseringsfond` as the desired set-aside amount and the engine returns `VALIDATION_ERROR` with the maximum allowed value if it exceeds the cap. Note: under BFL/BFNAR 2016:10 kap 13 (materiellt samband for AB), periodiseringsfond is BOOKED as an obeskattad reserv on accounts 2110–2139, not just declared on INK2 — the engine posts the booking automatically as part of the year-end procedure.
- **Don't unlock a period after the AB's annual report is filed.** The signed annual report is a public document at Bolagsverket; unlocking and changing the books afterwards creates a discrepancy with the filed report (which is itself an audit finding). Use storno (`POST /journal-entries/{id}/reverse`) to correct in the current open period instead.
- **Year-end is async.** The operation can take minutes; don't block your request loop on it. Subscribe to `operation.completed` or poll `GET /operations/{id}` with reasonable backoff (every 5–10s).
- **The closing-period confirmation phrase is locale-sensitive.** It must match exactly. If you localise the prompt to Swedish ("stäng period 2025 oåterkalleligt"), document the exact string your UI requires — the API requires the English version above.

## Next steps

- **[VAT declaration cookbook](/docs/api/cookbook/file-vat-declaration)** — covers each monthly cycle within the year.
- **[Payroll cookbook](/docs/api/cookbook/run-payroll-and-agi)** — kontrolluppgift season (jan of year N+1) follows naturally after year-end.
- **[Fiscal-periods reference](/docs/api/reference/fiscal-periods)** — every parameter, every state transition.

---

# Changelog

> Reverse-chronological release notes for the gnubok REST API. Versions follow Stripe's dated format (`YYYY-MM-DD`). The current version is **`2026-05-12`**.

---

## 2026-05-12 *(current)*

The first stable release of the public REST API. Six phases of development covering the full agent-native surface: authentication + discovery, invoicing vertical, transactions vertical, bookkeeping engine + suppliers + compliance check, payroll + reports + import, webhooks.

### Authentication + discovery (Phase 1)

- API key auth via `Authorization: Bearer gnubok_sk_<live|test>_<random>`. 100 RPM rate limit per key.
- `gnubok_sk_test_*` keys bound to deterministic sandbox companies.
- Scope-based authorisation per endpoint (`invoices:read`, `payroll:write`, `webhooks:manage`, ...).
- Discovery: `GET /llms.txt`, `GET /api/v1/openapi.json`, `GET /.well-known/skills/index.json`.
- Health: `GET /api/v1/health`.
- Response envelope: `{ data, meta: { request_id, api_version, audit, next_cursor } }`.
- `X-Request-Id` on every response; idempotency on every write.

### Invoices vertical (Phase 2)

- **Customers**: GET list + detail, POST create + bulk-create, PATCH, DELETE.
- **Invoices**: GET list + detail, POST create, PATCH, lifecycle verbs `/mark-sent`, `/mark-paid`, `/credit`, `/send`, `/bulk-create`. PDF download at `/{id}/pdf`.
- VIES validation runs on commit for EU-business customers with a VAT number.
- Mixed-rate invoices supported — per-item `vat_rate` overrides the header rate.
- ROT/RUT-avdrag flow and supplier-invoice fakturamodellen on the AP side.

### Transactions vertical (Phase 3)

- **Transactions**: cursor-paginated GET list + detail. Single-tx verbs `/categorize`, `/uncategorize`, `/match-invoice`, `/match-supplier-invoice`. Bulk `/ingest` (up to 500), `/batch-categorize` (up to 100).
- **Reconciliation**: `POST /reconciliation/bank/run`, `GET /reconciliation/bank/status`.
- **Reads**: `GET /accounts`, `GET /fiscal-periods`.
- All write surfaces honour strict-mode (commit fully or error with no side effects).

### Bookkeeping primitives + AP + compliance (Phase 4)

- **Suppliers + supplier-invoices** vertical (mirror of Phase 2 invoices on the AP side).
- **Journal entries** primitives: `POST /journal-entries` (draft+commit), `/{id}/commit`, `/{id}/reverse` (storno) and `/{id}/correct` (rättelse) — both satisfy BFL 5 kap 5 § (storno is the canonical method of rättelse), `/batch-create`.
- **Voucher gap explanations**: `POST /voucher-gap-explanations` per BFNAR 2013:2.
- **Fiscal-periods async ops**: `/lock`, `/close`, `/year-end`, `/opening-balances`, `/currency-revaluation`. All return 202 with operation_id; poll at `GET /api/v1/operations/{id}`.
- **Compliance check**: `GET /compliance/check?type={year_end_readiness|voucher_gaps}` — pre-flight findings before submission.
- **Documents**: `POST /documents` (multipart upload, magic-number-checked), `GET /{id}/download` (15-min signed URL), `POST /{id}/link` (attach to journal entry).

### Payroll + reports + import (Phase 5)

- **Employees**: full CRUD with personnummer masking on list/create per GDPR Art.5(1)(c). Soft-delete via `is_active`.
- **Salary runs**: CRUD + lifecycle verbs `/calculate`, `/approve`, `/mark-paid`, `/book`, `/generate-agi`. State machine: draft → review → approved → paid → booked. `/generate-agi` produces and persists the arbetsgivardeklaration XML — the response carries it as `data.xml` for the integrator to upload to Skatteverket Mina Sidor (or via the optional `skatteverket` extension). gnubok 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** (14): 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 11 bank formats). Both async via `operations` substrate. **Post-SIE-import warning:** SIE files do NOT carry VAT codes or tax-rate-to-account mappings, AND they do NOT transfer behandlingshistorik (the source system's processing log required by BFNAR 2013:2 kap 8 §) or systemdokumentation. After importing from Fortnox / Visma / BL / SpeedLedger / Bokio you MUST manually reconfigure VAT codes (typically via `/settings/tax-codes`) before the first momsdeklaration; skipping this step is the most common source of incorrect VAT submissions in migrated bookkeeping. The behandlingshistorik gap must be preserved separately — under BFNAR 2013:2 kap 8 § the obligation attaches to the entire räkenskapsår, not from the import date forward. Best practice for a mid-year migration: export the source system's behandlingshistorik for the full fiscal year and archive it alongside the SIE file. gnubok starts a fresh behandlingshistorik from the import date forward; the pre-import portion of the year remains the source system's record.

### Webhooks (Phase 6 PR-1) *— shipped 2026-05-15*

- **Subscriptions**: `POST /webhooks` (HMAC secret returned exactly once), GET list + detail, PATCH, DELETE. Per-event-type elevated scope check (`salary_run.*` and `agi.generated` require `payroll:read`).
- **Delivery substrate**: per-minute Vercel cron at `/api/webhooks/dispatch/cron`. Exponential backoff `1m / 5m / 30m / 2h / 12h / 24h / 48h` (7 retries, ~72h 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](/docs/api/webhooks#verifying-signatures).
- **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. `redirect: 'error'` on every outbound POST.
- **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. gnubok keeps accounting-event delivery rows for 7 years as a voluntary operational policy (the duration aligns with BFL 7 kap on the underlying records but is not itself a statutory obligation on delivery rows). Webhook DELETE preserves the delivery audit trail (`ON DELETE SET NULL` on `webhook_id`).
- **Verbs**: `POST /webhooks/{id}/test` enqueues a synthetic event; `POST /webhook-deliveries/{id}/retry` re-enqueues a dead/delivered delivery.