# accounted API

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

The accounted API covers the financial workflows an integration needs: 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. (A few dashboard-only steps remain, e.g. generating salary payment files and sending payslips, and each cookbook calls out where one applies.)

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://app.gnubok.se/api/v1/companies \
  -H "Authorization: Bearer gnubok_sk_live_..."
```

Create keys in the accounted 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.

## Base URL

```
https://app.gnubok.se/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://app.gnubok.se/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 return an `error` object instead of `data` (and carry no `meta` block — the `request_id` moves inside `error`):

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

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://app.gnubok.se/api/v1/openapi.json)**: machine-readable OpenAPI 3.1 spec.
- **[`/.well-known/skills/index.json`](https://app.gnubok.se/.well-known/skills/index.json)**: accounted-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://app.gnubok.se/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 treated as an opaque string (a UUID is recommended, but any unique value works); the server caches the response keyed by `(user_id, company_id, idempotency_key)` for 24 hours. The canonical hash of the request body is stored alongside and compared on replay: same key + same body returns the cached response, same key + different body returns `409 IDEMPOTENCY_KEY_REUSE`.

### 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** → the cache lookup takes no lock, so both may execute; the response write is best-effort (the first writer wins, the loser hits the unique-index conflict and skips). Idempotency de-duplicates *sequential* retries reliably; serialise concurrent retries of the same action on your side.

### Required vs supported

Most write endpoints **require** an Idempotency-Key — not only the obvious creates (invoices, customers, supplier-invoices, webhooks) but also journal-entry and salary-run creation, invoice `send`, period `lock`/`close`/`year-end`, bank reconciliation runs, dimension-value creation, PATCH/DELETE on customers, and more. Each endpoint's reference page states whether the key is required. Calls that omit a required key 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://app.gnubok.se/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. A dry-run always returns **HTTP 200** with the `X-Dry-Run: true` response header — never the resource's normal success status (201, 204). The body wraps a preview, not the resource itself:

- All **local** `validation_error` shapes that a real commit would produce surface here (Zod, business rules, period locks, cross-tenant guards). Failures that depend on external providers do **not** surface — dry-run skips them (see below), so a VIES/BankID/Skatteverket rejection only appears on the real commit.
- `data.preview` holds the would-be record (same shape as the success response).
- For **financial** writes (invoices, journal entries, period ops, salary) the preview also carries `staged_operation_id`, the `journal_lines` that would be posted, `account_deltas`, and `voucher_number_assigned_on_commit` (a projection — the committed number can differ by one or two if another writer takes the next number first).

```json
{
  "data": {
    "dry_run": true,
    "preview": { ... },
    "staged_operation_id": "po_...",
    "journal_lines": [{ "account": "1510", "debit": 12500, "credit": 0 }],
    "voucher_number_assigned_on_commit": "A-2026-043"
  },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
```

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_LINK_TX_FAILED`).

Storno reversals are themselves immutable journal entries: the original audit trail remains visible per BFL 5 kap 5 §. `reversed_by_id` on the original row points at the storno; the storno carries `reverses_id` back to the original.

---

## 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://app.gnubok.se/bookkeeping/...",
      "audit_trail_url": "https://app.gnubok.se/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 Accounted: invoices paid, journal entries committed, periods locked, salary runs booked, AGI files generated. At-least-once delivery with exponential backoff over ~87 hours (about 3.6 days).

If you've used [Stripe webhooks](https://docs.stripe.com/webhooks), the model is identical: subscribe a URL to an event type, Accounted 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-create). The response includes an HMAC signing secret returned **exactly once**: store it on the receiver side immediately. If you lose it, rotate it with [`POST /api/v1/companies/{companyId}/webhooks/{webhookId}/rotate-secret`](/docs/api/reference/webhooks#post-webhooks-rotate_secret): a fresh secret is issued in place and the old one is invalidated immediately, with no change to the webhook's id or delivery history.
2. **Accounted 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, ~87 hours total, about 3.6 days). 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": "d290f1ee-6c54-4b01-90e6-d701748f0851",
  "type": "invoice.paid",
  "api_version": "2026-05-12",
  "created": 1778846400,
  "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 /api/v1/companies/{companyId}/webhooks/{webhookId}/deliveries`](/docs/api/reference/webhooks#get-webhooks-deliveries-list).
- `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` is reserved for a future field-diff feature and is currently always `null` for every event type.

## 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=1778846400,v1=2f5c...
X-Gnubok-Event: invoice.paid
X-Gnubok-Delivery: d290f1ee-6c54-4b01-90e6-d701748f0851
X-Gnubok-Api-Version: 2026-05-12
X-Request-Id: whdel_d290f1ee-6c54-4b01-90e6-d701748f0851
```

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 Accounted 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 Accounted 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 (~87h 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-webhooks-deliveries-list) 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-webhook_deliveries-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 /api/v1/companies/{companyId}/webhooks/{webhookId}/test`](/docs/api/reference/webhooks#post-webhooks-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 /api/v1/companies/{companyId}/webhooks/{webhookId}`](/docs/api/reference/webhooks#patch-webhooks-update) 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`), Accounted keeps the delivery rows for 7 years. **This is a voluntary operational audit-trail policy Accounted 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 Accounted 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 catalogue entry 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.",
    "details": { "fiscal_period_id": "..." },
    "recovery_hint": "Unlock via /fiscal-periods/{id}/unlock or pick an open period.",
    "docs_url": "https://app.gnubok.se/docs/api/errors/PERIOD_LOCKED",
    "request_id": "req_..."
  }
}
```

The `message` field is Swedish (matches the dashboard); `message_en` is English (for agent and developer logs). `recovery_hint` (when present) is a one-line description of the canonical fix. `request_id` echoes the request for support and log correlation — note it lives inside `error` on failures (there is no top-level `meta` block on an error response, unlike the success envelope). `docs_url` is built from the code (`…/docs/api/errors/<CODE>`).

## 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: `Accounted://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_CREATE_BLOCKED_BY_OPEN_PERIODS

**HTTP `409`**: Conflict

Cannot create a new fiscal year while a prior period is still open; lock it first.

**Swedish:** Du måste låsa föregående räkenskapsår innan du kan skapa ett nytt.

### 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.

**Remediation:** Either unlock the period via gnubok_unlock_period (if status is "locked", not "closed") or change the entry date to fall inside an open period.
Related tool: `gnubok_unlock_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`

### PERIOD_UNLOCK_CLOSED

**HTTP `409`**: Conflict

A closed fiscal year cannot be unlocked.

**Swedish:** Ett stängt räkenskapsår kan inte låsas upp.

### PERIOD_UNLOCK_NOT_LOCKED

**HTTP `409`**: Conflict

Period is not locked.

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

### YEAR_END_FAILED

**HTTP `400`**: Bad request

Failed to execute year-end closing.

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

### YEAR_END_NEXT_PERIOD_HAS_IB

**HTTP `400`**: Bad request

Next fiscal period already has opening balances posted; reverse them before re-running year-end.

**Swedish:** Nästa räkenskapsperiod har redan ingående balanser bokförda. Storno dem innan du kör om bokslutet.

### 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_PERSONAL_NUMBER_NOT_ALLOWED

**HTTP `400`**: Bad request

Personal numbers can only be stored for individual customers.

**Swedish:** Personnummer kan endast sparas för privatkunder.

### 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_BOOK_ALREADY_BOOKED

**HTTP `400`**: Bad request

The invoice is already booked.

**Swedish:** Fakturan är redan bokförd.

### INVOICE_BOOK_CASH_METHOD

**HTTP `400`**: Bad request

Under the cash method the invoice is booked when it is paid.

**Swedish:** Vid kontantmetoden bokförs fakturan när den betalas.

### INVOICE_BOOK_CONFLICT

**HTTP `409`**: Conflict

The invoice was booked concurrently by another request. Reload the page.

**Swedish:** Fakturan bokfördes samtidigt av en annan begäran. Ladda om sidan.

### INVOICE_BOOK_FAILED

**HTTP `500`**: Server error

Failed to book the invoice.

**Swedish:** Fakturan kunde inte bokföras.

### INVOICE_BOOK_INVALID_STATUS

**HTTP `400`**: Bad request

Only sent or overdue invoices can be booked afterwards.

**Swedish:** Endast skickade eller förfallna fakturor kan bokföras i efterhand.

### INVOICE_BOOK_NOT_BOOKABLE

**HTTP `400`**: Bad request

Credit notes and other document types are not booked through this step.

**Swedish:** Kreditfakturor och andra dokumenttyper bokförs inte via detta steg.

### INVOICE_BOOK_NO_FISCAL_PERIOD

**HTTP `400`**: Bad request

No open fiscal period covers the invoice date. Create the fiscal year first.

**Swedish:** Inget öppet räkenskapsår täcker fakturadatumet. Skapa räkenskapsåret först.

### 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_ACCRUAL_INVALID

**HTTP `400`**: Bad request

Periodisering cannot be used here. It requires the accrual method and is not supported for reverse charge, export, or proforma documents.

**Swedish:** Periodisering kan inte användas här. Den kräver faktureringsmetoden och stöds inte för omvänd skattskyldighet, export eller proforma.

### 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_REVENUE_ACCOUNT_INVALID

**HTTP `400`**: Bad request

A supplied revenue account does not exist or is not an active class-3 income account.

**Swedish:** Ett angivet försäljningskonto finns inte eller är inte ett aktivt intäktskonto (klass 3).

### INVOICE_CREATE_ROT_RUT_PERSONNUMMER_INVALID

**HTTP `400`**: Bad request

The personnummer provided for the ROT/RUT deduction is invalid.

**Swedish:** Personnumret för ROT/RUT-avdraget är ogiltigt.

### INVOICE_CREATE_ROT_RUT_VALIDATION

**HTTP `400`**: Bad request

ROT/RUT deduction failed validation. Check personnummer and housing designation.

**Swedish:** ROT/RUT-avdraget kunde inte valideras. Kontrollera personnummer och fastighetsbeteckning.

### 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_ALREADY_ISSUED

**HTTP `409`**: Conflict

The credit note has already been issued.

**Swedish:** Kreditfakturan har redan utfärdats.

### INVOICE_CREDIT_ISSUE_INCOMPLETE

**HTTP `500`**: Server error

The credit note could not be issued completely. No email was sent. Please try again.

**Swedish:** Kreditfakturan kunde inte utfärdas färdigt. Ingen e-post skickades. Försök igen.

### 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_CREDIT_REPAIR_REQUIRED

**HTTP `500`**: Server error

The credit-note voucher was created, but issuance must be completed. Retry or contact support.

**Swedish:** Kreditfakturans verifikat skapades, men utfärdandet måste slutföras. Försök igen eller kontakta support.

### INVOICE_CUSTOMER_NOT_FOUND

**HTTP `404`**: Not found

Customer not found.

**Swedish:** Kunden kunde inte hittas.

### INVOICE_DELETE_FAILED

**HTTP `500`**: Server error

The invoice could not be deleted or cancelled.

**Swedish:** Fakturan kunde inte tas bort eller makuleras.

### 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_FINALIZE_INCOMPLETE

**HTTP `500`**: Server error

The invoice number was assigned but the invoice could not be re-read. Reload the page and verify the invoice.

**Swedish:** Fakturanumret tilldelades men fakturan kunde inte läsas tillbaka. Ladda om sidan och kontrollera fakturan.

### INVOICE_FINALIZE_NOT_DRAFT

**HTTP `409`**: Conflict

Only unnumbered drafts can be finalized; this invoice already has a number or is not a draft.

**Swedish:** Endast onumrerade utkast kan skapas. Fakturan har redan ett nummer eller är inte ett utkast.

### INVOICE_MARK_SENT_BOOK_FAILED

**HTTP `500`**: Server error

The invoice could not be posted and remains a draft.

**Swedish:** Fakturan kunde inte bokföras och ligger kvar som utkast.

### INVOICE_MARK_SENT_INVALID_STATUS

**HTTP `400`**: Bad request

The invoice cannot be marked as sent in its current status.

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

### INVOICE_MARK_SENT_LINES_INVALID

**HTTP `400`**: Bad request

Custom journal lines are invalid: a row carries both debit and credit, or uses a 29xx interim account. Use line-level periodisering instead.

**Swedish:** Verifikationsraderna kan inte användas: en rad har både debet och kredit, eller använder ett interimskonto (29xx). Använd periodisering på fakturaraden istället.

### INVOICE_MARK_SENT_LINES_UNBALANCED

**HTTP `400`**: Bad request

Custom journal lines do not balance.

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

### INVOICE_MARK_SENT_RACE

**HTTP `409`**: Conflict

The invoice was changed by another request. Reload and retry.

**Swedish:** Fakturan ändrades av en annan begäran. Ladda om och försök igen.

### INVOICE_MARK_SENT_REPAIR_REQUIRED

**HTTP `500`**: Server error

The voucher was created, but its invoice link must be repaired. Contact support.

**Swedish:** Verifikatet skapades, men kopplingen till fakturan måste återställas. Kontakta support.

### INVOICE_MARK_SENT_STATUS_FAILED

**HTTP `500`**: Server error

The invoice status could not be updated.

**Swedish:** Fakturans status kunde inte uppdateras.

### INVOICE_NOT_FOUND

**HTTP `404`**: Not found

Invoice not found.

**Swedish:** Fakturan kunde inte hittas.

### 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_AMOUNT_EXCEEDS_REMAINING

**HTTP `400`**: Bad request

Transaction amount exceeds the invoice remaining amount. Use the split-payment flow to allocate the excess across one or more other invoices.

**Swedish:** Transaktionsbeloppet är större än fakturans återstående belopp. Dela betalningen och fördela överskottet på en eller flera andra fakturor.

### MATCH_INVOICE_ALREADY_HAS_PAYMENT_VOUCHER

**HTTP `409`**: Conflict

Invoice already has a payment journal entry. Link the bank transaction to the existing voucher instead, or correct the prior bookkeeping first.

**Swedish:** Fakturan har redan en betalningsverifikation. Koppla istället bankhändelsen till befintlig verifikation, eller rätta tidigare bokföring först.

### 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_CREDIT_NOTE

**HTTP `400`**: Bad request

Credit notes cannot be recorded as paid.

**Swedish:** Kreditfakturor kan inte registreras som betalda.

### 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_FORCE_CANDIDATE_MISMATCH

**HTTP `409`**: Conflict

The candidate journal entry echoed in expected_journal_entry_id does not match the one detected at request time. Re-run the duplicate-payment pre-flight to obtain the current candidate, then retry.

**Swedish:** Verifikationen som dubblettkontrollen visade matchar inte längre. Stäng dialogen och försök igen så att rätt verifikation visas.

### MATCH_INVOICE_FX_RATE_UNAVAILABLE

**HTTP `400`**: Bad request

Could not retrieve an exchange rate from Riksbanken for the payment date. Provide the rate manually from your bank statement (manual_exchange_rate field).

**Swedish:** Kunde inte hämta valutakurs från Riksbanken för betalningsdatumet. Ange kursen manuellt från ditt bankutdrag (fältet manual_exchange_rate).

### 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_POSSIBLE_DUPLICATE

**HTTP `409`**: Conflict

A posted journal entry already books the same amount on a nearby date. The user may have already booked this payment manually: link to the existing voucher or pass force=true to create a new one anyway.

**Swedish:** Det finns redan en bokförd verifikation på samma belopp och datum. Har du redan bokfört denna betalning? Koppla bankhändelsen till befintlig verifikation, eller skapa ny verifikation ändå om de inte hör ihop.

### 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_AMOUNT_EXCEEDS_REMAINING

**HTTP `400`**: Bad request

Transaction amount exceeds the supplier invoice remaining amount. Use the split-payment flow to allocate the excess across one or more other supplier invoices.

**Swedish:** Transaktionsbeloppet är större än leverantörsfakturans återstående belopp. Dela betalningen och fördela överskottet på en eller flera andra leverantörsfakturor.

### MATCH_SI_CASH_FX_UNSUPPORTED

**HTTP `400`**: Bad request

The cash method cannot handle a partial foreign-currency payment. Pay the invoice in full, switch to accrual, or book the payment manually.

**Swedish:** Kontantmetoden kan inte dela upp en delbetalning i utländsk valuta. Betala hela fakturan på en gång, byt till löpande bokföring eller bokför betalningen 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_JE_FAILED

**HTTP `500`**: Server error

Failed to create the payment voucher. The match was aborted: no changes were saved.

**Swedish:** Betalningsverifikationen kunde inte skapas. Matchningen avbröts: inga ändringar har sparats.

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

### TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH

**HTTP `409`**: Conflict

The duplicate candidate echoed in expected_duplicate_transaction_id / expected_duplicate_journal_entry_id no longer matches the one detected at request time. Re-run the booking pre-flight to obtain the current candidate, then retry.

**Swedish:** Den möjliga dubbletten som visades matchar inte längre. Ladda om och försök igen så att rätt kandidat visas.

### TRANSACTION_BOOK_POSSIBLE_DUPLICATE

**HTTP `409`**: Conflict

This business event already appears to be booked: either another transaction with the same date and amount, or a voucher that already books the same amount on the bank account (e.g. a paid invoice or a salary run). Do not book the same business event twice. Review the existing voucher and link this transaction to it, or pass force=true to book it anyway if they are genuinely unrelated.

**Swedish:** Den här affärshändelsen ser redan ut att vara bokförd: antingen en annan transaktion på samma datum och belopp, eller en verifikation som redan bokar samma belopp på bankkontot (t.ex. en betald faktura eller en lönekörning). Bokför inte samma affärshändelse två gånger. Granska den befintliga verifikationen och länka transaktionen till den, eller bokför ändå om de inte hör ihop.

### TRANSACTION_TITLE_LOCKED

**HTTP `409`**: Conflict

Cannot edit the title of a booked or matched transaction. Posted vouchers are corrected with storno.

**Swedish:** Det går inte att ändra titeln på en bokförd eller matchad transaktion. Bokförda verifikat rättas med storno.

## 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_LINE_NOT_FOUND

**HTTP `404`**: Not found

Payslip line not found.

**Swedish:** Lönebeskedsraden kunde inte hittas.

### SALARY_PAYSLIPS_NO_EMPLOYEES

**HTTP `400`**: Bad request

No employees in the salary run.

**Swedish:** Inga anställda i lönekörningen.

### SALARY_PAYSLIPS_SEND_INVALID_STATUS

**HTTP `400`**: Bad request

Payslips can only be sent after the salary run is approved.

**Swedish:** Lönespecifikationer kan bara skickas efter godkännande.

### SALARY_RUN_ALREADY_BOOKED

**HTTP `409`**: Conflict

Salary run is already booked.

**Swedish:** Lönekörningen är redan bokförd.

### 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_EMPLOYEES_NOT_DRAFT

**HTTP `400`**: Bad request

Employees can only be added or removed while the salary run is a draft.

**Swedish:** Anställda kan bara läggas till eller tas bort medan lönekörningen är ett utkast.

### SALARY_RUN_EMPLOYEE_DUPLICATE

**HTTP `409`**: Conflict

Employee is already part of this salary run.

**Swedish:** Den anställda finns redan i lönekörningen.

### SALARY_RUN_EMPLOYEE_NOT_FOUND

**HTTP `404`**: Not found

Employee is not part of this salary run.

**Swedish:** Anställd finns inte i denna lönekörning.

### SALARY_RUN_LINE_NOT_DRAFT

**HTTP `400`**: Bad request

Payslip lines can only be edited while the salary run is a draft.

