# Tax Automation with the Accounting API

By the end of this page you'll have pulled a customer's configured tax rates out of their ledger, referenced them by the right key on both an invoice and a bill, and posted the period-close journal that moves the net tax position to the remittance account, inside QuickBooks, Xero, or NetSuite. One integration against the unified [Accounting API](/apis/accounting/reference), four calls, and no per-connector tax-code table to maintain.

_Your product reads tax rates and taxed transactions through the Apideck Accounting API from whichever ledger your customer uses, then writes the calculated tax liability back into that ledger as a journal entry._

Tax setup lives in the customer's accounting system, so the integration job is to read what is already configured, reference it by the key that connector accepts, and let the downstream system compute and post the liability. The same `tax_rate` shape covers invoice and bill line items, [Vault](/guides/vault) handles auth and token refresh, and the `accounting.journal-entry.created` webhook confirms the remittance posting landed without polling.

## Resource mapping

Tax rates in the unified model normalise to the connector's tax catalogue. Liability journal entries normalise to the connector's general journal.

| Connector | Tax rate object | Journal entry object |
| --- | --- | --- |
| QuickBooks | `TaxRate` (or AST agency rate) | `JournalEntry` |
| Xero | `TaxRate` (`TaxType` code) | `ManualJournal` |
| NetSuite | `TaxCode` / `TaxGroup` | `Journal` |
| Sage Intacct | `Tax Detail` | `GL Journal Entry` |
| Exact Online | `VAT Code` | `Journal Entry` |
| Workday | `Tax Code` | `Journal` |
| Zoho Books | `Tax` | `Journal` |
| Stripe | `Tax Rate` | n/a |

## Walkthrough

The flow has four steps: fetch the existing tax rates, reference them on invoice line items, reference them on bill line items, and post a tax liability journal at period close.

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

### 1. Pull the connector's tax rates

