sandbox.bankconnector.com API Reference

Submitting payments

Audience: 🔌 Integration Client

One endpoint submits a payment:

POST /api/journal/payments

Request

{
  "companyId":  "<companyId>",    // scope (required): the API key's own company
  "bankKey":    "abn-amro-nl",    // which bank to target (required)
  "payment":    { /* canonical payment instruction: see the Payment field reference */ },

  // optional:
  "environment": "test",          // which of THIS bank's connections to use, see below (default: "production")
  "validate":    true,            // also XSD-validate the generated XML
  "maker":       { "reference": "erp-service" }, // maker identity (object), for maker-checker approval flows; also accepts "name"
  "source":      { "reference": "PAY-0031", "name": "Payments 2026-09-28" }, // the ERP journal this came from (label only)
  "erpNotes":    [ /* facts your ERP records for an auditor, see below */ ],
  "idempotencyKey": "..."         // alternative to the Idempotency-Key header
}

source names the journal in your ERP that the payment came from (its reference and its name, each optional and at most 140 characters). The Payments list and the approval screen show it next to the payment's id, so an approver can tell which journal they are approving. It is a label only: it is not part of the payment and not part of the idempotency check, so re-sending the same payment after the journal was renamed replays the original answer rather than answering 409.

erpNotes records what your ERP showed the maker about the batch. The one kind today is a payee whose bank account changed since their last payment:

"erpNotes": [{
  "kind": "payee-account-changed",
  "paymentRef": "PAY-0031-3",            // which payment in the batch
  "from": "DK5000400440116243",          // the account their last payment went to
  "to": "DK5000400440119999",            // the account this batch pays
  "shownTo": "Frederik Larsen",          // who your ERP showed the warning to
  "shownAt": "2026-10-01T09: 12: 00+02: 00" // when, with Z or an offset
}]

Each note goes into the payment's tamper-evident audit trail, without the accounts, and onto its approval request, and the approval screen shows it above Approve. The approval request keeps the accounts masked to their first two and last four characters. At most 500 notes; each text field 1–140 characters with no line breaks or other control characters. Notes are not part of the payment: they never reach the bank file, and a retry with different notes replays the original answer.

Send the idempotency value as the Idempotency-Key header (preferred) or the body field. The header wins if both are present. See Idempotency: on host-to-host the payment's messageId is its identity whether or not you send a key, and a submission with neither is refused with 400 idempotency_key_required.

What environment does

A bank connection is set up as either a test or a production connection. A test connection points at the bank's own test/UAT endpoint, a production one at the live endpoint. environment on the payment picks which of your connections for this bank to send over (default production). It does not change which BankConnector server you're talking to.

