# Accounts Payable Automation

By the end of this page you'll have created a vendor, pushed a bill with its lines coded to expense accounts, and cleared it with an allocated bill payment, inside a customer's QuickBooks, Xero, or Sage Intacct. One integration against the unified [Accounting API](/apis/accounting/reference), three calls, about ten minutes.

_Your product sends bills and bill payments to the Apideck Accounting API, which writes them into whichever ledger your customer uses and returns the outstanding balance._

Payment allocations are what let the downstream system flip a bill to paid using its own rules, [Vault](/guides/vault) handles auth and token refresh instead of one OAuth or API-key flow per system, and the same contract covers reading and writing ledger accounts, suppliers, tax rates, and tracking categories.

## Resource mapping

The unified `Bill` resource maps to the closest AP document in each system. `BillPayment` maps to the payment object the system uses for vendor disbursements.

| Connector | Bill maps to | Bill payment maps to |
| --- | --- | --- |
| QuickBooks | `Bill` | `BillPayment` |
| Xero | `Invoice` of type `ACCPAY` | `Payment` against an AP invoice |
| NetSuite | `Vendor Bill` | `Vendor Payment` |
| Sage Intacct | `AP Bill` | `AP Payment` |
| Microsoft Dynamics 365 Business Central | `Purchase Invoice` | `Vendor Payment Journal` line |
| Exact Online | `Purchase Invoice` | `Payment` |
| Odoo | `account.move` (type `in_invoice`) | `account.payment` |
| Sage Business Cloud Accounting | `Purchase Invoice` | `Purchase Payment` |
| FreshBooks | `Bill` | `Bill Payment` |
| Zoho Books | `Bill` | `Vendor Payment` |

## Walkthrough

The example below ingests a vendor invoice, codes its lines to expense accounts, and records a payment that clears the bill. All requests target `https://unify.apideck.com`.

Each step 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'
})
```

### 1. Resolve or create the supplier

First, look up the vendor by name to avoid duplicates. Send this to [`GET /accounting/suppliers`](/apis/accounting/reference#operation/suppliersAll) with a filter:

```
GET /accounting/suppliers?filter[company_name]=Northwind%20Cloud%20Hosting
Authorization: Bearer ${APIDECK_API_KEY}
x-apideck-app-id: ${APIDECK_APP_ID}
x-apideck-consumer-id: ${APIDECK_CONSUMER_ID}
x-apideck-service-id: quickbooks
```

If the supplier doesn't exist, create one via [`POST /accounting/suppliers`](/apis/accounting/reference#operation/suppliersAdd):

```json
{
  "company_name": "Northwind Cloud Hosting",
  "display_name": "Northwind Cloud Hosting",
  "tax_number": "GB123456789",
  "currency": "USD",
  "addresses": [
    {
      "type": "primary",
      "line1": "455 Market Street",
      "line2": "Suite 1400",
      "city": "San Francisco",
      "state": "CA",
      "postal_code": "94105",
      "country": "US"
    }
  ],
  "emails": [{ "email": "ap@northwindcloud.example", "type": "primary" }],
  "phone_numbers": [{ "number": "+1 415 555 0188", "type": "primary" }],
  "payment_method": "ACH",
  "status": "active"
}
```

Or through the SDK, looking the vendor up first so you do not create duplicates:

```ts
const { getSuppliersResponse } = await apideck.accounting.suppliers.list({
  serviceId: 'quickbooks',
  filter: { companyName: 'Northwind Cloud Hosting' },
  limit: 1
})

const supplierId =
  getSuppliersResponse?.data?.[0]?.id ??
  (
    await apideck.accounting.suppliers.create({
      serviceId: 'quickbooks',
      supplier: {
        companyName: 'Northwind Cloud Hosting',
        displayName: 'Northwind Cloud Hosting',
        taxNumber: 'GB123456789',
        currency: 'USD',
        emails: [{ email: 'ap@northwindcloud.example', type: 'primary' }],
        status: 'active'
      }
    })
  ).createSupplierResponse?.data?.id

