# Building Accounting Integrations for Expense Management Platforms

By the end of this page you'll have a card transaction leaving your product and landing as a coded expense or bill inside a customer's QuickBooks, Xero, NetSuite, or Exact Online, with its receipt attached and its payment reconciled. One integration against the unified [Accounting API](/apis/accounting/reference) covers every provider, and the per-provider work is the last five percent.

_Your product sends coded expenses and bills to the Apideck Accounting API, which writes them into whichever ledger your customer uses and returns the ledger accounts and tax rates you map your categories against._

Account mapping is what decides whether a transaction lands on the right ledger account, [Vault](/guides/vault) handles the OAuth flow and token refresh for each provider, and the `vault.connection.invalid` and `accounting.bill.created` webhooks keep your sync state honest without polling.

---

## Integration Architecture Overview

The integration has three layers:

1. **Your application**: expense tracking, mapping settings, and export engine
2. **Apideck Unified API**: Vault (auth), Accounting (CRUD), Webhooks (sync)
3. **Downstream providers**: QuickBooks, Xero, Exact Online, NetSuite, Sage, Business Central

Your app talks only to Apideck. Apideck handles provider-specific differences, auth token management, and data mapping.

Each phase below shows the raw request body first, then the same call through the [`@apideck/unify`](/sdks/node) SDK. The SDK takes camelCase and handles serialization, so pick whichever fits your stack. Every snippet is verified against `@apideck/unify` v0.46.0.

```ts
import { Apideck } from '@apideck/unify'

const apideck = new Apideck({
  apiKey: process.env.API_KEY!,
  appId: process.env.APP_ID!,
  consumerId: 'cons_01H8X9Y2A3K4M5N6P7Q8R9S0T1'
})
```

---

## Phase 1: Connection Setup

### Embed Apideck Vault

[Vault](/guides/vault) handles the OAuth flow with each accounting provider. Embed it in your application's settings or onboarding flow:

```ts
const { createSessionResponse } = await apideck.vault.sessions.create({
  consumerId: 'cons_01H8X9Y2A3K4M5N6P7Q8R9S0T1',
  session: {
    consumerMetadata: {
      accountName: 'Beachside Surf Co.',
      email: 'ops@beachsidesurf.com'
    },
    settings: { unifiedApis: ['accounting'] }
  }
})

const sessionUri = createSessionResponse?.data.sessionUri
if (!sessionUri) throw new Error('Vault session was not created')
```

Redirect the user to `sessionUri`, or hand the same URI to the embedded Vault component. Sessions expire on the `settings.sessionLength` you set (up to one week), so mint one per visit rather than caching it.

### Monitor Connection State

Use [webhooks](/guides/webhooks) to track when connections become active or need re-authorization:

```javascript
// Key events to handle
'vault.connection.callable'  // Connection ready, start syncing
'vault.connection.invalid'   // Token expired, prompt re-auth
'vault.connection.revoked'   // User disconnected, stop syncing
```

See the [Connection States guide](/guides/connection-states) for the full state machine.

---

## Phase 2: Account Mapping

This is the most critical UX step. Your users need to map their expense categories to the correct ledger accounts in their accounting system.

### What to Map

| Your Side | Accounting Side | API |
|-----------|----------------|-----|
| Expense categories | Ledger accounts (type: expense) | [`GET /accounting/ledger-accounts`](/apis/accounting/reference#tag/Ledger-Accounts) |
| Payment methods / cards | Bank accounts (type: bank) | [`GET /accounting/ledger-accounts`](/apis/accounting/reference#tag/Ledger-Accounts) |
| Merchants / counterparties | Suppliers / Vendors | [`GET /accounting/suppliers`](/apis/accounting/reference#tag/Suppliers) |
| Tax categories | Tax rates | [`GET /accounting/tax-rates`](/apis/accounting/reference#tag/Tax-Rates) |
| Departments / cost centers | Tracking categories | [`GET /accounting/tracking-categories`](/apis/accounting/reference#tag/Tracking-Categories) |

Pull the two lists that drive the mapping screen once per connection and cache them. `limit` caps at 200, so paginate through `meta.cursors.next` on a real chart of accounts rather than assuming one page holds everything:

```ts
const { getLedgerAccountsResponse } = await apideck.accounting.ledgerAccounts.list({
  serviceId: 'quickbooks',
  filter: { classification: 'expense' },
  limit: 200
})

const { getTaxRatesResponse } = await apideck.accounting.taxRates.list({
  serviceId: 'quickbooks',
  limit: 200
})

const expenseAccounts = getLedgerAccountsResponse?.data ?? []
const nextPage = getLedgerAccountsResponse?.meta?.cursors?.next
const taxRates = getTaxRatesResponse?.data ?? []
void [expenseAccounts, nextPage, taxRates]
```

### Building the Mapping UI

Present a two-column interface where users match their categories to accounting accounts:

| Your Category | Accounting Account |
|---|---|
| Travel | 6200 - Travel & Entertainment |
| Office Supplies | 6100 - Office Expenses |
| Software | 6350 - IT Costs |
| Meals | 6200 - Travel & Entertainment |
| **Default bank account** | 1100 - Business Account |
| **Default tax rate** | 21% VAT |

For detailed implementation guidance, see the [Ledger Account Mapping guide](/guides/ledger-account-mapping).

---

## Phase 3: Exporting Expenses

### Choose the Right Resource

| Scenario | Resource | Why |
|----------|----------|-----|
| Expense already paid (card transaction) | **Expense** | Records the payment and categorization in one step |
| Expense awaiting reimbursement/approval | **Bill** | Creates an AP entry that can be paid later |
| Exact Online (any scenario) | **Bill** | Expenses not supported, use Bills with `due_date = bill_date` |
| Need full debit/credit control | **Journal Entry** | For complex multi-account transactions |

See [When to Use Bills vs. Expenses](/guides/expenses-bills#when-to-use-bills-vs-expenses) for the detailed decision matrix.

### Create an Expense (for QuickBooks, Xero, NetSuite)

The header `account` is the bank or card the money left, and each line item's `account` is the expense category it gets coded to. Both take a linked reference object rather than a bare id:

```json
{
  "transaction_date": "2025-03-15T12:00:00.000Z",
  "account": {
    "id": "1100"
  },
  "supplier": {
    "id": "supp_01HZ9Q2K4M5N6P7Q8R9S0T1U2V"
  },
  "currency": "EUR",
  "payment_type": "credit_card",
  "memo": "Business lunch, client meeting",
  "line_items": [
    {
      "account": {
        "id": "6200"
      },
      "description": "Business lunch",
      "total_amount": 45.50,
      "tax_rate": {
        "id": "VAT21"
      },
      "tracking_categories": [
        {
          "id": "dept_sales"
        }
      ]
    }
  ],
  "total_amount": 45.50
}
```

Send this to [`POST /accounting/expenses`](/apis/accounting/reference#operation/expensesAdd). The tax reference on a line is `tax_rate` holding an object, not a flat `tax_rate_id`: the flat form does not exist on the expense line item model and is dropped without an error.

Or through the SDK:

```ts
const mapping = {
  bankAccountId: '1100',
  categoryAccountId: '6200',
  categoryAccountCode: '6200',
  supplierId: 'supp_01HZ9Q2K4M5N6P7Q8R9S0T1U2V',
  taxRateId: 'VAT21',
  departmentId: 'dept_sales'
}

const { createExpenseResponse } = await apideck.accounting.expenses.create({
  serviceId: 'quickbooks',
  expense: {
    transactionDate: new Date('2025-03-15T12:00:00.000Z'),
    account: { id: mapping.bankAccountId },
    supplier: { id: mapping.supplierId },
    currency: 'EUR',
    paymentType: 'credit_card',
    memo: 'Business lunch, client meeting',
    lineItems: [
      {
        account: { id: mapping.categoryAccountId },
        description: 'Business lunch',
        totalAmount: 45.5,
        taxRate: { id: mapping.taxRateId },
        trackingCategories: [{ id: mapping.departmentId }]
      }
    ],
    totalAmount: 45.5
  }
})

const expenseId = createExpenseResponse?.data?.id
if (!expenseId) throw new Error('Expense was not created')
```

### Create a Bill (for Exact Online and reimbursements)

```json
{
  "bill_number": "EXP-4821",
  "supplier": {
    "id": "supp_01HZ9Q2K4M5N6P7Q8R9S0T1U2V"
  },
  "bill_date": "2025-03-15",
  "due_date": "2025-03-15",
  "currency": "EUR",
  "line_items": [
    {
      "description": "Business lunch, client meeting",
      "ledger_account": {
        "id": "6200"
      },
      "total_amount": 45.50,
      "tax_amount": 7.90,
      "tax_rate": {
        "id": "VAT21"
      }
    }
  ],
  "total": 45.50,
  "status": "authorised"
}
```

Send this to [`POST /accounting/bills`](/apis/accounting/reference#operation/billsAdd). Three things in that body are easy to get wrong:

- The supplier goes in `supplier` as an object. `supplier_id` is not a field on the bill model at all, unlike on the expense model where it survives as a deprecated alias.
- The line account is `ledger_account`, not `account_id`. Exact Online is the exception worth knowing about: its bill line coverage does not include `ledger_account`, so category coding there runs through the referenced `item` instead.
- Keep `total` gross. Send the pre-tax subtotal while the line carries a `tax_rate` and the downstream bill posts a higher gross than you paid, your payment underpays it, and the balance never reaches zero. The alternative is `tax_inclusive: true`, which Xero and QuickBooks accept on bills but Exact Online and NetSuite do not.

Send `status: "authorised"` rather than `"draft"`. A draft bill is unposted and will reject the payment allocation in the next phase.

Or through the SDK, running against Xero because its bill lines carry `ledger_account` and it also accepts the `tax_amount` and `status` this bill sends. Note Xero exposes only `code` on a bill line's ledger account, not `id`, so the GL account is referenced by nominal code here. QuickBooks is the mirror image: it takes `ledger_account.id` but carries neither `tax_amount` nor `status` on a bill.

```ts
const { createBillResponse } = await apideck.accounting.bills.create({
  serviceId: 'xero',
  bill: {
    billNumber: 'EXP-4821',
    supplier: { id: mapping.supplierId },
    billDate: new Date('2025-03-15'),
    dueDate: new Date('2025-03-15'),
    currency: 'EUR',
    lineItems: [
      {
        description: 'Business lunch, client meeting',
        ledgerAccount: { code: mapping.categoryAccountCode },
        totalAmount: 45.5,
        taxAmount: 7.9,
        taxRate: { id: mapping.taxRateId }
      }
    ],
    total: 45.5,
    status: 'authorised'
  }
})

const billId = createBillResponse?.data?.id
if (!billId) throw new Error('Bill was not created')
```

Date-only fields such as `billDate` and `dueDate` are plain `Date` values in the SDK. There is no `RFCDate` export to reach for.

---

## Phase 4: Payment Reconciliation

After creating bills, you need to record the payment to mark them as paid. This is the reconciliation step.

### Create a Bill Payment

```json
{
  "supplier": {
    "id": "supp_01HZ9Q2K4M5N6P7Q8R9S0T1U2V"
  },
  "total_amount": 45.50,
  "transaction_date": "2025-03-18T09:30:00.000Z",
  "account": {
    "id": "1100"
  },
  "allocations": [
    {
      "id": "bill_01HZ9R4M5N6P7Q8R9S0T1U2V3W",
      "type": "bill",
      "amount": 45.50
    }
  ],
  "status": "authorised",
  "type": "accounts_payable",
  "currency": "EUR"
}
```

Send this to [`POST /accounting/bill-payments`](/apis/accounting/reference#operation/billPaymentsAdd). `total_amount` and `transaction_date` are both required, and the supplier is again an object rather than a `supplier_id`.

Or through the SDK:

```ts
await apideck.accounting.billPayments.create({
  serviceId: 'xero',
  billPayment: {
    supplier: { id: mapping.supplierId },
    totalAmount: 45.5,
    transactionDate: new Date('2025-03-18T09:30:00.000Z'),
    account: { id: mapping.bankAccountId },
    allocations: [{ id: billId, type: 'bill', amount: 45.5 }],
    status: 'authorised',
    type: 'accounts_payable',
    currency: 'EUR'
  }
})
```

The accounting system automatically updates the bill status to **paid** once the full amount is allocated. Omit `allocations` and the cash posts against the supplier while the bill stays open, which is the single most common reconciliation bug.

For more details, see the [Mark Invoices as Paid guide](/guides/mark-invoices-as-paid).

>
> The example above runs against Xero. If you post bill payments to Exact Online instead, they use the XML API under the hood. This is handled transparently by Apideck, so your API calls remain the same REST format.

---

## Phase 5: Attachments & Receipts

Attach receipt images to expenses or bills for audit compliance:

```javascript
const formData = new FormData()
formData.append('file', receiptFile)

await fetch(
  `https://unify.apideck.com/accounting/attachments/bill/${billId}`,
  {
    method: 'POST',
    headers: {
      'x-apideck-consumer-id': consumerId,
      'x-apideck-app-id': appId,
      'x-apideck-service-id': serviceId,
      Authorization: `Bearer ${apiKey}`,
      'x-apideck-metadata': JSON.stringify({
        name: receiptFile.name,
        description: 'Expense receipt'
      })
    },
    body: formData
  }
)
```

Or through the SDK. `requestBody` accepts a `ReadableStream`, a `Blob`, an `ArrayBuffer`, or a `Uint8Array`, and the per-file metadata rides along as a JSON string header:

```ts
// receiptBytes is the raw file, for example from fs.readFile or a multipart upload
const receiptBytes = new Uint8Array([37, 80, 68, 70])

await apideck.accounting.attachments.upload({
  serviceId: 'xero',
  referenceType: 'bill',
  referenceId: billId,
  xApideckMetadata: JSON.stringify({
    name: 'lunch-receipt.pdf',
    description: 'Expense receipt'
  }),
  requestBody: receiptBytes
})
```

Valid `referenceType` values are `invoice`, `bill`, `expense`, `expense-report`, and `quote`. See [`POST /accounting/attachments/{reference_type}/{reference_id}`](/apis/accounting/reference#operation/attachmentsUpload) for the full contract.

---

## Phase 6: Error Handling & Monitoring

### Handle Common Errors

```javascript
try {
  await apideck.accounting.bills.create(billData)
} catch (error) {
  switch (error.status) {
    case 401:
      // Connection expired, redirect to Vault re-auth
      await redirectToVault(consumerId)
      break
    case 422:
      // Validation error, check required fields
      // Common: missing ledger_account, invalid date, closed period
      logValidationError(error.detail)
      break
    case 429:
      // Rate limited, implement backoff
      await backoff(error.headers['retry-after'])
      break
  }
}
```

### Monitor with Webhooks

```javascript
app.post('/webhooks/apideck', (req, res) => {
  const { event_type, payload } = req.body

  switch (event_type) {
    case 'accounting.bill.created':
      markExpenseAsSynced(payload.id)
      break
    case 'vault.connection.invalid':
      notifyUserToReconnect(payload.consumer_id)
      break
  }

  res.status(200).send()
})
```

---

## What actually bites people

Three constraints on the connectors most expense products start with, each verified against the live coverage matrix rather than the unified schema.

**QuickBooks Online.** Expenses map to Purchases, and QuickBooks is the only one of the three that reports a header-level `tax_rate` and supports custom fields on the resource. It does not report `status` on an expense, so you cannot read back whether a purchase posted cleanly: record your own sync state per transaction and drive retries off that. Its only expense filter is `updated_since`.

**Xero.** Expenses map to Bank Transactions, and the line item is thin: beyond the ids, coverage lists only `description`, `total_amount`, `account`, `tax_rate`, and `tracking_categories`. `quantity`, `unit_price`, `customer`, `department`, and `location` are not there, so per-unit pricing silently collapses into the line total. Xero also drops `payment_type`, `sub_total`, and `status` from the expense, and its journal entries ignore `limit`, so page them with `meta.cursors.next` instead of asking for a page size.

**NetSuite.** Expenses map to Credit Charges and support no filters at all, not even `updated_since`. There is no incremental resync to lean on: persist the last exported transaction on your side and replay from there. Tax lives only on the line, since the NetSuite expense has no header `tax_rate` or `tax_inclusive`, and NetSuite journal entry lines carry no `tax_rate`, `tax_amount`, or `tracking_categories` at all, so a tax-bearing correction cannot be expressed as a journal entry there.

---

## Development Strategy

### Start Simple, Expand Later

1. **Week 1-2**: Implement Vault connection + basic bill creation against QuickBooks sandbox
2. **Week 3**: Add ledger account mapping UI
3. **Week 4**: Add bill payments for reconciliation
4. **Week 5**: Test against your target provider (for example Exact Online)
5. **Week 6**: Add attachments, webhooks, and error handling

### Start with QuickBooks or Xero

Even if your primary market uses Exact Online, start development with QuickBooks or Xero:
- Free sandbox accounts with sample data
- Best developer documentation
- Since Apideck provides a unified API, **95% of your code is the same** across providers
- Each connector has setup docs, for example [Exact Online setup](/connectors/exact-online-nl/docs/application_owner+oauth_credentials)

### Test the Remaining 5% Per Provider

Each provider has small differences:
- **Exact Online**: Only Bills (no Expenses), XML-based bill payments
- **QuickBooks**: Expenses map to Purchases, Classes for tracking
- **Xero**: Expenses map to Bank Transactions, Tracking Categories for dimensions
- **NetSuite**: Supports Subsidiaries and multi-dimensional tracking

---

## Complete Integration Checklist

| Area | Requirement |
|------|-------------|
| **Connection** | Embed Vault for OAuth connection flow |
| | Handle connection state webhooks (callable, invalid, revoked) |
| | Support multiple accounting connections per user |
| **Mapping** | Fetch and display ledger accounts for mapping |
| | Map expense categories to expense ledger accounts |
| | Map payment methods to bank/card ledger accounts |
| | Map merchants to suppliers (with option to create new) |
| | Map tax categories to tax rates |
| | Optional: map departments to tracking categories |
| | Implement auto-match by label similarity |
| | Persist mappings per user per connection |
| **Export** | Create Bills for AP / reimbursement expenses |
| | Create Expenses for already-paid transactions (where supported) |
| | Handle line items with correct account, tax, and tracking references |
| | Support multi-currency with exchange rates |
| | Upload receipt attachments |
| **Reconciliation** | Create Bill Payments to mark bills as paid |
| | Handle partial payments and overpayments via allocations |
| | Verify bill status updates after payment creation |
| **Production** | Error handling for auth failures, validation errors, rate limits |
| | Webhook monitoring for connection health |
| | Retry logic with exponential backoff |
| | Logging for debugging sync failures |
| | User-facing sync status UI |

---

## AI Agent Prompt

Use this prompt with your AI coding assistant (Claude, Cursor, Copilot, etc.) to scaffold the integration.

```text
Build an accounting integration for an expense management platform using the
Apideck unified Accounting API. The integration should:

1. VAULT CONNECTION
   - Embed Apideck Vault for OAuth connection flow
   - Use x-apideck-consumer-id, x-apideck-app-id, and Bearer token auth
   - Handle vault.connection.callable, vault.connection.invalid, and
     vault.connection.revoked webhook events
   - Base URL: https://unify.apideck.com

2. LEDGER ACCOUNT MAPPING
   - Fetch ledger accounts: GET /accounting/ledger-accounts
     (filter by classification)
   - Fetch suppliers: GET /accounting/suppliers
   - Fetch tax rates: GET /accounting/tax-rates
   - Build a settings UI where users map their expense categories to
     ledger accounts, payment methods to bank accounts, and merchants
     to suppliers
   - Store mappings per consumer_id + service_id
   - limit caps at 200, so paginate via meta.cursors.next

3. EXPENSE EXPORT
   - For already-paid expenses: POST /accounting/expenses
     (QuickBooks, Xero, NetSuite only, NOT Exact Online)
   - For AP / reimbursements: POST /accounting/bills
     (works on all providers including Exact Online)
   - Expense line items reference: account { id } (or the deprecated
     account_id), tax_rate { id }, and optionally tracking_categories
   - Bill line items reference: ledger_account { id } and tax_rate { id }
   - Suppliers are always objects: supplier { id }, never supplier_id
   - Keep the header total gross, or set tax_inclusive where supported
   - Support multi-currency via currency and currency_rate fields

4. PAYMENT RECONCILIATION
   - Create bills with status "authorised", not "draft", or the
     allocation will be rejected
   - After creating a bill, reconcile with: POST /accounting/bill-payments
   - total_amount and transaction_date are required
   - Link payment to bill via allocations[].id = bill.id, type "bill"
   - Bill status automatically updates to "paid"

5. ATTACHMENTS
   - Upload receipts: POST /accounting/attachments/{reference_type}/{reference_id}
   - reference_type is one of invoice, bill, expense, expense-report, quote
   - Either multipart/form-data over HTTP or accounting.attachments.upload
     in the @apideck/unify SDK

6. ERROR HANDLING
   - 401: redirect to Vault re-auth
   - 422: log validation error details
   - 429: implement exponential backoff using retry-after header

Provider differences (from the live coverage matrix):
- Exact Online: Only Bills (no Expenses), bill payments use XML API
  (transparent via Apideck), bill lines have no ledger_account
- QuickBooks: Expenses map to Purchases, no status on the expense,
  updated_since is the only filter
- Xero: Expenses map to Bank Transactions, line items carry no quantity
  or unit_price, journal entries ignore limit
- NetSuite: Expenses map to Credit Charges and support no filters,
  tax is line-level only

Reference: https://developers.apideck.com/guides/expense-management-integration
API docs: https://developers.apideck.com/apis/accounting/reference
```

---

## Visit the demo

The Expense Management demo runs this flow against a live connected ledger: it maps categories to ledger accounts, exports a transaction as an expense or a bill, and reconciles the payment. Nothing to set up.

## Related Guides

- [Ledger Account Mapping](/guides/ledger-account-mapping) for building the mapping UI
- [Integrating Expenses and Bills](/guides/expenses-bills) for the detailed Bills versus Expenses decision
- [Mark Invoices as Paid](/guides/mark-invoices-as-paid) for payment allocation
- [Accounting Data Model](/guides/accounting-data-model) for entity relationships
- [Vault](/guides/vault) for embedding the connection UI
- [Webhooks](/guides/webhooks) for real-time event monitoring
- [Tracking Dimensions](/guides/locations-subsidiaries-departments) for departments and locations
