Unified.to
All articles

How to Reconcile Customer Payments (AR) with Unified's Accounting API


January 27, 2026

Accounts Receivable reconciliation is often implemented in the wrong place.

Teams start with payment events—charges, refunds, payouts—and try to infer AR state from them. That works in a single-provider setup, but it breaks as soon as you support multiple accounting systems. Payment processors report activity. Accounting systems record obligation and settlement. Those are not the same thing.

For PMs and backend teams, this mismatch creates immediate ambiguity:

  • Is AR driven by what was charged, or what the ledger recorded?
  • How do partial payments, credits, and write-offs actually surface?
  • Which numbers should users trust when systems disagree?

When reconciliation logic is built on payment events alone, edge cases accumulate quickly. Credits don't line up. Invoice balances drift. Collections workflows lose credibility.

Unified's Accounting API is designed to anchor AR reconciliation where it belongs: in the general ledger. In Unified Accounting, invoices define what is owed, and transactions define what posted. Customer payments are not a separate object—they are ledger entries that must be interpreted in context.

This guide shows how to reconcile AR defensively using that model—combining invoices and transactions, matching payments best-effort using documented fields only, and treating invoice balances as the final authority—so your AR logic remains correct across accounting providers.

Why AR reconciliation lives in Accounting (not Payments)

Unified exposes two different surfaces that mention 'payments':

  • Payments API: payment-processor events (e.g., Stripe/Square charges, refunds, payouts).
  • Accounting API: the general ledger view—what the accounting system recorded.

AR reconciliation belongs to Accounting, because AR is a ledger concept. In Unified Accounting:

  • Invoices represent what's owed.
  • Transactions represent what happened (including customer payments).
  • Accounts classify those transactions (e.g., AR vs bank).

There is no standalone 'Payment' object in Accounting. Customer payments surface as AccountingTransaction records.

What this guide covers

  • Listing invoices and transactions with pagination and incremental sync
  • Understanding how customer payments appear as transactions
  • Matching payments to invoices (best-effort, doc-accurate)
  • Computing open AR, paid AR, partials, and credits
  • Validating results against account balances and reports (optional)

Prerequisites

  • Node.js 18+
  • A Unified workspace
  • An Accounting integration authorized for a customer
  • A valid connectionId
  • Unified API key

Step 1: Initialize the SDK

import { UnifiedTo } from '@unified-api/typescript-sdk';

const sdk = new UnifiedTo({
  security: {
    jwt: process.env.UNIFIED_API_KEY!,
  },
});

All Accounting API calls are scoped by connectionId, which represents a single authorized customer integration.

Step 2: Understand the two source objects

Invoices = AR state

From the AccountingInvoice model, the AR-relevant fields are:

  • total_amount
  • paid_amount
  • balance_amount
  • status
  • paid_at
  • contact_id
  • invoice_number / reference

Invoices tell you what remains owed. Treat balance_amount and status as authoritative.

Transactions = ledger events

From the AccountingTransaction model:

  • total_amount
    • positive = DEBIT
    • negative = CREDIT
  • account_id
  • type (examples include ReceivePayment, SalesReceipt, CreditMemo, CreditRefund)
  • contacts[] (with is_customer / is_supplier)
  • reference
  • created_at / updated_at

Transactions tell you what posted—including customer payments.

Important: The docs do not guarantee a direct invoice↔transaction foreign key. Reconciliation is therefore best-effort matching, with invoice balances as the final truth.

Step 3: List invoices (what was owed)

const invoices = await sdk.accounting.listAccountingInvoices({
  connectionId,
  limit: 50,
  offset: 0,
  updated_gte: '2026-01-22T23:22:07.715Z',
  sort: 'updated_at',
  order: 'asc',
  query: '',
  contact_id: '',
  org_id: '',
  type: '',
  fields: '',
  raw: '',
});

Pagination rules

  • Default page size: 100
  • Max page size: 100 on most endpoints
  • Pagination uses offset
  • Stop when results.length < limit

Use updated_gte to incrementally sync invoice state.

Step 4: List transactions (what actually happened)

const transactions = await sdk.accounting.listAccountingTransactions({
  connectionId,
  limit: 50,
  offset: 0,
  updated_gte: '2026-01-22T22:41:23.849Z',
  sort: 'updated_at',
  order: 'asc',
  query: '',
  contact_id: '',
  fields: '',
  raw: '',
});

