The Lunch Money API v2 introduces several important improvements to make the API more consistent, reliable, and easier to work with. This guide will help you update your application to work with v2.
Before you begin, we recommend:
The base URL for all API requests changes from:
https://dev.lunchmoney.app/v1/
to:
https://api.lunchmoney.dev/v2
200 OK response even when the API fails and the response body contains an error message. V1 apps may or may not have special logic to handle errors after the API returns a 2XX response.In the v2 API, error responses are now more consistent and informative:
HTTP Status Codes: Errors now return proper 4XX status codes:
400 Bad Request - Invalid parameters or request body401 Unauthorized - Authentication problems404 Not Found - Resource not found429 Too Many Requests - Rate limit exceeded (includes Retry-After header)Error Response Format: All errors return a JSON object with:
{
"message": "Overall error description",
"errors": [
{
"errMsg": "Specific error about a parameter or property"
}
...
]
}
In addition, the v2 API does full request validation and generates errors when a request has invalid query parameters or invalid properties in a request body. The v1 API often accepted these types of requests, sometimes with strange results. As you move your app to v2 requests, test iteratively and adjust your code as necessary to remove invalid components from your request.
200 OK when called with proper query parameters and headers.201 Created (instead of 200 OK) with the created object in the response body.200 OK with the updated object in the response body.204 No Content with no response body (instead of 200 OK with true).Most Lunch Money objects are now updatable via a PUT request to the objects endpoint. When making a PUT request:
Request bodies must contain at least one or more "user-definable" properties to change.
Any "user-definable" property in a PUT will be changed in the specified object.
Any "system-defined" property in a PUT request will be tolerated but ignored.
Any other unexpected property will fail validation and return a 400 response.
Because request bodies MAY optionally contain "system-definable" properties, developers may perform PUT requests using the following pattern:
GET /endpoint/{id} request.PUT /endpoint/{id} request, passing in the complete modified object.Some object and property names have changed in the v2 API. The details are listed below but in general property names have been standardized:
id instead of category_id on category objects)category_id on transactions)_at (e.g., created_at, updated_at, archived_at)tag_ids instead of tags in transaction objects)children propertyAll id properties in request and response bodies are now explicitly integer type instead of number for better validation. This change should not impact existing v1 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 boolean indicating how to interpret transaction amounts.Migration Tips:
user_id, user_name, and user_email in your code to use the new property names.debits_as_negative setting for the user. The value of this setting will inform how you interpret the amount and to_base property values returned when fetching accounts and transactions.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 v2 Category Object Docs
View v1/v2 differences
GET /v1/categories?format=nested // or "flattened" - default: flattened
GET /v2/categories // Default: nested alphabetized 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 v2 Tag Object Docs
View v1/v2 differences
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 v2 Transaction Object Docs
View v1/v2 differences
The debit_as_negative property has been removed as an optional query parameter and request body property in the /transactions endpoints. Developers should query the user's debit_as_negative setting by inspecting the response from the GET /me endpoint, in order to determine if positive amounts in transactions returned by the GET /transactions endpoint should be interpreted as credits or debits.
GET /v1/transactions?debit_as_negative=false // Query parameter
GET /v2/me // Check user.debits_as_negative property
GET /v2/transactions
// Amounts are always set according to user preference
debit_as_negative query parameter was false. The default user setting for debit_as_negative is true. Existing applications that assume positive values are credits need to be updated to check the user's setting and treat the values accordingly.debit_as_negative query parameters and check the user object to determine how to interpret amounts."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 /transactionsparent_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 - check user object instead
// 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 - check user object insteadexternal_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
] }
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
{
"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 v2 Recurring Object Docs
View v1/v2 Recurring Object differences
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 v2 Manual Account Object Docs
View v1/v2 Manual Account Object differences
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 v2 Plaid Account Object Docs
View v1/v2 Plaid Account Object differences
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.
A new v2/summary endpoint replaces the v1/budgets endpoint.
This endpoint significantly refactors the response and aligns with the recently released v2 Budgets feature.
View v2 Summary Endpoint Docs
View v2 Summary Object Docs
const 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!