# Sync ecommerce sales into accounting

Online merchants need store revenue to land in the general ledger with the right accounts, tax rates, and payment reconciliation. This guide reads orders, customers, and products from a merchant's storefront through the [Ecommerce API](/apis/ecommerce/reference) and posts them to the merchant's ledger through the [Accounting API](/apis/accounting/reference). It covers Shopify, BigCommerce, WooCommerce, Magento, Amazon Seller Central, Walmart, eBay, Etsy, TikTok Shop, Wix, Lightspeed, PrestaShop, Picqer, Shopware, and Bol.com on the storefront side, and QuickBooks, Xero, NetSuite, Sage Intacct, Exact Online, and others on the ledger side.

## One integration for every ledger

Posting ecommerce revenue into an accounting system touches two integration surfaces that both vary by connector. The Ecommerce API normalizes the storefront side and the Accounting API normalizes the ledger side, so the sync layer does not need provider-specific code.

- One order schema across storefronts, instead of Shopify line items, Amazon order items, and Magento quote items.
- One set of ledger objects (`Invoice`, `Payment`, `JournalEntry`, `CreditNote`) regardless of which ledger the merchant uses.
- One pagination, filtering, and `updated_since` convention across every storefront.

## Resource mapping

The sync is a cross-API flow, so the mapping runs between unified resources rather than from a single resource down to one downstream object.

| Ecommerce source | Accounting destination |
| --- | --- |
| `Order` (unpaid, awaiting fulfillment) | `Invoice` with a later `Payment` allocation |
| `Order` (paid at checkout) | `Invoice` plus `Payment`, or a summary `JournalEntry` |
| `Order` line item | `Invoice` line against a revenue `LedgerAccount` |
| `Order` line item `tax_rate` / `tax_amount` | `TaxRate` on each invoice line |
| `Order` `shipping_cost` | Dedicated invoice line against a shipping income account |
| `Order` `total_discount` | Negative invoice line or a dedicated discount account |
| Gateway payout (Shopify Payments, Stripe, PayPal, marketplace settlements) | `Payment` with the receivable cleared, or a transfer out of a clearing bank account |
| `Order` `refunds` | `CreditNote` allocated to the original invoice, plus a `Refund` for the cash returned |

## Walkthrough

### 1. Pick a posting model

Two models cover almost every ecommerce accounting sync. The right choice depends on order volume and how much per-order visibility the merchant's accountant wants in the ledger.

With one invoice per order, each ecommerce order becomes a single `Invoice` in the ledger, with one line per product, one line for shipping, one line for discounts, and tax applied via `TaxRate`. Payment is recorded as a separate `Payment` allocated to the invoice. Pick this when daily order count is below a few hundred, when the merchant reconciles customer by customer, or when the ledger needs individual-order traceability (common for B2B merchants on Shopify Plus or BigCommerce).

