Ga naar inhoud

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 returns 405 Not Allowed from 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 exclusivitypayee_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_key is required. Omitting it returns 422. 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