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 |
{
"items": [
{
"key": "string",
"bankName": "string",
"countryCode": "string",
"painVersion": "string",
"unsupported": {
"reason": "string"
},
"paymentTypes": [
{
"code": "string",
"label": "string",
"description": "string",
"requires": [],
"channel": "string"
}
]
}
]
}curl -X GET https://your-host/api/profiles \
-H 'X-API-Key: YOUR_KEY'const res = await fetch("https://your-host/api/profiles", {
method: "GET",
headers: {
"X-API-Key": "YOUR_KEY",
},
});
const data = await res.json();import requests
resp = requests.request(
"GET", "https://your-host/api/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 |
{
"items": [
{
"code": "string",
"label": "string",
"description": "string",
"requires": [
"string"
],
"channel": "string",
"isoEncoding": {
"serviceLevel": "string",
"localInstrument": "string",
"categoryPurpose": "string"
}
}
]
}/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 |
{
"items": [
{
"code": "string",
"label": "string",
"description": "string",
"requires": [
"string"
],
"channel": "string",
"isoEncoding": {
"serviceLevel": "string",
"localInstrument": "string",
"categoryPurpose": "string"
}
}
],
"bankKey": "string",
"bankName": "string",
"countryCode": "string"
}/banks/{bankKey}/status| Name | In | Required | Description |
|---|---|---|---|
bankKey BankKey | path | yes |
| Status | Description |
|---|---|
| 200 | Bank support status |
| 404 | Unknown bank |
{
"bankKey": "string",
"deliveryMode": "converted-only",
"hostToHost": false
}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 |
|---|---|---|---|
schemaVersion | enum | no | Canonical contract version; defaults to the current version when omitted. |
messageId | string | yes | |
creationDateTime | string | no | |
initiatingParty | object | yes | |
payments | Payment[] | yes |
{
"schemaVersion": "1.0",
"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",
"priority": "normal",
"chargeBearer": "DEBT",
"localInstrument": "string",
"categoryPurpose": "string",
"executionDate": "2026-06-04",
"debtor": {
"name": "Acme Corp ApS",
"country": "DK",
"postalAddress": {
"streetName": "string",
"buildingNumber": "string",
"postCode": "string",
"townName": "string",
"countrySubDivision": "string",
"addressLines": []
},
"account": {
"iban": "DK5000400440116243",
"currency": "EUR",
"other": {}
},
"agent": {
"bic": "DEUTDEFF",
"clearing": {},
"name": "string",
"country": "DE"
},
"organisationId": {
"id": "DK12345678",
"scheme": "CUST",
"issuer": "string"
}
},
"ultimateDebtor": {
"name": "Acme Subsidiary GmbH",
"country": "DE",
"postalAddress": {
"streetName": "string",
"buildingNumber": "string",
"postCode": "string",
"townName": "string",
"countrySubDivision": "string",
"addressLines": []
},
"organisationId": {
"id": "DK12345678",
"scheme": "CUST",
"issuer": "string"
}
},
"transactions": [
{
"endToEndId": "INV-2026-588",
"instructionId": "string",
"amount": "1500.00",
"currency": "EUR",
"creditor": {},
"ultimateDebtor": {},
"ultimateCreditor": {},
"instructionForDebtorAgent": "string",
"remittance": {},
"purposeCode": "GDDS",
"regulatoryReporting": {},
"splitPayment": {}
}
]
}
]
}| Status | Description |
|---|---|
| 200 | Validation outcome (valid or not: check `valid`) |
| 400 | Malformed JSON body |
| 404 | Unknown bank key |
{
"valid": false,
"issues": [
{
"code": "string",
"level": "generic",
"severity": "error",
"message": "string",
"path": "string",
"bank": "string"
}
]
}curl -X POST https://your-host/api/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/api/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/api/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 |
|---|---|---|---|
schemaVersion | enum | no | Canonical contract version; defaults to the current version when omitted. |
messageId | string | yes | |
creationDateTime | string | no | |
initiatingParty | object | yes | |
payments | Payment[] | yes |
{
"schemaVersion": "1.0",
"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",
"priority": "normal",
"chargeBearer": "DEBT",
"localInstrument": "string",
"categoryPurpose": "string",
"executionDate": "2026-06-04",
"debtor": {
"name": "Acme Corp ApS",
"country": "DK",
"postalAddress": {
"streetName": "string",
"buildingNumber": "string",
"postCode": "string",
"townName": "string",
"countrySubDivision": "string",
"addressLines": []
},
"account": {
"iban": "DK5000400440116243",
"currency": "EUR",
"other": {}
},
"agent": {
"bic": "DEUTDEFF",
"clearing": {},
"name": "string",
"country": "DE"
},
"organisationId": {
"id": "DK12345678",
"scheme": "CUST",
"issuer": "string"
}
},
"ultimateDebtor": {
"name": "Acme Subsidiary GmbH",
"country": "DE",
"postalAddress": {
"streetName": "string",
"buildingNumber": "string",
"postCode": "string",
"townName": "string",
"countrySubDivision": "string",
"addressLines": []
},
"organisationId": {
"id": "DK12345678",
"scheme": "CUST",
"issuer": "string"
}
},
"transactions": [
{
"endToEndId": "INV-2026-588",
"instructionId": "string",
"amount": "1500.00",
"currency": "EUR",
"creditor": {},
"ultimateDebtor": {},
"ultimateCreditor": {},
"instructionForDebtorAgent": "string",
"remittance": {},
"purposeCode": "GDDS",
"regulatoryReporting": {},
"splitPayment": {}
}
]
}
]
}| Status | Description |
|---|---|
| 200 | Bank-specific pain.001 XML. A payment that is VALID but raised non-blocking warnings still converts — warnings never block — and its response carries the `X-Validation-Warnings` header. |
| 400 | Malformed JSON body |
| 404 | Unknown bank key |
| 422 | Validation failed: returns all issues at once |
curl -X POST https://your-host/api/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/api/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/api/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 |
|---|---|---|---|
schemaVersion | enum | no | Canonical contract version; defaults to the current version when omitted. |
messageId | string | yes | |
creationDateTime | string | no | |
initiatingParty | object | yes | |
payments | Payment[] | yes |
{
"schemaVersion": "1.0",
"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",
"priority": "normal",
"chargeBearer": "DEBT",
"localInstrument": "string",
"categoryPurpose": "string",
"executionDate": "2026-06-04",
"debtor": {
"name": "Acme Corp ApS",
"country": "DK",
"postalAddress": {
"streetName": "string",
"buildingNumber": "string",
"postCode": "string",
"townName": "string",
"countrySubDivision": "string",
"addressLines": []
},
"account": {
"iban": "DK5000400440116243",
"currency": "EUR",
"other": {}
},
"agent": {
"bic": "DEUTDEFF",
"clearing": {},
"name": "string",
"country": "DE"
},
"organisationId": {
"id": "DK12345678",
"scheme": "CUST",
"issuer": "string"
}
},
"ultimateDebtor": {
"name": "Acme Subsidiary GmbH",
"country": "DE",
"postalAddress": {
"streetName": "string",
"buildingNumber": "string",
"postCode": "string",
"townName": "string",
"countrySubDivision": "string",
"addressLines": []
},
"organisationId": {
"id": "DK12345678",
"scheme": "CUST",
"issuer": "string"
}
},
"transactions": [
{
"endToEndId": "INV-2026-588",
"instructionId": "string",
"amount": "1500.00",
"currency": "EUR",
"creditor": {},
"ultimateDebtor": {},
"ultimateCreditor": {},
"instructionForDebtorAgent": "string",
"remittance": {},
"purposeCode": "GDDS",
"regulatoryReporting": {},
"splitPayment": {}
}
]
}
]
}| Status | Description |
|---|---|
| 202 | Conversion enqueued. `warnings` is present only when the (valid) payment raised non-blocking warnings — they are returned here, on the enqueue, rather than on the job poll, whose success body is the XML file. |
| 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": "pending",
"bankKey": "string",
"autoPaymentType": "string",
"unknownFieldsDropped": [
"string"
],
"warnings": [
{
"code": "string",
"level": "generic",
"severity": "error",
"message": "string",
"path": "string",
"bank": "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 | Terminal state: the converted XML once the job has succeeded. A permanently-failed (dead) job returns 200 with a `failure` object — NOT the reserved `error` envelope key, which always means the REQUEST itself failed. |
| 202 | Job still pending or running — poll again. Same job-status body as the 200. |
| 404 | Conversion job not found (or not owned by the caller) |
{
"jobId": "string",
"status": "pending",
"createdAt": "string",
"updatedAt": "string",
"failure": {
"code": "string",
"message": "string",
"details": {
"lastError": "string"
}
}
}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. A re-submission under a payment.messageId whose original payment is rejected/cancelled is refused with 409 message_id_already_used rather than replayed — issue a new messageId. 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": {
"schemaVersion": "1.0",
"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",
"priority": "normal",
"chargeBearer": "DEBT",
"localInstrument": "string",
"categoryPurpose": "string",
"executionDate": "2026-06-04",
"debtor": {
"name": "Acme Corp ApS",
"country": "DK",
"postalAddress": {},
"account": {},
"agent": {},
"organisationId": {}
},
"ultimateDebtor": {
"name": "Acme Subsidiary GmbH",
"country": "DE",
"postalAddress": {},
"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`. `warnings` is present only when the accepted payment raised non-blocking validation warnings — they never block a submit and never change this status code. |
| 400 | `idempotency_key_required` — no Idempotency-Key (header or body) and no `payment.messageId` on a host-to-host submit |
| 401 | Authentication required |
| 402 | Two billing gates share this status, both PRODUCTION submit only and neither ever returned for a `test`-environment submit or for `POST /journal/payments/preview`. `trial_payments_exhausted` — this company has already sent its 10 free lifetime production payments (docs/PRICING.md) and has no active package; `error.details` carries `productionPaymentsSent`, `freeLimit` and `packagesUrl` (GET /billing/packages for current prices). `billing_past_due` — an established account 30+ days past due on an unpaid invoice; `error.details` carries `pastDueSince` and `graceDays`. A payment already approved and in flight is never affected. |
| 403 | Scope mismatch (cross-tenant) |
| 409 | `conflict` — idempotency conflict (same key, different body) or an in-flight retry. `message_id_already_used` — the payment previously submitted with this `payment.messageId` is terminal (`rejected` / `cancelled`), so this re-submission was NOT attempted: a `messageId` is used once. `details` carries `originalJournalNo`, `originalStatus` and `remedy: "new_message_id"`. Issue a new `messageId` for a new attempt — a new `Idempotency-Key` does not help, because the messageId governs. A re-submission while the original is still IN FLIGHT (or already `executed`) replays the original 201 as before. |
| 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"
}
}// Submit a payment through the engine — validated, recorded in the journal, dispatched to the bank.
// The SDK auto-generates an Idempotency-Key, so a network retry can never create a duplicate payment.
import { BankConnector, BankConnectorApiError } from "@bankconnector/sdk";
export async function submitPayment(client: BankConnector) {
try {
const result = await client.submitPayment({
companyId: "YOUR_COMPANY_ID",
bankKey: "nordea-dk",
payment: {
messageId: "M-2026-0001",
initiatingParty: { name: "Acme ApS" },
payments: [
{
paymentId: "P-1",
paymentType: "domestic",
executionDate: "2026-08-20",
debtor: { name: "Acme ApS", account: { iban: "DK5000400440116243" } },
transactions: [
{
endToEndId: "INV-2026-0042",
amount: "1500.00",
currency: "DKK",
creditor: { name: "Supplier A/S", country: "DK", account: { iban: "DK9520000123456789" } },
},
],
},
],
},
});
console.log(`Recorded ${result.journalNo} — status ${result.status}`);
} catch (err) {
if (err instanceof BankConnectorApiError && err.code === "validation_failed") {
// Every issue at once — fix in one pass rather than one 422 at a time.
console.error("Rejected:", (err.details as { issues: { code: string; message: string }[] }).issues);
return;
}
throw err;
}
}
// new BankConnector({ baseUrl: "https://your-host", apiKey: process.env.BANKCONNECTOR_API_KEY })curl -X POST https://your-host/api/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/api/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/api/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": {
"schemaVersion": "1.0",
"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",
"priority": "normal",
"chargeBearer": "DEBT",
"localInstrument": "string",
"categoryPurpose": "string",
"executionDate": "2026-06-04",
"debtor": {
"name": "Acme Corp ApS",
"country": "DK",
"postalAddress": {},
"account": {},
"agent": {},
"organisationId": {}
},
"ultimateDebtor": {
"name": "Acme Subsidiary GmbH",
"country": "DE",
"postalAddress": {},
"organisationId": {}
},
"transactions": [
{}
]
}
]
},
"environment": "test",
"validate": false,
"idempotencyKey": "string"
}| Status | Description |
|---|---|
| 200 | Preview generated. Carries the same `warnings` a real submit would return (present only when the payment raised non-blocking warnings) — seeing them here is the point of a preview. |
| 400 | Missing fields |
| 401 | Authentication required |
| 422 | Validation / XSD failed |
{
"xml": "string",
"painVersion": "pain.001.001.03",
"preview": false,
"warnings": [
{
"code": "string",
"level": "generic",
"severity": "error",
"message": "string",
"path": "string",
"bank": "string",
"paymentType": "string"
}
],
"autoSelected": [
{
"paymentId": "string",
"selected": "string",
"confidence": "string",
"reason": "string"
}
],
"autoFilled": [
"string"
],
"unknownFieldsDropped": [
"string"
],
"whitelist": {
"applicable": false,
"outcome": "cleared",
"checks": [
{
"nip": "string",
"nrb": "string",
"date": "string",
"status": "listed",
"requestId": "string",
"error": "string"
}
]
}
}/journal/ingestUpload a raw bank statement/status file; it is parsed to the one normalised shape and journaled. Requires authentication.
| Property | Type | Required | Description |
|---|---|---|---|
platformId | string | no | |
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 |
environment | enum | no |
{
"platformId": "string",
"companyId": "string",
"bankKey": "string",
"content": "string",
"environment": "test"
}| Status | Description |
|---|---|
| 200 | Dedupe hit (R2-M10): this content was already ingested — returns the EXISTING document unchanged, and no side-effects (events, status advances) re-run. |
| 201 | File ingested + normalised. INGEST-FIRST-DOCUMENT-SPEAKS-1 narrowed this status: it now means every statement block of the file was ingested. A file whose blocks did not all end the same way answers 207. |
| 207 | INGEST-FIRST-DOCUMENT-SPEAKS-1 — PARTIAL: the file's statement blocks did not all end the same way (some ingested, some deduped, some quarantined as belonging to another company), or a file-level alert says the file was not whole (a per-account block that opened and never closed). The body is the 201's — the primary document plus the `ingest` block — and `ingest.statements` says what happened to every block. A camt.053 carries one block per ACCOUNT, so this is the status that distinguishes "3 of 4 accounts imported" from a clean import; before this existed both answered 201. |
| 401 | Authentication required |
| 422 | Unrecognised / unparseable file |
{
"id": "string",
"journalNo": "string",
"platformId": "string",
"companyId": "string",
"bankKey": "string",
"environment": "test",
"direction": "inbound",
"type": "pain.001",
"sourceFormat": "string",
"summary": "string",
"status": "string",
"messageId": "string",
"importSource": "manual",
"transactionCount": 0,
"totals": [
{
"currency": "string",
"amount": "string"
}
],
"paymentType": "string",
"idempotencyKey": "string",
"collected": false,
"collectedAt": "string",
"createdAt": "string",
"expiresAt": "string",
"deduped": false,
"ingest": {
"statementCount": 0,
"ingested": 0,
"deduped": 0,
"quarantined": 0,
"partial": false,
"statements": [
{
"index": 0,
"kind": "ingested",
"documentId": "string",
"journalNo": "string",
"account": "string",
"message": "string"
}
],
"alerts": [
{
"kind": "string",
"message": "string",
"reason": "string",
"account": "string",
"unclosedBlockCount": 0,
"survivingStatementCount": 0
}
]
}
}/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 | |
collected boolean | query | no | Filter by collection state (CM-19): `false` = you have not collected it yet, `true` = already collected. This is the item's `collected` flag, not a `status` value. |
q string | query | no | Free-text search (case-insensitive, matched literally — `%` and `_` are not wildcards). Matches the document summary, messageId, journalNo, bankKey, status, type, and the rendered `<amount> <currency>` totals text. |
status string | query | no | One or more exact document statuses (e.g. `pending-approval`, `sent`, `executed`, `rejected`), comma-separated for an OR match (e.g. `converted,sent`). Exact match per value, not a search. |
amountMin string | query | no | Lower bound on any of the document's totals, inclusive. Decimal string, compared in EXACT minor units (never as a float). ⚠️ Applied to the returned PAGE by the service, not pushed into SQL — so with a page size N you get the matching subset OF THAT PAGE; keep paging via `cursor` for the rest. A non-numeric value is IGNORED (no 400). |
amountMax string | query | no | Upper bound on any of the document's totals, inclusive. Same semantics as `amountMin`. |
dateFrom string | query | no | Only documents created on or after this day (`YYYY-MM-DD`, inclusive, server timezone). |
dateTo string | query | no | Only documents created on or before this day (`YYYY-MM-DD`, **inclusive** — the whole day is covered). |
limit integer | query | no | Page size (enables keyset pagination via nextCursor — on filtered searches too). |
cursor string | query | no | Opaque cursor from a prior page's `nextCursor`. |
| Status | Description |
|---|---|
| 200 | Document summaries |
| 401 | Authentication required |
{
"items": [
{
"id": "string",
"journalNo": "string",
"direction": "inbound",
"type": "pain.001",
"sourceFormat": "string",
"bankKey": "string",
"bankName": "string",
"summary": "string",
"status": "string",
"deliveryMode": "converted-only",
"importSource": "manual",
"transactionCount": 0,
"totals": [
{
"currency": "string",
"amount": "string"
}
],
"paymentType": "string",
"accountIban": "string",
"accountCurrency": "string",
"collected": false,
"collectedAt": "string",
"createdAt": "string"
}
],
"nextCursor": "string",
"total": 0
}/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 |
|---|---|---|---|
platformId | string | no | |
companyId | string | yes | Your company id. platformId is inferred from your API key — do not send it. |
journalNos | string[] | yes | |
filter | — | no |
{
"platformId": "string",
"companyId": "string",
"journalNos": [
"string"
]
}| Status | Description |
|---|---|
| 200 | Exported documents + any notFound journal numbers |
| 207 | Partial hit: SOME of the requested journalNos exist and were exported, but at least one did not — check `notFound` for which. Same body as 200 (API-SPEC-JOURNAL-STATUS-GAP-1); the status code alone is the signal a client must branch on, since a 200-only client would treat this as full success and silently miss the unexported documents. |
| 401 | Authentication required |
| 403 | Admin required (human session) |
{
"exported": [
{
"journalNo": "string",
"documentId": "string",
"direction": "inbound",
"type": "pain.001",
"sourceFormat": "string",
"bankKey": "string",
"createdAt": "string",
"wasAlreadyCollected": false,
"transactionCount": 0
}
],
"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 uncollectedOnly=true to fetch only documents you have not collected yet, then mark them collected via POST /journal/export. total counts all documents; uncollected counts how many are still uncollected. Each item carries collected (boolean) and collectedAt so a poller can track exactly what it has pulled. One vocabulary throughout: collected / uncollected / uncollectedOnly all name the same thing. 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 | |
uncollectedOnly 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,
"uncollected": 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
}
]
}// The ERP pull loop: walk every uncollected journal document, newest pages first fetched for you —
// the async iterator follows `nextCursor` until the server says there is no more.
import { BankConnector } from "@bankconnector/sdk";
export async function pollStatements(client: BankConnector) {
for await (const doc of client.journalListAll({ direction: "inbound", uncollectedOnly: true })) {
console.log(`${doc.journalNo} ${doc.type} ${doc.summary}`);
// Fetch + import the full document into your ERP here, then it is marked collected by the export call.
}
}/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": "string",
"platformId": "string",
"companyId": "string",
"bankKey": "string",
"environment": "test",
"direction": "inbound",
"type": "pain.001",
"sourceFormat": "string",
"summary": "string",
"status": "string",
"messageId": "string",
"importSource": "manual",
"transactionCount": 0,
"totals": [
{
"currency": "string",
"amount": "string"
}
],
"paymentType": "string",
"idempotencyKey": "string",
"collected": false,
"collectedAt": "string",
"createdAt": "string",
"expiresAt": "string"
}/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 |
{
"documentId": "string",
"journalNo": "string",
"messageId": "string",
"status": "string",
"deliveryMode": "converted-only",
"bankKey": "string",
"bankName": "string",
"createdAt": "string",
"payments": [
{
"index": 0,
"payee": "string",
"amount": "string",
"currency": "string",
"endToEndId": "string",
"recon": {
"state": "cleared",
"evidence": {
"source": "pain.002",
"summary": "string",
"date": "string",
"amount": "string",
"currency": "string",
"reason": "string",
"addtlInf": "string",
"documentId": "string"
}
}
}
],
"approval": {
"id": "string",
"status": "pending",
"paymentRef": "string",
"payeeName": "string",
"currency": "string",
"maxTransaction": "string",
"journalTotal": "string",
"requiredApprovals": 0,
"requireOneFromEachGroup": false,
"orderedApproval": false,
"groupCount": 0,
"approvals": [
{
"userName": "string",
"at": "string"
}
],
"rejections": [
{
"userName": "string",
"at": "string",
"reason": "string"
}
],
"canApprove": false,
"blockReason": "string",
"twoFactorRequired": false
},
"recon": {
"state": "cleared",
"evidence": {
"source": "pain.002",
"summary": "string",
"date": "string",
"amount": "string",
"currency": "string",
"reason": "string",
"addtlInf": "string",
"documentId": "string"
}
}
}/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 collected.
| 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. Keyset-paginated over the outbound payments (default 500, max 1000): a non-null nextCursor means MORE payments exist — pass it back as ?cursor= to walk the rest.
| Name | In | Required | Description |
|---|---|---|---|
companyId string | query | yes | |
limit integer | query | no | Page size over the outbound payments. |
cursor string | query | no | Opaque keyset cursor from a previous response's `nextCursor`. |
| Status | Description |
|---|---|
| 200 | Per-payment reconciliation + evidence |
| 401 | Authentication required |
{
"items": [
{
"documentId": "string",
"journalNo": "string",
"messageId": "string",
"bankKey": "string",
"summary": "string",
"totals": [
{
"currency": "string",
"amount": "string"
}
],
"status": "string",
"createdAt": "2026-01-01T00: 00: 00Z",
"state": "cleared",
"evidence": {
"source": "pain.002",
"summary": "string",
"date": "string",
"amount": "string",
"currency": "string",
"reason": "string",
"documentId": "string"
}
}
],
"nextCursor": "string"
}/journal/eventsKeyset-paginated newest-first (default page 5000 — the historical scan bound — max 5000): a non-null nextCursor means the result was TRUNCATED and more events exist — pass it back as ?cursor= to walk the rest.
?since=<seq> is a second, independent paging mode for a poller that has no public endpoint of its own (webhooks unreachable): it walks the same rows OLDEST-FIRST by the seq every item already carries, so since=<the last seq you saved> resumes exactly where you left off — across a crash, a restart, or a client that was offline for a week. ?since= and ?cursor= are mutually exclusive (400 if both are given).
?connectionId=<id> narrows the feed to ONE bank channel: that connection's own lifecycle events plus every event recorded against a bank the connection serves (a shared connection covers a whole bank group, and those countries' events belong to it) in this connection's environment — one company legitimately holds a test and a production connection for the same bank, so an event explicitly stamped with the OTHER environment belongs to that other channel and is excluded here. Events carrying no environment at all appear on both. It composes with either paging mode and changes neither.
| Name | In | Required | Description |
|---|---|---|---|
companyId string | query | yes | |
limit integer | query | no | |
cursor string | query | no | Opaque keyset cursor from a previous response's `nextCursor`. Mutually exclusive with `since`. |
connectionId string | query | no | Return only this bank channel's events — this connection's own events plus its banks' events in THIS connection's environment (see the endpoint description). 404 `connection_not_found` if the id is not one of your connections — never a silently empty page. Combines with `limit`, `cursor` and `since`. |
since integer | query | no | Resume the event feed oldest-first after this `seq` (0 to start from the beginning). Pass the highest `seq` you have processed so far; the response's `nextCursor`, when non-null, is the next `since` value to poll immediately (more events are already waiting) — a null `nextCursor` means you are caught up. Mutually exclusive with `cursor`. |
| Status | Description |
|---|---|
| 200 | Events newest-first |
| 401 | Authentication required |
{
"items": [
{
"id": "string",
"platformId": "string",
"companyId": "string",
"type": "string",
"message": "string",
"actorUserId": "string",
"metadata": {},
"createdAt": "string",
"expiresAt": "string",
"prevHash": "string",
"hash": "string",
"seq": 0
}
],
"nextCursor": "string"
}/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 |
{
"messageId": "string",
"createdAt": "string",
"originalMessageId": "string",
"sourceFormat": "pain.002.001.03",
"groupStatus": "accepted",
"groupStatusCode": "string",
"originalTxCount": 0,
"paymentGroups": [
{
"paymentInfoId": "string",
"status": "accepted",
"reasons": [
{
"code": "string",
"description": "string"
}
],
"transactions": [
{
"endToEndId": "string",
"instructionId": "string",
"originalAmount": "string",
"currency": "string",
"status": "accepted",
"statusCode": "string",
"reasons": [],
"bankRef": "string"
}
]
}
],
"rejectedTransactions": [
{
"endToEndId": "string",
"instructionId": "string",
"originalAmount": "string",
"currency": "string",
"status": "accepted",
"statusCode": "string",
"reasons": [
{
"code": "string",
"description": "string"
}
],
"bankRef": "string"
}
],
"acceptedTransactions": [
{
"endToEndId": "string",
"instructionId": "string",
"originalAmount": "string",
"currency": "string",
"status": "accepted",
"statusCode": "string",
"reasons": [
{
"code": "string",
"description": "string"
}
],
"bankRef": "string"
}
]
}/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": [
{
"messageId": "string",
"createdAt": "string",
"sequenceNumber": "string",
"sourceFormat": "string",
"accountIban": "string",
"accountOther": "string",
"accountCurrency": "string",
"accountOwner": "string",
"fromDate": "string",
"toDate": "string",
"entrySummary": {
"totalCount": 0,
"totalSum": "string",
"totalDirection": "credit",
"creditCount": 0,
"creditSum": "string",
"debitCount": 0,
"debitSum": "string"
},
"balances": [
{
"type": "string",
"amount": "string",
"currency": "string",
"direction": "credit",
"date": "string",
"creditLineIncluded": false,
"creditLineAmount": "string"
}
],
"closingBalance": {
"type": "string",
"amount": "string",
"currency": "string",
"direction": "credit",
"date": "string",
"creditLineIncluded": false,
"creditLineAmount": "string"
},
"openingBalance": {
"type": "string",
"amount": "string",
"currency": "string",
"direction": "credit",
"date": "string",
"creditLineIncluded": false,
"creditLineAmount": "string"
},
"transactions": [
{
"bankRef": "string",
"endToEndId": "string",
"instructionId": "string",
"amount": "string",
"currency": "string",
"direction": "credit",
"status": "booked",
"bookingDate": "string",
"valueDate": "string",
"counterpartyName": "string",
"counterpartyIban": "string",
"counterpartyAccount": "string",
"counterpartyBic": "string",
"counterpartyCountry": "string",
"counterpartyAddress": "string",
"remittance": "string",
"creditorReference": "string",
"creditorReferenceType": "string",
"referredDocuments": [],
"txCodeDomain": "string",
"txCodeFamily": "string",
"txCodeProprietary": "string",
"originalCurrency": "string",
"originalAmount": "string",
"chargeAmount": "string",
"chargeCurrency": "string"
}
],
"droppedCurrencies": [
"string"
]
}
]
}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": [
{
"id": "string",
"platformId": "string",
"companyId": "string",
"iban": "string",
"accountOther": "string",
"country": "string",
"currency": "string",
"bankKey": "string",
"bankName": "string",
"connected": false,
"lastReceivedData": "string",
"latestBalance": "string",
"latestBalanceCurrency": "string",
"latestBalanceDate": "string",
"createdAt": "string",
"updatedAt": "string"
}
],
"nextCursor": "string",
"total": 0
}/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 | yes |
| Status | Description |
|---|---|
| 200 | Statements + any detected sequence gaps |
| 401 | Sign in required |
{
"account": "string",
"statements": [
{
"documentId": "string",
"fromDate": "string",
"toDate": "string",
"sequenceNumber": "string",
"opening": {
"amount": "string",
"currency": "string",
"direction": "string",
"date": "string"
},
"closing": {
"amount": "string",
"currency": "string",
"direction": "string",
"date": "string"
},
"txCount": 0
}
],
"gaps": [
{
"kind": "balance",
"reason": "string",
"afterDocumentId": "string",
"beforeDocumentId": "string"
}
]
}Webhooks
Outbound event subscriptions (session, Admin)
/webhooks| Name | In | Required | Description |
|---|---|---|---|
companyId string | query | yes |
| Status | Description |
|---|---|
| 200 | Subscriptions |
| 401 | Sign in required |
{
"items": [
{
"id": "string",
"platformId": "string",
"companyId": "string",
"url": "string",
"eventTypes": [
"string"
],
"description": "string",
"enabled": false,
"createdAt": "string",
"secretHint": "string",
"lastStatus": "delivered",
"lastAttemptAt": "string",
"lastError": "string",
"consecutiveFailures": 0,
"disabledAt": "string",
"disabledReason": "string",
"pendingSecretExpiresAt": "string",
"transport": "https",
"emailTo": "string",
"filterPredicate": {
"bankKey": "string",
"currency": "string",
"minAmount": 0,
"maxAmount": 0
},
"deliverySequence": 0
}
]
}/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 | no | |
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. |
description | string | no | |
transport | enum | no | |
emailTo | string | no | |
filterPredicate | object | no |
{
"platformId": "string",
"companyId": "string",
"url": "string",
"eventTypes": [
"string"
],
"description": "string",
"transport": "https",
"emailTo": "string",
"filterPredicate": {
"bankKey": "string",
"currency": "string",
"minAmount": 0,
"maxAmount": 0
}
}| Status | Description |
|---|---|
| 201 | Subscription created (returns the signing secret once) |
| 401 | Sign in required |
| 403 | Admin required |
{
"endpoint": {
"id": "string",
"platformId": "string",
"companyId": "string",
"url": "string",
"eventTypes": [
"string"
],
"description": "string",
"enabled": false,
"createdAt": "string",
"secretHint": "string",
"lastStatus": "delivered",
"lastAttemptAt": "string",
"lastError": "string",
"consecutiveFailures": 0,
"disabledAt": "string",
"disabledReason": "string",
"pendingSecretExpiresAt": "string",
"transport": "https",
"emailTo": "string",
"filterPredicate": {
"bankKey": "string",
"currency": "string",
"minAmount": 0,
"maxAmount": 0
},
"deliverySequence": 0
},
"secret": "string"
}// Verify a webhook delivery REALLY came from BankConnector — the control that stops a forged
// "payment.sent" being accepted. Verify over the RAW body bytes, before any JSON parsing.
import { verifyWebhookSignature, SIGNATURE_HEADER } from "@bankconnector/sdk";
export function handleWebhook(rawBody: string, headers: Record<string, string | undefined>): { ok: boolean } {
const signature = headers[SIGNATURE_HEADER];
const secret = process.env.BANKCONNECTOR_WEBHOOK_SECRET; // the `secret` returned ONCE by POST /webhooks
if (!signature || !secret) return { ok: false };
if (!verifyWebhookSignature(secret, signature, rawBody, { replayProtection: true })) {
return { ok: false }; // discard silently — do not tell a forger why
}
const event = JSON.parse(rawBody) as { type: string; data: unknown };
console.log(`verified ${event.type}`);
return { ok: true };
}/webhooks/{id}Returns one webhook subscription's detail (includes deliverySequence). API-WEBHOOKS-BY-ID-UNDOCUMENTED-1: this route was registered and live before it had an operation here.
| Name | In | Required | Description |
|---|---|---|---|
id string | path | yes | |
companyId string | query | yes |
| Status | Description |
|---|---|
| 200 | The webhook subscription |
| 401 | Sign in required |
| 403 | Admin required |
| 404 | Not found |
{
"endpoint": {
"id": "string",
"platformId": "string",
"companyId": "string",
"url": "string",
"eventTypes": [
"string"
],
"description": "string",
"enabled": false,
"createdAt": "string",
"secretHint": "string",
"lastStatus": "delivered",
"lastAttemptAt": "string",
"lastError": "string",
"consecutiveFailures": 0,
"disabledAt": "string",
"disabledReason": "string",
"pendingSecretExpiresAt": "string",
"transport": "https",
"emailTo": "string",
"filterPredicate": {
"bankKey": "string",
"currency": "string",
"minAmount": 0,
"maxAmount": 0
},
"deliverySequence": 0
}
}/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 |
{
"removed": false
}/webhooks/{id}/enable| Name | In | Required | Description |
|---|---|---|---|
id string | path | yes |
| Property | Type | Required | Description |
|---|---|---|---|
platformId | string | no | |
companyId | string | yes |
{
"platformId": "string",
"companyId": "string"
}| Status | Description |
|---|---|
| 200 | Webhook enabled |
| 401 | Sign in required |
| 403 | Admin required |
| 404 | Not found |
{
"endpoint": {
"id": "string",
"platformId": "string",
"companyId": "string",
"url": "string",
"eventTypes": [
"string"
],
"description": "string",
"enabled": false,
"createdAt": "string",
"secretHint": "string",
"lastStatus": "delivered",
"lastAttemptAt": "string",
"lastError": "string",
"consecutiveFailures": 0,
"disabledAt": "string",
"disabledReason": "string",
"pendingSecretExpiresAt": "string",
"transport": "https",
"emailTo": "string",
"filterPredicate": {
"bankKey": "string",
"currency": "string",
"minAmount": 0,
"maxAmount": 0
},
"deliverySequence": 0
}
}/webhooks/{id}/disable| Name | In | Required | Description |
|---|---|---|---|
id string | path | yes |
| Property | Type | Required | Description |
|---|---|---|---|
platformId | string | no | |
companyId | string | yes |
{
"platformId": "string",
"companyId": "string"
}| Status | Description |
|---|---|
| 200 | Webhook disabled |
| 401 | Sign in required |
| 403 | Admin required |
| 404 | Not found |
{
"endpoint": {
"id": "string",
"platformId": "string",
"companyId": "string",
"url": "string",
"eventTypes": [
"string"
],
"description": "string",
"enabled": false,
"createdAt": "string",
"secretHint": "string",
"lastStatus": "delivered",
"lastAttemptAt": "string",
"lastError": "string",
"consecutiveFailures": 0,
"disabledAt": "string",
"disabledReason": "string",
"pendingSecretExpiresAt": "string",
"transport": "https",
"emailTo": "string",
"filterPredicate": {
"bankKey": "string",
"currency": "string",
"minAmount": 0,
"maxAmount": 0
},
"deliverySequence": 0
}
}/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 | no | |
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 |
{
"ok": false,
"queued": false
}/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`. |
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",
"sequence": 0,
"expiresAt": "2026-01-01T00: 00: 00Z",
"sentAt": "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": "string"
}/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 |
|---|---|---|---|
platformId | string | no | |
companyId | string | no | |
overlapHours | number | no | How long the old secret stays valid. |
{
"platformId": "string",
"companyId": "string",
"overlapHours": 0
}| 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": "string"
}/billing/webhooks/stripeVerifies the Stripe-Signature header over the raw request body and records the event idempotently. Not part of the integrator surface — configured once, in the Stripe Dashboard, against this platform's own webhook signing secret.
| Name | In | Required | Description |
|---|---|---|---|
Stripe-Signature string | header | yes | HMAC signature over the raw body, minted by Stripe. |
| Status | Description |
|---|---|
| 200 | Accepted (fresh event, redelivery, or a recognised-but-unhandled event type) |
| 401 | Missing or invalid Stripe-Signature |
| 403 | Caller IP is not on the published Stripe webhook allowlist |
| 404 | Billing not configured on this deployment |
{
"received": false
}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": [
{
"id": "domestic",
"label": "string",
"description": "string",
"amount": "string",
"currency": "string",
"creditorName": "string",
"paymentType": "string"
}
]
}/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 |
{
"id": "string",
"journalNo": "string",
"platformId": "string",
"companyId": "string",
"bankKey": "string",
"environment": "test",
"direction": "inbound",
"type": "pain.001",
"sourceFormat": "string",
"summary": "string",
"status": "string",
"messageId": "string",
"transactionCount": 0,
"totals": [
{
"currency": "string",
"amount": "string"
}
],
"paymentType": "string",
"collected": false,
"collectedAt": "string",
"idempotencyKey": "string",
"createdAt": "string",
"expiresAt": "string",
"deduped": false,
"xml": "string",
"deliveryMode": "string",
"autoSelected": [
{
"paymentId": "string",
"selected": "string",
"confidence": "string",
"reason": "string"
}
],
"autoFilled": [],
"unknownFieldsDropped": [
"string"
],
"warnings": [
{
"code": "string",
"level": "generic",
"severity": "error",
"message": "string",
"path": "string",
"bank": "string",
"paymentType": "string"
}
]
}System
Health and version
/healthAlways 200 while the process is up. status is strictly serviceability (QA-11) — a serving box says "ok" even when non-fatal startup config warnings exist; those ride in the advisory warnings field as stable machine codes. Use /ready for the orchestrator serviceability probe.
| Status | Description |
|---|---|
| 200 | Server is running |
{
"status": "string",
"warnings": [
"string"
]
}/version| Status | Description |
|---|---|
| 200 | Current version |
{
"version": "string"
}/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 — frontend bundle missing, schema not migrated, or database unreachable. `error.details.reason` is one of `frontend_bundle_missing`, `schema_not_migrated`, `database_unreachable` so a caller can branch on which; `error.details.ready` is always `false`. |
{
"ready": false
}/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": "string",
"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. |
{
"message": "string",
"apiKey": "string",
"baseUrl": "string",
"platformId": "string",
"companyId": "string",
"subdomain": "string",
"bank": {
"key": "string",
"name": "string",
"country": "string"
},
"dashboardLogin": {
"url": "string",
"subdomain": "string",
"email": "string",
"password": "string"
},
"firstPayment": {
"description": "string",
"method": "string",
"url": "string",
"headers": {
"X-API-Key": "string",
"Content-Type": "string"
},
"body": {
"companyId": "string",
"bankKey": "string",
"environment": "string",
"payment": {
"schemaVersion": "1.0",
"messageId": "MSG-20260601-0001",
"creationDateTime": "2026-06-01T10: 30: 00Z",
"initiatingParty": {
"name": "Acme Corp ApS",
"organisationId": {}
},
"payments": [
{}
]
}
}
},
"then": {
"description": "string",
"reconciliation": "string",
"poll": "string"
}
}/signupSelf-serve production onboarding — production only. Creates your organisation and invites every administrator you list, then emails each of them a verification link. Nothing usable (no password, no API key) exists until an administrator completes POST /signup/verify with the token from their email. administrators[0] becomes the account that verification mints an API key for. The organisation always starts on the platform's default approval policy (2 distinct approvers, no self-approval) — configure anything else afterwards from Approval policies. Rate-limited per IP.
| Property | Type | Required | Description |
|---|---|---|---|
administrators | object[] | yes | One or more administrators to invite. `administrators[0]` is the signup owner — the row `POST /signup/verify` recognises via its Terms-of-Service acceptance and mints an API key for. Every entry is created with `["admin","approver"]` roles. Since the organisation's default approval policy needs 2 distinct approvers, listing only one administrator here is legal but leaves the organisation unable to release a payment until a second one is invited (from Settings → Team, after verifying). |
companyName | string | yes | The new organisation's name. |
termsAccepted | boolean | yes | Must be `true` — the Terms of Service acceptance this call records. |
programToken | string | no | Optional. The token from a partner's registration link (`signup/p/<token>`, resolvable via `GET /signup/program/{token}`). It attributes the new organisation to that partner's program and applies the program's capability settings; it grants the partner no access to the organisation, and the organisation is created exactly where a direct signup's would be. A token that names no program is rejected with 422 rather than ignored — omit the field entirely for a direct signup. |
{
"administrators": [
{
"email": "owner@example.com",
"name": "Jane Owner"
},
{
"email": "cofounder@example.com",
"name": "Alex Cofounder"
}
],
"companyName": "Acme ApS",
"termsAccepted": false,
"programToken": "string"
}| Status | Description |
|---|---|
| 202 | Signup accepted — check email to verify |
| 404 | Not a production deployment (this endpoint does not exist elsewhere). |
| 422 | Missing/invalid field, terms not accepted, no administrators, more than 10 administrators, a duplicate administrator email, or a `programToken` that names no program. |
| 429 | Rate limited — too many signups from this IP recently. |
| 503 | Signup is unavailable — no email transport is configured on this instance. |
{
"message": "string",
"email": "string",
"administratorsInvited": 0
}/signup/program/{token}A partner hands out a registration link (signup/p/<token>). This resolves that token to the branding the signup page renders — production only, and unauthenticated, because it is called before anyone has an account. It returns branding and nothing else: the program's capability settings are what the new organisation gets, not something the page shows. Pass the same token back to POST /signup as programToken to have the organisation attributed to that program. Rate-limited per IP, on its own budget rather than the signup one.
| Name | In | Required | Description |
|---|---|---|---|
token string | path | yes | The opaque token from the partner's registration link. |
| Status | Description |
|---|---|
| 200 | The program this link names |
| 404 | No program holds this token (the same answer for a link that never existed and one that has been rotated away), or this is not a production deployment. |
| 429 | Rate limited — too many lookups from this IP recently. |
{
"programId": "string",
"displayName": "string",
"logoUrl": "string",
"primaryColor": "string",
"supportEmail": "string",
"termsUrl": "string"
}/signup/verifyRedeems the token emailed by POST /signup, sets your password, and — the first time this succeeds for a signup's owner — mints the organisation's platform API key. Returns 200 with no credential for any other invite token (this route is not a substitute for POST /auth/set-password).
| Property | Type | Required | Description |
|---|---|---|---|
token | string | yes | The token from the verification email link. |
password | string | yes | The password to set for this account. |
{
"token": "string",
"password": "string"
}| Status | Description |
|---|---|
| 200 | Password set (not a signup-owner token, or key generation could not complete). |
| 201 | Signup verified — organisation activated |
| 400 | Missing token/password, or the token is invalid/expired/already used. |
| 404 | Not a production deployment (this endpoint does not exist elsewhere). |
| 429 | Rate limited — too many attempts from this IP recently. |