With a daily summary journal entry per store and currency, all orders for a given store and currency on a given day are aggregated into one `JournalEntry` that debits a clearing bank account (the gateway's holding account) and credits revenue, shipping income, and tax liability accounts, with discounts as a debit. Pick this when daily order volume is in the thousands (typical for high-volume Shopify, Amazon, or TikTok Shop sellers), when the merchant does not want per-customer records in the ledger, or when the ledger connector throttles writes.

If the merchant has multiple stores or currencies, run one summary per `(store_id, currency)` pair per day. Read the store id once per connection from [Store](/apis/ecommerce/reference#operation/storesOne).

### 2. Pull new and changed orders from the storefront

Use `filter[updated_since]` to only fetch what has moved since the last successful sync. Store the high-watermark timestamp per `(consumer_id, service_id)` pair.

```http
GET /ecommerce/orders?filter[updated_since]=2024-11-18T00:00:00Z&limit=200 HTTP/1.1
Host: unify.apideck.com
Authorization: Bearer {API_KEY}
x-apideck-app-id: {APP_ID}
x-apideck-consumer-id: {CONSUMER_ID}
x-apideck-service-id: shopify
```

See [Orders list](/apis/ecommerce/reference#operation/ordersAll). Pass `meta.cursors.next` back as the `cursor` query parameter until it is empty, then advance the watermark to the maximum `updated_at` observed. Subtract a few minutes from the watermark before the next call to absorb clock skew. The overlap does not create duplicates, because the lookup-before-write check in step 5 skips anything already posted.

An order payload coming back from the Ecommerce API looks like this, trimmed to the fields that matter for posting. Monetary values on ecommerce orders are strings; parse them as decimals before summing.

```json
{
  "id": "6253748391204",
  "order_number": "1047",
  "status": "active",
  "payment_status": "paid",
  "fulfillment_status": "pending",
  "currency": "USD",
  "customer": {
    "id": "7891234567890",
    "first_name": "Nora",
    "last_name": "Valen",
    "emails": [{ "email": "nora.v@kettlecoffee.example", "type": "primary" }]
  },
  "line_items": [
    {
      "id": "15923847562",
      "product_id": "8471029384756",
      "sku": "KC-ESPRESSO-250",
      "name": "Single Origin Espresso 250g",
      "quantity": "2",
      "unit_price": "18.50",
      "total_amount": "37.00",
      "tax_amount": "2.96"
    }
  ],
  "sub_total": "37.00",
  "shipping_cost": "6.00",
  "total_discount": "5.00",
  "total_tax": "2.96",
  "total_amount": "40.96",
  "refunds": [],
  "created_at": "2024-11-18T09:14:22Z",
  "updated_at": "2024-11-18T09:14:22Z"
}
```

### 3. Resolve the destination customer and ledger accounts

Before posting, resolve the ledger-side identifiers once per sync and cache them:

- The merchant's revenue ledger accounts, fetched via [Ledger accounts list](/apis/accounting/reference#operation/ledgerAccountsAll) with `filter[classification]=revenue`. Some ledgers classify sales accounts as `income`, so query both values. Map SKUs or product categories to accounts in the settings UI.
- A shipping income account and a discount account.
- A `TaxRate` id per rate from [Tax rates list](/apis/accounting/reference#operation/taxRatesAll), or a tax liability account per rate for the summary model.
- A clearing bank account per payment gateway (for example "Shopify Payments Clearing") so payouts can later be reconciled against actual bank deposits.
- For the invoice-per-order model, a ledger-side customer. Match on `filter[email]` via [Customers list](/apis/accounting/reference#operation/customersAll), and if nothing matches, create one via [Customers create](/apis/accounting/reference#operation/customersAdd).

### 4a. Post one invoice per order

Check first whether an invoice already exists for this ecommerce order. Put a deterministic value derived from the order in the invoice `number` and look it up with `filter[number]`. Also keep your own table of `(service_id, order_id)` to ledger invoice id, because some ledgers assign their own invoice numbers and ignore the one you send.

```http
GET /accounting/invoices?filter[number]=SHOP-1047 HTTP/1.1
Host: unify.apideck.com
Authorization: Bearer {API_KEY}
x-apideck-app-id: {APP_ID}
x-apideck-consumer-id: {CONSUMER_ID}
x-apideck-service-id: xero
```

If the response is empty and your own table has no entry, send the invoice. Carry the source order id in `reference` as well, so the accountant can trace the invoice back to the store.

```json
{
  "number": "SHOP-1047",
  "reference": "shopify:6253748391204",
  "invoice_date": "2024-11-18",
  "due_date": "2024-11-18",
  "customer": {
    "id": "cust_01HQ8RJ4YF7P2N3K5V8M6B9D4T",
    "display_name": "Nora Valen"
  },
  "currency": "USD",
  "line_items": [
    {
      "description": "Single Origin Espresso 250g",
      "quantity": 2,
      "unit_price": 18.50,
      "total_amount": 37.00,
      "ledger_account": { "id": "400-sales-coffee" },
      "tax_rate": { "id": "tax-us-ca-sales-8" }
    },
    {
      "description": "Shipping",
      "quantity": 1,
      "unit_price": 6.00,
      "total_amount": 6.00,
      "ledger_account": { "id": "410-shipping-income" }
    },
    {
      "description": "Order discount",
      "type": "discount",
      "quantity": 1,
      "unit_price": -5.00,
      "total_amount": -5.00,
      "ledger_account": { "id": "415-sales-discounts" }
    }
  ],
  "sub_total": 38.00,
  "total_tax": 2.96,
  "total": 40.96,
  "status": "authorised"
}
```

Send this to `POST /accounting/invoices`. See [Invoices create](/apis/accounting/reference#operation/invoicesAdd). Store the returned invoice `id` against the order.

If the order was paid at checkout, record the gateway payment and allocate it to the new invoice with [Payments create](/apis/accounting/reference#operation/paymentsAdd):

```json
{
  "transaction_date": "2024-11-18T09:14:22Z",
  "currency": "USD",
  "total_amount": 40.96,
  "payment_method": "Credit Card",
  "reference": "shopify_payments:po_1QRk7g2eZvKYlo2C",
  "customer": { "id": "cust_01HQ8RJ4YF7P2N3K5V8M6B9D4T" },
  "account": { "id": "bank-shopify-payments-clearing" },
  "type": "accounts_receivable",
  "allocations": [
    {
      "id": "inv_01HQ8S0Y7KF9D2N4P6V8MX3B5R",
      "type": "invoice",
      "amount": 40.96
    }
  ]
}
```

The payments list has no `reference` filter, so dedupe payments on your side: store the returned payment `id` against the order before marking it done. You can also list [Payments](/apis/accounting/reference#operation/paymentsAll) with `filter[invoice_id]` to confirm nothing is allocated to the invoice yet.

Post the payment to a clearing bank account, not the operating bank account. The actual bank deposit from Shopify Payments or Stripe arrives days later, net of fees. Reconcile the clearing account against the real payout to book gateway fees as an expense.

### 4b. Post a daily summary journal entry

For high-volume stores, aggregate orders in memory per `(store_id, currency, posting_date)` and emit one `JournalEntry` per bucket. Debits go to the gateway clearing bank account for net cash received and to the discount account; credits go to revenue, shipping income, and tax liability accounts.

```json
{
  "title": "Shopify daily sales 2024-11-18",
  "source_id": "shopify-summary:store_42:USD:2024-11-18",
  "memo": "Shopify daily sales summary for 2024-11-18 (store 42, USD)",
  "posted_at": "2024-11-18T23:59:59Z",
  "currency": "USD",
  "line_items": [
    {
      "description": "Shopify Payments net receipts",
      "type": "debit",
      "total_amount": 12840.37,
      "ledger_account": { "id": "bank-shopify-payments-clearing" }
    },
    {
      "description": "Product sales",
      "type": "credit",
      "total_amount": 11920.00,
      "ledger_account": { "id": "400-sales-coffee" }
    },
    {
      "description": "Shipping income",
      "type": "credit",
      "total_amount": 480.00,
      "ledger_account": { "id": "410-shipping-income" }
    },
    {
      "description": "Discounts applied",
      "type": "debit",
      "total_amount": 310.00,
      "ledger_account": { "id": "415-sales-discounts" }
    },
    {
      "description": "Sales tax collected",
      "type": "credit",
      "total_amount": 750.37,
      "ledger_account": { "id": "220-sales-tax-payable" }
    }
  ]
}
```

Send this to `POST /accounting/journal-entries`. See [Journal entries create](/apis/accounting/reference#operation/journalEntriesAdd). Before posting, look for an existing entry with [Journal entries list](/apis/accounting/reference#operation/journalEntriesAll) and `filter[source_id]=shopify-summary:store_42:USD:2024-11-18`. Connectors that do not store `source_id` reject that filter with `UnsupportedFiltersError`; for those, rely on your own table of bucket key to journal entry id.

### 5. Make the loop safe to re-run

- Build deterministic keys from the source data. In the invoice-per-order model, derive `number` and `reference` from `(service_id, order_id)`. In the summary model, derive `source_id` from `(service_id, store_id, currency, posting_date)`.
- Look up before every write. Check your own key-to-id table first, then the ledger with `filter[number]` on invoices or `filter[source_id]` on journal entries. If a record exists, skip it. Do not patch on a retry unless the order itself changed.
- Advance the per-connection `updated_since` watermark only after the batch finishes. A crash mid-batch replays safely because of the lookup.

Send an [`Idempotency-Key` header](/guides/idempotency-keys) on [Invoices create](/apis/accounting/reference#operation/invoicesAdd) and [Journal entries create](/apis/accounting/reference#operation/journalEntriesAdd). Derive the key from the same deterministic key, and resend a timed-out request with the same key and body: the record is created at most once. Keys are remembered for 24 hours, or the connector's shorter window (6 minutes on Xero), and connectors that cannot honour the key reject the request with 400. The key protects a single retry. Payments, credit notes and refunds do not accept it, and a replay after the window still needs the lookup above.

### 6. Handle late changes

Orders change after the first post. Cancellations arrive hours later and refunds arrive days later. The incremental sync picks them up because `updated_at` changes, and the posting logic decides what to do with each one.

- If the order was cancelled before shipping and the invoice is still `draft`, void it by updating its status to `void` with [Invoices update](/apis/accounting/reference#operation/invoicesUpdate), or remove it with [Invoices delete](/apis/accounting/reference#operation/invoicesDelete), which is a hard delete. In Xero, QuickBooks, and NetSuite, voiding is the safe default because it keeps an audit trail. Record the void in the local sync log so the next run does not re-post.
- If the order was cancelled or refunded after the invoice was authorised and paid, issue a credit note against the original invoice and record the cash returned. Create the credit note with [Credit notes create](/apis/accounting/reference#operation/creditNotesAdd) and link it to the invoice through `allocations`:

  ```json
  {
    "number": "CN-SHOP-1047",
    "reference": "shopify-refund:6253748391204",
    "type": "accounts_receivable_credit",
    "customer": { "id": "cust_01HQ8RJ4YF7P2N3K5V8M6B9D4T" },
    "currency": "USD",
    "date_issued": "2024-11-22",
    "sub_total": 38.00,
    "total_tax": 2.96,
    "total_amount": 40.96,
    "line_items": [
      {
        "description": "Refund: Single Origin Espresso 250g",
        "quantity": 2,
        "unit_price": 18.50,
        "total_amount": 37.00,
        "ledger_account": { "id": "400-sales-coffee" },
        "tax_rate": { "id": "tax-us-ca-sales-8" }
      },
      {
        "description": "Refund: Shipping",
        "quantity": 1,
        "unit_price": 6.00,
        "total_amount": 6.00,
        "ledger_account": { "id": "410-shipping-income" }
      },
      {
        "description": "Refund: Order discount",
        "quantity": 1,
        "unit_price": -5.00,
        "total_amount": -5.00,
        "ledger_account": { "id": "415-sales-discounts" }
      }
    ],
    "allocations": [
      { "id": "inv_01HQ8S0Y7KF9D2N4P6V8MX3B5R", "type": "invoice", "amount": 40.96 }
    ],
    "status": "authorised"
  }
  ```

  Record the money sent back to the shopper with [Refunds create](/apis/accounting/reference#operation/refundsAdd), using the gateway clearing account as `account`.
- For a partial refund, credit only the refunded lines. The order's `line_items[].refunded_amount` and `refunds[].amount` tell you which lines and how much. Keep the original invoice allocated to its original payment.
- In the summary model, post a correcting journal entry on the day the refund settled, not the day of the original order. Its `source_id` becomes `shopify-summary:store_42:USD:2024-11-22:refunds`.

## Connector-specific behavior

### Ecommerce storefronts

Every ecommerce connector in Apideck is read-only on `Order` and `Product`, which is what this flow needs. Pagination and the `updated_since` filter work the same across all of them.

| Connector | Notes |
| --- | --- |
| Shopify (`shopify`) | Standard mapping. No known quirks beyond the unified model. |
| Shopify public app (`shopify-public-app`) | Standard mapping. Treat identically to `shopify` for order and product reads. |
| BigCommerce (`bigcommerce`) | Standard mapping. `Store` is also readable, useful for the per-store summary key. |
| WooCommerce (`woocommerce`) | Standard mapping. No known quirks beyond the unified model. |
| Magento (`magento`) | Standard mapping. No known quirks beyond the unified model. |
| PrestaShop (`prestashop`) | Standard mapping. No known quirks beyond the unified model. |
| Shopware (`shopware`) | Standard mapping. No known quirks beyond the unified model. |
| Wix (`wix`) | Standard mapping. No known quirks beyond the unified model. |
| Lightspeed (`lightspeed`) | Standard mapping. No known quirks beyond the unified model. |
| Lightspeed Ecommerce (`lightspeed-ecommerce`) | `Store` is not in coverage; drive the summary bucket from the connection alone. `Product` and `Order` reads work as expected. |
| Picqer (`picqer`) | Standard mapping. No known quirks beyond the unified model. |
| Amazon Seller Central (`amazon-seller-central`) | Marketplace model: `Store` is not in coverage. Post orders without customer resolution, or group under a single "Amazon marketplace" customer. |
| Walmart (`walmart`) | Marketplace model, same posting treatment as Amazon. |
| eBay (`ebay`) | Marketplace model. Payouts settle through eBay Managed Payments; use a dedicated clearing account. |
| Etsy (`etsy`) | Marketplace model. Payouts settle through Etsy Payments; use a dedicated clearing account. |
| TikTok Shop (`tiktok`) | Marketplace model. Payouts settle through TikTok Shop; use a dedicated clearing account. |
| Bol.com (`bol-com`) | Marketplace model. `Store` and `Product` are not in coverage; drive the summary bucket from the Bol.com account alone. |

### Payment-only connectors

| Connector | Notes |
| --- | --- |
| Stripe (`stripe`) | Not in `Order` coverage. Use it as a source of `Payment` and `Invoice` reads for Stripe-native billing, or as a reconciliation source for gateway payouts. |
| Mollie (`mollie`) | Not in `Order` coverage. Treat as a payment gateway reference and reconcile payouts against the clearing bank account manually. |

### Accounting ledgers

Coverage varies more on the ledger side. The table below lists every connector in the catalog and whether it supports the writes this flow needs: `Invoice` create, `Payment` create, `JournalEntry` create, and `CreditNote` create. Confirm the rows that describe partial coverage in the live coverage matrix before building against them.

| Connector | Notes |
| --- | --- |
| QuickBooks (`quickbooks`) | Full coverage for invoices, payments, journal entries, and credit notes. Standard mapping. |
| Intuit Enterprise Suite (`intuit-enterprise-suite`) | Full coverage for all four writes. Standard mapping. |
| Xero (`xero`) | Full coverage. Link a credit note to its original invoice through the credit note's `allocations`. |
| NetSuite (`netsuite`) | Full coverage. Pass `subsidiary` and `location_id` on the invoice when the merchant has multiple subsidiaries. |
| Sage Intacct (`sage-intacct`) | Full coverage. Standard mapping. |
| Sage Intacct REST (`sage-intacct-rest`) | Full coverage. Standard mapping. |
| Sage Business Cloud Accounting (`sage-business-cloud-accounting`) | Supports invoices, payments, and credit notes. Journal entries are read-only; use the invoice-per-order model, not the summary model. |
| Exact Online (`exact-online`) | Invoices, payments, and journal entries are read+write. Credit notes are read-only; handle refunds through a negative journal entry or a reversing invoice instead. |
| Exact Online NL (`exact-online-nl`) | Same posture as `exact-online`: credit notes are read-only. |
| Exact Online UK (`exact-online-uk`) | Invoices, payments, and journal entries are read+write. Credit notes are read-only. |
| Microsoft Dynamics 365 Business Central (`microsoft-dynamics-365-business-central`) | Invoices, journal entries, and credit notes are read+write. Payments are read-only; record the receivable settlement via a journal entry or an allocating credit note. |
| Workday (`workday`) | Invoices, payments, and journal entries are read+write. Credit notes are not in coverage; model refunds as reversing journal lines. Bill payments are read-only, which does not affect the sales-side flow. |
| MYOB (`myob`) | Invoices, payments, and journal entries are read+write. Credit notes are read-only; reverse through a negative invoice line or a reversing journal entry. |
| MYOB Acumatica (`myob-acumatica`) | Invoices, payments, journal entries, and credit notes are all read+write. Standard mapping. |
| Acumatica (`acumatica`) | Invoices, payments, journal entries, and credit notes are all read+write. Standard mapping. |
| Zoho Books (`zoho-books`) | Full coverage. Standard mapping. |
| FreshBooks (`freshbooks`) | Invoices, payments, and credit notes are read+write. Journal entries are read-only; use the invoice-per-order model. |
| Odoo (`odoo`) | Invoices, payments, journal entries, and credit notes are read+write. Standard mapping. |
| Wave (`wave`) | Invoices are read+write. Payments, journal entries, and credit notes are not in coverage. Only the invoice can be posted through the Accounting API, and settlement has to happen outside the connector. |
| Moneybird (`moneybird`) | Invoices, payments, and journal entries are read+write. Credit notes are not in coverage; use a reversing invoice. |
| FreeAgent (`freeagent`) | Invoices, payments, journal entries, and credit notes are read+write. Standard mapping. |
| Pennylane (`pennylane`) | Invoices and journal entries are read+write. Payments and credit notes are not in coverage; use the summary journal entry model. |
| Procountor FI (`procountor-fi`) | Invoices and journal entries are read+write. Payments are read-only and credit notes are not in coverage; use the summary journal entry model. |
| Visma Netvisor (`visma-netvisor`) | Invoices, payments, and credit notes are read+write. Journal entries are read-only; use the invoice-per-order model. |
| Yuki (`yuki`) | Invoices and journal entries are read+write. Payments and credit notes are not in coverage; use the summary journal entry model. |
| Kashflow (`kashflow`) | Invoices, payments, and credit notes are read-only. Not writable by this flow. |
| Clear Books UK (`clearbooks-uk`) | Invoices and credit notes are read-only. Not writable by this flow. |
| Banqup (`banqup`) | Invoices are read-only; payments and journal entries are not in coverage. Not writable by this flow. |
| Access Financials (`access-financials`) | Invoices, payments, and credit notes are read+write. Journal entries are not in coverage; use the invoice-per-order model. |
| Rillet (`rillet`) | Invoices, payments, journal entries, and credit notes are read+write. Ledger accounts are read-only, so bank clearing accounts must be set up in Rillet first. |
| DualEntry (`dualentry`) | Invoices, payments, journal entries, and credit notes are read+write. Ledger accounts are read-only; pre-create the clearing bank account in DualEntry. |
| Campfire (`campfire`) | Invoices, journal entries, and credit notes are read+write. Payment coverage is limited; prefer the summary journal entry model and verify payment behavior in the coverage matrix. |
| Digits (`digits`) | Journal entries are read-only; invoices and credit notes are not in coverage. Not writable by this flow; use it as a reporting destination only. |
| MRI Software (`mrisoftware`) | Journal entries are read+write. Invoices, payments, and credit notes are not in coverage; only the summary journal entry model works, and refund reversals must be posted as later journal lines. |

### Meta connectors

| Connector | Notes |
| --- | --- |
| API Explorer (`api-explorer`) | Not a production connector; used for interactive testing in the Apideck dashboard. |
| Coverage (`coverage`) | Not a production connector; serves the coverage matrix. |
| Overview (`overview`) | Not a production connector; serves the connector overview. |

### Notes on specific connectors

#### NetSuite

When the merchant operates multiple subsidiaries, include `subsidiary.id` on every invoice and journal entry. Fetch options via [Subsidiaries list](/apis/accounting/reference#operation/subsidiariesAll). `Location`, `Department`, and `Class` are the native tracking dimensions and ride through as [Tracking categories](/apis/accounting/reference#tag/Tracking-Categories).

#### QuickBooks

Classes map to tracking categories. For high-volume merchants, the summary model is often the safer default to stay under per-minute write throttles.

#### Xero

Xero shows the invoice `reference` to users, which makes it a readable carrier for the ecommerce order id. Credit notes allocate against the originating invoice through the credit note's `allocations` array.

#### Exact Online

Credit notes are read-only across `exact-online`, `exact-online-nl`, and `exact-online-uk`. Model refunds as reversing journal entries or negative invoices rather than through the Credit Notes endpoint.

#### Microsoft Dynamics 365 Business Central

Payments are read-only, so the invoice-plus-payment flow does not post as described. Record the cash movement via a journal entry that debits the clearing account and credits accounts receivable, or rely on the connector's own bank reconciliation to clear the invoice after the fact.

#### Workday

Credit notes are not in coverage. Record refund reversals as additional journal lines in a later period.

## Next steps

- [Mark invoices and bills as paid](/guides/mark-invoices-as-paid) for details on allocating payments to receivables.
- [Handling bills and expenses](/guides/expenses-bills) for the mirror flow on the accounts-payable side.
- [Ecommerce API reference](/apis/ecommerce/reference) and [Accounting API reference](/apis/accounting/reference).
