Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

53 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

BankingCircle

Hex.pm Docs

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:

  • 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

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.

Multiple clients (e.g. one per legal entity)

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

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

: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.

About

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.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages