# FP&A: Budgeting, Forecasting, and Variance Reporting with the Accounting API

By the end of this page you'll have a scheduled pipeline that reads period P&L, period balance sheets, and journal-level detail out of a customer's QuickBooks, Xero, NetSuite, or Sage Intacct and lands them as period-aligned actuals. That fact table is what your budget-vs-actual, rolling-forecast, and headcount models query, and it comes from one integration against the unified [Accounting API](/apis/accounting/reference) rather than a report parser per ERP.

_Apideck reads the profit and loss, balance sheet, and journal entries out of whichever ledger your customer uses and returns period actuals to your planning product, which drives the next scheduled pull with an updated_since cursor._

One incremental-sync pattern (`updated_since`) covers every entity in the customer's stack, `tracking_categories` folds classes, locations, departments, and projects into a single dimension model on the connectors that expose it, and currency, tax, and journal shapes stay consistent across consolidations.

## Resource mapping

The dedicated reporting endpoints map onto the closest native report in each system. Use them for aggregates; drop down to journal entries and invoices only when you need line-level detail. Coverage of each dedicated report endpoint varies by connector. Verify per-resource support in the live coverage matrix before relying on a specific endpoint in production.

| Connector | Downstream report object |
| --- | --- |
| QuickBooks | `BalanceSheet`, `ProfitAndLoss`, `AgedReceivables`, `AgedPayables` reports |
| Xero | `Reports/BalanceSheet`, `Reports/ProfitAndLoss`, `Reports/AgedReceivablesByContact` |
| NetSuite | `Financial Report` (Balance Sheet, Income Statement) saved searches |
| Sage Intacct | `gltransaction` and `genledger` reports with dimension structures |
| Microsoft Dynamics 365 Business Central | `accountSchedules`, `generalLedgerEntries` |
| Exact Online | `BalanceLines` and `ProfitLossOverview` queries |
| Odoo | `account.move`, `account.account` aggregations |
| FreshBooks | `reports/accounting/profitloss_entity`, `reports/accounting/balance_sheet` |
| Sage Business Cloud Accounting | `journals`, `aged_debtors`, `aged_creditors` |
| Zoho Books | `reports/balancesheet`, `reports/profitandloss` |

## Walkthrough

The pipeline has four phases: an initial backfill, an incremental loop, a segment enrichment pass, and a working-capital read. Run all four against each connected entity, then consolidate in your warehouse.

Each step below shows the raw request 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: 'cust_acme_holdings_us'
})
```

### Pull the period P&L

Start with the income statement at the period grain the planning model expects (monthly is the common default). The dedicated endpoint returns the downstream system's native bucketing, which preserves the customer's accounting calendar without you reimplementing fiscal-year offsets.

Send this to [`GET /accounting/profit-and-loss`](/apis/accounting/reference#operation/profitAndLossOne).

```
GET /accounting/profit-and-loss?filter[start_date]=2024-01-01&filter[end_date]=2024-12-31&filter[accounting_method]=accrual
Authorization: Bearer sk_live_E7y2W...
x-apideck-app-id: app_01H9Q0K2X8V3...
x-apideck-consumer-id: cust_acme_holdings_us
x-apideck-service-id: quickbooks
```

One call returns one report, not a period matrix. `data` carries the `income`, `cost_of_goods_sold`, `expenses`, `other_income`, and `other_expenses` sections plus the `gross_profit`, `net_operating_income`, and `net_income` indicators. Each section has a `title`, a `total`, a `type` of either `Section` or `Record`, and a `records` array holding the accounts inside it. There is no period filter on this endpoint, so loop one call per period and stamp each result with the period you asked for.

Or through the SDK. The endpoint is a single-report read, so it exposes `get()` and no `list()`:

```ts
const { getProfitAndLossResponse } = await apideck.accounting.profitAndLoss.get({
  serviceId: 'quickbooks',
  filter: {
    startDate: '2024-01-01',
    endDate: '2024-12-31',
    accountingMethod: 'accrual'
  }
})

const pnl = getProfitAndLossResponse?.data
const revenue = pnl?.income?.total ?? 0
const cogs = pnl?.costOfGoodsSold?.total ?? 0
const grossProfit = pnl?.grossProfit?.total ?? revenue - cogs

void grossProfit
```

Neither filter is universal: `customer_id` is in coverage only on QuickBooks and Intuit Enterprise Suite, and those two plus Xero are the only ones that accept `accounting_method`.

### Pull period balance sheets

Pair the P&L with snapshot balance sheets at each period close. Use [`GET /accounting/balance-sheet`](/apis/accounting/reference#operation/balanceSheetOne) with an end date per period rather than recomputing balances from journal entries.

```json
{
  "filter": {
    "end_date": "2024-12-31",
    "period_count": 12,
    "period_type": "month"
  }
}
```

`filter[start_date]` is deprecated on this endpoint, so scope with `end_date`. `period_count` and `period_type` return a `reports[]` array with one snapshot per period, but only Xero honors them: everywhere else a call returns a single report, so loop the request across the period grid you maintain in the planning warehouse. For systems that close mid-month, request the customer's actual period boundary rather than the calendar boundary.

Or through the SDK, reading the `reports[]` array so the same code path works whether the connector expanded the periods or not:

```ts
const { getBalanceSheetResponse } = await apideck.accounting.balanceSheet.get({
  serviceId: 'xero',
  filter: { endDate: '2024-12-31', periodCount: 12, periodType: 'month' }
})

for (const report of getBalanceSheetResponse?.data?.reports ?? []) {
  const assets = report.assets?.value ?? 0
  const liabilities = report.liabilities?.value ?? 0
  void [report.endDate, assets, liabilities, report.netAssets ?? assets - liabilities]
}
```

Unlike the P&L, balance-sheet accounts do carry `account_id` and `name` alongside `value`, so you can key the fact table on the ledger account directly.

### Run the incremental sync

After the initial backfill, switch to incremental pulls keyed off `updated_since`. The journal-entries feed is the canonical change stream for actuals: any posting that affects the P&L or balance sheet shows up here.

Send this to [`GET /accounting/journal-entries`](/apis/accounting/reference#operation/journalEntriesAll).

```
GET /accounting/journal-entries?filter[updated_since]=2025-01-15T00:00:00Z&limit=200
Authorization: Bearer sk_live_E7y2W...
x-apideck-app-id: app_01H9Q0K2X8V3...
x-apideck-consumer-id: cust_acme_holdings_us
x-apideck-service-id: quickbooks
```

Or through the SDK. `list()` returns an async iterator that walks `meta.cursors.next` for you, so you never hand-roll pagination and never miss a page:

```ts
let watermark = new Date('2025-01-15T00:00:00Z')

const pages = await apideck.accounting.journalEntries.list({
  serviceId: 'quickbooks',
  filter: { updatedSince: watermark },
  limit: 200
})

for await (const page of pages) {
  for (const entry of page.getJournalEntriesResponse?.data ?? []) {
    if (entry.updatedAt && entry.updatedAt.getTime() > watermark.getTime()) {
      watermark = entry.updatedAt
    }
    for (const line of entry.lineItems ?? []) {
      void [line.ledgerAccount?.id, line.totalAmount, line.trackingCategories]
    }
  }
}
```

`limit` caps at 200 and some connectors ignore it, so treat page size as advisory. `filter[start_date]` and `filter[end_date]` exist here but only Zoho Books accepts them: everyone else rejects them with `UnsupportedFiltersError`, which is why `updated_since` is the cursor to build on.

Persist the high-water mark per `(consumer_id, service_id)` pair after each successful page. On the next run, advance `updated_since` to the largest `updated_at` you saw, minus a small overlap window (15 minutes is a safe default) to absorb out-of-order writes.

When a journal entry lands in a closed period, recompute the P&L and balance sheet for that period rather than trusting your incremental cache. Closed-period adjustments are a known source of variance drift.

### Map tracking_categories to segments

Segment reporting (revenue by department, opex by location, gross margin by project) hinges on the `tracking_categories` array that most connectors expose on journal-entry lines, invoices, and bills. List the available categories first, then store the customer's mapping from category to planning dimension.

Send this to [`GET /accounting/tracking-categories`](/apis/accounting/reference#operation/trackingCategoriesAll).

```json
{
  "data": [
    {
      "id": "tc_dept_01HZ8K3W9XQ7M2N6P4",
      "parent_id": null,
      "name": "Department",
      "code": "DEPT",
      "status": "active"
    },
    {
      "id": "tc_dept_eng_01HZ8K3W9XQ7M2N6P5",
      "parent_id": "tc_dept_01HZ8K3W9XQ7M2N6P4",
      "name": "Engineering",
      "code": "DEPT-ENG",
      "status": "active"
    }
  ]
}
```

Or through the SDK:

```ts
const categoryPages = await apideck.accounting.trackingCategories.list({
  serviceId: 'quickbooks',
  limit: 200
})

