Best practices: building a resilient client
Audience: 🔌 Integration Client
Each item is a concrete, testable rule.
Correctness
- Send an
Idempotency-Keyon every payment, and freeze the key with the payload. Mint both when you decide to pay, store them, reuse both on every retry. → Idempotency
- Treat
201as "accepted," not "sent." The bank send is asynchronous. Never mark a payment "paid" in your system on the201. Wait forpayment.sentand then the bank acknowledgement (executed/rejected). → Core concepts
- Respect terminal statuses.
executed,rejected, andcancelledare final. Don't build logic that expects them to change; late bank messages can't flip them and neither should you.
- Branch on
code, notmessage. Messages are readable prose and may change. Codes are stable. → Errors
- Model your error type on the object envelope (
{ error: { code, message, details? } }) on every non-2xx response; the envelope is universal. → Errors
- Amounts are strings, always two decimals.
"1250.00", not1250or1250.0. You don't adjust decimals per currency; the engine emits each currency's correct precision. It only rejects an amount the currency can't represent (a non-zero sub-unit, e.g."1000.50"JPY) rather than truncate and change what you're paying. → Validation
- Timestamps you send must be UTC
Z. Offsets are rejected. Prefer to omitcreationDateTimeand let the server stamp it.
- Keep
paymentIdandendToEndIdunique within a batch. Duplicates are hard errors because banks silently de-duplicate on them.
Resilience
- Retry only what's retryable:
429,503, in-progress409, and cautiously5xx. Never blind-retry other4xx. HonourRetry-After. → Errors
- Throttle yourself to both buckets. API-key traffic shares one 300-req/60 s bucket per key, expensive POSTs (including
/api/journal/payments/preview) _and_ fourGETjournal-read routes (/journal/list,/journal/documents,/journal/events,/journal/reconciliation), across all the workers using that key. A poller on those routes spends the same budget a payment submission does. On top of that, a pre-auth per-IP cap of 600/60 s counts every request, GETs included, so a poller behind a NAT'd/shared egress IP can429on reads even off that list. Put a client-side limiter in front of your POSTs _and_ your journal polling, keep polling intervals sane, and back off on429even forGETs. → Errors
- Verify every webhook signature over the raw body, with a constant-time compare, and enforce a timestamp window yourself (it's off by default). Accept any
v1=value during secret rotation. → Receiving results
- Deduplicate webhooks on the event
id. Delivery is at-least-once; the same event can arrive more than once. Theidis stable across retries.
- Monitor for webhook auto-disable. Five consecutive failures silently disables your endpoint. Watch the delivery log and
X-Delivery-Sequencegaps, and always run a polling backstop so a disabled webhook can't lose you data.
- Ack webhooks fast, process afterward. Return
2xxin well under the 10 s timeout, then do the real work asynchronously keyed on the event id.
- Make retries idempotent end-to-end. Your side must tolerate seeing a
201withIdempotency-Replayed: trueand treat it as success, not a new payment.
Observability
- Log
X-Request-IDon every response. It's how support finds your exact request. Store it alongside your own correlation id.
- Persist the
messageId↔ your-order mapping. Reconciliation (pain.002,camt.053) correlates onmessageId; you'll want to join back to your domain objects.
- Log validation warnings, don't drop them. A truncation or non-banking-day warning is often the earliest signal of an upstream data problem.
Environment hygiene
- Do all integration work in sandbox first, against the demo bank. The demo bank channel runs a full payment lifecycle in about 5 seconds and never moves a real payment. The deployed sandbox also stops every production send, so
sandbox.bankconnector.comcan't move a real payment (see Going live).
- Get the spec from docs.bankconnector.com, not a live server. On a production instance
GET /openapi.jsonandGET /openapi.yamlreturn404.
- Pin the API version with
BankConnector-Version: YYYY-MM-DDso a server-side version bump can't silently change behaviour. It's echoed back on every response.
A pre-launch checklist
Before you call the integration done:
- Idempotency key sent on every payment, stable across retries
-
201handled as "accepted"; "paid" is driven by later events - Retry logic covers
429/503/in-progress409and honoursRetry-After - Client-side rate limiter respects the shared per-key bucket
- Webhook signatures verified over raw body, constant-time, rotation-aware
- Webhook events deduplicated on
id - Polling backstop drains
uncollectedOnly=trueon a timer - Webhook auto-disable is monitored and alertable
-
X-Request-IDlogged everywhere - Error handling branches on
code, error type models the object envelope - Amounts are currency-correct strings; timestamps are UTC
Z - Everything validated end-to-end in sandbox before production credentials