# Payroll Journal Entries with the Accounting API

By the end of this page you'll have read a customer's chart of accounts, mapped every payroll category to one of their GL accounts, and posted a balanced pay run journal entry into their QuickBooks, Xero, or NetSuite. One integration against the unified [Accounting API](/apis/accounting/reference), one posting call per pay run, and the same code for whichever ledger the customer is on.

_Your payroll engine sends each completed pay run to the Apideck Accounting API as one balanced journal entry, which posts it into whichever general ledger your customer uses and returns the chart of accounts your GL mapping is built from._

The GL mapping UI is built from the customer's own accounts through [`GET /accounting/ledger-accounts`](/apis/accounting/reference#operation/ledgerAccountsAll), [Vault](/guides/vault) handles auth and token refresh, and the `accounting.journal-entry.created` webhook confirms each posting without polling.

## Resource mapping

The unified Journal Entries resource maps to the native GL posting object in each accounting system.

| Connector | Downstream object |
| --- | --- |
| QuickBooks | `JournalEntry` |
| Intuit Enterprise Suite | `JournalEntry` |
| Xero | `Manual Journal` |
| NetSuite | `Journal` |
| Sage Intacct | `General Ledger Journal Entry` |
| Microsoft Dynamics 365 Business Central | `Journal Line` (general journal batch) |
| Exact Online (NL/UK) | `GLTransaction` |
| Odoo | `account.move` |
| Zoho Books | `Journal` |
| Pennylane | `Manual Journal` |
| MYOB Acumatica / Acumatica | `GL Transaction` |
| Moneybird | `GeneralJournalEntry` |
| Yuki | `GLTransaction` |
| Procountor | `Journal` |
| MRI Software | `GL Journal` |
| Rillet | `JournalEntry` |
| Workday | `Journal` |
| DualEntry | `Journal Entry` |
| Campfire | `JournalEntry` |

## Walkthrough

The flow has three steps: load the customer's chart of accounts, build a mapping from payroll categories to GL accounts, then post a balanced journal entry per pay run.

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: 'csm_company_44219'
})
```

### 1. Load the chart of accounts

Fetch the GL accounts the customer can post to. Filter out non-postable headers and reconciliation accounts client-side based on the `type` and `status` returned per account. Only a few connectors accept `filter[status]`, so read `status` off each account rather than asking the connector to filter for you.

Send this to [`GET /accounting/ledger-accounts`](/apis/accounting/reference#operation/ledgerAccountsAll).

```json
{
  "data": [
    {
      "id": "led_01H8X9Y2A3B4C5D6E7F8G9H0",
      "nominal_code": "6000",
      "name": "Salaries and Wages",
      "type": "expense",
      "classification": "expense",
      "status": "active",
      "currency": "USD"
    },
    {
      "id": "led_01H8X9Y2A3B4C5D6E7F8G9H1",
      "nominal_code": "6010",
      "name": "Employer Payroll Taxes",
      "type": "expense",
      "classification": "expense",
      "status": "active",
      "currency": "USD"
    },
    {
      "id": "led_01H8X9Y2A3B4C5D6E7F8G9H2",
      "nominal_code": "2100",
      "name": "Payroll Liabilities: Federal Tax Withheld",
      "type": "liability",
      "classification": "liability",
      "status": "active",
      "currency": "USD"
    }
  ]
}
```

Or through the SDK. A chart of accounts routinely runs past the 200 record page cap, so walk every page rather than reading the first one:

```ts
const accounts: { id: string; code: string; name: string; type: string }[] = []