for await (const page of categoryPages) {
  for (const category of page.getTrackingCategoriesResponse?.data ?? []) {
    void [category.id, category.parentId, category.name, category.code, category.status]
  }
}
```

Within journal entries, each line carries its own `tracking_categories[]` on QuickBooks, Xero, and Sage Intacct. NetSuite and Sage Business Cloud Accounting expose none on journal lines, and Workday puts segments in a separate `worktags[]` array, so normalize all three shapes on ingest. Index your warehouse by `(period, account_id, tracking_category_ids[])` so the planning model can slice across any dimension without re-querying the source system.

### Consolidate across multiple entities

Multi-entity customers connect each legal entity as a separate Vault connection. Iterate over consumer connections and tag each row with the originating `service_id` and `consumer_id`. Use the Vault [`GET /vault/connections`](/apis/vault/reference#operation/connectionsAll) endpoint with the consumer header to enumerate active connections, and filter to `state: callable`.

Apply consolidation rules in your warehouse, not at read time:

- Translate non-base-currency entities using period-end rates for balance sheet accounts and period-average rates for P&L accounts
- Eliminate inter-company balances using a tracking category or account-code convention agreed with the customer
- Roll up segment dimensions when the planning chart of accounts is shallower than the source

### Pull aged AR and AP for working capital

Working-capital projections need aging buckets, not raw invoice lists. Use the dedicated endpoints so the buckets line up with whatever the source system reports natively.

Send these to [`GET /accounting/aged-debtors`](/apis/accounting/reference#operation/agedDebtorsOne) and [`GET /accounting/aged-creditors`](/apis/accounting/reference#operation/agedCreditorsOne).

```
GET /accounting/aged-debtors?filter[report_as_of_date]=2025-01-31
GET /accounting/aged-creditors?filter[report_as_of_date]=2025-01-31
```

Or through the SDK. Both are single-report reads, and both accept `periodCount` and `periodLength` to control the bucket grid where the connector supports it:

```ts
const [debtors, creditors] = await Promise.all([
  apideck.accounting.agedDebtors.get({
    serviceId: 'quickbooks',
    filter: { reportAsOfDate: '2025-01-31', periodCount: 4, periodLength: 30 }
  }),
  apideck.accounting.agedCreditors.get({
    serviceId: 'quickbooks',
    filter: { reportAsOfDate: '2025-01-31', periodCount: 4, periodLength: 30 }
  })
])

