monzo

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 27, 2026 License: MIT Imports: 6 Imported by: 0

README

monzo-go

A complete, production-grade Go client for the Monzo API, structured with domain-driven design layering and built entirely on the standard library.

  • Full API coverage — OAuth2, Accounts, Balance, Pots, Transactions, Feed Items, Attachments, Transaction Receipts, Webhooks
  • DDD architecture — domain/ (entities + repository ports) → application/ (use-case orchestration + validation) → infrastructure/ (HTTP transport + REST adapters)
  • Zero dependencies — nothing beyond the Go standard library
  • Resilient by default — jittered exponential-backoff retries on transient errors, automatic token refresh on 401s
  • Idiomatic Go — context.Context everywhere, errors.Is-compatible typed errors, functional options, a generics-based pagination iterator

Note on scope: the Monzo API is intended for personal use or a small, explicitly-allowed set of users — not for public multi-tenant applications. See Monzo's docs for details.

Install

go get github.com/iamkanishka/monzo-go

Requires Go 1.22+.

Project layout

monzo-go/
├── client.go                    # public façade: monzo.New(...), functional options
├── domain/                      # entities + repository interfaces (ports), no I/O
│   ├── shared/                  # APIError, ValidationError, money/pagination value objects
│   ├── auth/ account/ balance/ pot/ transaction/ feeditem/ attachment/ receipt/ webhook/
├── application/                 # use-case orchestration: validation, pagination, token lifecycle
├── infrastructure/
│   ├── httpclient/              # shared transport: retries, timeouts, auth, error mapping
│   └── rest/                    # repository implementations against the real Monzo API
├── pkg/
│   ├── pagination/               # generic Paginator[T] for full-history walks
│   └── security/                 # OAuth state token generation, constant-time comparison
└── examples/                    # oauth/, transactions/, webhook/ - runnable demos

Quickstart

package main

import (
	"context"
	"fmt"
	"log"
	"os"

	monzo "github.com/iamkanishka/monzo-go"
	"github.com/iamkanishka/monzo-go/domain/account"
)

func main() {
	client := monzo.New(
		monzo.WithTokens(os.Getenv("MONZO_ACCESS_TOKEN"), os.Getenv("MONZO_REFRESH_TOKEN")),
		monzo.WithClientCredentials(os.Getenv("MONZO_CLIENT_ID"), os.Getenv("MONZO_CLIENT_SECRET")),
	)

	ctx := context.Background()
	accounts, err := client.Accounts.List(ctx, account.ListParams{})
	if err != nil {
		log.Fatal(err)
	}

	balance, err := client.Balance.Read(ctx, accounts[0].ID)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Balance: %.2f %s\n", float64(balance.Balance)/100, balance.Currency)
}

The OAuth2 flow

Monzo access tokens come from a standard OAuth2 authorization-code flow, with one wrinkle: the token you get back is not usable until the user approves it inside the Monzo app (a push notification + PIN/biometric prompt). Calls fail with 403 until that happens.

import (
	monzo "github.com/iamkanishka/monzo-go"
	"github.com/iamkanishka/monzo-go/application"
	"github.com/iamkanishka/monzo-go/domain/auth"
)

client := monzo.New(monzo.WithClientCredentials(clientID, clientSecret))

// 1. Send the user to Monzo. Persist `state` (e.g. a signed cookie) to verify on callback.
state, _ := application.GenerateState()
url, _ := client.Auth.BuildAuthorizationURL(auth.AuthorizationURLParams{
	ClientID:    clientID,
	RedirectURI: "https://yourapp.com/oauth/callback",
	State:       state,
})
// redirect the user's browser to `url`

// 2. In your callback handler, verify `state` matches (security.ConstantTimeEqual), then:
token, err := client.Auth.ExchangeCode(ctx, auth.ExchangeCodeParams{
	ClientID:     clientID,
	ClientSecret: clientSecret,
	RedirectURI:  "https://yourapp.com/oauth/callback",
	Code:         code,
})

// 3. Tell the user to check their phone and approve access in the Monzo app.

See examples/oauth/main.go for a complete runnable HTTP server.

Automatic token refresh

Construct the client with WithTokens + WithClientCredentials and it will transparently refresh an expired access token on a 401 and retry the original request once - no extra code needed. Use WithTokenRefreshHook to persist rotated tokens (Monzo refresh tokens are single-use):

