Skip to content

Repository files navigation

bankingcircle-go

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"

Design goals

  • 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 in internal/.
  • Client-side validation before any network call. Invalid payment fields, malformed IBANs, etc. fail fast as a *bankingcircle.Error without 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.Error normalizes every documented Banking Circle error body shape, with an ErrorKind you can branch on.
  • context.Context everywhere a network call happens, for cancellation, deadlines, and tracing propagation.

Install

go get github.com/iamkanishka/bankingcircle-go

Requires Go 1.25+.

Quick start

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.

Configuration

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.

Services

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

Payments

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

FX — REST and streaming

// 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,
})

Reporting

// 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)
}

Webhooks

// 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,
})

Error handling

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.

Telemetry

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.

Project structure

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.

Confidence notes / scope

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.

Development

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 build

License

MIT — see LICENSE.

About

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.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages