Unified.to
All articles

How to Analyze Spend with Unified's Accounting API


January 10, 2026

For most product teams, 'spend analysis' sounds straightforward. Pull bills. Pull expenses. Group them by vendor and category. Show totals in a dashboard.

In practice, this breaks down quickly once you support more than one accounting system.

QuickBooks, Xero, NetSuite, Sage, and similar platforms all model spend differently. Some treat vendor bills and employee expenses as separate objects. Others blur the line. Categories live on headers in one system, line items in another. Vendor identity may be a contact, a supplier, or an implicit relationship on a transaction.

For a PM, the problem isn't how to write the aggregation logic. It's deciding what your product can reliably promise:

  • Can users expect consistent spend totals across accounting providers?
  • Can you support vendor-level reporting without custom logic per integration?
  • Can you roll this feature out broadly, or only for a short list of 'supported' systems?

This is why many spend features quietly turn into one-off integrations, fragile mappings, or 'best-effort' reporting disclaimers.

Unified's Accounting API exists to remove that decision from your roadmap. Instead of building spend logic vendor-by-vendor, you work against normalized bill, expense, contact, and category objects—so your product logic stays the same even as customers connect different accounting tools.

The rest of this guide shows how that looks in practice: fetching bills and expenses through one SDK, then grouping spend by vendor and category using Unified's documented fields. No per-vendor branching. No accounting-system-specific assumptions.

Prerequisites

  • Node.js v18+
  • A Unified account with an Accounting integration enabled
  • Your Unified API key
  • A customer Accounting connectionId

Step 1: Set up your project

mkdir spend-analysis-demo
cd spend-analysis-demo
npm init -y
npm install @unified-api/typescript-sdk dotenv

Create a .env file:

UNIFIED_API_KEY=your_unified_api_key
CONNECTION_ACCOUNTING=your_customer_accounting_connection_id

Step 2: Initialize the SDK

import "dotenv/config";
import { UnifiedTo } from "@unified-api/typescript-sdk";

const { UNIFIED_API_KEY, CONNECTION_ACCOUNTING } = process.env;

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

Step 3: Understand the normalized Accounting objects

Unified's Accounting models use snake_case field names in the API docs and TypeScript types shown here.

Bills (AccountingBill)

Bills represent vendor invoices (committed spend / AP). Useful fields include:

  • id, bill_number
  • contact_id (vendor)
  • total_amount, paid_amount, balance_amount
  • currency
  • posted_at, due_at, paid_at
  • status
  • lineitems[] (where categorization typically lives)

Expenses (AccountingExpense)

Expenses represent employee spend / reimbursements (actual spend).

  • id
  • user_id (employee)
  • total_amount
  • currency
  • approved_at, reimbursed_at, reimbursed_amount
  • lineitems[]

Contacts (AccountingContact)

Contacts represent vendors/customers. For vendor grouping you typically use:

  • id
  • name / company_name
  • is_supplier

Categories (AccountingCategory)

Categories are the normalized classification layer:

  • id
  • name
  • parent_id

Line item attribution (important)

Both bills and expenses include lineitems[], and each line item can include:

  • account_id (chart-of-accounts account)
  • category_ids (one or more category IDs)
  • total_amount

For category grouping, prefer line item fields over header totals.

Step 4: Fetch all bills (with pagination)

Below are partial TypeScript shapes showing only the fields used in this example.

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

export type AccountingBill = {
  id?: string;
  contact_id?: string;
  currency?: string;
  total_amount?: number;
  lineitems?: {
    total_amount?: number;
    account_id?: string;
    category_ids?: any;
  }[];
};

export async function fetchAllBills(
  sdk: UnifiedTo,
  connectionId: string,
  opts?: {
    pageSize?: number;
    updated_gte?: string;
    sort?: "name" | "updated_at" | "created_at";
    order?: "asc" | "desc";
    query?: string;
    contact_id?: string;
    fields?: string;
    raw?: string;
  }
): Promise<AccountingBill[]> {
  const pageSize = opts?.pageSize ?? 100;
  let offset = 0;
  const out: AccountingBill[] = [];

  while (true) {
    const page = await sdk.accounting.listAccountingBills({
      connectionId,
      limit: pageSize,
      offset,
      updated_gte: opts?.updated_gte,
      sort: opts?.sort,
      order: opts?.order,
      query: opts?.query,
      contact_id: opts?.contact_id,
      fields: opts?.fields ?? "",
      raw: opts?.raw ?? "",
    });

    if (!page || page.length === 0) break;

    out.push(...page);
    offset += pageSize;
  }

  return out;
}

Step 5: Fetch all expenses (with pagination)

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

export type AccountingExpense = {
  id?: string;
  user_id?: string;
  currency?: string;
  total_amount?: number;
  lineitems?: {
    total_amount?: number;
    account_id?: string;
    category_ids?: any;
  }[];
};

export async function fetchAllExpenses(
  sdk: UnifiedTo,
  connectionId: string,
  opts?: {
    pageSize?: number;
    updated_gte?: string;
    sort?: "name" | "updated_at" | "created_at";
    order?: "asc" | "desc";
    query?: string;
    user_id?: string;
    fields?: string;
    raw?: string;
  }
): Promise<AccountingExpense[]> {
  const pageSize = opts?.pageSize ?? 100;
  let offset = 0;
  const out: AccountingExpense[] = [];

  while (true) {
    const page = await sdk.accounting.listAccountingExpenses({
      connectionId,
      limit: pageSize,
      offset,
      updated_gte: opts?.updated_gte,
      sort: opts?.sort,
      order: opts?.order,
      query: opts?.query,
      user_id: opts?.user_id,
      fields: opts?.fields ?? "",
      raw: opts?.raw ?? "",
    });

    if (!page || page.length === 0) break;

    out.push(...page);
    offset += pageSize;
  }

  return out;
}

Step 6: Aggregate spend by vendor and category (using line items)

This step uses only Unified's normalized fields:

  • Bills: contact_id (vendor)
  • Expenses: user_id (employee bucket)
  • Line items: account_id and category_ids
import type { AccountingBill } from "./bills";
import type { AccountingExpense } from "./expenses";

type SpendTotals = Record<string, Record<string, Record<string, number>>>;
// vendor_key -> bucket_key -> currency -> total_amount

function addAmount(
  acc: SpendTotals,
  vendorKey: string,
  bucketKey: string,
  currency: string,
  amount: number
) {
  if (!acc[vendorKey]) acc[vendorKey] = {};
  if (!acc[vendorKey][bucketKey]) acc[vendorKey][bucketKey] = {};
  acc[vendorKey][bucketKey][currency] = (acc[vendorKey][bucketKey][currency] ?? 0) + amount;
}

function normalizeCategoryIds(category_ids: any): string[] {
  if (!category_ids) return [];
  if (Array.isArray(category_ids)) return category_ids.map(String).filter(Boolean);
  return [String(category_ids)].filter(Boolean);
}

export function groupSpendByVendorAndCategory(input: {
  bills: AccountingBill[];
  expenses: AccountingExpense[];
}): SpendTotals {
  const totals: SpendTotals = {};

  // Bills (vendor spend)
  for (const b of input.bills) {
    const vendorKey = b.contact_id ?? "vendor:unknown";
    const currency = b.currency ?? "UNKNOWN";
    const lineitems = b.lineitems ?? [];

    if (lineitems.length > 0) {
      for (const li of lineitems) {
        const amt = Number(li.total_amount ?? 0);

        if (li.account_id) {
          addAmount(totals, vendorKey, `account:${li.account_id}`, currency, amt);
          continue;
        }

        const cats = normalizeCategoryIds(li.category_ids);
        if (cats.length > 0) {
          for (const c of cats) addAmount(totals, vendorKey, `category:${c}`, currency, amt);
          continue;
        }

        addAmount(totals, vendorKey, "unattributed", currency, amt);
      }
    } else {
      addAmount(totals, vendorKey, "unattributed", currency, Number(b.total_amount ?? 0));
    }
  }

  // Expenses (employee spend)
  for (const e of input.expenses) {
    const vendorKey = e.user_id ? `employee:${e.user_id}` : "employee:unknown";
    const currency = e.currency ?? "UNKNOWN";
    const lineitems = e.lineitems ?? [];

    if (lineitems.length > 0) {
      for (const li of lineitems) {
        const amt = Number(li.total_amount ?? 0);

        if (li.account_id) {
          addAmount(totals, vendorKey, `account:${li.account_id}`, currency, amt);
          continue;
        }

        const cats = normalizeCategoryIds(li.category_ids);
        if (cats.length > 0) {
          for (const c of cats) addAmount(totals, vendorKey, `category:${c}`, currency, amt);
          continue;
        }

        addAmount(totals, vendorKey, "unattributed", currency, amt);
      }
    } else {
      addAmount(totals, vendorKey, "unattributed", currency, Number(e.total_amount ?? 0));
    }
  }

  return totals;
}