Step 5: Identify customer payment transactions

Customer payments surface as transactions that:

  1. Reference a customer
    (contacts[] contains an entry with is_customer === true)
  2. Have a credit sign
    (total_amount < 0)
  3. Use a payment-like type (examples, not exhaustive):
    • ReceivePayment
    • SalesReceipt
    • CreditMemo
    • CreditRefund

Because providers vary, treat the type list as examples, not a closed set.

Example filter:

const customerPayments = transactions.filter(tx => {
  const isCustomer = tx.contacts?.some(c => c.is_customer);
  const isCredit = (tx.total_amount ?? 0) < 0;
  return isCustomer && isCredit;
});

Step 6: Scope reconciliation by customer

Invoices and transactions both reference customers.

function byContactId<T extends { contact_id?: string }>(
  rows: T[],
  contactId: string
) {
  return rows.filter(r => r.contact_id === contactId);
}

Reconcile per customer to avoid cross-account contamination.

Step 7: Match payments to invoices (best-effort)

Because there is no guaranteed foreign key, matching uses documented fields only:

Safe matching signals

  • contact_id (must match)
  • reference / invoice_number (when present)
  • Amount proximity
  • Temporal proximity (created_at vs paid_at)

Matching strategy

  1. Group invoices by customer.
  2. For each invoice:
    • If balance_amount === 0, treat as settled.
    • Otherwise, look for customer payment transactions whose:
      • credit magnitude aligns with invoice amount, and/or
      • reference matches invoice reference/number, and/or
      • timestamp falls after invoice posted_at and before paid_at.

Always trust the invoice's balance_amount and status over heuristic matches.

Step 8: Compute AR metrics

Open AR

Sum of balance_amount for all non-settled invoices.

const openAR = invoices
  .filter(inv => (inv.balance_amount ?? 0) > 0)
  .reduce((sum, inv) => sum + (inv.balance_amount ?? 0), 0);

Sum of paid_amount across invoices (or derived from credits if needed).

Partial payments

Invoices with paid_amount > 0 and balance_amount > 0.

Credits / overpayments

Invoices or credit transactions where:

  • paid_amount > total_amount, or
  • credit transactions exist without a matching open invoice.

Step 9: Validate against Accounts Receivable balances (optional)

AR accounts are identified by:

type === 'ACCOUNTS_RECEIVABLE'

List accounts:

const accounts = await sdk.accounting.listAccountingAccounts({
  connectionId,
  limit: 50,
  offset: 0,
  updated_gte: '2026-01-22T22:41:23.849Z',
  sort: 'updated_at',
  order: 'asc',
  query: '',
  org_id: '',
  fields: '',
  raw: '',
});

Compare:

  • Computed open AR vs
  • AR account balance

Discrepancies can occur due to timing or provider posting rules; document them rather than forcing alignment.

Step 10: Validate with reports (optional, non-deprecated fields only)

Profit & Loss (validation only)

Retrieve by ID:

const pl = await sdk.accounting.getAccountingProfitloss({
  connectionId,
  id: profitlossId,
  fields: '',
  raw: '',
});

Use only:

  • income_total_amount
  • expenses_total_amount
  • net_income_amount
  • expenses_sections[].accounts[].transaction_ids

Do not use deprecated fields.

Balance Sheet (AR snapshot)

Retrieve by ID:

const bs = await sdk.accounting.getAccountingBalancesheet({
  connectionId,
  id: balancesheetId,
  fields: '',
  raw: '',
});

Use:

  • assets[] filtered to AR accounts
  • net_assets_amount (context)

Reports confirm reconciliation; they do not replace transaction-based logic.

Pagination, rate limits, and operations

  • All list endpoints use limit / offset
  • Default and max page size is 100
  • Rate limits come from providers and Unified
  • Rate-limit errors return HTTP 429

Recommended handling:

  • Exponential backoff with jitter
  • Cap retries
  • Prefer webhooks for continuous sync

Summary

Using Unified's Accounting API, you can reconcile customer payments by:

  • Treating invoices as the AR state
  • Treating transactions as the ledger of payments
  • Matching best-effort with documented fields
  • Trusting invoice balance_amount and status as final
  • Validating with AR accounts and non-deprecated reports

The guiding rule is simple:

Invoices tell you what's owed. Transactions tell you what happened. Reconciliation connects the two—defensively.

Start your 30-day free trial

Book a demo

All articles