Migrating from API v1 to v2

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.

Getting Started

Before you begin, we recommend:

  1. Reviewing the v2 changelog for a complete list of changes
  2. Testing your migration in a development environment
  3. Keeping your v1 integration working as a fallback until migration is complete
Migration Strategy
Start with read operations first, then gradually migrate write operations. This allows you to test v2 responses while still relying on v1 for writes during the transition.

Base URL Changes

The base URL for all API requests changes from:

https://dev.lunchmoney.app/v1/

to:

https://api.lunchmoney.dev/v2

General Changes

Error Handling

Breaking Change
The v1 API often returns a 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:

You may wish to revisit any error handling portions of your existing app. You should no longer need to examine 2XX response bodies for error messages, but you may need to add new handlers for other types of error responses.

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.

Response Codes

HTTP Status Code Changes
The following responses indicate a successful API request:
  • GET requests return 200 OK when called with proper query parameters and headers.
  • POST requests now return 201 Created (instead of 200 OK) with the created object in the response body.
  • PUT requests return 200 OK with the updated object in the response body.
  • DELETE requests return 204 No Content with no response body (instead of 200 OK with true).

Updating Objects

Most Lunch Money objects are now updatable via a PUT request to the objects endpoint. When making a PUT request:

Property Replacement Behavior
Unless otherwise documented, the values for complex properties, such as objects and arrays that are set in the request body, will completely replace any previous value for these properties. Therefore, if you wish to, for example, add a new tag to a transaction or a new child category to a category group, you should first query the existing object and then update the property appropriately.

Property Naming Conventions

Some object and property names have changed in the v2 API. The details are listed below but in general property names have been standardized:

All 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.

Object and Endpoint Specific Changes

User Object

Property Renames

{
  "user_id": 123,
  "user_name": "John Doe",
  "user_email": "john@example.com"
}
{
  "id": 123,
  "name": "John Doe",
  "email": "john@example.com"
}

New Properties

View v1/v2 differences

View v2 User Object Docs

Migration Tips:

Categories

Endpoint Changes

Creating Categories

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": [...] }

Adding to Category Groups

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

Deleting Categories

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

Response Changes

Object Property Changes

View v2 Category Object Docs
View v1/v2 differences

Query Parameters
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:

Tags

New Properties

The tag object now includes:

View v2 Tag Object Docs
View v1/v2 differences

New Endpoints

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

List Ordering

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.

Transactions

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.

Breaking Change: Dehydrated Responses
To optimize the responsiveness of the v2 Lunch Money APIs, the transaction object will no longer be hydrated. Categories, accounts, and tags are now returned as IDs only. Details of these objects can be retrieved by calling the appropriate endpoint using the supplied ID. Developers are encouraged to maintain a local cache of these objects to reduce the number of API calls.
{
  "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:

Removed Properties

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):

These account-related properties are no longer included (get from account object):

Renamed Properties

The following properties have been renamed or refactored in the v2 Transaction object

These recurring-related properties have changed:

View v2 Transaction Object Docs
View v1/v2 differences

Transaction Amounts

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
Behavior Change
In the v1 API the default value for the 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.
Migration Tip
Remove debit_as_negative query parameters and check the user object to determine how to interpret amounts.

Status Values

"status": "cleared" | "uncleared" | "pending" | "recurring" | "recurring_suggested"
"status": "reviewed" | "unreviewed" | "deleted_pending"

Migration: Update status checks:

Split and Grouped Transactions

Split Transactions:

Grouped Transactions:

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.

Query Parameters

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

Creating Transactions

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",
      }
    }
  ]
}
Important Changes
  • asset_idmanual_account_id
  • tags 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 instead
  • Duplicated transactions (due to external_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 reported
Migration Steps
  • Ensure that amounts are properly signed for inserted transactions. Regardless of the value of the users debits_as_negative property always use positive numbers for debits and negative numbers for credits.
  • When creating transactions with tags, create new tags first using POST /tags before assigning them
  • Update changed property names in request bodies
  • Modify handling of success responses to leverage the full objects for inserted transactions
  • Check success response for skipped_duplicates

Updating Transactions

PUT /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:

Splitting and Grouping Transactions

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:

Migration:

Deleting Transactions

New Feature
It is now possible to delete transactions via the API!
// 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.

Use With Caution!
Deletions are permanent. Ensure apps that use these endpoints are very well tested before using on real user data.

Transaction Attachments (New)

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

Recurring Items

A new `GET v2/recurring_items/{id} endpoint to get a single recurring item has been added.

View v2 Recurring Endpoint Docs

Object Structure Changes

Major Reorganization
The recurring object has been significantly reorganized into logical groups for better structure.
{
  "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

Query Parameters

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:

Manual Accounts (formerly Assets)

The /assets endpoint was renamed to /manual_accounts to align with language used in the web client.

Endpoint Rename

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

Property Renames

// v1 → v2
"type_name" → "type"
"subtype_name" → "subtype"
"exclude_transactions" → "exclude_from_transactions"
Type Name Change
Accounts with type_name: "depository" now return type: "cash".

New Properties

View v2 Manual Account Object Docs
View v1/v2 Manual Account Object differences

Migration:

Plaid Accounts

New Endpoints

// 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

New Response Properties

View v2 Plaid Account Object Docs
View v1/v2 Plaid Account Object differences

Fetch Endpoint Changes

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.

Summary Endpoint

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

Common Migration Patterns

Pattern 1: Getting object details for non hydrated responses

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;
}

Pattern 2: Handling Response Codes

// 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!
}

Pattern 3: Error Handling

// 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);
    });
  }
}

Pattern 4: Creating New Tags with New Transactions

// 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]
  }]
}

Testing Your Migration

  1. Start with Read Operations: Update your code to read from v2 endpoints first
  2. Handle Dehydration: Ensure your code can work with IDs instead of hydrated objects
  3. Update Write Operations: Migrate create/update/delete operations
  4. Test Edge Cases:
    • Split transactions
    • Grouped transactions
    • Transactions with recurring items
    • Category groups with children
  5. Verify Error Handling: Test various error scenarios to ensure proper handling
  6. Add New Capabilities: Take advantage of new endpoints to fully manage Transactions, Tags, and Manual Accounts and more.

Need Help?

If you run into issues during migration:

We're here to help make your migration as smooth as possible!