Step 7 (optional): Fetch vendors to display names

Uses your Contact schema (is_supplier, company_name, name).

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

export type AccountingContact = {
  id?: string;
  name?: string;
  company_name?: string;
  is_supplier?: boolean;
};

export async function fetchAllVendors(
  sdk: UnifiedTo,
  connectionId: string,
  opts?: {
    pageSize?: number;
    updated_gte?: string;
    sort?: "name" | "updated_at" | "created_at";
    order?: "asc" | "desc";
    query?: string;
    type?: string;
    fields?: string;
    raw?: string;
  }
): Promise<AccountingContact[]> {
  const pageSize = opts?.pageSize ?? 100;
  let offset = 0;
  const out: AccountingContact[] = [];

  while (true) {
    const page = await sdk.accounting.listAccountingContacts({
      connectionId,
      limit: pageSize,
      offset,
      updated_gte: opts?.updated_gte,
      sort: opts?.sort,
      order: opts?.order,
      query: opts?.query,
      type: opts?.type ?? "",
      fields: opts?.fields ?? "",
      raw: opts?.raw ?? "",
    });

    if (!page || page.length === 0) break;

    out.push(...page);
    offset += pageSize;
  }

  return out.filter((c) => c.is_supplier);
}

Step 8: Putting it all together

import "dotenv/config";
import { UnifiedTo } from "@unified-api/typescript-sdk";

import { fetchAllBills } from "./bills";
import { fetchAllExpenses } from "./expenses";
import { groupSpendByVendorAndCategory } from "./aggregate";
import { fetchAllVendors } from "./vendors";

const { UNIFIED_API_KEY, CONNECTION_ACCOUNTING } = process.env;

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

function buildVendorIndex(vendors: { id?: string; company_name?: string; name?: string }[]) {
  return Object.fromEntries(
    vendors
      .filter((v) => v.id)
      .map((v) => [v.id!, v.company_name ?? v.name ?? v.id!])
  );
}

/**
 * Pretty-print helper: replaces vendor IDs with names when possible.
 * Keeps the underlying totals structure intact.
 */
function printSpendTotals(
  totals: Record<string, Record<string, Record<string, number>>>,
  vendorIndex?: Record<string, string>
) {
  const output: Record<string, any> = {};

  for (const [vendorKey, buckets] of Object.entries(totals)) {
    const displayVendor =
      vendorIndex && vendorKey in vendorIndex ? vendorIndex[vendorKey] : vendorKey;

    output[displayVendor] = {};

    for (const [bucketKey, byCurrency] of Object.entries(buckets)) {
      // Round for readability; remove rounding if you want exact floats
      output[displayVendor][bucketKey] = Object.fromEntries(
        Object.entries(byCurrency).map(([currency, amount]) => [
          currency,
          Math.round(amount * 100) / 100,
        ])
      );
    }
  }

  console.log(JSON.stringify(output, null, 2));
}

async function main() {
  const connectionId = CONNECTION_ACCOUNTING!;
  if (!connectionId) throw new Error("Missing CONNECTION_ACCOUNTING");

  // Fetch core spend objects
  const [bills, expenses] = await Promise.all([
    fetchAllBills(sdk, connectionId, { pageSize: 100 }),
    fetchAllExpenses(sdk, connectionId, { pageSize: 100 }),
  ]);

  // Aggregate (currency-safe)
  const totals = groupSpendByVendorAndCategory({ bills, expenses });

  console.log("Spend totals (IDs):");
  printSpendTotals(totals);

  // Optional: fetch vendors (contacts) and resolve IDs to names
  const vendors = await fetchAllVendors(sdk, connectionId, { pageSize: 100 });
  const vendorIndex = buildVendorIndex(vendors);

  console.log("Spend totals (vendor names where available):");
  printSpendTotals(totals, vendorIndex);
}

main().catch(console.error);

TLDR;

You now have a minimal, SDK-first spend analysis flow that:

  • Lists all bills and expenses with explicit pagination
  • Uses Unified's documented list params (limit, offset, updated_gte, sort, order, query, etc.)
  • Aggregates totals by vendor (contact_id for bills, employee:<user_id> buckets for expenses)
  • Groups spend by account (preferred) and category (fallback) using normalized line item fields
  • Optionally resolves vendor IDs to names by listing contacts filtered to is_supplier

Start your 30-day free trial

Book a demo

All articles