Bygg på Kantax
Ren REST-API for fakturering, utgifter, kunder, leverandører og prosjekter. Token-auth, JSON inn, JSON ut. Ingen OAuth-dans.
Tier-1 API
Hver feature i UI er også eksponert via API — ikke et avtrykk på siden.
Personal access tokens
Generer kx_pat_-tokens fra Innstillinger. Vises én gang, lagres som SHA-256 hash.
Org-scoped
Hver token er bundet til ett foretak. Du kan aldri ved et uhell røre data fra et annet.
#Autentisering
Send token i Authorization-headeren. Alle endepunkter krever en gyldig token.
Authorization: Bearer kx_pat_<din-token>Lag tokens på Innstillinger → Developer API.
Test tokenen din
GET /v1/me returnerer foretaket og brukeren tokenen tilhører — billigste måten å bekrefte at integrasjonen din når oss.
curl https://kantax.no/api/v1/me \
-H "Authorization: Bearer $KANTAX_TOKEN"
# 200 OK — note: /me is the one endpoint that does NOT use the
# { data: ... } envelope. Three top-level keys instead.
{
"organization": {
"id": "5b2…",
"name": "Kantos AS",
"org_number": "924…",
"mva_periode": "bi_monthly",
"subscription_status": "active",
"is_test_company": false
},
"user": { "id": "a1…", "email": "you@example.com" },
"token": {
"id": "tok_…",
"scopes": ["read:invoices", "write:expenses"] // null = unrestricted
}
}
# 401 Unauthorized → token missing / revoked / typo
{ "error": "Invalid or missing bearer token" }Hurtigeksempler
Lag en faktura via API i 3 språk:
curl
curl -X POST https://kantax.no/api/v1/invoices \
-H "Authorization: Bearer $KANTAX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"customer_name": "Acme AS",
"due_date": "2026-06-30",
"lines": [{ "description": "Konsulent",
"quantity": 10, "unit_price_ore": 150000 }]
}'Node / TypeScript
const res = await fetch(
'https://kantax.no/api/v1/invoices',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.KANTAX_TOKEN}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
customer_name: 'Acme AS',
due_date: '2026-06-30',
lines: [{
description: 'Konsulent',
quantity: 10,
unit_price_ore: 150_000,
}],
}),
}
);
const { data } = await res.json();Python
import os, uuid, requests
res = requests.post(
'https://kantax.no/api/v1/invoices',
headers={
'Authorization': f"Bearer {os.environ['KANTAX_TOKEN']}",
'Content-Type': 'application/json',
'Idempotency-Key': str(uuid.uuid4()),
},
json={
'customer_name': 'Acme AS',
'due_date': '2026-06-30',
'lines': [{
'description': 'Konsulent',
'quantity': 10,
'unit_price_ore': 150_000,
}],
},
)
invoice = res.json()['data']Offentlige endepunkter (uten token)
Disse trenger ingen Authorization-header — ment for byggetidsbruk og oppetidssjekker.
GET /v1/health200 hvis databasen svarer, 503 ellers — for oppetidsmonitor.
# 200 OK
{
"ok": true,
"db": "up",
"db_latency_ms": 14,
"elapsed_ms": 16,
"ts": "2026-05-19T10:42:01.234Z",
"version": "v1"
}
# 503 Service Unavailable — DB unreachable
{
"ok": false,
"db": "down",
"db_latency_ms": 0,
"elapsed_ms": 5012,
"ts": "2026-05-19T10:42:06.246Z",
"version": "v1"
}
# Cache-Control: no-store — every probe hits the DBGET /v1/event-typesHendelseskatalog for SDK-typegenerering. Returnerer { data: [...], custom_event_prefix, glob_filter_supported }. Cachet i 1 t.GET /v1/openapi.jsonOpenAPI 3.1-spec for hele API-en. Bruker ETag for cache.
# GET /v1/event-types — example slice
{
"data": [
{ "name": "invoice.finalized", "group": "sales" },
{ "name": "invoice.paid", "group": "sales" },
{ "name": "expense.created", "group": "purchases" },
{ "name": "mva.submitted", "group": "compliance" },
{ "name": "manual_posting.created", "group": "manual" }
],
"custom_event_prefix": "custom.",
"glob_filter_supported": true
}#Scopes
Tokens kan begrenses til spesifikke rettigheter. Velg scopes når du oppretter et token i Innstillinger → Developer API. Format: action:target — e.g. read:invoices, write:expenses, read:* (alle reads), write:* (alle writes).
# Common scope combinations:
read:* # accountant or read-only dashboard
write:expenses # receipt-uploader bot
write:invoices, read:customers # CRM → invoice automation
read:invoices, read:expenses # custom BI dashboard
# A request hitting a guarded endpoint without the right scope gets:
HTTP/1.1 403 Forbidden
{
"error": "Token is missing required scope: write:invoices",
"code": "insufficient_scope"
}
# Legacy tokens (created before scopes shipped) have scopes = null and
# satisfy every endpoint — back-compatible by default.Tilgjengelige targets (kombiner med read: eller write:). read:* og write:* matcher alle innen sin handling. Read-only targets (ingen write: scope): bank-accounts, bank-transactions, exchange-rates, pay-runs, mva-submissions, amelding-submissions, year-closes, webhook-endpoints, webhook-deliveries — disse styres fra dashbordet eller mates fra eksterne kilder (PSD2, Norges Bank).
Salg
invoicescustomersproductsprojectsKjøp
expensessuppliersinboxinbox-rulesLønn
employeespay-runsEtterlevelse
mva-submissionsamelding-submissionsmanual-postingsyear-closesassetsBank
bank-accountsbank-transactionsexchange-ratesPlattform
audit-logwebhook-endpointswebhook-deliveries#Hastighetsgrense
Hver token har et tak på 120 forespørsler per minutt. Alle svar har X-RateLimit-* headere så du vet hvor mye du har igjen. Over taket gir 429 med Retry-After.
# Every successful response has these headers:
HTTP/1.1 200 OK
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1768003860 # unix seconds when the bucket flips
# Hammer past the cap → 429:
HTTP/1.1 429 Too Many Requests
Retry-After: 23
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1768003883
{
"error": "Rate limit exceeded — slow down",
"code": "rate_limited"
}
# Buckets are aligned to the minute (UTC), counter resets at :00.
# 429 responses are short-circuited — they don't count against your
# usage of other resources.Trenger du å se hvor mye du har brukt før neste kall? Tokens kan introspektere seg selv via GET /v1/me/usage — returnerer minutt-for-minutt-historikk for siste time, time- total og gjenværende kvote i nåværende bucket. Krever ingen scope (selv scope-begrensede tokens kan kalle det).
# GET /v1/me/usage — { data: ... } envelope, same as every other endpoint
{
"data": {
"token_id": "tok_8a31…",
"org_id": "5b2e1c…",
"limit_per_minute": 120,
"current_minute": {
"bucket": "2026-05-19T10:42:00Z",
"count": 17,
"remaining": 103
},
"hour_total": 384,
"hour_series": [
{ "minute": "2026-05-19T09:43:00Z", "count": 22 },
{ "minute": "2026-05-19T09:44:00Z", "count": 18 },
// … one entry per minute up to current_minute
]
}
}Håndter 429 med Retry-After
Vent på det serveren sier (i sekunder) — ikke gjett. Buckets resettes på minutt-grensen i UTC, så Retry-After er sjelden mer enn 60.
async function kantaxFetch(path: string, init?: RequestInit) {
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(`https://kantax.no/api/v1${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.KANTAX_TOKEN}`,
...init?.headers,
},
});
if (res.status !== 429) return res;
const wait = Number(res.headers.get('retry-after') ?? '5');
await new Promise((r) => setTimeout(r, wait * 1000));
}
throw new Error('Rate-limited after 3 attempts');
}#Idempotens
Alle muterende endepunkter (POST/PATCH/DELETE) godtar en valgfri Idempotency-Key header. Samme nøkkel + samme body innen 24 timer spiller av det første svaret på nytt — trygt å prøve på nytt ved nettverksfeil.
# First attempt — creates the invoice
curl -X POST https://kantax.no/api/v1/invoices \
-H "Authorization: Bearer $KANTAX_TOKEN" \
-H "Idempotency-Key: 9c8e1f2a-...-retry-1" \
-H "Content-Type: application/json" \
-d '{"customer_name":"Acme","due_date":"2026-06-01","lines":[...]}'
# Network drops mid-response — retry with the same key + same body:
curl -X POST https://kantax.no/api/v1/invoices \
-H "Authorization: Bearer $KANTAX_TOKEN" \
-H "Idempotency-Key: 9c8e1f2a-...-retry-1" \
-H "Content-Type: application/json" \
-d '{"customer_name":"Acme","due_date":"2026-06-01","lines":[...]}'
# → 201 with the same invoice id, plus header:
# Idempotent-Replayed: true
# Same key with a DIFFERENT body → conflict:
HTTP/1.1 422 Unprocessable Entity
{
"error": "Idempotency-Key has already been used with a different request body",
"code": "idempotency_conflict"
}
# Format: 1–255 printable ASCII chars. UUIDs are the typical choice.
# Cached responses live 24h then drop — GET requests ignore the header.Beste praksis
- Generér én Idempotency-Key per logiske operasjon — ikke per retry.
- Bruk UUIDv4 eller en deterministisk hash av forretningsnøkkelen din (f.eks. "shipped-order-#4521").
- 5xx-svar caches ikke — trygg å prøve på nytt med samme nøkkel.
- 4xx-svar caches — fiks bodyen, så bytt nøkkel.
#OpenAPI-spesifikasjon
Hele v1-API-en er beskrevet i en OpenAPI 3.1-spesifikasjon. Importer i Postman/Insomnia/Bruno, eller generer en SDK med openapi-generator.
https://kantax.no/api/v1/openapi.json# Generate a TypeScript SDK locally
npx @openapitools/openapi-generator-cli generate \
-i https://kantax.no/api/v1/openapi.json \
-g typescript-fetch \
-o ./kantax-sdk
# Or a Python SDK
openapi-generator generate \
-i https://kantax.no/api/v1/openapi.json \
-g python \
-o ./kantax-pythonBruk i kode:
import { Configuration, InvoicesApi } from './kantax-sdk';
const api = new InvoicesApi(
new Configuration({
basePath: 'https://kantax.no/api/v1',
accessToken: process.env.KANTAX_TOKEN,
}),
);
const { data } = await api.listInvoices({ limit: 50 });
// ^? Invoice[] — fully typed from the OpenAPI schemaTrenger du bare hendelseskatalogen for å typesette webhook-håndteringen din? Hent den fra et offentlig endepunkt:
#Konvensjoner
- Pengebeløp er i øre (heltall, ikke desimal). 100 NOK = 10000 øre. Bruk
Math.round(nok * 100)ved konvertering — ren multiplikasjon med flyttall gir avrundingsfeil (f.eks. 12.34 × 100 = 1233.999…). - Datoer er ISO 8601 (YYYY-MM-DD).
- Lister returnerer { data: [...], pagination: { limit, offset, total } }.
- Enkeltobjekter (GET /v1/invoices/:id) returnerer også { data: {...} } — wrapped i en konvolutt så feilformatet er konsistent. Eneste unntak: /v1/me returnerer flat { organization, user, token }.
- Alle tidsstempler er UTC (Z-suffix). Konverter til Europe/Oslo i klienten om du skal vise dem til brukere.
- Feil returnerer { error: "melding", code: "stabil_kode" } med passende HTTP-status. message kan endres — code er stabil.
- Flervaluta — pass
currency(3-bokstavs ISO) +currency_rate(NOK per 1 enhet utenlandsk valuta) på fakturaer og utgifter. En trigger fyllernok_*_oreautomatisk, så rapporter forblir i NOK. BrukGET /v1/exchange-ratesfor Norges Banks dagskurser. - org_id er implisitt fra token. Du kan ikke krysse foretak.
- Sletting er myk — DELETE setter
archived = truei stedet for å fjerne raden (bokføringsloven krever 5 års oppbevaring). List-endepunkter skjuler arkiverte som standard; pass?archived=truefor å inkludere dem. - PATCH er delvis oppdatering — send kun feltene du vil endre. Tom body gir 400
empty_patch. Tilgjengelig for customers, suppliers, projects, products, employees, expenses, invoices (kun draft), inbox, inbox-rules, assets. - DELETE arkiverer — returnerer
{ data: { id, archived: true } }. Tilgjengelig på customers, suppliers, projects, products, employees, expenses, inbox-rules, assets. Bruk PATCH medarchived: falsefor å gjenopprette.
Eksempel-feil
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": "Invalid JSON body",
"code": "invalid_body"
}Felttyper
| Suffiks | Type | Eksempel |
|---|---|---|
| _ore | Heltall, øre (1/100 NOK) | amount_ore: 10000 |
| _id | UUID v4 (36 tegn) | customer_id: "5b2…" |
| _date | ISO 8601 dato | invoice_date: "2026-06-30" |
| _at | ISO 8601 tidsstempel (UTC) | created_at: "2026-05-19T14:22:11Z" |
| _pct | Desimal prosent (25.00 = 25%) | vat_rate_pct: 25.00 |
| _code | Stabil streng-identifikator | period_code: "2026-T3" |
| _path | Supabase Storage-sti — be om signed URL | bilag_path: "org-…/file.pdf" |
| _rate | Desimal valutakurs (NOK per 1 enhet) | currency_rate: 10.4823 |
#Paginering
List-endepunkter aksepterer limit (standard 25, maks 100) og offset. Svaret inneholder pagination.total så du vet når du er ferdig.
GET /api/v1/invoices?limit=50&offset=0
Authorization: Bearer kx_pat_...
# Response
{
"data": [ ... 50 invoices ... ],
"pagination": { "limit": 50, "offset": 0, "total": 137 }
}
# Next page
GET /api/v1/invoices?limit=50&offset=50Stopp når data.length < limit eller offset + limit ≥ total. limit > 100 klippes ned automatisk — det gir ingen feil.
Iterer gjennom alle resultater
async function* paginate(path: string) {
const limit = 100; // max — minimizes round-trips
let offset = 0;
while (true) {
const res = await fetch(
`https://kantax.no/api/v1${path}?limit=${limit}&offset=${offset}`,
{ headers: { Authorization: `Bearer ${process.env.KANTAX_TOKEN}` } },
);
const body = await res.json();
for (const item of body.data) yield item;
offset += body.data.length;
if (offset >= body.pagination.total) break;
if (body.data.length < limit) break; // belt-and-braces
}
}
// Usage:
for await (const invoice of paginate('/invoices')) {
console.log(invoice.invoice_number);
}#Versjonering & stabilitet
Stien /api/v1/ er stabil. Vi legger til nye felt og endepunkter når som helst, men endrer ikke betydningen av eksisterende felt uten å bumpe til /api/v2/.
- Tilleggsendringer er trygge — nye valgfrie felter i requests, nye felter i responses, og nye verdier i åpne enums kan dukke opp når som helst. Klienter må ignorere ukjente felter.
- Bryterendringer varsles minst 90 dager i forveien via
Sunset-header på berørte endepunkter, samt e-post til alle organisasjoner med aktive tokens. - Felt merket "beta" i OpenAPI-spesifikasjonen kan endres uten 90-dagers varsel mens vi finjusterer.
- Endringslogg:
info.versioni OpenAPI bumpes på hver release; sjekk openapi.json for gjeldende versjon.
#Endepunkter
/api/v1/meIdentify the token. Returns the org + user it belongs to and the scopes it carries. Cheapest endpoint to confirm an integration can reach Kantax — also the canonical way to pull the org's settings (org_form for compliance branching, accounting_start_year for query bounds, invoice_bank_account_no for payment-side integrations, address block for invoice-rendering integrations). NOTE: /me is the one endpoint that does NOT use the { data: ... } envelope — three top-level keys instead.
curl https://kantax.no/api/v1/me \
-H "Authorization: Bearer kx_pat_…"
# 200 OK — flat shape, no { data: ... } envelope
{
"organization": {
"id": "org_…",
"name": "Kantos AS",
"org_number": "933221105",
"org_form": "AS",
"mva_periode": "bi_monthly",
"accounting_start_year": 2024,
"founding_date": "2024-01-15",
"address": "Storgata 1\n0001 Oslo",
"postnummer": "0001",
"poststed": "Oslo",
"phone": "+47 22 00 00 00",
"email": "post@kantos.ai",
"invoice_bank_account_no": "12345678903",
"invoice_bank_account_name": "Kantos AS",
"logo_url": "https://…/logo.png",
"subscription_status": "active",
"is_test_company": false
},
"user": {
"id": "u_…",
"email": "kari@example.com"
},
"token": {
"id": "tok_…",
"scopes": ["read:invoices", "write:expenses"]
}
}/api/v1/customersList customers. Filters: q (case-insensitive name), org_number (exact 9-digit lookup, 400 invalid_query on non-9-digit; whitespace stripped), archived=true. List view returns the raw customer rows; computed rollups (outstanding_ore, ytd_revenue_ore, …) live on GET /v1/customers/:id.
curl "https://kantax.no/api/v1/customers?q=acme&limit=20" \
-H "Authorization: Bearer kx_pat_…"
# Find-or-create: look up by org_number before POSTing to avoid 409 conflicts
curl "https://kantax.no/api/v1/customers?org_number=923609016" \
-H "Authorization: Bearer kx_pat_…"
# Row shape — no rollups on list view; fetch /v1/customers/:id for those
{
"data": [
{
"id": "5b2…",
"name": "Acme AS",
"org_number": "923609016",
"email": "ap@acme.no",
"phone": "+47 22 00 00 00",
"address": "Storgata 1\n0001 Oslo",
"default_payment_terms_days": 14,
"notes": null,
"archived": false,
"created_at": "2025-09-12T09:00:00Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 42 }
}/api/v1/customersCreate a customer. Only name is required. org_number must be 9 digits (any spaces are stripped before validation) — 409 org_number_conflict if it already exists in your org. default_payment_terms_days clamped 0-180. Emits customer.created (with name + org_number in metadata) — Stripe-customer-sync integrations use the resulting webhook to write the Kantax id back into Stripe metadata, closing the loop without polling.
# Stripe-sync flow: use the Stripe customer id as the Idempotency-Key
# so a re-played sync run replays the cached 201 instead of trying to
# create a second customer row.
curl -X POST https://kantax.no/api/v1/customers \
-H "Authorization: Bearer kx_pat_…" \
-H "Idempotency-Key: stripe-cus_NeGfPRiPV9XK1m" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme AS",
"org_number": "923609016",
"email": "ap@acme.no",
"phone": "+47 22 00 00 00",
"address": "Storgata 1\n0001 Oslo",
"default_payment_terms_days": 14
}'
# 201 Created — org_number is stripped of spaces before storage
{
"data": {
"id": "5b2…",
"name": "Acme AS",
"org_number": "923609016",
"email": "ap@acme.no",
"phone": "+47 22 00 00 00",
"address": "Storgata 1\n0001 Oslo",
"default_payment_terms_days": 14,
"archived": false,
"created_at": "2026-05-20T11:00:00Z"
}
}
# 409 — org_number duplicate
{ "error": "Customer with that org_number already exists", "code": "org_number_conflict" }/api/v1/customers/:idFetch a single customer with computed rollups: outstanding_ore (unpaid finalized invoices), ytd_revenue_ore, lifetime_revenue_ore, invoice_count, last_invoice_date. Saves a second round-trip when building a customer dashboard.
curl https://kantax.no/api/v1/customers/<uuid> \
-H "Authorization: Bearer kx_pat_…"
# 200 OK
{
"data": {
"id": "5b2…",
"name": "Acme AS",
"org_number": "923609016",
"email": "ap@acme.no",
"phone": "+47 22 00 00 00",
"address": "Storgata 1\n0001 Oslo",
"default_payment_terms_days": 14,
"notes": null,
"archived": false,
"created_at": "2025-09-12T09:00:00Z",
"rollups": {
"outstanding_ore": 437500,
"ytd_revenue_ore": 2150000,
"lifetime_revenue_ore": 8420000,
"invoice_count": 12,
"last_invoice_date": "2026-05-15"
}
}
}/api/v1/customers/:idUpdate any subset of customer fields. Pass empty string to clear an optional field (org_number, email, phone, address, notes) — those get coerced to null. 409 org_number_conflict on duplicate org_number; 400 empty_patch if no fields supplied.
curl -X PATCH https://kantax.no/api/v1/customers/<uuid> \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{
"email": "billing@acme.no",
"default_payment_terms_days": 30
}'
# 200 OK — full row echoed back
{
"data": {
"id": "5b2…",
"name": "Acme AS",
"org_number": "923609016",
"email": "billing@acme.no",
"default_payment_terms_days": 30,
"archived": false
}
}/api/v1/customers/:idSoft-archive (sets archived=true). The row stays in the DB because invoices reference it + bokføringsloven requires 5-year retention. Use PATCH archived=false to restore.
curl -X DELETE https://kantax.no/api/v1/customers/<uuid> \
-H "Authorization: Bearer kx_pat_…"
# 200 OK
{ "data": { "id": "5b2…", "archived": true } }
# 404 — customer not found in your org
{ "error": "Customer not found", "code": "not_found" }/api/v1/suppliersList leverandører. Filters: q (case-insensitive name), org_number (exact 9-digit lookup, 400 invalid_query on non-9-digit; whitespace stripped), archived=true. Expense rows match by vendor-name (case-insensitive) — the dashboard auto-links them, but the API surfaces the raw suppliers table without those rollups.
curl "https://kantax.no/api/v1/suppliers?q=aws" \
-H "Authorization: Bearer kx_pat_…"
# Find-or-create: look up by org_number before POSTing
curl "https://kantax.no/api/v1/suppliers?org_number=823609012" \
-H "Authorization: Bearer kx_pat_…"
# Row shape — list view; no rollups (use expense filters to derive spend)
{
"data": [
{
"id": "sup_…",
"name": "AWS",
"org_number": "823609019",
"email": "ap@amazon.com",
"phone": null,
"address": null,
"default_payment_terms_days": 30,
"notes": null,
"archived": false,
"created_at": "2025-11-04T10:00:00Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 18 }
}/api/v1/suppliersCreate a supplier. Only name is required. org_number must be 9 digits (spaces stripped) — 409 org_number_conflict on dup. default_payment_terms_days defaults to 14. Emits supplier.created (with name + org_number in metadata) so procurement integrations syncing vendor lists can mirror Kantax ids.
curl -X POST https://kantax.no/api/v1/suppliers \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{
"name": "AWS",
"org_number": "823609019",
"email": "ap@amazon.com",
"default_payment_terms_days": 30
}'
# 201 Created
{
"data": {
"id": "sup_…",
"name": "AWS",
"org_number": "823609019",
"email": "ap@amazon.com",
"phone": null,
"address": null,
"default_payment_terms_days": 30,
"archived": false,
"created_at": "2026-05-20T11:30:00Z"
}
}
# 409 — org_number duplicate
{ "error": "Supplier with that org_number already exists", "code": "org_number_conflict" }/api/v1/suppliers/:idFetch a single supplier with computed rollups: ytd_spend_ore, lifetime_spend_ore, lifetime_vat_ore, expense_count, last_expense_date. Spend rollups are derived from expense vendor-name matching (case-insensitive ilike).
curl https://kantax.no/api/v1/suppliers/<uuid> \
-H "Authorization: Bearer kx_pat_…"
# 200 OK
{
"data": {
"id": "sup_…",
"name": "AWS",
"org_number": "823609019",
"email": "ap@amazon.com",
"phone": null,
"address": null,
"default_payment_terms_days": 30,
"notes": null,
"archived": false,
"created_at": "2025-11-04T10:00:00Z",
"rollups": {
"ytd_spend_ore": 4500000,
"lifetime_spend_ore": 12800000,
"lifetime_vat_ore": 0,
"expense_count": 18,
"last_expense_date": "2026-05-12"
}
}
}/api/v1/suppliers/:idUpdate any subset of supplier fields. Empty strings on optional fields (org_number, email, phone, address, notes) are coerced to null. 409 org_number_conflict on duplicate.
curl -X PATCH https://kantax.no/api/v1/suppliers/<uuid> \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{ "email": "billing@amazon.com" }'
# 200 OK — full row echoed back/api/v1/suppliers/:idSoft-archive (sets archived=true). Expenses referencing the supplier name stay intact — bokføringsloven requires 5-year retention. Use PATCH archived=false to restore.
curl -X DELETE https://kantax.no/api/v1/suppliers/<uuid> \
-H "Authorization: Bearer kx_pat_…"
# 200 OK
{ "data": { "id": "sup_…", "archived": true } }/api/v1/projects/:idGet project + per-year rollups: revenue_ore, expense_ore, net_ore, invoice_count, expense_count, last_activity. Override the year window with ?year=YYYY (defaults to current calendar year).
curl "https://kantax.no/api/v1/projects/<uuid>?year=2026" \
-H "Authorization: Bearer kx_pat_…"
# 200 OK
{
"data": {
"id": "abc…",
"name": "Hjemmeside-redesign",
"code": "HSR-01",
"description": null,
"color": "#0ea5e9",
"customer_id": "5b2…",
"archived": false,
"created_at": "2026-01-08T12:00:00Z",
"rollups": {
"year": 2026,
"revenue_ore": 4500000,
"expense_ore": 1200000,
"net_ore": 3300000,
"invoice_count": 3,
"expense_count": 8,
"last_activity": "2026-05-15"
}
}
}/api/v1/invoicesList invoices. Filters: status (draft | finalized | voided), paid (true | false — narrows finalized by paid_at), customer_id (UUID), project_id (UUID), limit, offset. List view omits lines[] — fetch /v1/invoices/:id for the full line items.
# All finalized invoices, newest first
curl "https://kantax.no/api/v1/invoices?status=finalized" \
-H "Authorization: Bearer kx_pat_…"
# Outstanding receivables (finalized + not yet paid)
curl "https://kantax.no/api/v1/invoices?status=finalized&paid=false" \
-H "Authorization: Bearer kx_pat_…"
# All invoices for one customer
curl "https://kantax.no/api/v1/invoices?customer_id=5b2…" \
-H "Authorization: Bearer kx_pat_…"
# Project revenue side — pair with /v1/expenses?project_id=… for full P&L
curl "https://kantax.no/api/v1/invoices?project_id=pj_…" \
-H "Authorization: Bearer kx_pat_…"
# Row shape — no lines[] on the list view (fetch /v1/invoices/:id for those)
{
"data": [
{
"id": "11d6…",
"invoice_number": 1042,
"status": "finalized",
"document_type": "invoice",
"customer_id": "5b2…",
"customer_name": "Acme AS",
"customer_org_number": "923609016",
"invoice_date": "2026-04-22",
"due_date": "2026-05-22",
"currency": "NOK",
"currency_rate": 1,
"subtotal_ore": 100000,
"vat_total_ore": 25000,
"total_ore": 125000,
"nok_subtotal_ore": 100000,
"nok_vat_total_ore": 25000,
"nok_total_ore": 125000,
"paid_at": null,
"paid_amount_ore": null,
"payment_method": null,
"payment_reference": null,
"voided_at": null,
"voided_by_invoice_id": null,
"converted_from_invoice_id": null,
"project_id": "pj_…",
"finalized_at": "2026-04-22T10:30:00Z",
"created_at": "2026-04-22T10:15:00Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 87 }
}/api/v1/expensesList expenses. Filters: since (expense_date ≥), until (expense_date ≤), project_id (UUID, attribute by project), archived=true to include archived, limit/offset. Newest expense_date first. nok_* fields are populated by a trigger after currency conversion — for NOK expenses they equal the native amounts.
# Q1 expenses only
curl "https://kantax.no/api/v1/expenses?since=2026-01-01&until=2026-03-31" \
-H "Authorization: Bearer kx_pat_…"
# All expenses attributed to one project — pair with /v1/invoices?project_id=…
# for full project P&L in 2 round-trips.
curl "https://kantax.no/api/v1/expenses?project_id=pj_…" \
-H "Authorization: Bearer kx_pat_…"
# Row shape — bilag_path is a Supabase Storage path; sign it via the Storage API
{
"data": [
{
"id": "ex_…",
"expense_date": "2026-03-12",
"vendor": "AWS",
"description": "EC2 + S3, March 2026",
"amount_ore": 124900,
"vat_amount_ore": 0,
"vat_rate_pct": 0,
"currency": "USD",
"currency_rate": 10.8,
"nok_amount_ore": 134892,
"nok_vat_amount_ore": 0,
"category": "Programvare / SaaS",
"paid_via": "Mastercard",
"project_id": "pj_…",
"bilag_path": "org-…/expenses/aws-202603.pdf",
"notes": null,
"archived": false,
"created_at": "2026-04-02T09:15:00Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 142 }
}/api/v1/expensesCreate an expense. vendor, expense_date, amount_ore, and category are required. For non-NOK receipts pass currency + currency_rate — a trigger fills nok_amount_ore + nok_vat_amount_ore so reports stay in NOK. paid_via is free-text (e.g. "Mastercard", "Vipps"). Emits expense.created (with vendor + category + amount_ore + currency in metadata, plus via:"api" so webhook subscribers can distinguish API-created from dashboard-created).
# Domestic NOK expense
curl -X POST https://kantax.no/api/v1/expenses \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{
"vendor": "Posten",
"expense_date": "2026-05-15",
"amount_ore": 12500,
"vat_amount_ore": 2500,
"vat_rate_pct": 25,
"category": "Frakt"
}'
# Foreign-currency (USD): pass currency + currency_rate
curl -X POST https://kantax.no/api/v1/expenses \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{
"vendor": "AWS",
"expense_date": "2026-05-15",
"amount_ore": 124900,
"vat_amount_ore": 0,
"currency": "USD",
"currency_rate": 10.80,
"category": "Programvare / SaaS"
}'
# 201 Created — nok_* fields are filled by a trigger after insert
{
"data": {
"id": "ex_…",
"expense_date": "2026-05-15",
"vendor": "AWS",
"amount_ore": 124900,
"vat_amount_ore": 0,
"currency": "USD",
"currency_rate": 10.80,
"nok_amount_ore": 134892,
"nok_vat_amount_ore": 0,
"category": "Programvare / SaaS",
"archived": false,
"created_at": "2026-05-15T18:00:00Z"
}
}/api/v1/expenses/:idSingle expense — same row shape as the list view. Useful for fetching an item right after the inbox/post round-trip returns expense.id.
curl https://kantax.no/api/v1/expenses/<uuid> \
-H "Authorization: Bearer kx_pat_…"/api/v1/expenses/:idUpdate any subset of expense fields. Editing currency or currency_rate re-triggers the nok_* conversion via a DB trigger. 400 empty_patch on no-op bodies.
curl -X PATCH https://kantax.no/api/v1/expenses/<uuid> \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{ "category": "Programvare", "vat_rate_pct": 25 }'/api/v1/expenses/:idSoft-archive (sets archived=true). Hard delete is blocked because the expense + its bilag are part of the bokføringsloven audit trail (5-year retention).
curl -X DELETE https://kantax.no/api/v1/expenses/<uuid> \
-H "Authorization: Bearer kx_pat_…"
# 200 OK
{ "data": { "id": "ex_…", "archived": true } }/api/v1/invoicesCreate a draft invoice with line items. Mints no invoice_number yet — call /finalize next.
curl https://kantax.no/api/v1/invoices \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{
"customer_name": "Acme AS",
"due_date": "2026-06-15",
"lines": [
{ "description": "Konsulenttimer", "quantity": 8, "unit_price_ore": 125000, "vat_rate_pct": 25 }
]
}'
# 201 Created — status="draft", invoice_number=null until /finalize is called
{
"data": {
"id": "11d6…",
"invoice_number": null,
"status": "draft",
"customer_name": "Acme AS",
"invoice_date": "2026-05-20",
"due_date": "2026-06-15",
"currency": "NOK",
"subtotal_ore": 1000000,
"vat_total_ore": 250000,
"total_ore": 1250000,
"finalized_at": null,
"created_at": "2026-05-20T10:00:00Z"
}
}/api/v1/invoices/:idFetch a single invoice with its line items expanded inline. NOK-converted totals (nok_subtotal_ore, nok_vat_total_ore, nok_total_ore) are present for FX invoices and equal the native totals when currency is NOK.
curl https://kantax.no/api/v1/invoices/<uuid> \
-H "Authorization: Bearer kx_pat_…"
# 200 OK
{
"data": {
"id": "11d6…",
"invoice_number": 1042,
"status": "finalized",
"customer_name": "Acme AS",
"invoice_date": "2026-04-22",
"due_date": "2026-05-22",
"currency": "NOK",
"currency_rate": 1,
"subtotal_ore": 1000000,
"vat_total_ore": 250000,
"total_ore": 1250000,
"lines": [
{
"id": "ab1…",
"sort_order": 0,
"description": "Konsulenttimer",
"quantity": 8,
"unit_price_ore": 125000,
"vat_rate_pct": 25,
"vat_amount_ore": 250000,
"line_total_ore": 1250000
}
]
}
}/api/v1/invoices/:idUpdate a draft invoice. Refuses with 409 invoice_not_draft once status is finalized or voided (Norwegian bokføringsforskriften makes finalized invoices immutable — use credit notes for corrections). Returns 400 empty_patch if no fields are provided. Line items aren't editable through this endpoint yet — use the dashboard editor or recreate the draft.
curl -X PATCH https://kantax.no/api/v1/invoices/<uuid> \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{
"due_date": "2026-07-15",
"notes": "Per kundens spørsmål: forlenget forfall"
}'
# 200 OK — same shape as GET /v1/invoices/:id, without lines[]
{
"data": {
"id": "11d6…",
"status": "draft",
"due_date": "2026-07-15",
"notes": "Per kundens spørsmål: forlenget forfall"
}
}
# 400 — body had no editable fields
{ "error": "No fields provided", "code": "empty_patch" }
# 409 — invoice already finalized or voided
{ "error": "Only draft invoices can be updated", "code": "invoice_not_draft" }/api/v1/invoices/:id/finalizeMint a sequential invoice_number, flip status to "finalized", stamp finalized_at, write invoice.finalized event. Idempotent — already-finalized invoices return 200 with the existing number (no re-mint). 409 invoice_voided refuses voided invoices since the number is permanently gone. Event metadata: { invoice_number, via: "api" } — subscribers can build a "newly numbered invoice" feed without follow-up GETs.
curl -X POST https://kantax.no/api/v1/invoices/<uuid>/finalize \
-H "Authorization: Bearer kx_pat_…"
# 200 OK — finalized (or already finalized; same shape either way)
{
"data": {
"id": "11d6…",
"status": "finalized",
"invoice_number": 1042
}
}
# 409 — voided invoices cannot be finalized (their slot is burned per bokføringsforskriften)
{ "error": "Cannot finalize a voided invoice", "code": "invoice_voided" }
# 404 — invoice not found in your org
{ "error": "Invoice not found", "code": "not_found" }/api/v1/invoices/:id/paidMark a finalized invoice as paid. paid_amount_ore is required; payment_method, payment_reference, paid_at are optional (paid_at defaults to now). 409 invalid_state if the invoice is still draft or voided; 409 already_paid if marked once before (no double-pay). Emits invoice.paid with { invoice_number, paid_amount_ore, payment_method, payment_reference } in metadata — bank-reconciliation bots subscribed to invoice.paid can match incoming bank tx by the §5-1 invoice number + KID without a follow-up fetch.
# Pay-once with Idempotency-Key — strongly recommended here.
# A retry with the same key + body replays the cached 200; a
# bank-reconciliation bot crashing mid-call won't double-record
# the payment.
curl -X POST https://kantax.no/api/v1/invoices/<uuid>/paid \
-H "Authorization: Bearer kx_pat_…" \
-H "Idempotency-Key: bank-tx-BANK-2026-04-22-001" \
-H "Content-Type: application/json" \
-d '{
"paid_amount_ore": 1250000,
"payment_method": "bank_transfer",
"payment_reference": "BANK-2026-04-22-001"
}'
# 200 OK
{
"data": {
"id": "11d6…",
"paid_at": "2026-04-22T14:30:00Z",
"paid_amount_ore": 1250000
}
}
# 409 — wrong state
{ "error": "Only finalized invoices can be marked paid", "code": "invalid_state" }
{ "error": "Invoice is already marked paid", "code": "already_paid" }
# 404 — invoice not found in your org
{ "error": "Invoice not found", "code": "not_found" }/api/v1/manual-postingsCreate a balanced journal entry. account_id values follow the Norwegian NS 4102 standard chart of accounts (1900 = Bank, 3000 = Salgsinntekt, 6000 = Avskrivning, etc.) — Kantax does not validate the codes against a closed list, so any integer-string is accepted, but stick to NS 4102 so the books reconcile against the standard. Sum of debit_ore must equal sum of credit_ore; each line must have exactly one of debit_ore or credit_ore (not both, not neither). Minimum 2 lines. Returns 201 with the posting + inserted lines, and emits manual_posting.created (with posting_date + total_ore + via:"api" in metadata) to the audit log + webhook pipeline. Once written the row is immutable per bokføringsloven — correct with a reversing entry, not a PATCH.
curl -X POST https://kantax.no/api/v1/manual-postings \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{
"posting_date": "2026-04-30",
"reference": "BANK-2026-04",
"description": "Eierinnskudd",
"lines": [
{ "account_id": "1900", "debit_ore": 50000, "description": "Bank" },
{ "account_id": "2050", "credit_ore": 50000, "description": "Egenkapital" }
]
}'
# 201 Created — full posting with lines echoed back
{
"data": {
"id": "8f12…",
"posting_date": "2026-04-30",
"reference": "BANK-2026-04",
"description": "Eierinnskudd",
"lines": [ … echoed lines with sort_order assigned … ]
}
}
# 400 — debit ≠ credit
{ "error": "Lines do not balance: sum(debit_ore) = 50000, sum(credit_ore) = 30000", "code": "unbalanced" }
# 400 — all-zero lines
{ "error": "Posting has zero amounts", "code": "empty_posting" }
# 400 — a line with both debit_ore AND credit_ore set
{ "error": "Each line must have either debit_ore OR credit_ore (not both, not neither)", "code": "invalid_body" }/api/v1/manual-postings/:idSingle manual posting with its lines expanded. Mutations are intentionally not exposed — bokføringsloven treats a finalized posting as immutable. Correct mistakes by booking a reversing entry, not by editing.
curl https://kantax.no/api/v1/manual-postings/<uuid> \
-H "Authorization: Bearer kx_pat_…"/api/v1/inboxList Innboks items. Filters: status (processing/ready/posted/failed/archived), since/until (created_at bounds), min_confidence (0-1, useful for low-confidence triage), file_hash (64-char SHA-256, for dedupe lookups). extracted_* fields are populated by the LLM once status flips to "ready"; null while processing.
curl "https://kantax.no/api/v1/inbox?status=ready" \
-H "Authorization: Bearer kx_pat_…"
# Low-confidence triage — queue for human review
curl "https://kantax.no/api/v1/inbox?status=ready&min_confidence=0" \
-H "Authorization: Bearer kx_pat_…"
# Dedupe pre-check before uploading: have I posted these exact bytes?
curl "https://kantax.no/api/v1/inbox?file_hash=<sha256>" \
-H "Authorization: Bearer kx_pat_…"
# Row shape — extracted_* fields only populated when status=ready/posted/failed
{
"data": [
{
"id": "5b2…",
"status": "ready",
"file_name": "vercel-receipt.pdf",
"file_type": "application/pdf",
"file_size_bytes": 84210,
"file_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"extracted_vendor": "Vercel Inc.",
"extracted_date": "2026-05-15",
"extracted_amount_ore": 1996,
"extracted_vat_ore": 0,
"extracted_vat_rate_pct": null,
"extracted_currency": "USD",
"extracted_category": "Programvare",
"extracted_notes": "Hobby plan upgrade",
"extracted_confidence": 0.92,
"expense_id": null,
"error_message": null,
"created_at": "2026-05-19T12:00:00Z",
"processed_at": "2026-05-19T12:00:15Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 14 }
}/api/v1/inboxUpload a bilag (PDF / image) for AI extraction. multipart/form-data with field "file" — max 20 MB. Allowed mimes: application/pdf, image/png, image/jpeg, image/webp, image/gif. Returns 202 Accepted immediately; extraction runs async. Preferred way to react to completion is to subscribe to the inbox.ready / inbox.failed webhook events — both fire with the inbox_item.id + file_name in metadata so you can correlate back to this upload. Polling GET /v1/inbox/:id also works for environments that cannot accept inbound webhooks.
curl -X POST https://kantax.no/api/v1/inbox \
-H "Authorization: Bearer kx_pat_…" \
-F "file=@./receipt.pdf"
# Retry-safe variant for flaky uploads — same key + same bytes within
# 24h replays the cached 202 instead of creating a second inbox_item:
curl -X POST https://kantax.no/api/v1/inbox \
-H "Authorization: Bearer kx_pat_…" \
-H "Idempotency-Key: receipt-2026-05-19-vercel" \
-F "file=@./receipt.pdf"
# 202 Accepted
{
"data": {
"id": "5b2e1c…",
"status": "processing",
"file_name": "receipt.pdf"
}
}
# Error envelopes
HTTP/1.1 400 — { "error": "Expected multipart/form-data", "code": "invalid_content_type" }
HTTP/1.1 400 — { "error": "Missing \"file\" field", "code": "invalid_body" }
HTTP/1.1 400 — { "error": "Unsupported file type…", "code": "invalid_file_type" }
HTTP/1.1 400 — { "error": "File too large (max 20 MB)", "code": "file_too_large" }/api/v1/inbox/:idSingle inbox item — useful for polling extraction status after POST /v1/inbox. Same row shape as the list endpoint. Pass ?include_raw=true to also return extracted_raw — the LLM's full structured output as JSON, useful for debugging extraction quality.
curl https://kantax.no/api/v1/inbox/<uuid> \
-H "Authorization: Bearer kx_pat_…"
# Include the LLM's raw output for debugging low-confidence rows
curl "https://kantax.no/api/v1/inbox/<uuid>?include_raw=true" \
-H "Authorization: Bearer kx_pat_…"/api/v1/inbox/:idOverride LLM-extracted fields on a ready (or failed) item before posting it. Once status=posted the item is immutable — edit the derived expense via /v1/expenses/:id instead. 409 already_posted on locked items.
curl -X PATCH https://kantax.no/api/v1/inbox/<uuid> \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{
"extracted_vendor": "Vercel Inc.",
"extracted_category": "Programvare",
"extracted_vat_rate_pct": 0
}'
# 200 OK — full row echoed back
# 409 — item already posted
{ "error": "Cannot edit a posted inbox item — edit the derived expense via /v1/expenses/:id", "code": "already_posted" }/api/v1/inbox/:id/postConvert an inbox item into a posted expense. Optional body overrides any LLM-extracted field (useful when your backend has a better categorisation model than the default). Currency falls through from extracted_currency by default; pass currency + currency_rate to pin a specific FX rate, otherwise Kantax auto-resolves the latest Norges Bank rate. Idempotent: if the item is already posted, returns the existing expense_id with already_posted=true. 409 invalid_status when the item is processing or archived. Emits TWO events on the success path: inbox.posted (with vendor + expense_id + amount_ore + file_name + file_hash in metadata) AND expense.created (with vendor + category + amount_ore + currency + inbox_item_id in metadata) — subscribers registered for expense.created get coverage on inbox-flow expenses too. Replay calls do not re-fire either event.
# Minimal: accept all LLM-extracted fields (incl. currency + auto-fetched rate)
curl -X POST https://kantax.no/api/v1/inbox/<uuid>/post \
-H "Authorization: Bearer kx_pat_…"
# Override low-confidence fields before posting
curl -X POST https://kantax.no/api/v1/inbox/<uuid>/post \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{
"vendor": "Vercel Inc.",
"category": "Programvare",
"vat_rate_pct": 0
}'
# Foreign-currency bilag with your own FX rate (skip Norges Bank lookup)
curl -X POST https://kantax.no/api/v1/inbox/<uuid>/post \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{
"currency": "USD",
"currency_rate": 10.84
}'
# 201 Created
{
"data": {
"inbox_item": { "id": "5b2…", "status": "posted", "expense_id": "9f1…" },
"expense": { /* full expense row, with bilag_path copied from inbox */ }
}
}
# Replayed (already-posted) — returns 200 with already_posted=true
{
"data": {
"inbox_item": { "id": "5b2…", "status": "posted", "expense_id": "9f1…" },
"already_posted": true
}
}/api/v1/inbox/:id/archiveSoft-hide an inbox item without posting it (e.g. personal receipts uploaded by mistake). The underlying file stays in storage so the action is reversible from the dashboard. Emits inbox.archived (entity_id = the inbox item id, no other metadata) so cleanup-tracker integrations can mirror archival state.
curl -X POST https://kantax.no/api/v1/inbox/<uuid>/archive \
-H "Authorization: Bearer kx_pat_…"
# 200 OK
{
"data": { "id": "5b2…", "status": "archived" }
}
# 404 — item not found in your org
{ "error": "Inbox item not found", "code": "not_found" }/api/v1/assetsList fixed assets with computed book_value_ore + accumulated_depreciation_ore per row (server does the math from asset_depreciation_postings). Filter by category (equipment/furniture/computer/vehicle/software/other), archived=true, or disposed=true.
curl "https://kantax.no/api/v1/assets?category=computer" \
-H "Authorization: Bearer kx_pat_…"
# Row shape — book_value_ore + accumulated_depreciation_ore are computed server-side
{
"data": [
{
"id": "as_…",
"name": "MacBook Pro M5",
"description": null,
"category": "computer",
"acquisition_date": "2026-05-15",
"acquisition_cost_ore": 3500000,
"salvage_value_ore": 0,
"useful_life_months": 36,
"depreciation_method": "linear",
"declining_rate_pct": null,
"accumulated_depreciation_ore": 97222,
"book_value_ore": 3402778,
"disposed_date": null,
"disposal_proceeds_ore": null,
"archived": false,
"created_at": "2026-05-15T14:00:00Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 9 }
}/api/v1/assetsCapitalise an asset. Useful when an integration spots a > 30 000 NOK invoice that needs to be depreciated rather than expensed. depreciation_method is "linear" (default) or "declining"; declining_rate_pct is required when method is declining. Returns 201 with the inserted asset. Emits asset.created (with name + category + acquisition_cost_ore + depreciation_method + useful_life_months in metadata) — external balance-sheet trackers can recreate the depreciation schedule from these fields alone.
# Linear depreciation — 36 months → equal monthly amount
curl -X POST https://kantax.no/api/v1/assets \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{
"name": "MacBook Pro M5",
"category": "computer",
"acquisition_date": "2026-05-15",
"acquisition_cost_ore": 3500000,
"useful_life_months": 36,
"depreciation_method": "linear"
}'
# Declining (20% saldoavskrivning — typical for vehicles)
curl -X POST https://kantax.no/api/v1/assets \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{
"name": "Servicebil — Toyota Hilux",
"category": "vehicle",
"acquisition_date": "2026-03-01",
"acquisition_cost_ore": 45000000,
"useful_life_months": 96,
"depreciation_method": "declining",
"declining_rate_pct": 20,
"salvage_value_ore": 5000000
}'
# 400 — declining without rate
{ "error": "declining_rate_pct is required when depreciation_method is \"declining\"", "code": "invalid_body" }/api/v1/assets/:idSingle asset with computed accumulated_depreciation_ore + book_value_ore inline. Pass ?include_postings=true to also receive the per-month depreciation_postings array — useful for compliance audits and external balance-sheet trackers reconstructing the monthly schedule.
curl https://kantax.no/api/v1/assets/<uuid> \
-H "Authorization: Bearer kx_pat_…"
# With monthly depreciation schedule attached
curl "https://kantax.no/api/v1/assets/<uuid>?include_postings=true" \
-H "Authorization: Bearer kx_pat_…"
# 200 OK with postings — sorted ascending by (period_year, period_month)
{
"data": {
"id": "as_…",
"name": "MacBook Pro M4",
"acquisition_cost_ore": 4500000,
"accumulated_depreciation_ore": 750000,
"book_value_ore": 3750000,
"depreciation_postings": [
{ "id": "adp_…", "period_year": 2026, "period_month": 1, "amount_ore": 125000, "created_at": "2026-01-31T18:00:00Z" },
{ "id": "adp_…", "period_year": 2026, "period_month": 2, "amount_ore": 125000, "created_at": "2026-02-28T18:00:00Z" }
]
}
}/api/v1/assets/:idUpdate asset fields. Common uses: dispose an asset (set disposed_date + disposal_proceeds_ore — the month-end depreciation job stops accruing); edit notes/description; or correct an acquisition_cost_ore typo. 400 empty_patch on no-op bodies.
# Mark an asset disposed
curl -X PATCH https://kantax.no/api/v1/assets/<uuid> \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{
"disposed_date": "2026-12-31",
"disposal_proceeds_ore": 800000
}'/api/v1/assets/:idSoft-archive (sets archived=true). Hard delete is blocked because asset_depreciation_postings reference the asset row + the postings themselves are part of the books.
curl -X DELETE https://kantax.no/api/v1/assets/<uuid> \
-H "Authorization: Bearer kx_pat_…"
# 200 OK
{ "data": { "id": "as_…", "archived": true } }/api/v1/audit-logPoll the activity log. Filter by event_type (exact), event_type_like (prefix, "invoice."), entity_type, entity_id, actor_user_id, or since (ISO timestamp — strict greater-than, so use the last seen created_at + 1ms when polling). Combine filters freely. metadata is the same JSON object that gets POSTed to webhook subscribers.
# Newest 20 invoice events
curl "https://kantax.no/api/v1/audit-log?event_type_like=invoice.&limit=20" \
-H "Authorization: Bearer kx_pat_…"
# Full audit trail for one specific invoice (entity_type + entity_id)
curl "https://kantax.no/api/v1/audit-log?entity_type=invoice&entity_id=11d6…" \
-H "Authorization: Bearer kx_pat_…"
# Everything Bob did this month
curl "https://kantax.no/api/v1/audit-log?actor_user_id=<auth.users.id>&since=2026-05-01T00:00:00Z" \
-H "Authorization: Bearer kx_pat_…"
# Strict since= for incremental polling — use the most recent
# created_at + 1ms so you don't replay the boundary event.
curl "https://kantax.no/api/v1/audit-log?since=2026-05-19T13:42:11.001Z" \
-H "Authorization: Bearer kx_pat_…"
# Row shape — actor_type is one of "user" | "api_token" | "system" | "webhook" | "integration"
# user → actor_label = user's email, actor_user_id = auth.users.id
# api_token → actor_label = first 8 chars of token id, actor_user_id = null
# system → actor_label = scheduler/internal subsystem name
# webhook → actor_label = delivery endpoint, used for webhook lifecycle events
# integration → actor_label = integration name (Maskinporten, Altinn, …)
{
"data": [
{
"id": "ae_…",
"actor_user_id": "f0b8…",
"actor_type": "user",
"actor_label": "kari@example.com",
"event_type": "invoice.finalized",
"entity_type": "invoice",
"entity_id": "11d6…",
"summary": "Faktura #1042 ferdigstilt",
"metadata": { "invoice_number": 1042 },
"created_at": "2026-05-19T13:42:11Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 137 }
}/api/v1/audit-logPush your own events into the activity log + webhook pipeline. event_type must match /^custom\.[a-z0-9_]+(\.[a-z0-9_]+)*$/ (e.g. "custom.order.shipped") — any other prefix is reserved for Kantax-emitted events. Requires write:audit-log scope. Custom events show up alongside built-in events and are caught by webhook endpoints subscribed to "custom.*". summary max 500 chars; metadata is an arbitrary JSON object (no schema).
curl -X POST https://kantax.no/api/v1/audit-log \
-H "Authorization: Bearer kx_pat_…" \
-H "Idempotency-Key: order-4521-shipped" \
-H "Content-Type: application/json" \
-d '{
"event_type": "custom.order.shipped",
"summary": "Bestilling #4521 sendt fra Posten",
"entity_type": "order",
"entity_id": "4521",
"metadata": { "carrier": "posten", "tracking": "JJD1234" }
}'
# 201 Created — returns the audit_log row id so you can chain
# follow-up events via entity_id (e.g. custom.order.delivered later).
{ "data": { "recorded": true, "id": "ae_…", "created_at": "2026-05-19T14:00:00Z" } }
# 400 — invalid event_type prefix
{ "error": "event_type must be in the form 'custom.foo' or 'custom.foo.bar'", "code": "invalid_body" }/api/v1/mva-submissionsMVA submission history. Filter by period (e.g. "2026-T1"), status, mode (live | test | dry_run — useful for separating real filings from sandbox runs in compliance dashboards). Pass include_xml=true to embed the Altinn payload_xml in each row — omitted by default to keep responses small.
# All live, accepted submissions (the ones that actually count)
curl "https://kantax.no/api/v1/mva-submissions?mode=live&status=accepted" \
-H "Authorization: Bearer kx_pat_…"
# Specific termin — with full XML payload for audit
curl "https://kantax.no/api/v1/mva-submissions?period=2026-T1&include_xml=true" \
-H "Authorization: Bearer kx_pat_…"
# Row shape — payload_xml is only included when ?include_xml=true;
# payload_json (the parsed Skatteetaten-shaped fields) is
# always present.
{
"data": [
{
"id": "ms_…",
"period_code": "2026-T1",
"mode": "live",
"status": "accepted",
"altinn_instance_id": "50012345/abc-…",
"payload_json": { "fields": { "output_vat_nok": 21500, "input_vat_nok": 2400, "net_position_nok": 19100 } },
"submitted_at": "2026-04-10T08:30:00Z",
"finalized_at": "2026-04-10T08:32:00Z",
"error_message": null,
"created_at": "2026-04-10T08:29:45Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 6 }
}/api/v1/amelding-submissionsA-melding submission history. Filter by period (e.g. "2026-04"), status, mode (live | test | dry_run), or pay_run_id. Pass include_xml=true to embed the Altinn payload_xml in each row — omitted by default to keep responses small.
# All accepted submissions for the year
curl "https://kantax.no/api/v1/amelding-submissions?status=accepted" \
-H "Authorization: Bearer kx_pat_…"
# A specific month — with full XML payload for audit
curl "https://kantax.no/api/v1/amelding-submissions?period=2026-04&include_xml=true" \
-H "Authorization: Bearer kx_pat_…"
# Row shape — pay_run_id links back to /v1/pay-runs/:id
{
"data": [
{
"id": "as_…",
"period_code": "2026-04",
"pay_run_id": "9a1…",
"mode": "live",
"status": "accepted",
"altinn_instance_id": "50054321/def-…",
"payload_json": { "totals": { "gross_nok": 195000, "tax_nok": 70200 } },
"submitted_at": "2026-05-05T10:00:00Z",
"finalized_at": "2026-05-05T10:02:00Z",
"error_message": null,
"created_at": "2026-05-05T09:55:00Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 4 }
}/api/v1/exchange-ratesNorges Bank daily reference rates — same NOK conversion factors Kantax uses internally for multi-currency expenses and invoices. Pass currency=USD for a single rate; omit for every supported currency. rate_to_nok = "1 unit foreign = X NOK". 502 rate_unavailable when Norges Bank is down.
# Single currency
curl "https://kantax.no/api/v1/exchange-rates?currency=USD" \
-H "Authorization: Bearer kx_pat_…"
# Response
{
"data": {
"currency": "USD",
"rate_to_nok": 10.4823,
"observed_date": "2026-05-19",
"source": "norges-bank"
}
}
# All supported currencies in one shot
curl https://kantax.no/api/v1/exchange-rates \
-H "Authorization: Bearer kx_pat_…"
# Response — each currency settled independently, failures surface as
# null rate + error string so one bad currency doesn't kill the batch
{
"data": [
{ "currency": "USD", "rate_to_nok": 10.4823, "observed_date": "2026-05-19", "source": "norges-bank" },
{ "currency": "EUR", "rate_to_nok": 11.7841, "observed_date": "2026-05-19", "source": "norges-bank" },
{ "currency": "GBP", "rate_to_nok": null, "observed_date": null, "source": "norges-bank", "error": "ECONNRESET" }
]
}
# 400 — unknown currency code passed via ?currency=
{ "error": "Currency XYZ not supported", "code": "invalid_currency" }
# 502 — Norges Bank unreachable (only applies to single-currency mode;
# batch mode degrades gracefully via null rate + error string).
{ "error": "Rate lookup failed", "code": "rate_unavailable" }/api/v1/inbox-rulesList per-vendor auto-classification rules. Filters: vendor_pattern (exact match, server lowercases + trims to mirror write-side normalization), auto_post (true|false — audit which vendors bypass human review). Useful for syncing a vendor taxonomy from a CRM/ops system into Kantax. user_id attributes the rule to the team member who created it — pair with /v1/audit-log?actor_user_id=… to see what categorization decisions they made.
curl https://kantax.no/api/v1/inbox-rules \
-H "Authorization: Bearer kx_pat_…"
# Confirm an upsert landed (exact-match lookup after POST)
curl "https://kantax.no/api/v1/inbox-rules?vendor_pattern=vercel" \
-H "Authorization: Bearer kx_pat_…"
# All rules that bypass human review
curl "https://kantax.no/api/v1/inbox-rules?auto_post=true" \
-H "Authorization: Bearer kx_pat_…"
# Row shape — match_count is the running tally of bilag this rule has matched
{
"data": [
{
"id": "ir_…",
"user_id": "u_…",
"vendor_pattern": "vercel",
"category": "Programvare",
"vat_rate_pct": 0,
"auto_post": true,
"match_count": 12,
"created_at": "2025-12-01T10:00:00Z",
"updated_at": "2026-04-15T09:30:00Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 8 }
}/api/v1/webhook-endpointsRead-only list of webhook endpoints registered for your org. Create + secret rotation happens in Settings → Webhooks (the signing secret is shown only once at creation, so the API never echoes it). event_filters is an array of glob patterns ("invoice.*", "expense.created"). user_id attributes the endpoint to the team member who registered it — useful for multi-user-org dashboards.
curl https://kantax.no/api/v1/webhook-endpoints \
-H "Authorization: Bearer kx_pat_…"
# Row shape
{
"data": [
{
"id": "we_…",
"user_id": "u_…",
"url": "https://ops.example.com/kantax-webhook",
"description": "Production CRM sync",
"active": true,
"event_filters": ["invoice.*", "expense.created"],
"last_used_at": "2026-05-19T14:30:00Z",
"created_at": "2026-04-01T10:00:00Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 1 }
}/api/v1/webhook-deliveriesRead-only delivery log so external monitoring (uptime dashboards, alerting) can poll the queue. Filter by endpoint_id, status (delivered/failed/pending), event_type (exact) or event_type_like (prefix wildcard, "invoice."), or since. attempt/max_attempts let you spot endpoints close to giving up; next_retry_at is null once delivered or maxed out.
# Recent failed deliveries for a specific endpoint
curl "https://kantax.no/api/v1/webhook-deliveries?endpoint_id=…&status=failed&limit=20" \
-H "Authorization: Bearer kx_pat_…"
# All deliveries for any invoice.* event in the last hour
curl "https://kantax.no/api/v1/webhook-deliveries?event_type_like=invoice.&since=2026-05-19T13:00:00Z" \
-H "Authorization: Bearer kx_pat_…"
# Row shape
{
"data": [
{
"id": "wd_…",
"endpoint_id": "we_…",
"event_type": "invoice.finalized",
"audit_log_id": "ae_…",
"attempt": 2,
"max_attempts": 5,
"http_status": 503,
"error_message": "Bad gateway",
"delivered_at": null,
"failed_at": null,
"next_retry_at": "2026-05-19T15:00:00Z",
"created_at": "2026-05-19T14:30:00Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 1 }
}/api/v1/webhook-deliveries/:idFull delivery row, including the payload JSON we sent + the response_body returned by your endpoint. Use this to post-mortem a failed delivery without re-firing the webhook. List view omits both fields to keep responses small.
curl https://kantax.no/api/v1/webhook-deliveries/<uuid> \
-H "Authorization: Bearer kx_pat_…"
# 200 OK — list fields + payload + response_body
{
"data": {
"id": "wd_…",
"endpoint_id": "we_…",
"event_type": "invoice.finalized",
"audit_log_id": "ae_…",
"attempt": 2,
"max_attempts": 5,
"http_status": 503,
"error_message": "Bad gateway",
"payload": { "event_type": "invoice.finalized", "data": { /* … */ } },
"response_body": "<html>nginx 503</html>",
"delivered_at": null,
"failed_at": null,
"next_retry_at": "2026-05-19T15:00:00Z",
"created_at": "2026-05-19T14:30:00Z"
}
}/api/v1/inbox-rulesUpsert a rule on (org, vendor_pattern). vendor_pattern is normalized to lower-case + trimmed before storage so casing differences do not create duplicates. Pass auto_post=true to bypass manual review for matching bilag — use sparingly: an LLM extraction quality regression on a high-frequency vendor could silently write bad expense rows at scale before a human notices. Periodically audit which rules carry auto_post=true via GET /v1/inbox-rules?auto_post=true. user_id is set to the token owner. Returns 201 on both create and update paths (upsert semantics).
curl -X POST https://kantax.no/api/v1/inbox-rules \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{
"vendor_pattern": "Vercel",
"category": "Programvare",
"vat_rate_pct": 0,
"auto_post": true
}'
# 201 Created — full row, whether the call created or updated the rule
{
"data": {
"id": "ir_…",
"user_id": "u_…",
"vendor_pattern": "vercel",
"category": "Programvare",
"vat_rate_pct": 0,
"auto_post": true,
"match_count": 0,
"created_at": "2026-05-20T09:00:00Z",
"updated_at": "2026-05-20T09:00:00Z"
}
}
# 400 — rule has neither category nor vat_rate_pct (would do nothing)
{ "error": "A rule must override at least category or vat_rate_pct", "code": "empty_rule" }/api/v1/inbox-rules/:idFetch a single inbox-rule by id. To look up by vendor_pattern instead (the common upsert-then-confirm path), use GET /v1/inbox-rules?vendor_pattern=… which exact-matches with the same case normalization the server applies on write.
curl https://kantax.no/api/v1/inbox-rules/<uuid> \
-H "Authorization: Bearer kx_pat_…"/api/v1/inbox-rules/:idUpdate fields on an existing rule (typically: flip auto_post, swap category, or change vat_rate_pct). 400 empty_patch on no-op bodies.
curl -X PATCH https://kantax.no/api/v1/inbox-rules/<uuid> \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{ "auto_post": false }'/api/v1/inbox-rules/:idHard-delete a rule. Unlike customers/suppliers/expenses, inbox rules carry no audit-trail obligation — gone is gone. Already-classified bilag keep their categorisation.
curl -X DELETE https://kantax.no/api/v1/inbox-rules/<uuid> \
-H "Authorization: Bearer kx_pat_…"
# 200 OK
{ "data": { "id": "ir_…", "deleted": true } }/api/v1/year-closesList fiscal-year closes for the org. Accountants reference these rows to confirm a year is locked before pulling final reports. Filters: fiscal_year (single-year lookup, 4 digits), include_reopened=true to include reopened years (hidden by default). Each row carries the YEC-year manual posting id (closing_posting_id) so you can fetch the actual journal entry via /v1/manual-postings.
# Active closes only (default)
curl https://kantax.no/api/v1/year-closes \
-H "Authorization: Bearer kx_pat_…"
# Is 2025 locked? (canonical single-year lookup)
curl "https://kantax.no/api/v1/year-closes?fiscal_year=2025" \
-H "Authorization: Bearer kx_pat_…"
# Include reopened years too
curl "https://kantax.no/api/v1/year-closes?include_reopened=true" \
-H "Authorization: Bearer kx_pat_…"
# Row shape
{
"data": [
{
"id": "yc_…",
"fiscal_year": 2025,
"closing_posting_id": "mp_…",
"net_result_ore": 12500000,
"total_revenue_ore": 88000000,
"total_expenses_ore": 75500000,
"closed_by_user_id": "u_…",
"closed_at": "2026-01-15T10:00:00Z",
"reopened_at": null,
"notes": null
}
],
"pagination": { "limit": 25, "offset": 0, "total": 3 }
}/api/v1/pay-runsList monthly pay runs with the rolled-up totals used for A-melding (gross, tax, arb.avg., OTP, feriepenger accrual, net). Filter by year, month, or status. Per-employee payslip detail lives at /pay-runs/:id.
# All pay runs for 2026
curl "https://kantax.no/api/v1/pay-runs?year=2026" \
-H "Authorization: Bearer kx_pat_…"
# A specific month
curl "https://kantax.no/api/v1/pay-runs?year=2026&month=5" \
-H "Authorization: Bearer kx_pat_…"
# Row shape — list view; payslips[] is only on GET /v1/pay-runs/:id
{
"data": [
{
"id": "9a1…",
"year": 2026,
"month": 5,
"status": "finalized",
"total_gross_ore": 19500000,
"total_tax_ore": 7020000,
"total_arb_avg_ore": 2749500,
"total_otp_ore": 390000,
"total_feriepenger_accrual_ore": 1989000,
"total_net_ore": 12480000,
"finalized_at": "2026-05-31T12:00:00Z",
"paid_at": null,
"created_at": "2026-05-25T09:00:00Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 12 }
}/api/v1/pay-runs/:idSingle pay run with per-employee payslip breakdown inline. pdf_url is the Supabase Storage path — generate a signed URL via the storage API if you need to download the slip itself.
curl https://kantax.no/api/v1/pay-runs/<uuid> \
-H "Authorization: Bearer kx_pat_…"
# 200 OK
{
"data": {
"id": "9a1…",
"year": 2026,
"month": 5,
"status": "finalized",
"total_gross_ore": 19500000,
"total_tax_ore": 7020000,
"total_arb_avg_ore": 2749500,
"total_otp_ore": 390000,
"total_feriepenger_accrual_ore": 1989000,
"total_net_ore": 12480000,
"finalized_at": "2026-05-31T12:00:00Z",
"paid_at": null,
"payslips": [
{
"id": "ps_…",
"employee_id": "emp_…",
"employee_name": "Kari Nordmann",
"employee_email": "kari@example.com",
"gross_ore": 6500000,
"tax_ore": 2340000,
"tax_pct": 36,
"arb_avg_ore": 916500,
"arb_avg_pct": 14.1,
"otp_ore": 130000,
"otp_pct": 2,
"feriepenger_accrual_ore": 663000,
"feriepenger_pct": 10.2,
"net_ore": 4160000,
"pdf_url": "org-.../payslip-….pdf"
}
]
}
}/api/v1/manual-postingsList manual journal entries with embedded lines (id + account_id + debit_ore + credit_ore). Each posting must balance (sum debit = sum credit). Filters: since/until (both ISO YYYY-MM-DD on posting_date), reference (exact match — useful for cross-system reconciliation when your integration writes its own external journal ID into reference and looks it up later).
curl "https://kantax.no/api/v1/manual-postings?since=2026-01-01&until=2026-12-31" \
-H "Authorization: Bearer kx_pat_…"
# Find a posting by your external journal ID
curl "https://kantax.no/api/v1/manual-postings?reference=JE-2026-0042" \
-H "Authorization: Bearer kx_pat_…"
# Row shape — lines[] is always embedded; sum(debit_ore) == sum(credit_ore)
# Each line carries a stable UUID (lines[].id) for accountants referencing
# specific lines in correction workflows.
{
"data": [
{
"id": "mp_…",
"posting_date": "2026-12-31",
"reference": "YEC-2025",
"description": "Periodisering: avskrivning Q4",
"lines": [
{ "id": "ml_…", "account_id": "6020", "debit_ore": 250000, "credit_ore": 0, "description": "Avskrivning Q4", "sort_order": 0 },
{ "id": "ml_…", "account_id": "1230", "debit_ore": 0, "credit_ore": 250000, "description": "Akk. avskrivning", "sort_order": 1 }
],
"created_at": "2026-12-31T18:00:00Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 14 }
}/api/v1/projectsList projects for cost-attribution. Filter by archived=true, q (ilike on name), or customer_id. Optional color is #RRGGBB hex.
curl "https://kantax.no/api/v1/projects?customer_id=…" \
-H "Authorization: Bearer kx_pat_…"
# Row shape — customer_id is null for internal/cross-customer projects
{
"data": [
{
"id": "prj_…",
"name": "Acme onboarding Q3",
"code": "ACM-Q3",
"description": "Implementation + onboarding",
"color": "#5B8DEF",
"customer_id": "5b2…",
"archived": false,
"created_at": "2026-01-08T12:00:00Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 4 }
}/api/v1/projectsCreate a project. Only name is required; code (≤ 40 chars), description, color (#RRGGBB), and customer_id are optional. Emits project.created (with name + code + customer_id + via:"api" in metadata) so PM-tool integrations syncing project lists back from Jira/Linear can mirror Kantax id assignments without polling.
curl -X POST https://kantax.no/api/v1/projects \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme onboarding Q3",
"code": "ACM-Q3",
"customer_id": "5b2…"
}'
# 201 Created
{
"data": {
"id": "prj_…",
"name": "Acme onboarding Q3",
"code": "ACM-Q3",
"description": null,
"color": null,
"customer_id": "5b2…",
"archived": false,
"created_at": "2026-05-20T15:00:00Z"
}
}
# 409 — another project in this org already uses that code
{ "error": "A project with this code already exists", "code": "code_conflict" }/api/v1/projects/:idUpdate project fields. Common uses: rebrand a project, swap customer_id, change color. 400 empty_patch on no-op bodies.
curl -X PATCH https://kantax.no/api/v1/projects/<uuid> \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{ "color": "#5B8DEF" }'/api/v1/projects/:idSoft-archive (sets archived=true). Invoice + expense rows that tag this project keep the reference — they're part of the books.
curl -X DELETE https://kantax.no/api/v1/projects/<uuid> \
-H "Authorization: Bearer kx_pat_…"
# 200 OK
{ "data": { "id": "prj_…", "archived": true } }/api/v1/productsList reusable invoice-line presets (description + unit_price_ore + default VAT). Filters: q (case-insensitive name), sku (exact match — Stripe-catalog sync flows store the Stripe price_id in SKU and use this to check whether a price has already been mirrored), archived=true.
curl https://kantax.no/api/v1/products \
-H "Authorization: Bearer kx_pat_…"
# Stripe-catalog sync: confirm a price_id is already mirrored
curl "https://kantax.no/api/v1/products?sku=price_1QabC2…" \
-H "Authorization: Bearer kx_pat_…"
# Row shape — sku is unique per org; archived rows are excluded by default
{
"data": [
{
"id": "pr_…",
"name": "Konsulenttime",
"sku": "CON-001",
"description": "Standard consulting hour",
"unit_price_ore": 150000,
"vat_rate_pct": 25,
"unit": "time",
"archived": false,
"created_at": "2026-01-08T12:00:00Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 7 }
}/api/v1/productsCreate a product preset that can be reused as an invoice line. sku is unique per org (409 conflict if duplicate). vat_rate_pct defaults to 25 (standard MVA). unit is free-text ("stk", "time", "kg"). Emits product.created (with name + sku + unit_price_ore in metadata) so Stripe-price-sync integrations get a webhook to confirm the mirror landed.
# Stripe-price-sync flow: store the Stripe price_id in sku so
# follow-up GET /v1/products?sku=price_… can confirm a mirror exists
# before the next sync attempt creates a duplicate.
curl -X POST https://kantax.no/api/v1/products \
-H "Authorization: Bearer kx_pat_…" \
-H "Idempotency-Key: stripe-price_1Qabc2DefGhi3Jkl" \
-H "Content-Type: application/json" \
-d '{
"name": "Konsulenttime",
"sku": "price_1Qabc2DefGhi3Jkl",
"unit_price_ore": 150000,
"vat_rate_pct": 25,
"unit": "time"
}'
# 201 Created
{
"data": {
"id": "pr_…",
"name": "Konsulenttime",
"sku": "price_1Qabc2DefGhi3Jkl",
"description": null,
"unit_price_ore": 150000,
"vat_rate_pct": 25,
"unit": "time",
"archived": false,
"created_at": "2026-05-20T12:00:00Z"
}
}
# 409 — sku already in use (retry the sync with the same Idempotency-Key
# instead of POSTing without it; or look up via GET /v1/products?sku=…)
{ "error": "Product with that sku already exists", "code": "sku_conflict" }/api/v1/products/:idFetch a single product preset.
curl https://kantax.no/api/v1/products/<uuid> \
-H "Authorization: Bearer kx_pat_…"/api/v1/products/:idUpdate product fields. SKU conflict returns 409 sku_conflict; empty patch returns 400 empty_patch.
curl -X PATCH https://kantax.no/api/v1/products/<uuid> \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{ "unit_price_ore": 175000 }'/api/v1/products/:idSoft-archive (sets archived=true). Hard delete would orphan invoice lines that reference the product.
curl -X DELETE https://kantax.no/api/v1/products/<uuid> \
-H "Authorization: Bearer kx_pat_…"
# 200 OK
{ "data": { "id": "pr_…", "archived": true } }/api/v1/employeesList employees (active by default). Pass active=false to include inactive. fodselsnummer is returned as-is — write:employees scope can already read it via the dashboard, so the API mirrors that.
curl https://kantax.no/api/v1/employees \
-H "Authorization: Bearer kx_pat_…"
# Row shape — fodselsnummer is 11 digits (Norwegian national ID); start_date is ISO date
{
"data": [
{
"id": "emp_…",
"name": "Kari Nordmann",
"email": "kari@example.com",
"fodselsnummer": "01018012371",
"start_date": "2024-09-01",
"gross_monthly_salary_ore": 6500000,
"tax_card_pct": 36,
"otp_pct": 2,
"arb_avg_rate": 14.1,
"active": true,
"tax_card_fetched_at": null,
"created_at": "2024-08-25T12:00:00Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 3 }
}/api/v1/bank-accountsRead-only list of connected bank accounts with last-synced balance + IBAN. Accounts are added through the consent flow (Neonomics) in Settings — not via API — because connecting a bank requires user interaction. external_account_id is the PSD2 provider's stable identifier — pair with provider for the canonical "reconcile Kantax bank_account.id ↔ Neonomics account" lookup. consent_expires_at tells you when the open-banking grant expires (PSD2 typically caps at 90 days).
curl https://kantax.no/api/v1/bank-accounts \
-H "Authorization: Bearer kx_pat_…"
# Row shape — account_name + iban + balance_ore are nullable for non-IBAN or
# pre-sync rows.
{
"data": [
{
"id": "ba_…",
"provider": "neonomics",
"external_account_id": "neo_account_47bf…",
"account_name": "DNB Driftskonto",
"iban": "NO9386011117947",
"currency": "NOK",
"balance_ore": 4250000,
"last_synced_at": "2026-05-19T14:00:00Z",
"consent_expires_at": "2026-08-17T00:00:00Z",
"created_at": "2026-02-04T11:00:00Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 1 }
}/api/v1/bank-transactionsRead-only stream of bank transactions across all connected accounts. Filter by bank_account_id, transaction-date window (since=/until=), or reconciled state (matched to invoice/expense yet). external_transaction_id is the PSD2 provider's stable id — Kantax uses (bank_account_id, external_transaction_id) as the dedupe key, so an integration can check "is this provider transaction already mirrored?" with a single query. booking_date is null while a transaction is pending.
# Unreconciled bank transactions for one account, last 30 days
curl "https://kantax.no/api/v1/bank-transactions?bank_account_id=…&since=2026-04-19&reconciled=false" \
-H "Authorization: Bearer kx_pat_…"
# Row shape
{
"data": [
{
"id": "tx_…",
"bank_account_id": "ba_…",
"external_transaction_id": "neo_tx_5829c…",
"transaction_date": "2026-05-19",
"booking_date": "2026-05-19",
"amount_ore": -125000,
"currency": "NOK",
"description": "VERCEL INC SAN FRANCISCO",
"counterparty_name": "Vercel Inc.",
"counterparty_account": null,
"reference": null,
"category": null,
"matched_expense_id": null,
"matched_invoice_id": null,
"reconciled_at": null,
"created_at": "2026-05-19T15:02:11Z"
}
],
"pagination": { "limit": 25, "offset": 0, "total": 8 }
}/api/v1/employeesCreate an employee. fodselsnummer must be 11 digits if provided. tax_card_pct (forskuddstrekk), otp_pct (default 2%), and arb_avg_rate (default 14.1%) drive payroll calculations. Emits employee.created (with name + email + start_date in metadata) so HR-system integrations syncing onboarding events can pick up the Kantax id without polling.
curl -X POST https://kantax.no/api/v1/employees \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{
"name": "Kari Nordmann",
"email": "kari@example.com",
"fodselsnummer": "01018012371",
"start_date": "2026-05-01",
"gross_monthly_salary_ore": 6500000,
"tax_card_pct": 36,
"otp_pct": 2,
"arb_avg_rate": 14.1
}'
# 201 Created
{
"data": {
"id": "emp_…",
"name": "Kari Nordmann",
"email": "kari@example.com",
"fodselsnummer": "01018012371",
"start_date": "2026-05-01",
"gross_monthly_salary_ore": 6500000,
"tax_card_pct": 36,
"otp_pct": 2,
"arb_avg_rate": 14.1,
"active": true,
"tax_card_fetched_at": null,
"created_at": "2026-05-20T13:00:00Z"
}
}
# 400 — fodselsnummer wrong length
{ "error": "fodselsnummer must be exactly 11 digits", "code": "invalid_body" }/api/v1/employees/:idSingle employee — same row shape as the list view.
curl https://kantax.no/api/v1/employees/<uuid> \
-H "Authorization: Bearer kx_pat_…"/api/v1/employees/:idUpdate employee fields (typical: salary changes, tax-card updates). 400 empty_patch on no-op bodies; fodselsnummer must remain 11 digits when present.
curl -X PATCH https://kantax.no/api/v1/employees/<uuid> \
-H "Authorization: Bearer kx_pat_…" \
-H "Content-Type: application/json" \
-d '{
"gross_monthly_salary_ore": 6800000,
"tax_card_pct": 38
}'/api/v1/employees/:idSoft delete via active=false. Hard delete blocked because pay_runs reference the employee — payslips need 5-year retention per bokføringsloven.
curl -X DELETE https://kantax.no/api/v1/employees/<uuid> \
-H "Authorization: Bearer kx_pat_…"
# 200 OK
{ "data": { "id": "emp_…", "active": false } }#Webhooks
Eksempel-payload:
{
"id": "f7e9c0d2-…", // delivery id
"event_id": "a3b1e2…", // audit_log id
"event_type": "invoice.finalized",
"entity_type": "invoice",
"entity_id": "11d6…",
"summary": "Faktura #1042 ferdigstilt",
"metadata": { "invoice_number": 1042 },
"org_id": "5b2…",
"created_at": "2026-05-18T14:22:11Z"
}Headers:
X-Kantax-Event: invoice.finalized
X-Kantax-Signature: sha256=<hex HMAC-SHA256 of body using endpoint secret>
X-Kantax-Delivery: <uuid>
Content-Type: application/json
User-Agent: Kantax-Webhook/1.0Tips: X-Kantax-Delivery er stabil mellom retries — lagre den i din database for å idempotent-håndtere samme leveranse. Hvis du allerede har prosessert id-en, returnér 200 og dropp.
Verifiser signatur i Node:
import { createHmac, timingSafeEqual } from 'node:crypto';
const expected = createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
const got = req.headers['x-kantax-signature'].replace('sha256=', '');
if (!timingSafeEqual(Buffer.from(expected), Buffer.from(got))) {
return res.status(401).send('Bad signature');
}Samme i Python:
import hmac, hashlib
expected = hmac.new(
WEBHOOK_SECRET.encode(),
raw_body,
hashlib.sha256,
).hexdigest()
got = request.headers['x-kantax-signature'].removeprefix('sha256=')
if not hmac.compare_digest(expected, got):
return Response('Bad signature', status=401)Tidsfrist for respons er 5 sekunder. 2xx = levert; alt annet logges som feil og retryes automatisk: 1 minutt → 5 minutter → 30 minutter → 2 timer → 6 timer (5 forsøk totalt). Sett opp endpoints på Innstillinger → Webhooks.
Roter hemmeligheter trygt: Rotering byttes umiddelbart — ingen overlapp-vindu. Når du klikker «Roter» i innstillinger, oppdater abonnent-koden din så snart du har den nye hemmeligheten, ellers vil signaturverifisering feile inntil du gjør det. Hendelsen webhook.secret_rotated logges også til hendelsesloggen for revisjon.
Eller: poll /v1/audit-log
Hvis du ikke kan ta imot inngående webhooks (e.g. lokal dev, batch-jobb), poll aktivitetsloggen med since=. Lagre seneste created_at og send den som since på neste kall.
# First call: pick up everything since 1h ago
GET /api/v1/audit-log?since=2026-05-19T13:00:00Z&limit=100
Authorization: Bearer $KANTAX_TOKEN
# Response (newest first)
{
"data": [
{ "id": "…", "event_type": "invoice.finalized",
"created_at": "2026-05-19T13:42:11Z", … },
…
],
"pagination": { "limit": 100, "offset": 0, "total": 7 }
}
# Next call: pass the most recent created_at + 1ms as since
GET /api/v1/audit-log?since=2026-05-19T13:42:11.001Z&limit=100
# Optional filter: only invoice.* events
GET /api/v1/audit-log?since=…&event_type_like=invoice.Krever read:audit-log scope. Webhooks foretrekkes — polling er greit for 1-min-takt eller sjeldnere, ikke som high-frequency feed.
Egendefinerte hendelser
Send dine egne hendelser inn i webhook-pipen via POST /v1/audit-log. event_type må starte med "custom." så abonnenter kan opt-in via custom.*.
curl -X POST https://kantax.no/api/v1/audit-log \
-H "Authorization: Bearer $KANTAX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"event_type": "custom.order.shipped",
"summary": "Bestilling #4521 sendt fra Posten",
"entity_type": "order",
"entity_id": "4521",
"metadata": { "carrier": "posten", "tracking": "JJD1234" }
}'Krever write:audit-log scope. Custom events vises i hendelsesloggen som vanlig og fanges av webhook-endepunkter abonnert på "custom.*".
Hendelses-typer
Glob-filter støttes — "invoice.*" matcher alle faktura-hendelser, "*" alle.
customer.* (Stripe-sync closing-the-loop hook)
- customer.createdKunde opprettet — bruk webhook til å skrive Kantax-id tilbake til stripe.metadata
product.* (Stripe-prices-sync closing-the-loop hook)
- product.createdProdukt opprettet — bruk webhook til å bekrefte Stripe price-id-speiling
invoice.* (draft → finalized → paid | voided)
- invoice.finalizedSekvensielt invoice_number tildelt — bokføringsforskriften §5-1 binding
- invoice.paidBetaling registrert (paid_at + paid_amount_ore satt) — terminal
- invoice.voidedReversert via kredittnota (voided_by_invoice_id satt) — terminal
supplier.*
- supplier.createdLeverandør opprettet — for procurement-integrasjoner som speiler vendor-lister
expense.*
- expense.createdUtgift registrert — fyrer fra både dashboard og POST /v1/expenses (via-felt skiller)
inbox.* (upload → ready/failed → posted/archived)
- inbox.readyLLM-utvinning fullført — én av {ready, failed} fyres etter upload
- inbox.failedUtvinning feilet (LLM eller filhåndtering) — terminal
- inbox.postedBokført som utgift via /post — terminal "happy path"
- inbox.archivedArkivert uten bokføring — terminal cleanup path
manual_posting.*
- manual_posting.createdFri postering opprettet
mva.* / amelding.* (prepared → submitted | submission_error; external_filed for manual)
- mva.submission_preparedPayload bygget — én av {submitted, submission_error} fyres etter
- mva.submittedSendt til Altinn 3 — terminal happy path (live mode)
- mva.submission_errorInnsending feilet — terminal error path
- mva.external_filedMarkert sendt eksternt (manuell Altinn-bruk) — alternativ terminal
- amelding.submission_preparedPayload bygget — én av {submitted, submission_error}
- amelding.submittedSendt til Altinn 3 — terminal happy path
- amelding.submission_errorInnsending feilet — terminal error path
- amelding.external_filedMarkert sendt eksternt — alternativ terminal
asset.* (created → depreciation_run* → disposed)
- asset.createdEiendel registrert (kapitalisert)
- asset.disposedAvhendet — terminal; depreciation_run stopper
- asset.depreciation_runAvskrivning kjørt for periode — fyres månedlig per aktiv eiendel
employee.*
- employee.createdAnsatt registrert
project.*
- project.createdProsjekt opprettet
- project.archivedProsjekt arkivert
- project.unarchivedProsjekt gjenåpnet
budget.*
- budget.createdBudsjett opprettet
- budget.deletedBudsjett slettet
year_close.* (closed ⇄ reopened — pair them in audit logs)
- year_close.closedRegnskapsår avsluttet — lås satt, YEC-postering opprettet
- year_close.reopenedLås brutt — krever revisor-godkjenning per bokføringsloven
api_token.* / webhook.* (security audit trail)
- api_token.createdToken opprettet — actor_label = første 8 tegn av token id
- api_token.rotatedRotert (nytt secret, samme id + scopes)
- api_token.revokedTilbakekalt — alle påfølgende kall får 401
- webhook.createdEndepunkt registrert
- webhook.deletedEndepunkt fjernet
- webhook.secret_rotatedSignaturhemmelighet rotert — ingen overlapp, abonnent må oppdatere umiddelbart
- webhook.testManuell test fra Innstillinger
#Feilkoder
Alle feil returneres som { error: "melding", code: "stabil_kode" } — flat struktur, ikke nestet. code er stabil; error er menneskevennlig og kan endres uten varsel.
| Status | Code | Hva |
|---|---|---|
| 401 | unauthenticated | Authorization-header mangler, eller token er ukjent/revokert/utløpt |
| 403 | insufficient_scope | Token mangler påkrevd scope |
| 400 | invalid_body | JSON er ugyldig eller mangler felt |
| 400 | invalid_query | Query-parameter mangler eller har feil format |
| 400 | invalid_id | Path-parameter er ikke en UUID |
| 400 | empty_patch | PATCH-body inneholder ingen felter |
| 404 | not_found | Ressursen finnes ikke i ditt foretak |
| 409 | org_number_conflict | Org.nr finnes fra før |
| 409 | invalid_status | Operasjonen er ikke gyldig fra nåværende status (f.eks. POST /v1/inbox/:id/post mens raden fortsatt er "processing") |
| 409 | sku_conflict | POST /v1/products: SKU finnes fra før |
| 409 | code_conflict | POST /v1/projects: prosjektkode finnes fra før |
| 409 | invoice_not_draft | PATCH /v1/invoices/:id: ferdigstilte/annullerte fakturaer er uforanderlige |
| 409 | invoice_voided | POST /v1/invoices/:id/finalize: annullerte fakturaer kan ikke ferdigstilles |
| 409 | invalid_state | POST /v1/invoices/:id/paid: kun ferdigstilte fakturaer kan markeres betalt |
| 409 | already_paid | POST /v1/invoices/:id/paid: faktura allerede markert betalt |
| 409 | already_posted | PATCH /v1/inbox/:id: kan ikke redigere bokførte bilag — rediger den avledede utgiften |
| 400 | empty_rule | POST /v1/inbox-rules: regel må overstyre minst category eller vat_rate_pct |
| 422 | idempotency_conflict | Idempotency-Key gjenbrukt med en annen body — roter nøkkelen |
| 400 | unbalanced | POST /v1/manual-postings: debet ≠ kredit |
| 400 | empty_posting | POST /v1/manual-postings: alle linjer er null |
| 400 | invalid_content_type | POST /v1/inbox: forventet multipart/form-data |
| 400 | invalid_file_type | POST /v1/inbox: filtype ikke støttet (PDF/PNG/JPG/WebP/GIF) |
| 400 | file_too_large | POST /v1/inbox: filen er over 20 MB |
| 400 | invalid_currency | GET /v1/exchange-rates: ukjent valutakode |
| 429 | rate_limited | Over 120 forespørsler/min — se Retry-After |
| 502 | rate_unavailable | Norges Bank-valutakurser midlertidig utilgjengelig |
| 500 | internal_error | Uventet serverfeil — trygt å prøve på nytt (Idempotency-Key brukes ikke for 5xx) |