sandbox.bankconnector.com API Reference

Receiving Results — Webhooks & Polling

Audience: 🔌 Integration Client

Because delivery is asynchronous, you find out what happened to a payment (and learn about incoming statements) in one of two ways. Most robust integrations use both: webhooks for latency, polling as a safety net.


Part A — Webhooks

BankConnector POSTs a signed JSON event to your HTTPS endpoint whenever something happens.

Registering an endpoint

POST /webhooks
{
  "companyId":  "<companyId>",          // platform is inferred from the API key
  "url":        "https://your-app.example/hooks/bankconnector",
  "eventTypes": ["payment.sent", "payment.delivery-failed", "approval.completed"],
  "transport":  "https",
  "filterPredicate": { "bankKey": "abn-amro-nl", "minAmount": "0.00" }   // optional
}
→ 201 { "endpoint": {...}, "secret": "whsec_..." }   // secret shown ONCE
  • Omit eventTypes to receive all events (defaults to ["*"]). An explicit empty array is rejected.
  • Unknown body keys are rejected (strictObject) — a typo like events instead of eventTypes fails loudly rather than silently subscribing you to everything.
  • The signing secret (whsec_…) is returned once. Store it securely; you need it to verify signatures. You can rotate it later.
  • The apikey principal _can_ manage webhooks (unlike connections/approvals), so your integration can register its own endpoints.

The event envelope

{
  "version": "1.0",
  "id": "<eventId>",
  "type": "payment.sent",
  "createdAt": "2026-07-02T10: 15: 30.000Z",
  "message": "Payment sent to bank.",
  "data": { "documentId": "...", "journalNo": "...", "bankKey": "...", "...": "..." },
  "platformId": "<platformId>",
  "companyId": "<companyId>"
}

The id is stable across every retry and redelivery of the same event, and it equals the X-BankConnector-Delivery header. Use it as your deduplication key — at-least-once delivery means you can receive the same event more than once.

Verifying the signature (do this — always)

Each delivery carries:

X-BankConnector-Signature: t=<ISO timestamp>,v1=<hex HMAC>
X-BankConnector-Event:     payment.sent
X-BankConnector-Delivery:  <eventId>        # dedup key
X-Delivery-Sequence:       <n>              # gap detection

The signature is HMAC-SHA256(secret, "<timestamp>.<rawBody>"), hex-encoded. Verify over the raw request body before parsing it, using a constant-time compare:

import crypto from "node:crypto";

function verify(secret: string, header: string, rawBody: string, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const t = parts["t"];
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");

  // header may carry multiple v1= values during secret rotation — accept any match
  const provided = header
    .split(",")
    .filter((s) => s.startsWith("v1="))
    .map((s) => s.slice(3));
  const ok = provided.some(
    (sig) => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
  );
  if (!ok) return false;

  // Optional replay window (recommended to enable on your side):
  const age = Date.now() - Date.parse(t);
  if (age > toleranceSec * 1000) return false;

  return true;
}

Two things to know:

  • Replay-window checking is opt-in and OFF by default. The default verify accepts any timestamp. Enforce the 300 s window yourself (as above) for defence-in-depth. Retries are re-signed with a fresh timestamp, so a modest window is safe.
  • During secret rotation the signature header carries multiple v1= values (old + new). Accept a match against _any_ of them. Rotate via POST /webhooks/<id>/rotate-secret { "overlapHours": 24 }.

Delivery guarantees & retries

  • At-least-once. Success is any HTTP 2xx from your endpoint. Deduplicate on the event id.
  • On non-2xx / timeout, BankConnector retries: 5 attempts, exponential backoff ≈ 30 s, 60 s, 120 s, 240 s (capped at 1 h). Each attempt has a 10 s timeout.
  • Respond fast (ack in well under 10 s). Do heavy work _after_ you've returned 2xx, keyed on the event id.

The failure mode to monitor