for await (const page of await apideck.accounting.ledgerAccounts.list({
  serviceId: 'quickbooks',
  limit: 200
})) {
  for (const account of page.getLedgerAccountsResponse?.data ?? []) {
    if (!account.id || account.status !== 'active') continue
    accounts.push({
      id: account.id,
      code: account.nominalCode ?? account.code ?? '',
      name: account.name ?? '',
      type: String(account.type ?? '')
    })
  }
}
```

Cache this per consumer and service. It changes rarely, and refetching it on every pay run is the fastest way to hit a rate limit on payroll day.

### 2. Persist the payroll category to GL account mapping

Show the GL accounts in a settings UI and let the customer assign each payroll category (gross wages, employer FICA, federal withholding, state withholding, 401(k) deductions, net pay clearing) to a ledger account ID. Store these mappings keyed by `consumer_id` and `service_id` so the same customer can be on multiple downstream systems.

A typical mapping for a US run looks like this:

```json
{
  "consumer_id": "csm_company_44219",
  "service_id": "quickbooks",
  "categories": {
    "gross_wages": "led_01H8X9Y2A3B4C5D6E7F8G9H0",
    "employer_payroll_taxes": "led_01H8X9Y2A3B4C5D6E7F8G9H1",
    "federal_tax_withheld": "led_01H8X9Y2A3B4C5D6E7F8G9H2",
    "state_tax_withheld": "led_01H8X9Y2A3B4C5D6E7F8G9H3",
    "employee_401k": "led_01H8X9Y2A3B4C5D6E7F8G9H4",
    "net_pay_clearing": "led_01H8X9Y2A3B4C5D6E7F8G9H5"
  }
}
```

Or, once it is loaded back into your posting code, as a typed lookup. The `ledger_account` reference on a journal line is required, so resolve it through a helper that throws rather than passing `undefined` into the line:

```ts
type PayrollCategory =
  | 'gross_wages'
  | 'employer_payroll_taxes'
  | 'federal_tax_withheld'
  | 'state_tax_withheld'
  | 'employee_401k'
  | 'net_pay_clearing'

const glMapping: Partial<Record<PayrollCategory, string>> = {
  gross_wages: 'led_01H8X9Y2A3B4C5D6E7F8G9H0',
  employer_payroll_taxes: 'led_01H8X9Y2A3B4C5D6E7F8G9H1',
  federal_tax_withheld: 'led_01H8X9Y2A3B4C5D6E7F8G9H2',
  state_tax_withheld: 'led_01H8X9Y2A3B4C5D6E7F8G9H3',
  employee_401k: 'led_01H8X9Y2A3B4C5D6E7F8G9H4',
  net_pay_clearing: 'led_01H8X9Y2A3B4C5D6E7F8G9H5'
}

const ledgerAccount = (category: PayrollCategory) => {
  const id = glMapping[category]
  if (!id) throw new Error(`No GL account mapped for payroll category ${category}`)
  return { id }
}
```

### 3. Post the payroll journal entry

After a pay run completes, build one balanced journal entry per pay run. Debits cover gross wages and employer taxes. Credits cover the withholding and deduction liabilities and the net pay clearing account that will later be cleared by the actual ACH debit.

The entry must balance, and the unified model expresses that through the sign of `total_amount` rather than through the `type` alone: debits are positive, credits are negative, and the `line_items` array has to sum to zero across at least two lines. Set `posted_at` to the pay date so the expense lands in the correct period. It is a timestamp, not a date, so send a full ISO 8601 value.

Send this to [`POST /accounting/journal-entries`](/apis/accounting/reference#operation/journalEntriesAdd) with the standard headers:

```http
POST /accounting/journal-entries HTTP/1.1
Host: unify.apideck.com
Authorization: Bearer ${APIDECK_API_KEY}
x-apideck-app-id: ${APIDECK_APP_ID}
x-apideck-consumer-id: csm_company_44219
x-apideck-service-id: quickbooks
Content-Type: application/json
```

```json
{
  "title": "Payroll run 2025-PR-0042",
  "memo": "Bi-weekly payroll for pay period ending 2025-03-14",
  "number": "2025-PR-0042",
  "posted_at": "2025-03-15T00:00:00.000Z",
  "currency": "USD",
  "journal_symbol": "PR",
  "line_items": [
    {
      "description": "Gross wages",
      "type": "debit",
      "total_amount": 48250.00,
      "ledger_account": {
        "id": "led_01H8X9Y2A3B4C5D6E7F8G9H0",
        "nominal_code": "6000"
      }
    },
    {
      "description": "Employer payroll taxes (FICA, FUTA, SUTA)",
      "type": "debit",
      "total_amount": 4112.13,
      "ledger_account": {
        "id": "led_01H8X9Y2A3B4C5D6E7F8G9H1",
        "nominal_code": "6010"
      }
    },
    {
      "description": "Federal income tax withheld",
      "type": "credit",
      "total_amount": -6890.40,
      "ledger_account": {
        "id": "led_01H8X9Y2A3B4C5D6E7F8G9H2",
        "nominal_code": "2100"
      }
    },
    {
      "description": "State income tax withheld",
      "type": "credit",
      "total_amount": -2105.18,
      "ledger_account": {
        "id": "led_01H8X9Y2A3B4C5D6E7F8G9H3",
        "nominal_code": "2110"
      }
    },
    {
      "description": "Employee 401(k) contributions",
      "type": "credit",
      "total_amount": -1930.00,
      "ledger_account": {
        "id": "led_01H8X9Y2A3B4C5D6E7F8G9H4",
        "nominal_code": "2120"
      }
    },
    {
      "description": "Net pay clearing",
      "type": "credit",
      "total_amount": -41436.55,
      "ledger_account": {
        "id": "led_01H8X9Y2A3B4C5D6E7F8G9H5",
        "nominal_code": "2150"
      }
    }
  ]
}
```

Or through the SDK, deriving the sign and the `type` from one signed amount per category so the two can never disagree:

```ts
const payRun = [
  { category: 'gross_wages', description: 'Gross wages', amount: 48250 },
  {
    category: 'employer_payroll_taxes',
    description: 'Employer payroll taxes (FICA, FUTA, SUTA)',
    amount: 4112.13
  },
  { category: 'federal_tax_withheld', description: 'Federal income tax withheld', amount: -6890.4 },
  { category: 'state_tax_withheld', description: 'State income tax withheld', amount: -2105.18 },
  { category: 'employee_401k', description: 'Employee 401(k) contributions', amount: -1930 },
  { category: 'net_pay_clearing', description: 'Net pay clearing', amount: -41436.55 }
] satisfies { category: PayrollCategory; description: string; amount: number }[]

