Packages
banking_circle
1.0.0
A complete, production-grade Elixir client for the Banking Circle Connect API — payments (single & bulk), accounts, webhooks (AES-256-GCM verified), OAuth2/JWT auth with automatic token refresh, idempotency, retries, and telemetry.
Current section
Files
Jump to
Current section
Files
banking_circle
README.md
README.md
# BankingCircle
[](https://hex.pm/packages/banking_circle)
[](https://hexdocs.pm/banking_circle)
A production-grade Elixir client for the [Banking Circle Connect
API](https://docs.bankingcircleconnect.com): 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:**
- `BankingCircle.VirtualAccounts.order/2`'s endpoint path is inferred
from consistent terminology in the docs, not confirmed against the API
reference directly — see its moduledoc before relying on it. Every
other function in that module (list, close, close-status,
add-customer-details, customer-details-status) is confirmed.
- `BankingCircle.ISO20022` is a transport layer (posts XML you supply to
the right endpoint with the right content type) — it does not build or
validate pain.001/pacs.008/camt.053 XML documents for you. See its
moduledoc.
- `BankingCircle.FX.Stream` (WebSocket streaming quotes) is built on
`WebSockex` rather than `Req`, has a genuinely different
connection-lifecycle shape from the rest of this library, and — per its
moduledoc — has had less real-world exercise than the REST modules.
Verify reconnect/token-refresh behavior against your own sandbox before
relying on it for production execution.
**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
```elixir
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.
```elixir
# 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.
### Multiple clients (e.g. one per legal entity)
```elixir
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"}
]
```
```elixir
BankingCircle.Payments.create_single(attrs, :eu_entity)
```
### Runtime-only clients (multi-tenant)
```elixir
{: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
```elixir
{: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`:
```elixir
{: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
```elixir
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:
```elixir
{:error, [{2, changeset}]} = BankingCircle.Payments.create_bulk([good_row, bad_row])
```
### Webhooks
```elixir
{: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):
```elixir
{: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):
```elixir
{: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):
```elixir
{: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:
```elixir
{:ok, held} = BankingCircle.FX.held_rate("EUR", "GBP", 1440)
```
### Reporting
```elixir
# 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
```elixir
{: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
```elixir
{: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
```elixir
{: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
```elixir
{:ok, recall} = BankingCircle.Payments.initiate_recall(payment_id, "AC03")
{:ok, trace} = BankingCircle.Payments.initiate_trace(payment_id)
```
## Architecture
```
BankingCircle — client lifecycle (start_client/stop_client)
├── Config — validated per-client config (NimbleOptions)
├── Environment — sandbox/production host resolution
├── Auth.Token — JWT + expiry helpers
├── Auth.TokenServer — cached, single-flight-refreshed token per client
├── HTTP.Client — shared Req pipeline: auth injection, errors
├── HTTP.Middleware.Retry — jittered 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.BulkPayment — batch validation + CSV rendering
```
### Why these design choices
- **Req over Tesla/HTTPoison**: Req is the current Elixir ecosystem
default for new HTTP clients (built on Finch/Mint, first-class
streaming, sane defaults). We lean on its *built-in* retry mechanism
rather than reimplementing request/response steps against internals.
- **Ecto embedded schemas without a database**: changesets are simply the
best-in-class validation/casting primitive available in Elixir; using
them here costs nothing (no Repo, no migrations) and gets you field-level
error reporting for free.
- **One GenServer per client for token caching**: concurrent callers
racing a near-simultaneous token expiry collapse into a single outbound
auth request because the GenServer mailbox serializes it — no separate
locking primitive needed.
- **Retry policy refuses to retry a bare POST**: Banking Circle's
idempotency-key support is currently scoped to Direct Debit Collections
endpoints. Blindly retrying an unacknowledged payment POST elsewhere
risks a duplicate payment, so the retry middleware only retries
GET/HEAD or requests explicitly carrying an `Idempotency-Key` header.
## Telemetry
```elixir
: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**:
```sh
mix deps.get
mix test
mix credo --strict
mix dialyzer
```
## License
MIT. See `LICENSE`.