Build on Kantax
A clean REST API for invoicing, expenses, customers, suppliers and projects. Token auth, JSON in, JSON out, no OAuth dance.
Tier-1 API
Every feature in the UI is also on the API — not an afterthought.
Personal access tokens
Generate kx_pat_ tokens from Settings. Shown once, stored as SHA-256.
Org-scoped
Every token is bound to a single org. You can never accidentally touch another tenant.
#Authentication
Send the token in the Authorization header. All endpoints require a valid token.
Authorization: Bearer kx_pat_<din-token>Generate tokens at Settings → Developer API.
Smoke-test your token
GET /v1/me returns the organization + user the token belongs to — cheapest way to confirm your integration can reach us.
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" }Quickstart examples
Create an invoice via API in 3 languages:
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']Public endpoints (no token)
These need no Authorization header — meant for build-time use and uptime checks.
GET /v1/health200 if the DB responds, 503 otherwise — for uptime monitors.
# 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-typesEvent catalog for SDK type generation. Returns { data: [...], custom_event_prefix, glob_filter_supported }. Cached 1 h.GET /v1/openapi.jsonOpenAPI 3.1 spec for the whole API. ETag-cached.
# 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 can be limited to specific permissions. Pick scopes when creating a token in Settings → Developer API. Format: action:target — e.g. read:invoices, write:expenses, read:* (all reads), write:* (all 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.Available targets (combine with read: or write:). read:* and write:* match every target within their action. Read-only targets (no write: scope): bank-accounts, bank-transactions, exchange-rates, pay-runs, mva-submissions, amelding-submissions, year-closes, webhook-endpoints, webhook-deliveries — these are dashboard-driven or fed from external sources (PSD2, Norges Bank).
Sales
invoicescustomersproductsprojectsPurchases
expensessuppliersinboxinbox-rulesPayroll
employeespay-runsCompliance
mva-submissionsamelding-submissionsmanual-postingsyear-closesassetsBanking
bank-accountsbank-transactionsexchange-ratesPlatform
audit-logwebhook-endpointswebhook-deliveries#Rate limits
Each token is capped at 120 requests per minute. Every response carries X-RateLimit-* headers so you know how much you have left. Over the cap returns 429 with 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.Need to see usage before your next call? Tokens can introspect themselves via GET /v1/me/usage — returns minute-by-minute history for the last hour, hour total, and remaining quota in the current bucket. No scope required (even scope-limited tokens can call it).
# 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
]
}
}Handle 429 with Retry-After
Wait for what the server says (in seconds) — don't guess. Buckets reset on the UTC-minute boundary, so Retry-After is rarely more than 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');
}#Idempotency
All mutating endpoints (POST/PATCH/DELETE) accept an optional Idempotency-Key header. Same key + same body within 24 hours replays the first response — safe to retry on network failures.
# 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.Best practices
- Generate one Idempotency-Key per logical operation — not per retry.
- Use UUIDv4 or a deterministic hash of your business key (e.g. "shipped-order-#4521").
- 5xx responses are not cached — safe to retry with the same key.
- 4xx responses are cached — fix the body, then rotate the key.
#OpenAPI spec
The entire v1 API is described in an OpenAPI 3.1 spec. Import to Postman / Insomnia / Bruno, or generate an SDK with 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-pythonUse it in code:
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 schemaJust need the event catalog to type your webhook handler? Pull it from a public endpoint:
#Conventions
- Money is in øre (integer, not decimal). 100 NOK = 10000 øre. Use
Math.round(nok * 100)when converting — plain float multiplication causes rounding errors (e.g. 12.34 × 100 = 1233.999…). - Dates are ISO 8601 (YYYY-MM-DD).
- Lists return { data: [...], pagination: { limit, offset, total } }.
- Single objects (GET /v1/invoices/:id) also return { data: {...} } — envelope-wrapped so the error shape stays consistent. Sole exception: /v1/me returns a flat { organization, user, token }.
- All timestamps are UTC (Z suffix). Convert to Europe/Oslo client-side when displaying to users.
- Errors return { error: "message", code: "stable_code" } with appropriate HTTP status. message may change — code is stable.
- Multi-currency — pass
currency(ISO 4217 3-letter) +currency_rate(NOK per 1 unit foreign) on invoices and expenses. A trigger fillsnok_*_oreautomatically so reports stay in NOK. UseGET /v1/exchange-ratesfor Norges Bank daily reference rates. - org_id is implicit from the token. You cannot cross tenants.
- Deletes are soft — DELETE flips
archived = trueinstead of removing the row (bokføringsloven mandates 5-year retention). List endpoints hide archived rows by default; pass?archived=trueto include them. - PATCH is partial update — send only the fields you want to change. Empty body returns 400
empty_patch. Available on customers, suppliers, projects, products, employees, expenses, invoices (draft only), inbox, inbox-rules, assets. - DELETE archives — returns
{ data: { id, archived: true } }. Available on customers, suppliers, projects, products, employees, expenses, inbox-rules, assets. Use PATCH witharchived: falseto restore.
Example error
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": "Invalid JSON body",
"code": "invalid_body"
}Field types
| Suffix | Type | Example |
|---|---|---|
| _ore | Integer, øre (1/100 NOK) | amount_ore: 10000 |
| _id | UUID v4 (36 chars) | customer_id: "5b2…" |
| _date | ISO 8601 date | invoice_date: "2026-06-30" |
| _at | ISO 8601 timestamp (UTC) | created_at: "2026-05-19T14:22:11Z" |
| _pct | Decimal percent (25.00 = 25%) | vat_rate_pct: 25.00 |
| _code | Stable string identifier | period_code: "2026-T3" |
| _path | Supabase Storage path — request a signed URL | bilag_path: "org-…/file.pdf" |
| _rate | Decimal exchange rate (NOK per 1 unit) | currency_rate: 10.4823 |
#Pagination
List endpoints accept limit (default 25, max 100) and offset. The response includes pagination.total so you know when you are done.
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=50Stop when data.length < limit or offset + limit ≥ total. limit > 100 is silently clamped — no error.
Iterate through all results
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);
}#Versioning & stability
The /api/v1/ path is stable. We add new fields and endpoints anytime, but we will not change the meaning of existing fields without bumping to /api/v2/.
- Additive changes are safe — new optional request fields, new response fields, and new values in open enums may appear at any time. Clients must ignore unknown fields.
- Breaking changes are announced at least 90 days in advance via a
Sunsetheader on affected endpoints plus email to every org with active tokens. - Fields marked "beta" in the OpenAPI spec may change without 90-day notice while we iterate.
- Changelog:
info.versionin OpenAPI bumps on every release; check openapi.json for the current version.
#Endpoints
/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
Example 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.0Tip: X-Kantax-Delivery stays stable across retries — store it in your DB to handle the same delivery idempotently. If you've already processed the id, return 200 and drop.
Verify the signature in 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');
}Same in 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)Response timeout is 5s. 2xx = delivered; anything else is logged as failed and auto-retried with exponential backoff: 1m → 5m → 30m → 2h → 6h (5 attempts total). Configure endpoints at Settings → Webhooks.
Rotate secrets safely: Rotation swaps immediately — there is no overlap window. When you click “Rotate” in settings, update your subscriber code as soon as you have the new secret, otherwise signature verification will fail until you do. The webhook.secret_rotated event is also written to the activity log for audit.
Or: poll /v1/audit-log
If you can't accept inbound webhooks (e.g. local dev, batch job), poll the activity log with since=. Keep track of the latest created_at you've seen and pass it as since on the next call.
# 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.Requires read:audit-log scope. Webhooks are preferred — polling is fine at 1-minute cadence or slower, not as a high-frequency feed.
Custom events
Push your own events into the webhook pipeline via POST /v1/audit-log. event_type must start with "custom." so subscribers can 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" }
}'Requires write:audit-log scope. Custom events show up in the activity log normally and are caught by webhook endpoints subscribed to "custom.*".
Event types
Glob filters supported — "invoice.*" matches every invoice event, "*" matches all.
customer.* (Stripe-sync closing-the-loop hook)
- customer.createdCustomer created — use webhook to write Kantax id back into stripe.metadata
product.* (Stripe-prices-sync closing-the-loop hook)
- product.createdProduct created — use webhook to confirm Stripe price-id mirror
invoice.* (draft → finalized → paid | voided)
- invoice.finalizedSequential invoice_number assigned — bokføringsforskriften §5-1 binding
- invoice.paidPayment recorded (paid_at + paid_amount_ore set) — terminal
- invoice.voidedReversed via credit note (voided_by_invoice_id set) — terminal
supplier.*
- supplier.createdSupplier created — for procurement integrations mirroring vendor lists
expense.*
- expense.createdExpense created — fires from both dashboard and POST /v1/expenses (via field distinguishes)
inbox.* (upload → ready/failed → posted/archived)
- inbox.readyLLM extraction complete — one of {ready, failed} fires after upload
- inbox.failedExtraction failed (LLM or file handling) — terminal
- inbox.postedPosted as expense via /post — terminal happy path
- inbox.archivedArchived without posting — terminal cleanup path
manual_posting.*
- manual_posting.createdManual journal entry created
mva.* / amelding.* (prepared → submitted | submission_error; external_filed for manual)
- mva.submission_preparedPayload built — one of {submitted, submission_error} fires after
- mva.submittedSubmitted to Altinn 3 — terminal happy path (live mode)
- mva.submission_errorSubmission failed — terminal error path
- mva.external_filedMarked filed externally (manual Altinn use) — alternative terminal
- amelding.submission_preparedPayload built — one of {submitted, submission_error}
- amelding.submittedSubmitted to Altinn 3 — terminal happy path
- amelding.submission_errorSubmission failed — terminal error path
- amelding.external_filedMarked filed externally — alternative terminal
asset.* (created → depreciation_run* → disposed)
- asset.createdAsset registered (capitalised)
- asset.disposedDisposed — terminal; depreciation_run stops
- asset.depreciation_runDepreciation run for period — fires monthly per active asset
employee.*
- employee.createdEmployee created
project.*
- project.createdProject created
- project.archivedProject archived
- project.unarchivedProject unarchived
budget.*
- budget.createdBudget created
- budget.deletedBudget deleted
year_close.* (closed ⇄ reopened — pair them in audit logs)
- year_close.closedFiscal year closed — lock set, YEC posting created
- year_close.reopenedLock broken — requires accountant approval per bokføringsloven
api_token.* / webhook.* (security audit trail)
- api_token.createdToken created — actor_label = first 8 chars of token id
- api_token.rotatedRotated (new secret, same id + scopes)
- api_token.revokedRevoked — all subsequent calls return 401
- webhook.createdEndpoint registered
- webhook.deletedEndpoint removed
- webhook.secret_rotatedSigning secret rotated — no overlap, subscriber must update immediately
- webhook.testManual test fired from Settings
#Error codes
All errors are returned as { error: "message", code: "stable_code" } — flat shape, not nested. code is stable; error is human-readable and may change without notice.
| Status | Code | Meaning |
|---|---|---|
| 401 | unauthenticated | Authorization header missing, or token unknown/revoked/expired |
| 403 | insufficient_scope | Token missing required scope |
| 400 | invalid_body | JSON invalid or missing fields |
| 400 | invalid_query | Query parameter missing or malformed |
| 400 | invalid_id | Path parameter is not a UUID |
| 400 | empty_patch | PATCH body contains no fields |
| 404 | not_found | Resource not in your org |
| 409 | org_number_conflict | Org number already exists |
| 409 | invalid_status | Operation not valid from current status (e.g. POST /v1/inbox/:id/post while still "processing") |
| 409 | sku_conflict | POST /v1/products: SKU already exists |
| 409 | code_conflict | POST /v1/projects: project code already exists |
| 409 | invoice_not_draft | PATCH /v1/invoices/:id: finalized/voided invoices are immutable |
| 409 | invoice_voided | POST /v1/invoices/:id/finalize: voided invoices cannot be finalized |
| 409 | invalid_state | POST /v1/invoices/:id/paid: only finalized invoices can be marked paid |
| 409 | already_paid | POST /v1/invoices/:id/paid: invoice already marked paid |
| 409 | already_posted | PATCH /v1/inbox/:id: cannot edit posted inbox items — edit the derived expense |
| 400 | empty_rule | POST /v1/inbox-rules: rule must override at least category or vat_rate_pct |
| 422 | idempotency_conflict | Idempotency-Key reused with a different body — rotate the key |
| 400 | unbalanced | POST /v1/manual-postings: debit ≠ credit |
| 400 | empty_posting | POST /v1/manual-postings: all lines are zero |
| 400 | invalid_content_type | POST /v1/inbox: expected multipart/form-data |
| 400 | invalid_file_type | POST /v1/inbox: file type not supported (PDF/PNG/JPG/WebP/GIF) |
| 400 | file_too_large | POST /v1/inbox: file exceeds 20 MB |
| 400 | invalid_currency | GET /v1/exchange-rates: unknown currency code |
| 429 | rate_limited | Over 120 req/min — check Retry-After |
| 502 | rate_unavailable | Norges Bank FX rates temporarily unavailable |
| 500 | internal_error | Unexpected server error — safe to retry (Idempotency-Key not cached for 5xx) |