Migrating v1 → v2

Migrating from API v1 to v2

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.

Getting Started

Before you begin:

  1. Review the v2 API Overview for general concepts
  2. Test your migration in a development environment
  3. Keep 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://api.lunchmoney.dev/v1/

to:

https://api.lunchmoney.dev/v2

General Changes

Error Handling

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

Response Codes

Two status code changes affect all endpoints:

See Response Shapes in the v2 API Overview for the complete table.

Updating Objects

Property Replacement Behavior
Complex properties — objects and arrays — are replaced entirely by what you send in the PUT body. To add a tag without removing existing ones, fetch the object first, update the array, then PUT the full array back.

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 Naming Conventions

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.

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 v1/v2 differences
View v2 Category Object Docs

Query Parameters

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:

Tags

New Properties

The tag object now includes:

View v1/v2 differences
View v2 Tag Object Docs

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 v1/v2 differences
View v2 Transaction Object Docs

Transaction Amounts

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
Behavior Change
In the v1 API the optional 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.

Migration Tip
Remove debit_as_negative query parameters and update your code to interpret positive transaction amounts as debits and negative amounts as credits.

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

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 - positive amounts are debits and negative amounts are credits
  • 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
] }
New Capability
Child transactions in 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:

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's properties are now grouped into three nested objects: 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

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 v1/v2 Manual Account Object differences
View v2 Manual Account Object Docs

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 v1/v2 Plaid Account Object differences
View v2 Plaid Account Object Docs

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.

Crypto Balances

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:

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.

include_occurrences=true
When include_occurrences is set to true, each returned category includes an occurrences array.
  • For aligned ranges, the array includes one occurrence per budget period in range.
  • For non-aligned ranges, the array includes only budget periods fully contained in range.
  • You can inspect 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

budgets endpoints

balance_history endpoints (New)

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!