A production-grade, dependency-free Go client for the Moneyhub Open Finance API - Open Banking account aggregation (AIS), payment initiation (PIS), data categorisation/enrichment, affordability, and webhooks.
Built entirely on the Go standard library (net/http, crypto/rsa,
encoding/json, context) - no third-party dependencies, no version
conflicts, no supply-chain surface beyond the Go toolchain itself.
- OpenID Connect authentication - Pushed Authorisation Requests
(PAR),
private_key_jwtclient assertions (signed with stdlibcrypto/rsa), authorisation code exchange,client_credentialstokens for ongoing per-user access, refresh tokens, and OIDC discovery. - Data Aggregation (AIS) - accounts (manual balances, standing
orders, sync status), transactions (manual transactions, splits, file
attachments), regular transaction (subscription/rent/salary)
detection, connection lifecycle (immediate sync, connection-type
filtered catalogs), categories and category groups,
categorisation-as-a-service, counterparties (per-user and global),
beneficiaries, investment holdings with ISIN matching, spending
analysis, savings/spending goals, rental records, affordability
reports, Standard Financial Statements, notification thresholds,
account statements, tax (SA105) data, projects, consent history, bank
icons, reseller checks, and both lightweight (
users) and SCIM-based (scimusers) user records. - Payments (PIS) - payees, single immediate payments, Variable Recurring Payments (VRP) with sweep triggering and funds confirmation, standing orders, bulk pay files, shareable pay links, and refunds.
- Webhooks - verifies both plain-JSON and signed-JWT webhook
deliveries against Moneyhub's published JWKS (RS256 signature
verification implemented with stdlib
crypto/rsa, no JWT library). - Automatic retry with backoff for
429(honouringRetry-After) and5xxresponses, a structured*transport.Errortype instead of bare errors, full context propagation, and a clean DDD package layout - one Go package per bounded context.
go get github.com/iamkanishka/moneyhub-goGo 1.21+ required (for the any alias and generics-adjacent stdlib
features used internally).
moneyhub-go/
├── config/ # Config type and functional options
├── internal/transport/ # Shared HTTP client, retry/backoff, errors
└── pkg/domain/ # One package per bounded context (DDD)
├── auth/ # OIDC: PAR, token exchange, JWKS, id_token verify
├── accounts/
├── transactions/
├── connections/
├── ... (34 domain packages total)
└── webhooks/
Each domain package exposes a Service type constructed via New(cfg),
and has no knowledge of any other domain package (aside from auth and
webhooks sharing the JWKS verification primitive).
Build a *config.Config once and pass it to every domain package's
New constructor. In production, Moneyhub requires private_key_jwt
client authentication:
import (
"github.com/iamkanishka/moneyhub-go/config"
"github.com/iamkanishka/moneyhub-go/pkg/domain/auth"
)
privateKeyPEM, err := os.ReadFile("/path/to/private_key.pem")
if err != nil {
log.Fatal(err)
}
privateKey, err := auth.LoadRSAPrivateKeyPEM(privateKeyPEM)
if err != nil {
log.Fatal(err)
}
cfg, err := config.New(
os.Getenv("MONEYHUB_CLIENT_ID"),
config.Production,
config.WithPrivateKeyJWT(privateKey, os.Getenv("MONEYHUB_KEY_ID")),
config.WithRedirectURI("https://myapp.example.com/moneyhub/callback"),
)
if err != nil {
log.Fatal(err)
}For early sandbox development, client_secret_basic is also supported:
cfg, err := config.New(
"my-client-id",
config.Sandbox,
config.WithClientSecretBasic("my-client-secret"),
config.WithRedirectURI("https://myapp.example.com/moneyhub/callback"),
)ctx := context.Background()
authSvc := auth.New(cfg)
accountsSvc := accounts.New(cfg)
transactionsSvc := transactions.New(cfg)
// 1. Build an authorisation URL for a new user (Moneyhub assigns the sub)
claims := auth.NewClaims().PutSub("")
result, err := authSvc.PushAuthorisationRequest(ctx, auth.AuthorisationURLOptions{
Scope: auth.AISOfflineScopes(),
Claims: claims,
})
if err != nil {
log.Fatal(err)
}
// 2. Redirect the user's browser to result.URL. They authenticate at
// their bank and are redirected back to your RedirectURI with
// ?code=...&state=...
// 3. Exchange the code for tokens and verify the id_token
tokens, err := authSvc.ExchangeCode(ctx, code, "")
if err != nil {
log.Fatal(err)
}
idClaims, err := authSvc.VerifyIDToken(ctx, tokens.IDToken)
if err != nil {
log.Fatal(err)
}
userID, _ := idClaims.String("sub")
// 4. From now on, fetch fresh data tokens for this user as needed
dataTokens, err := authSvc.TokenForUser(ctx, userID, "")
if err != nil {
log.Fatal(err)
}
accountsList, err := accountsSvc.List(ctx, dataTokens.AccessToken, accounts.ListOptions{})
if err != nil {
log.Fatal(err)
}
txs, err := transactionsSvc.List(ctx, dataTokens.AccessToken, transactions.ListOptions{
AccountID: accountsList[0].ID,
})paymentsSvc := payments.New(cfg)
payment := map[string]any{
"amount": map[string]any{"amount": 10.50, "currency": "GBP"},
"creditorAccount": map[string]any{
"identification": map[string]any{
"sortCode": "010203",
"accountNumber": "12345678",
},
},
"reference": "Invoice 123",
}
claims := auth.NewClaims().PutSub("").PutPayment(payment)
result, err := authSvc.PushAuthorisationRequest(ctx, auth.AuthorisationURLOptions{
Scope: auth.PaymentScopes(),
Claims: claims,
})
// redirect the user to result.URL to authorise the payment at their bank, then:
tokens, err := authSvc.ExchangeCode(ctx, code, "")
idClaims, err := authSvc.VerifyIDToken(ctx, tokens.IDToken)
paymentInfo := idClaims["mh:payment"]webhooksVerifier := webhooks.New(cfg)
func handleWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
event, err := webhooksVerifier.Parse(r.Context(), body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
switch event.ID {
case "newTransactions":
go processNewTransactions(event.Payload)
default:
go handleGenericEvent(event)
}
w.WriteHeader(http.StatusOK)
}Moneyhub's webhook delivery has a 5 second response timeout and at most
one retry - acknowledge with 200 immediately and do slow processing in
a goroutine afterwards.
Every function that can fail returns a *transport.Error (which
implements error) with a structured Reason
(ReasonConfig/ReasonNetwork/ReasonAPI/ReasonRateLimited/
ReasonDecode/ReasonJWT/ReasonValidation) instead of an opaque
error string:
import "errors"
accountsList, err := accountsSvc.List(ctx, token, accounts.ListOptions{})
if err != nil {
var apiErr *transport.Error
if errors.As(err, &apiErr) {
switch apiErr.Reason {
case transport.ReasonRateLimited:
time.Sleep(time.Duration(apiErr.RetryAfter) * time.Second)
case transport.ReasonAPI:
log.Printf("moneyhub API error %d: %s", apiErr.Status, apiErr.Code)
}
}
}The whole test suite uses only net/http/httptest - no mocking
framework, no third-party assertion library:
go test ./...
go test ./... -raceFull package documentation: https://pkg.go.dev/github.com/iamkanishka/moneyhub-go.
MIT