client := monzo.New(
	monzo.WithTokens(user.AccessToken, user.RefreshToken),
	monzo.WithClientCredentials(clientID, clientSecret),
	monzo.WithTokenRefreshHook(func(accessToken, refreshToken string) {
		db.Users.UpdateTokens(ctx, user.ID, accessToken, refreshToken)
	}),
)

Usage examples

Pots
import "github.com/iamkanishka/monzo-go/domain/pot"

pots, err := client.Pots.List(ctx, accountID)

_, err = client.Pots.Deposit(ctx, pot.DepositParams{
	TransferParams: pot.TransferParams{
		PotID:    pots[0].ID,
		Amount:   5000, // minor units - 5000 = £50.00
		DedupeID: fmt.Sprintf("deposit-%s", orderID), // static across retries of the same transfer
	},
	SourceAccountID: accountID,
})
Transactions
import "github.com/iamkanishka/monzo-go/domain/transaction"

// A single page
txs, err := client.Transactions.List(ctx, transaction.ListParams{AccountID: accountID})

// A specific transaction, with merchant expanded inline
tx, err := client.Transactions.Retrieve(ctx, transaction.RetrieveParams{
	TransactionID: "tx_00008zIcpb1TB4yfVsE6EY",
	Expand:        []transaction.ExpandField{transaction.ExpandMerchant},
})

// Every transaction, transparently paginated
paginator := client.Transactions.ListAll(application.ListAllOptions{AccountID: accountID})
for paginator.Next(ctx) {
	tx := paginator.Item()
	fmt.Println(tx.ID, tx.Amount, tx.Description)
}
if err := paginator.Err(); err != nil {
	log.Fatal(err)
}

// Annotate with custom metadata (empty string deletes a key)
_, err = client.Transactions.Annotate(ctx, transaction.AnnotateParams{
	TransactionID: tx.ID,
	Metadata:      map[string]string{"my_app_category": "business_expense"},
})

Full-history sync window: Monzo only allows fetching a user's complete transaction history during the first 5 minutes after authentication. After that, only the last 90 days are available. If you need full history, run your pagination walk immediately after the OAuth callback completes.

Attachments
import "github.com/iamkanishka/monzo-go/domain/attachment"

upload, err := client.Attachments.RequestUpload(ctx, attachment.UploadParams{
	FileName: "receipt.jpg", FileType: "image/jpeg", ContentLength: int64(len(fileBytes)),
})

err = client.Attachments.UploadBytes(ctx, upload.UploadURL, bytes.NewReader(fileBytes), "image/jpeg", int64(len(fileBytes)))

att, err := client.Attachments.Register(ctx, attachment.RegisterParams{
	ExternalID: tx.ID, FileURL: upload.FileURL, FileType: "image/jpeg",
})
Transaction receipts

Unlike the rest of the API, this endpoint takes a JSON body. ExternalID is your own idempotency key - calling Create again with the same ExternalID updates the receipt.

import "github.com/iamkanishka/monzo-go/domain/receipt"

receiptID, err := client.Receipts.Create(ctx, receipt.Receipt{
	ExternalID:    fmt.Sprintf("order-%s", orderID),
	TransactionID: tx.ID,
	Total:         1299,
	Currency:      "GBP",
	Items: []receipt.Item{
		{SubItem: receipt.SubItem{Description: "Flat White", Quantity: 1, Amount: 1299, Currency: "GBP"}},
	},
})
Webhooks
hook, err := client.Webhooks.Register(ctx, accountID, "https://yourapp.com/hooks/monzo")

Handling an inbound webhook:

import (
	"github.com/iamkanishka/monzo-go/application"
	"github.com/iamkanishka/monzo-go/domain/webhook"
)

http.HandleFunc("/hooks/monzo", func(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(r.Body)

	event, err := application.ParseEvent(body)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	if err := application.AssertExpectedAccount(event, []string{expectedAccountID}); err != nil {
		http.Error(w, err.Error(), http.StatusForbidden)
		return
	}

	if event.Type == webhook.EventTransactionCreated {
		log.Println("new transaction:", event.Data.ID, event.Data.Amount)
	}
	w.WriteHeader(http.StatusOK)
})

On webhook security: as of this writing, Monzo does not document a cryptographic signature (HMAC or similar) on webhook deliveries, so ParseEvent cannot verify authenticity beyond shape-checking the payload. Serve your endpoint over HTTPS, treat the URL as a secret, validate the account id against an allow-list (as above), and re-fetch anything financially sensitive via client.Transactions.Retrieve rather than trusting the webhook body outright.

Error handling

Every error the SDK returns supports errors.Is/errors.As against a small set of sentinels:

import (
	"errors"
	"github.com/iamkanishka/monzo-go/domain/shared"
)

_, err := client.Pots.Withdraw(ctx, params)
if err != nil {
	var apiErr *shared.APIError
	switch {
	case errors.As(err, &apiErr):
		fmt.Println(apiErr.StatusCode, apiErr.Code, apiErr.RequestID)
		switch {
		case errors.Is(err, shared.ErrRateLimited):
			// back off
		case errors.Is(err, shared.ErrForbidden):
			// token hasn't been approved in-app yet, or lacks required scope
		}
	case errors.Is(err, shared.ErrValidation):
		// invalid arguments caught client-side before any request was sent
	}
}

Configuration reference

monzo.New(
	monzo.WithBaseURL(url),                  // default: https://api.monzo.com
	monzo.WithHTTPClient(doer),              // default: http.DefaultClient
	monzo.WithTimeout(d),                    // default: 15s
	monzo.WithRetryPolicy(httpclient.RetryPolicy{MaxRetries: 2, BaseDelay: 250*time.Millisecond, MaxDelay: 4*time.Second}),
	monzo.WithLogger(logger),                // default: no-op; implement httpclient.Logger
	monzo.WithUserAgent(ua),
	monzo.WithTokens(accessToken, refreshToken),
	monzo.WithClientCredentials(clientID, clientSecret),
	monzo.WithTokenRefreshHook(func(access, refresh string) { ... }),
)

Development

make build       # go build ./...
make test        # go test ./...
make test-cover  # go test ./... -cover
make lint        # golangci-lint run ./...
make fmt         # gofmt -w .
make vet         # go vet ./...
make check       # fmt + vet + build + test + lint, in that order

License

MIT

Documentation

Overview

