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_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