const lineItems = payRun.map((line) => ({
  description: line.description,
  type: line.amount >= 0 ? ('debit' as const) : ('credit' as const),
  totalAmount: line.amount,
  ledgerAccount: ledgerAccount(line.category)
}))

const cents = lineItems.reduce((sum, line) => sum + Math.round(line.totalAmount * 100), 0)
if (cents !== 0) throw new Error(`Pay run is out of balance by ${cents / 100}`)

const { createJournalEntryResponse } = await apideck.accounting.journalEntries.create({
  serviceId: 'quickbooks',
  journalEntry: {
    title: 'Payroll run 2025-PR-0042',
    memo: 'Bi-weekly payroll for pay period ending 2025-03-14',
    number: '2025-PR-0042',
    postedAt: new Date('2025-03-15'),
    currency: 'USD',
    journalSymbol: 'PR',
    lineItems
  }
})

const journalEntryId = createJournalEntryResponse?.data?.id
if (!journalEntryId) throw new Error('Journal entry was not created')
```

Check the balance in integer cents, not floats. Adding those six amounts as doubles leaves a residue of roughly -7e-12, so a naive `sum !== 0` guard rejects a pay run that is in fact balanced.

Not every header field survives every connector. `title` and `memo` are two different fields and most connectors take one or the other, `journal_symbol` is honored on Sage Intacct, Exact Online and Fortnox but dropped on QuickBooks, Xero and NetSuite, and Xero exposes no `currency` on a journal entry at all. Send both `title` and `memo` when you have them, and check below for which one your target connector keeps.

Store the returned journal entry `id` against your pay run record so you can reconcile, retract, or supersede it later. To retrieve the same entry, use [`GET /accounting/journal-entries/{id}`](/apis/accounting/reference#operation/journalEntriesOne), or `apideck.accounting.journalEntries.get()` through the SDK.

### 4. Optional: split per cost center, project, or department

If the customer tracks payroll by department, project, or location, attach `tracking_categories` to each line item. Discover the available dimensions through [`GET /accounting/tracking-categories`](/apis/accounting/reference#operation/trackingCategoriesAll) and let the customer map their payroll cost centers to the IDs returned. The same line-item shape works regardless of whether the downstream system calls them classes, tracking categories, or dimensions, with one exception worth planning for: NetSuite journal lines take `department_id` and `location_id` instead of `tracking_categories`, so a dimensional payroll model needs both paths. Each split still has to balance within the entry as a whole.

```json
{
  "description": "Gross wages: Engineering",
  "type": "debit",
  "total_amount": 31200.00,
  "ledger_account": {
    "id": "led_01H8X9Y2A3B4C5D6E7F8G9H0",
    "nominal_code": "6000"
  },
  "tracking_categories": [
    { "id": "trk_dept_eng", "name": "Engineering" }
  ]
}
```

Or through the SDK, tagging both sides of a departmental split so the entry still sums to zero:

```ts
const { getTrackingCategoriesResponse } = await apideck.accounting.trackingCategories.list({
  serviceId: 'quickbooks'
})