**Swedish:** Lönebeskedets rader kan bara redigeras medan lönekörningen är ett utkast.

### 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.

### API_KEY_SOD_CONFLICT

**HTTP `409`**: Conflict

This key combines a staging write scope with pending_operations:approve, letting an automated agent both stage and approve postings with no human in the loop (segregation of duties, ISO 27001 A.5.3 / BFNAR 2013:2).

**Swedish:** Nyckeln kombinerar ett skriv-scope som stagar bokföring med pending_operations:approve. Då kan en automatiserad agent både skapa och godkänna verifikationer utan mänsklig granskning (ansvarsfördelning, ISO 27001 A.5.3 / BFNAR 2013:2). Bekräfta att du förstår risken för att skapa nyckeln ändå.

**Remediation:** Inform the user of the segregation-of-duties risk, then re-POST the same scopes with acknowledge_sod: true to create the key anyway.

### 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_IMPORT_DOCUMENTS_FAILED

**HTTP `500`**: Server error

Failed to import documents from provider.

**Swedish:** Kunde inte importera underlag från leverantören.

### PROVIDER_INVALID

**HTTP `400`**: Bad request

Unknown provider.

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

### PROVIDER_LICENSE_MISSING

**HTTP `403`**: Forbidden

Fortnox refused the connection because the integration license is not active. Activate the "Fortnox Integration" add-on in your Fortnox account, then reconnect. You can also import via SIE file in the meantime.

**Swedish:** Fortnox nekade anslutningen eftersom integrationslicensen inte är aktiv. Aktivera tilläggstjänsten "Fortnox Integration" i ditt Fortnox-konto (Inställningar → Tilläggstjänster) och återanslut sedan. Du kan även importera via SIE-fil under tiden.

### 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_IMPORT_REQUIRED

**HTTP `409`**: Conflict

A completed SIE import is required first. Import the SIE file (chart of accounts, opening balances and verifications) before importing customers, suppliers and invoices from this provider.

**Swedish:** Bokföringsdata (SIE) måste importeras först. Ladda upp en SIE-fil med kontoplan, ingående balanser och verifikationer innan du hämtar kunder, leverantörer och fakturor från den här leverantören.

### PROVIDER_SIE_NOT_SUPPORTED

**HTTP `400`**: Bad request

This provider does not support fetching SIE via API. Upload a SIE file manually instead.

**Swedish:** Den här leverantören stöder inte SIE-hämtning via API. Ladda upp en SIE-fil manuellt istället.

### PROVIDER_SIE_NO_YEARS

**HTTP `404`**: Not found

No fiscal years available within the supported range.

**Swedish:** Inga räkenskapsår inom det stödda intervallet hittades hos leverantören.

### PROVIDER_STATUS_FAILED

**HTTP `500`**: Server error

Failed to fetch provider status.

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

### PROVIDER_TOKEN_INVALID

**HTTP `422`**: Unprocessable

The provider rejected the credentials. Check that the account ID and application token are correct and try again.

**Swedish:** Leverantören avvisade uppgifterna. Kontrollera att konto-ID och applikationstoken stämmer och försök igen.

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

### ABSENCE_HOURS_CONFLICT

**HTTP `409`**: Conflict

Total absence hours for the day exceed 24 hours.

**Swedish:** Total frånvarotid för dagen överstiger 24 timmar.

### ABSENCE_RANGE_TOO_LARGE

**HTTP `400`**: Bad request

Absence range too large. Maximum 92 days per request.

**Swedish:** Frånvarointervallet är för stort. Max 92 dagar per anrop.

### 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: `Accounted://chart-of-accounts`

### ACCRUAL_DISSOLVE_FAILED

**HTTP `400`**: Bad request

The accrual schedule could not be dissolved.

**Swedish:** Periodiseringen kunde inte lösas upp.

### ACCRUAL_NOTHING_TO_DISSOLVE

**HTTP `400`**: Bad request

There is nothing left to dissolve on this accrual schedule.

**Swedish:** Det finns inget kvar att lösa upp.

### ACCRUAL_NOT_ACTIVE

**HTTP `400`**: Bad request

The accrual schedule is not active.

**Swedish:** Periodiseringen är inte aktiv.

### ACCRUAL_NOT_FOUND

**HTTP `404`**: Not found

Accrual schedule not found.

**Swedish:** Periodiseringen kunde inte hittas.

### ARTICLE_CREATE_FAILED

**HTTP `500`**: Server error

Failed to create article.

**Swedish:** Artikeln kunde inte skapas.

### ARTICLE_DELETE_FAILED

**HTTP `500`**: Server error

Failed to delete article.

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

### ARTICLE_DUPLICATE_NUMBER

**HTTP `409`**: Conflict

An article with that article number already exists.

**Swedish:** En artikel med samma artikelnummer finns redan.

### ARTICLE_IN_USE

**HTTP `409`**: Conflict

The article has been used on an invoice and cannot be deleted.

**Swedish:** Artikeln har använts på en faktura och kan därför inte tas bort.

### ARTICLE_NOT_FOUND

**HTTP `404`**: Not found

Article not found.

**Swedish:** Artikeln kunde inte hittas.

### ARTICLE_REVENUE_ACCOUNT_INVALID

**HTTP `400`**: Bad request

The revenue account does not exist or is not an active class-3 income account.

**Swedish:** Försäljningskontot finns inte eller är inte ett aktivt intäktskonto (klass 3).

### ARTICLE_UPDATE_FAILED

**HTTP `500`**: Server error

Failed to update article.

**Swedish:** Artikeln kunde inte uppdateras.

### ASSET_CORRECTION_BLOCKED

**HTTP `409`**: Conflict

Acquisition date, cost and category cannot be changed once the asset has been disposed or depreciation has been posted. Reverse (storno) first, or use the disposal flow.

**Swedish:** Anskaffningsdatum, anskaffningsvärde och kategori kan inte ändras efter att tillgången avyttrats eller avskrivningar bokförts. Återför (storno) först, eller använd avyttringsflödet.

### 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.

### BATCH_AMOUNT_BELOW_TX

**HTTP `400`**: Bad request

The full transaction amount must be allocated. Add more invoices or raise an amount so the sum matches the bank movement.

**Swedish:** Hela transaktionen måste fördelas. Lägg till fler fakturor eller höj något belopp så att summan motsvarar bankhändelsen.

### BATCH_AMOUNT_EXCEEDS_TX

**HTTP `400`**: Bad request

Sum of allocations exceeds the transaction amount.

**Swedish:** Summan av fördelningarna är större än transaktionens belopp.

### BATCH_CURRENCY_MISMATCH

**HTTP `400`**: Bad request

Invoice currency does not match the transaction currency. Same-currency only in v1.

**Swedish:** Fakturans valuta matchar inte transaktionens. Endast samma valuta stöds i V1.

### BATCH_DIRECTION_MISMATCH

**HTTP `400`**: Bad request

Transaction direction does not match allocation kind: customer invoices require income, supplier invoices require expense.

**Swedish:** Transaktionens riktning matchar inte fördelningens typ. Kundfakturor kräver inkommande, leverantörsfakturor utgående.

### BATCH_DUPLICATE_ALLOCATION

**HTTP `400`**: Bad request

The same invoice appears twice in the allocations. Merge the amounts or remove the duplicate.

**Swedish:** Samma faktura förekommer två gånger i fördelningen. Slå ihop beloppen eller ta bort dubbletten.

### BATCH_FX_DEVIATION_TOO_LARGE

**HTTP `400`**: Bad request

The amount you entered deviates more than 10% from the invoice's booked SEK value. Check that you entered the bank-side amount in the transaction's currency.

**Swedish:** Beloppet du angav avviker mer än 10 % från fakturans bokförda värde. Kontrollera att du fyllt i bankbeloppet i transaktionens valuta.

### BATCH_FX_RATE_MISSING

**HTTP `400`**: Bad request

The foreign-currency invoice has no exchange rate on file. Complete invoice.exchange_rate before allocating.

**Swedish:** Fakturan i annan valuta saknar växelkurs. Komplettera fakturans exchange_rate innan du fördelar.

### BATCH_INVALID_AMOUNT

**HTTP `400`**: Bad request

Allocation amount must be positive.

**Swedish:** Fördelningens belopp måste vara positivt.

### BATCH_INVALID_KIND

**HTTP `400`**: Bad request

Unknown allocation kind. Only customer_invoice and supplier_invoice are supported.

**Swedish:** Okänd typ av fördelning. Endast customer_invoice och supplier_invoice stöds.

### BATCH_INVOICE_NOT_FOUND

**HTTP `404`**: Not found

One of the invoices in the allocation could not be found.

**Swedish:** En av fakturorna i fördelningen kunde inte hittas.

### BATCH_INVOICE_NOT_OPEN

**HTTP `409`**: Conflict

One of the invoices is not in an open state.

**Swedish:** En av fakturorna är inte i ett obetalt läge och kan inte ta emot betalning.

### BATCH_MIXED_KINDS_UNSUPPORTED

**HTTP `400`**: Bad request

A single transaction cannot allocate to both customer and supplier invoices in one batch.

**Swedish:** En transaktion kan inte fördelas på både kund- och leverantörsfakturor i samma verifikat. Skapa två separata fördelningar.

### BATCH_NO_ALLOCATIONS

**HTTP `400`**: Bad request

At least one allocation is required.

**Swedish:** Minst en fördelning krävs.

### BATCH_NO_FISCAL_PERIOD

**HTTP `400`**: Bad request

No fiscal period exists for the transaction date. Create the period first.

**Swedish:** Det finns ingen öppen räkenskapsperiod för transaktionens datum. Skapa perioden först.

### BATCH_OVERSHOOT

**HTTP `400`**: Bad request

One allocation exceeds the invoice remaining amount. Lower it or split the excess across additional invoices.

**Swedish:** En av fördelningarna överskrider fakturans återstående belopp. Sänk beloppet eller fördela överskottet på fler fakturor.

### BATCH_PERIOD_LOCKED

**HTTP `409`**: Conflict

Fiscal period for the transaction date is closed/locked. Open the period or pick a different date.

**Swedish:** Räkenskapsperioden för transaktionens datum är stängd. Öppna perioden eller välj ett annat datum.

### BATCH_RPC_FAILED

**HTTP `500`**: Server error

Database error during batch allocation. Please retry.

**Swedish:** Databasfel under fördelning. Försök igen.

### BATCH_SUPPLIER_INVOICE_NOT_FOUND

**HTTP `404`**: Not found

One of the supplier invoices in the allocation could not be found.

**Swedish:** En av leverantörsfakturorna i fördelningen kunde inte hittas.

### BATCH_SUPPLIER_INVOICE_NOT_OPEN

**HTTP `409`**: Conflict

One of the supplier invoices is not in an open state.

**Swedish:** En av leverantörsfakturorna är inte i ett obetalt läge och kan inte ta emot betalning.

### BATCH_TX_ALREADY_BOOKED

**HTTP `409`**: Conflict

Transaction is already booked. Reverse the existing journal entry before re-allocating.

**Swedish:** Transaktionen är redan bokförd. Avbokföra först (storno) innan du fördelar den på flera fakturor.

### BATCH_TX_NOT_FOUND

**HTTP `404`**: Not found

Transaction not found.

**Swedish:** Transaktionen kunde inte hittas.

### BATCH_TX_ZERO_AMOUNT

**HTTP `400`**: Bad request

Zero-amount transactions cannot be allocated.

**Swedish:** Transaktioner med beloppet 0 kan inte bokföras.

### BATCH_UNAUTHORIZED

**HTTP `403`**: Forbidden

You are not authorized to allocate transactions for this company.

**Swedish:** Du har inte behörighet att fördela transaktioner för det här företaget.

### BOLAGSVERKET_API_ERROR

**HTTP `502`**

The Bolagsverket API returned an error. See details for the upstream message.

**Swedish:** Bolagsverkets tjänst svarade med ett fel. Se detaljerna och försök igen.

### BOLAGSVERKET_CONFIG_MISSING

**HTTP `503`**

Server configuration required by the Bolagsverket integration is missing (see details).

**Swedish:** Serverkonfiguration saknas för Bolagsverket-integrationen. Kontakta administratören.

### BOLAGSVERKET_ENV_NOT_ALLOWED

**HTTP `403`**: Forbidden

The selected Bolagsverket environment exceeds the platform ceiling set by BOLAGSVERKET_ENV (order: test < accept < prod; unset means test).

**Swedish:** Den valda Bolagsverket-miljön är inte tillåten i den här installationen. Plattformens BOLAGSVERKET_ENV sätter taket.

### BOLAGSVERKET_FORBIDDEN

**HTTP `403`**: Forbidden

Insufficient role to file an årsredovisning for this company (viewer members cannot submit).

**Swedish:** Otillräcklig behörighet för att lämna in årsredovisning för det här företaget.

### BOLAGSVERKET_INVALID_ENVIRONMENT

**HTTP `400`**: Bad request

Invalid Bolagsverket environment. Allowed values: 'test', 'accept', 'prod'.

**Swedish:** Ogiltig Bolagsverket-miljö. Tillåtna värden: 'test', 'accept', 'prod'.

### BOLAGSVERKET_NO_SUBSCRIPTION

**HTTP `404`**: Not found

No Bolagsverket event subscription exists for this company yet. One is created on the first submission.

**Swedish:** Ingen händelseprenumeration finns för företaget ännu.

### BOLAGSVERKET_SUBMISSION_EXISTS

**HTTP `409`**: Conflict

An active årsredovisning submission already exists for this fiscal period. Wait for Bolagsverket to resolve it before submitting again.

**Swedish:** Det finns redan en aktiv inlämning av årsredovisningen för räkenskapsåret. Invänta Bolagsverkets besked innan du lämnar in på nytt.

### BULK_BOOK_AMOUNT_MISMATCH

**HTTP `400`**: Bad request

The sum of the selected transactions does not match the bank-line net amount on the journal entry.

**Swedish:** Summan av transaktionerna stämmer inte med bankradens nettobelopp på verifikationen.

### BULK_BOOK_BOTH_SIDES_NONZERO

**HTTP `400`**: Bad request

A journal entry line cannot have both debit and credit non-zero.

**Swedish:** En verifikationsrad kan inte ha både debet och kredit nollskilda.

### BULK_BOOK_DATE_MISMATCH

**HTTP `400`**: Bad request

All transactions in a bulk booking must share the same date (BFL 5 kap 6§).

**Swedish:** Alla transaktioner i en samlingsbokföring måste ha samma datum (BFL 5 kap 6§).

### BULK_BOOK_DIRECTION_MISMATCH

**HTTP `400`**: Bad request

All transactions must be the same direction (all income or all expense).

**Swedish:** Alla transaktioner måste vara samma riktning (alla intäkter eller alla utgifter).

### BULK_BOOK_INVALID_ACCOUNT

**HTTP `400`**: Bad request

One or more accounts are not in the chart of accounts or are inactive. Pick valid BAS accounts.

**Swedish:** Ett eller flera konton finns inte i kontoplanen eller är inaktiva. Välj giltiga BAS-konton.

### BULK_BOOK_INVALID_PAYLOAD

**HTTP `400`**: Bad request

Provide either existing_journal_entry_id (link) or template_id (create new), not both, and not neither.

**Swedish:** Ange antingen existing_journal_entry_id (länkning) eller template_id (skapa ny), inte båda, och inte ingen.

### BULK_BOOK_MISSING_DESCRIPTION

**HTTP `400`**: Bad request

Description is required when creating a new combined journal entry.

**Swedish:** Beskrivning krävs för en ny samlingsverifikation.

### BULK_BOOK_MIXED_CURRENCY

**HTTP `400`**: Bad request

Bulk booking supports only single-currency batches. Select transactions in one currency at a time.

**Swedish:** Samlingsbokföring stödjer endast transaktioner i samma valuta. Välj transaktioner i en valuta åt gången.

### BULK_BOOK_NEGATIVE_LINE

**HTTP `400`**: Bad request

Journal entry lines cannot have negative amounts.

**Swedish:** Verifikationsrader kan inte ha negativa belopp.

### BULK_BOOK_NO_BANK_LINE

**HTTP `400`**: Bad request

The journal entry has no bank-account (19xx) line and cannot be linked to bank transactions.

**Swedish:** Verifikationen har ingen rad på bankkonto (19xx). Den kan inte länkas mot banktransaktioner.

### BULK_BOOK_NO_FISCAL_PERIOD

**HTTP `400`**: Bad request

No fiscal period exists for the transaction date. Create the period first.

**Swedish:** Det finns ingen öppen räkenskapsperiod för transaktionsdatumet. Skapa perioden först.

### BULK_BOOK_NO_LINES

**HTTP `400`**: Bad request

The journal entry must contain at least two lines (debit and credit).

**Swedish:** Verifikationen måste innehålla minst två rader (debit och kredit).

### BULK_BOOK_NO_TXS

**HTTP `400`**: Bad request

No transactions to book.

**Swedish:** Inga transaktioner att bokföra.

### BULK_BOOK_PERIOD_LOCKED

**HTTP `409`**: Conflict

The fiscal period for the transaction date is closed/locked.

**Swedish:** Räkenskapsperioden för transaktionsdatumet är stängd. Öppna perioden eller välj ett annat datum.

### BULK_BOOK_RPC_FAILED

**HTTP `500`**: Server error

Database error during bulk booking. Please retry.

**Swedish:** Databasfel under samlingsbokföring. Försök igen.

### BULK_BOOK_TEMPLATE_NOT_FOUND

**HTTP `404`**: Not found

The selected booking template could not be found.

**Swedish:** Den valda bokföringsmallen kunde inte hittas.

### BULK_BOOK_TXS_NOT_FOUND

**HTTP `404`**: Not found

One or more transactions could not be found in this company.

**Swedish:** En eller flera transaktioner kunde inte hittas i det aktuella företaget.

### BULK_BOOK_TX_ALREADY_BOOKED

**HTTP `409`**: Conflict

One of the selected transactions is already booked. Reverse the existing journal entry first or deselect it.

**Swedish:** En av de valda transaktionerna är redan bokförd. Avbokföra (storno) den först eller välj bort den.

### BULK_BOOK_TX_ZERO_AMOUNT

**HTTP `400`**: Bad request

Zero-amount transactions cannot be part of a bulk booking.

**Swedish:** Transaktioner med beloppet 0 kan inte ingå i en samlingsbokföring.

### BULK_BOOK_UNAUTHORIZED

**HTTP `403`**: Forbidden

You are not authorized to bulk-book transactions for this company.

**Swedish:** Du har inte behörighet att bokföra transaktioner för det här företaget.

### BULK_BOOK_UNBALANCED

**HTTP `400`**: Bad request

The journal entry does not balance: debits must equal credits.

**Swedish:** Verifikationen balanserar inte: summa debet måste lika summa kredit.

### BULK_BOOK_VOUCHER_NOT_FOUND

**HTTP `404`**: Not found

The target journal entry could not be found.

**Swedish:** Verifikationen kunde inte hittas.

### BULK_BOOK_VOUCHER_NOT_POSTED

**HTTP `409`**: Conflict

Only posted journal entries can be linked.

**Swedish:** Endast bokförda verifikationer kan länkas mot banktransaktioner.

### 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.

### CANNOT_REVERSE_STORNO

**HTTP `400`**: Bad request

A storno or correction entry cannot be reversed.

**Swedish:** En stornering eller rättelse kan inte 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.

### DIMENSION_IMPORT_FAILED

**HTTP `500`**: Server error

Failed to import existing dimension codes from journal lines.

**Swedish:** Import av befintliga dimensionskoder misslyckades.

### DIMENSION_NOT_FOUND

**HTTP `404`**: Not found

Dimension not found.

**Swedish:** Dimensionen kunde inte hittas.

### DIMENSION_SYSTEM_RENAME

**HTTP `400`**: Bad request

System dimensions (kostnadsställe/projekt) cannot be renamed.

**Swedish:** Systemdimensioner kan inte döpas om.

### DIMENSION_UPDATE_FAILED

**HTTP `500`**: Server error

Failed to update dimension.

**Swedish:** Dimensionen kunde inte uppdateras.

### DIMENSION_VALIDATION_FAILED

**HTTP `400`**: Bad request

One or more dimension codes on the entry lines are missing from the dimension registry or archived. details.issues lists each offending sie_dim_no/code.

**Swedish:** Ett angivet kostnadsställe/projekt finns inte i dimensionsregistret eller är arkiverat. Skapa värdet i registret först.

**Remediation:** Create the missing dimension value in the register (or re-activate the archived value), then retry. Only companies with dimensions enabled are validated; each issue in details.issues carries sie_dim_no, code and reason (unknown_dimension | unknown_value | archived_value).

### DIMENSION_VALUE_CREATE_FAILED

**HTTP `500`**: Server error

Failed to create dimension value.

**Swedish:** Dimensionsvärdet kunde inte skapas.

### DIMENSION_VALUE_DATES_NOT_ALLOWED

**HTTP `400`**: Bad request

Start/end dates can only be set on accumulating dimensions (e.g. projects): this dimension resets annually.

**Swedish:** Datum kan bara sättas på ackumulerande dimensioner (t.ex. projekt).

### DIMENSION_VALUE_DELETE_FAILED

**HTTP `500`**: Server error

Failed to delete dimension value.

**Swedish:** Dimensionsvärdet kunde inte tas bort.

### DIMENSION_VALUE_DUPLICATE_CODE

**HTTP `409`**: Conflict

A value with that code already exists in the dimension.

**Swedish:** Ett värde med samma kod finns redan i dimensionen.

### DIMENSION_VALUE_NOT_FOUND

**HTTP `404`**: Not found

Dimension value not found.

**Swedish:** Dimensionsvärdet kunde inte hittas.

### DIMENSION_VALUE_REFERENCED

**HTTP `409`**: Conflict

The value is referenced by posted vouchers and cannot be deleted: archive (inactivate) it instead.

**Swedish:** Värdet används på bokförda verifikat och kan inte tas bort: arkivera det istället.

**Remediation:** Archive the value instead: PATCH the dimension value with { "is_active": false }. Codes referenced by posted lines are retained for the BFL 7-year period.

### DIMENSION_VALUE_UPDATE_FAILED

**HTTP `500`**: Server error

Failed to update dimension value.

**Swedish:** Dimensionsvärdet kunde inte uppdateras.

### 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_INVALID_CONTENT

**HTTP `400`**: Bad request

The file could not be read as a valid PDF or image. Check that the file is not corrupted.

**Swedish:** Filen kunde inte läsas som en giltig PDF eller bild. Kontrollera att filen inte är skadad.

### DOC_UPLOAD_NO_FILE

**HTTP `400`**: Bad request

No file attached.

**Swedish:** Ingen fil bifogad.

### DOC_UPLOAD_PERIOD_LOCKED

**HTTP `400`**: Bad request

Cannot attach documents to entries in a locked or closed fiscal period.

**Swedish:** Det går inte att bifoga underlag till verifikationer i en låst eller stängd period.

### 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: `Accounted://period/active`

### EXTENSION_DISABLED

**HTTP `503`**

The Skatteverket integration is not enabled in this environment.

**Swedish:** Skatteverket-integrationen är inte aktiverad i denna miljö.

### 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: `Accounted://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.

### LINK_SI_VOUCHER_ALREADY_LINKED

**HTTP `409`**: Conflict

This journal entry is already linked to this supplier invoice.

**Swedish:** Verifikationen är redan länkad till den här leverantörsfakturan.

### LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING

**HTTP `400`**: Bad request

The voucher's AP debit exceeds the supplier invoice's remaining balance. Split the voucher across multiple supplier invoices via gnubok_correct_entry first, or pick a different voucher.

**Swedish:** Verifikationens leverantörsskuldsdebitering är större än leverantörsfakturans återstående belopp. Verifikationen täcker fler fakturor: välj en annan verifikation eller rätta beloppet först.

### LINK_SI_VOUCHER_CURRENCY_MISMATCH

**HTTP `400`**: Bad request

The voucher's currency does not match the supplier invoice currency.

**Swedish:** Verifikationens valuta matchar inte leverantörsfakturans. Endast verifikationer i fakturans valuta kan länkas.

### LINK_SI_VOUCHER_DB_ERROR

**HTTP `500`**: Server error

Database error while linking the voucher. Please retry.

**Swedish:** Databasfel under länkning. Försök igen.

### LINK_SI_VOUCHER_INVOICE_FULLY_PAID

**HTTP `409`**: Conflict

Supplier invoice is already fully paid.

**Swedish:** Leverantörsfakturan har redan slutbetalats. Inget mer behöver länkas.

### LINK_SI_VOUCHER_INVOICE_NOT_FOUND

**HTTP `404`**: Not found

Supplier invoice not found.

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

### LINK_SI_VOUCHER_NOT_POSTED

**HTTP `409`**: Conflict

Journal entry is not posted. Only posted entries can be linked as a payment.

**Swedish:** Verifikationen är inte bokförd. Endast bokförda verifikationer kan länkas som betalning.

### LINK_SI_VOUCHER_NO_AP_DEBIT

**HTTP `400`**: Bad request

The journal entry does not debit any accounts-payable account in the 244x range (e.g. 2440 SEK, 2441 foreign currency). Correct the booking first via a storno+correction (gnubok_correct_entry).

**Swedish:** Verifikationen debiterar inget leverantörsskuldskonto (244x). Rätta bokföringen först med en stornoverifikation som debiterar t.ex. 2440 (SEK) eller 2441 (utländsk valuta), via gnubok_correct_entry.

**Remediation:** Use gnubok_correct_entry to storno the existing voucher and re-book the payment as Dr 244x / Cr 1930, then link the corrected voucher.
Related tool: `gnubok_correct_entry`

### LINK_SI_VOUCHER_VOUCHER_NOT_FOUND

**HTTP `404`**: Not found

Journal entry not found.

**Swedish:** Verifikationen kunde inte hittas.

### LINK_TX_INVOICE_CREDIT_NOTE

**HTTP `400`**: Bad request

Credit notes cannot be recorded as paid.

**Swedish:** Kreditfakturor kan inte registreras som betalda.

### LINK_TX_INVOICE_CURRENCY_MISMATCH

**HTTP `400`**: Bad request

Transaction and invoice currency must match to link to an existing voucher. Use the match-invoice flow for cross-currency settlement.

**Swedish:** Transaktionens och fakturans valuta måste vara samma för att länka till en befintlig verifikation. Använd matchningsdialogen för valutaomräkning.

### LINK_TX_INVOICE_NOT_FOUND

**HTTP `404`**: Not found

Invoice not found.

**Swedish:** Fakturan kunde inte hittas.

### LINK_TX_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 kopplas.

### LINK_TX_INVOICE_RACE

**HTTP `409`**: Conflict

Invoice status changed concurrently. Retry the request.

**Swedish:** Fakturan ändrades samtidigt. Försök igen.

### LINK_TX_JE_NOT_FOUND

**HTTP `404`**: Not found

Journal entry not found.

**Swedish:** Verifikationen kunde inte hittas.

### LINK_TX_JE_NOT_POSTED

**HTTP `400`**: Bad request

Only posted journal entries can be linked to a transaction.

**Swedish:** Endast bokförda verifikationer kan kopplas till en banktransaktion.

### LINK_TX_TX_ALREADY_LINKED

**HTTP `400`**: Bad request

Transaction is already linked to a journal entry.

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

### LINK_VOUCHER_ALREADY_LINKED

**HTTP `409`**: Conflict

This journal entry is already linked to this invoice.

**Swedish:** Verifikationen är redan länkad till den här fakturan.

### LINK_VOUCHER_AMOUNT_EXCEEDS_REMAINING

**HTTP `400`**: Bad request

The voucher's AR credit exceeds the invoice's remaining balance. Split the voucher across multiple invoices via gnubok_correct_entry first, or pick a different voucher.

**Swedish:** Verifikationens kundfordringskreditering är större än fakturans återstående belopp. Verifikationen täcker fler fakturor: välj en annan verifikation eller rätta beloppet först.

### LINK_VOUCHER_CURRENCY_MISMATCH

**HTTP `400`**: Bad request

The voucher's currency does not match the invoice currency.

**Swedish:** Verifikationens valuta matchar inte fakturans. Endast verifikationer i fakturans valuta kan länkas.

### LINK_VOUCHER_DB_ERROR

**HTTP `500`**: Server error

Database error while linking the voucher. Please retry.

**Swedish:** Databasfel under länkning. Försök igen.

### LINK_VOUCHER_INVOICE_FULLY_PAID

**HTTP `409`**: Conflict

Invoice is already fully paid.

**Swedish:** Fakturan har redan slutbetalats. Inget mer behöver länkas.

### LINK_VOUCHER_INVOICE_NOT_FOUND

**HTTP `404`**: Not found

Invoice not found.

**Swedish:** Fakturan kunde inte hittas.

### LINK_VOUCHER_NOT_POSTED

**HTTP `409`**: Conflict

Journal entry is not posted. Only posted entries can be linked as a payment.

**Swedish:** Verifikationen är inte bokförd. Endast bokförda verifikationer kan länkas som betalning.

### LINK_VOUCHER_NO_AR_CREDIT

**HTTP `400`**: Bad request

The journal entry does not credit an accounts-receivable account (151x). Correct the booking first via a storno+correction (gnubok_correct_entry) that credits 1510.

**Swedish:** Verifikationen krediterar inte ett kundfordringskonto (151x). Bokföringen behöver först rättas med en stornoverifikation som krediterar 1510, t.ex. via gnubok_correct_entry.

**Remediation:** Use gnubok_correct_entry to storno the existing voucher and re-book the receipt as Dr 1930 / Cr 1510, then link the corrected voucher.
Related tool: `gnubok_correct_entry`

### LINK_VOUCHER_VOUCHER_NOT_FOUND

**HTTP `404`**: Not found

Journal entry not found.

**Swedish:** Verifikationen kunde inte hittas.

### MEANINGLESS_CORRECTION

**HTTP `400`**: Bad request

The correction represents no economic event: nothing to correct.

**Swedish:** Rättelsen motsvarar ingen ekonomisk händelse: det finns inget att rätta.

### NO_OPEN_PERIOD_FOR_DATE

**HTTP `400`**: Bad request

No fiscal period covers the selected date.

**Swedish:** Det finns ingen räkenskapsperiod som täcker det valda datumet. Skapa eller öppna räkenskapsåret först.

**Remediation:** Create or open the fiscal year that covers the date before retrying.
Related resource: `Accounted://period/active`

### 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_COMPANY_LOCK_DATE

**HTTP `409`**: Conflict

The company-wide bookkeeping lock date covers the period start — opening balances cannot be corrected. Remove or move the lock date under Settings → Bookkeeping and try again.

**Swedish:** Bokföringen är låst t.o.m. ett låsdatum som täcker periodens start — ingående balanser kan inte korrigeras. Ta bort eller flytta låsdatumet under Inställningar → Bokföring och försök igen.

**Remediation:** Clear or move the bookkeeping lock date (company_settings.bookkeeping_locked_through) to a date before the period start, then retry the correction.

### OB_CORRECT_FAILED

**HTTP `500`**: Server error

Opening balance correction failed.

**Swedish:** Korrigeringen av ingående balanser misslyckades.

### OB_CORRECT_NO_EXISTING

**HTTP `409`**: Conflict

The period has no opening balances to correct. Book them first.

**Swedish:** Perioden har inga ingående balanser att korrigera. Bokför dem först.

### OB_CORRECT_YEAR_END_EXISTS

**HTTP `409`**: Conflict

The period has a year-end close. Reverse the close and reopen the period before opening balances can be corrected.

**Swedish:** Perioden har ett bokslut. Återför bokslutet och öppna perioden innan ingående balanser kan korrigeras.

### 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_BALANCES_LOCKED

**HTTP `409`**: Conflict

Opening balances are locked: the employee has a booked salary run.

**Swedish:** Ingående saldon är låsta: den anställda har en bokförd lönekörning.

### OPENING_BAL_PERIOD_NOT_FOUND

**HTTP `404`**: Not found

Fiscal period not found.

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

### PO_LINK_REQUIRED

**HTTP `422`**: Unprocessable

Company settings require every supplier invoice to be linked to a purchase order.

**Swedish:** Inställningarna kräver att varje leverantörsfaktura kopplas till en inköpsorder.

### PO_NOT_FOUND

**HTTP `404`**: Not found

Purchase order not found.

**Swedish:** Inköpsordern kunde inte hittas.

### PO_THREE_WAY_MATCH_FAILED

**HTTP `422`**: Unprocessable

Three-way match failed: the supplier invoice does not reconcile with the purchase order / goods receipt.

**Swedish:** Trevägs-matchning misslyckades: leverantörsfakturan stämmer inte med inköpsordern eller godsmottagningen.

### QUOTE_CONVERSION_FAILED

**HTTP `500`**: Server error

Failed to convert quote to invoice.

**Swedish:** Offerten kunde inte konverteras till faktura.

### QUOTE_INVALID_STATE

**HTTP `400`**: Bad request

Quote is not in a state that allows this action.

**Swedish:** Offerten är inte i en status som tillåter denna åtgärd.

### QUOTE_NOT_FOUND

**HTTP `404`**: Not found

Quote not found.

**Swedish:** Offerten kunde inte hittas.

### QUOTE_NOT_QUOTE

**HTTP `400`**: Bad request

This document is not a quote.

**Swedish:** Detta dokument är inte en offert.

### QUOTE_NUMBER_ASSIGN_FAILED

**HTTP `500`**: Server error

Failed to assign quote number.

**Swedish:** Kunde inte tilldela offertnummer.

### QUOTE_TOKEN_INVALID

**HTTP `404`**: Not found

The link is invalid or has expired.

**Swedish:** Länken är ogiltig eller har gått ut.

### 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.

### ROT_RUT_BESLUT_WRONG_COMPANY

**HTTP `400`**: Bad request

The decision file's utförare does not match the company's organisation number. Check that the file was downloaded for the right company.

**Swedish:** Beslutsfilens utförare matchar inte företagets organisationsnummer. Kontrollera att filen laddades ner för rätt företag.

### ROT_RUT_FILE_CREATE_FAILED

**HTTP `500`**: Server error

The payout file could not be created.

**Swedish:** Filen kunde inte skapas.

### ROT_RUT_INVALID_STATUS_TRANSITION

**HTTP `400`**: Bad request

The status transition is not allowed from the request current state.

**Swedish:** Statusändringen är inte tillåten för begäran i dess nuvarande läge.

### ROT_RUT_INVOICES_BLOCKED

**HTTP `400`**: Bad request

One or more selected invoices cannot be included in the file. Fix the blockers or deselect the invoices.

**Swedish:** En eller flera valda fakturor kan inte ingå i filen. Åtgärda blockeringarna eller välj bort fakturorna.

### ROT_RUT_INVOICE_CONFLICT

**HTTP `409`**: Conflict

At least one invoice is already part of an active payout request.

**Swedish:** Minst en faktura ingår redan i en aktiv begäran om utbetalning.

### ROT_RUT_NO_ELIGIBLE_INVOICES

**HTTP `400`**: Bad request

None of the selected invoices can be included in the file. See the per-invoice blockers.

**Swedish:** Ingen av de valda fakturorna kan ingå i filen. Se blockeringarna per faktura.

### ROT_RUT_REQUEST_NOT_FOUND

**HTTP `404`**: Not found

Payout request not found.

**Swedish:** Begäran om utbetalning hittades inte.

### ROT_RUT_SETTLE_INVALID_STATE

**HTTP `400`**: Bad request

The payout can only be booked for a submitted request that is not already settled.

**Swedish:** Utbetalningen kan bara bokföras för en inskickad begäran som inte redan är bokförd.

### 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.

### SIE_UNDO_FAILED

**HTTP `400`**: Bad request

Failed to undo SIE import.

**Swedish:** SIE-importen kunde inte ångras.

### 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_BOOK_ALREADY_BOOKED

**HTTP `400`**: Bad request

The supplier invoice is already booked.

**Swedish:** Leverantörsfakturan är redan bokförd.

### SI_BOOK_CASH_METHOD

**HTTP `400`**: Bad request

Under the cash method the invoice is booked when it is paid.

**Swedish:** Vid kontantmetoden bokförs fakturan när den betalas.

### SI_BOOK_CONFLICT

**HTTP `409`**: Conflict

The supplier invoice was booked concurrently by another request. Reload the page.

**Swedish:** Leverantörsfakturan bokfördes samtidigt av en annan begäran. Ladda om sidan.

### SI_BOOK_FAILED

**HTTP `500`**: Server error

Failed to book the supplier invoice.

**Swedish:** Leverantörsfakturan kunde inte bokföras.

### SI_BOOK_INVALID_STATUS

**HTTP `400`**: Bad request

Only registered, approved or overdue invoices can be booked afterwards.

**Swedish:** Endast registrerade, godkända eller förfallna fakturor kan bokföras i efterhand.

### SI_BOOK_NOT_BOOKABLE

**HTTP `400`**: Bad request

Credit notes are not booked through this step.

**Swedish:** Kreditfakturor bokförs inte via detta steg.

### SI_BOOK_NO_FISCAL_PERIOD

**HTTP `400`**: Bad request

No open fiscal period covers the invoice date. Create the fiscal year first.

**Swedish:** Inget öppet räkenskapsår täcker fakturadatumet. Skapa räkenskapsåret först.

### SI_CREATE_ACCRUAL_REVERSE_CHARGE

**HTTP `400`**: Bad request

Periodisering cannot be combined with reverse charge. The expense line carries the VAT base for the VAT declaration (boxes 20-32), so the net amount cannot be deferred to an interim account.

**Swedish:** Periodisering kan inte kombineras med omvänd skattskyldighet. Kostnadsraden utgör momsunderlaget i momsdeklarationen (ruta 20-32), så nettobeloppet kan inte skjutas upp till ett interimskonto.

### 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_CREATE_NO_FISCAL_PERIOD

**HTTP `400`**: Bad request

No fiscal year covers the invoice date. Create the fiscal year first, or change the invoice date.

**Swedish:** Det finns inget räkenskapsår som täcker fakturadatumet. Lägg upp räkenskapsåret först, eller ändra fakturadatumet.

### 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_DELETE_HAS_BOOKING

**HTTP `400`**: Bad request

The supplier invoice has a posted journal entry or an accrual schedule and cannot be deleted. Create a credit note instead to reverse the bookkeeping.

**Swedish:** Leverantörsfakturan är bokförd eller har en periodisering och kan inte tas bort. Skapa en kreditfaktura i stället för att återställa bokföringen.

### 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.

### SKATTEVERKET_ACCESS_DENIED

**HTTP `403`**: Forbidden

Skatteverket denied access for this company (missing authorisation or scope).

**Swedish:** Behörighet saknas hos Skatteverket för det här företaget. Kontrollera att du är firmatecknare eller deklarationsombud.

