Skip to content

fintech-sdk/truelayer-client-go

Repository files navigation

truelayer-client-go

Go Reference Go Version License

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

Releases

Packages

Contributors

Languages