const engineering = getTrackingCategoriesResponse?.data?.find((c) => c.name === 'Engineering')
if (!engineering?.id) throw new Error('Engineering cost center is not mapped downstream')

await apideck.accounting.journalEntries.create({
  serviceId: 'quickbooks',
  journalEntry: {
    title: 'Payroll run 2025-PR-0042: Engineering',
    postedAt: new Date('2025-03-15'),
    lineItems: [
      {
        description: 'Gross wages: Engineering',
        type: 'debit',
        totalAmount: 31200,
        ledgerAccount: ledgerAccount('gross_wages'),
        trackingCategories: [{ id: engineering.id, name: 'Engineering' }]
      },
      {
        description: 'Net pay clearing: Engineering',
        type: 'credit',
        totalAmount: -31200,
        ledgerAccount: ledgerAccount('net_pay_clearing'),
        trackingCategories: [{ id: engineering.id, name: 'Engineering' }]
      }
    ]
  }
})
```

The `.find()` above reads a single page. Cost center lists are usually short, but if the customer has more than 200, paginate through `meta.cursors.next` rather than letting a miss fall through to an untagged line.

## What actually bites people

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

**QuickBooks Online.** It has `memo` on a journal entry but no `title`, so a pay run reference sent only as `title` disappears. Put it in `memo` and `number`. QuickBooks is also the only one of the three whose ledger accounts carry no `nominal_code` at all, only `code`, and its journal line `ledger_account` reference accepts just `id` and `name`. Key your mapping table on the account `id` and treat any nominal code you show in the UI as a label, not an identifier.

**Xero.** The mirror image: `title` is supported and `memo` is not, so the same payload needs its reference in the other field. Xero is also the only one of the three with no `delete` on journal entries, and its `update` is unsupported downstream, which means a posted manual journal cannot be amended or removed through Apideck. Correct a bad pay run with a reversing entry, not an edit. Line `ledger_account` references take `id` and `code` here, not `name`, and there is no `currency` field on the entry.

**NetSuite.** Journal line items expose no `tracking_categories` whatsoever, only `department_id` and `location_id`, even though NetSuite's standalone tracking categories resource is fully readable and writable. Departmental payroll splits have to go through those two ID fields. At the header, `subsidiary` is not in coverage at all and `company_id` is the supported scope field, so scope a multi-entity posting with `company_id` rather than trying to set a subsidiary on the entry. NetSuite lines also carry no `tax_rate`, `tax_amount`, or `sub_total`, so any tax split has to be its own line against its own account.

## Connector-specific behavior

Coverage and quirks vary across the connector catalog. Read-only support means the connector can list and retrieve journal entries through Apideck but cannot post new ones, so payroll posting is not possible there through this resource.

| Connector | Notes |
| --- | --- |
| `access-financials` | Journal entries not in coverage. Skip this connector for payroll export. |
| `acumatica` | Read and write supported. Standard mapping. No known quirks beyond the unified model. |
| `banqup` | Journal entries not in coverage. |
| `campfire` | Read and write supported. Standard mapping. No known quirks beyond the unified model. |
| `clearbooks-uk` | Journal entries not in coverage. |
| `digits` | Journal entries are read-only through Apideck, so payroll posting is not supported on this connector. |
| `dualentry` | Read and write supported. Standard mapping. No known quirks beyond the unified model. |
| `exact-online` | Read and write supported. Tracking categories are not in coverage for this connector, so dimensional splits must be expressed as separate lines per GL account. |
| `exact-online-nl` | Read and write supported. Same shape as Exact Online. |
| `exact-online-uk` | Read and write supported on journal entries. Bills and credit notes are read-only on this connector. |
| `freeagent` | Journal entries are read-only through Apideck, so payroll posting is not supported on this connector. |
| `freshbooks` | Journal entries are read-only through Apideck, so payroll posting is not supported on this connector. |
| `intuit-enterprise-suite` | Read and write supported. Tracking categories are in coverage on line items. There are no `department_id` or `location_id` line fields here. |
| `kashflow` | Journal entries are read-only through Apideck, so payroll posting is not supported on this connector. |
| `microsoft-dynamics-365-business-central` | Read and write supported. Journal entries post to the default general journal batch unless a `pass_through` overrides the template. `title`, `memo`, and `journal_symbol` are all outside coverage here, so carry the pay run reference on the line descriptions. Tracking categories are in coverage on line items. |
| `moneybird` | Read and write supported. Tracking categories are in coverage and can be applied per line. |
| `mrisoftware` | Read and write supported. Standard mapping. No known quirks beyond the unified model. |
| `myob` | Journal entries not in coverage. Consider posting via expenses or skipping this connector. |
| `myob-acumatica` | Read and write supported. Same shape as Acumatica. |
| `netsuite` | Read and write supported. `company_id` is the supported scope field and `subsidiary` is not in coverage. Line items take `department_id` and `location_id`, not `tracking_categories`. |
| `odoo` | Read and write supported. Posts to `account.move`; the move stays in draft unless the downstream company auto-posts journal entries. |
| `pennylane` | Read and write supported, apart from `delete`. Tracking categories are not in coverage on journal entry line items. |
| `procountor-fi` | Read and write supported. Standard mapping. No known quirks beyond the unified model. |
| `quickbooks` | Read and write supported. `tracking_categories` map to QuickBooks Classes, and `department_id` and `location_id` are also available per line. No `title` field: use `memo`. |
| `rillet` | Read and write supported. Standard mapping. No known quirks beyond the unified model. |
| `sage-business-cloud-accounting` | Journal entries are read-only through Apideck, so payroll posting is not supported on this connector. |
| `sage-intacct` | Read and write supported. Posts to a GL Journal; the target journal symbol can be set via `journal_symbol`. Dimensions are in coverage as tracking categories. |
| `sage-intacct-rest` | Read and write supported, with the same journal entry shape as the legacy Sage Intacct connector including `journal_symbol` and tracking categories. |
| `stripe` | Journal entries not in coverage. Stripe is not a general ledger. |
| `visma-netvisor` | Journal entries are read-only through Apideck, so payroll posting is not supported on this connector. |
| `wave` | Journal entries not in coverage. |
| `workday` | Read and write supported on journal entries. Tracking categories are not in coverage for this connector, so worktag-style dimensions cannot be set through the unified `tracking_categories` field; verify in the coverage matrix before depending on dimensional splits here. |
| `xero` | Read and write supported. Posts as a Manual Journal. The unified `tracking_categories` field maps to existing Xero Tracking Categories (creating new categories through Apideck is not supported), with the usual two-dimension limit per line. |
| `yuki` | List and create supported. Retrieving a single entry and deleting are not. Tracking categories are not in coverage on journal entry line items. |
| `zoho-books` | Read and write supported. Standard mapping. No known quirks beyond the unified model. |

### NetSuite

On a OneWorld account a journal entry needs entity context, but the unified `subsidiary` object is not writable on NetSuite journal entries: `company_id` is the supported scope field. Resolve the customer's entity up front through [`GET /accounting/subsidiaries`](/apis/accounting/reference#operation/subsidiariesAll), which NetSuite does support, store it alongside the GL mapping, and pass it as `company_id`. Keep the entire pay run inside one entity. Intercompany payroll allocations need a separate intercompany journal.

### Xero

Xero Manual Journals support a maximum of two tracking categories per line. If a customer's payroll cost model needs more dimensions than that, collapse them into a single concatenated category before posting, or split the journal entry across multiple lines per employee group.

### Sage Intacct

Set `journal_symbol` to the customer's payroll journal code (commonly `PR` or `PJ`) so the entry lands in the right book. Sage Intacct enforces dimension restrictions per GL account; if a line is rejected, inspect the response and remove the offending dimension before retrying.

### Microsoft Dynamics 365 Business Central

Business Central journal entries post into a general journal batch. Apideck targets the default batch unless the downstream account is configured otherwise. For multi-batch setups, use `pass_through` to specify the batch name.

### Odoo

Odoo creates the underlying `account.move` in `draft` unless the company has been configured to auto-post journal entries. If the workflow expects the entry to be posted immediately, confirm with the customer that auto-posting is enabled, or post the entry manually in Odoo after creation.

## Visit the demo

The HRIS and Payroll demo runs this flow against a live connected ledger: it syncs employees out of an HRIS, then posts the pay run to the general ledger as a journal entry. It is the one demo wired to both APIs at once, across 42 accounting and 60 HRIS connectors, with nothing to set up.

## Next steps

- [Journal Entries reference](/apis/accounting/reference#tag/Journal-Entries)
- [Ledger Accounts reference](/apis/accounting/reference#tag/Ledger-Accounts) for building the GL picker
- [Tracking Categories reference](/apis/accounting/reference#tag/Tracking-Categories) for departmental and project splits
- [Handling Bills and Expenses](/guides/expenses-bills) for non-payroll spend posting
