bankingcircle-sdk
v1.0.0
Published
A complete, dependency-free TypeScript client for the Banking Circle Connect API: payments, accounts, virtual accounts, FX (incl. WebSocket streaming), reporting, case management, direct debit, ISO20022, and webhooks.
Maintainers
Readme
bankingcircle-sdk
A complete, production-grade, zero-dependency TypeScript client for the Banking Circle Connect API: cross-border payments, accounts, virtual accounts (VIBANs), FX (trading, RFQ, held rates, and live WebSocket streaming), reporting, case management (RFI/recall), direct debit collections, ISO20022 message transport, and webhooks.
This is a TypeScript port of the banking_circle
Elixir hex package (and its sibling bankingcircle-go
Go module), restructured as an idiomatic, modern npm package.
import { BankingCircleClient } from "bankingcircle-sdk";Design goals
- Zero runtime dependencies. Built entirely on platform globals —
fetch,WebSocket,crypto.subtle,crypto.randomUUID,FormData/Blob. No supply-chain surface beyond the JS runtime itself. - Domain-driven module layout. Each bounded context (
payments,accounts,fx, ...) owns its types, validation, and service class in one module, built on a shared kernel ininternal/. - Client-side validation before any network call. Invalid payment
fields, malformed IBANs, etc. throw a
ValidationErrorwithout hitting the network. - Safe retries. Full-jitter exponential backoff, but only for
GET/HEAD or requests carrying an
Idempotency-Key— a bare POST is never auto-retried, so a flaky network can't duplicate a payment. - Structured, classifiable errors.
BankingCircleErrornormalizes every documented Banking Circle error body shape, with anErrorKindyou can branch on. AbortSignaleverywhere a network call happens, for cancellation, timeouts, and integration with your own request lifecycle.- Dual ESM/CJS with full type declarations — works with
importandrequire()alike.
Install
npm install bankingcircle-sdkRequires Node.js 18+. FX WebSocket streaming (client.streamFx)
requires Node.js 22+ (native WebSocket), or pass a compatible
implementation via StreamParams.webSocketImpl on earlier versions.
Everything else works in any modern JS runtime — Node, browsers, Deno,
Bun, Cloudflare Workers — though see the runtime notes
below before using this in a browser.
Quick start
import { BankingCircleClient, BankingCircleError } from "bankingcircle-sdk";
const client = new BankingCircleClient({
environment: "sandbox",
username: process.env.BC_USERNAME!,
password: process.env.BC_PASSWORD!,
certificateThumbprint: process.env.BC_CERT_THUMBPRINT!,
requestTimeoutMs: 20_000,
});
try {
const payment = await client.payments.createSingle({
debtorAccountId: "acc_123",
amount: "100.50",
currency: "EUR",
creditorName: "Jane Doe",
creditorIban: "DE89370400440532013000",
transactionReference: "INV-2026-001",
});
console.log("payment created:", payment);
} catch (err) {
if (err instanceof BankingCircleError) {
console.error(`payment failed [${err.kind}]: ${err.message}`);
} else {
throw err;
}
}See examples/basic.ts for a fuller runnable walkthrough (accounts, a
payment, and an FX rate lookup) and examples/webhookReceiver.ts for a
webhook HTTP handler.
Configuration
const client = new BankingCircleClient({
environment: "sandbox", // or "production"
username, password, certificateThumbprint, // required
requestTimeoutMs: 20_000, // default 15000
maxRetries: 3, // default 3
retryBaseDelayMs: 250, // default 250, capped at 8000
fetch: myFetch, // override the global fetch (mTLS, testing, ...)
telemetry: myTelemetry, // see Telemetry below
webhookEncryptionKey: myKey, // default key for verifyAndDecryptWebhook
name: "my-service", // telemetry label only
});environment and username/password/certificateThumbprint are
required; everything else has a sensible default. The constructor
validates configuration and throws immediately — no network call is
made during construction, since tokens are fetched lazily and cached.
A client is safe for concurrent use; build one per Banking Circle account/legal-entity credential pair and share it.
mTLS
Banking Circle requires client-certificate authentication at the
transport layer in addition to the Basic-auth credentials above. The
Fetch API has no standard way to configure this, so pass a
pre-configured fetch via the fetch option — for example, with
undici:
import { Agent, fetch as undiciFetch } from "undici";
import fs from "node:fs";
const agent = new Agent({
connect: {
cert: fs.readFileSync("client-cert.pem"),
key: fs.readFileSync("client-key.pem"),
},
});
const client = new BankingCircleClient({
// ...
fetch: ((url, init) => undiciFetch(url, { ...init, dispatcher: agent })) as typeof fetch,
});Services
Every bounded context is exposed as a property on BankingCircleClient,
and documented in its own module:
| Property | Module | Covers |
|-----------------------------|----------------------|---|
| client.payments | payments | Single & bulk payments, status, cancellation, MT103, recalls, traces, Correspondent/Agency Banking (FI-to-FI) |
| client.accounts | accounts | Listing, balances, bookings, Account Holder Verification |
| client.virtualAccounts | virtualAccounts | VIBAN listing, ordering, customer/UBO details, closure |
| client.fx | fx | Market order / RFQ / held-rate trading, indicative rates, trade history, WebSocket streaming (client.streamFx) |
| client.reporting | reporting | Async report request/poll/download, sync reconciliation report |
| client.cases | cases | RFI/Recall case listing, detail, attachments, answers |
| client.directDebit | directDebit | Mandate-referenced collection initiation (idempotency-key supported) |
| client.iso20022 | iso20022 | pain.001/pacs.008 XML transport, camt.053 statements |
| client.webhooks | webhooks | Subscription CRUD, sandbox simulation |
| — | verifyAndDecryptWebhook (top-level export) | Network-free AES-256-GCM payload verification/decryption |
Payments
// Single payment
const payment = await client.payments.createSingle({
debtorAccountId: "acc_123",
amount: "100.50",
currency: "EUR",
creditorName: "Jane Doe",
creditorIban: "DE89370400440532013000",
transactionReference: "INV-2026-001",
urgency: "instant", // optional, defaults to "standard"
});
// Bulk payment — every row validated client-side; a single invalid row
// rejects the whole batch before any network call, with 1-based row
// indices matching Banking Circle's elementIndex error semantics.
import { BulkValidationError } from "bankingcircle-sdk";
try {
await client.payments.createBulk([
{ debtorAccountId: "acc_123", amount: "10.00", currency: "EUR", /* ... */ },
{ debtorAccountId: "acc_123", amount: "20.00", currency: "EUR", /* ... */ },
]);
} catch (err) {
if (err instanceof BulkValidationError) {
for (const row of err.rows) {
console.error(`row ${row.index}:`, row.error.fields);
}
}
}
// Recall / trace a payment
await client.payments.initiateRecall(paymentId, "AM09"); // wrong amount
await client.payments.initiateTrace(paymentId);Only directDebit.initiate currently carries documented idempotency-key
support — createSingle/createBulk are never auto-retried by the
shared HTTP pipeline for exactly that reason. If a payment request times
out, check getByTransactionReference before resubmitting.
FX — REST and streaming
// Market order
const trade = await client.fx.trade({
clientOrderId: "order-1",
buyCurrency: "USD",
sellCurrency: "EUR",
amount: "10000",
amountCurrency: "EUR",
tenor: "SPOT",
});
// RFQ -> trade
const [quote] = await client.fx.requestQuotes([
{ currencyOne: "EUR", currencyTwo: "USD", amount: "10000", amountCurrency: "EUR", tenor: "SPOT", requestType: "Rfq" },
]);
await client.fx.trade({
clientOrderId: "order-2", buyCurrency: "USD", sellCurrency: "EUR",
amount: "10000", amountCurrency: "EUR", quoteId: quote["id"] as string,
});
// Live streaming quotes + Market Order execution (requires Node 22+, see Install)
const stream = await client.streamFx({
customerId: "000012345",
onMessage: (msg) => console.log(msg.type(), msg),
});
stream.subscribe("EUR/USD", "SPOT");
stream.marketOrder({
clientOrderId: "stream-order-1",
buyCurrency: "EUR", sellCurrency: "USD",
amountCurrency: "EUR", amount: "3000000", tenor: "SPOT",
});
// ... later
stream.close();Reporting
// Blocking convenience wrapper around request -> poll -> download
const body = await client.reporting.fetchReport(
"reconciliation",
{ fromDate: "2026-07-01", toDate: "2026-07-27" },
{ timeoutMs: 3 * 60_000 },
);
// Or drive the flow yourself (e.g. from a background job)
const requestId = await client.reporting.requestReport("account-activity", attrs);
const outcome = await client.reporting.pollStatus(requestId);
if (outcome.complete && outcome.reportId) {
const body = await client.reporting.download(outcome.reportId);
}Node.js required for the async flow.
requestReport/pollStatusread the real HTTP status andLocationheader off a manually-followed redirect. Browsers make this response opaque per the Fetch spec; Node's nativefetchdoes not. Since this API needs server-held OAuth2 credentials that shouldn't reach a browser anyway, this is a non-issue for the intended backend-to-backend usage.
Webhooks
import { verifyAndDecryptWebhook } from "bankingcircle-sdk";
// Subscribe
await client.webhooks.createSubscription({
url: "https://example.com/webhooks/banking-circle",
encryptionKey: myThirtyTwoCharacterKey,
eventTypes: ["PaymentProcessed", "CaseOpened"],
});
// Verify + decrypt an inbound payload (network-free — call this from
// your own HTTP handler; see examples/webhookReceiver.ts)
const event = await verifyAndDecryptWebhook(rawBodyBytes, {
checksum: req.headers["x-bc-checksum"],
tag: req.headers["x-bc-auth-tag"],
nonce: req.headers["x-bc-nonce"],
key: myThirtyTwoCharacterKey,
});Error handling
Every failure mode — transport errors, HTTP 4xx/5xx, auth failures, and
client-side validation — surfaces as a BankingCircleError:
import { BankingCircleError } from "bankingcircle-sdk";
try {
await client.payments.createSingle(input);
} catch (err) {
if (err instanceof BankingCircleError) {
switch (err.kind) {
case "validation_error":
// client-side or 422 validation problem; see err.details
break;
case "rate_limited":
// 429; err.retryAfterMs is populated if the server sent one
break;
case "auth_error":
// 401/403
break;
}
if (err.retryable) {
// "rate_limited", "server_error", "timeout", "transport_error"
}
}
}err.details normalizes both of Banking Circle's documented error body
shapes (the single-object propertyName/errorCode/errorDescription
shape, and the bulk-operation fieldIndex/elementIndex list shape)
into one ErrorDetail[].
Telemetry
Implement the Telemetry interface to receive lifecycle events for
every outgoing request (across every service):
interface Telemetry {
onRequestStart(method: string, path: string): void;
onRequestStop(method: string, path: string, durationMs: number, status: number | undefined, error: unknown): void;
}Wire it up via new BankingCircleClient({ telemetry: myImpl, ... }). Use
it to feed OpenTelemetry, Prometheus, or structured logging.
Runtime notes
- Node.js is the intended runtime. This client holds long-lived OAuth2 client credentials — the kind of secret that should never ship to a browser. Browser support is provided where it costs nothing (the code has no Node-specific imports), but the reporting service's async flow and the FX WebSocket's header-based auth (see below) both have browser-specific caveats documented in their respective modules.
- FX streaming (
client.streamFx) authenticates via a?bearerToken=query parameter, because the standardWebSocketconstructor has no mechanism for custom handshake headers. This is a common workaround for that exact constraint but is not confirmed against Banking Circle's documentation for this specific endpoint — verify it against your sandbox, and seeFxStream's doc comment for the fallback if it doesn't work.
Project structure
bankingcircle-sdk/
├── src/
│ ├── index.ts # Public API barrel export
│ ├── client.ts # BankingCircleClient facade
│ ├── config.ts # Options validation/resolution
│ ├── environment.ts # Sandbox/Production host resolution
│ ├── errors.ts, telemetry.ts, json.ts
│ ├── internal/
│ │ ├── auth/ # Cached, single-flight-refreshed OAuth2 TokenManager
│ │ └── http/ # Shared request pipeline: retries, idempotency, telemetry
│ ├── payments/ # Types + validation + CSV + service, one module per bounded context
│ ├── accounts/
│ ├── virtualAccounts/
│ ├── fx/ # + stream.ts for WebSocket quote streaming / Market Orders
│ ├── reporting/
│ ├── cases/
│ ├── directDebit/
│ ├── iso20022/
│ ├── webhooks/ # Subscription management (network)
│ └── webhook/ # Payload verification/decryption (network-free)
├── test/ # vitest — unit + integration tests against local HTTP servers
└── examples/
├── basic.ts
└── webhookReceiver.tsEach bounded-context module is self-contained: its types, client-side
validation, and service class live together, on top of the shared
kernel in internal/. internal/ modules are never part of the public
API surface (see src/index.ts's barrel export).
Confidence notes / scope
This SDK implements the full documented Banking Circle Connect API
surface, with two exceptions, both explained in src/index.ts's
module doc comment:
- Correspondent/Agency Banking over raw SWIFT FIN (MT101/MT103 message exchange) is not an HTTP endpoint and is out of scope.
- Aliases (PayID, etc.) are described in Banking Circle's docs but no REST endpoint paths/payload shapes are published anywhere we could find — inventing plausible-looking ones for a payment-routing feature would be actively dangerous, not just inconvenient.
Additionally:
VirtualAccountsService.order's exact endpoint path/payload shape is inferred from documentation terminology rather than confirmed directly against the API reference — see that class's doc comment.FxStream's query-param WebSocket auth (see Runtime notes).ReportingService's async flow requires a Node-likefetch(see Reporting).
Everything else has been checked against the reference Elixir/Go clients' documented behavior.
Development
npm run build # tsup -> dist/ (ESM + CJS + .d.ts)
npm run typecheck # tsc --noEmit across src + test + examples
npm run lint # eslint src test examples
npm run format:check # prettier --check
npm test # vitest run
npm run test:coverage # vitest run --coverage
npm run example:basic # tsx examples/basic.ts
npm run example:webhook # tsx examples/webhookReceiver.tsLicense
MIT — see LICENSE.
