A complete, production-grade, dependency-free Go 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 Go port of the banking_circle
Elixir hex package, restructured as an idiomatic Go SDK.
import "github.com/iamkanishka/bankingcircle-go"- Zero third-party runtime dependencies. Standard library only — including a from-scratch RFC 6455 WebSocket client for FX streaming and a UUIDv4 generator for idempotency keys.
- Domain-driven package layout. Each bounded context (
payments,accounts,fx, ...) owns its entities, validation, and service in one package, built on a shared kernel ininternal/. - Client-side validation before any network call. Invalid payment
fields, malformed IBANs, etc. fail fast as a
*bankingcircle.Errorwithout 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.
*bankingcircle.Errornormalizes every documented Banking Circle error body shape, with anErrorKindyou can branch on. context.Contexteverywhere a network call happens, for cancellation, deadlines, and tracing propagation.
go get github.com/iamkanishka/bankingcircle-goRequires Go 1.25+.
package main
import (
"context"
"log"
"time"
"github.com/iamkanishka/bankingcircle-go"
"github.com/iamkanishka/bankingcircle-go/payments"
)
func main() {
client, err := bankingcircle.New(
bankingcircle.WithEnvironment(bankingcircle.Sandbox),
bankingcircle.WithCredentials(username, password, certThumbprint),
bankingcircle.WithRequestTimeout(20*time.Second),
)
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
payment, err := client.Payments.CreateSingle(ctx, payments.CreateSingleInput{
DebtorAccountID: "acc_123",
Amount: "100.50",
Currency: "EUR",
CreditorName: "Jane Doe",
CreditorIBAN: "DE89370400440532013000",
TransactionReference: "INV-2026-001",
})
if err != nil {
if bcErr, ok := bankingcircle.AsError(err); ok {
log.Fatalf("payment failed [%s]: %s", bcErr.Kind, bcErr.Message)
}
log.Fatal(err)
}
log.Printf("payment created: %v", payment)
}See examples/basic for a fuller runnable walkthrough (accounts, a
payment, and an FX rate lookup) and examples/webhookreceiver for a
webhook HTTP handler.
client, err := bankingcircle.New(
bankingcircle.WithEnvironment(bankingcircle.Sandbox), // or bankingcircle.Production
bankingcircle.WithCredentials(username, password, certThumbprint),
bankingcircle.WithClientCertificate(certPath, keyPath), // mTLS, required by Banking Circle
bankingcircle.WithRequestTimeout(20*time.Second), // default 15s
bankingcircle.WithMaxRetries(3), // default 3
bankingcircle.WithRetryBaseDelay(250*time.Millisecond), // default 250ms, capped at 8s
bankingcircle.WithTelemetry(myTelemetry), // see Telemetry below
bankingcircle.WithName("my-service"), // telemetry label only
)WithEnvironment and WithCredentials are effectively required (the
default environment is Sandbox, but you'll want to set it explicitly);
everything else has a sensible default. New validates configuration and
returns an error 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.
Every bounded context is exposed as a field on *bankingcircle.Client,
and documented in its own package:
| Field | Package | 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 |
| — | webhook |
Network-free AES-256-GCM payload verification/decryption |
// Single payment
payment, err := client.Payments.CreateSingle(ctx, payments.CreateSingleInput{
DebtorAccountID: "acc_123",
Amount: "100.50",
Currency: "EUR",
CreditorName: "Jane Doe",
CreditorIBAN: "DE89370400440532013000",
TransactionReference: "INV-2026-001",
Urgency: payments.UrgencyInstant, // 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.
result, err := client.Payments.CreateBulk(ctx, []payments.CreateSingleInput{
{DebtorAccountID: "acc_123", Amount: "10.00", Currency: "EUR", /* ... */},
{DebtorAccountID: "acc_123", Amount: "20.00", Currency: "EUR", /* ... */},
})
var bulkErr *payments.BulkValidationError
if errors.As(err, &bulkErr) {
for _, row := range bulkErr.Rows {
log.Printf("row %d: %v", row.Index, row.Err)
}
}
// Recall / trace a payment
client.Payments.InitiateRecall(ctx, paymentID, payments.RecallReasonWrongAmount)
client.Payments.InitiateTrace(ctx, 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.
// Market order
trade, err := client.FX.Trade(ctx, fx.TradeInput{
ClientOrderID: "order-1",
BuyCurrency: "USD",
SellCurrency: "EUR",
Amount: "10000",
AmountCurrency: "EUR",
Tenor: fx.TenorSpot,
})
// RFQ -> trade
quotes, _ := client.FX.RequestQuotes(ctx, []fx.QuoteRequest{
{CurrencyOne: "EUR", CurrencyTwo: "USD", Amount: "10000", AmountCurrency: "EUR",
Tenor: fx.TenorSpot, RequestType: fx.RequestTypeRFQ},
})
trade, err := client.FX.Trade(ctx, fx.TradeInput{
ClientOrderID: "order-2", BuyCurrency: "USD", SellCurrency: "EUR",
Amount: "10000", AmountCurrency: "EUR", QuoteID: quotes[0]["id"].(string),
})
// Live streaming quotes + Market Order execution
stream, err := client.StreamFX(ctx, fx.StreamParams{
CustomerID: "000012345",
Handler: func(msg fx.StreamMessage) {
log.Printf("[%s] %v", msg.Type(), msg)
},
})
defer stream.Close()
stream.Subscribe("EUR/USD", fx.TenorSpot, 0)
stream.MarketOrder(fx.StreamOrderInput{
ClientOrderID: "stream-order-1", BuyCurrency: "EUR", SellCurrency: "USD",
AmountCurrency: "EUR", Amount: "3000000", Tenor: fx.TenorSpot,
})// Blocking convenience wrapper around request -> poll -> download
body, err := client.Reporting.FetchReport(ctx, reporting.ReportReconciliation,
map[string]interface{}{"fromDate": "2026-07-01", "toDate": "2026-07-27"},
reporting.FetchReportOptions{Timeout: 3 * time.Minute},
)
// Or drive the flow yourself (e.g. from a background job)
requestID, _ := client.Reporting.RequestReport(ctx, reporting.ReportAccountActivity, attrs)
outcome, _ := client.Reporting.PollStatus(ctx, requestID)
if outcome.Complete {
body, _ := client.Reporting.Download(ctx, outcome.ReportID)
}// Subscribe (see the webhooks package)
sub, err := client.Webhooks.CreateSubscription(ctx, webhooks.CreateSubscriptionInput{
URL: "https://example.com/webhooks/banking-circle",
EncryptionKey: myThirtyTwoCharacterKey,
EventTypes: []string{"PaymentProcessed", "CaseOpened"},
})
// Verify + decrypt an inbound payload (network-free — call this from
// your own HTTP handler; see examples/webhookreceiver)
event, err := webhook.VerifyAndDecrypt(body, webhook.Options{
Checksum: r.Header.Get("X-Bc-Checksum"),
Tag: r.Header.Get("X-Bc-Auth-Tag"),
Nonce: r.Header.Get("X-Bc-Nonce"),
Key: myThirtyTwoCharacterKey,
})Every failure mode — transport errors, HTTP 4xx/5xx, auth failures, and
client-side validation — surfaces as a *bankingcircle.Error:
_, err := client.Payments.CreateSingle(ctx, input)
if bcErr, ok := bankingcircle.AsError(err); ok {
switch bcErr.Kind {
case bankingcircle.KindValidation:
// client-side or 422 validation problem; see bcErr.Details
case bankingcircle.KindRateLimited:
// 429; bcErr.RetryAfterMs is populated if the server sent one
case bankingcircle.KindAuth:
// 401/403
}
if bcErr.Retryable() {
// KindRateLimited, KindServerError, KindTimeout, KindTransport
}
}bcErr.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.
Implement bankingcircle.Telemetry to receive lifecycle events for every
outgoing request (across every service):
type Telemetry interface {
OnRequestStart(ctx context.Context, method, path string)
OnRequestStop(ctx context.Context, method, path string, duration time.Duration, status int, err error)
}Wire it up with bankingcircle.WithTelemetry(myImpl). Use it to feed
Prometheus, OpenTelemetry, or structured logging.
bankingcircle-go/
├── bankingcircle.go # Client facade: wires config, auth, transport, and every service
├── config.go, options.go # Config + functional options
├── environment.go # Sandbox/Production host resolution
├── errors.go, telemetry.go # Public error & telemetry types
├── internal/
│ ├── apierrors/ # Canonical Error type + parsing (both documented error shapes)
│ ├── auth/ # Cached, single-flight-refreshed OAuth2 token manager
│ ├── httpclient/ # Shared request pipeline: retries, idempotency, telemetry
│ └── wsclient/ # From-scratch RFC 6455 WebSocket client (stdlib only)
├── payments/ # Entities + validation + service, one bounded context per package
├── accounts/
├── virtualaccounts/
├── fx/ # + stream.go for WebSocket quote streaming / Market Orders
├── reporting/
├── cases/
├── directdebit/
├── iso20022/
├── webhooks/ # Subscription management (network)
├── webhook/ # Payload verification/decryption (network-free)
└── examples/
├── basic/
└── webhookreceiver/
Each bounded-context package is self-contained: its entities, client-side
validation, and service methods live together, on top of the shared
kernel in internal/. internal/ packages never import the root
bankingcircle package (avoiding import cycles) — public types like
bankingcircle.Error are type aliases onto their internal/apierrors
counterparts, so every service package can construct and return them
directly.
This SDK implements the full documented Banking Circle Connect API
surface, with two exceptions, both explained in the bankingcircle
package doc:
- 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, virtualaccounts.Service.Order's exact endpoint
path/payload shape is inferred from documentation terminology rather than
confirmed directly against the API reference — see that package's doc
comment, and verify against your sandbox before relying on it in
production. Everything else has been checked against the reference
Elixir client's documented behavior.
make build # go build ./...
make test-race # go test ./... -race -count=1
make vet # go vet ./...
make fmt-check # gofmt -l .
make lint # golangci-lint run ./... (see .golangci.yml)
make ci # fmt-check + vet + test-race + examples buildMIT — see LICENSE.