Build / API reference
API reference
Every endpoint an integration writes code against — discovery, conversion, the journal, accounts, and webhooks — generated from the OpenAPI spec. Base URL https://sandbox.bankconnector.com; the sandbox demo bank never moves real money by construction, and a real bank connection is only money-safe if your company is provisioned sandboxOnly (see Going Live). One-time operator setup lives in the Operator API reference.
Download the spec: openapi.json · openapi.yaml — the full spec covers both tracks.
Discovery
Explore what banks and payment types are available
/profilesReturns every registered bank, its ISO 20022 version, and the payment types available for its country (with full metadata).
| Status | Description |
|---|---|
| 200 | List of bank profiles |
{
"profiles": [
{
"key": "nordea-dk",
"bankName": "Nordea Danmark",
"countryCode": "DK",
"painVersion": "pain.001.001.03",
"bankBic": "NDEADKKK",
"paymentTypes": [
{
"code": "sepa",
"label": "SEPA Credit Transfer",
"description": "string",
"requires": [],
"channel": "SEPA / TARGET2",
"isoEncoding": {}
}
]
}
]
}curl -X GET https://your-host/profiles \
-H 'X-API-Key: YOUR_KEY'const res = await fetch("https://your-host/profiles", {
method: "GET",
headers: {
"X-API-Key": "YOUR_KEY",
},
});
const data = await res.json();import requests
resp = requests.request(
"GET", "https://your-host/profiles",
headers={"X-API-Key": "YOUR_KEY"},
)
resp.raise_for_status()
data = resp.json()/payment-typesAll payment types in the shared catalog with ISO 20022 encoding, descriptions, and requirements.
| Status | Description |
|---|---|
| 200 | Full catalog |
{
"paymentTypes": [
{
"code": "sepa",
"label": "SEPA Credit Transfer",
"description": "string",
"requires": [
"string"
],
"channel": "SEPA / TARGET2",
"isoEncoding": {
"serviceLevel": "SEPA",
"localInstrument": "INST",
"categoryPurpose": "TAXS"
}
}
]
}/banks/{bankKey}/payment-types| Name | In | Required | Description |
|---|---|---|---|
bankKey BankKey | path | yes |
| Status | Description |
|---|---|
| 200 | Bank key/name + offered payment types with full metadata |
| 404 | Unknown bank |
{
"bankKey": "string",
"bank": "string",
"bankName": "string",
"countryCode": "string",
"paymentTypes": [
{}
]
}/banks/{bankKey}/status| Name | In | Required | Description |
|---|---|---|---|
bankKey BankKey | path | yes |
| Status | Description |
|---|---|
| 200 | Bank support status |
| 404 | Unknown bank |
Conversion
Validate and convert canonical payment JSON
/validate/{bankKey}Runs all three validation levels (generic → bank → payment-type) and returns every issue at once. Always returns HTTP 200: check valid in the response body. Use this to check a payment before sending it.
| Name | In | Required | Description |
|---|---|---|---|
bankKey BankKey | path | yes |
| Property | Type | Required | Description |
|---|---|---|---|
messageId | string | yes | |
creationDateTime | string | no | |
initiatingParty | object | yes | |
payments | Payment[] | yes |
{
"messageId": "MSG-20260601-0001",
"creationDateTime": "2026-06-01T10: 30: 00Z",
"initiatingParty": {
"name": "Acme Corp ApS",
"organisationId": {
"id": "DK12345678",
"scheme": "CUST",
"issuer": "string"
}
},
"payments": [
{
"paymentId": "PMT-0001",
"paymentType": "sepa",
"executionDate": "2026-06-04",
"debtor": {
"name": "Acme Corp ApS",
"country": "DK",
"account": {
"iban": "DK5000400440116243",
"currency": "EUR",
"other": {}
},
"agent": {
"bic": "DEUTDEFF",
"clearing": {},
"name": "string"
},
"organisationId": {
"id": "DK12345678",
"scheme": "CUST",
"issuer": "string"
}
},
"transactions": [
{
"endToEndId": "INV-2026-588",
"instructionId": "string",
"amount": "1500.00",
"currency": "EUR",
"creditor": {},
"remittance": {}
}
]
}
]
}| Status | Description |
|---|---|
| 200 | Validation outcome (valid or not: check `valid`) |
| 400 | Malformed JSON body |
| 404 | Unknown bank key |
{
"valid": false,
"issues": [
{
"code": "BANK_BIC_REQUIRED",
"level": "bank",
"severity": "error",
"message": "Bank \"nordea-dk\" requires a BIC for the debtor.",
"path": "payments[0].debtor.agent.bic",
"bank": "nordea-dk",
"paymentType": "sepa"
}
]
}curl -X POST https://your-host/validate/nordea-dk \
-H 'X-API-Key: YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{"messageId":"M-1","creationDateTime":"2026-06-11T10:00:00Z","initiatingParty":{"name":"Acme"},"payments":[{"paymentId":"P-1","paymentType":"sepa","executionDate":"2026-06-12","debtor":{"name":"Acme","account":{"iban":"DK5000400440116243"}},"transactions":[{"endToEndId":"E2E-1","amount":"100.00","currency":"EUR","creditor":{"name":"Beta","country":"DE","account":{"iban":"DE89370400440532013000"}}}]}]}'const res = await fetch("https://your-host/validate/nordea-dk", {
method: "POST",
headers: {
"X-API-Key": "YOUR_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({"messageId":"M-1","creationDateTime":"2026-06-11T10:00:00Z","initiatingParty":{"name":"Acme"},"payments":[{"paymentId":"P-1","paymentType":"sepa","executionDate":"2026-06-12","debtor":{"name":"Acme","account":{"iban":"DK5000400440116243"}},"transactions":[{"endToEndId":"E2E-1","amount":"100.00","currency":"EUR","creditor":{"name":"Beta","country":"DE","account":{"iban":"DE89370400440532013000"}}}]}]}),
});
const data = await res.json();import requests
resp = requests.request(
"POST", "https://your-host/validate/nordea-dk",
headers={"X-API-Key": "YOUR_KEY"},
json={"messageId":"M-1","creationDateTime":"2026-06-11T10:00:00Z","initiatingParty":{"name":"Acme"},"payments":[{"paymentId":"P-1","paymentType":"sepa","executionDate":"2026-06-12","debtor":{"name":"Acme","account":{"iban":"DK5000400440116243"}},"transactions":[{"endToEndId":"E2E-1","amount":"100.00","currency":"EUR","creditor":{"name":"Beta","country":"DE","account":{"iban":"DE89370400440532013000"}}}]}]},
)
resp.raise_for_status()
data = resp.json()/convert/{bankKey}Validates the input (all three levels) then converts it to the bank's specific ISO 20022 pain.001 format. Returns XML on success. Add ?validate=true to additionally validate the generated XML against the official ISO 20022 XSD. This is the STATELESS engine — it does NOT apply per-company bank settings (charge bearer, execution-date offset). Pass ?painVersion= to choose the output version here; per-company settings are applied only on the delivery path, POST /journal/payments. Use that route to see a payment exactly as it will be submitted.
| Name | In | Required | Description |
|---|---|---|---|
bankKey BankKey | path | yes | |
validate boolean | query | no | If true, XSD-validate the generated XML before returning it. |
| Property | Type | Required | Description |
|---|---|---|---|
messageId | string | yes | |
creationDateTime | string | no | |
initiatingParty | object | yes | |
payments | Payment[] | yes |
{
"messageId": "MSG-20260601-0001",
"creationDateTime": "2026-06-01T10: 30: 00Z",
"initiatingParty": {
"name": "Acme Corp ApS",
"organisationId": {
"id": "DK12345678",
"scheme": "CUST",
"issuer": "string"
}
},
"payments": [
{
"paymentId": "PMT-0001",
"paymentType": "sepa",
"executionDate": "2026-06-04",
"debtor": {
"name": "Acme Corp ApS",
"country": "DK",
"account": {
"iban": "DK5000400440116243",
"currency": "EUR",
"other": {}
},
"agent": {
"bic": "DEUTDEFF",
"clearing": {},
"name": "string"
},
"organisationId": {
"id": "DK12345678",
"scheme": "CUST",
"issuer": "string"
}
},
"transactions": [
{
"endToEndId": "INV-2026-588",
"instructionId": "string",
"amount": "1500.00",
"currency": "EUR",
"creditor": {},
"remittance": {}
}
]
}
]
}| Status | Description |
|---|---|
| 200 | Bank-specific pain.001 XML |
| 400 | Malformed JSON body |
| 404 | Unknown bank key |
| 422 | Validation failed: returns all issues at once |
curl -X POST https://your-host/convert/nordea-dk \
-H 'X-API-Key: YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{"messageId":"M-1","creationDateTime":"2026-06-11T10:00:00Z","initiatingParty":{"name":"Acme"},"payments":[{"paymentId":"P-1","paymentType":"sepa","executionDate":"2026-06-12","debtor":{"name":"Acme","account":{"iban":"DK5000400440116243"}},"transactions":[{"endToEndId":"E2E-1","amount":"100.00","currency":"EUR","creditor":{"name":"Beta","country":"DE","account":{"iban":"DE89370400440532013000"}}}]}]}'const res = await fetch("https://your-host/convert/nordea-dk", {
method: "POST",
headers: {
"X-API-Key": "YOUR_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({"messageId":"M-1","creationDateTime":"2026-06-11T10:00:00Z","initiatingParty":{"name":"Acme"},"payments":[{"paymentId":"P-1","paymentType":"sepa","executionDate":"2026-06-12","debtor":{"name":"Acme","account":{"iban":"DK5000400440116243"}},"transactions":[{"endToEndId":"E2E-1","amount":"100.00","currency":"EUR","creditor":{"name":"Beta","country":"DE","account":{"iban":"DE89370400440532013000"}}}]}]}),
});
const data = await res.text(); // pain.001 XMLimport requests
resp = requests.request(
"POST", "https://your-host/convert/nordea-dk",
headers={"X-API-Key": "YOUR_KEY"},
json={"messageId":"M-1","creationDateTime":"2026-06-11T10:00:00Z","initiatingParty":{"name":"Acme"},"payments":[{"paymentId":"P-1","paymentType":"sepa","executionDate":"2026-06-12","debtor":{"name":"Acme","account":{"iban":"DK5000400440116243"}},"transactions":[{"endToEndId":"E2E-1","amount":"100.00","currency":"EUR","creditor":{"name":"Beta","country":"DE","account":{"iban":"DE89370400440532013000"}}}]}]},
)
resp.raise_for_status()
data = resp.text # pain.001 XML/convert-async/{bankKey}Non-blocking sibling of POST /convert/{bankKey}: validates the input, enqueues a background conversion, and returns 202 with a jobId immediately. Poll GET /convert-jobs/{id} for the result. ?validate=true is NOT supported here (the async worker can't run the XSD) — use the synchronous /convert/{bankKey}?validate=true instead.
| Name | In | Required | Description |
|---|---|---|---|
bankKey BankKey | path | yes |
| Property | Type | Required | Description |
|---|---|---|---|
messageId | string | yes | |
creationDateTime | string | no | |
initiatingParty | object | yes | |
payments | Payment[] | yes |
{
"messageId": "MSG-20260601-0001",
"creationDateTime": "2026-06-01T10: 30: 00Z",
"initiatingParty": {
"name": "Acme Corp ApS",
"organisationId": {
"id": "DK12345678",
"scheme": "CUST",
"issuer": "string"
}
},
"payments": [
{
"paymentId": "PMT-0001",
"paymentType": "sepa",
"executionDate": "2026-06-04",
"debtor": {
"name": "Acme Corp ApS",
"country": "DK",
"account": {
"iban": "DK5000400440116243",
"currency": "EUR",
"other": {}
},
"agent": {
"bic": "DEUTDEFF",
"clearing": {},
"name": "string"
},
"organisationId": {
"id": "DK12345678",
"scheme": "CUST",
"issuer": "string"
}
},
"transactions": [
{
"endToEndId": "INV-2026-588",
"instructionId": "string",
"amount": "1500.00",
"currency": "EUR",
"creditor": {},
"remittance": {}
}
]
}
]
}| Status | Description |
|---|---|
| 202 | Conversion enqueued |
| 400 | Malformed JSON body, ?validate=true (unsupported on the async path), or unsupported painVersion |
| 404 | Unknown bank key |
| 422 | Validation failed: returns all issues at once |
{
"jobId": "string",
"status": "string",
"bankKey": "string",
"autoPaymentType": "string"
}/convert-jobs/{id}Returns the status of a job created by POST /convert-async/{bankKey}. While pending/running the body carries the job status; once succeeded it returns the converted pain.001 XML. A job is readable only by the tenant that created it (others get 404 — never a cross-tenant leak).
| Name | In | Required | Description |
|---|---|---|---|
id string | path | yes |
| Status | Description |
|---|---|
| 200 | Job status, or the converted XML once the job has succeeded |
| 404 | Conversion job not found (or not owned by the caller) |
Journal
Submit payments + read the journal: the ERP integration surface (X-API-Key)
/journal/paymentsThe primary ERP integration call. Validates + converts the canonical payment for bankKey, records it in the journal, and routes it through approval + delivery. Pass only companyId (+ bankKey + payment) — platformId is inferred from your API key; do not send it. Idempotency-Key is MANDATORY for host-to-host (a missing key returns 400 idempotency_key_required, unless the payment.messageId is supplied and used as the fallback dedupe key). Send a unique key per logical payment so a network-timeout retry can never double-submit. A retry with the SAME key + SAME body replays the original 201 response and carries the header Idempotency-Replayed: true; the same key with a DIFFERENT body → 409. Requires authentication (X-API-Key for ERP integrations).
| Name | In | Required | Description |
|---|---|---|---|
Idempotency-Key string | header | no | Unique per company per logical payment. MANDATORY for host-to-host (else 400 `idempotency_key_required`, unless `payment.messageId` is provided). Same key + same body replays the original 201 (with `Idempotency-Replayed: true`); same key + different body → 409. |
| Property | Type | Required | Description |
|---|---|---|---|
companyId | string | yes | The company this payment belongs to. (platformId is inferred from your API key — do not send it.) |
bankKey | BankKey | yes | |
payment | PaymentInstruction | yes | |
environment | enum | no | Which connection delivers the payment. Defaults to production; pass "test" to dispatch over the bank's test channel. |
validate | boolean | no | Also XSD-validate the generated XML. |
idempotencyKey | string | no | Alternative to the Idempotency-Key header. |
{
"companyId": "string",
"bankKey": "nordea-dk",
"payment": {
"messageId": "MSG-20260601-0001",
"creationDateTime": "2026-06-01T10: 30: 00Z",
"initiatingParty": {
"name": "Acme Corp ApS",
"organisationId": {
"id": "DK12345678",
"scheme": "CUST",
"issuer": "string"
}
},
"payments": [
{
"paymentId": "PMT-0001",
"paymentType": "sepa",
"executionDate": "2026-06-04",
"debtor": {
"name": "Acme Corp ApS",
"country": "DK",
"account": {},
"agent": {},
"organisationId": {}
},
"transactions": [
{}
]
}
]
},
"environment": "test",
"validate": false,
"idempotencyKey": "string"
}| Status | Description |
|---|---|
| 201 | Payment recorded + journaled. A replay of a prior idempotent submit ALSO returns 201, additionally carrying the response header `Idempotency-Replayed: true`. |
| 400 | `idempotency_key_required` — no Idempotency-Key (header or body) and no `payment.messageId` on a host-to-host submit |
| 401 | Authentication required |
| 403 | Scope mismatch (cross-tenant) |
| 409 | Idempotency conflict (same key, different body) or an in-flight retry |
| 422 | Validation failed: all issues at once |
| 429 | Rate limited (production): see Retry-After |
{
"id": "doc_9f3a",
"journalNo": "OUT-000123",
"platformId": "plat_123",
"companyId": "comp_123",
"bankKey": "nordea-dk",
"environment": "production",
"direction": "outbound",
"type": "pain.001",
"status": "converted",
"summary": "SEPA — 1 payment",
"transactionCount": 1,
"totals": [
{
"currency": "EUR",
"amount": "1000.00"
}
],
"xml": "<?xml version=\"1.0\"?><Document xmlns=\"urn:iso:std:iso: 20022:tech:xsd:pain.001.001.09\">…</Document>",
"deliveryMode": "host-to-host",
"createdAt": "2026-06-20T10: 31: 00Z",
"delivery": {
"status": "queued"
}
}curl -X POST https://your-host/journal/payments \
-H 'X-API-Key: YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{"companyId":"YOUR_COMPANY_ID","bankKey":"nordea-dk","payment":{"messageId":"M-1","creationDateTime":"2026-06-11T10:00:00Z","initiatingParty":{"name":"Acme"},"payments":[{"paymentId":"P-1","paymentType":"sepa","executionDate":"2026-06-12","debtor":{"name":"Acme","account":{"iban":"DK5000400440116243"}},"transactions":[{"endToEndId":"E2E-1","amount":"100.00","currency":"EUR","creditor":{"name":"Beta","country":"DE","account":{"iban":"DE89370400440532013000"}}}]}]}}'const res = await fetch("https://your-host/journal/payments", {
method: "POST",
headers: {
"X-API-Key": "YOUR_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({"companyId":"YOUR_COMPANY_ID","bankKey":"nordea-dk","payment":{"messageId":"M-1","creationDateTime":"2026-06-11T10:00:00Z","initiatingParty":{"name":"Acme"},"payments":[{"paymentId":"P-1","paymentType":"sepa","executionDate":"2026-06-12","debtor":{"name":"Acme","account":{"iban":"DK5000400440116243"}},"transactions":[{"endToEndId":"E2E-1","amount":"100.00","currency":"EUR","creditor":{"name":"Beta","country":"DE","account":{"iban":"DE89370400440532013000"}}}]}]}}),
});
const data = await res.json();import requests
resp = requests.request(
"POST", "https://your-host/journal/payments",
headers={"X-API-Key": "YOUR_KEY"},
json={"companyId":"YOUR_COMPANY_ID","bankKey":"nordea-dk","payment":{"messageId":"M-1","creationDateTime":"2026-06-11T10:00:00Z","initiatingParty":{"name":"Acme"},"payments":[{"paymentId":"P-1","paymentType":"sepa","executionDate":"2026-06-12","debtor":{"name":"Acme","account":{"iban":"DK5000400440116243"}},"transactions":[{"endToEndId":"E2E-1","amount":"100.00","currency":"EUR","creditor":{"name":"Beta","country":"DE","account":{"iban":"DE89370400440532013000"}}}]}]}},
)
resp.raise_for_status()
data = resp.json()/journal/payments/previewRead-only. Runs the same settings-fill → validate → convert (+ XSD) pipeline as POST /journal/payments and returns the generated bank file, but does NOT journal, approve, or dispatch — and needs no live host-to-host connection. Use it to inspect the exact bank file a payment would produce before submitting it. Requires authentication.
| Property | Type | Required | Description |
|---|---|---|---|
companyId | string | yes | The company this payment belongs to. (platformId is inferred from your API key — do not send it.) |
bankKey | BankKey | yes | |
payment | PaymentInstruction | yes | |
environment | enum | no | Which connection delivers the payment. Defaults to production; pass "test" to dispatch over the bank's test channel. |
validate | boolean | no | Also XSD-validate the generated XML. |
idempotencyKey | string | no | Alternative to the Idempotency-Key header. |
{
"companyId": "string",
"bankKey": "nordea-dk",
"payment": {
"messageId": "MSG-20260601-0001",
"creationDateTime": "2026-06-01T10: 30: 00Z",
"initiatingParty": {
"name": "Acme Corp ApS",
"organisationId": {
"id": "DK12345678",
"scheme": "CUST",
"issuer": "string"
}
},
"payments": [
{
"paymentId": "PMT-0001",
"paymentType": "sepa",
"executionDate": "2026-06-04",
"debtor": {
"name": "Acme Corp ApS",
"country": "DK",
"account": {},
"agent": {},
"organisationId": {}
},
"transactions": [
{}
]
}
]
},
"environment": "test",
"validate": false,
"idempotencyKey": "string"
}| Status | Description |
|---|---|
| 200 | Preview generated |
| 400 | Missing fields |
| 401 | Authentication required |
| 422 | Validation / XSD failed |
{
"xml": "string",
"painVersion": "string",
"preview": false
}/journal/ingestUpload a raw bank statement/status file; it is parsed to the one normalised shape and journaled. Requires authentication.
| Property | Type | Required | Description |
|---|---|---|---|
companyId | string | yes | Your company id. platformId is inferred from your API key — do not send it. |
bankKey | string | no | |
content | string | yes | Raw file contents |
{
"companyId": "string",
"bankKey": "string",
"content": "string"
}| Status | Description |
|---|---|
| 201 | File ingested + normalised |
| 401 | Authentication required |
| 422 | Unrecognised / unparseable file |
{
"id": "doc_a1b2",
"journalNo": "IN-000042",
"companyId": "comp_123",
"bankKey": "nordea-dk",
"environment": "production",
"direction": "inbound",
"type": "camt.053",
"status": "imported",
"summary": "Statement — 3 entries",
"transactionCount": 3,
"totals": [
{
"currency": "EUR",
"amount": "4200.00"
}
],
"createdAt": "2026-06-20T08: 15: 00Z"
}/journal/documentsTenant-scoped list of payments + statements (metadata only: no decrypted payload). Filter by direction/type.
| Name | In | Required | Description |
|---|---|---|---|
companyId string | query | yes | |
direction enum | query | no | |
type string | query | no | |
limit integer | query | no | Page size (enables keyset pagination via nextCursor). |
cursor string | query | no | Opaque cursor from a prior page's `nextCursor`. (`before` is accepted as a deprecated alias.) |
| Status | Description |
|---|---|
| 200 | Document summaries |
| 401 | Authentication required |
{
"items": [
{
"id": "string",
"journalNo": "OUT-000123",
"platformId": "string",
"companyId": "string",
"direction": "outbound",
"type": "pain.001",
"bankKey": "string",
"environment": "test",
"status": "converted",
"summary": "string",
"transactionCount": 0,
"totals": [
{
"currency": "string",
"amount": "string"
}
],
"xml": "string",
"deliveryMode": "host-to-host",
"createdAt": "2026-01-01T00: 00: 00Z"
}
],
"nextCursor": "string"
}/journal/exportBulk extraction of full pain.001/camt payloads (account numbers + amounts), optionally filtered within a statement. Admin-only for human sessions; the ERP API key is the intended consumer.
| Property | Type | Required | Description |
|---|---|---|---|
companyId | string | yes | Your company id. platformId is inferred from your API key — do not send it. |
journalNos | string[] | yes |
{
"companyId": "string",
"journalNos": [
"string"
]
}| Status | Description |
|---|---|
| 200 | Exported documents + any notFound journal numbers |
| 401 | Authentication required |
| 403 | Admin required (human session) |
{
"exported": [
{}
],
"notFound": [
"string"
]
}/journal/listTenant-scoped, newest-first list of journal documents with a stable opaque cursor for forward pagination. Metadata only (no decrypted payload). The flagship ERP polling pattern: pass unretrievedOnly=true to fetch only documents you have not collected yet, then mark them collected via POST /journal/export. total counts all documents; unretrieved counts how many are still uncollected. Each item carries collected (boolean) and collectedAt so a poller can track exactly what it has pulled. platformId is inferred from your API key — pass only companyId.
| Name | In | Required | Description |
|---|---|---|---|
companyId string | query | yes | Your company id. (platformId is inferred from your API key — do not send it.) |
cursor string | query | no | Opaque cursor from a prior page's `nextCursor`. |
limit integer | query | no | Page size — default 500, max 1000 (larger than the interactive /journal/documents feed, for bulk ERP pulls). |
direction enum | query | no | |
type string | query | no | |
unretrievedOnly boolean | query | no | When `true`, returns ONLY documents not yet collected (the standard poll-then-collect loop). |
| Status | Description |
|---|---|
| 200 | A page of documents + counts + nextCursor |
| 401 | Authentication required |
{
"total": 42,
"unretrieved": 3,
"nextCursor": null,
"items": [
{
"journalNo": "OUT-000123",
"direction": "outbound",
"type": "pain.001",
"sourceFormat": "pain.001.001.09",
"bankKey": "nordea-dk",
"summary": "SEPA — 1 payment",
"status": "converted",
"collected": false,
"collectedAt": null,
"createdAt": "2026-06-20T10: 31: 00Z",
"expiresAt": null
}
]
}/journal/documents/{id}Returns the document including its canonical/normalised payload and, for seeded/generated docs, the raw xml for download. Tenant-scoped.
| Name | In | Required | Description |
|---|---|---|---|
id string | path | yes | |
companyId string | query | yes |
| Status | Description |
|---|---|
| 200 | Document + payload |
| 401 | Authentication required |
| 404 | Not found (or not in this tenant's scope) |
{
"id": "string",
"journalNo": "OUT-000123",
"platformId": "string",
"companyId": "string",
"direction": "outbound",
"type": "pain.001",
"bankKey": "string",
"environment": "test",
"status": "converted",
"summary": "string",
"transactionCount": 0,
"totals": [
{
"currency": "string",
"amount": "string"
}
],
"xml": "string",
"deliveryMode": "host-to-host",
"createdAt": "2026-01-01T00: 00: 00Z"
}/journal/documents/{id}/payments| Name | In | Required | Description |
|---|---|---|---|
id string | path | yes | |
companyId string | query | yes |
| Status | Description |
|---|---|
| 200 | Per-line payments (payee, amount, endToEndId, status) |
| 401 | Authentication required |
{
"payments": [
{}
]
}/journal/documents/{id}/fileReturns the stored payment file as an application/xml attachment. 404 if the document isn't in scope, isn't outbound, or has no generated XML. Downloading does NOT mark the document retrieved.
| Name | In | Required | Description |
|---|---|---|---|
id string | path | yes | |
companyId string | query | yes |
| Status | Description |
|---|---|
| 200 | The pain.001 XML (attachment) |
| 404 | No downloadable file for this document |
/journal/payment-typesLightweight GROUP BY over the plaintext summary column (no payload decrypt), weighted by transaction count: a tally of your outbound payments grouped by payment type.
| Name | In | Required | Description |
|---|---|---|---|
companyId string | query | yes |
| Status | Description |
|---|---|
| 200 | Per-type counts + total |
| 401 | Authentication required |
{
"counts": {},
"total": 0,
"sampled": 0
}/journal/reconciliationFor each outbound payment, returns its real-world state (cleared / rejected / pending) and the matching evidence, by correlating inbound pain.002 status reports and camt.053/054 statement entries 1:1.
| Name | In | Required | Description |
|---|---|---|---|
companyId string | query | yes |
| Status | Description |
|---|---|
| 200 | Per-payment reconciliation + evidence |
| 401 | Authentication required |
{
"items": [
{
"documentId": "string",
"journalNo": "string",
"messageId": "string",
"state": "cleared",
"evidence": {}
}
]
}/journal/events| Name | In | Required | Description |
|---|---|---|---|
companyId string | query | yes |
| Status | Description |
|---|---|
| 200 | Events newest-first |
| 401 | Authentication required |
{
"items": [
{}
]
}/inbound/payment-statusAccepts a raw pain.002 (or normalised status); matches it to the original outbound by MsgId/endToEndId and advances the payment status. Authenticated.
| Property | Type | Required | Description |
|---|---|---|---|
companyId | string | yes | Your company id. platformId is inferred from your API key — do not send it. |
bankKey | string | no | |
content | string | yes | Raw pain.002 XML. |
{
"companyId": "string",
"bankKey": "string",
"content": "string"
}| Status | Description |
|---|---|
| 200 | Reconciled (status advanced) |
| 401 | Authentication required |
| 422 | Unparseable / unmatched status |
/inbound/statementStateless parse-only preview: the raw file body is normalised and returned but NOT journaled (use POST /journal/ingest to store). A camt.053 may carry several <Stmt> (one per account); ALL are returned in the items list envelope.
| Status | Description |
|---|---|
| 200 | Normalised statement(s) |
| 400 | Empty body or unrecognised format |
| 422 | Unparseable file |
{
"items": [
{}
]
}Accounts
Bank accounts registry + imported statements (session)
/accounts| Name | In | Required | Description |
|---|---|---|---|
companyId string | query | yes |
| Status | Description |
|---|---|
| 200 | Accounts with latest balances |
| 401 | Sign in required |
{
"items": [
{}
]
}/accounts/statementsPer-account statement history (the parsed NormalisedStatement summaries) used by the Statements screen.
| Name | In | Required | Description |
|---|---|---|---|
companyId string | query | yes | |
account string | query | no |
| Status | Description |
|---|---|
| 200 | Statements + any detected sequence gaps |
| 401 | Sign in required |
{
"account": "string",
"statements": [
{}
],
"gaps": [
{}
]
}Webhooks
Outbound event subscriptions (session, Admin)
/webhooks| Name | In | Required | Description |
|---|---|---|---|
companyId string | query | yes |
| Status | Description |
|---|---|
| 200 | Subscriptions |
| 401 | Sign in required |
{
"items": [
{}
]
}/webhooksRegisters an HTTPS endpoint to receive signed event callbacks. Admin only. The subscription field is eventTypes (an array of event names); a typo like events is rejected (400) rather than silently subscribing to everything. Omit eventTypes entirely to receive all events.
| Property | Type | Required | Description |
|---|---|---|---|
platformId | string | yes | |
companyId | string | yes | |
url | string | yes | |
eventTypes | string[] | no | Event names to subscribe to, e.g. ["payment.sent", "payment.rejected"]. Omit to receive all events; an explicitly-empty array is rejected. |
{
"platformId": "string",
"companyId": "string",
"url": "string",
"eventTypes": [
"string"
]
}| Status | Description |
|---|---|
| 201 | Subscription created (returns the signing secret once) |
| 401 | Sign in required |
| 403 | Admin required |
/webhooks/{id}| Name | In | Required | Description |
|---|---|---|---|
id string | path | yes | |
companyId string | query | yes |
| Status | Description |
|---|---|
| 200 | Deleted |
| 401 | Sign in required |
| 403 | Admin required |
| 404 | Not found |
/webhooks/{id}/enable| Name | In | Required | Description |
|---|---|---|---|
id string | path | yes |
| Property | Type | Required | Description |
|---|---|---|---|
platformId | string | yes | |
companyId | string | yes |
{
"platformId": "string",
"companyId": "string"
}| Status | Description |
|---|---|
| 200 | Webhook enabled |
| 401 | Sign in required |
| 403 | Admin required |
| 404 | Not found |
/webhooks/{id}/disable| Name | In | Required | Description |
|---|---|---|---|
id string | path | yes |
| Property | Type | Required | Description |
|---|---|---|---|
platformId | string | yes | |
companyId | string | yes |
{
"platformId": "string",
"companyId": "string"
}| Status | Description |
|---|---|
| 200 | Webhook disabled |
| 401 | Sign in required |
| 403 | Admin required |
| 404 | Not found |
/webhooks/{id}/testSends a signed ping to the endpoint and returns the delivery result.
| Name | In | Required | Description |
|---|---|---|---|
id string | path | yes |
| Property | Type | Required | Description |
|---|---|---|---|
platformId | string | yes | |
companyId | string | yes |
{
"platformId": "string",
"companyId": "string"
}| Status | Description |
|---|---|
| 200 | Delivery result of the test event |
| 401 | Sign in required |
| 403 | Admin required |
| 404 | Not found |
/webhooks/{id}/deliveriesPer-attempt delivery log so no event is silently lost. Newest first, keyset-paginated: pass cursor = the previous page's nextCursor to continue. limit defaults to 50, max 200.
| Name | In | Required | Description |
|---|---|---|---|
id string | path | yes | |
companyId string | query | yes | |
cursor string | query | no | Opaque cursor from the previous page's `nextCursor`. (`after` is accepted as a deprecated alias.) |
limit integer | query | no |
| Status | Description |
|---|---|
| 200 | A page of delivery attempts |
| 401 | Sign in required |
| 403 | Admin required |
| 404 | Webhook not found |
{
"items": [
{
"id": "whd_…",
"webhookId": "wh_…",
"platformId": "string",
"companyId": "string",
"eventType": "payment.sent",
"payload": {},
"status": "delivered",
"httpStatus": 0,
"lastError": "string",
"attempts": 0,
"deliveredAt": "2026-01-01T00: 00: 00Z",
"createdAt": "2026-01-01T00: 00: 00Z",
"updatedAt": "2026-01-01T00: 00: 00Z"
}
],
"nextCursor": "string"
}/webhooks/{id}/deliveries/{deliveryId}/redeliverRe-enqueues the original event payload as a NEW delivery attempt (a new row — the original failure record is preserved). A fresh signature is computed at send time. 404 if the delivery is unknown or not in a failed/delivered state.
| Name | In | Required | Description |
|---|---|---|---|
id string | path | yes | |
deliveryId string | path | yes | |
companyId string | query | yes |
| Status | Description |
|---|---|
| 200 | Re-enqueued |
| 401 | Sign in required |
| 403 | Admin required |
| 404 | Delivery not found / not redeliverable |
{
"redelivered": false,
"deliveryId": "string"
}/webhooks/{id}/redeliver-failedRe-enqueues failed deliveries for the webhook created at/after since. BOUNDED: a single call replays at most a fixed cap (500); when a full page is returned, nextCursor is non-null — pass it back as cursor to drain the rest. Idempotent: a replay id is derived from the source delivery, so a repeated/overlapping window cannot double-enqueue.
| Name | In | Required | Description |
|---|---|---|---|
id string | path | yes | |
companyId string | query | yes | |
since string | query | yes | ISO timestamp — only failed deliveries created at/after this are replayed. |
cursor string | query | no | Opaque cursor from a prior page's `nextCursor` to continue draining. |
| Status | Description |
|---|---|
| 200 | Re-enqueued count + drain cursor |
| 400 | Missing/invalid since |
| 401 | Sign in required |
| 403 | Admin required |
| 404 | Webhook not found |
{
"count": 0,
"nextCursor": "string"
}/webhooks/{id}/re-enableClears an auto-disabled state: resets consecutiveFailures to 0 and clears disabledAt/disabledReason. 400 if the webhook is not currently disabled.
| Name | In | Required | Description |
|---|---|---|---|
id string | path | yes | |
companyId string | query | yes |
| Status | Description |
|---|---|
| 200 | Re-enabled |
| 400 | Webhook is not disabled |
| 401 | Sign in required |
| 403 | Admin required |
| 404 | Webhook not found |
{
"webhookId": "string",
"enabledAt": "2026-01-01T00: 00: 00Z"
}/webhooks/{id}/rotate-secretGenerates a new signing secret and returns it ONCE. The previous secret is retained as a 'pending' secret and BOTH signatures verify during the overlap window (overlapHours, default 24, max 72), so the receiver can update its key without dropping in-flight deliveries. Outbound deliveries are immediately signed with the NEW secret. After the window (housekeeping) the old secret is dropped.
| Name | In | Required | Description |
|---|---|---|---|
id string | path | yes | |
companyId string | query | yes |
| Property | Type | Required | Description |
|---|---|---|---|
overlapHours | integer | no | How long the old secret stays valid. |
{
"overlapHours": 24
}| Status | Description |
|---|---|
| 200 | Rotated — newSecret shown once |
| 400 | Invalid body |
| 401 | Sign in required |
| 403 | Admin required |
| 404 | Webhook not found |
{
"webhookId": "string",
"newSecret": "string",
"overlapUntil": "2026-01-01T00: 00: 00Z"
}Platform
Tenant dashboard + per-bank test payments
/banks/{bankKey}/test-scenariosLists the canned test-payment scenarios this bank can send (domestic, sepa, international, urgent), tailored to its country and offered payment types: each with its label, amount, currency and the chosen payment type.
| Name | In | Required | Description |
|---|---|---|---|
bankKey string | path | yes |
| Status | Description |
|---|---|
| 200 | Offered scenarios |
{
"scenarios": [
{}
]
}/banks/{bankKey}/test-paymentBuilds a schema-valid canonical payment for the chosen scenario from the debtor account supplied, then runs it through the normal convert → approval → deliver path over the chosen environment (defaults to the test channel). Requires a signed-in session.
| Name | In | Required | Description |
|---|---|---|---|
bankKey BankKey | path | yes |
| Property | Type | Required | Description |
|---|---|---|---|
platformId | string | yes | |
companyId | string | yes | |
scenario | enum | yes | |
environment | enum | no | |
account | object | yes |
{
"platformId": "string",
"companyId": "string",
"scenario": "domestic",
"environment": "test",
"account": {
"name": "string",
"iban": "string",
"other": {
"id": "string",
"scheme": "string"
},
"currency": "string",
"country": "string"
}
}| Status | Description |
|---|---|
| 201 | Test payment journaled |
| 400 | Invalid request |
| 401 | Sign in required |
System
Health and version
/health| Status | Description |
|---|---|
| 200 | Server is running |
{
"status": "ok"
}/version| Status | Description |
|---|---|
| 200 | Current version |
{
"version": "0.1.0"
}/readyReadiness probe for orchestrators (distinct from /health liveness): 200 when the app can serve — the database is reachable AND migrated when one is configured — else 503 so the load balancer keeps traffic away. Public, like /health.
| Status | Description |
|---|---|
| 200 | Ready |
| 503 | Not ready (database unreachable or schema not migrated) |
{
"ready": true
}/fx/ratesGlobal (non-tenant) indicative EUR-base FX rates for converting balances to a common currency. Read-only, kept fresh by a daily job (seeded so it works offline). Shape: { base, rates, asOf }.
| Status | Description |
|---|---|
| 200 | Cached EUR-base rates |
{
"base": "EUR",
"rates": {},
"asOf": "string"
}Getting started
/sandbox/provisionSelf-serve onboarding — sandbox only. With NO authentication, provisions a complete, isolated sandbox: your own platform, a company, an admin user, an API key, and an active demo bank connection. Everything created is sandbox-only (no real money can move). Returns the raw API key (shown once) plus a ready-to-run first payment. Your key identifies the platform, so on later requests you pass only companyId. Rate-limited per IP; unused sandboxes are removed after 30 days.
| Property | Type | Required | Description |
|---|---|---|---|
bankKey | string | no | Demo bank to connect (default `danske-dk`). See GET /profiles. |
companyName | string | no | Optional name for the created company. |
label | string | no | Optional label for the API key. |
{
"bankKey": "danske-dk",
"companyName": "Acme Sandbox",
"label": "my-integration"
}| Status | Description |
|---|---|
| 201 | Sandbox provisioned |
| 404 | Not a deployed sandbox (this endpoint does not exist in production). |
| 429 | Rate limited — too many sandboxes from this IP recently. |
{
"apiKey": "key_1a2b…",
"companyId": "comp_…",
"platformId": "plat_…",
"baseUrl": "https://sandbox.bankconnector.com",
"bank": {
"key": "string",
"name": "string",
"country": "string"
},
"firstPayment": {}
}