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 /api/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 /api/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 /api/webhooks/<id>/re-enable. Guard against a silent outage:

  • Watch GET /api/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 sequence gap detected in statement continuity (missing page between two received statements). camt.053 / MT940 only — see the note below
account.statement-integrity-gapa statement's own arithmetic doesn't balance (opening ± booked entries ≠ closing) — split from account.statement-gap so the two can be alerted on separately (ALERT-STATEMENT-GAP-NO-OPERATOR-1)
statement.truncatedan inbound camt file was cut short — entries after the cut, or a whole account's statement, are missing
connection.poll-failinga connection's polling is failing
connection.cert-expiringa bank cert nears expiry. Two sources: our daily scan at 30/14/7 days, and — on Danske — the bank's own status check reporting a certificate as expiring soon, which has no threshold and can arrive at any time
grant.expiringa connected application's authorisation nears its absolute expiry (90/30/7/1 days). Re-authorise the app before then — on expiry its requests stop authenticating and its payments are refused
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 /api/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)
payment.duplicate-message-ida payment reused a MsgId already used on a prior submission (same bank+environment) — the bank will most likely reject it as "not unique"
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). camt.053 / MT940 only — see the note below
payment.strandeda queued payment couldn't be picked up for delivery
payment.delivery-blockeda fully approved payment is HELD, not sent — the file the bank must receive cannot be rebuilt with its approvers' Norwegian AML codes (reason in data). Fix the reason and it goes
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
approval.pending-overduea payment has been waiting for a 4-eyes approval signature longer than the alert window (default 2 days) — nobody has signed it, so nothing has been sent to the bank
security.cross-company-approveran approver attempted to act across a company boundary (a security event, not a normal flow)
security.oauth-refresh-reusea consumed OAuth refresh token was replayed outside the crash-recovery grace window — the token family and its grant are revoked, and the add-on must be re-paired (authorized again)
connection.bank-key-unresolveda connection references a bank key that no longer resolves
connection.no-payment-railsthe bank this connection was set up to offers no payment rails, so any payment to it is refused at conversion. Advisory, once at setup — an inbound-only connection is unaffected
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 /api/journal/list or GET /api/journal/events if you need to observe those. (BankConnector performs no sanctions screening, so there is no sanctions event to wait on.)

Which statement alerts fire for camt.052 and camt.054. Most statement-level alerts run for every statement-shaped document we ingest — camt.052 (intraday report), camt.053 (statement), camt.054 (debit/credit notification) and MT940. Two are deliberately narrower, and both exceptions are about avoiding false alarms rather than about effort:

  • account.statement-gap is camt.053/MT940 only. A camt.052 is an _interim_ report numbered in its own intraday series, so its sequence numbers legitimately repeat and skip relative to the daily statement series. Comparing the two would report a missing page on almost every account.
  • payment.double-debit is camt.053/MT940 only. A camt.054 usually _notifies_ the very debit the day's camt.053 also books — one movement reported twice, by design. Counting both would report a double execution for entirely routine bank traffic. The companion alert statement.unmatched-our-reference has no such coupling and does fire for camt.052 and camt.054.

account.statement-integrity-gap runs for all four, but it can only reach a verdict when the document carries both a booked opening and a booked closing balance. A camt.054 carries none, and a camt.052 typically states an _interim_ balance (ITBD) rather than a closing one — so on those it stays silent rather than guessing.

Testing

POST /api/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 /api/journal/list?direction=outbound&uncollectedOnly=true&limit=500&cursor=<...>

Returns:

{
  "total": 1234,
  "uncollected": 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": "..."
    }
  ]
}
  • uncollectedOnly=true returns only what you haven't collected yet, the clean way to drain new results without reprocessing. One word throughout this response: each item's collected / collectedAt, the envelope's uncollected count, and the uncollectedOnly filter all name the same thing — whether you have pulled the document. (It does not mean whether we have it from the bank.)
  • 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 /api/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 /api/journal/documents/<id>/payments (status, delivery mode, approval, recon). For reconciliation across pain.002 + camt.053, use GET /api/journal/reconciliation. For bank statements and detected gaps, use GET /api/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 uncollectedOnly=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