Fortnox API — code examplesFortnox API code examples
Four working examples against the Fortnox API, each in both Python and TypeScript: fetch unpaid invoices, create an invoice, page through the supplier register, and handle 429 with backoff.
All four examples authenticate the same way: a Bearer token in the Authorization header against https://api.fortnox.se/3. Swap ACCESS_TOKEN (Python) or accessToken (TypeScript) for a real token before you run the code.
You don't type that token in by hand — it comes from Fortnox's OAuth 2.0 flow and renews with a refresh token. How authentication works step by step.
Fetch unpaid customer invoices
GET /3/invoices with the filter=unpaid query parameter returns only unpaid invoices — the Fortnox OpenAPI spec defines the filter alongside cancelled, fullypaid, unpaidoverdue, and unbooked. The response is an Invoices list where every entry carries a Total field, so the example counts and sums it directly.
Python
import requests
ACCESS_TOKEN = "..." # from the Fortnox OAuth flow, see /fortnox-api/auth
response = requests.get(
"https://api.fortnox.se/3/invoices",
headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
params={"filter": "unpaid"},
)
response.raise_for_status()
invoices = response.json()["Invoices"]
total = sum(invoice["Total"] for invoice in invoices)
print(f"{len(invoices)} obetalda fakturor, totalt {total}")TypeScript
const accessToken = "..."; // from the Fortnox OAuth flow, see /fortnox-api/auth
const response = await fetch(
"https://api.fortnox.se/3/invoices?filter=unpaid",
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
if (!response.ok) {
throw new Error(`Fortnox request failed: ${response.status}`);
}
const { Invoices } = await response.json();
const total = Invoices.reduce((sum, invoice) => sum + invoice.Total, 0);
console.log(`${Invoices.length} unpaid invoices, totaling ${total}`);Example · sample invoices
| No. | Date | Customer | Total |
|---|---|---|---|
| 1001 | 3 Jun | Kvarnen AB | 12 500 |
| 1004 | 9 Jun | Nordic Bygg | 6 250 |
| 1007 | 17 Jun | Lindqvist & Co | 3 750 |
Create a customer invoice
POST /3/invoices takes an Invoice payload. Per the bundled OpenAPI spec, CustomerNumber is the only required field on the Invoice schema — the example also adds one InvoiceRows entry so the invoice is actually sendable. A successful call returns status 201 and the created invoice.
Python
import requests
ACCESS_TOKEN = "..." # from the Fortnox OAuth flow, see /fortnox-api/auth
payload = {
"Invoice": {
"CustomerNumber": "1", # the only required field on the Invoice schema
"InvoiceRows": [
{"Description": "Konsulttimmar", "Price": 1200, "VAT": 25},
],
}
}
response = requests.post(
"https://api.fortnox.se/3/invoices",
headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
json=payload,
)
if response.status_code != 201:
raise RuntimeError(f"Fortnox request failed: {response.status_code} {response.text}")
invoice = response.json()["Invoice"]
print(f"Created invoice {invoice['DocumentNumber']} for {invoice['Total']}")TypeScript
const accessToken = "..."; // from the Fortnox OAuth flow, see /fortnox-api/auth
const response = await fetch("https://api.fortnox.se/3/invoices", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
Invoice: {
CustomerNumber: "1", // the only required field on the Invoice schema
InvoiceRows: [{ Description: "Konsulttimmar", Price: 1200, VAT: 25 }],
},
}),
});
if (!response.ok) {
throw new Error(`Fortnox request failed: ${response.status}`);
}
const { Invoice } = await response.json();
console.log(`Created invoice ${Invoice.DocumentNumber} for ${Invoice.Total}`);Example · sample invoice
Preview · Fortnox
Create customer invoice for customer 1, 1 500 kr
Requested via Claude · Acme AB
| Field | In Fortnox now | After approval |
|---|---|---|
| Customer number | In Fortnox now— | After approval1 |
| Invoice row · Konsulttimmar | In Fortnox now— | After approval1,200.00 |
| VAT 25 % | In Fortnox now— | After approval300.00 |
| Total | In Fortnox now— | After approval1,500.00 |
Nothing is written to Fortnox until you approve, unless the company has enabled auto-approve.
Handle pagination
GET /3/suppliers returns a Suppliers list with no page metadata in the bundled spec. Instead of reading a TotalPages field that isn't in the schema, the example loops on page and limit until a page comes back shorter than limit.
Python
import requests
ACCESS_TOKEN = "..." # from the Fortnox OAuth flow, see /fortnox-api/auth
PAGE_SIZE = 100
suppliers = []
page = 1
while True:
response = requests.get(
"https://api.fortnox.se/3/suppliers",
headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
params={"page": page, "limit": PAGE_SIZE},
)
response.raise_for_status()
batch = response.json()["Suppliers"]
suppliers.extend(batch)
if len(batch) < PAGE_SIZE:
break
page += 1
print(f"{len(suppliers)} leverantörer")TypeScript
const accessToken = "..."; // from the Fortnox OAuth flow, see /fortnox-api/auth
const PAGE_SIZE = 100;
const suppliers: unknown[] = [];
let page = 1;
while (true) {
const response = await fetch(
`https://api.fortnox.se/3/suppliers?page=${page}&limit=${PAGE_SIZE}`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
if (!response.ok) {
throw new Error(`Fortnox request failed: ${response.status}`);
}
const { Suppliers } = await response.json();
suppliers.push(...Suppliers);
if (Suppliers.length < PAGE_SIZE) break;
page += 1;
}
console.log(`${suppliers.length} suppliers`);Handle 429 with backoff
HTTP 429 means you've hit the quota (25 calls per 5 seconds). The wrapper below uses the same defaults as our production client: three retries, exponential backoff from 1 to 30 seconds with jitter, and it honors the Retry-After header when Fortnox sends one.
Python
import random
import time
import requests
MAX_RETRIES = 3
BASE_DELAY_S = 1
MAX_DELAY_S = 30
def fortnox_get(path, access_token, **params):
for attempt in range(MAX_RETRIES + 1):
response = requests.get(
f"https://api.fortnox.se/3{path}",
headers={"Authorization": f"Bearer {access_token}"},
params=params,
)
if response.status_code != 429:
response.raise_for_status()
return response.json()
if attempt == MAX_RETRIES:
response.raise_for_status()
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else min(BASE_DELAY_S * 2 ** attempt, MAX_DELAY_S)
time.sleep(delay + random.uniform(0, delay * 0.5)) # jitter
ACCESS_TOKEN = "..." # from the Fortnox OAuth flow, see /fortnox-api/auth
data = fortnox_get("/invoices", ACCESS_TOKEN, filter="unpaid")TypeScript
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1_000;
const MAX_DELAY_MS = 30_000;
async function fortnoxGet(path: string, accessToken: string): Promise<unknown> {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const response = await fetch(`https://api.fortnox.se/3${path}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (response.status !== 429) {
if (!response.ok) {
throw new Error(`Fortnox request failed: ${response.status}`);
}
return response.json();
}
if (attempt === MAX_RETRIES) {
throw new Error("Fortnox rate limit: retries exhausted");
}
const retryAfter = response.headers.get("Retry-After");
const backoff = Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
const delayMs = retryAfter ? Number(retryAfter) * 1000 : backoff;
await new Promise((resolve) => setTimeout(resolve, delayMs + Math.random() * delayMs * 0.5));
}
throw new Error("Fortnox rate limit: retries exhausted");
}
const accessToken = "..."; // from the Fortnox OAuth flow, see /fortnox-api/auth
const data = await fortnoxGet("/invoices?filter=unpaid", accessToken);Or skip the code
Nordsynk connects an AI assistant straight to Fortnox over MCP. The same four tasks become one line of text instead of a client file to maintain:
The same four tasks · two ways
- 01Fetch unpaid invoices
- 02Create an invoice
- 03Page through suppliers
- 04Handle 429 with backoff
Nordsynk connects an AI assistant straight to Fortnox over MCP. The four tasks become one line of text instead of a client file to maintain.
“show unpaid invoices”“create an invoice”“list suppliers”“no need to handle 429 — we take care of retries”