The v2 API was built with direct input from the Lunch Money developer community. Features and design decisions were shaped by feedback including:
This guide covers what changed from v1 to v2 and how to update your integration. For a general introduction to v2 concepts — authentication, response shapes, naming conventions, error handling, and hydration — see the v2 API Overview.
Before you begin:
The base URL for all API requests changes from:
https://api.lunchmoney.dev/v1/
to:
https://api.lunchmoney.dev/v2
200 OK even when the request fails — the error is in the response body. Update your error handling to check HTTP status codes instead of inspecting 2XX response bodies.The v2 API returns consistent 4XX status codes for errors and validates all requests strictly, including query parameters and request body properties that v1 would silently ignore. See Error Responses in the v2 API Overview for the full status code table and error body format.
Two status code changes affect all endpoints:
201 Created (was 200 OK)204 No Content with no response body (was 200 OK with true)See Response Shapes in the v2 API Overview for the complete table.
The v2 PUT pattern (GET → modify → PUT the full object) and all other PUT rules are covered in Updating Objects in the v2 API Overview.
Property names are standardized across all v2 objects. The specific renames are listed in the endpoint sections below. See Naming Conventions in the v2 API Overview for the general rules.
One change worth noting: all id fields are now explicitly integer type (not number). This should not affect most apps.
{
"user_id": 123,
"user_name": "John Doe",
"user_email": "john@example.com"
}
{
"id": 123,
"name": "John Doe",
"email": "john@example.com"
}
debits_as_negative: A user preference shown in the Lunch Money app. This no longer changes how transaction amounts are represented in the v2 API, but apps may use this setting to display amounts and balances in the way the user prefers. See the Amounts & Balances guide for details.Migration Tips:
user_id, user_name, and user_email in your code to use the new property names.debits_as_negative setting to interpret v2 transaction amounts. In the v2 API, positive amount values are always debits and negative amount values are always credits.v1 had separate endpoints for categories and category groups. v2 uses a single endpoint:
POST /v1/categories
{ "name": "Groceries", ... }
POST /v1/categories/group
{ "name": "Food & Drink", "category_ids": [...], ... }
// Regular category
POST /v2/categories
{ "name": "Groceries", ... }
// Category group
POST /v2/categories
{ "name": "Food & Drink", "is_group": true, "children": [...] }
v1 had a unique endpoint for adding children to an existing category group.
v2 allows children to be modified via the PUT /categories endpoint.
POST /v1/categories/group/:group_id/add
{ "category_ids": [1, 2, 3] }
PUT /v2/categories/:id
{ "children": [1, 2, 3] } // Replaces all children
When attempting to delete a category, both the v1 and v2 APIs will return a dependents object if other Lunch Money elements (ie: transactions, rules, etc) depend on the category. Both versions also provide a way to force delete the category when this happens but the syntax is slightly different:
DELETE /v1/categories/:id/force
DELETE /v2/categories/:id?force=true
POST /categories now returns the complete category object{ "category_id": 123 })PUT /categories/:id returns the complete updated category object (not just true)DELETE /categories/:id returns 204 No Content (not true)children property of a category group is never null - it's an empty array [] for groups with no childrenchildren property in (non group) categoriesgroup_category_name has been removedgroup_name is now present on all grouped categories (not just in certain responses)collapsed is now present on all category objects. When set to true on a category group it will appear collapsed in the Web UI.View v1/v2 differences
View v2 Category Object Docs
Categories in the v2 nested response are ordered by the order property. When order is not set on a category, it falls back to alphabetical ordering.
GET /v1/categories?format=nested // or "flattened" - default: flattened
GET /v2/categories // Default: nested ordered list
GET /v2/categories?format=flattened // All categories including duplicates
GET /v2/categories?is_group=false // Only assignable categories (no groups)
GET /v2/categories?is_group=true // Only category groups
Migration:
format query parameter to GET /v2/categories requests as needed, the default has changed is_group propertyPUT with children arraychildren === null to check for empty array insteadPOST and PUT requests.The tag object now includes:
created_atupdated_atarchived_atView v1/v2 differences
View v2 Tag Object Docs
v2 adds full CRUD operations for tags:
// Get single tag
GET /v2/tags/:id
// Create tag
POST /v2/tags
{ "name": "Work Expense", ... }
// Update tag
PUT /v2/tags/:id
{ "name": "Business Expense", ... }
// Delete tag
DELETE /v2/tags/:id
GET /tags now returns tags in alphabetical order (matching the GUI).
Migration: Take advantage of the new tag management endpoints if you need to create or modify tags programmatically.
The transaction object has changed significantly in v2. A transaction has properties that reference other objects in Lunch Money such as categories, accounts, and tags. In v1 certain details for these related objects were "hydrated". In the context of RESTful APIs, "hydration" generally refers to populating an object with all its data. In the Lunch Money APIs, hydration generally relates to providing some details of associated objects that are also referred to by their IDs in the response body.
{
"id": 123,
"category_id": 456,
"category_name": "Groceries", // ❌ Not in v2
"category_group_id": 789, // ❌ Not in v2
"category_group_name": "Food", // ❌ Not in v2
"is_income": false, // ❌ Not in v2
"tags": [ // ❌ Not in v2
{ "id": 1, "name": "Work" }
],
"asset_name": "Checking", // ❌ Not in v2
"plaid_account_name": "...", // ❌ Not in v2
...
}
{
"id": 123,
"category_id": 456, // ✅ Still here
"tag_ids": [1, 2], // ✅ Array of IDs
"manual_account_id": 789, // ✅ Renamed from asset_id
"plaid_account_id": 123, // ✅ Still here
...
}
To get full details, fetch the related objects:
// Get category details
GET /v2/categories/456
// Get account details
GET /v2/manual_accounts/789
// OR
GET /v2/plaid_accounts/123
// Get tag details
GET /v2/tags/1
Migration Strategy:
The following properties found in the hydrated v1 Transaction Object are no longer present in the v2 Transaction object
These category-related properties are no longer included (get from category object):
category_namecategory_group_idcategory_group_nameis_incomeexclude_from_budgetexclude_from_totalsgroup_category_nameThese account-related properties are no longer included (get from account object):
asset_institution_nameasset_nameasset_display_nameasset_statusplaid_account_nameplaid_account_maskinstitution_nameplaid_account_display_nameThe following properties have been renamed or refactored in the v2 Transaction object
asset_id → manual_account_id tags (array of objects) → tag_ids (array of tag id integers)has_children → is_split_parentparent_id → split_parent_idis_group → is_group_parentgroup_id → group_parent_iddisplay_name → removed (use payee directly)display_notes → removed (use notes directly)These recurring-related properties have changed:
recurring_payee, recurring_description, etc. are now in an overrides objectrecurring_id and fetch from /v2/recurring/:id for detailsView v1/v2 differences
View v2 Transaction Object Docs
The debit_as_negative property has been removed as an optional query parameter and request body property in the /transactions endpoints. In the v2 API, positive amount values always indicate debit transactions and negative amount values always indicate credit transactions, regardless of the user's debits_as_negative setting in the Lunch Money app.
GET /v1/transactions?debit_as_negative=false // Query parameter
GET /v2/transactions
// Positive amount values indicate debit transactions
// Negative amount values indicate credit transactions
debit_as_negative query parameter could change how transaction amounts were represented. In the v2 API, transaction amount signs are fixed: positive values are debits and negative values are credits. Existing applications that assume positive values are credits need to be updated.See the Amounts & Balances guide for the full sign convention and how to_base works for multi-currency accounts.
debit_as_negative query parameters and update your code to interpret positive transaction amounts as debits and negative amounts as credits."status": "cleared" | "uncleared" | "pending" | "recurring" | "recurring_suggested"
"status": "reviewed" | "unreviewed" | "deleted_pending"
Migration: Update status checks:
"cleared" → "reviewed""uncleared" → "unreviewed"is_pending: true always have status "unreviewed"status is pending to use is_pending insteadSplit Transactions:
is_parent: true) are not returned by default in GET /transactionssplit_parent_id pointing to the parentGET /transactions/:id to get parent with full children arrayGET /transactions with query param include_split_parents=true to include parents in listGrouped Transactions:
is_group: true) is returnedGET /transactions/:id to get details of grouped transactions in the transaction group's children array.Migration: Update code that processes split or grouped transactions to use the details endpoint when info is needed about split parent transactions, or transactions that are now part of a grouped transaction.
GET /v1/transactions?pending=true&debit_as_negative=false
GET /v2/transactions?include_pending=true
// debit_as_negative removed
// Positive amount values indicate debit transactions
// Negative amount values indicate credit transactions
// New optional parameters:
// ?include_metadata=true // Include plaid_metadata, custom_metadata
// ?include_files=true // Include file attachments
// ?include_children=true // Include children for split/grouped
// ?include_split_parents=true // Include parent transactions
// ?created_since=2024-01-01 // Filter by creation timestamp (date or datetime)
// ?updated_since=2024-01-01T12:00:00Z // Filter by update timestamp (date or datetime)
Migration: Update query parameter names on GET /transaction
debit_as_negativeinclude_XXX params to get additional info in the returned objectscreated_since and updated_since to filter transactions by creation or update timestamps (accepts date YYYY-MM-DD or ISO 8601 datetime format)Request body changes:
POST /v1/transactions
{
"transactions": [...],
"debit_as_negative": false, // ❌ Removed in v2
...
}
POST /v2/transactions
{
"transactions": [
{
"asset_id": 123, // ❌ Renamed
"manual_account_id": 123, // ✅ New name
"tags": ["Work"], // ❌ Can't create tags on the fly
"tag_ids": [1, 2] // ✅ Must use existing tag IDs
}
]
}
Response body changes (specific to duplicate detection):
{
"ids": [new_transaction1.id, ...],
}
{
"error": [
"Key (user_external_id, asset_id, account_id)=(123457891, 178181, 66938) already exists."
]
}
// New transactions ARE inserted, even if some are duplicates
{
"transactions": [
{
"id": 8
// rest of full transaction object
},
{
"id": 9,
// rest of full transaction object
}
],
"skipped_duplicates": [
{
"reason": "duplicate_external_id",
"request_transactions_index": 1,
"existing_transaction_id": 2, // id of the existing match
"request_transaction": { // from the request body
"date": "2025-06-20",
"amount": "250.0000",
"payee": "Fidelity",
"manual_account_id": 1,
"external_id": "12345",
}
}
]
}
asset_id → manual_account_idtags array (could include strings to create tags) → tag_ids array (only existing IDs)plaid_account_id can now be set when creating transactions (if account allows modifications)debit_as_negative removed - positive amounts are debits and negative amounts are creditsexternal_id conflict, or matching date, amount and account when skip_duplicates: true is in the request body), no longer cause the entire request to fail. Non-duplicates are inserted, and duplicates are reporteddebits_as_negative property always use positive numbers for debits and negative numbers for credits.POST /tags before assigning themskipped_duplicatesPUT /v1/transactions/:id
{
"transaction": { ... },
"split": [...], // ❌ Moved to separate endpoint
"debit_as_negative": false, // ❌ Removed
"skip_balance_update": true // ❌ Renamed
}
PUT /v2/transactions/:id?update_balance=false // Query parameter
{
// Just the transaction properties to update OR
// A full transaction object with at least one updatable property modified
}
PUT /v2/transactions
{
"transactions": [...]
// Array of full or partial transaction objects to update
// Each object MUST include the id of the existing transaction to update
// The rest of the object should be at least one property to update
}
Migration:
These endpoints have been modified to follow the standard pattern of using POST to create something and PUT to modify something, and DELETE to remove something.
Splitting Transactions:
PUT /v1/transactions/:id
{ "split": [...] }
POST /v2/transactions/split/:id
{ "child_transactions": [
// objects with at least "amount" and "payee" fields
] }
POST /transactions/split/{id} now support tag_ids.Unsplit Transactions:
POST /v1/transactions/unsplit
{ "parent_ids": [...] }
DELETE /v2/transactions/split/:parent_id
View v2 Split Transaction Endpoint Docs
Grouping Transactions:
POST /v1/transactions/group
{
"transactions": [id1, id2, ...],
// other properties, ie: date, payee
}
POST /v2/transactions/group
{
"ids": [id1, id2,...]
// other properties remain the same
}
Ungroup Transactions:
DELETE /v1/transactions/group/:id
// Returns 200 with a transactions array of ids that were ungrouped
DELETE /v2/transactions/group/:id
// Returns 204 No Content
View v2 Group Transactions Endpoint Docs
Important Changes:
ids property replaces transactions on the POST /transactions/group endpointMigration:
// No delete endpoint available
DELETE /v2/transactions/:id // Single transaction
DELETE /v2/transactions // Bulk delete
{ "ids": [1, 2, 3] }
View v2 Delete Transactions Endpoint Docs
View v2 Bulk Delete Transactions Endpoint Docs
Migration: Update code that may have been working around the lack of delete endpoints.
v2 introduces the ability to view, create and delete file attachments to transactions
// Upload attachment
POST /v2/transactions/:transaction_id/attachments
// multipart/form-data with file and optional notes
// Get attachment details
GET /v2/transactions/attachments/:file_id
// Get download URL
GET /v2/transactions/attachments/:file_id/url
// Delete attachment
DELETE /v2/transactions/attachments/:file_id
View v2 Transaction Attachment Endpoint Docs
A new `GET v2/recurring_items/{id} endpoint to get a single recurring item has been added.
View v2 Recurring Endpoint Docs
transaction_criteria (scheduling and amount details), overrides (per-occurrence customizations), and matches (query results). Previously these were all flat top-level properties.{
"id": 123,
"start_date": "2024-01-01",
"end_date": null,
"billing_date": "2024-01-15",
"payee": "Netflix",
"amount": "15.99",
"category_id": 456,
"category_group_id": 789, // ❌ Removed
"is_income": false, // ❌ Removed (get from category)
"exclude_from_totals": false, // ❌ Removed (get from category)
"granularity": "month",
"quantity": 1,
"occurrences": { ... },
"transactions_within_range": [...],
"missing_dates_within_range": [...],
"date": "2024-06-04"
}
{
"id": 123,
"description": "Netflix subscription",
"source": "manual",
"status": "reviewed", // ✅ New: "reviewed" or "suggested"
"transaction_criteria": {
"start_date": "2024-01-01", // ✅ Moved here
"end_date": null, // ✅ Moved here
"anchor_date": "2024-01-15", // ✅ Renamed from billing_date
"payee": "Netflix", // ✅ Moved here
"amount": "15.99", // ✅ Moved here
"currency": "usd",
"granularity": "month", // ✅ Moved here
"quantity": 1, // ✅ Moved here
"manual_account_id": 123,
"plaid_account_id": null
},
"overrides": {
"payee": "Netflix Subscription", // ✅ Moved here
"notes": "Monthly subscription",
"category_id": 456 // ✅ Moved here
},
"matches": {
"request_start_date": "2024-06-01", // ✅ Renamed from date
"request_end_date": "2024-06-30", // ✅ New
"expected_occurrence_dates": [...], // ✅ Renamed from occurrences
"found_transactions": [ // ✅ Renamed from transactions_within_range
{ "date": "2024-06-15", "transaction_id": 789 }
],
"missing_transaction_dates": [...] // ✅ Renamed from missing_dates_within_range
}
}
View v1/v2 Recurring Object differences
View v2 Recurring Object Docs
GET /v1/recurring_items?start_date=2024-06-01
GET /v2/recurring?start_date=2024-06-01&end_date=2024-06-30 // Both required if using dates
GET /v2/recurring?include_suggested=true // Include suggested recurring items
Migration:
billing_date → transaction_criteria.anchor_date)start_date and end_date when specifying a rangeThe /assets endpoint was renamed to /manual_accounts to align with language used in the web client.
GET /v1/assets
POST /v1/assets
PUT /v1/assets/:id
GET /v2/manual_accounts
POST /v2/manual_accounts
PUT /v2/manual_accounts/:id
GET /v2/manual_accounts/:id // ✅ New: Get single account
DELETE /v2/manual_accounts/:id // ✅ New: Delete account
View v2 Manual Accounts Endpoint Docs
// v1 → v2
"type_name" → "type"
"subtype_name" → "subtype"
"exclude_transactions" → "exclude_from_transactions"
type_name: "depository" now return type: "cash".updated_atexternal_id (API-only, can be set/updated via API)custom_metadata (API-only, any valid JSON object < 4MB)View v1/v2 Manual Account Object differences
View v2 Manual Account Object Docs
Migration:
/assets to /manual_accountsdepository → cash type change// v1 - Only list endpoint
GET /v1/plaid_accounts
// v2 - Added single account endpoint
GET /v2/plaid_accounts
GET /v2/plaid_accounts/:id
View v2 Plaid Accounts Endpoint Docs
allow_transaction_modification: Boolean indicating if transactions can be modified (enabled by default)View v1/v2 Plaid Account Object differences
View v2 Plaid Account Object Docs
POST /v1/plaid_accounts/fetch
// Returns: true
POST /v2/plaid_accounts/fetch
// Returns: { "plaid_accounts": [...] } // List of accounts that were fetched
Migration: Update fetch response handling to expect an object with plaid_accounts array instead of true.
The v2 API adds crypto endpoints that support the full lifecycle of manual crypto balances. It also adds endpoints for viewing and extending the list of supported manual cryptocurrencies.
The v2 API also provides new endpoints for reading and refreshing synced crypto balances. Synced crypto account linking and management are supported in the Lunch Money Web app only.
GET /v1/crypto //manual balances only
GET /v2/cryptocurrencies
POST /v2/cryptocurrencies
GET /v2/crypto/manual
GET /v2/crypto/manual/:id
GET /v2/crypto/synced
GET /v2/crypto/synced/:id // Returns one synced crypto account with nested balances
GET /v2/crypto/synced/:id/:symbol
POST /v2/crypto/synced/:id/refresh
POST /v2/crypto/manual
PUT /v2/crypto/manual/:id
DELETE /v2/crypto/manual/:id
View v2 Manual Crypto Docs
View v2 Synced Crypto Docs
View v1/v2 Crypto Object differences
Migration:
GET /v1/crypto consumers with one of:GET /v2/crypto/manual for manual-only balancesGET /v2/crypto/synced for synced-only balancesGET /v2/cryptocurrencies to inspect the supported catalog, then POST /v2/cryptocurrencies with a CoinGecko coin-page URL when a symbol is missingsource as the contract-level differentiator (manual vs synced)balances on GET /v2/crypto/synced/:idGET /v2/crypto/synced/:id/:symbol when you need one balance from a synced crypto account422 Unprocessable Content when keep_history is not explicitly provided and history existsA new v2/summary endpoint replaces the v1/budgets endpoint.
This endpoint significantly refactors the response and aligns with the recently released v2 Budgets feature.
include_occurrences=trueinclude_occurrences is set to true, each returned category includes an occurrences array.
start_date and end_date in occurrence objects to choose a fully aligned date range for subsequent requests.View v2 Summary Endpoint Docs
View v2 Summary Object Docs
GET /budgets/settings has been added View DocsPUT /budgets has been added View DocsDELETE /budgets has been added View DocsGET /balance_history has been added View DocsGET /balance_history/{account_type}/{account_id} has been added View DocsGET /balance_history/crypto_synced/{account_id}/{symbol} has been added View DocsPUT /balance_history/{account_type}/{account_id} has been added View DocsPUT /balance_history/crypto_synced/{account_id}/{symbol} has been added View DocsDELETE /balance_history/{account_type}/{account_id} has been added View DocsDELETE /balance_history/crypto_synced/{account_id}/{symbol} has been added View DocsDELETE /balance_history/entries/{id} has been added View DocsPUT /balance_history/deleted/{account_id}/details has been added View Docsconst transaction = await getTransaction(123);
console.log(transaction.category_name); // Direct access
const transaction = await getTransaction(123);
const category = await getCategory(transaction.category_id);
console.log(category.name); // Access from category object
// Better: Cache categories
const categoryCache = new Map();
async function getCategoryName(id) {
if (!categoryCache.has(id)) {
const cat = await getCategory(id);
categoryCache.set(id, cat);
}
return categoryCache.get(id).name;
}
// All success responses were 200 OK
if (response.status === 200) {
const data = response.data; // Might be true, an ID, an error message, or a complex response object
}
// Proper HTTP status codes
if (response.status === 201) {
// POST - created object in response.body
const created = response.body;
} else if (response.status === 200) {
// GET - requested items are returned
// PUT - updated object in response.body
const updated = response.body;
} else if (response.status === 204) {
// DELETE - no response body
// Success!
}
// Inconsistent error formats
try {
// Some errors returned as 200 with { error: "message" }
// Some errors returned as 200 with { errors: ["message"] }
// Some errors returned as 404
} catch (e) {
// Handle inconsistently
}
// Consistent error format
try {
const response = await api.post('/transactions', data);
} catch (error) {
if (error.response.status === 400) {
const { message, errors } = error.response.body;
errors.forEach(err => {
console.error(err.errMsg);
});
}
}
// Create tags on the fly
POST /v1/transactions
{
"transactions": [{
"tags": ["New Tag"] // Creates tag automatically
}]
}
// Step 1: Create tag
const tag = await POST /v2/tags { name: "New Tag" };
// Step 2: Use tag ID
POST /v2/transactions
{
"transactions": [{
"tag_ids": [tag.id]
}]
}
If you run into issues during migration:
We're here to help make your migration as smooth as possible!