if (!supplierId) throw new Error('Could not resolve a supplier to bill')
```

Persist the returned `id` (e.g. `acct_supp_01H8X9Y2A3B4C5D6E7F8G9H0J1`) on your vendor record so subsequent bills can link to it. Guard it before you use it: passing `undefined` into the bill's `supplier` reference posts an unlinked bill rather than failing.

### 2. Fetch the chart of accounts for line coding

Pull the expense and liability accounts you'll post against using [`GET /accounting/ledger-accounts`](/apis/accounting/reference#operation/ledgerAccountsAll). Filter by classification to keep the response focused:

```
GET /accounting/ledger-accounts?filter[classification]=expense
```

Or through the SDK:

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

const expenseAccounts = getLedgerAccountsResponse?.data ?? []
void expenseAccounts
```

Cache the resulting account IDs (and the AP control account) in your category mapping. The bill creation in step 3 references those IDs directly on each line. `limit` caps at 200 and a real chart of accounts is usually longer, so page through `meta.cursors.next`: a mapping miss yields `undefined` and posts an uncoded line without an error.

### 3. Ingest the bill

With the supplier and account IDs in hand, post the bill to [`POST /accounting/bills`](/apis/accounting/reference#operation/billsAdd):

```json
{
  "bill_number": "NW-2025-04412",
  "supplier": {
    "id": "acct_supp_01H8X9Y2A3B4C5D6E7F8G9H0J1"
  },
  "currency": "USD",
  "bill_date": "2025-03-04",
  "due_date": "2025-04-03",
  "reference": "PO-9921",
  "po_number": "PO-9921",
  "line_items": [
    {
      "description": "Production Kubernetes cluster, March 2025",
      "quantity": 1,
      "unit_price": 4200.00,
      "total_amount": 4200.00,
      "ledger_account": {
        "id": "acct_ledger_01HQR4F2T8YZA3KQVN9XJ7M5RP",
        "nominal_code": "6310",
        "name": "Cloud Hosting"
      },
      "tracking_categories": [
        { "id": "trk_dept_eng", "name": "Engineering" }
      ]
    },
    {
      "description": "Egress and CDN overage",
      "quantity": 1,
      "unit_price": 318.47,
      "total_amount": 318.47,
      "ledger_account": {
        "id": "acct_ledger_01HQR4F31KZB6F2N7P9Q8T0V2W",
        "nominal_code": "6315",
        "name": "Bandwidth"
      }
    }
  ],
  "sub_total": 4518.47,
  "total_tax": 0.00,
  "total": 4518.47,
  "status": "authorised"
}
```

Two things in that body are easy to get wrong. The vendor is linked through the nested `supplier` object, not a top-level `supplier_id`: there is no such field on the bill, so a payload built that way posts a bill with no vendor attached. And the bill goes out as `authorised`, not `draft`, because a draft bill is unposted and will refuse the payment allocation in step 5. QuickBooks does not carry `status` in its supported field set for bills, so it derives the state itself and the field is dropped there. It is load-bearing on Xero and Sage Intacct, which is why the snippet sends it.

Or through the SDK:

```ts
const { createBillResponse } = await apideck.accounting.bills.create({
  serviceId: 'quickbooks',
  bill: {
    billNumber: 'NW-2025-04412',
    supplier: { id: supplierId },
    currency: 'USD',
    billDate: new Date('2025-03-04'),
    dueDate: new Date('2025-04-03'),
    reference: 'PO-9921',
    poNumber: 'PO-9921',
    lineItems: [
      {
        description: 'Production Kubernetes cluster, March 2025',
        quantity: 1,
        unitPrice: 4200,
        totalAmount: 4200,
        ledgerAccount: { id: 'acct_ledger_01HQR4F2T8YZA3KQVN9XJ7M5RP', nominalCode: '6310' },
        trackingCategories: [{ id: 'trk_dept_eng', name: 'Engineering' }]
      },
      {
        description: 'Egress and CDN overage',
        quantity: 1,
        unitPrice: 318.47,
        totalAmount: 318.47,
        ledgerAccount: { id: 'acct_ledger_01HQR4F31KZB6F2N7P9Q8T0V2W', nominalCode: '6315' }
      }
    ],
    subTotal: 4518.47,
    totalTax: 0,
    total: 4518.47,
    status: 'authorised'
  }
})

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

The response returns the bill `id`. Store it alongside your internal AP record so payment events can be applied back to it. Note the date-only fields: `billDate` and `dueDate` take a plain `Date`, and there is no `RFCDate` export to wrap them in.

### 4. Attach the source document

For audit trail, upload the original PDF invoice with [`POST /accounting/attachments/{reference_type}/{reference_id}`](/apis/accounting/reference#operation/attachmentsUpload), using `bill` as the `reference_type` and the bill ID as the `reference_id`. Attachment support is connector-dependent (see the matrix below); fall back to storing the file in your own object storage for connectors that don't accept uploads.

### 5. Record the bill payment

When the bill is paid, post a payment that allocates against it via [`POST /accounting/bill-payments`](/apis/accounting/reference#operation/billPaymentsAdd):

```json
{
  "transaction_date": "2025-04-02T16:00:00Z",
  "supplier": {
    "id": "acct_supp_01H8X9Y2A3B4C5D6E7F8G9H0J1",
    "display_name": "Northwind Cloud Hosting"
  },
  "currency": "USD",
  "total_amount": 4518.47,
  "reference": "ACH-2025-04-02-00471",
  "payment_method": "ACH",
  "account": {
    "id": "acct_ledger_01HQR4F4P9R2VX5N6T8Q1W3Y4Z",
    "nominal_code": "1100",
    "name": "Operating Bank Account"
  },
  "type": "accounts_payable",
  "status": "authorised",
  "reconciled": false,
  "allocations": [
    {
      "id": "acct_bill_01HQR8M5Y2K3X7N6P9Q1V4T8R5",
      "type": "bill",
      "amount": 4518.47
    }
  ]
}
```

The bill payment links its vendor the same way the bill does, through a nested `supplier` object. QuickBooks does not carry `status`, `type`, or `reconciled` in its supported field set for bill payments, so those three are dropped there and the allocation is the only thing that moves the bill. They are honored on connectors that do model payment state, such as Xero and Sage Intacct, which is why the snippet sends them. Or through the SDK:

```ts
await apideck.accounting.billPayments.create({
  serviceId: 'quickbooks',
  billPayment: {
    transactionDate: new Date('2025-04-02T16:00:00Z'),
    totalAmount: 4518.47,
    currency: 'USD',
    supplier: { id: supplierId },
    reference: 'ACH-2025-04-02-00471',
    paymentMethod: 'ACH',
    account: { id: 'acct_ledger_01HQR4F4P9R2VX5N6T8Q1W3Y4Z', nominalCode: '1100' },
    type: 'accounts_payable',
    status: 'authorised',
    reconciled: false,
    allocations: [{ id: billId, type: 'bill', amount: 4518.47 }]
  }
})
```

The downstream system computes the bill's paid status from the allocations. There's no separate "mark as paid" call, and omitting `allocations` posts the cash against the vendor while the bill stays open. For partial payments, credit memos and overpayments, see [Mark Invoices and Bills as Paid](/guides/mark-invoices-as-paid).

## What actually bites people

Three constraints on the connectors most AP products start with, each read off the live coverage matrix rather than the unified schema.

**QuickBooks Online.** `status` is not in the supported field set for bills, so it comes back empty no matter what you sent and QuickBooks derives the state itself. Drive your open, part paid, and paid states off `balance` against `total` instead. Bill payments drop `status`, `type`, and `reconciled` the same way, which leaves the allocation as the only thing that actually moves the bill. The walkthrough snippets still send these fields because they are load-bearing on the connectors that do model payment state, Xero and Sage Intacct among them.

**Xero.** Bill payments have no update operation, only list, add, get, and delete, so correcting one means deleting it and posting a replacement. Xero bill lines also reference the ledger account by `code` only and not by `id`, so send `ledger_account.code` and keep the account code in your category mapping alongside the ID you use elsewhere. In exchange, Xero is the only one of the three that lets you filter bill payments by `bill_id`.

**Sage Intacct.** A bill line carries `total_amount` and nothing else numeric: `quantity` and `unit_price` are not in coverage, so compute the extended amount yourself before posting. Dimensions sit on the line as `department_id` and `location_id`, and the departments and locations resources support only list and get, so those values must already exist in Intacct and belong behind a dropdown rather than a free-text field. Vendor lookups take `filter[display_name]`; `filter[company_name]` is not supported here even though QuickBooks and Xero both accept it.

## Connector-specific behavior

Coverage varies across the catalog. The table below summarizes what each connector supports for the supplier, bill, and bill-payment resources used in this flow. "Read-only" means the connector exposes the resource but Apideck cannot create or update records there yet. "Limited" and "not in coverage" assignments come from the live coverage matrix; verify in Vault before relying on edge behavior.

| Connector | Notes |
| --- | --- |
| `access-financials` | Suppliers and unified payments are writable, but bills and bill payments are not in coverage. AP automation requires a connector with bill write support; use this one for vendor sync only. |
| `acumatica` | Suppliers and bills writable. Bill payments not in coverage. Record the disbursement against the bill using [`POST /accounting/payments`](/apis/accounting/reference#operation/paymentsAdd) with `type: accounts_payable`. |
| `banqup` | Not in coverage for suppliers, bills, or bill payments. Skip for AP automation. |
| `campfire` | Suppliers and bills writable. Bill payments are limited. Verify payment write behavior in Vault before building automated reconciliation. |
| `clearbooks-uk` | Suppliers and bills are read-only; bill payments not in coverage. Use for AP visibility only, not for ingest. |
| `digits` | Suppliers read-only. Bills and bill payments not in coverage. AP ingest is not supported. |
| `dualentry` | Full read+write across suppliers, bills, and bill payments. Standard mapping. |
| `exact-online` | Bills and bill payments are writable. Suppliers are read-only, so vendors must already exist in Exact Online. Resolve existing suppliers via `GET /accounting/suppliers` and have the customer create new vendors in Exact directly. |
| `exact-online-nl` | Same constraints as `exact-online`. Suppliers read-only, bills and bill payments writable. |
| `exact-online-uk` | Suppliers and bills are read-only. Bill payments are writable, but you can only record payments against bills already present in Exact UK. Treat as read-mostly for ingest. |
| `freeagent` | Suppliers and bills are read-only. Bill payments not in coverage. Use for AP read-back only. |
| `freshbooks` | Full read+write across suppliers, bills, and bill payments. Standard mapping. |
| `intuit-enterprise-suite` | Full read+write across suppliers, bills, and bill payments. Also supports attachments, departments, and locations. |
| `kashflow` | Suppliers read-only. Bills and bill payments not in coverage. Skip for AP ingest. |
| `microsoft-dynamics-365-business-central` | Full read+write across suppliers, bills, and bill payments. Bill payments map to vendor payment journal lines; allocations are required to apply against the originating purchase invoice. |
| `moneybird` | Suppliers and bills writable. Bill payments are limited. Confirm whether allocations are accepted before relying on automated bill clearing. |
| `mrisoftware` | Suppliers and bills writable. Bill payments not in coverage and unified payments not in coverage either. Use [`POST /accounting/journal-entries`](/apis/accounting/reference#operation/journalEntriesAdd) when a payment record is required. |
| `myob` | Suppliers and bills not in coverage. Skip for AP automation. |
| `myob-acumatica` | Suppliers and bills writable. Bill payments not in coverage. Use [`POST /accounting/payments`](/apis/accounting/reference#operation/paymentsAdd) with allocations to record disbursements. |
| `netsuite` | Full read+write across suppliers, bills, and bill payments. Subsidiary is required on every bill in OneWorld accounts. Departments, classes, and locations are read-only and must be selected from existing values. |
| `odoo` | Full read+write across suppliers, bills, and bill payments. Bills are stored as `account.move` records of type `in_invoice` and must be confirmed before payment allocation. |
| `pennylane` | Suppliers and bills writable. Bill payments not in coverage and unified payments are not in coverage either. Track payment status outside Pennylane or use journal entries. |
| `procountor-fi` | Suppliers and bills writable. Bill payments not in coverage. Unified payments are read-only, so you can read disbursement status but not write it. |
| `quickbooks` | Full read+write across suppliers, bills, and bill payments. Attachments, departments, and locations are all writable. |
| `rillet` | Suppliers and bills writable. Bill payments are limited. Verify allocation behavior before building automated reconciliation. |
| `sage-business-cloud-accounting` | Full read+write across suppliers, bills, and bill payments. Standard mapping. |
| `sage-intacct` | Full read+write across suppliers, bills, and bill payments. Dimensions (departments, locations) are read-only and must already exist in Intacct. |
| `sage-intacct-rest` | Not in coverage for any of the resources used in this flow. Skip. |
| `stripe` | Suppliers and bills not in coverage. Stripe is not an AP system; skip for vendor bill workflows. |
| `visma-netvisor` | Suppliers and bills writable. Bill payments not in coverage. Use [`POST /accounting/payments`](/apis/accounting/reference#operation/paymentsAdd) with `type: accounts_payable` to record disbursements. |
| `wave` | Suppliers read-only and bills not in coverage. Skip for AP ingest. |
| `workday` | Suppliers and bills writable. Bill payments are read-only, so payment data flows from Workday into your product, not the other way. |
| `xero` | Full read+write across suppliers, bills, and bill payments. Bills are AP invoices (`ACCPAY`); the bill-payment endpoint allocates against them. |
| `yuki` | Suppliers writable, bills are read-only, bill payments not in coverage. Use for vendor sync only. |
| `zoho-books` | Full read+write across suppliers, bills, and bill payments. Standard mapping. |

### NetSuite

NetSuite OneWorld accounts require subsidiary context on the bill. Read available subsidiaries from [`GET /accounting/subsidiaries`](/apis/accounting/reference#operation/subsidiariesAll). Lines also reference `department_id`, `class_id`, and `location_id`, but these dimensions are read-only on NetSuite; surface them as dropdowns sourced from the connector rather than free-text fields.

### Exact Online

Suppliers in Exact Online are read-only across `exact-online`, `exact-online-nl`, and `exact-online-uk`. Vendor onboarding must happen in Exact's UI; your product can only resolve and reference existing suppliers. Bills and bill payments are writable on `exact-online` and `exact-online-nl`; on `exact-online-uk`, bills are read-only too, so only payments against pre-existing bills can be recorded.

### Acumatica, MYOB Acumatica, Visma Netvisor, Pennylane, Procountor

These connectors support bill creation but not the dedicated `bill-payments` endpoint. For the three where the unified `payments` endpoint is writable (`acumatica`, `myob-acumatica`, `visma-netvisor`), record the disbursement via [`POST /accounting/payments`](/apis/accounting/reference#operation/paymentsAdd) with `type: accounts_payable` and an `allocations` entry referencing the bill ID. For `pennylane` (payments not in coverage) and `procountor-fi` (payments read-only), neither bill payments nor writable payments are available, so payment status has to be tracked outside the connector.

### Microsoft Dynamics 365 Business Central

Bill payments map to lines on a vendor payment journal. Always include `allocations` referencing the bill ID; without an allocation, the journal line posts but does not clear the bill.

## Next steps

- [Mark Invoices and Bills as Paid](/guides/mark-invoices-as-paid) for the payment-allocation pattern in more depth
- [Handling Bills and Expenses](/guides/expenses-bills) for the broader Bills vs Expenses decision
- [Accounting API reference](/apis/accounting/reference) for the full unified schema
- [`GET /accounting/aged-creditors`](/apis/accounting/reference#operation/agedCreditorsOne) for AP aging reports without recomputing buckets from raw bills
