BankingCircle

Hex.pmDocs

A production-grade Elixir client for the Banking Circle Connect API: cross-border payments (single & bulk), accounts, and webhooks — with OAuth2/JWT auth (auto-refreshed, single-flighted), client-side request validation, safe retries with jittered backoff, idempotency support, and :telemetry instrumentation.

Scope of this release

This package implements the full documented API surface: Authentication, Payments (single & bulk, plus recalls and traces), Accounts (basic operations, bookings/ledger, + AHV), Virtual Accounts (VIBANs — listing, ordering, POBO/COBO customer/UBO details, closure lifecycle), Webhooks (subscription management + AES-256-GCM payload verification), FX (market-order trading, RFQ, indicative rates, held rates, trade history/exposure, plus a WebSocket streaming client for live quotes and Market Order execution), Reporting (both the async request → poll → download flow, and the synchronous Reconciliation Report endpoint), Case Management (RFI and Recall cases, including attachment upload and answer submission), Direct Debit Collections (mandate-based collection initiation, the one endpoint with documented idempotency-key support), Correspondent and Agency Banking (the fi-to-fi-customer-credit-transfer-initiation JSON endpoint), ISO20022 message transport (pain.001/pacs.008/camt.053 — see the scope note on BankingCircle.ISO20022 below), Error handling, Idempotency, and Rate limiting / retry.

Deliberately out of scope: Correspondent/Agency Banking via Swift. That integration path runs over the SWIFT FIN network itself (MT101/MT103 message exchange, BIC-to-BIC, via your Swift service bureau or direct connection) — it isn't a Banking Circle Connect HTTP endpoint at all, so there's nothing for an HTTP client library to wrap. If your integration needs this, it's a separate SWIFT connectivity project, not something banking_circle can extend into.

Genuinely not implemented: Aliases (PayID, etc.). Banking Circle's docs describe this feature but I couldn't find its REST endpoint paths and payload shapes documented anywhere I could search — rather than invent plausible-looking endpoints for a payment-routing feature (where a wrong guess is actively dangerous, not just inconvenient), I've left it out. If you have API reference access to the Aliases endpoints, that's a straightforward addition following the same pattern as every other module here.

Narrower scope notes, not full gaps:

Note on network verification: every module here has been written and tested against a hand-built harness (Bypass fake servers standing in for Banking Circle's endpoints) rather than against Banking Circle's actual sandbox — some endpoint paths, request/response field names, and the webhook checksum construction should be confirmed against your own sandbox account before going to production. Wherever the docs left a detail ambiguous, the code says so in a comment (see especially BankingCircle.Webhook.Verifier and the mTLS connect_options in BankingCircle.Auth.TokenServer).

Installation

def deps do
[
{:banking_circle, "~> 1.0.0"}
]
end

Configuration

Banking Circle uses entirely separate hosts, credentials, and client certificates for sandbox vs. production — mixing them up is the most common integration mistake, so BankingCircle.Config validates all of this at boot rather than at first request.

# config/runtime.exs
import Config
config :banking_circle, BankingCircle,
environment: :sandbox, # or :production
username: {:system, "BC_USERNAME"},
password: {:system, "BC_PASSWORD"},
certificate_thumbprint: {:system, "BC_CERT_THUMBPRINT"},
client_cert_path: {:system, "BC_CLIENT_CERT_PATH"},
client_key_path: {:system, "BC_CLIENT_KEY_PATH"},
request_timeout_ms: 15_000,
max_retries: 3

With that in place, add nothing else — BankingCircle.Application starts a supervised TokenServer for the :default client automatically.

config :banking_circle, :entities,
eu_entity: [
environment: :production,
username: {:system, "BC_EU_USERNAME"},
password: {:system, "BC_EU_PASSWORD"},
certificate_thumbprint: {:system, "BC_EU_CERT_THUMBPRINT"}
],
uk_entity: [
environment: :production,
username: {:system, "BC_UK_USERNAME"},
password: {:system, "BC_UK_PASSWORD"},
certificate_thumbprint: {:system, "BC_UK_CERT_THUMBPRINT"}
]
BankingCircle.Payments.create_single(attrs, :eu_entity)

Runtime-only clients (multi-tenant)

{:ok, _pid} =
BankingCircle.start_client(:tenant_42,
environment: :production,
username: tenant.bc_username,
password: tenant.bc_password,
certificate_thumbprint: tenant.bc_cert_thumbprint
)
BankingCircle.Payments.create_single(attrs, :tenant_42)
:ok = BankingCircle.stop_client(:tenant_42)

Usage

Single payments

{:ok, payment} =
BankingCircle.Payments.create_single(%{
debtor_account_id: "acc_123",
amount: Decimal.new("100.50"),
currency: "EUR",
creditor_name: "Jane Doe",
creditor_iban: "DE89370400440532013000",
transaction_reference: "INV-2026-001"
})
{:ok, %{"status" => status}} = BankingCircle.Payments.get_status(payment["id"])

Invalid input is caught before any network call, via an Ecto.Changeset:

{:error, changeset} = BankingCircle.Payments.create_single(%{currency: "euros"})
Ecto.Changeset.traverse_errors(changeset, fn {msg, _} -> msg end)
#=> %{currency: ["must be a 3-letter ISO 4217 code"], ...}

Bulk payments

rows = [
%{debtor_account_id: "acc_1", amount: Decimal.new("10.00"), currency: "EUR", ...},
%{debtor_account_id: "acc_1", amount: Decimal.new("20.00"), currency: "EUR", ...}
]
{:ok, bulk} = BankingCircle.Payments.create_bulk(rows)
{:ok, stats} = BankingCircle.Payments.get_bulk(bulk["id"])

A row failing validation is reported with a 1-based index matching Banking Circle's documented elementIndex semantics, so client-side and server-side validation errors read the same way:

{:error, [{2, changeset}]} = BankingCircle.Payments.create_bulk([good_row, bad_row])

Webhooks

{:ok, sub} =
BankingCircle.Webhooks.create_subscription(%{
url: "https://myapp.example.com/webhooks/banking_circle",
encryption_key: my_32_char_key,
event_types: ["OutgoingPaymentBooked", "IncomingPaymentProcessed"],
active: false # verify your receiver before flipping this on
})

Verifying an inbound payload (see BankingCircle.Webhook.Verifier for the full Plug example and important caveats about the checksum field):

{:ok, event} =
BankingCircle.Webhook.Verifier.verify_and_decrypt(raw_body,
checksum: checksum_header,
tag: tag_header,
nonce: nonce_header,
key: my_32_char_key
)

FX

Market order (filled instantly at the prevailing rate):

{:ok, trade} =
BankingCircle.FX.trade(%{
client_order_id: "YourUniqueReference",
buy_currency: "EUR",
sell_currency: "GBP",
amount: 10_000,
amount_currency: "EUR",
tenor: :on
})

RFQ (get a firm 30-second quote, then trade against it — no tenor when trading a quote):

{:ok, [quote]} =
BankingCircle.FX.request_quotes([
%{
quote_request_id: Ecto.UUID.generate(),
customer_id: "000012356",
currency_pair: "EURUSD",
amount: 10_000,
amount_currency: "EUR",
tenor: :on,
request_type: :rfq
}
])
{:ok, trade} =
BankingCircle.FX.trade(%{
client_order_id: "YourUniqueReference",
buy_currency: "EUR",
sell_currency: "USD",
amount: 10_000,
amount_currency: "EUR",
quote_id: quote["quoteId"]
})

Held rate, locked for up to 24 hours and reusable within your daily limits:

{:ok, held} = BankingCircle.FX.held_rate("EUR", "GBP", 1440)

Reporting

# Blocks until ready — fine for scripts/IEx/background workers.
{:ok, report} = BankingCircle.Reporting.fetch_report(:reconciliation, %{from: ~D[2026-07-01], to: ~D[2026-07-10]})
# Or drive it yourself for a web request handler / background job:
{:ok, request_id} = BankingCircle.Reporting.request_report(:account_activity, %{account_id: "acc_123"})
{:processing, retry_after_ms} = BankingCircle.Reporting.poll_status(request_id)
# ... schedule a follow-up poll after retry_after_ms ...
{:complete, report_id} = BankingCircle.Reporting.poll_status(request_id)
{:ok, report} = BankingCircle.Reporting.download(report_id)

Case Management

{:ok, %{"questions" => questions}} = BankingCircle.Cases.get_rfi_case(case_id)
{:ok, %{"attachmentId" => attachment_id}} =
BankingCircle.Cases.upload_attachment(case_id, File.read!("passport.pdf"), "passport.pdf", "application/pdf")
answers = BankingCircle.Cases.RFI.build_answers(%{
0 => "1994-06-01",
1 => {:attachments, [attachment_id]}
})
{:ok, _} = BankingCircle.Cases.submit_rfi_answers(case_id, %{answers: answers})

Direct Debit Collections

{:ok, collection} =
BankingCircle.DirectDebit.initiate(%{
creditor_account_id: "acc_123",
mandate_id: "your-stored-mandate-id",
debtor_iban: "DE89370400440532013000",
amount: Decimal.new("25.00"),
currency: "EUR",
end_to_end_id: "INV-2026-001"
})

Correspondent / Agency Banking

{:ok, payment} =
BankingCircle.Payments.create_fi_to_fi(%{
debtor_account: %{account: "NL4089009999910133", financial_institution: "AAAANL2LXXX", country: "NL"},
debtor_name: "Debtor Name",
creditor_account: %{account: "CH2289000000021111123", country: "CH"},
creditor_name: "Creditor Name",
amount: %{currency: "EUR", amount: "1.00"},
instr_id: "Agency banking payment 1"
})

Recalls and traces

{:ok, recall} = BankingCircle.Payments.initiate_recall(payment_id, "AC03")
{:ok, trace} = BankingCircle.Payments.initiate_trace(payment_id)

Architecture

BankingCircleclient lifecycle (start_client/stop_client)
├── Config — validated per-client config (NimbleOptions)
├── Environment — sandbox/production host resolution
├── Auth.Token — JWT + expiry helpers
├── Auth.TokenServercached, single-flight-refreshed token per client
├── HTTP.Clientshared Req pipeline: auth injection, errors
├── HTTP.Middleware.Retryjittered backoff, Retry-After, safe-methods-only
├── HTTP.Middleware.Idempotency — UUIDv4 Idempotency-Key generation/attachment
├── Error — normalizes both documented error body shapes
├── Payments — single & bulk payment lifecycle, recalls, traces, fi-to-fi
├── Accounts — balances, bookings, AHV
├── VirtualAccounts — VIBAN listing, ordering, UBO details, closure
├── FX — market order / RFQ / held rate trading, trade history
├── FX.Stream — WebSocket streaming quotes + Market Order execution
├── Reporting — async report request/poll/download flow
├── Cases / Cases.RFI — RFI & Recall case management
├── DirectDebit — mandate-based collection initiation (idempotency-keyed)
├── ISO20022 — pain.001/pacs.008/camt.053 XML transport
├── Webhooks — subscription management
├── Webhook.Verifier — AES-256-GCM payload decrypt/verify
├── Schemas.Payment — Ecto-changeset request validation
└── Schemas.BulkPaymentbatch validation + CSV rendering

Why these design choices

Telemetry

:telemetry.attach_many(
"banking-circle-logger",
[
[:banking_circle, :request, :start],
[:banking_circle, :request, :stop],
[:banking_circle, :request, :exception]
],
&MyApp.Telemetry.handle_event/4,
nil
)

See BankingCircle.Telemetry for the full event/measurement/metadata table.

Development

This package was authored without a local Elixir toolchain available in its build environment, so while every module has been written and reviewed carefully against the Elixir/OTP and Req/Ecto APIs it relies on, run the test suite yourself before depending on this in production:

mix deps.get
mix test
mix credo --strict
mix dialyzer

License

MIT. See LICENSE.