Start by listing the tax rates configured on the connection. These are the only rates that can be referenced on transactions, and each connector exposes a different subset of the unified tax-rate model. Send this to [`GET /accounting/tax-rates`](/apis/accounting/reference#operation/taxRatesAll).

```http
GET /accounting/tax-rates HTTP/1.1
Host: unify.apideck.com
Authorization: Bearer <APIDECK_API_KEY>
x-apideck-app-id: dWN0c3Rfb...
x-apideck-consumer-id: cnsmr_01H8X9Y2A3B4C5D6E7
x-apideck-service-id: xero
```

A response item from Xero looks like this. Xero returns the `TaxType` and `ReportTaxType` values on `type` and `report_tax_type` and does not expose `code` or `description` at all, so those are absent here.

```json
{
  "id": "tx_01H8X9Y2A3B4C5D6E7F8G9H0",
  "name": "20% (VAT on Income)",
  "effective_tax_rate": 20.0,
  "total_tax_rate": 20.0,
  "components": [
    {
      "name": "VAT on Income",
      "rate": 20.0,
      "compound": false
    }
  ],
  "type": "OUTPUT2",
  "report_tax_type": "OUTPUT",
  "status": "active"
}
```

Store whichever key that connector accepts on a line item, plus `effective_tax_rate`, so the front end can label the rate and the backend can attach it to outgoing transactions. QuickBooks returns `tax_payable_account_id` and `description` but no `code` and no `total_tax_rate`; NetSuite returns `code`, `country`, and `subsidiaries`. `components[]` is the per-jurisdiction breakdown and `total_tax_rate` is the non-compounded sum of it, which is where a US sales tax rate arrives split into its state, county, and city portions. Where `total_tax_rate` is missing, sum `components[].rate` yourself.

If the connector supports it (see the coverage table below), new rates can be created with [`POST /accounting/tax-rates`](/apis/accounting/reference#operation/taxRatesAdd). Most production integrations only need read access, since the customer maintains tax setup in their accounting system.

Or through the SDK. `limit` caps at 200 and several connectors return the catalogue over more than one page, so iterate rather than reading a single page: a `.get()` miss returns `undefined` and posts an uncoded line without complaining.

```ts
const rateByKey = new Map<string, { id: string; rate: number }>()

for await (const page of await apideck.accounting.taxRates.list({
  serviceId: 'xero',
  filter: { status: 'active' },
  limit: 200
})) {
  for (const rate of page.getTaxRatesResponse?.data ?? []) {
    const key = rate.code ?? rate.type
    if (!key || !rate.id) continue
    rateByKey.set(key, { id: rate.id, rate: rate.effectiveTaxRate ?? 0 })
  }
}

const outputVat = rateByKey.get('OUTPUT2')
const inputVat = rateByKey.get('INPUT2')
if (!outputVat || !inputVat) {
  throw new Error('This connection has no OUTPUT2 or INPUT2 tax rate configured')
}
```

### 2. Attach a tax rate to an invoice line item

Reference the tax rate on each line. The downstream system computes `tax_amount` from the line `unit_price`, `quantity`, and the rate. Send this to [`POST /accounting/invoices`](/apis/accounting/reference#operation/invoicesAdd).

```json
{
  "number": "INV-2025-00471",
  "customer": {
    "id": "cust_01H8X9Y2A3B4C5D6E7F8G9CUST"
  },
  "invoice_date": "2025-03-14",
  "due_date": "2025-04-13",
  "currency": "GBP",
  "line_items": [
    {
      "description": "Implementation services, March 2025",
      "quantity": 12,
      "unit_price": 150.0,
      "ledger_account": {
        "id": "led_acct_01H8X9Y2A3B4C5D6E7F8G9SALES"
      },
      "tax_rate": {
        "id": "tx_01H8X9Y2A3B4C5D6E7F8G9H0",
        "code": "OUTPUT2"
      }
    }
  ],
  "sub_total": 1800.0,
  "total_tax": 360.0,
  "total": 2160.0,
  "status": "authorised"
}
```

Two things about that body. The invoice number field is `number`, not `invoice_number`: the wrong name is accepted and then silently dropped. And sending both `id` and `code` on `tax_rate` is deliberate, because connectors disagree on which one they read. NetSuite accepts `id`, `code`, and `name` on a line item's tax rate, while QuickBooks and Xero accept only `code`.

Note also that `total` is the gross figure. If a line carries a tax rate and you set `total` to the pre-tax subtotal, any payment you later allocate underpays and the balance never reaches zero. Either send a gross `total` as below or set `tax_inclusive: true`, which NetSuite does not support.

Or through the SDK:

```ts
const { createInvoiceResponse } = await apideck.accounting.invoices.create({
  serviceId: 'xero',
  invoice: {
    number: 'INV-2025-00471',
    customer: { id: 'cust_01H8X9Y2A3B4C5D6E7F8G9CUST' },
    invoiceDate: new Date('2025-03-14'),
    dueDate: new Date('2025-04-13'),
    currency: 'GBP',
    lineItems: [
      {
        description: 'Implementation services, March 2025',
        quantity: 12,
        unitPrice: 150,
        ledgerAccount: { id: 'led_acct_01H8X9Y2A3B4C5D6E7F8G9SALES' },
        taxRate: { id: outputVat.id, code: 'OUTPUT2' }
      }
    ],
    subTotal: 1800,
    totalTax: 1800 * (outputVat.rate / 100),
    total: 1800 + 1800 * (outputVat.rate / 100),
    status: 'authorised'
  }
})

const invoiceId = createInvoiceResponse?.data?.id
if (!invoiceId) throw new Error('Invoice was not created')
```

### 3. Attach a tax rate to a bill line item

The same `tax_rate` shape works on bills. Use the input-side rate code where the connector distinguishes input from output VAT (Xero `INPUT2`, Exact Online `IB`, NetSuite tax codes flagged as purchase). Send this to [`POST /accounting/bills`](/apis/accounting/reference#operation/billsAdd).

```json
{
  "bill_number": "VEND-887412",
  "supplier": {
    "id": "sup_01H8X9Y2A3B4C5D6E7F8G9SUPP"
  },
  "bill_date": "2025-03-04",
  "due_date": "2025-04-03",
  "currency": "GBP",
  "line_items": [
    {
      "description": "Cloud hosting, March 2025",
      "quantity": 1,
      "unit_price": 480.0,
      "ledger_account": {
        "id": "led_acct_01H8X9Y2A3B4C5D6E7F8G9HOST"
      },
      "tax_rate": {
        "id": "tx_01H8X9Y2A3B4C5D6E7F8G9INPUT",
        "code": "INPUT2"
      }
    }
  ],
  "sub_total": 480.0,
  "total_tax": 96.0,
  "total": 576.0,
  "status": "authorised"
}
```

Or through the SDK. Bills use `bill_number` and a linked `supplier` object, not `supplier_id`:

```ts
await apideck.accounting.bills.create({
  serviceId: 'xero',
  bill: {
    billNumber: 'VEND-887412',
    supplier: { id: 'sup_01H8X9Y2A3B4C5D6E7F8G9SUPP' },
    billDate: new Date('2025-03-04'),
    dueDate: new Date('2025-04-03'),
    currency: 'GBP',
    lineItems: [
      {
        description: 'Cloud hosting, March 2025',
        quantity: 1,
        unitPrice: 480,
        ledgerAccount: { id: 'led_acct_01H8X9Y2A3B4C5D6E7F8G9HOST' },
        taxRate: { id: inputVat.id, code: 'INPUT2' }
      }
    ],
    subTotal: 480,
    totalTax: 480 * (inputVat.rate / 100),
    total: 480 + 480 * (inputVat.rate / 100),
    status: 'authorised'
  }
})
```

### 4. Post a tax liability journal entry

At period close, post a manual journal that moves the net VAT or sales tax position to the remittance account. On connectors that return it, the `tax_payable_account_id` from step 1 is the control account to debit when net tax is owed. Xero and NetSuite do not expose that field on a tax rate, so resolve the control account from [`GET /accounting/ledger-accounts`](/apis/accounting/reference#operation/ledgerAccountsAll) instead and cache it per consumer. Send this to [`POST /accounting/journal-entries`](/apis/accounting/reference#operation/journalEntriesAdd).

```json
{
  "title": "VAT remittance, Q1 2025",
  "memo": "Net VAT owed for the quarter ending 2025-03-31",
  "posted_at": "2025-04-07T00:00:00.000Z",
  "currency": "GBP",
  "journal_symbol": "GJ",
  "line_items": [
    {
      "description": "Clear output VAT control",
      "type": "debit",
      "total_amount": 12480.0,
      "ledger_account": {
        "id": "led_acct_01H8X9Y2A3B4C5D6E7F8G9HXX"
      }
    },
    {
      "description": "Reclaim input VAT control",
      "type": "credit",
      "total_amount": 4180.0,
      "ledger_account": {
        "id": "led_acct_01H8X9Y2A3B4C5D6E7F8G9INPUT"
      }
    },
    {
      "description": "Bank transfer to HMRC",
      "type": "credit",
      "total_amount": 8300.0,
      "ledger_account": {
        "id": "led_acct_01H8X9Y2A3B4C5D6E7F8G9BANK"
      }
    }
  ]
}
```

Or through the SDK. `type` and `ledger_account` are the only two required properties on a journal line, and the debits have to net against the credits exactly as they would in any general journal:

```ts
await apideck.accounting.journalEntries.create({
  serviceId: 'xero',
  journalEntry: {
    title: 'VAT remittance, Q1 2025',
    memo: 'Net VAT owed for the quarter ending 2025-03-31',
    postedAt: new Date('2025-04-07T00:00:00.000Z'),
    currency: 'GBP',
    journalSymbol: 'GJ',
    lineItems: [
      {
        description: 'Clear output VAT control',
        type: 'debit',
        totalAmount: 12480,
        ledgerAccount: { id: 'led_acct_01H8X9Y2A3B4C5D6E7F8G9HXX' }
      },
      {
        description: 'Reclaim input VAT control',
        type: 'credit',
        totalAmount: 4180,
        ledgerAccount: { id: 'led_acct_01H8X9Y2A3B4C5D6E7F8G9INPUT' }
      },
      {
        description: 'Bank transfer to HMRC',
        type: 'credit',
        totalAmount: 8300,
        ledgerAccount: { id: 'led_acct_01H8X9Y2A3B4C5D6E7F8G9BANK' }
      }
    ]
  }
})
```

>
> Tax rate IDs are stable per connection but not portable across connections. Do not cache them in shared application state. Re-list rates per consumer, and refresh on tax-rate change events from the webhook stream.

## What actually bites people

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

**QuickBooks Online.** Tax rates come back without `total_tax_rate`, so derive the combined rate by summing `components[].rate`. The tax-rates resource also reports no pagination support at all, which means `limit` and `cursor` are ignored and the whole catalogue arrives in one response, and its supported operations are `all`, `add`, and `one` only: a rate you create through Apideck cannot then be updated or deleted through Apideck. On the invoice side, `status` and `sub_total` are not supported fields, so drive paid state off `balance` against `total` and let QuickBooks derive the subtotal itself.

**Xero.** Xero tax rates report `one` and `update` as unsupported downstream, so there is no fetch by ID and no edit: list the catalogue and match locally, which is also why Xero's tax-rate pagination is virtual rather than native. Xero does not expose `code` or `description` on a tax rate either. The `TaxType` and `ReportTaxType` values arrive on `type` and `report_tax_type`, and that `TaxType` string is what an invoice line item's `tax_rate.code` expects, so `type` is the field you copy into it.

**NetSuite.** `tax_inclusive` is not a supported invoice field on NetSuite, so you cannot hand it a gross figure and ask it to work backwards. Compute the tax yourself and send a gross `total` alongside `sub_total` and `total_tax`. NetSuite invoice line items also carry no `description` and no `ledger_account`, so the line is defined by the `item` you reference and the coding follows that item rather than a per-line account. Its tax rates do carry `subsidiaries[].id`, and the rate set is scoped per subsidiary.

## Connector-specific behavior

The note column reflects what is exposed today through the unified API. "Read-only" means tax rates can be listed and referenced on transactions but cannot be created or updated through Apideck. "Not in coverage" means the tax-rates endpoint is not exposed for that connector, and tax must be applied by referencing percentages or codes directly in the line item or by relying on downstream computation. Always verify gaps in the live coverage matrix before depending on a write path.

| Connector | Notes |
| --- | --- |
| Access Financials | Tax rates are read-only. Bills are not in coverage; reference existing rate IDs on invoice and credit-note line items and manage tax setup in Access. |
| Acumatica | Tax rates are read-only. Manage tax categories and zones in Acumatica and reference them on invoice and bill lines. |
| Banqup | Tax rates are not in coverage. Invoices and customers are read-only and suppliers are not in coverage, so use Banqup as the system of record for tax-bearing data. |
| Campfire | Tax rates are not in coverage. Use the journal-entries endpoint, which is read+write on Campfire, to record tax liability postings directly. |
| Clearbooks UK | Tax rates are not in coverage. Invoices, bills, and credit notes are all read-only, so use Clearbooks as the system of record. |
| Digits | Tax rates are not in coverage. Reporting-style connector. Pull computed totals back rather than pushing tax-bearing transactions. |
| DualEntry | Tax rates are not in coverage. Provide tax-inclusive totals on invoice and bill line items and let the connector reconcile. |
| Exact Online | Tax rates are read-only. Reference VAT codes by ID on invoice and bill line items. |
| Exact Online NL | Tax rates are read-only. Dutch BTW codes are configured per administration; reference by ID. |
| Exact Online UK | Tax rates are read-only. Bills and credit notes are read-only as well, so write paths for tax-bearing transactions are limited to invoices and journal entries. |
| FreeAgent | Tax rates are not in coverage. Bills and journal entries are read-only; tax-bearing writes flow through invoices and credit notes, both of which are read+write. |
| FreshBooks | Full read and write on tax rates. Standard mapping on invoices and bills. Expenses are not in coverage on FreshBooks, and journal entries are read-only. |
| Intuit Enterprise Suite | Full read and write on tax rates. Supports departments, locations, and tracking categories on tax-bearing transactions. |
| KashFlow | Tax rates are read-only. Invoices and journal entries are read-only as well, and bills are not in coverage. KashFlow is effectively the system of record. |
| Microsoft Dynamics 365 Business Central | Tax rates are read-only. VAT posting groups must be configured in Business Central before they can be referenced. |
| Moneybird | Tax rates are read-only. Reference Dutch BTW codes by ID. Tracking categories are read+write. |
| MRI Software | Tax rates are read-only. Invoices are not in coverage on MRI Software, so the tax-rate references shown in this guide apply to journal entries and supplier-side records rather than AR invoices. Verify against the live coverage matrix before depending on any write path. |
| MYOB | Full read and write on tax rates. Bills and journal entries are not in coverage on MYOB, so tax workflows run through invoices and payments only. |
| MYOB Acumatica | Tax rates are read-only. Reference tax categories on line items. |
| NetSuite | Full read and write on tax rates. Subsidiaries (read+write) and departments and locations (read-only) can all be referenced on tax-bearing transactions. |
| Odoo | Tax rates are read-only. Odoo computes tax from the tax IDs assigned to each line; reference IDs returned from the tax-rates endpoint. |
| Pennylane | Tax rate support is limited. Prefer reading existing rates and avoid creating new ones through the API. |
| Procountor (FI) | Tax rates are read-only. Reference Finnish VAT codes on line items. |
| QuickBooks | Full read and write on tax rates. US files using Automated Sales Tax compute tax centrally; do not create custom rates on AST files. See subsection below. |
| Rillet | Tax rates are read-only. Standard mapping on invoices, bills, and journal entries. |
| Sage Business Cloud Accounting | Tax rates are read-only. Reference tax codes on invoice and bill line items. Journal entries are read-only on this connector. |
| Sage Intacct | Tax rates are read-only. Tax solutions and tax details are configured at the company level. |
| Sage Intacct REST | Tax rates are not in coverage on this connector variant, and most other tax-relevant resources are not exposed either. Use the classic Sage Intacct connector for tax workflows. |
| Stripe | Full read and write on tax rates via Stripe Tax. Bills and journal entries are not in coverage; tax workflows are invoice-driven. |
| Visma Netvisor | Tax rates are not in coverage. Reference Finnish VAT percentages directly on line items, and note that journal entries are read-only here. |
| Wave | Tax rates are read-only. Bills are not in coverage; tax workflows run through invoices and ledger accounts. |
| Workday | Full read and write on tax rates. Subsidiaries and departments are read-only and can be referenced on tax-bearing transactions. |
| Xero | Full read and write on tax rates. Each line item must reference a `TaxType` code that exists on the Xero organisation. See subsection below. |
| Yuki | Tax rates are read-only. Bills are read-only and credit notes are not in coverage, so tax-bearing writes flow through invoices and journal entries. Tracking categories are read+write. |
| Zoho Books | Full read and write on tax rates. Multi-organization setups require selecting the correct organization context per request. |

### QuickBooks

QuickBooks Online files in the United States use Automated Sales Tax (AST). On AST files, tax is computed by Intuit based on customer address and product taxability, not by referencing a fixed rate. Listing tax rates still works and returns the agency-level rates QuickBooks tracks internally, but creating custom rates on an AST file is rejected by the downstream API. For non-US locales (UK, Canada, Australia) the standard `TaxRate` model applies and full read and write is available.

### Xero

Xero distinguishes input tax (`INPUT`, `INPUT2`, `RRINPUT`) from output tax (`OUTPUT`, `OUTPUT2`, `RROUTPUT`). The unified `tax_rate.code` field maps to the Xero `TaxType` code. Tax rates created via the unified API become custom tax rates on the Xero organisation and are immediately referenceable.

### NetSuite

NetSuite supports both `TaxCode` (single jurisdiction) and `TaxGroup` (combined jurisdictions). The unified tax-rates endpoint returns both. On multi-subsidiary accounts the rate set is filtered by subsidiary, so scope the parent transaction with `company_id`, which is the supported scope field on a NetSuite invoice, and ensure the referenced tax rate belongs to that subsidiary. The unified `subsidiary` object is not writable on NetSuite invoices.

### Stripe

Stripe exposes `Tax Rate` objects through Stripe Tax. These are the same rates used by Stripe Invoicing and Stripe Billing. Because Stripe does not maintain a general ledger, the journal-entry step in the walkthrough does not apply; reconcile Stripe tax totals into the destination general ledger via a separate accounting connection.

## Visit the demo

The sales tax demo runs this flow end to end against a connected ledger: it reads the seller's sales and configured tax rates, calculates what is owed per jurisdiction, flags economic-nexus triggers and under-collection, then writes the corrected liability back into their books. The same code path runs on NetSuite, QuickBooks, Xero, and the rest, and nothing needs setting up.

## Next steps

- [Mark invoices and bills as paid](/guides/mark-invoices-as-paid) once tax-inclusive totals have settled
- [Handling bills and expenses](/guides/expenses-bills) for the broader AP workflow
- [Tax Rates reference](/apis/accounting/reference#tag/Tax-Rates)
- [Journal Entries reference](/apis/accounting/reference#tag/Journal-Entries)
