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 robust 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.
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):
const age = 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: 5 attempts, exponential backoff ≈ 30 s, 60 s, 120 s, 240 s (capped at 1 h). 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 transactions) |
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 |
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 — cannot be matched |
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 |
Some internal transitions (e.g.
payment.sanctions-hold,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.
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. GETs aren't rate-limited, so a periodic poll is cheap.