**Remediation:** Verify the signed-in user is firmatecknare/deklarationsombud for this company at Skatteverket, then reconnect with BankID.

### SKATTEVERKET_NOT_CONNECTED

**HTTP `401`**: Unauthorized

No valid Skatteverket connection. Reconnect with BankID before retrying.

**Swedish:** Anslutningen till Skatteverket saknas eller har gått ut. Anslut med BankID under Inställningar → Skatteverket.

**Remediation:** Connect (or reconnect) to Skatteverket with BankID under Settings → Skatteverket, then retry.

### SKATTEVERKET_RATE_LIMITED

**HTTP `429`**: Rate limited

Skatteverket rate limit exceeded.

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

### TARGET_PERIOD_CLOSED

**HTTP `409`**: Conflict

The fiscal year covering the date is closed and cannot be reopened.

**Swedish:** Räkenskapsåret som täcker datumet är stängt (bokslut) och kan inte öppnas. Bokför i en öppen period i stället.

### TARGET_PERIOD_LOCKED

**HTTP `409`**: Conflict

The fiscal period covering the date is locked.

**Swedish:** Räkenskapsperioden som täcker datumet är låst.

**Remediation:** Unlock the period (if status is "locked", not "closed") or use a date inside an open period.
Related tool: `gnubok_unlock_period`

### TEST_KEY_WRITE_BLOCKED

**HTTP `403`**: Forbidden

This endpoint cannot be simulated, so it is not available with a test key. Test keys force dry-run on every write; use a live key for endpoints that do not support dry-run.

**Swedish:** Den här åtgärden kan inte simuleras och är därför inte tillgänglig med en testnyckel. Använd en live-nyckel.

**Remediation:** Use a live key for this endpoint, or pick an endpoint that supports dry-run.

### TRANSIENT_ERROR

**HTTP `503`**

Transient failure: retry the same request after a short backoff.

**Swedish:** Tillfälligt fel: försök igen om en stund.

### 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: `Accounted://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.

### VACATION_BALANCE_NOT_FOUND

**HTTP `404`**: Not found

No vacation balance exists for the employee yet.

**Swedish:** Inget semestersaldo finns för den anställda ännu.

### VACATION_CLOSE_ADJUSTMENT_FAILED

**HTTP `500`**: Server error

Vacation balances rolled but the adjustment entry failed to post. Book the adjustment manually from the report.

**Swedish:** Semestersaldon rullades men justeringsverifikationen kunde inte bokföras. Bokför justeringen manuellt från rapporten.

### VACATION_YEAR_ALREADY_CLOSED

**HTTP `409`**: Conflict

The vacation year is already closed.

**Swedish:** Semesteråret är redan stängt.

### VACATION_YEAR_NOT_ENDED

**HTTP `400`**: Bad request

The vacation year cannot be closed before it has ended.

**Swedish:** Semesteråret kan inte stängas innan det har tagit slut.

---

# API reference