After 5 consecutive failures, an endpoint is auto-disabled and silently stops receiving events until you call POST /webhooks/<id>/re-enable. Guard against a silent outage:

  • Watch GET /webhooks/<id>/deliveries for failures.
  • Track the X-Delivery-Sequence header — a gap means you missed events.
  • Back everything with a periodic poll (Part B) so a disabled webhook can't cause you to lose data.

Event catalog

The ones you'll use most:

EventFires when
payment.convertedpayment validated & converted
payment.queuedqueued for delivery
payment.senthanded to the bank
payment.delivery-faileda delivery attempt failed (retryable? in data)
payment.delivery-abandoneddelivery gave up after retries
payment.validatedthe bank accepted the payment (pain.002 ACCP/ACTC) — not yet settled
payment.executedthe bank settled the payment (pain.002 ACSC/ACCC)
payment.rejectedthe bank rejected the payment (reasonCodes in data)
payment.partially-acceptedsome lines accepted, some rejected (reasonCodes in data)
payment.cancelledthe bank cancelled the payment
approval.neededa payment is waiting for approval
approval.completed / approval.givenfully approved
approval.rejectedan approver rejected it
file.receivedan inbound file arrived
statement.importeda statement was parsed (includes transactions)
balance.updatedan account balance changed
account.statement-gapa gap detected in statement continuity
connection.poll-failinga connection's polling is failing
connection.cert-expiringa bank cert nears expiry (30/14/7 days)
webhook.testyou called the test endpoint

The full catalog also includes some less-common but still integration-relevant events, worth subscribing to if they matter to your flow:

EventFires when
payment.duplicate-contenta resubmit matched an existing payment's content fingerprint (not the idempotency key path)
pain002.unmatched-paymenta bank status report arrived that matched no outbound payment (originalMessageId in data)
pain002.unmatched-orig-msg-ida bank status report arrived with no / unusable original-message id — cannot be matched
payment.strandeda queued payment couldn't be picked up for delivery
approval.externalan approval decision was recorded from outside the normal approve/reject action
approval.quorum-unreachablethe approval policy can never be satisfied with the current approver set
security.cross-company-approveran approver attempted to act across a company boundary (a security event, not a normal flow)
connection.bank-key-unresolveda connection references a bank key that no longer resolves

Some internal transitions (e.g. payment.sanctions-hold, webhook.auto-disabled) are audit-log only and are _not_ fanned out as webhooks — don't wait on them. Poll GET /journal/list or GET /journal/events if you need to observe those.

Testing

POST /webhooks/<id>/test sends a webhook.test event through the real signing and delivery path — use it to validate your receiver end to end.


Part B — Polling

The designated integration poll is:

GET /journal/list?direction=outbound&unretrievedOnly=true&limit=500&cursor=<...>

Returns:

{
  "total": 1234,
  "unretrieved": 12,
  "nextCursor": "<opaque>",       // null when you've reached the end
  "items": [
    {
      "journalNo": "...", "direction": "outbound", "type": "pain.001",
      "bankKey": "...", "summary": {...}, "status": "executed",
      "collected": false, "collectedAt": null,
      "createdAt": "...", "expiresAt": "..."
    }
  ]
}
  • unretrievedOnly=true returns only what you haven't collected yet — the clean way to drain new results without reprocessing.
  • Page with cursor until nextCursor is null. /journal/list only accepts cursor — it doesn't have before/after aliases (that's a different route, GET /journal/documents, which accepts cursor or before).
  • If you omit limit, /journal/list defaults to 500 (capped at 1000) — it does not return every matching document unbounded. Set limit explicitly if you want a specific page size.

For a single payment's current state, poll GET /journal/documents/<id>/payments (status, delivery mode, approval, recon). For reconciliation across pain.002 + camt.053, use GET /journal/reconciliation. For bank statements and detected gaps, use GET /accounts/statements.

Choosing (or combining)

WebhooksPolling
Latencysecondsyour interval
Complexitysignature verify + dedup + monitoringa loop + a cursor
Failure modesilent auto-disablenone (you drive it)

Recommended: webhooks for real-time reaction, plus a poll every few minutes using unretrievedOnly=true as a backstop. GETs aren't rate-limited, so a periodic poll is cheap.