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_numbercontact_id(vendor)total_amount,paid_amount,balance_amountcurrencyposted_at,due_at,paid_atstatuslineitems[](where categorization typically lives)
Expenses (AccountingExpense)
Expenses represent employee spend / reimbursements (actual spend).
iduser_id(employee)total_amountcurrencyapproved_at,reimbursed_at,reimbursed_amountlineitems[]
Contacts (AccountingContact)
Contacts represent vendors/customers. For vendor grouping you typically use:
idname/company_nameis_supplier
Categories (AccountingCategory)
Categories are the normalized classification layer:
idnameparent_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_idandcategory_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_idfor 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