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
eventTypesto receive all events (defaults to["*"]). An explicit empty array is rejected. - Unknown body keys are rejected (
strictObject): a typo likeeventsinstead ofeventTypesfails 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
apikeyprincipal 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 viaPOST /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>/deliveriesfor failures. - Track the
X-Delivery-Sequenceheader: 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:
| Event | Fires when |
|---|---|
payment.converted | payment validated & converted |
payment.queued | queued for delivery |
payment.sent | handed to the bank |
payment.delivery-failed | a delivery attempt failed (retryable? in data) |
payment.delivery-abandoned | delivery gave up after retries |
payment.validated | the bank accepted the payment (pain.002 ACCP/ACTC), not yet settled |
payment.executed | the bank settled the payment (pain.002 ACSC/ACCC) |
payment.rejected | the bank rejected the payment (reasonCodes in data) |
payment.partially-accepted | some lines accepted, some rejected (reasonCodes in data) |
payment.cancelled | the bank cancelled the payment |
approval.needed | a payment is waiting for approval |
approval.completed / approval.given | fully approved |
approval.rejected | an approver rejected it |
file.received | an inbound file arrived |
statement.imported | a statement was parsed (includes up to the first 100 transactions — see the note below) |
balance.updated | an account balance changed |
account.statement-gap | a gap detected in statement continuity |
connection.poll-failing | a connection's polling is failing |
connection.cert-expiring | a bank cert nears expiry (30/14/7 days) |
webhook.test | you called the test endpoint |
statement.importedembeds at most the first 100 transactions (PERF-M3).transactionCountis 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}(thedocumentIdis 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:
| Event | Fires when |
|---|---|
payment.duplicate-content | a resubmit matched an existing payment's content fingerprint (not the idempotency key path) |
pain002.unmatched-payment | a bank status report arrived that matched no outbound payment (originalMessageId in data) |
pain002.unmatched-orig-msg-id | a bank status report arrived with no / unusable original-message id, so it cannot be matched |
statement.unmatched-our-reference | a 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-debit | a second statement debit settled an already-settled payment (possible double execution; the payment's status is not changed) |
payment.stranded | a queued payment couldn't be picked up for delivery |
approval.external | an approval decision was recorded from outside the normal approve/reject action |
approval.quorum-unreachable | the approval policy can never be satisfied with the current approver set |
security.cross-company-approver | an approver attempted to act across a company boundary (a security event, not a normal flow) |
connection.bank-key-unresolved | a connection references a bank key that no longer resolves |
payment.veu-cosigned | an EBICS distributed-signature (VEU) co-signature was added to a payment |
payment.veu-cosign-incomplete | an 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. PollGET /journal/listorGET /journal/eventsif 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=truereturns only what you haven't collected yet, the clean way to drain new results without reprocessing.- Page with
cursoruntilnextCursorisnull./journal/listonly acceptscursor: it doesn't havebefore/afteraliases (that's a different route,GET /journal/documents, which acceptscursororbefore). - If you omit
limit,/journal/listdefaults to 500 (capped at 1000): it does not return every matching document unbounded. Setlimitexplicitly 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)
| Webhooks | Polling | |
|---|---|---|
| Latency | seconds | your interval |
| Complexity | signature verify + dedup + monitoring | a loop + a cursor |
| Failure mode | silent auto-disable | none (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