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
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 /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>/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 sequence gap detected in statement continuity (missing page between two received statements). camt.053 / MT940 only — see the note below |
account.statement-integrity-gap | a 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.truncated | an inbound camt file was cut short — entries after the cut, or a whole account's statement, are missing |
connection.poll-failing | a connection's polling is failing |
connection.cert-expiring | a 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.expiring | a 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.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 /api/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) |
payment.duplicate-message-id | a 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-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). camt.053 / MT940 only — see the note below |
payment.stranded | a queued payment couldn't be picked up for delivery |
payment.delivery-blocked | a 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.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 |
approval.pending-overdue | a 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-approver | an approver attempted to act across a company boundary (a security event, not a normal flow) |
security.oauth-refresh-reuse | a 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-unresolved | a connection references a bank key that no longer resolves |
connection.no-payment-rails | the 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-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 /api/journal/listorGET /api/journal/eventsif 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-gapis 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-debitis 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 alertstatement.unmatched-our-referencehas no such coupling and does fire for camt.052 and camt.054.
account.statement-integrity-gapruns 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=truereturns only what you haven't collected yet, the clean way to drain new results without reprocessing. One word throughout this response: each item'scollected/collectedAt, the envelope'suncollectedcount, and theuncollectedOnlyfilter all name the same thing — whether you have pulled the document. (It does not mean whether we have it from the bank.)- Page with
cursoruntilnextCursorisnull./journal/listonly acceptscursor: it doesn't havebefore/afteraliases (that's a different route,GET /api/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 /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)
| 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 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