Monzo

Hex.pm

A complete, production-grade Elixir client for the Monzo API.

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.

Installation

def deps do
[
{:monzo, "~> 1.0"}
]
end

Requires Elixir ~> 1.14. No other dependencies required.

Quickstart

client = Monzo.Client.new(
access_token: System.fetch_env!("MONZO_ACCESS_TOKEN"),
refresh_token: System.fetch_env!("MONZO_REFRESH_TOKEN"),
client_id: System.fetch_env!("MONZO_CLIENT_ID"),
client_secret: System.fetch_env!("MONZO_CLIENT_SECRET")
)
{:ok, accounts} = Monzo.Accounts.list(client)
{:ok, balance} = Monzo.Balances.read(client, hd(accounts).id)
IO.puts("Balance: #{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.

client = Monzo.Client.new(client_id: client_id, client_secret: client_secret)
# 1. Send the user to Monzo. Persist `state` (e.g. a signed session) to verify on callback.
state = Monzo.Security.generate_state()
{:ok, url} = Monzo.Auth.build_authorization_url(%{
client_id: client_id,
redirect_uri: "https://yourapp.com/oauth/callback",
state: state
})
# redirect the user's browser to `url`
# 2. In your callback handler, verify `state` matches (Monzo.Security.constant_time_equal?/2), then:
{:ok, token} = Monzo.Auth.exchange_code(client, %{
client_id: client_id,
client_secret: client_secret,
redirect_uri: "https://yourapp.com/oauth/callback",
code: code
})
Monzo.Client.set_tokens(client, token.access_token, token.refresh_token)
# 3. Tell the user to check their phone and approve access in the Monzo app.

Automatic token refresh

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

client = Monzo.Client.new(
access_token: user.access_token,
refresh_token: user.refresh_token,
client_id: client_id,
client_secret: client_secret,
on_refresh: fn access_token, refresh_token ->
MyApp.Users.update_tokens(user.id, access_token, refresh_token)
end
)

Usage examples

Pots

{:ok, pots} = Monzo.Pots.list(client, account_id)
{:ok, _pot} = Monzo.Pots.deposit(client, %{
pot_id: hd(pots).id,
amount: 5_000, # minor units - 5000 = £50.00
dedupe_id: "deposit-#{order_id}", # keep static across retries of the same logical transfer
source_account_id: account_id
})

Transactions

# A single page
{:ok, transactions} = Monzo.Transactions.list(client, %{account_id: account_id, limit: 50})
# A specific transaction, with merchant expanded inline
{:ok, tx} = Monzo.Transactions.retrieve(client, %{transaction_id: "tx_00008zIcpb1TB4yfVsE6EY", expand: [:merchant]})
# Every transaction, lazily paginated as a Stream
client
|> Monzo.Transactions.stream(%{account_id: account_id})
|> Enum.each(fn tx -> IO.puts("#{tx.created} #{tx.amount} #{tx.description}") end)
# Or lazily take just the first 10:
first_ten = client |> Monzo.Transactions.stream(%{account_id: account_id}) |> Enum.take(10)
# Annotate with custom metadata (empty string deletes a key)
{:ok, tx} = Monzo.Transactions.annotate(client, %{
transaction_id: tx.id,
metadata: %{"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 stream/2 backfill immediately after the OAuth callback completes.

Attachments

{:ok, %{upload_url: upload_url, file_url: file_url}} = Monzo.Attachments.request_upload(client, %{
file_name: "receipt.jpg", file_type: "image/jpeg", content_length: byte_size(file_bytes)
})
:ok = Monzo.Attachments.upload_bytes(client, upload_url, file_bytes, "image/jpeg")
{:ok, attachment} = Monzo.Attachments.register(client, %{
external_id: tx.id, file_url: file_url, file_type: "image/jpeg"
})

Transaction receipts

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

{:ok, receipt_id} = Monzo.Receipts.create(client, %Monzo.Receipt{
external_id: "order-#{order_id}",
transaction_id: tx.id,
total: 1299,
currency: "GBP",
items: [%Monzo.Receipt.Item{description: "Flat White", quantity: 1, amount: 1299, currency: "GBP"}]
})

Webhooks

{:ok, webhook} = Monzo.Webhooks.register(client, account_id, "https://yourapp.com/hooks/monzo")

Handling an inbound webhook (e.g. in a Phoenix controller):

def handle_webhook(conn, _params) do
{:ok, raw_body, conn} = Plug.Conn.read_body(conn)
with {:ok, event} <- Monzo.Webhooks.parse_event(raw_body),
:ok <- Monzo.Webhooks.assert_expected_account(event, [expected_account_id]) do
if event.type == "transaction.created" do
Logger.info("New transaction: #{event.data.id} #{event.data.amount}")
end
send_resp(conn, 200, "")
else
{:error, _reason} -> send_resp(conn, 400, "")
end
end

On webhook security: as of this writing, Monzo does not document a cryptographic signature (HMAC or similar) on webhook deliveries, so parse_event/1 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 Monzo.Transactions.retrieve/2 rather than trusting the webhook body outright.

Error handling

Every error the SDK returns is a proper Exception struct, returned as {:error, exception} (functions never raise for expected API/validation failures - only for genuine bugs):

case Monzo.Pots.withdraw(client, params) do
{:ok, pot} ->
pot
{:error, %Monzo.Error.APIError{} = error} ->
cond do
Monzo.Error.APIError.rate_limited?(error) -> :backoff
Monzo.Error.APIError.forbidden?(error) -> :token_not_yet_approved
true -> Logger.error("Monzo API error: #{Exception.message(error)}")
end
{:error, %Monzo.Error.ValidationError{} = error} ->
# invalid arguments caught client-side before any request was sent
Logger.error(Exception.message(error))
{:error, %Monzo.Error.TimeoutError{}} ->
:timed_out
end

Configuration reference

Monzo.Client.new(
base_url: url, # default: "https://api.monzo.com"
adapter: module_or_tuple, # default: Monzo.HTTP.HttpcAdapter
timeout_ms: ms, # default: 15_000
retry: %{max_retries: 2, base_delay_ms: 250, max_delay_ms: 4_000},
logger: fn level, message -> ... end,
user_agent: "my-app/1.0",
access_token: token,
refresh_token: token,
client_id: id,
client_secret: secret,
on_refresh: fn access, refresh -> ... end,
token_store: pid_or_name # reuse an already-running Monzo.TokenStore
)

Custom HTTP adapters

Monzo.HTTP.HttpcAdapter (built on :httpc) is the default and requires no extra dependencies. To use a connection-pooled client like Finch instead, implement the Monzo.HTTP.Adapter behaviour and pass it (optionally as {MyAdapter, opts} if it needs configuration) via :adapter.

Development

mix deps.get # fetches ex_doc/dialyxir/credo/excoveralls (dev/test only, not required at runtime)
mix compile
mix test
mix format --check-formatted
mix credo --strict
mix dialyzer

This package was originally built in a network-sandboxed environment without access to repo.hex.pm, so its dev-only tooling deps are commented out in mix.exs by default - on a machine with normal Hex access, uncomment them and run mix deps.get.

License

MIT