To test without a real bank, use the sandbox (https://sandbox.bankconnector.com): every payment there goes to a simulated demo bank and can never move a real payment. Use environment: "test" only when you have set up a connection to a bank's own UAT environment.

Response: 201 Created

The journal document's own fields (id, journalNo, messageId, status, …) come back at the top level, alongside the conversion output:

{
  "id":           "...",
  "journalNo":    "OUT-000123",
  "messageId":    "ORDER-2026-10432",
  "status":       "queued-for-delivery", // released to delivery; "pending-approval" if a policy holds it
  "xml":          "<Document>...</Document>",   // the generated bank file, for your records
  "deliveryMode": "host-to-host",               // always host-to-host: BankConnector delivers it for you
  "autoSelected": { ... },   // present if the payment type was inferred
  "autoFilled":   { ... },   // present if bank settings filled missing fields
  "approval":     { ... },   // present when the payment is held for approval
  "delivery":     { ... }    // delivery planning info
}

There is no Location header and no "document" wrapper. A 201 means accepted and converted, not yet at the bank. status tells you where it went next: pending-approval when its approval policy holds it for a User approver, or queued-for-delivery when it was released straight to delivery because no approval was needed.

The two branches after submission

Every payment is governed by an approval policy: the one your company has set for that bank, or its company default. Until your company locks a policy of its own, the built-in default applies, which requires two distinct approvers on every payment. A policy sets two separate controls:

  • Approval required (four eyes): whether a payment needs approving before it goes to the bank, and by how many distinct approvers. A policy can require approval on every payment, or only above an amount threshold. The maker may be one of the approvers. A payment takes exactly the number of approvers its policy requires: the default policy requires two, and a policy that requires one releases a payment on that one signature, the maker's own included unless maker-checker is on.
  • Maker-checker: whether the user who created the payment (the maker) is excluded from approving it. It is off in the default policy; turn it on per policy, or for every policy at once in your company's settings.

The first control decides which branch below a payment takes. On the sandbox approvals are skipped, so every payment there takes branch A.

A. No approval needed

The policy that governs the payment does not require approval for it (for example, the amount is within the policy's threshold), or you are on the sandbox. The payment is queued for delivery immediately. Watch for payment.queued → payment.sent, then bank acknowledgement moves it to validated / executed (or rejected).

B. An approval policy applies

The payment lands in pending-approval and will not go to the bank until it's fully approved. Approval is a user action with 2FA step-up; your API key cannot approve. The flow:

  1. You submit → 201, status pending-approval, and an approval.needed event fires.
  2. Approvers (Users) approve in the UI, or programmatically via POST /api/approvals/<id>/approve. This call authenticates with the approver's session token (X-Session-Token), _not_ an API key, and a 2FA-enrolled approver passes their current authenticator code as { "code": "123456" } in the body (or { "backupCode": "…" }). The default policy requires two distinct approvers, and the maker may be one of them. Where the organisation has switched it on, an approver can also decide inside an ERP add-on on their own sign-in, never the add-on's install credential; see Authentication.
  3. On the final approval, delivery is queued and you get approval.completed followed by the normal delivery events.
  4. A rejection (POST /api/approvals/<id>/reject) stops it for good: you get approval.rejected, and the payment's status becomes rejected-by-approver, a final status. It is never sent and cannot be approved later. To pay it anyway, submit a new payment with a new messageId; re-sending the vetoed messageId is refused with 409 message_id_already_used.

Under the default policy every payment lands in pending-approval. Handle both statuses, because a policy with an amount threshold releases payments within it straight to delivery.

Preview before you submit

POST /api/journal/payments/preview runs the same validation and conversion with no journal write and no delivery. Use it to validate user input or as a dry run. Same request shape (minus idempotency); returns 200 with the xml, or 422 with issues.

The 200 also says whether submitting would wait for approval: approval.required is true when the payment would land in pending-approval, false when it would go straight to the bank. It is the same decision the submit makes, so you can skip asking your user "send this?" when approval is the gate anyway.

Batching

One instruction can carry up to 1000 payments, each with up to 10000 transactions. The HTTP body is capped at 1 MB, so large batches hit the size cap first. For high volumes, send more requests with smaller batches, within the rate limits.

Worked example (TypeScript)

const BASE = "https://sandbox.bankconnector.com";
const KEY  = process.env.BC_KEY!;              // key_...

async function submitPayment(idempotencyKey: string, payment: unknown) {
  const res = await fetch(`${BASE}/api/journal/payments`, {
    method: "POST",
    headers: {
      "X-API-Key": KEY,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify({
      companyId: process.env.BC_COMPANY,   // the key's own company
      bankKey:   "abn-amro-nl",
      payment,
    }),
  });

  const requestId = res.headers.get("x-request-id");   // log this always

  if (res.status === 201) {
    const replayed = res.headers.get("idempotency-replayed") === "true";
    const doc = await res.json();       // { id, journalNo, status, xml, deliveryMode, ... } flat, no wrapper
    return { ok: true, replayed, doc, requestId };
  }

  if (res.status === 409) {
    // Two very different meanings: see Idempotency + Errors chapters.
    const body = await res.json();
    return { ok: false, conflict: body.error, requestId };
  }

  if (res.status === 422) {
    const body = await res.json();          // { error: { code, message, details: { issues } } }
    return { ok: false, issues: body.error.details?.issues, requestId };
  }

  throw new Error(`Unexpected ${res.status} (request ${requestId})`);
}

Prerequisites for real delivery

A payment needs an active connection to the bank. BankConnector delivers the payment over that connection and never hands you a file to upload, so without a connection, submission fails 422. A user sets up the connection once in the UI (your API key can't). See Going live.