InvoiceMate API
Multi-tenant invoicing and accounting API for the Swedish market. Every documented endpoint below is generated from the canonical route inventory and continuously drift-checked against the implementation.
Interactive reference (Swagger UI) openapi.json (3.1) openapi.yaml
Getting started
Four calls take you from zero to a sandboxed API request. All code samples on this page match the tested request shapes — copy them verbatim.
1. Register an account
curl -s https://api.invoicemate.se/register \
-H 'Content-Type: application/json' \
-d '{"email":"dev@example.com","password":"lösenord-9abc","organization_name":"Dev Org AB"}'
# → {"token":"<JWT>","organization_id":…,
# "user":{"user_id":…,"organization_id":…,"role":"admin","email":…}}
2. Log in
curl -s https://api.invoicemate.se/login \
-H 'Content-Type: application/json' \
-d '{"username":"dev@example.com","password":"lösenord-9abc"}'
# → {"token":"<JWT>","user":{…}}
# If the account has 2FA enabled the response is
# {"requires_2fa":true,"temp_token":"…"} — complete it via
# POST /auth/2fa/login-verify {"temp_token","code"}.
3. Create an isolated sandbox organisation
curl -s https://api.invoicemate.se/v1/organizations \
-H "Authorization: Bearer <JWT>" \
-H 'Content-Type: application/json' \
-d '{"name":"My API Sandbox","purpose":"sandbox"}'
# → {"organization_id": …} (separate tenant database)
4. Switch context and call the API
curl -s https://api.invoicemate.se/v1/user/switch-org \
-H "Authorization: Bearer <JWT>" \
-H 'Content-Type: application/json' \
-d '{"organization_id":<sandbox_org_id>}'
# → {"token":"<NEW JWT scoped to the sandbox org>"}
curl -s https://api.invoicemate.se/v1/<sandbox_org_id>/contacts \
-H "Authorization: Bearer <SANDBOX_JWT>"
Authentication
API calls authenticate with a JWT in the Authorization: Bearer header.
Tokens are valid for 24 hours. The token carries
user_id, organization_id and role; the org in the
token must match the {organizationId} path segment or the request is
rejected with 403 — this is the tenant-isolation boundary.
| Role | Permissions |
|---|---|
admin | Full read/write within the tenant, including billing and webhooks. |
accountant | Read/write for bookkeeping surfaces. |
viewer / user | Read-only. |
Exact per-route role gates are published in
docs/tenant-auth-matrix.json (generated). Logout is server-side —
POST /logout revokes every JWT issued before the call.
Sandbox
Create any number of throwaway organisations with
purpose: "sandbox". Each organisation — production or sandbox — lives in
its own fully isolated tenant database, so a sandbox can never see or modify live
tenant data. Sandbox orgs:
- are for test identities and synthetic data only,
- are excluded from production billing flows,
- are swept by a TTL job (default retention 30 days,
bin/cleanup_sandbox_orgs.php), - support everything the API supports, including webhook subscriptions and safe delivery replay.
There is no shared seed dataset — you create the fixtures you need inside your own sandbox tenant.
Versioning & deprecation
Endpoints are versioned under /v{N}/…. You may pin a version via the
Accept-Version request header or the ?api-version= query
parameter; without either, the latest supported version answers.
- Responses carry
X-API-VersionandX-API-Latest-Version. - Versions approaching retirement carry a
Sunsetheader per RFC 8594. - Requesting an unsupported version returns
406 Not Acceptable. - Breaking changes ship as a new version — existing clients are never broken in place.
Errors
Every error is a JSON object {"error": "…"} — never stack traces or SQL.
| Status | Meaning |
|---|---|
| 400 | Validation or input error |
| 401 | Missing, invalid or expired JWT |
| 403 | Insufficient role, or token org does not match the path |
| 404 | Resource not found in this tenant |
| 406 | Unsupported API version requested |
| 409 | Conflict (e.g. concurrent modification) |
| 422 | Semantic error in a well-formed request |
| 429 | Rate limit exceeded |
| 503 | Required external dependency not configured / unavailable |
Rate limits
Rate limiting is enforced per route class. Default policy:
| Surface | Limit |
|---|---|
Auth endpoints (/login, /register, password reset, 2FA) | 10 req / 15 min |
Inbound webhook receivers (/v1/webhooks/*) | 120 req / min |
| AI endpoints (assistant, categorisation, invoice generation) | 60 req / min |
Organisation provisioning (POST /v1/organizations) | 30 req / hour |
Public payment links (/v1/{org}/payment-links/*) | 30 req / min |
| Outbound email send surfaces | 120 req / min (10 req / min for emails/send-bulk) |
| Global default | 600 req / min |
Per-deployment overrides exist (RATE_LIMIT_RULES); treat these numbers as
the floor. Back off on 429.
Idempotency & pagination
Mutations support opt-in idempotency: send an
Idempotency-Key: <unique string> header (8–200 chars,
[A-Za-z0-9_-], e.g. a UUID) on POST/PUT/PATCH/DELETE.
A retry with the same key and identical body within 24 hours replays the stored
response; the same key with a different body returns 409, as does a
concurrent request still in flight. Keys are scoped per organisation + user +
method + path. Financial mutations are transactional regardless.
List endpoints paginate with page and limit query
parameters (limits are capped, e.g. 100 on contacts). The envelope varies by
resource — contacts return {"items":[…],"count":n,"page":n,"pageSize":n},
invoices return {"data":[…],"pagination":{"page","pageSize","pages","total"}}.
Check the exact shape per operation in the API reference.
Webhooks
Subscribe per organisation; deliveries are HTTPS POSTs signed with HMAC-SHA256.
Only the events below exist — the full registry is introspectable at
GET /v1/{org}/webhooks/events.
| Event | Fires when |
|---|---|
invoice.created | an invoice is created |
invoice.updated | an invoice is updated |
invoice.deleted | an invoice is deleted |
invoice.sent | an invoice is sent to the customer |
invoice.partial_created | a partial invoice is created |
payment.received | a payment is registered on an invoice |
contact.created | a contact is created |
contact.updated | a contact is updated |
contact.deleted | a contact is deleted |
Subscribe
curl -s https://api.invoicemate.se/v1/<org>/webhooks/subscribe \
-H "Authorization: Bearer <JWT>" \
-H 'Content-Type: application/json' \
-d '{"eventId":<id from /webhooks/events>,"uri":"https://your.host/hooks/im","name":"my hook"}'
# → {"id":…,"secret":"<64-hex signing secret — store it, it is shown once>"}
Subscription URLs must be public HTTPS — private/internal addresses are rejected (SSRF guard) at subscribe time and re-checked on every delivery and replay.
Delivery
Body is the envelope {"event","event_id","timestamp","organization_id","data"}.
Headers:
| Header | Value |
|---|---|
X-Webhook-Signature | sha256=<HMAC-SHA256 hex over raw body, subscription secret> |
X-Webhook-Timestamp | Unix seconds |
X-Event-Type | e.g. invoice.created |
X-Event-Id | Unique delivery id — deduplicate on this |
X-Webhook-Replay | 1 on operator/test replays only |
Any 2xx acknowledges. 5xx and 429 are retried by the
scheduled worker (exponential backoff); all other non-2xx are final for that event.
Delivery history: GET /v1/{org}/webhooks/subscriptions/{id}/deliveries.
Verify the signature — PHP
$body = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$ok = hash_equals('sha256=' . hash_hmac('sha256', $body, $secret), $sig);
Verify the signature — Node.js
const crypto = require('crypto');
const sig = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(req.headers['x-webhook-signature']));
Replay a delivery (sandbox-friendly)
curl -s -X POST \
https://api.invoicemate.se/v1/<org>/webhooks/subscriptions/<subId>/deliveries/<deliveryId>/replay \
-H "Authorization: Bearer <JWT>"
# → {"replay_delivery_id":…} — byte-identical body, same event_id,
# fresh HMAC over the current secret. Use it to test your
# signature check and your idempotency handling.
Code examples
cURL — create a contact
curl -s https://api.invoicemate.se/v1/<org>/contacts \
-H "Authorization: Bearer <JWT>" \
-H 'Content-Type: application/json' \
-d '{"name":"Konsult AB","se_number":"559123-4567","email":"faktura@konsult.se"}'
JavaScript (fetch)
const res = await fetch(`https://api.invoicemate.se/v1/${orgId}/contacts`, {
headers: { Authorization: `Bearer ${token}` }
});
const contacts = await res.json();
PHP
$ch = curl_init("https://api.invoicemate.se/v1/{$orgId}/contacts");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$token}"],
]);
$contacts = json_decode(curl_exec($ch), true);
Python
import requests
r = requests.get(f"https://api.invoicemate.se/v1/{org_id}/contacts",
headers={"Authorization": f"Bearer {token}"})
contacts = r.json()
Changelog & deprecation policy
- Breaking changes only ever land under a new
/v{N}/prefix. - Retiring versions emit a
Sunsetresponse header at least 6 months before removal. - The spec version (
info.version) is stamped with the generation date; the contract itself is regenerated from code on every change and drift-gated in the test suite. - Feature-gated and internal/operator routes are never part of the published spec.
Support
Integration questions: support@invoicemate.se. Security reports: see security.txt (RFC 9116). Do not run volume/load tests against production; use a sandbox org.