monzo-sdk
v1.0.0
Published
A complete, type-safe, production-grade Node.js/TypeScript SDK for the Monzo API (OAuth2, Accounts, Balance, Pots, Transactions, Feed Items, Attachments, Receipts, Webhooks).
Maintainers
Readme
monzo-sdk
A complete, type-safe, production-grade Node.js/TypeScript SDK for the Monzo API.
- Full API coverage — OAuth2, Accounts, Balance, Pots, Transactions, Feed Items, Attachments, Transaction Receipts, Webhooks
- Strict TypeScript — every request and response is fully typed, built with
exactOptionalPropertyTypesandstrictmode - Dual ESM/CJS — works in Node.js
require(), ESMimport, and modern bundlers - Resilient by default — automatic exponential-backoff retries on transient errors, automatic token refresh on
401s - Zero runtime dependencies — built entirely on the standard
fetchAPI
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. This SDK doesn't change that policy; it's just a client for it.
Install
npm install monzo-sdkRequires Node.js >= 18.17 (for global fetch/crypto.getRandomValues), or any modern browser/edge runtime.
Quickstart
import { MonzoClient } from "monzo-sdk";
const client = new MonzoClient({
accessToken: process.env.MONZO_ACCESS_TOKEN,
refreshToken: process.env.MONZO_REFRESH_TOKEN, // optional, enables auto-refresh
clientId: process.env.MONZO_CLIENT_ID,
clientSecret: process.env.MONZO_CLIENT_SECRET,
});
const accounts = await client.accounts.list();
const balance = await client.balance.read({ account_id: accounts[0].id });
console.log(`Balance: ${balance.balance / 100} ${balance.currency}`);The OAuth2 flow
Monzo access tokens are obtained via a standard OAuth2 authorization-code flow, with one wrinkle:
the token you get back from the code exchange is not usable until the user approves it inside
the Monzo app (a push notification + PIN/biometric prompt). Calls will fail with 403 until
that happens.
import { MonzoClient, generateStateToken } from "monzo-sdk";
const client = new MonzoClient();
// 1. Send the user to Monzo to authorize your app.
// Persist `state` (e.g. in a signed cookie/session) to verify on callback.
const state = generateStateToken();
const url = client.auth.buildAuthorizationUrl({
clientId: process.env.MONZO_CLIENT_ID!,
redirectUri: "https://yourapp.com/oauth/callback",
state,
});
// redirect the user's browser to `url`
// 2. In your callback handler, verify `state` matches, then exchange the code.
const tokens = await client.auth.exchangeAuthorizationCode({
clientId: process.env.MONZO_CLIENT_ID!,
clientSecret: process.env.MONZO_CLIENT_SECRET!,
redirectUri: "https://yourapp.com/oauth/callback",
code: req.query.code as string,
});
client.setTokens({ accessToken: tokens.access_token, refreshToken: tokens.refresh_token });
// 3. Tell the user to check their phone and approve access in the Monzo app
// before making further calls.Automatic token refresh
If you construct the client with refreshToken, clientId, and clientSecret, it will
transparently refresh an expired access token on a 401 and retry the original request once —
no extra code needed. Use onTokenRefresh to persist the rotated tokens (Monzo refresh tokens
are single-use):
const client = new MonzoClient({
accessToken: user.accessToken,
refreshToken: user.refreshToken,
clientId: process.env.MONZO_CLIENT_ID!,
clientSecret: process.env.MONZO_CLIENT_SECRET!,
onTokenRefresh: async (tokens) => {
await db.users.update(user.id, tokens);
},
});Usage examples
Accounts & balance
const accounts = await client.accounts.list({ account_type: "uk_retail" });
const balance = await client.balance.read({ account_id: accounts[0].id });Pots
const pots = await client.pots.list({ current_account_id: accountId });
await client.pots.deposit({
potId: pots[0].id,
sourceAccountId: accountId,
amount: 5000, // minor units — 5000 = £50.00
dedupeId: `deposit-${orderId}`, // keep static across retries of the same logical transfer
});
await client.pots.withdraw({
potId: pots[0].id,
destinationAccountId: accountId,
amount: 2500,
dedupeId: `withdraw-${orderId}`,
});Transactions
// A single page
const transactions = await client.transactions.list({ account_id: accountId, limit: 50 });
// A specific transaction, with merchant details expanded inline
const tx = await client.transactions.retrieve({ transactionId: "tx_00008zIcpb1TB4yfVsE6EY", expand: ["merchant"] });
// Every transaction, transparently paginated (async iterator)
for await (const tx of client.transactions.listAll({ account_id: accountId })) {
console.log(tx.id, tx.amount, tx.description);
}
// Annotate with custom metadata (empty string deletes a key)
await client.transactions.annotate({
transactionId: 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
listAll()backfill immediately after the OAuth callback completes.
Feed items
await client.feedItems.create({
accountId,
type: "basic",
params: {
title: "Order shipped!",
image_url: "https://example.com/icon.png",
body: "Your order #1234 is on its way.",
},
url: "https://example.com/orders/1234",
});Attachments
const { upload_url, file_url } = await client.attachments.upload({
fileName: "receipt.jpg",
fileType: "image/jpeg",
contentLength: fileBuffer.byteLength,
});
// Uploads directly to Monzo's storage (not the Monzo API itself)
await client.attachments.uploadFileToUrl(upload_url, fileBuffer, "image/jpeg");
const attachment = await client.attachments.register({
externalId: tx.id,
fileUrl: file_url,
fileType: "image/jpeg",
});
// Later, if needed:
await client.attachments.deregister({ id: attachment.id });Transaction receipts
Unlike the rest of the API, this endpoint takes a JSON body. external_id is your own
idempotency key — calling create again with the same external_id updates the receipt.
await client.receipts.create({
external_id: `order-${orderId}`,
transaction_id: tx.id,
total: 1299,
currency: "GBP",
items: [{ description: "Flat White", quantity: 1, amount: 1299, currency: "GBP" }],
});
const receipt = await client.receipts.retrieve({ externalId: `order-${orderId}` });
await client.receipts.delete({ externalId: `order-${orderId}` });Webhooks
const webhook = await client.webhooks.register({ accountId, url: "https://yourapp.com/hooks/monzo" });
const webhooks = await client.webhooks.list({ accountId });
await client.webhooks.delete({ webhookId: webhook.id });Handling an inbound webhook (e.g. in an Express/Fastify route):
import { parseWebhookEvent, assertExpectedAccount } from "monzo-sdk";
app.post("/hooks/monzo", express.text({ type: "*/*" }), (req, res) => {
const event = parseWebhookEvent(req.body);
assertExpectedAccount(event, [expectedAccountId]);
if (event.type === "transaction.created") {
console.log("New transaction:", event.data.id, event.data.amount);
}
res.sendStatus(200);
});On webhook security: as of this writing, Monzo does not document a cryptographic signature (HMAC or similar) on webhook deliveries, so
parseWebhookEventcannot verify authenticity beyond shape-checking the payload. Serve your webhook endpoint over HTTPS, treat the URL itself as a secret, validate the account id against an allowlist (as above), and re-fetch anything financially sensitive viaclient.transactions.retrieve(...)rather than trusting the webhook body outright.
Error handling
Every error thrown by the SDK extends MonzoError, so you can catch broadly or narrowly:
import { MonzoApiError, MonzoTimeoutError, MonzoNetworkError, MonzoError } from "monzo-sdk";
try {
await client.pots.withdraw({ potId, destinationAccountId, amount, dedupeId });
} catch (err) {
if (err instanceof MonzoApiError) {
if (err.isRateLimited) {
// back off
} else if (err.isForbidden) {
// token hasn't been approved in-app yet, or lacks required scope
}
console.error(err.status, err.body, err.requestId);
} else if (err instanceof MonzoTimeoutError || err instanceof MonzoNetworkError) {
// transient — the SDK already retried idempotent requests automatically
} else if (err instanceof MonzoError) {
// MonzoValidationError, MonzoWebhookVerificationError, etc.
}
throw err;
}Configuration reference
new MonzoClient({
accessToken: string, // current user access token
refreshToken: string, // enables auto-refresh, together with clientId/clientSecret
clientId: string,
clientSecret: string,
baseUrl: string, // default: https://api.monzo.com — override for testing
fetch: typeof fetch, // default: global fetch — override for testing/custom runtimes
timeoutMs: number, // default: 15000
retry: { // default: { maxRetries: 2, baseDelayMs: 250, maxDelayMs: 4000 }
maxRetries: number,
baseDelayMs: number,
maxDelayMs: number,
},
logger: { // default: no-op
debug(message, meta?), warn(message, meta?), error(message, meta?)
},
userAgent: string,
onTokenRefresh: (tokens) => void | Promise<void>,
});Development
npm install
npm run build # tsup — dual ESM/CJS + .d.ts
npm test # vitest run (built on Vite)
npm run test:coverage
npm run lint
npm run typecheckLicense
MIT
