Unified.to
All articles

How to Build a Spend Analysis application with Unified's Accounting API


January 6, 2026

Accurate spend analysis depends on a consistent view of vendor bills and employee expenses across accounting systems. QuickBooks, Xero, NetSuite, Sage, and others represent bills, expenses, and categories differently, which makes spend reporting brittle if you integrate vendor-by-vendor.

Unified's Accounting API normalizes these objects behind one SDK, so you can list bills and expenses the same way across providers. This guide walks through fetching bills and expenses, then grouping totals by vendor and category using Unified's normalized fields.

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