Public API¶
The ERP NL public API is a REST API for machine-to-machine integrations. It allows external systems to submit and query payment requests without access to the internal application UI.
Base URL¶
The API host and the authentication (Keycloak) host are on separate domains. Do not mix them up.
| Environment | API base URL | Token URL (Keycloak) |
|---|---|---|
| Cloud (production-like) | https://dev-latest.sovereign-erp.nl/api/v1 |
https://auth.dev-latest.sovereign-erp.nl/realms/erp-nl/protocol/openid-connect/token |
| Local (OrbStack) | https://erp-nl.local.gd/api/v1 |
https://auth.erp-nl.local.gd/realms/erp-nl/protocol/openid-connect/token |
All endpoints require HTTPS. HTTP requests are redirected to HTTPS by the Traefik ingress.
Interactive docs¶
| Interface | URL |
|---|---|
| Swagger UI | /api/v1/docs |
| ReDoc | /api/v1/redoc |
| OpenAPI JSON schema | /api/v1/openapi.json |
The schema is scoped to /api/v1/* routes only — internal endpoints are not included.
Authentication¶
All public API endpoints require a valid OAuth2 Bearer token obtained via the Client Credentials grant. Human user tokens (authorization code flow) are rejected.
Token request¶
POST https://auth.dev-latest.sovereign-erp.nl/realms/erp-nl/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=<keycloak_client_id>
&client_secret=<secret>
Note: the token URL uses the
auth.*subdomain, not the API domain. Sending the token request to the API domain returns405 Not Allowedfrom the nginx reverse proxy.
Using the token¶
POST /api/v1/payment-requests
Authorization: Bearer <access_token>
Content-Type: application/json
The organisation is not identified via a header. Instead, every request must include an
organization_short_code field in the request body (POST) or as a query parameter (GET).
The backend resolves the short code to the organisation UUID, verifies the service account
is a member of that organisation, and sets the appropriate RLS context.
Content-type requirements¶
All POST/PUT/PATCH requests must include Content-Type: application/json. Requests without
a valid JSON content-type receive 415 Unsupported Media Type.
All requests must accept JSON responses — an Accept header that explicitly excludes
application/json receives 406 Not Acceptable.
Request bodies are limited to 1 MB. Larger payloads receive 413 Request Entity Too Large.
Required roles / permissions¶
| Endpoint | Required permission |
|---|---|
POST /payment-requests |
payables.payment_request.create |
GET /payment-requests |
payables.payment_request.read |
GET /payment-requests/{id} |
payables.payment_request.read |
Permissions are granted via RBAC role assignments in the ERP application. See service_accounts.md for how to set up a service account and assign roles.
Rate limiting¶
Requests to /api/v\d+/ paths are rate-limited at the Traefik ingress layer before they reach
the application:
| Limit | Value |
|---|---|
| Sustained (all clients combined) | 200 req/s |
| Short burst | 400 requests |
Requests that exceed the burst limit receive 429 Too Many Requests.
Endpoints¶
POST /api/v1/payment-requests¶
Submit a new payment request.
Constraints:
- source_system in the request body must exactly match the authenticated service account's
Keycloak client_id (case-insensitive). This prevents one integration from submitting
requests attributed to another system.
- All identifiers are human-readable natural keys — not internal UUIDs (see
Identifier mapping below).
- Payee type mutual exclusivity — payee_type determines which payee fields are required.
Providing fields for the wrong type returns 422:
payee_type |
Required | Must be absent |
|---|---|---|
SUPPLIER |
supplier_number, supplier_site_code |
person_number |
PERSON |
person_number |
supplier_number, supplier_site_code |
Request body:
{
"organization_short_code": "NL01",
"external_request_id": "INV-2026-00123",
"source_system": "my-erp-connector",
"idempotency_key": "550e8400-e29b-41d4-a716-446655440001",
"payment_request_number": "PR-2026-00123",
"payee_type": "SUPPLIER",
"supplier_number": "S-001",
"supplier_site_code": "MAIN",
"invoice_date": "2026-05-01",
"requested_execution_date": "2026-05-15",
"amount": 1000.00,
"currency_code": "EUR",
"own_bank_account_iban": "NL91ABNA0417164300",
"beneficiary_bank_account": {
"iban": "NL02ABNA0123456789",
"bic": "ABNANL2A",
"account_holder_name": "Leverancier BV",
"country_code": "NL",
"currency_code": null
},
"payment_rail": "SEPA_CT",
"remittance_reference": "Factuur INV-2026-00123",
"description": "Inkoop kantoorbenodigdheden",
"lines": [
{
"description": "Kantoorbenodigdheden",
"line_type": "ITEM",
"amount": 1000.00,
"quantity": 10,
"unit_price": 100.00,
"distributions": [
{
"account_combination_code": "1000.400.00.000",
"amount": 1000.00,
"description": "Kantoorbenodigdheden Q2"
}
]
}
],
"auto_process": false,
"build_instructions": true,
"publish": false
}
| Field | Type | Description |
|---|---|---|
auto_process |
bool |
Reserved for future use. Has no effect in the current version. Defaults to false. |
build_instructions |
bool |
When true (default) a payment instruction file (e.g. SEPA XML) is built after the payment is processed. Set to false to stop at the payment level (PAYMENT_BUILT) without generating the instruction — useful when your integration handles instruction dispatch separately. |
publish |
bool |
Reserved for future use. Has no effect in the current version. Defaults to false. |
Field validation rules:
| Field | Constraint |
|---|---|
organization_short_code |
Max 5 characters |
external_request_id |
Max 255 characters |
source_system |
Max 255 characters; must match the authenticated service account's client_id |
idempotency_key |
Max 255 characters; required — enables safe retry on network failure |
payment_request_number |
Max 255 characters |
invoice_date |
ISO 8601 date (YYYY-MM-DD); invalid format returns 422 |
requested_execution_date |
ISO 8601 date (YYYY-MM-DD); invalid format returns 422 |
currency_code |
Exactly 3 uppercase letters (ISO 4217), e.g. EUR, USD; invalid format returns 422 |
own_bank_account_iban |
Max 34 characters |
payment_rail |
Max 100 characters |
remittance_reference |
Max 140 characters (SEPA pain.001 limit) |
description |
Max 500 characters |
line_type |
Must be one of: ITEM, TAX, FREIGHT, DISCOUNT |
beneficiary_bank_account.iban |
Max 34 characters |
beneficiary_bank_account.bic |
Max 11 characters |
beneficiary_bank_account.account_holder_name |
Max 255 characters |
beneficiary_bank_account.country_code |
Max 2 characters (ISO 3166-1 alpha-2, e.g. NL) |
beneficiary_bank_account.currency_code |
Optional; max 3 characters (ISO 4217) if provided |
lines |
Max 500 items |
lines[].distributions |
Max 500 items per line |
Response 201 Created (or 200 OK for an idempotent replay):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"organization_short_code": "NL01",
"external_request_id": "INV-2026-00123",
"source_system": "my-erp-connector",
"idempotency_key": "550e8400-e29b-41d4-a716-446655440001",
"payee_type": "SUPPLIER",
"person_number": null,
"payee_name": "Leverancier BV",
"requested_execution_date": "2026-05-15",
"intake_status": "PENDING_VALIDATION",
"validation_status": "PENDING",
"process_status": null,
"last_error_code": null,
"last_error_message": null,
"beneficiary_bank_account_snapshot": {
"iban": "NL02**************1234",
"bic": "ABNANL2A",
"account_holder_name": "Leverancier BV",
"resolution_status": "CREATED_PENDING_APPROVAL"
},
"invoice": {
"invoice_number": "INV-2026-00123",
"status": "VALIDATED",
"payment_state": "UNPAID",
"amount": 1000.00,
"currency_code": "EUR",
"supplier_number": "S-001",
"supplier_site_code": "MAIN"
},
"active_hold_count": 0,
"blocking_hold_count": 0,
"created_at": "2026-05-30T12:00:00",
"last_updated_at": "2026-05-30T12:00:00"
}
A Location header is included pointing to the canonical URL (path component is URI-encoded):
Location: /api/v1/payment-requests/INV-2026-00123?organization_short_code=NL01
Idempotent replay: If the same external_request_id (or idempotency_key) is submitted again with an identical payload, the server returns 200 OK with the existing resource and an Idempotent-Replay: true response header. Submitting the same key with a different payload returns 409 Conflict.
idempotency_keyis required. Omitting it returns422. Always use a stable, unique key per submission (e.g. a UUID derived from the source transaction) so that retries on network failures are safe.
GET /api/v1/payment-requests¶
List payment requests submitted by this service account. Results are automatically scoped to
the calling service account's source_system and the specified organisation.
Query parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
organization_short_code |
string | yes | — | Short code of the organisation (e.g. NL01) |
page |
integer | no | 1 |
Page number (1-based, minimum 1) |
page_size |
integer | no | 50 |
Results per page (minimum 1, maximum 100) |
Response 200 OK:
{
"items": [ ... ],
"total": 142,
"page": 1,
"page_size": 50
}
GET /api/v1/payment-requests/{external_request_id}¶
Retrieve the current status of a single payment request by its external_request_id — the
same natural key the source system provided when submitting via POST.
Records are scoped to both the calling service account's source_system and the specified
organisation. Records from other source systems or organisations produce 404 to avoid
information leakage.
Query parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
organization_short_code |
string | yes | Short code of the organisation (e.g. NL01) |
Response 200 OK: the payment request resource (same shape as the POST response).
Payment request fields¶
All three endpoints return the same payment request object shape (defined by
PublicPaymentRequestResponse in backend/public_api/v1_payment_requests.py).
| Field | Type | Description |
|---|---|---|
id |
string (UUID) |
Unique identifier for the payment request |
organization_short_code |
string \| null |
Short code of the organisation (e.g. NL01) |
external_request_id |
string |
Caller-provided external reference |
source_system |
string |
Keycloak client_id of the submitting system |
idempotency_key |
string |
Caller-provided idempotency key |
payee_type |
"SUPPLIER" \| "PERSON" |
Payee type |
payee_name |
string \| null |
Display name of the payee |
person_number |
string \| null |
Person number (when payee_type = PERSON) |
requested_execution_date |
string (ISO 8601 date) |
Requested payment execution date |
intake_status |
string |
Current lifecycle status (see below) |
validation_status |
string |
Validation result: PENDING, VALID, INVALID |
process_status |
string \| null |
Payment processing status |
last_error_code |
string \| null |
Machine-readable error code if failed |
last_error_message |
string \| null |
Human-readable error message if failed |
beneficiary_bank_account_snapshot |
object |
Snapshot of the beneficiary bank account at intake time (see below) |
invoice |
object \| null |
Nested invoice summary (see below) |
active_hold_count |
integer |
Number of active payment holds |
blocking_hold_count |
integer |
Number of holds blocking payment |
created_at |
string \| null (ISO 8601) |
Creation timestamp |
last_updated_at |
string \| null (ISO 8601) |
Last modification timestamp |
intake_status lifecycle values:
| Value | Meaning |
|---|---|
PENDING_VALIDATION |
Submitted; awaiting validation |
READY_FOR_PAYMENT |
Validated and eligible for payment |
VALIDATION_FAILED |
Validation failed — see last_error_code |
PAYMENT_BUILT |
Payment instruction built |
INSTRUCTION_BUILT |
Payment file built |
PROCESSING |
Transmitted to bank; awaiting confirmation |
PAID |
Payment confirmed by bank |
FAILED |
Irrecoverable failure |
Nested beneficiary_bank_account_snapshot fields:
| Field | Type | Description |
|---|---|---|
iban |
string \| null |
Masked IBAN (middle digits redacted) |
bic |
string \| null |
BIC/SWIFT code |
account_holder_name |
string \| null |
Name on the bank account |
country_code |
string \| null |
ISO 3166-1 alpha-2 country code |
resolution_status |
string \| null |
Bank account governance status: CREATED_AUTO_APPROVED, CREATED_PENDING_APPROVAL, APPROVED, REJECTED |
Nested invoice fields:
| Field | Type | Description |
|---|---|---|
invoice_number |
string \| null |
Invoice number |
status |
string \| null |
Invoice status |
payment_state |
string \| null |
Payment state |
amount |
number |
Invoice amount (decimal precision) |
currency_code |
string \| null |
ISO 4217 currency code |
supplier_number |
string \| null |
Supplier natural key |
supplier_site_code |
string \| null |
Supplier site natural key |
The full schema is also available at /api/v1/openapi.json and rendered in the
Swagger UI and ReDoc.
Identifier mapping¶
Public API requests use human-readable natural keys instead of internal database UUIDs. The backend resolves them before delegating to the internal service layer.
| Public field | Resolved to | Resolution rule |
|---|---|---|
organization_short_code |
Organization.id |
Must be a short code the service account is a member of |
supplier_number |
Supplier.id |
Unique per organisation |
supplier_site_code |
SupplierSite.id |
Unique per supplier |
person_number |
PersonPayee.id + PersonPayee.payee_id |
Unique per organisation |
account_combination_code |
GLAccountCombination.id |
Must be ACTIVE; matched on display_string (e.g. 1000.400.00.000) |
own_bank_account_iban |
BankAccount.id |
Must be an active bank account belonging to the organisation |
If any identifier cannot be resolved, the endpoint returns 400 or 404 with a descriptive
error message.
Error format¶
All error responses follow a consistent shape:
{
"detail": "Beschrijving van de fout.",
"code": "ERROR"
}
| Status | Meaning |
|---|---|
400 |
Invalid request body or unresolvable identifier |
401 |
Missing or invalid Bearer token |
403 |
Token is valid but permission or organisation check failed |
404 |
Resource not found (scoped to the caller's organisation) |
406 |
Accept header excludes application/json |
413 |
Request body exceeds 1 MB |
415 |
Missing or unsupported Content-Type (must be application/json) |
422 |
Request body failed schema validation |
429 |
Rate limit exceeded |
503 |
Database temporarily unavailable — retry after 30 s (Retry-After header included) |
Response headers¶
Every response from the backend includes the following headers:
| Header | Value | Purpose |
|---|---|---|
API-Version |
1.0.0 |
Current public API version (NLGov ADR 2.1.0 /core/version-header) |
Strict-Transport-Security |
max-age=31536000; includeSubDomains; preload |
RFC 6797 HSTS |
X-Content-Type-Options |
nosniff |
Prevent MIME sniffing |
X-Frame-Options |
DENY |
Clickjacking protection |
Referrer-Policy |
strict-origin-when-cross-origin |
Limit referrer leakage |
Content-Security-Policy |
Environment-specific (see security/overview.md) | Content injection and clickjacking protection |
Cache-Control |
no-store |
Prevent response caching |