Package monzo is the public entry point for the monzo-go SDK: a complete, production-grade Go client for the Monzo API (https://docs.monzo.com), organised internally with a domain-driven design layering (domain / application / infrastructure).

Construct a Client with New, then call its resource-service fields:

client := monzo.New(
    monzo.WithTokens("access-token", "refresh-token"),
    monzo.WithClientCredentials("client-id", "client-secret"),
)
accounts, err := client.Accounts.List(ctx, account.ListParams{})

Index

Constants

View Source
const DefaultBaseURL = "https://api.monzo.com"

DefaultBaseURL is the production Monzo API base URL.

Variables

This section is empty.

Functions

This section is empty.

Types

type Client

type Client struct {
	Auth         *application.AuthService
	Accounts     *application.AccountService
	Balance      *application.BalanceService
	Pots         *application.PotService
	Transactions *application.TransactionService
	FeedItems    *application.FeedItemService
	Attachments  *application.AttachmentService
	Receipts     *application.ReceiptService
	Webhooks     *application.WebhookService
	// contains filtered or unexported fields
}

Client is the SDK entry point. Every resource is exposed as a field backed by an application-layer service, which in turn talks to Monzo through an infrastructure/rest repository.

func New

func New(opts ...Option) *Client

New constructs a fully-wired Client.

func (*Client) AccessToken

func (c *Client) AccessToken() string

AccessToken returns the access token currently in use, if any.

func (*Client) Refresh

func (c *Client) Refresh(ctx context.Context) (string, error)

Refresh forces a refresh of the access token using the configured refresh token and client credentials. Most callers don't need this - the client refreshes automatically on 401s.

func (*Client) SetTokens

func (c *Client) SetTokens(accessToken, refreshToken string)

SetTokens manually sets new tokens on the client (e.g. after completing the OAuth flow, or restoring persisted tokens for a returning user).

type Option

type Option func(*config)

Option configures a Client constructed via New.

func WithBaseURL

func WithBaseURL(baseURL string) Option

WithBaseURL overrides the API base URL. Defaults to DefaultBaseURL. Useful for testing against a mock server.

func WithClientCredentials

func WithClientCredentials(clientID, clientSecret string) Option

WithClientCredentials sets the OAuth2 client id/secret, required for token exchange, manual refresh, and automatic 401 recovery.

func WithHTTPClient

func WithHTTPClient(doer httpclient.Doer) Option

WithHTTPClient overrides the underlying HTTP doer. Defaults to http.DefaultClient. Accepts any httpclient.Doer (typically *http.Client).

func WithLogger

func WithLogger(logger httpclient.Logger) Option

WithLogger sets a structured logger for debug/warn events. Defaults to a no-op logger.

func WithRetryPolicy

func WithRetryPolicy(policy httpclient.RetryPolicy) Option

WithRetryPolicy overrides the retry policy for transient (429/5xx/network) failures on idempotent requests.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout overrides the per-request timeout. Defaults to 15s.

func WithTokenRefreshHook

func WithTokenRefreshHook(hook application.TokenRefreshHook) Option

WithTokenRefreshHook registers a callback invoked whenever the client automatically refreshes the access token, so you can persist the rotated tokens (Monzo refresh tokens are single-use).

func WithTokens

func WithTokens(accessToken, refreshToken string) Option

WithTokens sets the current user's access token and (optionally) refresh token.

func WithUserAgent

func WithUserAgent(ua string) Option

WithUserAgent overrides the User-Agent header. Defaults to "monzo-go/<version>".

Directories

Path Synopsis
Package application contains use-case orchestration that sits above the domain repositories: client-side validation, pagination helpers, and any logic that doesn't belong in either the domain entities or the transport.
Package application contains use-case orchestration that sits above the domain repositories: client-side validation, pagination helpers, and any logic that doesn't belong in either the domain entities or the transport.
domain
account
Package account models the Accounts bounded context.
Package account models the Accounts bounded context.
attachment
Package attachment models the Attachments bounded context: hosting and linking images/PDFs against transactions.
Package attachment models the Attachments bounded context: hosting and linking images/PDFs against transactions.
auth
Package auth models the OAuth2 authentication bounded context: tokens, the authorization-code flow, and token introspection.
Package auth models the OAuth2 authentication bounded context: tokens, the authorization-code flow, and token introspection.
balance
Package balance models the Balance bounded context.
Package balance models the Balance bounded context.
feeditem
Package feeditem models the Feed Items bounded context.
Package feeditem models the Feed Items bounded context.
pot
Package pot models the Pots bounded context.
Package pot models the Pots bounded context.
receipt
Package receipt models the Transaction Receipts bounded context.
Package receipt models the Transaction Receipts bounded context.
shared
Package shared contains value objects and error types shared across every bounded context in the monzo-go domain layer.
Package shared contains value objects and error types shared across every bounded context in the monzo-go domain layer.
transaction
Package transaction models the Transactions bounded context.
Package transaction models the Transactions bounded context.
webhook
Package webhook models the Webhooks bounded context: registering callback URLs and parsing/validating inbound event deliveries.
Package webhook models the Webhooks bounded context: registering callback URLs and parsing/validating inbound event deliveries.
examples
oauth command
Command oauth demonstrates the full OAuth2 authorization-code flow: a tiny HTTP server that sends the user to Monzo, then handles the callback.
Command oauth demonstrates the full OAuth2 authorization-code flow: a tiny HTTP server that sends the user to Monzo, then handles the callback.
transactions command
Command transactions demonstrates listing accounts, reading a balance, and walking every transaction for an account using the pagination.Paginator.
Command transactions demonstrates listing accounts, reading a balance, and walking every transaction for an account using the pagination.Paginator.
webhook command
Command webhook demonstrates registering a webhook and handling inbound transaction.created deliveries.
Command webhook demonstrates registering a webhook and handling inbound transaction.created deliveries.
infrastructure
httpclient
Package httpclient provides the low-level HTTP transport shared by every infrastructure/rest repository: auth headers, timeouts, retry-with-backoff on transient failures, and a single automatic re-auth-and-retry on 401s.
Package httpclient provides the low-level HTTP transport shared by every infrastructure/rest repository: auth headers, timeouts, retry-with-backoff on transient failures, and a single automatic re-auth-and-retry on 401s.
rest
Package rest implements every domain Repository interface against Monzo's actual HTTP API, using infrastructure/httpclient as the transport.
Package rest implements every domain Repository interface against Monzo's actual HTTP API, using infrastructure/httpclient as the transport.
internal
testutil
Package testutil provides shared test doubles used across the test suite.
Package testutil provides shared test doubles used across the test suite.
pkg
pagination
Package pagination provides a generic, cursor-based paginator for Monzo list endpoints that are ordered oldest-first and support a `since` cursor plus a page `limit`.
Package pagination provides a generic, cursor-based paginator for Monzo list endpoints that are ordered oldest-first and support a `since` cursor plus a page `limit`.
security
Package security provides small, dependency-free cryptographic helpers used by the OAuth2 flow: CSRF state token generation and constant-time comparison.
Package security provides small, dependency-free cryptographic helpers used by the OAuth2 flow: CSRF state token generation and constant-time comparison.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL