truelayer-client-go

A production-grade Go SDK for the TrueLayer open banking platform — built from the official API Reference and OpenAPI specs.
Zero external dependencies. Pure stdlib Go 1.25+.
API Coverage
| Domain |
Endpoints |
| Auth (Authentication Server) |
Generate auth link, exchange code, client credentials, refresh token, delete credential, debug IDs |
| Payments v3 |
Create, get, cancel payment; full authorization flow (provider selection, scheme selection, form, consent); refunds (create/get/list); payment links (create/get/list payments) |
| Payments Providers |
Search providers, get provider, submit return parameters |
| Payouts |
Create payout, get payout |
| Merchant Accounts |
List/get accounts, get transactions, setup/disable/get sweeping, get payment sources |
| Mandates (VRP) |
Create, list, get mandate; start auth flow, submit provider selection/consent; revoke, confirm funds, get constraints |
| Data API v1 |
Connection metadata, user info; accounts (list/get/balance/transactions/pending/standing orders/direct debits); cards (list/get/balance/transactions/pending); providers; direct auth link, reauth link, extend connection |
| Verification |
Verify account holder name; create/get account holder verification |
| Signup+ |
Get user data by payment/connected account/mandate; generate auth URI |
| Client Tracking |
Get tracked events by flow ID |
| Webhooks |
HMAC-SHA256 signature verification, replay-attack protection, typed event dispatch, http.Handler implementation |
Installation
go get github.com/iamkanishka/truelayer-client-go
Requires Go 1.25+.
Quick Start
import truelayer "github.com/iamkanishka/truelayer-client-go/truelayer"
client, err := truelayer.New(
truelayer.WithEnvironment(truelayer.Sandbox), // or truelayer.Live
truelayer.WithCredentials("client_id", "secret"),
truelayer.WithRedirectURI("https://yourapp.com/cb"),
truelayer.WithSigningKey(privateKeyPEM, "key-id"), // required for Payments
truelayer.WithWebhookSigningKey(webhookSecret),
)
Every sub-client is accessible through a typed accessor with no global state:
client.Auth() // *auth.Client
client.Payments() // *payments.Client
client.Payouts() // *payouts.Client
client.Merchant() // *merchant.Client
client.Mandates() // *mandates.Client
client.Data() // *data.Client
client.Verification() // *verification.Client
client.SignupPlus() // *signupplus.Client
client.Tracking() // *tracking.Client
client.Webhooks() // *webhooks.Handler
Authentication
TrueLayer uses OAuth2. The SDK handles all token lifecycle automatically.
Generate a bank login link
link, err := client.Auth().GenerateAuthLink(auth.AuthLinkOptions{
Scopes: auth.PaymentsScopes, // or auth.DataScopes
State: csrfToken, // validate on redirect — mandatory
})
// Redirect user to link
Exchange code for token
// After user completes bank login and is redirected back with ?code=...
tok, err := client.Auth().ExchangeCode(ctx, authCode, auth.TokenTypePayments)
Token isolation
Payments and Data tokens occupy separate store slots. A Data token can never be used to authorise a payment and vice versa — enforced at the TokenType level throughout the SDK.
Custom token store (Redis, DynamoDB, etc.)
type RedisStore struct{ client *redis.Client }
func (r *RedisStore) Get(ctx context.Context, tt auth.TokenType) (*auth.Token, error) { ... }
func (r *RedisStore) Set(ctx context.Context, tt auth.TokenType, t *auth.Token) error { ... }
func (r *RedisStore) Delete(ctx context.Context, tt auth.TokenType) error { ... }
client, _ := truelayer.New(
truelayer.WithTokenStore(&RedisStore{client: rdb}),
// ...
)
Payments API v3
Create a payment
created, err := client.Payments().CreatePayment(ctx, payments.CreatePaymentRequest{
AmountInMinor: 1000, // £10.00 in pence
Currency: "GBP",
PaymentMethod: payments.PaymentMethod{
Type: "bank_transfer",
ProviderSelection: payments.ProviderSelection{
Type: "user_selected",
Filter: &payments.ProviderFilter{Countries: []string{"GB"}},
},
Beneficiary: payments.Beneficiary{
Type: "merchant_account",
MerchantAccountID: merchantAccountID,
Reference: "Order #12345",
},
},
User: payments.PaymentUser{Name: "Jane Doe", Email: "jane@example.com"},
Metadata: payments.Metadata{"order_id": "12345"},
}, "order-12345") // stable operationID → safe retries
The operationID is hashed into a stable idempotency key stored by the SDK's idempotency.Manager. Retrying the same operationID always sends the same Idempotency-Key header, preventing duplicate payments.
Authorization flow
// Start the flow (returns next action for the UI)
flowResp, err := client.Payments().StartAuthorizationFlow(ctx, paymentID,
payments.StartAuthorizationFlowRequest{
Redirect: &payments.RedirectConfig{ReturnURI: "https://yourapp.com/cb"},
ProviderSelection: &payments.AuthorizationFlowProviderSelection{
EnableSearchByProvider: true,
},
})
// Submit provider selection
resp, err := client.Payments().SubmitProviderSelection(ctx, paymentID,
payments.SubmitProviderSelectionRequest{ProviderID: "ob-monzo"})
// Submit scheme selection
resp, err := client.Payments().SubmitSchemeSelection(ctx, paymentID,
payments.SubmitSchemeSelectionRequest{SchemeID: "faster_payments_service"})
// Submit form inputs
resp, err := client.Payments().SubmitForm(ctx, paymentID,
payments.SubmitFormRequest{Inputs: map[string]string{"username": "user123"}})
// Submit consent
resp, err := client.Payments().SubmitConsent(ctx, paymentID,
payments.SubmitConsentRequest{Consent: true})
Get payment status
payment, err := client.Payments().GetPayment(ctx, paymentID)
fmt.Println(payment.Status) // "authorization_required" | "authorizing" | "authorized" | "executed" | "settled" | "failed"
Refunds
// Create a partial refund
refund, err := client.Payments().CreateRefund(ctx, paymentID,
payments.CreateRefundRequest{AmountInMinor: 500, Reference: "Partial refund"},
"refund-op-001")
// Full refund (AmountInMinor: 0)
refund, err := client.Payments().CreateRefund(ctx, paymentID,
payments.CreateRefundRequest{}, "fullrefund-op-001")
// List all refunds
refunds, err := client.Payments().ListRefunds(ctx, paymentID)
Payment links
link, err := client.Payments().CreatePaymentLink(ctx, payments.CreatePaymentLinkRequest{
AmountInMinor: 2500,
Currency: "GBP",
PaymentMethod: payments.PaymentLinkMethod{
Type: "bank_transfer",
Beneficiary: payments.Beneficiary{Type: "merchant_account", MerchantAccountID: maID},
},
ReturnURI: "https://yourapp.com/thank-you",
}, "link-op-001")
fmt.Println(link.Link) // Share this URL with the user
Payouts
payout, err := client.Payouts().CreatePayout(ctx, payouts.CreatePayoutRequest{
MerchantAccountID: merchantAccountID,
AmountInMinor: 5000,
Currency: "GBP",
Beneficiary: payments.Beneficiary{
Type: "external_account",
AccountHolderName: "Alice Smith",
AccountIdentifier: &payments.AccountIdentifier{
Type: "sort_code_account_number", SortCode: "040004", AccountNumber: "12345678",
},
Reference: "Payout ref",
},
}, "payout-op-001")
Merchant Accounts
// List accounts
accounts, err := client.Merchant().ListMerchantAccounts(ctx)
// Get transactions with filters
txns, err := client.Merchant().GetTransactions(ctx, accountID, merchant.TransactionsQuery{
From: &from, To: &to, Type: merchant.TransactionTypePayout,
})
// Configure sweeping
cfg, err := client.Merchant().SetupSweeping(ctx, accountID, merchant.SetupSweepingRequest{
MaxAmountInMinor: 100000,
Currency: "GBP",
Frequency: "daily",
})
// Disable sweeping
err = client.Merchant().DisableSweeping(ctx, accountID)
Mandates (Variable Recurring Payments)
validTo := time.Now().AddDate(0, 6, 0)
mandate, err := client.Mandates().CreateMandate(ctx, mandates.CreateMandateRequest{
MandateType: mandates.MandateTypeVRPSweeping,
Currency: "GBP",
User: payments.PaymentUser{Name: "Jane", Email: "jane@example.com"},
Constraints: mandates.Constraints{
ValidTo: &validTo,
MaxSinglePayment: &mandates.SinglePaymentLimit{MaxAmountInMinor: 50000},
PeriodicLimits: []mandates.PeriodicLimit{{
MaxAmountInMinor: 200000,
PeriodType: mandates.PeriodicMonth,
PeriodAlignment: mandates.AlignmentCalendar,
}},
},
Beneficiary: payments.Beneficiary{Type: "merchant_account", MerchantAccountID: maID},
ProviderSelection: payments.ProviderSelection{Type: "user_selected"},
}, "mandate-op-001")
// Check funds availability
funds, err := client.Mandates().ConfirmFunds(ctx, mandate.ID, 10000)
fmt.Println(funds.Confirmed) // true/false
// Revoke a mandate
err = client.Mandates().RevokeMandate(ctx, mandate.ID)
Data API v1
// List all accounts
accounts, err := client.Data().ListAccounts(ctx)
// Get balance
balance, err := client.Data().GetAccountBalance(ctx, accountID)
// Paginated transaction iterator — handles all pages automatically
from := time.Now().AddDate(0, -3, 0)
iter := client.Data().TransactionIterator(ctx, accountID, data.TransactionQuery{From: &from})
for iter.Next() {
for _, txn := range iter.Page() {
fmt.Printf("%s %+.2f %s\n", txn.Timestamp.Format("2006-01-02"), txn.Amount, txn.Currency)
}
}
if err := iter.Err(); err != nil {
log.Fatal(err)
}
// Or collect all at once
all, err := iter.All()
// Cards
cards, err := client.Data().ListCards(ctx)
balance, err := client.Data().GetCardBalance(ctx, cardAccountID)
txns, err := client.Data().GetCardTransactions(ctx, cardAccountID, data.TransactionQuery{})
// Standing orders & direct debits
orders, err := client.Data().GetStandingOrders(ctx, accountID)
debits, err := client.Data().GetDirectDebits(ctx, accountID)
// Identity
info, err := client.Data().GetUserInfo(ctx)
fmt.Println(info.FullName)
// Connection management
err = client.Data().ExtendConnection(ctx, data.ExtendConnectionRequest{})
Verification
// Simple name verification
result, err := client.Verification().VerifyAccountHolderName(ctx,
verification.VerifyAccountHolderRequest{
AccountHolderName: "Jane Doe",
AccountIdentifier: verification.AccountIdentifier{
Type: "sort_code_account_number",
SortCode: "040004", AccountNumber: "12345678",
},
})
fmt.Println(result.Result) // "match" | "no_match"
// Async account holder verification (with webhook)
ahv, err := client.Verification().CreateAccountHolderVerification(ctx,
verification.CreateAccountHolderVerificationRequest{
AccountHolderName: "Jane Doe",
AccountIdentifier: verification.AccountIdentifier{Type: "iban", IBAN: "GB29NWBK60161331926819"},
})
// Poll result
ahv, err = client.Verification().GetAccountHolderVerification(ctx, ahv.ID)
Signup+
// Get verified user data after a payment
userData, err := client.SignupPlus().GetUserDataByPayment(ctx, paymentID)
fmt.Printf("Name: %s, Email: %s\n", userData.Name, userData.Email)
// Get user data after a mandate
userData, err = client.SignupPlus().GetUserDataByMandate(ctx, mandateID)
// Generate a Signup+ auth URI
authURI, err := client.SignupPlus().GenerateAuthURI(ctx, signupplus.GenerateAuthURIRequest{
RedirectURI: "https://yourapp.com/signup/callback",
Scopes: []string{"openid", "name", "email", "address"},
State: csrfToken,
PaymentID: paymentID,
})
Webhooks
Mount the handler on your router — it implements http.Handler:
wh := client.Webhooks()
wh.On(webhooks.EventPaymentExecuted, func(ctx context.Context, e webhooks.Event) error {
var p webhooks.PaymentPayload
webhooks.ParsePayload(e, &p)
log.Printf("payment %s executed", p.PaymentID)
return nil
})
wh.On(webhooks.EventPaymentFailed, func(ctx context.Context, e webhooks.Event) error {
var p webhooks.PaymentPayload
webhooks.ParsePayload(e, &p)
log.Printf("payment %s failed: %s", p.PaymentID, p.FailureReason)
return nil
})
wh.On(webhooks.EventRefundExecuted, func(ctx context.Context, e webhooks.Event) error {
var p webhooks.RefundPayload
webhooks.ParsePayload(e, &p)
log.Printf("refund %s executed for payment %s", p.RefundID, p.PaymentID)
return nil
})
wh.On(webhooks.EventPayoutExecuted, func(ctx context.Context, e webhooks.Event) error {
var p webhooks.PayoutPayload
webhooks.ParsePayload(e, &p)
return nil
})
wh.On(webhooks.EventMandateAuthorized, func(ctx context.Context, e webhooks.Event) error {
var p webhooks.MandatePayload
webhooks.ParsePayload(e, &p)
return nil
})
// Catch-all for event types you haven't explicitly handled
wh.OnFallback(func(ctx context.Context, e webhooks.Event) error {
log.Printf("unhandled event type: %s", e.EventType)
return nil
})
// Mount
http.Handle("/webhooks/truelayer", wh)
Full event type list
webhooks.EventPaymentAuthorized
webhooks.EventPaymentExecuted
webhooks.EventPaymentSettled
webhooks.EventPaymentFailed
webhooks.EventRefundExecuted
webhooks.EventRefundFailed
webhooks.EventPayoutExecuted
webhooks.EventPayoutFailed
webhooks.EventMerchantAccountPaymentSettled
webhooks.EventMerchantAccountPaymentFailed
webhooks.EventMandateAuthorized
webhooks.EventMandateRevoked
webhooks.EventMandateFailed
webhooks.EventVRPPaymentExecuted
webhooks.EventVRPPaymentFailed
webhooks.EventPaymentLinkPaymentExecuted
webhooks.EventAHVCompleted
webhooks.EventAHVFailed
webhooks.EventSignupAuthURIExpired
Observability
Structured logging (log/slog)
truelayer.WithLogger(obs.NewSlogLogger(slog.Default()))
Or implement obs.Logger to bridge to zap, zerolog, etc.:
type ZapLogger struct{ l *zap.SugaredLogger }
func (z *ZapLogger) InfoCtx(ctx context.Context, msg string, args ...any) { z.l.Infow(msg, args...) }
func (z *ZapLogger) ErrorCtx(ctx context.Context, msg string, args ...any) { z.l.Errorw(msg, args...) }
func (z *ZapLogger) DebugCtx(ctx context.Context, msg string, args ...any) { z.l.Debugw(msg, args...) }
func (z *ZapLogger) WarnCtx(ctx context.Context, msg string, args ...any) { z.l.Warnw(msg, args...) }
client, _ := truelayer.New(truelayer.WithLogger(&ZapLogger{l: sugar}), ...)
Prometheus metrics
type PrometheusHook struct {
hist *prometheus.HistogramVec
}
func (p *PrometheusHook) RecordRequest(ctx context.Context, method, path string, status int, latency time.Duration) {
p.hist.WithLabelValues(method, path, strconv.Itoa(status)).Observe(latency.Seconds())
}
client, _ := truelayer.New(truelayer.WithMetrics(&PrometheusHook{hist: requestDuration}), ...)
OpenTelemetry tracing
type OTelTracer struct{ tracer trace.Tracer }
func (o *OTelTracer) StartSpan(ctx context.Context, name string) (context.Context, func()) {
ctx, span := o.tracer.Start(ctx, name)
return ctx, span.End
}
client, _ := truelayer.New(truelayer.WithTracer(&OTelTracer{tracer: otel.Tracer("truelayer")}), ...)
Request / response hooks
client, _ := truelayer.New(
truelayer.WithRequestHook(func(ctx context.Context, req *http.Request) {
req.Header.Set("X-Correlation-Id", getCorrelationID(ctx))
}),
truelayer.WithResponseHook(func(ctx context.Context, req *http.Request, resp *http.Response, elapsed time.Duration) {
log.Printf("← %s %s %d %dms", req.Method, req.URL.Path, resp.StatusCode, elapsed.Milliseconds())
}),
)
Error Handling
All errors are wrapped with context. Use errors.As to inspect structured fields:
payment, err := client.Payments().GetPayment(ctx, id)
if err != nil {
var ae *truelayer.APIError
if errors.As(err, &ae) {
fmt.Printf("status=%d trace=%s title=%s\n", ae.StatusCode, ae.TraceID, ae.Title)
if ae.IsRetryable() {
// Tl-Should-Retry: true was set — the SDK already retried, this is final
}
}
return
}
Convenience helpers:
truelayer.IsNotFound(err) // 404
truelayer.IsUnauthorized(err) // 401
truelayer.IsRateLimited(err) // 429
truelayer.IsConflict(err) // 409
truelayer.IsServerError(err) // 5xx
Retry Policy
Default policy: 3 attempts, 300 ms base delay, 2× multiplier, 10 s cap, full jitter.
truelayer.WithRetryPolicy(retry.Policy{
MaxAttempts: 5,
BaseDelay: 500 * time.Millisecond,
MaxDelay: 30 * time.Second,
Multiplier: 2.0,
})
Only idempotent requests (GET, HEAD, PUT, DELETE) are retried automatically. POST requests are retried only when TrueLayer explicitly signals it via Tl-Should-Retry: true.
Architecture
github.com/iamkanishka/truelayer-client-go
├── truelayer.go Package entry point, Sandbox/Live constants
├── client.go Root Client, New(), 10 sub-client accessors
├── options.go 14 functional options
├── errors.go IsNotFound/IsRateLimited/IsServerError/IsConflict helpers
│
├── auth/ OAuth2 tokens, debug IDs, auth links
├── payments/ Pay-ins, auth flow, refunds, payment links, provider search
├── payouts/ Merchant → external-account payouts
├── merchant/ Merchant accounts, sweeping, payment sources
├── mandates/ VRP mandates (sweeping + commercial)
├── data/ Accounts, balances, transactions, cards, identity
│ └── iterator.go Generic Iterator[T] for cursor-based pagination
├── verification/ Account holder name verification
├── signupplus/ Embedded user data collection
├── tracking/ Authorization flow event tracking
├── webhooks/ Signature verification, replay protection, dispatch
│
└── internal/
├── httpx/ Instrumented HTTP client; RFC 7807 errors; Tl-* headers
├── signing/ ES512 JWS Sign + Verify (P-521)
├── retry/ Exponential backoff with full jitter (math/rand/v2)
├── ratelimit/ Token bucket rate limiter
├── idempotency/ Stable idempotency key manager (crypto/rand)
├── circuitbreaker/ Three-state circuit breaker
└── obs/ log/slog bridge; Nop implementations
Key design decisions
| Concern |
Approach |
| Token isolation |
TokenType discriminant forces payment vs data tokens into separate store slots |
| Request signing |
Mandatory for Payments/Payouts/Mandates — requireSigner() fails fast before any HTTP call |
| Idempotency |
Stable key per operationID — same key survives retries; Release() after confirmed success |
| Retry safety |
Only GET/HEAD/PUT/DELETE retry unconditionally; POST only retries on Tl-Should-Retry: true |
| Concurrency |
Token refresh uses per-type mutex with double-checked locking — no thundering herd |
| Webhooks |
hmac.Equal (constant-time) prevents timing attacks; replay window enforced before dispatch |
| Zero deps |
Pure stdlib — no external packages in the dependency graph |
Running Tests
go test ./...
For integration tests against the Sandbox:
export TRUELAYER_CLIENT_ID=your_client_id
export TRUELAYER_CLIENT_SECRET=your_secret
export TRUELAYER_KEY_ID=your_key_id
export TRUELAYER_AUTH_CODE=code_from_redirect
go test ./... -tags integration -v
License
MIT — see LICENSE.
References