A typed TypeScript SDK for the Monzo developer API.
Built against the live API, not just the docs. The official documentation is incomplete in a few areas and this library attempts to fill those gaps (see Notes on the docs).
npm install github:stephendotgg/monzo-api-tsimport { MonzoClient } from 'monzo-api-ts'
const client = new MonzoClient({ accessToken: 'your_access_token' })
// Verify authentication
const identity = await client.auth.whoami()
// List accounts
const accounts = await client.accounts.list()
const [account] = accounts
// Read balance (amounts are in minor units - pence for GBP)
const balance = await client.balance.read(account.id)
console.log(balance.balance) // e.g. 100050 (= £1000.50)
// List recent transactions. The endpoint returns oldest-first, so order: 'desc' is
// what you want for "the last 20 things I spent money on".
const transactions = await client.transactions.list({
account_id: account.id,
limit: 20,
order: 'desc',
})
// Auto-paginate through all transactions
for await (const tx of client.transactions.listAll({ account_id: account.id })) {
console.log(tx.description, tx.amount)
}
// Pots
const pots = await client.pots.list(account.id)
await client.pots.deposit(pots[0].id, {
source_account_id: account.id,
amount: 500,
dedupe_id: crypto.randomUUID(),
})Token exchange and refresh are standalone functions rather than methods on MonzoClient since they run before you have an access token:
import { exchangeToken, refreshToken } from 'monzo-api-ts'
const tokens = await exchangeToken({
client_id: '...',
client_secret: '...',
redirect_uri: 'https://yourapp.com/callback',
code: 'auth_code_from_redirect',
})
const client = new MonzoClient({ accessToken: tokens.access_token })
// Refresh when the token expires. Only confidential clients are issued a refresh
// token, so it is optional on the response.
if (tokens.refresh_token) {
const refreshed = await refreshToken({
client_id: '...',
client_secret: '...',
refresh_token: tokens.refresh_token,
})
}
// Invalidate the token immediately
await client.auth.logout()A confidential client is one that can keep its client secret hidden – a server-side app. Those get a refresh token. Public clients (anything shipping the secret to a user's device) do not, and must send the user back through the authorisation flow when the access token expires, roughly every six hours.
Both helpers throw MonzoApiError on a failed token request, carrying the HTTP status
and the OAuth error / error_description from the response:
import { MonzoApiError } from 'monzo-api-ts'
try {
await refreshToken({ client_id: '...', client_secret: '...', refresh_token: '...' })
} catch (error) {
if (error instanceof MonzoApiError && error.code === 'invalid_grant') {
// Refresh token already used or revoked - the user has to re-authenticate.
}
}const client = new MonzoClient({
accessToken: 'your_access_token',
timeout: 10_000, // request timeout in ms (default: 30000)
maxRetries: 3, // retries on 429s and transport failures (default: 2)
retryDelay: 250, // base backoff in ms for transport retries, doubled per attempt
})Two things are retried, with different waits:
- 429 responses – the wait comes from the
Retry-Afterheader on the response. - Transport failures – a timeout, or
fetchrejecting outright. The wait isretryDelay × 2 ^ attempt, so 250ms then 500ms by default.
Transport retries apply to GET, PUT and DELETE only. A connection that never
returned a response may still have reached Monzo and been acted on, so POST and
PATCH – creating a feed item, annotating a transaction – fail on the first attempt
rather than risk doing the thing twice. Set maxRetries: 0 to opt out entirely.
HTTP errors other than 429 are never retried; a 500 throws MonzoApiError straight
away.
Two error types, split by whether the API answered at all.
MonzoApiError carries an HTTP status, the API's error code and message, plus
isUnauthorized / isForbidden / isNotFound / isRateLimited.
MonzoConnectionError is thrown when no response arrived: the client-side timeout
fired, or fetch rejected (DNS, TLS, connection reset, offline). Its reason is
'timeout' or 'network', isTimeout is the shorthand, and cause holds the
underlying error.
import { MonzoApiError, MonzoConnectionError } from 'monzo-api-ts'
try {
await client.transactions.list({ account_id: accountId })
} catch (error) {
if (error instanceof MonzoConnectionError) {
// Never reached Monzo - safe to retry.
} else if (error instanceof MonzoApiError && error.isForbidden) {
// 403: usually the 90-day transaction window, or a missing approval in the app.
}
}import type { WebhookEvent } from 'monzo-api-ts'
const webhook = await client.webhooks.create(account.id, 'https://yourapp.com/webhook')
function handleWebhook(event: WebhookEvent) {
if (event.type === 'transaction.created') {
console.log(event.data.description, event.data.amount)
}
}All monetary values in the Monzo API are integers in minor units (pence for GBP, cents for EUR/USD). Two helpers are exported to avoid converting this everywhere:
import { toMajorUnits, formatAmount } from 'monzo-api-ts'
toMajorUnits(1050) // 10.5
formatAmount(1050, 'GBP') // '£10.50'
formatAmount(-350, 'GBP') // '-£3.50'How many minor units make a major one depends on the currency, so both helpers take it into account: 2 decimals for GBP, 0 for JPY and KRW, 3 for KWD and BHD. This matters for local_amount/local_currency on foreign transactions – dividing yen by 100 puts a Tokyo coffee at 1% of its price.
toMajorUnits(1050, 'JPY') // 1050, not 10.5
formatAmount(1050, 'JPY') // 'JP¥1,050'
formatAmount(1050, 'KWD') // 'KWD 1.050'toMajorUnits defaults to GBP when no currency is given. formatAmount takes an optional third argument for the locale, defaulting to en-GB.
| Resource | Methods |
|---|---|
auth |
whoami(), logout() |
accounts |
list() |
balance |
read(accountId) |
transactions |
list(), get(), annotate(), listAll() |
pots |
list(), deposit(), withdraw() |
webhooks |
list(), create(), delete() |
feed |
create() |
attachments |
upload(), register(), deregister() |
receipts |
save(), get(), delete() |
The official Monzo developer documentation is fairly sparse and has not kept up with the API in a few places. These are the gaps found while building and testing this SDK against a real account:
Account types
The docs list three account types: uk_retail, uk_retail_joint, and uk_monzo_flex. The live API also returns uk_rewards (Monzo Rewards) and uk_business (Monzo Business), neither of which are documented. This SDK accepts any string for the type field and exposes the known values as a TypeScript union.
Transaction fields
The docs imply all transaction fields are always present. In practice, several are absent depending on the account type and transaction state:
account_balanceis not returned for certain account types (Flex, Business)settled,local_amount, andlocal_currencyare absent on pending transactionsis_load,updated, andmetadataare occasionally missing
All of these are typed as optional in this library.
Transaction access window
The docs mention that after 5 minutes of authentication, only the last 90 days of transactions are accessible. In practice this is enforced, but the error message from the API is a generic 403 rather than anything descriptive.
Pagination
The docs describe since/before pagination for transactions but give no indication of what the default or maximum limit value is. Testing shows the default is 30 and the maximum is 100 per request.
They also omit the ordering, which decides how you page. Transactions come back oldest-first, since is an exclusive lower bound accepting either a timestamp or a transaction id, and before is an exclusive upper bound. Only since advances a cursor: paging on before re-reads the oldest page forever and never reaches recent transactions. The listAll() method walks forward with since set to the id of the last transaction of each page, so it handles this automatically.
An undocumented order parameter accepts asc (the default) and desc. list() passes it through, which turns "the most recent N transactions" into one request instead of a walk through the entire history. listAll() excludes it at the type level: its cursor only advances under the oldest-first ordering, and a desc walk would re-read the newest page forever.
MIT