void [debtors.getAgedDebtorsResponse?.data, creditors.getAgedCreditorsResponse?.data]
```

Only fall back to [`GET /accounting/invoices`](/apis/accounting/reference#operation/invoicesAll) and [`GET /accounting/bills`](/apis/accounting/reference#operation/billsAll) when the planning model needs invoice-level detail (for example, to model individual large customer DSO).

### Build period-over-period comparisons

The planning UI typically shows three columns: this period, prior period, and same period last year. Compute these in the warehouse from the same canonical fact table, keyed on `(entity_id, period_end_date, account_id, tracking_category_ids[])`:

- **This period**: latest closed period from the incremental sync
- **Prior period**: the period immediately before, from the same fact table
- **Same period last year**: 12-month offset on `period_end_date`, accounting for fiscal calendars that are not calendar-aligned

Store the source `currency` and the FX rate used for translation alongside each row. When users drill into a variance, surfacing the underlying journal entries via `journal_entry_id` gives them the audit trail without an extra round-trip to the source system.

### When to drop down to the Proxy API

The unified endpoints cover the report shapes most FP&A use cases need. Reach for the [Proxy API](/apis/proxy/reference) only when the customer asks for a downstream-native artifact the unified model deliberately does not expose, for example NetSuite saved searches with custom formulas, Xero's budget endpoint, or Sage Intacct's `query` engine for ad-hoc dimension combinations. Treat Proxy responses as connector-specific by definition: they do not flow through the unified mapping layer, so they should land in a separate, connector-tagged table in your warehouse rather than the canonical fact table.

## What actually bites people

Three constraints on the connectors most FP&A pipelines start with, each verified against the live coverage matrix rather than the unified schema.

**Xero.** Its balance sheet is the only one in coverage that accepts `period_count` and `period_type`, so a single call returns twelve monthly snapshots, but it has no `start_date` filter at all: scope with `end_date` plus a period count. Journal entries are the opposite story. `limit` is not supported, so page size is whatever Xero decides, and `update` and `delete` are flagged unsupported downstream because Xero journals are immutable. Xero is also the only connector that reads the `scope` filter, where `manual` reads manual journals and `system` reads the full general ledger.

**NetSuite.** Journal lines carry no `tracking_categories`. Segments arrive as scalar `department_id` and `location_id` per line, so a NetSuite dimension mapping cannot reuse the code path you wrote for QuickBooks or Sage Intacct. Its P&L accepts only `start_date` and `end_date`: no `customer_id`, so per-customer revenue has to be rebuilt from journal entries, and no `accounting_method`, so you get whatever the account is configured for. It is the one connector whose journal entries support `sort` on `created_at` and `updated_at`.

**Sage.** The two Sage connectors behave nothing alike. Sage Business Cloud Accounting journal entries are read-only and expose no `posted_at` at all, so the posting date that decides which period a line belongs to is simply absent: fall back to `updated_at` and accept the drift, or treat the report totals as period truth. Its balance sheet takes `end_date` only, and its P&L returns `records` on `income` and `expenses` but `total` alone on `cost_of_goods_sold`, so COGS has no line-level detail. Sage Intacct is far richer: full journal write support, `tracking_categories` on lines, and a `location_id` filter on both reports.

## Connector-specific behavior

Coverage of the dedicated balance-sheet, P&L, journal-entries, tracking-categories, and aged report endpoints varies by connector. For any row below where the guide depends on a report endpoint that is limited or out of coverage, fall back to journal entries plus account classifications, or use the Proxy API for a downstream-native report. Verify per-resource support in the live coverage matrix before relying on a specific endpoint in production. Quirks below are noted only where there is real signal; rows that do not call out a quirk should be treated as standard mapping with coverage to be verified.

| Connector | Notes |
| --- | --- |
| `access-financials` | Standard mapping. Verify report-endpoint coverage in the matrix before relying on dedicated balance-sheet and P&L pulls. |
| `acumatica` | Branches and subaccounts surface as `tracking_categories` where in coverage. Period rollups follow the financial period structure rather than calendar months. |
| `banqup` | AP-leaning scope. Verify report-endpoint coverage in the matrix; journal entries and invoices are the safer baseline. |
| `campfire` | Standard mapping. No known quirks beyond the unified model; verify coverage before relying on any specific report endpoint. |
| `clearbooks-uk` | UK-style VAT periods. Verify coverage of the aged report endpoints before using their bucket structure directly. |
| `digits` | Standard mapping. No known quirks beyond the unified model; verify coverage before relying on any specific report endpoint. |
| `dualentry` | Standard mapping. No known quirks beyond the unified model; verify coverage before relying on any specific report endpoint. |
| `exact-online` | Cost Centers and Cost Units map to `tracking_categories` where in coverage. Closed-period postings appear via `updated_since`. |
| `exact-online-nl` | Same as `exact-online` with NL-specific VAT codes on journal lines. |
| `exact-online-uk` | Same as `exact-online` with UK VAT codes. |
| `freeagent` | SMB scope. Project tracking maps to `tracking_categories` where in coverage; deeper segment dimensions are not modeled downstream. |
| `freshbooks` | SMB scope. Class and department dimensions are not modeled. Verify which dedicated report endpoints are in coverage; otherwise reconstruct from journal entries and account classifications. |
| `intuit-enterprise-suite` | Classes, locations, and departments surface as `tracking_categories` where in coverage. Multi-entity consolidation is native to the underlying suite. |
| `kashflow` | UK SMB scope. Project codes map to `tracking_categories` where in coverage. |
| `microsoft-dynamics-365-business-central` | Global Dimension 1, Global Dimension 2, and shortcut dimensions all map to `tracking_categories` where in coverage. Account schedules drive the native report shape. |
| `moneybird` | NL SMB scope. Cost centers map to `tracking_categories` where in coverage. |
| `mrisoftware` | Property-management scope. Property-level dimensions map to `tracking_categories` where in coverage. Period boundaries follow the property accounting calendar. |
| `myob` | Categories and jobs map to `tracking_categories` where in coverage. |
| `myob-acumatica` | Branches and subaccounts surface as `tracking_categories`, matching the underlying Acumatica engine. |
| `netsuite` | Journal lines carry no `tracking_categories`. Departments and locations arrive as scalar `department_id` and `location_id` per line. Multi-book accounting is supported, with the primary book exposed by default. |
| `odoo` | Analytic accounts map to `tracking_categories` where in coverage. The analytic dimension is the primary segment lever. |
| `pennylane` | French chart of accounts. Analytic codes map to `tracking_categories` where in coverage. |
| `procountor-fi` | Finnish chart of accounts and statutory periods. Cost centers map to `tracking_categories` where in coverage. |
| `quickbooks` | Classes and locations map to `tracking_categories` where in coverage. Sub-accounts roll up under their parent in the P&L unless the customer opts out. |
| `rillet` | Standard mapping. No known quirks beyond the unified model; verify coverage before relying on any specific report endpoint. |
| `sage-business-cloud-accounting` | Journal entries are read-only, carry no `tracking_categories`, and expose no `posted_at`. The balance sheet takes `end_date` only. Verify aged report coverage before using its bucket structure directly. |
| `sage-intacct` | Native dimensions (Department, Location, Project, Class, plus user-defined dimensions) surface as `tracking_categories` where in coverage. Use the Proxy API for ad-hoc multi-dimension queries the unified report shape cannot express. |
| `sage-intacct-rest` | REST variant of `sage-intacct`. Same dimension model; verify per-resource coverage separately. |
| `stripe` | Payment-processor scope, not a general ledger. The dedicated balance-sheet, P&L, and aged report endpoints are not meaningful here. Treat as a transactional source for revenue and fees and reconcile in your warehouse. |
| `visma-netvisor` | Finnish chart of accounts. Cost centers map to `tracking_categories` where in coverage. |
| `wave` | SMB scope. Limited segment dimensions; rely on account-code conventions for grouping. Verify report-endpoint coverage in the matrix. |
| `workday` | Journal lines carry a dedicated `worktags` array rather than `tracking_categories`. Consolidation hierarchy mirrors Workday's organizational structure. |
| `xero` | Up to two tracking categories per line, surfaced as `tracking_categories`. The only connector whose balance sheet accepts `period_count` and `period_type`, and the only one that reads the journal-entries `scope` filter. Journal entries ignore `limit`. |
| `yuki` | NL SMB scope. Cost centers map to `tracking_categories` where in coverage. |
| `zoho-books` | Classes and projects map to `tracking_categories` where in coverage. |

### NetSuite

NetSuite is the most segment-rich connector in the catalog, but its journal lines surface segments as scalar `department_id` and `location_id` rather than in `tracking_categories`, and the subsidiary lives on the journal entry rather than the line. For consolidation, that subsidiary value is what tells you which legal entity the row belongs to; do not rely on the connection alone if the customer uses OneWorld with multiple subsidiaries on a single connection. Scope a read to one entity with `filter[subsidiary_id]`, which is honored only on connectors that support multi-entity scoping.

### Sage Intacct

Intacct's dimension model is the closest analog to a planning tool's own dimension model, but customers commonly add user-defined dimensions that the unified shape may flatten. When a customer reports that a slice in their FP&A platform doesn't match Intacct, check whether the slice is a UDD before assuming a sync issue, and use the Proxy API to pull it directly if needed.

### QuickBooks

QuickBooks sub-accounts are a common source of variance complaints: the P&L endpoint rolls them up by default, but the journal entries feed exposes them at the leaf level. Pick one shape and stick with it across the warehouse.

### Stripe

Stripe is a payment processor, not a general ledger. Connect it as a complementary feed to the customer's accounting system, not as a substitute. Use it to reconcile revenue at the transaction grain when the GL only has summary deposits.

### Workday

Workday's worktag model is multi-valued per line and hierarchical, and it arrives in its own `worktags` array on the journal line rather than in `tracking_categories`. Persist the full worktag set per line rather than the lowest-level value, so the planning UI can roll up flexibly.

## Visit the demo

The FP&A demo runs this pipeline against a connected ledger: it pulls live actuals, computes variance by account and department, projects a rolling forecast and cash runway, and builds a headcount plan from the HRIS side of the same Vault connection. Pick a ledger and watch the reads land, nothing to set up.

## Next steps

- [Mark Invoices and Bills as Paid](/guides/mark-invoices-as-paid) for write-back flows from the planning tool
- [Handling Bills and Expenses](/guides/expenses-bills) for AP-side detail beyond the aged report
- [Accounting API reference](/apis/accounting/reference)
- [Proxy API reference](/apis/proxy/reference) for downstream-native reports the unified model does not cover