> Every endpoint exposed by the Accounted REST API, grouped by resource. Auto-generated from the same Zod registry that powers the [OpenAPI 3.1 spec](https://app.gnubok.se/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.

### [Articles](/docs/api/reference/articles)

Read the article/product catalogue used to build invoice line items.

### [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.

### [Inbox items](/docs/api/reference/inbox-items)

Stamp incoming documents in the inbox to turn them into supplier invoices or transactions.

### [Dimensions](/docs/api/reference/dimensions)

Cost-centre / project dimensions and their values for tagging journal lines.

### [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 (12 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 Accounted 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 Accounted 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. Supports ROT/RUT deduction lines (items[].deduction_type = "rot"|"rut" with invoice-level deduction_personnummer + deduction_housing_designation, or deduction_apartment_number + deduction_brf_org_number for bostadsrätt), article linkage (items[].article_id + optional revenue_account override from the artikelregister), and project/cost-centre tagging (default_dimensions / items[].dimensions). 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). Set is_self_billed=true (with external_invoice_number + received_date) to instead register a received self-billing invoice (mottagen självfaktura, ML 17 kap 15§): a sale booked immediately with the counterparty's number, not a draft.

**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'.
- is_self_billed=true registers a self-billing invoice your CUSTOMER issued on your behalf (a sale for you). It is booked immediately (not a draft, no F-number), so external_invoice_number and received_date are required and it is NOT dry-run-free of side effects on the live call. Do NOT set it for a normal invoice you issue yourself.
- Project/cost-center tagging: pass default_dimensions ({"6":"P001"} = project, {"1":"KS01"} = kostnadsställe) for the whole invoice and/or items[].dimensions per line (per-line wins per key). Tags are stored on the draft and applied to the journal entry lines when the invoice is sent. When the company has the dimension registry enabled, unknown or archived codes are rejected at :send with 400 DIMENSION_VALIDATION_FAILED — list valid codes via GET /dimensions.
- ROT/RUT: set items[].deduction_type ("rot"|"rut") on labor lines plus labor_hours and work_type (Skatteverket arbetstypskod). The invoice must carry deduction_personnummer AND housing info: deduction_housing_designation (fastighetsbeteckning) for småhus, or deduction_apartment_number + deduction_brf_org_number for bostadsrätt. deduction_amount is computed server-side and cannot be set by the caller; the response exposes deduction_total and remaining_amount = total - deduction_total (Skatteverket pays the rest via 1513). Validation failures return 400 INVOICE_CREATE_ROT_RUT_VALIDATION.
- Articles: pass items[].article_id (from the artikelregister, GET /articles) to link a line to a catalog article; price/description are still taken from the request body (the API never auto-fills from the article: send the values you want on the invoice). items[].revenue_account optionally overrides the BAS class-3 account and is validated against the chart of accounts.

**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 Accounted (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 Accounted'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 Accounted 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 Accounted 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, default_dimensions (project/cost-centre tags, e.g. {"6":"P001"}; replaces the whole bag). 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.
- default_dimensions replaces the entire bag (no per-key merge): read the current value first if you want to add a tag. Send {} to clear all tags. Codes are validated against the dimension registry at :send, not at PATCH time.

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

---

---

# Articles

> Read the article/product catalogue used to build invoice line items.

## Endpoints

- [`GET` `/api/v1/companies/:companyId/articles`](#get-articles-list): List the article register (artikelregister).

---

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

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

List the article register (artikelregister).

Returns the company's articles ordered by name. Pass ?include_inactive=true to include soft-deactivated articles. Use the returned id as items[].article_id when creating invoices; housework_type carries the ROT/RUT arbetstypskod for service articles, and revenue_account the optional BAS class-3 override.

**Use when:** You need the article catalog before composing invoice lines: to resolve an article_id, read its price/VAT defaults, or find ROT/RUT-tagged service articles (housework_type set).

**Don't use for:** Creating or editing articles (dashboard-only for now). Invoice line creation itself (POST …/invoices with items[].article_id).

**Pitfalls**
- Linking article_id does NOT auto-fill the invoice line: send description, unit_price, vat_rate etc. explicitly on the item (copy them from this response).
- price_excl_vat always excludes VAT.
- housework_type is an arbetstypskod hint (e.g. BYGG, STAD); the invoice line still needs deduction_type + labor_hours + work_type set explicitly for ROT/RUT.
- Inactive articles (active=false) are hidden by default but remain linkable for historical reads.

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

**Example response**

```json
{
  "data": {
    "articles": [
      {
        "id": "0e9c…",
        "article_number": "A-0001",
        "name": "Takarbete",
        "name_en": null,
        "type": "tjanst",
        "unit": "tim",
        "price_excl_vat": 850,
        "vat_rate": 25,
        "revenue_account": null,
        "cost_price": null,
        "ean": null,
        "housework_type": "BYGG",
        "notes": null,
        "active": true,
        "created_at": "2026-05-01T09:14:33Z",
        "updated_at": "2026-05-01T09:14:33Z"
      }
    ]
  },
  "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.
- Project/cost-center tagging: pass default_dimensions ({"6":"P001"} = project, {"1":"KS01"} = kostnadsställe) for the whole invoice and/or items[].dimensions per line (per-line wins per key). The registration JE lines are tagged accordingly. When the company has the dimension registry enabled, unknown or archived codes are rejected with 400 DIMENSION_VALIDATION_FAILED — list valid codes via GET /dimensions.

**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",
  "default_dimensions": {
    "6": "P001"
  },
  "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, credit on the transaction's own settlement account, 1930 when unlinked; cash-method: collapsed registration+payment), updates supplier_invoices, inserts a supplier_invoice_payments row, and links the transaction. Handles FX differences for cross-currency payments (7960 gain / 3960 loss).

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

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

**Pitfalls**
- Cash-method companies can settle a foreign invoice in full (booked at the payment-date rate); only a PARTIAL cash-method payment across currencies is rejected (MATCH_SI_CASH_FX_UNSUPPORTED): pay in full, switch to accrual, or book manually.
- 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 runs in addition: a row matching an already-booked transaction by date, amount AND description (prefix-containment, to survive PSD2 title enrichment) is skipped even if external_id differs.
- raw_insert_only=true skips ALL post-insert pipeline steps (matching, categorization). Use for viewer-only imports.
- Max 500 items per call. For larger imports, split into pages of 500.
- Dry-run previews external_id + content dedup against BOOKED rows only; the live pipeline also dedups against unbooked bank-synced rows, so preview skips are a lower bound on the live skip count.

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

**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.
- difference compares against gl_1930_period_movement (movement excl. opening balance), NOT gl_1930_balance. Do not display gl_1930_balance next to difference.
- is_reconciled means |difference| < 0.01 for the window, an aggregate check, not a per-transaction guarantee.

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

**Example response**

```json
{
  "data": {
    "bank_transaction_total": 48150,
    "gl_1930_balance": 98150,
    "gl_1930_period_movement": 48150,
    "gl_1930_opening_balance": 50000,
    "gl_1930_correction_adjustment": 0,
    "difference": 0,
    "is_reconciled": true,
    "matched_count": 142,
    "unmatched_transaction_count": 3,
    "unmatched_gl_line_count": 2
  },
  "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.
- account_number defaults to 1930. Multi-account companies must pass the BAS code of the account they are reconciling (e.g. 1932 for a EUR account), or it silently reconciles 1930.
- Idempotency-Key is mandatory.
- Without confidence_threshold, a non-dry run applies EVERY match found, including fuzzy ones at confidence 0.75. Pass confidence_threshold (0.9 recommended, matching gnubok_auto_match_period) for unattended runs, or dry-run first and review matches.confidence before applying. Matches below the threshold are returned but not applied (skipped_below_threshold counts them).
- The 366-day window bound only applies when BOTH date_from and date_to are set; a single-sided or absent window scans full history.

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

**Example request**

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

**Example response**

```json
{
  "data": {
    "matches": [],
    "applied": 0,
    "errors": 0,
    "skipped_below_threshold": 0
  },
  "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 (ankomstnr 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 Accounted 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": {
    "fiscal_periods": [
      {
        "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 Accounted 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": {
    "accounts": [
      {
        "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. Optionally stamps the originating invoice_inbox_items row as consumed via inbox_item_id. 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. Pass inbox_item_id when the document came from the invoice inbox so the item is marked resolved in one call.

**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.
- inbox_item_id stamping is best-effort: if the stamp fails the document link still succeeds. Use POST /api/v1/companies/:companyId/inbox-items/:id/stamp to stamp independently.

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

---

---

# Inbox items

> Stamp incoming documents in the inbox to turn them into supplier invoices or transactions.

## Endpoints

- [`POST` `/api/v1/companies/:companyId/inbox-items/:id/stamp`](#post-inbox-items-stamp): Mark an inbox item as consumed by a journal entry.

---

### `POST` /api/v1/companies/:companyId/inbox-items/:id/stamp {#post-inbox-items-stamp}

**`inbox-items.stamp`** · scope `documents:write`

Mark an inbox item as consumed by a journal entry.

Sets created_journal_entry_id on an invoice_inbox_items row so the item drops out of the active inbox todo list. Use when the document was linked to a JE via a separate call and you need to close the inbox item independently.

**Use when:** An inbox document has already been attached to a verifikation (via documents link) but the inbox item itself was not stamped at link time: e.g. when using the v1 link endpoint without inbox_item_id.

**Don't use for:** Creating a new journal entry from an inbox item: use the invoice-inbox extension book-direct route for that.

**Pitfalls**
- Idempotency-Key is mandatory.
- The inbox item and journal_entry_id must both belong to the caller's company.
- Stamping with a different journal_entry_id than the one already set returns CONFLICT: the item is already resolved.

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

**Example request**

```json
{
  "journal_entry_id": "dcccb3c5-b44a-4536-82fa-f0b9bb77f900"
}
```

**Example response**

```json
{
  "data": {
    "id": "4d2fcdbb-13b3-4ff3-911f-a4cc82f1f6db",
    "created_journal_entry_id": "dcccb3c5-b44a-4536-82fa-f0b9bb77f900"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

---

# Dimensions

> Cost-centre / project dimensions and their values for tagging journal lines.

## Endpoints

- [`GET` `/api/v1/companies/:companyId/dimensions`](#get-dimensions-list): List dimensions (kostnadsställe/projekt) with their values.
- [`POST` `/api/v1/companies/:companyId/dimensions/:id/values`](#post-dimensions-values-create): Create a dimension value (kostnadsställe/projekt code).
- [`PATCH` `/api/v1/companies/:companyId/dimensions/:id/values/:valueId`](#patch-dimensions-values-update): Update a dimension value (rename, archive, set start/end date).
- [`DELETE` `/api/v1/companies/:companyId/dimensions/:id/values/:valueId`](#delete-dimensions-values-delete): Delete an unreferenced dimension value.

---

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

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

List dimensions (kostnadsställe/projekt) with their values.

Returns the company's dimension registry: SIE #DIM entries keyed by sie_dim_no (1 = Kostnadsställe, 6 = Projekt; both always exist): with the registered values (#OBJEKT) nested under each dimension. Dimensions are ordered by sort_order, values by code. Line-level tags on journal entries reference these values as {"<sie_dim_no>":"<code>"} in the `dimensions` map.

**Use when:** You need the valid dimension value codes before tagging journal-entry lines with a cost centre or project, or you are rendering a dimension picker.

**Don't use for:** Filtering reports (pass the dimension filter to the report endpoints once available) or reading which lines carry a tag (read the journal entries themselves).

**Pitfalls**
- Dimension value codes are STRINGS and case-sensitive: "P001", not 1.
- sie_dim_no is the key used in journal_entry_lines.dimensions, NOT the dimension row id.
- is_active=false values are historical (archived): do not tag new lines with them.
- resets_annually=true (dim 1) means balances reset each fiscal year; dim 6 (projekt) accumulates across years.

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

**Example response**

```json
{
  "data": {
    "dimensions": [
      {
        "id": "0e9c…",
        "sie_dim_no": 1,
        "name": "Kostnadsställe",
        "resets_annually": true,
        "is_system": true,
        "is_active": true,
        "sort_order": 10,
        "values": [
          {
            "id": "a8f1…",
            "code": "BUTIK",
            "name": "Butiken",
            "is_active": true,
            "start_date": null,
            "end_date": null
          }
        ]
      }
    ]
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/dimensions/:id/values {#post-dimensions-values-create}

**`dimensions.values.create`** · scope `bookkeeping:write`

Create a dimension value (kostnadsställe/projekt code).

Registers a new value (SIE #OBJEKT) under a dimension: e.g. a new project code under dimension 6. Requires Idempotency-Key (UUID). Supports ?dry_run=true to validate the code format without committing. The `:id` path segment is the dimension row id (from GET …/dimensions), not the sie_dim_no. Duplicate codes within the dimension return 409 DIMENSION_VALUE_DUPLICATE_CODE.

**Use when:** A voucher or invoice references a cost centre / project code that does not exist yet and the user has confirmed it should be created.

**Don't use for:** Renaming or archiving an existing value (dashboard register in v1). Tagging lines: pass the dimensions map on the journal-entry line instead.

**Pitfalls**
- Idempotency-Key is mandatory: calls without it return 400 VALIDATION_ERROR.
- The :id segment is the dimension UUID, not the SIE dimension number.
- Codes are limited to the strict Fortnox charset (A-Ö, digits, _, +, -; max 20 chars) even though historical imported codes may be looser.
- code is immutable after creation: there is no rename in v1; create the correct code and archive the wrong one.

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

**Example request**

```json
{
  "code": "P001",
  "name": "Villa Almgren tak"
}
```

**Example response**

```json
{
  "data": {
    "id": "0e9c…",
    "dimension_id": "a8f1…",
    "code": "P001",
    "name": "Villa Almgren tak",
    "is_active": true,
    "start_date": null,
    "end_date": null,
    "created_at": "2026-07-02T12:00:00Z"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `PATCH` /api/v1/companies/:companyId/dimensions/:id/values/:valueId {#patch-dimensions-values-update}

**`dimensions.values.update`** · scope `bookkeeping:write`

Update a dimension value (rename, archive, set start/end date).

Sparse update of a dimension value (SIE #OBJEKT): name, is_active (false = archive), start_date, end_date. `code` is immutable: renaming a code would orphan every journal line tagged with it; create a new value and archive the old one instead. Dates are only allowed on accumulating dimensions (resets_annually=false, e.g. dim 6 Projekt): use end_date to close a finished project. Idempotent (mandatory Idempotency-Key) and dry-runnable.

**Use when:** You need to rename a project/cost-centre, mark a finished project with an end date, or archive (is_active=false) a value that should no longer be used on new lines.

**Don't use for:** Changing the code (immutable: create + archive instead). Removing an unused value entirely (use DELETE). Tagging lines (pass dimensions on the journal-entry line or invoice).

**Pitfalls**
- Idempotency-Key is mandatory.
- The :id segment is the dimension UUID and :valueId the value UUID (both from GET …/dimensions), not SIE numbers or codes.
- start_date/end_date return 400 DIMENSION_VALUE_DATES_NOT_ALLOWED on resets_annually dimensions (dim 1 Kostnadsställe).
- Archived values (is_active=false) still appear in GET …/dimensions and remain valid on historical lines; they are only blocked for NEW tags.

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

**Example request**

```json
{
  "end_date": "2026-08-31",
  "is_active": false
}
```

**Example response**

```json
{
  "data": {
    "id": "0e9c…",
    "dimension_id": "a8f1…",
    "code": "P001",
    "name": "Villa Almgren tak",
    "is_active": false,
    "start_date": null,
    "end_date": "2026-08-31"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `DELETE` /api/v1/companies/:companyId/dimensions/:id/values/:valueId {#delete-dimensions-values-delete}

**`dimensions.values.delete`** · scope `bookkeeping:write`

Delete an unreferenced dimension value.

Hard-deletes a dimension value (SIE #OBJEKT) that no journal line references. Values used on posted or reversed verifikat are retained for the BFL 7-year archive and cannot be deleted: the DB trigger blocks it and this endpoint returns 409 DIMENSION_VALUE_REFERENCED. Archive those instead (PATCH is_active=false). Requires Idempotency-Key.

**Use when:** A project/cost-centre code was created by mistake (typo, duplicate) and has never been used on any booking.

**Don't use for:** Retiring a project that has bookings: PATCH is_active=false (and optionally end_date) instead. Deleting a whole dimension (not supported).

**Pitfalls**
- Idempotency-Key is mandatory.
- 409 DIMENSION_VALUE_REFERENCED means the value is used on booked verifikat: it can never be deleted, only archived.
- Deletion is permanent: the code can be re-created afterwards, but the old row id is gone.

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

**Example response**

```json
{
  "data": {
    "deleted": true,
    "id": "0e9c…"
  },
  "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.
- [`GET` `/api/v1/companies/:companyId/employees/:id/absence`](#get-employees-absence-list): List absence days for an employee in a date range.
- [`GET` `/api/v1/companies/:companyId/employees/:id/opening-balances`](#get-employees-opening-balances-get): Get an employee's payroll cutover opening balances.
- [`GET` `/api/v1/companies/:companyId/employees/:id/vacation-balance`](#get-employees-vacation-balance-get): Get an employee's current vacation balance.
- [`GET` `/api/v1/companies/:companyId/salary-runs/:id/employees`](#get-salary-runs-employees-list): List per-employee results of a salary run.
- [`GET` `/api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId`](#get-salary-runs-employees-get): Get one employee's payslip in a salary run.
- [`POST` `/api/v1/companies/:companyId/employees`](#post-employees-create): Create an employee.
- [`POST` `/api/v1/companies/:companyId/salary-runs/:id/employees`](#post-salary-runs-employees-add): Add an employee to a draft salary run.
- [`POST` `/api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId/lines`](#post-salary-runs-lines-create): Add a payslip line to an employee in a draft salary run.
- [`PATCH` `/api/v1/companies/:companyId/employees/:id`](#patch-employees-update): Update an employee.
- [`PUT` `/api/v1/companies/:companyId/employees/:id/absence`](#put-employees-absence-upsert): Register absence for an employee over a date range.
- [`PUT` `/api/v1/companies/:companyId/employees/:id/opening-balances`](#put-employees-opening-balances-set): Set an employee's payroll cutover opening balances.
- [`PUT` `/api/v1/companies/:companyId/employees/opening-balances`](#put-employees-opening-balances-bulk-set): Bulk-set payroll cutover opening balances (atomic).
- [`DELETE` `/api/v1/companies/:companyId/employees/:id`](#delete-employees-delete): Soft-delete an employee.
- [`DELETE` `/api/v1/companies/:companyId/employees/:id/absence`](#delete-employees-absence-delete): Delete absence days for an employee in a date range.
- [`DELETE` `/api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId`](#delete-salary-runs-employees-remove): Remove an employee from a draft salary run.

---

### `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"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/employees/:id/absence {#get-employees-absence-list}

**`employees.absence.list`** · scope `payroll:read`

List absence days for an employee in a date range.

Returns per-day absence rows (sick, vab, parental, ...) between ?from and ?to (inclusive, max 92 days). No cursor pagination: the bounded range is the page. Optional ?type filter.

**Use when:** You need an employee's registered absence: to reconcile with an external time-tracking system, to verify what the salary engine will derive, or to display a calendar.

**Don't use for:** The derived pay impact (karensavdrag, sjuklön lines): that lives on the payslip detail after :calculate. Worked hours for hourly staff: separate register, not on v1 yet.

**Pitfalls**
- Ranges over 92 days return 400 ABSENCE_RANGE_TOO_LARGE: iterate quarters instead.
- A day can carry multiple rows with different absence_type values (e.g. half-day sick + half-day vab).
- Rows may reference the salary run that consumed them via salary_run_employee_id.

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

**Example response**

```json
{
  "data": [
    {
      "salary_absence_day_id": "abs_91d2…",
      "absence_date": "2026-03-03",
      "absence_type": "sick",
      "hours": 8,
      "notes": null
    }
  ],
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/employees/:id/opening-balances {#get-employees-opening-balances-get}

**`employees.opening-balances.get`** · scope `payroll:read`

Get an employee's payroll cutover opening balances.

Returns the opening balances set for a mid-year migration (YTD gross/tax/net, vacation balances, opening semesterlöneskuld, karens adjustment) plus the lock state: locked=true once the employee has a booked salary run.

**Use when:** Verifying cutover state before the first calculated run, or checking whether balances can still be edited (locked=false).

**Don't use for:** The live vacation liability (GET /reports/vacation-liability includes the opening terms). Pre-cutover absence history: GET /employees/{id}/absence.

**Pitfalls**
- 404 NOT_FOUND when no opening balances have been set: distinct from an all-zeros row.
- locked_by_run_id names the booked run that froze the row; correcting that run unlocks it.

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

**Example response**

```json
{
  "data": {
    "employee_id": "emp_77b2…",
    "cutover_date": "2026-07-01",
    "ytd_gross": 210000,
    "vacation_paid_days_remaining": 12.5,
    "locked": false
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/employees/:id/vacation-balance {#get-employees-vacation-balance-get}

**`employees.vacation-balance.get`** · scope `payroll:read`

Get an employee's current vacation balance.

Returns the open vacation-ledger row (recomputed on every booking): entitled/taken/remaining days, sparade dagar keyed by origin year (Semesterlagen 5-year rule), forced-payout days from expired savings, and a computed SEK estimate of the individual semesterlöneskuld.

**Use when:** Answering "how many vacation days does Anna have left", pre-payroll review, or preparing the year-close.

**Don't use for:** The company-wide liability report: GET /reports/vacation-liability. Closing the year: POST /salary/vacation-year-close.

**Pitfalls**
- 404 VACATION_BALANCE_NOT_FOUND until the first booking (or year-close) touches the employee: the ledger seeds lazily.
- remaining_days can go negative if more days were taken than entitled: surface it, do not clamp.
- The SEK estimate uses the year-close day valuation (simplified BFNAR 2016:10); the booked 2920 is reconciled only at year-close.

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

**Example response**

```json
{
  "data": {
    "employee_id": "emp_77b2…",
    "vacation_year_start": "2026-01-01",
    "entitled_days": 25,
    "taken_days": 10,
    "remaining_days": 15,
    "saved_days": {
      "2025": 5
    },
    "saved_days_total": 5,
    "estimated_liability_sek": 31151.4
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `GET` /api/v1/companies/:companyId/salary-runs/:id/employees {#get-salary-runs-employees-list}

**`salary-runs.employees.list`** · scope `payroll:read`

List per-employee results of a salary run.

Returns one row per employee in the run with the calculated aggregates: gross salary, tax withheld, net pay, arbetsgivaravgifter, vacation accrual, and absence day counts. All aggregate fields are 0 until POST /calculate has run. Cursor pagination on (created_at, id).

**Use when:** You need the per-employee outcome of a run: to review before approval, to reconcile against an external system, or to pick an employee_id for the payslip drill-in.

**Don't use for:** Payslip line items or the step-by-step calculation breakdown: use GET /salary-runs/{id}/employees/{employeeId}. The employee master record: use GET /employees/{id}.

**Pitfalls**
- Aggregates are 0 until POST /calculate has advanced the run to review.
- tax_withheld_override / avgifter_amount_override are review-stage manual adjustments; the effective value is COALESCE(override, calculated).
- personnummer is masked on all payslip-shaped responses (GDPR Art.5(1)(c)); the employee detail endpoint returns the full value.

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

**Example response**

```json
{
  "data": [
    {
      "salary_run_employee_id": "sre_a8f1…",
      "employee_id": "emp_77b2…",
      "first_name": "Anna",
      "last_name": "Andersson",
      "personnummer_masked": "YYYYMMDDXXXX",
      "salary_type": "monthly",
      "gross_salary": 35000,
      "tax_withheld": -8200,
      "net_salary": 26800,
      "avgifter_amount": 10997
    }
  ],
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12",
    "next_cursor": null
  }
}
```

---

### `GET` /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId {#get-salary-runs-employees-get}

**`salary-runs.employees.get`** · scope `payroll:read`

Get one employee's payslip in a salary run.

Returns the full payslip for one employee in a run: gross/tax/net aggregates, arbetsgivaravgifter with category, vacation accrual, YTD accumulators, every payslip line item (grundlön, tillägg, avdrag, förmåner), and the step-by-step calculation_breakdown recorded by the engine.

**Use when:** You need to verify how a specific employee's pay was computed: reviewing a run before approval, answering "why is the tax this amount", or rendering a payslip in an external system.

**Don't use for:** The rendered PDF payslip: use GET /salary-runs/{id}/payslips/{employeeId}/pdf. Editing line items: POST/PATCH/DELETE on the lines endpoints.

**Pitfalls**
- calculation_breakdown is null and aggregates are 0 until POST /calculate has run.
- line_items include engine-derived rows (absence, benefits) that are regenerated on every :calculate; manual rows survive recalculation.
- The effective tax is COALESCE(tax_withheld_override, tax_withheld); same for avgifter overrides.
- personnummer is masked here (GDPR Art.5(1)(c)); GET /employees/{id} is the identity drill-in.

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

**Example response**

```json
{
  "data": {
    "salary_run_employee_id": "sre_a8f1…",
    "employee_id": "emp_77b2…",
    "first_name": "Anna",
    "last_name": "Andersson",
    "personnummer_masked": "YYYYMMDDXXXX",
    "gross_salary": 35000,
    "tax_withheld": -8200,
    "net_salary": 26800,
    "line_items": [
      {
        "salary_line_item_id": "sli_31c9…",
        "item_type": "monthly_salary",
        "description": "Grundlön",
        "amount": 35000
      }
    ]
  },
  "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"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/salary-runs/:id/employees {#post-salary-runs-employees-add}

**`salary-runs.employees.add`** · scope `payroll:write`

Add an employee to a draft salary run.

Attaches an active employee to a draft run: snapshots their pay configuration (salary, degree, tax table) onto the run and seeds the base salary line (Grundlön/Timlön). For hourly employees, pass hours_worked.

**Use when:** The run was created without this employee (e.g. hired after the run was drafted), or you create runs empty and attach employees one by one from an external system.

**Don't use for:** Changing an attached employee's pay for this month (internal per-run PATCH; not on v1). Re-attaching after removal is fine: the snapshot is retaken.

**Pitfalls**
- Draft-only: 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced.
- Attaching twice returns 409 SALARY_RUN_EMPLOYEE_DUPLICATE.
- The snapshot freezes salary/degree/tax-table at attach time: later employee edits do not flow into this run.
- Inactive (soft-deleted) employees cannot be attached: 404 EMPLOYEE_NOT_FOUND.

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

**Example request**

```json
{
  "employee_id": "emp_77b2…"
}
```

**Example response**

```json
{
  "data": {
    "salary_run_employee_id": "sre_a8f1…",
    "employee_id": "emp_77b2…",
    "salary_type": "monthly",
    "monthly_salary": 35000
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `POST` /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId/lines {#post-salary-runs-lines-create}

**`salary-runs.lines.create`** · scope `payroll:write`

Add a payslip line to an employee in a draft salary run.

Creates a salary_line_items row (bonus, overtime, gross/net deduction, benefit, traktamente, ...) for one employee in a draft run. account_number auto-resolves from item_type when omitted. Amounts are rounded to whole öre.

**Use when:** You need to add a one-off pay component before calculating: a bonus, an expense reimbursement, a union fee, or a manual correction line.

**Don't use for:** Editing the base monthly salary (PATCH the run-employee via the internal surface; not on v1 yet). Absence: register absence days instead (PUT /employees/{id}/absence); the engine derives sick/VAB lines itself.

**Pitfalls**
- Draft-only: returns 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced.
- Line edits do not recompute tax or totals: call POST /salary-runs/{id}/calculate afterwards.
- Engine-derived lines (absence, benefits) are regenerated on every :calculate; manual lines survive.

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

**Example request**

```json
{
  "item_type": "bonus",
  "description": "Kvartalsbonus Q2",
  "amount": 5000
}
```

**Example response**

```json
{
  "data": {
    "salary_line_item_id": "sli_31c9…",
    "item_type": "bonus",
    "description": "Kvartalsbonus Q2",
    "amount": 5000,
    "account_number": "7210"
  },
  "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
  }
}
```

---

### `PUT` /api/v1/companies/:companyId/employees/:id/absence {#put-employees-absence-upsert}

**`employees.absence.upsert`** · scope `payroll:write`

Register absence for an employee over a date range.

Expands [from, to] (max 92 days) to per-day rows and upserts them on the natural key (employee, date, type). Weekends are skipped unless include_weekends=true. Single day = from == to. Idempotent by construction: replaying the same PUT converges on the same rows.

**Use when:** "Anna was sick 3-7 March": one call registers the whole event. Also for pre-cutover history backfill when migrating from another payroll system (any past date is legal; imported sick days feed the karensavdrag lookback).

**Don't use for:** Vacation day REQUESTS/approval workflows (out of scope). Editing hours on one existing day inside a range: PUT the single day (from == to) with the new hours.

**Pitfalls**
- Weekends are skipped by default: pass include_weekends=true for schedules that span them.
- Upsert REPLACES the (date, type) rows in the range: hours/notes are overwritten, not merged.
- A day whose combined absence + worked hours exceed 24h returns 409 ABSENCE_HOURS_CONFLICT and the whole range is rejected (atomic).
- Registering absence does not recompute an open salary run: call POST /salary-runs/{id}/calculate afterwards.

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

**Example request**

```json
{
  "from": "2026-03-03",
  "to": "2026-03-07",
  "absence_type": "sick"
}
```

**Example response**

```json
{
  "data": {
    "count": 5,
    "days": [
      {
        "absence_date": "2026-03-03",
        "absence_type": "sick",
        "hours": 8
      }
    ]
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `PUT` /api/v1/companies/:companyId/employees/:id/opening-balances {#put-employees-opening-balances-set}

**`employees.opening-balances.set`** · scope `payroll:write`

Set an employee's payroll cutover opening balances.

Full-replace upsert of the cutover state: YTD gross/tax/net for the cutover year, paid vacation days remaining, sparade dagar keyed by origin year (5-year rule), opening semesterlöneskuld SEK (+avgifter), and karens periods not covered by imported absence rows. cutover_date must be the first of a month in the current or previous year, on/after employment_start.

**Use when:** Onboarding one employee during a mid-year migration from Fortnox/Visma/etc. For whole-company onboarding, prefer the bulk PUT /employees/opening-balances.

**Don't use for:** SIE opening balances on the LEDGER (2920/2940 arrive via the SIE import). Ongoing sick cases: import pre-cutover days via PUT /employees/{id}/absence instead.

**Pitfalls**
- Full replace: omitted numeric fields reset to 0 (their defaults). Send the complete state every time.
- 409 OPENING_BALANCES_LOCKED once the employee has a booked run; correcting that run unlocks.
- The opening liability is NOT booked by Accounted: it only feeds the vacation-liability report.
- YTD affects payslip display and reports only; per-month tax and avgifter caps never read it.

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

**Example request**

```json
{
  "cutover_date": "2026-07-01",
  "ytd_gross": 210000,
  "ytd_tax": 48000,
  "ytd_net": 162000,
  "vacation_paid_days_remaining": 12.5,
  "vacation_saved_days_by_year": {
    "2025": 5
  },
  "opening_semester_liability": 42000,
  "opening_semester_liability_avgifter": 13196.4,
  "karens_periods_adjustment": 1
}
```

**Example response**

```json
{
  "data": {
    "employee_id": "emp_77b2…",
    "cutover_date": "2026-07-01",
    "locked": false
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `PUT` /api/v1/companies/:companyId/employees/opening-balances {#put-employees-opening-balances-bulk-set}

**`employees.opening-balances.bulk-set`** · scope `payroll:write`

Bulk-set payroll cutover opening balances (atomic).

Upserts opening balances for up to 200 employees in one call. Validation is all-or-nothing: any invalid item (unknown/inactive employee, cutover before employment_start, locked by a booked run) fails the WHOLE request with a per-item error list and zero writes.

**Use when:** Onboarding a whole company mid-year from another payroll system: one call per migration file instead of N sequential PUTs.

**Don't use for:** Single-employee corrections after go-live: PUT /employees/{id}/opening-balances. Ledger opening balances (SIE import).

**Pitfalls**
- Atomic: one bad item fails everything. The error details carry item_errors[{index, employee_id, code, message}]: fix and resubmit the full set.
- Full replace per employee: resubmitting with fewer fields resets the omitted ones to 0.
- Duplicate employee_id within items is rejected outright.

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

**Example request**

```json
{
  "items": [
    {
      "employee_id": "emp_77b2…",
      "cutover_date": "2026-07-01",
      "ytd_gross": 210000,
      "ytd_tax": 48000,
      "ytd_net": 162000
    }
  ]
}
```

**Example response**

```json
{
  "data": {
    "count": 1,
    "rows": [
      {
        "employee_id": "emp_77b2…",
        "cutover_date": "2026-07-01",
        "locked": false
      }
    ]
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `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
}
```

---

### `DELETE` /api/v1/companies/:companyId/employees/:id/absence {#delete-employees-absence-delete}

**`employees.absence.delete`** · scope `payroll:write`

Delete absence days for an employee in a date range.

Deletes per-day absence rows between ?from and ?to (inclusive), optionally filtered by ?type. Returns deleted_count (200, not 204) so callers can verify how many rows went.

**Use when:** An absence event was registered by mistake or ended early: "Anna came back Thursday, delete Thu-Fri sick days".

**Don't use for:** Correcting hours on a day: PUT the day again instead. Rows already consumed by a BOOKED run: deleting them does not un-book the run; use the run correction flow.

**Pitfalls**
- Without ?type, ALL absence types in the range are deleted.
- deleted_count: 0 with a 200 means nothing matched: not an error.

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

**Example response**

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

---

### `DELETE` /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId {#delete-salary-runs-employees-remove}

**`salary-runs.employees.remove`** · scope `payroll:write`

Remove an employee from a draft salary run.

Detaches the employee from the run and cascades away their payslip line items. Draft-only. The employee master record is untouched: this only affects the run roster.

**Use when:** An employee should not be paid this period (unpaid leave the whole month, employment ended) but was auto-added when the run was created.

**Don't use for:** Deactivating the employee entirely: DELETE /employees/{id} (soft-delete). Zero-salary months: keep them in the run with a 0 base instead if you want a nollkörning on record.

**Pitfalls**
- Draft-only: 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced.
- Cascade-deletes the employee's line items in this run, including manual ones.
- Re-attaching later retakes the pay snapshot from the employee master.

**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.
- [`GET` `/api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf`](#get-salary-runs-payslip-pdf): Download one employee's payslip as PDF.
- [`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.
- [`PATCH` `/api/v1/companies/:companyId/salary-runs/:id/lines/:lineId`](#patch-salary-runs-lines-update): Update a payslip line in a draft salary run.
- [`DELETE` `/api/v1/companies/:companyId/salary-runs/:id`](#delete-salary-runs-delete): Delete a draft salary run.
- [`DELETE` `/api/v1/companies/:companyId/salary-runs/:id/lines/:lineId`](#delete-salary-runs-lines-delete): Delete a payslip line from 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: use GET /salary-runs/{id}/employees (list) or /salary-runs/{id}/employees/{employeeId} (payslip detail). Salary journal report: use GET /reports/salary-journal.

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

---

### `GET` /api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf {#get-salary-runs-payslip-pdf}

**`salary-runs.payslip.pdf`** · scope `payroll:read`

Download one employee's payslip as PDF.

Returns the rendered payslip (lönespecifikation) as application/pdf, byte-equivalent to the dashboard download. Content-Disposition is attachment with a filename derived from the period and employee name.

**Use when:** You need the payslip document itself: archiving, forwarding to the employee outside the Accounted send flow, or attaching to an external HR system.

**Don't use for:** The payslip DATA (amounts, line items): use GET /salary-runs/{id}/employees/{employeeId}, which is cheaper and structured. Emailing payslips to employees: the send flow is internal-only today.

**Pitfalls**
- The PDF renders whatever the run currently holds: for a draft run that has not been calculated, amounts are 0.
- PDF rendering takes a few hundred milliseconds; cache on the client if requesting repeatedly.

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

**Example response**

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

---

### `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"
  }
}
```

---

### `PATCH` /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId {#patch-salary-runs-lines-update}

**`salary-runs.lines.update`** · scope `payroll:write`

Update a payslip line in a draft salary run.

Updates fields on a salary_line_items row (amount, description, quantity, unit_price, flags, account_number) while the run is a draft. Amounts are rounded to whole öre.

**Use when:** You spotted a wrong amount or description on a manual line before calculating: fix it in place instead of delete + recreate.

**Don't use for:** Post-calculation tax/avgifter adjustments (review-stage overrides are not on v1). Engine-derived lines (absence/benefits): they are regenerated by :calculate, so edits are overwritten.

**Pitfalls**
- Draft-only: 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced.
- A lineId that belongs to a different run returns 404 SALARY_LINE_NOT_FOUND.
- Line edits do not recompute tax or totals: call POST /salary-runs/{id}/calculate afterwards.

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

**Example request**

```json
{
  "amount": 5500
}
```

**Example response**

```json
{
  "data": {
    "salary_line_item_id": "sli_31c9…",
    "amount": 5500
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}
```

---

### `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
}
```

---

### `DELETE` /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId {#delete-salary-runs-lines-delete}

**`salary-runs.lines.delete`** · scope `payroll:write`

Delete a payslip line from a draft salary run.

Removes a salary_line_items row while the run is a draft. Engine-derived lines (absence, benefits) reappear on the next :calculate; delete the underlying absence/benefit record instead.

**Use when:** A manual line (bonus, deduction) was added by mistake and the run has not been calculated/advanced yet.

**Don't use for:** Removing an employee from the run entirely: DELETE /salary-runs/{id}/employees/{employeeId}. Suppressing engine-derived lines: fix the source data (absence days, benefits).

**Pitfalls**
- Draft-only: 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced.
- Deleting an engine-derived line is futile: :calculate regenerates it from source data.

**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`.
- Default encoding is UTF-8 (no #FORMAT PC8 tag). Pass `encoding=cp437` to get a spec-compliant CP437-encoded file with #FORMAT PC8, required by some legacy desktop bookkeeping software.
- 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 (12 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, northmill, wise, 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 Accounted, 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.
- Account mappings are generated server-side from the file's #KONTO records (plus stored per-company overrides). By default the file's account names are carried into the chart, renaming existing accounts whose names differ: pass options.updateAccountNames=false to keep BAS default names.

**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 Accounted 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 ~87h (about 3.6 days). 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 until a terminal status. (The 202 response advertises `operation.completed` as the eventual push signal, but that webhook event is not deliverable yet — polling is the only supported completion signal today.)

**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, and idempotency patterns you'll use everywhere.

## What you'll need

- A **live** API key (`gnubok_sk_*`, no `test_` infix) from the Accounted dashboard at **/settings/api**. Run the whole walkthrough with this one key: the create→send→pay sequence chains on real IDs (the customer's `id`, then the invoice's `id`) that only committed writes return. A **test** key (`gnubok_sk_test_*`) is useful to validate request *shapes* first — it's bound to a deterministic sandbox company and the wrapper forces every write into dry-run — but because a forced dry-run never persists (`id: null`, no emails), it can't complete the stateful flow, and its sandbox `companyId` differs from your live one. So: **use one key throughout.** If you validate with a test key first, re-run step 1 with your live key before step 3 so `COMPANY_ID` matches the company you're actually writing to.
- `curl` or any HTTP client.

## 1. List the companies the key can access

This call confirms your key works and returns the `companyId` you'll use throughout. Run it with the **same key you'll use for the committing steps** so the `companyId` matches — a live key resolves to the real companies it can access; a test key resolves to its sandbox company.

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

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)

Most writes support `?dry_run=true`: the response shows the would-be record without committing. (Bank/SIE imports are the exception: they run for real or not at all.) Use it in agent test loops to validate inputs before paying the side-effect cost.

```bash
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/customers?dry_run=true" \
  -H "Authorization: Bearer gnubok_sk_..." \
  -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). The would-be record is nested under `data.preview`, with `data.dry_run: true` so agents can branch on it without reading headers:

```json
{
  "data": {
    "dry_run": true,
    "preview": {
      "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" }
}
```

With a **live** key, dropping `?dry_run=true` commits the row: the response drops the `dry_run`/`preview` wrapper and returns the record directly under `data` with a real `id` and `created_at`. Save that committed `data.id` as `CUSTOMER_ID` for the next step. With a **test** key the write is forced to dry-run either way, so you keep getting this preview (`id`/`created_at` stay `null`) — which is why the create→send→pay steps below need a live key to produce IDs to chain on.

## 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://app.gnubok.se/api/v1/companies/$COMPANY_ID/invoices" \
  -H "Authorization: Bearer gnubok_sk_..." \
  -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",
    "delivery_date": "2026-05-15",
    "currency": "SEK",
    "items": [
      { "description": "Konsultation, maj 2026", "quantity": 8, "unit": "tim", "unit_price": 1200, "vat_rate": 25 }
    ]
  }'
```

A fresh draft has `invoice_number: null`: the F-series löpnummer is allocated atomically on the first `/send`, not at create time (ML 17 kap 24§ p.2 requires an unbroken series covering only issued invoices, so abandoned drafts must not burn a number). The response carries the computed VAT lines and totals; there is no audit block, because the verifikation hasn't been posted yet (drafts are not yet räkenskapsinformation):

```json
{
  "data": {
    "id": "...",
    "invoice_number": null,
    "status": "draft",
    "subtotal": 9600.00,
    "vat_amount": 2400.00,
    "total": 12000.00,
    "items": [...]
  },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
```

(With a test key this call returns the dry-run preview shape instead: `data: { "dry_run": true, "preview": { ... } }`. The committed shape above is what a live key returns.)

## 4. Send it

`POST /invoices/{id}/send` runs a sequential pipeline: preflight PDF render (validates rendering before a number is burned) → allocate the F-series number atomically → final PDF render → email the customer via Resend (PDF attached, copy to the company). Any hard failure up to and including the email rolls nothing forward. Once the email is delivered the pipeline hits a **point of no return**: the status flip to `sent`, the journal entry (accrual + real invoices), PDF archival as underlag, and the `invoice.sent` event are best-effort, and any of their failures surface as `warnings` on the response rather than unwinding the send. It is not a single atomic transaction.

```bash
# Generate the key ONCE and reuse it on every retry of this same send, so a
# timeout-then-retry replays the original result instead of sending twice.
SEND_IDEMP=$(uuidgen)
curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/send" \
  -H "Authorization: Bearer gnubok_sk_..." \
  -H "Idempotency-Key: $SEND_IDEMP"
```

Response carries the allocated invoice number and the id of the posted journal entry (there is no `meta.audit` block on this endpoint: use `data.journal_entry_id` and `data.invoice_number` as the posting reference):

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

If any post-email step degraded, `data.warnings` is present (e.g. `JOURNAL_ENTRY_NOT_POSTED`); the invoice is marked `sent` regardless.

## 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. There is no `payment_amount` field: the default books the full remaining amount (`remaining_amount`). For a partial payment, pass a custom `lines` array (at least two balanced rows).

```bash
# One key per logical payment; reuse it across retries of this mark-paid.
PAY_IDEMP=$(uuidgen)
curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/mark-paid" \
  -H "Authorization: Bearer gnubok_sk_..." \
  -H "Idempotency-Key: $PAY_IDEMP" \
  -H "Content-Type: application/json" \
  -d '{ "payment_date": "2026-05-22" }'
```

## What just happened

This flow creates a customer, drafts a single-line invoice, posts the verifikation on `/send`, emails the PDF, and records the payment. That's **five committing calls** — list companies, create customer, create invoice, send, mark-paid — plus the optional `?dry_run=true` preview in step 2 if you run it. The engine handles BAS account selection, voucher numbering, period-lock checks, audit-trail entries, and PDF rendering. Run the committing calls with a live key; a test key can validate request shapes via dry-run first but never persists, so it can't chain the real IDs each step needs.

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.** Keep it in sync from the settings page in the dashboard (there is no v1 settings endpoint today): 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_amount`, `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

- **Use one idempotency key per logical action.** The key is treated as an opaque string (UUIDs are recommended, but any unique value works). Generate one per logical action and reuse it across retries of that same action: never on a fresh attempt. Reusing a key with a different request body is rejected with `IDEMPOTENCY_KEY_REUSE`.
- **Test keys never send or post.** `gnubok_sk_test_*` keys are simulation-only: every write is forced to dry-run, so `/send` never emails the customer, never allocates an F-series number, and never posts a voucher. On top of that the sandbox company they're bound to blocks outbound email outright (403 `sandbox_blocked`). Use a live key on a real company to actually deliver an invoice.
- **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.** It only fires when you create an `eu_business` customer with a `vat_number` (Swedish customers like the one above never trigger it). Dry-run skips the external VIES call; the real commit blocks on slow VIES responses (we time out after 10s, but that's still up to 10s added to the request). There is no v1 pre-validation endpoint today: rely on dry-run to check the rest of the payload first.

---

# Quickstart: send your first invoice

> Five minutes from a fresh sandbox to an emailed invoice. Demonstrates the auth, dry-run, and idempotency patterns you'll use everywhere.

## What you'll need

- A **live** API key (`gnubok_sk_*`, no `test_` infix) from the Accounted dashboard at **/settings/api**. Run the whole walkthrough with this one key: the create→send→pay sequence chains on real IDs (the customer's `id`, then the invoice's `id`) that only committed writes return. A **test** key (`gnubok_sk_test_*`) is useful to validate request *shapes* first — it's bound to a deterministic sandbox company and the wrapper forces every write into dry-run — but because a forced dry-run never persists (`id: null`, no emails), it can't complete the stateful flow, and its sandbox `companyId` differs from your live one. So: **use one key throughout.** If you validate with a test key first, re-run step 1 with your live key before step 3 so `COMPANY_ID` matches the company you're actually writing to.
- `curl` or any HTTP client.

## 1. List the companies the key can access

This call confirms your key works and returns the `companyId` you'll use throughout. Run it with the **same key you'll use for the committing steps** so the `companyId` matches — a live key resolves to the real companies it can access; a test key resolves to its sandbox company.

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

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)

Most writes support `?dry_run=true`: the response shows the would-be record without committing. (Bank/SIE imports are the exception: they run for real or not at all.) Use it in agent test loops to validate inputs before paying the side-effect cost.

```bash
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/customers?dry_run=true" \
  -H "Authorization: Bearer gnubok_sk_..." \
  -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). The would-be record is nested under `data.preview`, with `data.dry_run: true` so agents can branch on it without reading headers:

```json
{
  "data": {
    "dry_run": true,
    "preview": {
      "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" }
}
```

With a **live** key, dropping `?dry_run=true` commits the row: the response drops the `dry_run`/`preview` wrapper and returns the record directly under `data` with a real `id` and `created_at`. Save that committed `data.id` as `CUSTOMER_ID` for the next step. With a **test** key the write is forced to dry-run either way, so you keep getting this preview (`id`/`created_at` stay `null`) — which is why the create→send→pay steps below need a live key to produce IDs to chain on.

## 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://app.gnubok.se/api/v1/companies/$COMPANY_ID/invoices" \
  -H "Authorization: Bearer gnubok_sk_..." \
  -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",
    "delivery_date": "2026-05-15",
    "currency": "SEK",
    "items": [
      { "description": "Konsultation, maj 2026", "quantity": 8, "unit": "tim", "unit_price": 1200, "vat_rate": 25 }
    ]
  }'
```

A fresh draft has `invoice_number: null`: the F-series löpnummer is allocated atomically on the first `/send`, not at create time (ML 17 kap 24§ p.2 requires an unbroken series covering only issued invoices, so abandoned drafts must not burn a number). The response carries the computed VAT lines and totals; there is no audit block, because the verifikation hasn't been posted yet (drafts are not yet räkenskapsinformation):

```json
{
  "data": {
    "id": "...",
    "invoice_number": null,
    "status": "draft",
    "subtotal": 9600.00,
    "vat_amount": 2400.00,
    "total": 12000.00,
    "items": [...]
  },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
```

(With a test key this call returns the dry-run preview shape instead: `data: { "dry_run": true, "preview": { ... } }`. The committed shape above is what a live key returns.)

## 4. Send it

`POST /invoices/{id}/send` runs a sequential pipeline: preflight PDF render (validates rendering before a number is burned) → allocate the F-series number atomically → final PDF render → email the customer via Resend (PDF attached, copy to the company). Any hard failure up to and including the email rolls nothing forward. Once the email is delivered the pipeline hits a **point of no return**: the status flip to `sent`, the journal entry (accrual + real invoices), PDF archival as underlag, and the `invoice.sent` event are best-effort, and any of their failures surface as `warnings` on the response rather than unwinding the send. It is not a single atomic transaction.

```bash
# Generate the key ONCE and reuse it on every retry of this same send, so a
# timeout-then-retry replays the original result instead of sending twice.
SEND_IDEMP=$(uuidgen)
curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/send" \
  -H "Authorization: Bearer gnubok_sk_..." \
  -H "Idempotency-Key: $SEND_IDEMP"
```

Response carries the allocated invoice number and the id of the posted journal entry (there is no `meta.audit` block on this endpoint: use `data.journal_entry_id` and `data.invoice_number` as the posting reference):

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

If any post-email step degraded, `data.warnings` is present (e.g. `JOURNAL_ENTRY_NOT_POSTED`); the invoice is marked `sent` regardless.

## 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. There is no `payment_amount` field: the default books the full remaining amount (`remaining_amount`). For a partial payment, pass a custom `lines` array (at least two balanced rows).

```bash
# One key per logical payment; reuse it across retries of this mark-paid.
PAY_IDEMP=$(uuidgen)
curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/mark-paid" \
  -H "Authorization: Bearer gnubok_sk_..." \
  -H "Idempotency-Key: $PAY_IDEMP" \
  -H "Content-Type: application/json" \
  -d '{ "payment_date": "2026-05-22" }'
```

## What just happened

This flow creates a customer, drafts a single-line invoice, posts the verifikation on `/send`, emails the PDF, and records the payment. That's **five committing calls** — list companies, create customer, create invoice, send, mark-paid — plus the optional `?dry_run=true` preview in step 2 if you run it. The engine handles BAS account selection, voucher numbering, period-lock checks, audit-trail entries, and PDF rendering. Run the committing calls with a live key; a test key can validate request shapes via dry-run first but never persists, so it can't chain the real IDs each step needs.

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.** Keep it in sync from the settings page in the dashboard (there is no v1 settings endpoint today): 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_amount`, `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

- **Use one idempotency key per logical action.** The key is treated as an opaque string (UUIDs are recommended, but any unique value works). Generate one per logical action and reuse it across retries of that same action: never on a fresh attempt. Reusing a key with a different request body is rejected with `IDEMPOTENCY_KEY_REUSE`.
- **Test keys never send or post.** `gnubok_sk_test_*` keys are simulation-only: every write is forced to dry-run, so `/send` never emails the customer, never allocates an F-series number, and never posts a voucher. On top of that the sandbox company they're bound to blocks outbound email outright (403 `sandbox_blocked`). Use a live key on a real company to actually deliver an invoice.
- **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.** It only fires when you create an `eu_business` customer with a `vat_number` (Swedish customers like the one above never trigger it). Dry-run skips the external VIES call; the real commit blocks on slow VIES responses (we time out after 10s, but that's still up to 10s added to the request). There is no v1 pre-validation endpoint today: rely on dry-run to check the rest of the payload first.

---

# 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 Accounted can POST to. For local development use [smee.io](https://smee.io) or `ngrok`: Accounted 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://app.gnubok.se/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": "a8f1e4c2-3b5d-4e6f-8a90-1b2c3d4e5f60",
    "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, mint a fresh one with `POST /api/v1/companies/{companyId}/webhooks/{id}/rotate-secret`: it re-issues the signing secret in place (returned exactly once, like creation) without deleting the webhook or its delivery history. Rotation invalidates the old secret immediately — there is no grace period — so stage the new value on the receiver first (or `PATCH active=false` to pause delivery while you swap), then re-deploy the receiver 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://app.gnubok.se/docs/api/webhooks#nodejs) or [Python](https://app.gnubok.se/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://app.gnubok.se/api/v1/companies/$COMPANY_ID/webhooks/$WEBHOOK_ID/test" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

Response:

```json
{
  "data": { "webhook_delivery_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "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=1778846400,v1=2f5c...
X-Gnubok-Event: webhook.test
X-Gnubok-Delivery: f47ac10b-58cc-4372-a567-0e02b2c3d479
X-Gnubok-Api-Version: 2026-05-12

{"id":"f47ac10b-58cc-4372-a567-0e02b2c3d479","type":"webhook.test","api_version":"2026-05-12","created":1778846400,"data":{"object":{"hello":"from Accounted","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://app.gnubok.se/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": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "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": "req_...",
    "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://app.gnubok.se/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" }'
```

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 Accounted 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://app.gnubok.se/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 a new `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://app.gnubok.se/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 Accounted 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 the full retry schedule (~87h, roughly 3.6 days). 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 Accounted can POST to. For local development use [smee.io](https://smee.io) or `ngrok`: Accounted 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://app.gnubok.se/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": "a8f1e4c2-3b5d-4e6f-8a90-1b2c3d4e5f60",
    "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, mint a fresh one with `POST /api/v1/companies/{companyId}/webhooks/{id}/rotate-secret`: it re-issues the signing secret in place (returned exactly once, like creation) without deleting the webhook or its delivery history. Rotation invalidates the old secret immediately — there is no grace period — so stage the new value on the receiver first (or `PATCH active=false` to pause delivery while you swap), then re-deploy the receiver 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://app.gnubok.se/docs/api/webhooks#nodejs) or [Python](https://app.gnubok.se/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://app.gnubok.se/api/v1/companies/$COMPANY_ID/webhooks/$WEBHOOK_ID/test" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

Response:

```json
{
  "data": { "webhook_delivery_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "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=1778846400,v1=2f5c...
X-Gnubok-Event: webhook.test
X-Gnubok-Delivery: f47ac10b-58cc-4372-a567-0e02b2c3d479
X-Gnubok-Api-Version: 2026-05-12

{"id":"f47ac10b-58cc-4372-a567-0e02b2c3d479","type":"webhook.test","api_version":"2026-05-12","created":1778846400,"data":{"object":{"hello":"from Accounted","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://app.gnubok.se/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": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "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": "req_...",
    "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://app.gnubok.se/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" }'
```

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 Accounted 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://app.gnubok.se/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 a new `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://app.gnubok.se/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 Accounted 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 the full retry schedule (~87h, roughly 3.6 days). 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 Accounted, 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 `operations:read` scopes. (There is no `imports:write` scope: the bank import runs under `transactions:write`, and polling the operation needs `operations:read`.)
- A bank statement file in one of the supported formats: CSV (SEB / Swedbank / Handelsbanken / Nordea / Nordea Business / Länsförsäkringar / ICA Banken / Skandia / Lunar / Northmill / generic CSV auto-detected), CAMT.053 XML, or a generic 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`). `/imports/bank` resolves the settlement account automatically; `settlement_account` is an explicit parameter of `POST /transactions/ingest`, not of the file upload.

## 1. Upload the bank file

`POST /imports/bank` accepts multipart upload. Format detection is automatic; the detected format is reported in the polled operation result (`result.format`), not in the upload response. The endpoint kicks off an async operation: you'll poll for the result.

```bash
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/imports/bank" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -F "file=@statement-2026-04.csv"
```

Response is a 202 with the operation handle:

```json
{
  "data": {
    "operation_id": "3f2a9c8e-...",
    "type": "import.bank",
    "status": "queued",
    "poll_url": "/api/v1/operations/3f2a9c8e-...",
    "webhook_event": "operation.completed"
  },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
```

## 2. Poll until the import completes

Polling is currently the way to detect import completion. `operation.completed` is named in the response as the intended push signal but is not yet a deliverable webhook event, so poll for now; the categorisation events (`transaction.categorized` / `transaction.reconciled`) that fire later in this flow are subscribable today ([cookbook](/docs/api/cookbook/webhooks)). The operation lifecycle is `queued → running → succeeded | failed | cancelled`.

```bash
curl "https://app.gnubok.se/api/v1/operations/$OPERATION_ID" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

On `succeeded`:

```json
{
  "data": {
    "operation_id": "3f2a9c8e-...",
    "type": "import.bank",
    "status": "succeeded",
    "progress": { "current": 187, "total": 187, "phase": "complete" },
    "result": {
      "format": "seb",
      "file_hash": "e3b0c44298fc...",
      "transactions_imported": 165,
      "transactions_duplicates": 22,
      "transactions_reconciled": 0,
      "transactions_auto_categorized": 0,
      "transactions_errors": 0,
      "date_from": "2026-04-01",
      "date_to": "2026-04-30"
    },
    "started_at": "2026-05-01T08:00:00Z",
    "completed_at": "2026-05-01T08:00:04Z"
  }
}
```

Note the dedup: each row gets a stable `external_id` (composed from date + amount + counterparty) as the primary dedup key, and a secondary content match on `(date, amount, description)` against already-booked rows also skips duplicates. Re-uploading the same file is safe.

## 3. List uncategorised transactions

After ingest the rows are in `transactions` but unbooked (`journal_entry_id: null`, `category: null`). The list `status` filter takes `booked` | `unbooked` (`unbooked` filters on `journal_entry_id IS NULL`), and date filtering uses `date_from` / `date_to` (there is no `period` param). List them:

```bash
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/transactions?status=unbooked&date_from=2026-04-01&date_to=2026-04-30&limit=50" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

Response (cursor-paginated, newest-imported first — ordered by `created_at` DESC):

```json
{
  "data": [
    {
      "id": "tx_...",
      "date": "2026-04-03",
      "description": "SEB CARD - SJ 25-...",
      "amount": -487.00,
      "currency": "SEK",
      "reference": null,
      "merchant_name": "SJ",
      "journal_entry_id": null,
      "invoice_id": null,
      "supplier_invoice_id": null,
      "is_business": null,
      "category": null,
      "import_source": "bank_file",
      "created_at": "2026-05-01T08:00:04Z"
    },
    ...
  ],
  "meta": { "request_id": "req_...", "next_cursor": "eyJ0cyI6Ij..." }
}
```

## 4. Decide the category

There is no category-suggestion endpoint in the v1 REST API. Ranked suggestions (from the description, counterparty history, and your booking-template library) are surfaced by the dashboard and by the MCP tool `gnubok_suggest_categories` — not over REST.

In a REST integration you supply the category yourself: choose a `category`, or pass an explicit `account_override` / `template_id` / `counterparty_template_id`, based on your own mapping logic. When categorising interactively, use the dry-run in the next step to preview the resolved verifikation before you commit.

## 5. Commit the categorisation

`POST /transactions/{id}/categorize` books the transaction (creates the verifikation directly — it is not staged for approval). `is_business` is required. Then supply a mapping: a `template_id` or `counterparty_template_id` (these take precedence), or a `category` for the default mapping. `account_override` is an optional posting-account override that **combines with `category`** — it applies only when neither `template_id` nor `counterparty_template_id` is present (as in the example below). Dry-run first to preview the resolved mapping:

```bash
curl "https://app.gnubok.se/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 '{
    "is_business": true,
    "category": "expense_travel",
    "account_override": "5800",
    "vat_treatment": "standard_25"
  }'
```

The dry-run returns the resolved mapping (debit / credit accounts + VAT lines) under `{ dry_run: true, preview }` — not a full posted journal, and no voucher number is burned:

```json
{
  "data": {
    "dry_run": true,
    "preview": {
      "category": "expense_travel",
      "mapping": {
        "debit_account": "5800",
        "credit_account": "1930",
        "vat_lines": [
          { "account_number": "2641", "debit_amount": 97.40, "credit_amount": 0, "description": "Ingående moms 25%" }
        ],
        "all_lines_complete": false
      },
      "would_create_journal_entry": true,
      "already_had_journal_entry": false
    }
  },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
```

Drop `?dry_run=true` and reuse the same `Idempotency-Key` to commit. The commit response returns `journal_entry_id` (plus `journal_entry_created` and `category`); it does not include a voucher number or audit block — fetch the voucher via the journal entry if you need it.

## 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://app.gnubok.se/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", "categorization": { "is_business": true, "category": "expense_travel", "account_override": "5800", "vat_treatment": "standard_25" } },
      { "transaction_id": "tx_2", "categorization": { "is_business": true, "category": "income_services", "account_override": "3001", "vat_treatment": "standard_25" } },
      ...
    ]
  }'
```

Response shape: every item carries its own `ok` flag and `transaction_id`:

```json
{
  "data": {
    "results": [
      { "ok": true,  "request_index": 0, "transaction_id": "tx_1", "data": { "journal_entry_created": true, "journal_entry_id": "je_...", "category": "expense_travel" } },
      { "ok": false, "request_index": 1, "transaction_id": "tx_2", "error": { "code": "PERIOD_LOCKED", "message": "Period is locked or closed; cannot post journal entry." } }
    ],
    "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://app.gnubok.se/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_..." }'
```

There is no `payment_date` field: the payment date is taken from the transaction's own `date`. For supplier-invoice payments use `POST /transactions/{id}/match-supplier-invoice` — same shape, but the body field is `supplier_invoice_id` and the transaction must be negative (expense).

## Multicurrency

Bank statements that include non-SEK transactions are imported with the foreign amount preserved in `amount` + `currency`; the booked SEK value is stored in `amount_sek`. 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.

The ledger is SEK-denominated; foreign amounts are converted to SEK automatically from the cached daily Riksbanken snapshot. There is no public FX-rate endpoint — rates are applied by the engine at categorisation time. (For cross-currency invoice settlement where no rate is published for a date, `match-invoice` accepts a `manual_exchange_rate`.)

## Common pitfalls

- **Re-running the same file is safe; date-only overlap is also safe.** Dedup is primarily by a stable `external_id` (date + amount + counterparty); a secondary content match on `(date, amount, description)` against already-booked rows also skips, so partial overlap of two statements doesn't double-import.
- **Settlement account selection matters.** The wrong settlement account silently breaks bank reconciliation later. `1930` (företagskonto) is the SEK default; a foreign-currency bank account uses its own asset account (e.g. `1932` for USD). `/imports/bank` resolves the settlement account automatically — to set it explicitly, ingest via `POST /transactions/ingest` with `settlement_account`.
- **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-62 from your posted general-ledger entries and prepare the numbers for manual submission to Skatteverket.

This is the operational companion to the [Reports reference](/docs/api/reference/reports). 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 all the rutor plus supporting counts and a per-source breakdown. The period is selected with three **required** params — `period_type` (monthly|quarterly|yearly), `year`, and `period` (monthly 1-12, quarterly 1-4, yearly 1) — plus an optional `accounting_method` (accrual|cash, default accrual):

```bash
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/reports/vat-declaration?period_type=monthly&year=2026&period=4" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

Response (abbreviated):

```json
{
  "data": {
    "period": { "type": "monthly", "year": 2026, "period": 4, "start": "2026-04-01", "end": "2026-04-30" },
    "rutor": {
      "ruta05": 133300.00,
      "ruta06":      0.00,
      "ruta10":  31075.00,
      "ruta11":    720.00,
      "ruta12":    180.00,
      "ruta20":      0.00,
      "ruta21":  20800.00,
      "ruta22":      0.00,
      "ruta23":      0.00,
      "ruta24":      0.00,
      "ruta30":   5200.00,
      "ruta31":      0.00,
      "ruta32":      0.00,
      "ruta39":   3450.00,
      "ruta40":      0.00,
      "ruta48":  17547.00,
      "ruta50":      0.00,
      "ruta60":      0.00,
      "ruta61":      0.00,
      "ruta62":      0.00,
      "ruta49":  19628.00
    },
    "invoiceCount": 42,
    "transactionCount": 15,
    "breakdown": {
      "invoices": {
        "ruta05": 133300.00, "ruta06": 0.00, "ruta07": 0.00,
        "ruta10": 31075.00, "ruta11": 720.00, "ruta12": 180.00,
        "ruta39": 3450.00, "ruta40": 0.00,
        "base25": 124300.00, "base12": 6000.00, "base6": 3000.00
      },
      "transactions": { "ruta48": 17547.00 },
      "receipts": { "ruta48": 0.00 },
      "reverseCharge": {
        "ruta20": 0.00, "ruta21": 20800.00, "ruta22": 0.00, "ruta23": 0.00, "ruta24": 0.00,
        "ruta30": 5200.00, "ruta31": 0.00, "ruta32": 0.00
      }
    }
  },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
```

Ruta 49 = (utgående moms 10+11+12 + omvänd skattskyldighet 30+31+32 + utgående moms vid import 60+61+62) − ingående moms (ruta 48). Positive → moms att betala. Negative → moms att återfå.

## 2. Trace the numbers back to the ledger

The declaration is a **pure projection of the general ledger**: every ruta is the aggregated balance of its BAS accounts over the period. There is no separate reconciliation object in the response — the rutor *are* the ledger balances. The account → ruta mapping (SKV 4700) is:

- `2611`: Utgående moms 25% → ruta 10
- `2614`: Utgående moms vid omvänd skattskyldighet → ruta 30
- `2615`: Utgående moms vid import → ruta 60
- `2621`: Utgående moms 12% → ruta 11
- `2631`: Utgående moms 6% → ruta 12
- `2641`: Ingående moms → ruta 48
- `2645`: Ingående moms på utländska förvärv → ruta 48

To sanity-check the figures, use the supporting fields the response already returns:

- `invoiceCount` / `transactionCount`: how many posted entries fed the period.
- `breakdown.invoices`: the sales-side rutor (05/06/07/10/11/12/39/40) plus per-rate bases (`base25`/`base12`/`base6`).
- `breakdown.transactions.ruta48` / `breakdown.receipts.ruta48`: where the input VAT in ruta 48 came from (`breakdown.transactions.ruta48` mirrors the full `rutor.ruta48`).
- `breakdown.reverseCharge`: the reverse-charge purchase bases (ruta 20-24) and the self-assessed output VAT (ruta 30-32).

For a byte-level tie-out against raw account balances, pull the [general ledger report](/docs/api/reference/reports) filtered to the 26xx accounts and compare each account against the mapping above.

## 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 (Prop. 2025/26:55). The decisive date under ML (2023:200) is the *tidpunkt för skattskyldighetens inträde*, whose timing rules live in **7 kap. 3–13 §§**. The general rule for goods is the **supply date** (delivery), not the invoice date — **but** a *förskott* (advance payment) received before delivery triggers the VAT point, and pins the rate, at the time of that payment for the amount paid. So a food förskott paid in March for an April delivery is taxed at 12%, even though delivery is after the cutover. Drive the treatment off the taxable event (delivery, or earlier advance payment), not the delivery date alone.

- **Choose the VAT treatment from the supply date, and record `delivery_date` when it differs from `invoice_date`.** You set the rate per line via `vat_treatment`: `reduced_6` books to `2631` (6%) and `reduced_12` books to `2621` (12%). The engine does **not** infer the rate from `delivery_date` — it books to the account implied by the treatment you send — so for food supplied ≥ 2026-04-01 send `reduced_6`, and `reduced_12` for food supplied before the cutover, 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. The supply date (March) governs, so the line must be booked at 12% (`reduced_12`) even though the invoice is dated April. Drive the treatment off the supply date — not the invoice date — and record `delivery_date` on the invoice for the audit trail. **Do this 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.
- If you omit `delivery_date`, the invoice carries no explicit supply date and you are implicitly treating the invoice date as the supply date — fine 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 need 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 `breakdown.invoices` per-rate bases (`base12` / `base6`) let you see the split, so you can catch any post-cutover line still booked at 12% before you submit.

## 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://app.gnubok.se/api/v1/companies/$COMPANY_ID/compliance/check?type=voucher_gaps&fiscal_period_id=$FISCAL_PERIOD_ID" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

The check takes the period's UUID as `fiscal_period_id` (not a `YYYY-MM` string); get it from `GET /fiscal-periods`. Supported `type` values are `voucher_gaps` and `year_end_readiness`. 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://app.gnubok.se/api/v1/companies/$COMPANY_ID/fiscal-periods/$PERIOD_ID/lock" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)"
```

Locking is reversible until you close the period, but the unlock endpoint is not exposed in v1: reverse a lock from the dashboard. 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. v1 does not expose a field for a Skatteverket submission reference on the fiscal period, so record it in your own system of record (or your integration's audit log) alongside the period it belongs to.

## EU and reverse-charge handling

Sales of services to other EU businesses (`vat_treatment: 'reverse_charge'`) book to account 3308 and appear on ruta 39, bypassing the output-moms accounts (no entry on 26xx). The customer accounts for moms in their own country. (Use `export` instead — account 3305, ruta 40 — for services sold outside the EU.)

Purchases of services from other EU businesses (supplier_invoice with `vat_treatment: 'reverse_charge'`) put the **purchase base** on ruta 21 (tjänster från EU). The engine books a calculated 25% reverse-charge output on account 2614 (→ ruta 30) AND an input-moms entry on 2645 (→ ruta 48): 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 deductible portion of the `2645` leg must be restricted at booking time (e.g. by splitting the non-deductible share onto account 2649, blandad verksamhet) — there is no company-level deduction-percent setting that does this for you; otherwise the full input-moms reaches ruta 48 and over-declares the deduction.

Imports from outside EU book through a customs-clearance flow — there is no `import` `vat_treatment` value; the customs (Tullverket) invoice is what posts the moms, not the supplier invoice itself. The beskattningsunderlag lands on ruta 50 and the calculated import output VAT on rutor 60/61/62 (25/12/6%). 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 with `period_type=monthly&year=2026&period=4` 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 the customer's VAT number against VIES before issuing the invoice — note that VAT-number validation is a dashboard feature and is **not** exposed as a public v1 endpoint, so do the VIES check in your own flow.

## 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 state transition idempotent and dry-runnable (generate-agi is idempotent but not dry-runnable).

This is the operational companion to the [Salary-runs reference](/docs/api/reference/salary-runs). The whole lifecycle is API-callable end-to-end: create the run, attach employees (`POST /salary-runs/{id}/employees`), add manual payslip lines if needed, calculate, approve, mark paid, book, generate AGI. Per-employee results are readable via `GET /salary-runs/{id}/employees` (list) and `GET /salary-runs/{id}/employees/{employeeId}` (payslip detail incl. line items and the step-by-step calculation breakdown); the rendered payslip PDF is at `GET /salary-runs/{id}/payslips/{employeeId}/pdf`.

## 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 the payroll fields set (`monthly_salary` or `hourly_rate`, `tax_table_number`, `tax_column`, `f_skatt_status`). `GET /employees` masks personnummer to `ÅÅÅÅMMDDXXXX`; `GET /employees/{id}` returns the full value (deliberate drill-in, GDPR Art.5(1)(c)).
- An open fiscal period covering the salary date.

## 1. Create a salary run (draft)

`POST /salary-runs` opens an **empty** run in `draft` status: it takes only the period + payment metadata (`period_year`, `period_month`, `payment_date`, optional `voucher_series` and `notes`). Attach each employee with `POST /salary-runs/{id}/employees` (body `{ "employee_id": "..." }`, plus `hours_worked` for hourly staff) before you `:calculate`. The attach snapshots the employee's pay config onto the run and seeds the base salary line; one-off components (bonus, deduction, reimbursement) go through `POST /salary-runs/{id}/employees/{employeeId}/lines` while the run is still a draft. Line edits never recompute tax: always `:calculate` afterwards.

```bash
curl "https://app.gnubok.se/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",
    "voucher_series": "L"
  }'
```

Response:

```json
{
  "data": {
    "id": "run_...",
    "status": "draft",
    "period_year": 2026,
    "period_month": 5,
    "payment_date": "2026-05-25",
    "voucher_series": "L",
    "total_gross": 0,
    "total_tax": 0,
    "total_net": 0,
    "total_avgifter": 0,
    "total_employer_cost": 0
  }
}
```

Totals are 0 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://app.gnubok.se/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": "run_...",
    "status": "review",
    "period_year": 2026,
    "period_month": 5,
    "total_gross":          80000.00,
    "total_tax":            24300.00,
    "total_net":            55700.00,
    "total_avgifter":       25136.00,
    "total_employer_cost": 105136.00,
    "warnings": []
  }
}
```

The `warnings` array surfaces non-blocking issues (Skatteverket tax-table fallback, läkarintyg day-8 / Försäkringskassan day-15 transitions, F-skatt `not_verified` employees); the run still advances to `review`. Per-employee figures are not returned inline: read them from the run detail or the salary-journal report.

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 run for payment. `PATCH` on a salary run is **draft-only** (`SALARY_RUN_PATCH_NOT_DRAFT`): once a run leaves `draft` it can no longer be edited, and there is no `:unapprove`. Correcting a booked run is done via the forthcoming `:correct` verb (storno-then-rebook).

```bash
curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/salary-runs/$SR_ID/approve" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)"
```

Response shows `status: 'approved'` plus a `warnings` array. The endpoint validates every employee on the run and returns **all** problems at once (not just the first):
- Bank details present: `clearing_number` AND `bank_account_number` (required for the transfer)
- `calculation_breakdown` populated (proves `:calculate` has run)

Missing email is a **non-blocking warning** (lönebesked can't be sent automatically) and does not fail approval. Validation failures return `SALARY_RUN_APPROVE_VALIDATION_FAILED` with the full `issues` list (and any `warnings`) in `details`. A non-`review` run returns `SALARY_RUN_APPROVE_NOT_REVIEW`.

## 4. Mark paid (approved → paid)

After the bank transfer settles (or you mark it on the same day for cash-method shops), tell Accounted:

```bash
curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/salary-runs/$SR_ID/mark-paid" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)"
```

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. It is a **bodyless POST**: `paid_at` is stamped server-side to the current UTC timestamp (the API does not accept a body-supplied date, to keep the BFL audit trail clean).

## 5. Book (paid → booked)

`POST /salary-runs/{id}/book` is the engine-touching step. It posts **2-4 verifikationer** atomically — always the salary and avgifter entries, plus a semesterlöneskuld-accrual entry and/or a löneväxling-pension entry when those apply:

- Verifikation 1: Bruttolön: debit → 7210 / 7220 / 7240 (Löner tjänstemän / företagsledare / styrelsearvoden, by `employment_type`), credit → 2710 (Personalskatt, avdragen skatt) + 1930 (utbetalning)
- Verifikation 2: Arbetsgivaravgifter: debit → 7510 (Lagstadgade sociala avgifter), credit → 2731 (Avräkning sociala avgifter: payable to Skatteverket, cleared when arbetsgivardeklarationen is paid)
- Verifikation 3 (if semesterlöneskuld): debit → 7290 (Förändring semesterlöneskuld) + 7519 (sociala avgifter på semester), credit → 2920 (Upplupna semesterlöner) + 2940 (Upplupna sociala avgifter)
- Verifikation 4 (if löneväxling): debit → 7410 (Pensionsförsäkringspremier) + 7533 (Särskild löneskatt på pensionskostnader), credit → 2740 (Skuld pensionsförsäkringar) + 2514 (Beräknad särskild löneskatt); pension = löneväxling × 1.058

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://app.gnubok.se/api/v1/companies/$COMPANY_ID/salary-runs/$SR_ID/book" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)"
```

Response:

```json
{
  "data": {
    "id": "run_...",
    "status": "booked",
    "booked_at": "2026-05-26T09:15:00Z",
    "booked_by": "user_...",
    "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"
    }
  }
}
```

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://app.gnubok.se/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_declaration_id": "agi_...",
    "period_year": 2026,
    "period_month": 5,
    "employee_count": 2,
    "is_correction": false,
    "totals": {
      "totalTax": 24300.00,
      "totalAvgifterBasis": 80000.00,
      "totalAvgifterAmount": 25136.00,
      "totalSjuklonekostnad": 0,
      "avgifterByCategory": { "standard": { "basis": 80000.00, "amount": 25136.00 } }
    },
    "xml": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Skatteverket omrade=\"Arbetsgivardeklaration\">…</Skatteverket>",
    "xml_filename": "AGI_5566778899_202605.xml"
  }
}
```

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.)

Generating the XML stamps `agi_generated_at` on the salary run and emits an `agi.generated` event. There is no public endpoint to store a Skatteverket confirmation number back on the run: track the submission reference in your own system.

## 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, the förmånsvärde is configured on the employee's benefit records: it is **not** passed on the run-creation request (`POST /salary-runs` takes only period + payment metadata). When you `:calculate`, 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; supply the figure directly: the API does not compute it from car make / model / year.

## Common pitfalls

- **`PATCH`/`DELETE` are draft-only.** Both require `draft` status (`SALARY_RUN_PATCH_NOT_DRAFT` / `SALARY_RUN_DELETE_NOT_DRAFT`). There is no revert-to-draft and no `:unapprove`; a booked run is corrected via the forthcoming `:correct` verb (storno-then-rebook), not by editing or deleting.
- **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.f_skatt_status` (`a_skatt` | `f_skatt` | `fa_skatt` | `not_verified`) to be in sync with the employee's live Skatteverket registration. A wrong status produces a non-compliant AGI; check the F-skattsedel before payroll runs. `not_verified` employees are surfaced as a non-blocking warning on `:calculate` (30% skatteavdrag and full avgifter are applied until verified).
- **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 derives age from the employee's personnummer (the leading birthdate digits: there is no separate `birthdate` field) 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)

> Run the year-end procedures for a Swedish fiscal year — the engine locks and closes it and sets the 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`, `reports:read`, `compliance:read`, `documents:write`, and `operations: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 for the year filed. (Employment income reported through monthly AGI is **not** re-reported on an annual kontrolluppgift — KU remains only for the specific payment types AGI doesn't cover, e.g. certain ränta/utdelning; it is not a blanket year-end step.)
- 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 you run year-end, verify the period's continuity. BFL 5 kap (with the SIE4 invariant) requires that a period's opening balance (IB) equals the previous period's closing balance (UB) on every BAS 1xxx, 2xxx account. The check takes the fiscal-period UUID and compares that period's IB against its predecessor's UB.

```bash
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/reports/continuity-check?period_id=$PERIOD_ID" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

Response (a passing check returns `valid: true` with an empty `discrepancies` array):

```json
{
  "data": {
    "valid": true,
    "period_name": "FY 2025",
    "previous_period_name": "FY 2024",
    "discrepancies": [],
    "checked_accounts": 42
  }
}
```

When a mismatch exists `valid` is `false` and each offending account appears in `discrepancies`:

```json
{
  "account_number": "1930",
  "account_name": "Företagskonto",
  "previous_ub_net": 156432.00,
  "current_ib_net": 156430.00,
  "difference": 2.00
}
```

`valid: false` is a BFL violation: the previous period's closing balance (UB) was not carried into this period as the opening balance (IB). This is always a carry-forward problem — a missing/incorrect opening-balance entry, or a post-close mutation of the prior period — not a missed bank reconciliation or a VAT-vs-GL disagreement. Investigate the opening-balance entry for the flagged account before proceeding.

## 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://app.gnubok.se/api/v1/companies/$COMPANY_ID/compliance/check?type=voucher_gaps&fiscal_period_id=$PERIOD_ID" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

The check returns a `findings` array; each unexplained gap is a `blocker` (code `VOUCHER_GAP_UNEXPLAINED`). For every gap, 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 v1 `compliance/check` endpoint does **not** gate on documents — its only supported types are `year_end_readiness` and `voucher_gaps` — so surface missing underlag through the MCP tools instead:

```
gnubok_list_verifikat_without_documents
gnubok_list_transactions_without_documents
```

Attach an already-uploaded document to its verifikation via `POST /documents/{id}/link`, passing the target entry in the body:

```bash
curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/documents/$DOCUMENT_ID/link" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "journal_entry_id": "a8f1c2d3-..." }'
```

The first link to a verifikation is allowed even after the period is locked (it completes the audit trail rather than modifying the entry). Re-linking a document that is already attached to a *posted* entry is refused with `CONFLICT` (`document_already_linked_to_posted_entry`) per BFL 5 kap 5 §.

## 4. Locking is handled by year-end (do NOT pre-lock)

The year-end run in step 5 locks the fiscal year itself as part of its flow. **Do not call `/lock` on the year you are about to close first**: `executeYearEndClosing` locks the period internally, and a period that is already locked makes the run fail with `Period is already locked`.

The standalone `POST /fiscal-periods/{id}/lock` endpoint (synchronous, returns `200` with the updated period) is for *interim* locking, e.g. freezing a finished month you are not year-end-closing. It refuses if uncategorised affärstransaktioner remain in the period (`PERIOD_HAS_UNBOOKED_TRANSACTIONS`).

```bash
curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/fiscal-periods/$PERIOD_ID/lock" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)"
```

Unlocking a locked period is not exposed in v1 (dashboard only). A closed period can never be unlocked: closing is irreversible per BFL 5 kap 8 §.

## 5. Run year-end

`POST /fiscal-periods/{id}/year-end` is the single engine-touching step. It takes **no request body** (only the mandatory `Idempotency-Key` header); the next fiscal period is resolved automatically — an existing next period is reused, otherwise one is created. In order, `executeYearEndClosing`:

1. Re-validates readiness (no drafts, no unexplained voucher gaps, trial balance balanced). Preview it first with `GET /compliance/check?type=year_end_readiness&fiscal_period_id=$PERIOD_ID`.
2. Runs currency revaluation, posting FX gains/losses to `3960`/`7960` **before** the closing entry so they get swept into the result.
3. Posts one closing entry (`source_type=year_end`, series A, "Årsbokslut …"): it zeros every result account in BAS class 3-8 and books the net result **directly** onto `2099` "Årets resultat" (aktiebolag) — or `2010` "Eget kapital" for enskild firma. There is no `8910` intermediate step.
4. Locks, then closes the period (irreversible per BFL 5 kap 8 §).
5. Generates the opening-balance verifikat in the next period: every BAS class 1-2 account's UB becomes its IB, as a single balanced entry.
6. Re-validates IB/UB continuity (rolling back on failure), then — for aktiebolag — omför föregående års resultat from `2099` to `2098` in the new period as a separate verifikat.

The engine does **not** compute or post income tax (bolagsskatt), periodiseringsfond, or överavskrivningar. Book any bokslutsdispositioner and the tax entry yourself as ordinary verifikationer **before** running year-end — the tax entry debits `8910` against `2512`; a periodiseringsfond avsättning debits `8811` against an obeskattad reserv in `21xx` — so the closing entry sweeps their class-8 legs into the result.

This is an async operation. It takes no body:

```bash
curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/fiscal-periods/$PERIOD_ID/year-end" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)"
```

Response is a `202` with an operation handle (operation ids are UUIDs):

```json
{
  "data": {
    "operation_id": "0e9c1f2a-8b3d-4f6a-9c21-1a2b3c4d5e6f",
    "type": "fiscal_periods.year_end",
    "status": "queued",
    "poll_url": "/api/v1/operations/0e9c1f2a-8b3d-4f6a-9c21-1a2b3c4d5e6f",
    "webhook_event": "operation.completed"
  }
}
```

Poll `GET /operations/{id}`; on `succeeded` the `result` block carries the ids of the entries the engine posted and the next period it opened:

```json
{
  "data": {
    "operation_id": "0e9c1f2a-8b3d-4f6a-9c21-1a2b3c4d5e6f",
    "type": "fiscal_periods.year_end",
    "status": "succeeded",
    "result": {
      "closing_entry_id": "a8f1c2d3-...",
      "revaluation_entry_id": "b7e2d3c4-...",
      "opening_balance_entry_id": "c6d3e4f5-...",
      "next_period_id": "d5c4b3a2-..."
    },
    "error": null,
    "poll_url": "/api/v1/operations/0e9c1f2a-8b3d-4f6a-9c21-1a2b3c4d5e6f",
    "webhook_event": "operation.completed"
  }
}
```

(`revaluation_entry_id` is `null` when there were no open foreign-currency items to revalue.)

## 6. Verify opening balances on the new year

After year-end runs, the new period (`next_period_id` from the operation result) has IB on every balance-sheet account matching the prior period's UB. Pass that period's UUID as `period_id`:

```bash
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/reports/trial-balance?period_id=$NEXT_PERIOD_ID" \
  -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 the closed year. `3xxx-8xxx` accounts have zero opening balance: the year-end procedure cleared them into `2099`.

You normally never touch `POST /fiscal-periods/{id}/opening-balances`: year-end already generates the IB, and this endpoint returns `CONFLICT` (`opening_balance_already_posted`) once an IB entry exists in the target period. It exists only to generate the IB in isolation — its body is `{ "next_period_id": "<uuid>" }` and it computes balances from the closed period's UB (no explicit values are accepted). To correct a wrong IB, post a storno/manual entry instead.

## 7. The period is already closed (`/close` is recovery-only)

Step 5 already locked **and** closed the period — `executeYearEndClosing` runs `closePeriod` as its final bookkeeping step. **BFL 5 kap 8 §: closing is irreversible.** No code path can re-open a closed period, so in the normal flow you never call `/close` yourself.

`POST /fiscal-periods/{id}/close` exists only as a recovery path: it finalizes a year-end that posted its closing entry and locked the period but did not reach the close step (a rare mid-flow failure). It reads **no body** — only the mandatory `Idempotency-Key` header:

```bash
curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/fiscal-periods/$PERIOD_ID/close" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)"
```

It returns `CONFLICT` (`already_closed`) if the period is already closed, `PERIOD_NOT_LOCKED` if it was never locked, and `CONFLICT` (`year_end_not_executed`) if no closing entry exists yet.

## 8. Assemble the årsredovisning (aktiebolag only)

For an AB, the annual report (årsredovisning) is filed with Bolagsverket within 7 months of the fiscal-year end. A single `/reports/annual-report` endpoint is **not yet exposed in v1**: assemble the K2/K3 source figures from the report endpoints that do exist, then typeset and submit via Bolagsverket Mina Sidor.

```bash
# Resultaträkning
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/reports/income-statement?period_id=$PERIOD_ID" \
  -H "Authorization: Bearer gnubok_sk_test_..."

# Balansräkning
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/reports/balance-sheet?period_id=$PERIOD_ID" \
  -H "Authorization: Bearer gnubok_sk_test_..."
```

The resultaträkning and balansräkning come from those two reports; the noter and förvaltningsberättelse are assembled from the GL on your side. 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://app.gnubok.se/api/v1/companies/$COMPANY_ID/reports/ink2?year=2025" \
  -H "Authorization: Bearer gnubok_sk_test_..."

# Enskild firma: NE-bilaga
curl "https://app.gnubok.se/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 not part of the v1 report surface yet: see the API changelog for current availability. Until they ship, generate the inputs via `/reports/trial-balance?period_id=<uuid>` 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 is your entry, not the engine's.** `/year-end` does not accept a set-aside amount and does not post the reserve; it only revalues currency, posts the closing entry (class 3-8 → `2099`), and generates the IB. If you set aside a periodiseringsfond, book it yourself as an obeskattad reserv (`21xx`, against `8811`) **before** running year-end. For sizing it: per IL 30 kap 5 § and 30 kap 6 a §, an 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 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%**. Under BFL/BFNAR 2016:10 (materiellt samband for AB), the periodiseringsfond is BOOKED as an obeskattad reserv, not just declared on INK2.
- **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. (Unlocking is dashboard-only; it is not in v1.)
- **Year-end is async.** Don't block your request loop on it: poll `GET /operations/{id}` with reasonable backoff (every 5-10s). If you prefer webhooks, subscribe to `period.year_closed` — that is the event the engine emits on completion. (`operation.completed`, shown in the operation envelope, is the polling contract, not a separately deliverable subscription event.)

## 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)**: the monthly AGI cycle, plus any exception-based kontrolluppgifter (the KU types AGI doesn't cover) filed in January of year N+1.
- **[Fiscal-periods reference](/docs/api/reference/fiscal-periods)**: every parameter, every state transition.

---

# Changelog

> Reverse-chronological release notes for the Accounted REST API. Versions follow Stripe's dated format (`YYYY-MM-DD`). The current version is **`2026-05-12`**.

---

## 2026-05-12 *(current)*

The first stable release of the public REST API. Six phases of development covering the full agent-native surface: authentication + discovery, invoicing vertical, transactions vertical, bookkeeping engine + suppliers + compliance check, payroll + reports + import, webhooks.

### Authentication + discovery (Phase 1)

- API key auth via `Authorization: Bearer gnubok_sk_<random>` (live keys) / `gnubok_sk_test_<random>` (test keys). 100 RPM rate limit per key.
- `gnubok_sk_test_*` keys 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 ops**: `/lock`, `/close` and `/opening-balances` are synchronous and return 200 with the updated period; `/year-end` and `/currency-revaluation` are async and return 202 with operation_id (poll at `GET /api/v1/operations/{id}`).
- **Compliance check**: `GET /compliance/check?type={year_end_readiness|voucher_gaps}`: pre-flight findings before submission.
- **Documents**: `POST /documents` (multipart upload, magic-number-checked), `GET /{id}/download` (15-min signed URL), `POST /{id}/link` (attach to journal entry).

### Payroll + reports + import (Phase 5)

- **Employees**: full CRUD with personnummer masking on list/create per GDPR Art.5(1)(c). Soft-delete via `is_active`.
- **Salary runs**: CRUD + lifecycle verbs `/calculate`, `/approve`, `/mark-paid`, `/book`, `/generate-agi`. State machine: draft → review → approved → paid → booked. `/generate-agi` produces and persists the arbetsgivardeklaration XML: the response carries it as `data.xml` for the integrator to upload to Skatteverket Mina Sidor (or via the optional `skatteverket` extension). Accounted does NOT auto-submit; the AGI deadline: **the 12th of the following month for every reporting period EXCEPT January and August, where companies with annual turnover ≤ 40 MSEK get the 17th**: is the integrator's responsibility.
- **JSON reports** (13): trial-balance, balance-sheet, income-statement, general-ledger, journal-register, vat-declaration, monthly-breakdown, ar-ledger, supplier-ledger, continuity-check, salary-journal, avgifter-basis, vacation-liability.
- **Binary report**: `GET /reports/sie-export` (text/plain SIE4 file). Note: a SIE4 export alone does NOT satisfy BFL 7 kap archiving obligations: SIE captures account-level positions and verifikationer but lacks system documentation and behandlingshistorik. Treat SIE as a portability format (Fortnox/Visma/Bokio migration), not as a complete archive.
- **Async imports**: `POST /imports/sie` (multipart, 50 MB), `POST /imports/bank` (multipart, 10 MB, auto-format detection across 12 bank formats). Both async via `operations` substrate. **Post-SIE-import warning:** SIE files do NOT carry VAT codes or tax-rate-to-account mappings, AND they do NOT transfer behandlingshistorik (the source system's processing log required by BFNAR 2013:2 kap 8 §) or systemdokumentation. After importing from Fortnox / Visma / BL / SpeedLedger / Bokio you MUST manually reconfigure VAT codes (typically via `/settings/tax-codes`) before the first momsdeklaration; skipping this step is the most common source of incorrect VAT submissions in migrated bookkeeping. The behandlingshistorik gap must be preserved separately: under BFNAR 2013:2 kap 8 § the obligation attaches to the entire räkenskapsår, not from the import date forward. Best practice for a mid-year migration: export the source system's behandlingshistorik for the full fiscal year and archive it alongside the SIE file. Accounted starts a fresh behandlingshistorik from the import date forward; the pre-import portion of the year remains the source system's record.

### 2026-05-15 — Webhooks (Phase 6 PR-1)

- **Subscriptions**: `POST /webhooks` (HMAC secret returned exactly once), GET list + detail, PATCH, DELETE. Per-event-type elevated scope check (`salary_run.*` and `agi.generated` require `payroll:read`).
- **Delivery substrate**: per-minute Vercel cron at `/api/webhooks/dispatch/cron`. Due rows are claimed atomically via the `claim_due_webhook_deliveries` SQL function (`FOR UPDATE SKIP LOCKED`), with `*.pg.test.ts` coverage for the claim path and the webhook DB triggers. Exponential backoff `1m / 5m / 30m / 2h / 12h / 24h / 48h` (7 retries, ~87h total). HTTP 410 from receiver auto-disables the webhook.
- **Signature**: `X-Gnubok-Signature: t=<unix>,v1=<hex-HMAC-SHA256>`. Stripe-format. Sample receivers in [Node + Python](/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. Dispatch pins the validated IP through a DNS-rebinding-safe `node:https.request` agent (`lib/webhooks/pinned-fetch.ts`); redirects are refused on every outbound POST (any 3xx is treated as a blocked redirect).
- **Audit + retention**: webhook delivery rows are *behandlingshistorik* per BFNAR 2013:2 kap 8 §: immutable once terminal so the audit trail of what an integration was notified of stays intact. Delivery rows are NOT räkenskapsinformation themselves; the 7-year statutory retention under BFL 7 kap 1 § applies only to the underlying verifikation / faktura / AGI XML in its own table, NOT to the delivery envelope. Accounted keeps accounting-event delivery rows for 7 years as a voluntary operational policy (the duration aligns with BFL 7 kap on the underlying records but is not itself a statutory obligation on delivery rows). Webhook DELETE preserves the delivery audit trail (`ON DELETE SET NULL` on `webhook_id`). Webhook lifecycle events (create / update / delete, plus dispatcher auto-disable) each write a V16 `audit_log` entry.
- **Verbs**: `POST /webhooks/{id}/test` enqueues a synthetic event; `POST /webhook-deliveries/{id}/retry` re-enqueues a dead/delivered delivery.

### Coming soon (Phase 6 PR-2 hardening)

- 90-day TTL cleanup cron for non-accounting webhook deliveries
- Per-route rate limits on `:test`, `:retry`, and webhook `:create`
- Populated `previous_attributes` for update-style webhook events