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 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. This part covers the receiver side (envelope, verification, retries, the event catalog); to create and manage endpoints (the full CRUD API, transports, filtering, and secret rotation), see the dedicated Webhooks guide.

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). Two-sided, like
  // the server: reject both stale AND future-dated timestamps.
  const age = Math.abs(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: 8 attempts (env WEBHOOK_MAX_ATTEMPTS), exponential backoff 30 s → 1 m → 2 m → 4 m → 8 m → 16 m → 32 m → 1 h (±20% jitter). 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 up to the first 100 transactions — see the note below)
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

statement.imported embeds at most the first 100 transactions (PERF-M3). transactionCount is always the FULL count, and when the statement has more than 100 the payload also carries `transactionsTruncated:

true` — so a truncated delivery is self-describing and you never have to infer it by comparing lengths. A

statement with 100 or fewer is byte-identical to before this cap existed (no flag, whole array).

Why: the payload is persisted into the delivery table and re-serialised on every retry, so it is a storage-and-repetition cost, not just a wire cost. An 11k-entry statement — well inside the default 8 MB tier — made a ~4 MB body; ~30 MB at the Enterprise tier.

To get every transaction, fetch the document: GET /journal/documents/{documentId} (the documentId is in the payload). The stored document is never truncated — only the webhook body is.

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, so it cannot be matched
statement.unmatched-our-referencea statement debit carries one of YOUR payment references but matches no payment on that connection (possible duplication/data loss; ordinary fees/salary/third-party debits never fire this)
payment.double-debita second statement debit settled an already-settled payment (possible double execution; the payment's status is not changed)
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
payment.veu-cosignedan EBICS distributed-signature (VEU) co-signature was added to a payment
payment.veu-cosign-incompletean EBICS VEU payment still needs further co-signatures before it can be sent

Some internal transitions (e.g. 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. (BankConnector performs no sanctions screening, so there is no sanctions event to wait on.)

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.

A poll every few minutes is cheap, but it is not free: GETs don't count against the 300/60 s per-caller POST bucket, yet every request (GETs included) counts against the pre-auth per-IP cap of 600/60 s. Behind a NAT'd or shared egress IP a tight poll loop can 429. Poll on an interval of seconds, not milliseconds, and honour Retry-After on reads too. → Rate limiting