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, and nothing was in flight when it did. data.documentStatus carries the payment's status — see the re-issue note below |
payment.stuck-sending | delivery was interrupted while the file was in flight; we do not know whether the bank received it. Do not re-issue — see the re-issue note below |
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.
🔴 Before you re-issue a payment automatically
Re-issuing under a fresh
MsgIdis the right remedy when a payment did not go through, and the wrong one when we cannot prove that. Two different events say so, and the difference is which one you got:
payment.delivery-abandoned— we gave up before or between attempts.data.documentStatustells you which:queued-for-delivery,delivery-failed,pending-approvalorconvertedmean no file reached the bank and no payment was made, so re-issuing is safe. Any other value means the bank already has the payment; that payment's own status event (payment.sent/payment.validated/payment.executed/payment.rejected/ …) is authoritative and this event does not change it, so do not re-issue on the strength of this one.documentStatusisnullonly when we could not read the status at all — check the payment before re-issuing.payment.stuck-sending— delivery was interrupted while the file was in flight. The bank may have received and executed it. The payment is held atsending, re-issuing it can pay twice, and clearing it needs a person: confirm with the bank, then resend from BankConnector if it was not paid. This event also fires from the daily SLA sweep for any payment left atsending, and means the same thing both times.The safe rule for an unattended integrator: automate on
payment.delivery-abandonedonly whendocumentStatusis one of the four pre-send values, and never onpayment.stuck-sending.
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