sandbox.bankconnector.com API Reference

Submitting Payments

Audience: 🔌 Integration Client

This is the core of the integration. One endpoint does the work:

POST /journal/payments

Request

{
  "companyId":  "<companyId>",    // scope (required): platform is inferred from the API key
  "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"
  "idempotencyKey": "..."         // alternative to the Idempotency-Key header
}

Send the idempotency value as the Idempotency-Key header (preferred) or the body field. The header wins if both are present. See Idempotency; for host-to-host banks it is mandatory.

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 just picks which of your connections for this bank to send over (default production). It never changes which BankConnector server you're talking to.

Testing without a real bank? You don't need this field. Use the sandbox (https://sandbox.bankconnector.com): every payment there flows against a simulated demo bank and can never move real money. environment: "test" is only for the narrower case where you've deliberately set up a connection to a bank's own UAT environment and want to exercise it from your live integration.

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 if an approval policy applies
  "delivery":     { ... }    // delivery planning info
}

There is no Location header and no "document" wrapper. The created resource's own fields are spread directly into the response body.

Remember: 201 = accepted + converted (not yet at the bank). status tells you where it went next: queued-for-delivery when it was released straight to delivery (no approval policy, or a platform that auto-releases), or pending-approval when a policy holds it for a User approver.

The two branches after submission

A. No approval policy

The payment is queued for delivery immediately. Watch for payment.queuedpayment.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 /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 approvals and forbids self-approval.
  3. On the final approval, delivery is queued and you get approval.completed followed by the normal delivery events.
  4. A rejection (POST /approvals/<id>/reject) stops it; you get approval.rejected.

If your company uses maker-checker, design your system to submit and then wait: don't expect a submitted payment to send on its own.

Preview before you submit

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

Batching

One instruction can carry up to 1000 payments, each with up to 10000 transactions. But the HTTP body is capped at 1 MB, so in practice large batches hit the size cap first. If you're sending high volumes, prefer more requests with smaller batches over a single giant one, and remember the rate-limit note below.

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}/journal/payments`, {
    method: "POST",
    headers: {
      "X-API-Key": KEY,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify({
      companyId: process.env.BC_COMPANY,   // no platformId: the key implies your platform
      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 requires an active connection to the bank to exist first. BankConnector always delivers the payment for you over that connection. It never hands you a file to upload yourself, so if there's no connection, submission fails 422 (rather than falling back to a download). Setting up that connection is a one-time User/UI step (your API key can't do it). See Going Live.