npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tallyroot/sdk

v1.2.0

Published

Official TypeScript SDK for the Tallyroot ingest API

Readme

@tallyroot/sdk

Official TypeScript SDK for the Tallyroot ingest API. Push transactions, FX trades, transfers, and balance snapshots from any data source — a bank connector, a CSV import, a third-party integration — into your Tallyroot workspace.


Table of Contents


Overview

Tallyroot is a personal finance platform that categorises, analyses, and budgets transactions across all your accounts. The ingest API is the entry point for external data: you authenticate with a workspace API key, describe transactions in a standard envelope, and Tallyroot handles deduplication, categorisation, and ledger reconciliation.

This SDK wraps the ingest API with:

  • A typed TallyrootClient class for all ingest endpoints
  • A structured TallyrootApiError with HTTP status, machine-readable code, and retry guidance
  • Pure-string money helpers (toMinor, formatMinor, negateMinor) that never touch JavaScript's floating-point numbers

Requirements: Node.js 18 or later (uses the built-in fetch API and ES modules).


Installation

npm install @tallyroot/sdk

Quick Start

import { TallyrootClient, toMinor } from '@tallyroot/sdk';

const client = new TallyrootClient({
  apiKey: 'bk_your_api_key_here',
  source: 'my-bank-connector',
});

const result = await client.upsertTransactions([
  {
    externalTxnId: 'txn_abc123',
    accountId: 'acc_checking',
    bookedAt: '2024-03-15T10:30:00Z',
    currency: 'USD',
    amountMinor: toMinor('-45.50', 2), // → "-4550"
    merchantName: 'Corner Coffee Shop',
    bookingStatus: 'booked',
  },
]);

console.log(result);
// { created: 1, skipped: 0, restated: 0 }

API Reference

TallyrootClient

import { TallyrootClient } from '@tallyroot/sdk';

const client = new TallyrootClient(options);

Constructor options:

| Option | Type | Required | Description | |--------|------|----------|-------------| | apiKey | string | Yes | Workspace API key. Always starts with bk_. | | source | string | Yes | Identifier for the data source (e.g. "chase-connector", "csv-import"). Included with every ingest call for traceability. | | baseUrl | string | No | Base URL of the Tallyroot API. Defaults to "https://app.tallyroot.com". |

// Custom base URL (e.g. self-hosted or staging)
const client = new TallyrootClient({
  apiKey: process.env.TALLYROOT_API_KEY!,
  source: 'my-connector',
  baseUrl: 'https://staging.tallyroot.com',
});

upsertTransactions

client.upsertTransactions(transactions: IngestTransactionUpsert[]): Promise<IngestResult>

Create or update regular account transactions. Each transaction is identified by externalTxnId — if a record with that ID already exists, it is updated in place; otherwise a new record is created.

const result = await client.upsertTransactions([
  {
    externalTxnId: 'txn_001',
    accountId: 'acc_checking',
    bookedAt: '2024-03-15T00:00:00Z',
    currency: 'USD',
    amountMinor: '-2500',       // -$25.00
    merchantName: 'Whole Foods',
    bookingStatus: 'booked',
  },
  {
    externalTxnId: 'txn_002',
    accountId: 'acc_checking',
    bookedAt: '2024-03-16T00:00:00Z',
    currency: 'USD',
    amountMinor: '150000',      // +$1,500.00 (incoming)
    description: 'Payroll deposit',
    bookingStatus: 'booked',
  },
]);

upsertFx

client.upsertFx(fxTransactions: IngestFxUpsert[]): Promise<IngestResult>

Create or update foreign-exchange transactions that move money between two currencies. The fromAmountMinor is always negative (outflow) and toAmountMinor is always positive (inflow) — or vice versa depending on your account perspective. Both amounts must be in the minor units of their respective currencies.

const result = await client.upsertFx([
  {
    externalTxnId: 'fx_001',
    fromAccountId: 'acc_usd_checking',
    toAccountId: 'acc_eur_checking',
    fromAmountMinor: '-100000',   // -$1,000.00 USD
    fromCurrency: 'USD',
    toAmountMinor: '92000',       // +€920.00 EUR
    toCurrency: 'EUR',
    bookedAt: '2024-03-15T14:00:00Z',
    description: 'Wise transfer',
  },
]);

upsertTransfers

client.upsertTransfers(transfers: IngestTransferUpsert[]): Promise<IngestResult>

Create or update internal transfers between two accounts in the same currency. Tallyroot will cancel out both legs so the transfer does not inflate income or expenses.

const result = await client.upsertTransfers([
  {
    externalTxnId: 'xfer_001',
    fromAccountId: 'acc_checking',
    toAccountId: 'acc_savings',
    amountMinor: '50000',         // $500.00
    currency: 'USD',
    bookedAt: '2024-03-15T09:00:00Z',
    description: 'Monthly savings transfer',
  },
]);

pushBalanceSnapshots

client.pushBalanceSnapshots(snapshots: IngestBalanceSnapshot[]): Promise<{ upserted: number }>

Push point-in-time balance snapshots for reconciliation. Tallyroot uses these to verify that the running balance derived from transactions matches your bank's reported balance.

const result = await client.pushBalanceSnapshots([
  {
    accountId: 'acc_checking',
    currency: 'USD',
    balanceMinor: '254370',       // $2,543.70
    snapshotAt: '2024-03-15T23:59:59Z',
  },
  {
    accountId: 'acc_savings',
    currency: 'USD',
    balanceMinor: '1500000',      // $15,000.00
    snapshotAt: '2024-03-15T23:59:59Z',
  },
]);

console.log(result.upserted); // 2

sendAlert

client.sendAlert(opts: { error: string; consecutiveFailures: number }): Promise<void>

Send a connector health alert to Tallyroot. Call this when your connector encounters an unrecoverable error so that the workspace owner is notified. consecutiveFailures lets Tallyroot gauge the severity.

await client.sendAlert({
  error: 'Bank API returned 503 Service Unavailable',
  consecutiveFailures: 5,
});

IngestResult

Returned by upsertTransactions, upsertFx, and upsertTransfers.

| Field | Type | Description | |-------|------|-------------| | created | number | New records that were inserted. | | skipped | number | Records that were identical to an existing record and required no change. | | restated | number | Records that existed but had at least one changed field and were updated. |


Types

IngestTransactionUpsert

interface IngestTransactionUpsert {
  externalTxnId: string;               // Your stable, unique transaction ID
  accountId: string;                   // Tallyroot account identifier
  bookedAt: string;                    // ISO 8601 datetime (e.g. "2024-03-15T10:30:00Z")
  currency: string;                    // ISO 4217 currency code (e.g. "USD", "EUR")
  amountMinor: string;                 // Amount in minor units as a string (negative = debit)
  baseAmountMinor?: string | null;     // Amount in base/home currency minor units (optional)
  merchantName?: string | null;        // Merchant display name
  description?: string | null;         // Raw description from the bank
  bookingStatus?: 'pending' | 'booked' | 'voided';  // Defaults to 'booked' if omitted
  raw?: Record<string, unknown> | null; // Arbitrary raw payload for debugging
}

Amount conventions:

  • Debits (money leaving the account) are negative: "-4550" for -$45.50
  • Credits (money entering the account) are positive: "150000" for +$1,500.00
  • All amounts are strings, never JavaScript numbers, to avoid floating-point precision loss

IngestFxUpsert

interface IngestFxUpsert {
  externalTxnId: string;
  fromAccountId: string;
  toAccountId: string;
  fromAmountMinor: string;             // Amount leaving fromAccount (typically negative)
  fromCurrency: string;
  toAmountMinor: string;               // Amount arriving in toAccount (typically positive)
  toCurrency: string;
  bookedAt: string;                    // ISO 8601 datetime
  baseFromAmountMinor?: string | null; // fromAmount in base/home currency
  baseToAmountMinor?: string | null;   // toAmount in base/home currency
  description?: string | null;
  raw?: Record<string, unknown> | null;
}

IngestTransferUpsert

interface IngestTransferUpsert {
  externalTxnId: string;
  fromAccountId: string;
  toAccountId: string;
  amountMinor: string;                 // Positive value representing the transferred amount
  currency: string;
  bookedAt: string;                    // ISO 8601 datetime
  description?: string | null;
  raw?: Record<string, unknown> | null;
}

IngestBalanceSnapshot

interface IngestBalanceSnapshot {
  accountId: string;
  currency: string;
  balanceMinor: string;                // Current account balance in minor units
  snapshotAt: string;                  // ISO 8601 datetime of the balance observation
}

TallyrootApiError

All API failures throw a TallyrootApiError. It extends the standard Error class and adds structured fields.

import { TallyrootApiError } from '@tallyroot/sdk';

try {
  await client.upsertTransactions([...]);
} catch (err) {
  if (err instanceof TallyrootApiError) {
    console.log(err.status);      // HTTP status code, e.g. 429
    console.log(err.code);        // Machine-readable code, e.g. "RATE_LIMITED"
    console.log(err.retryAfter);  // Seconds to wait before retrying, or null
    console.log(err.message);     // Human-readable message from the API
  }
}

Fields:

| Field | Type | Description | |-------|------|-------------| | status | number | HTTP status code from the API response. | | code | string \| null | Machine-readable error code. null for unexpected errors. | | retryAfter | number \| null | Seconds to wait before retrying. Only set on 429 responses. | | message | string | Human-readable description of the error. |

Known error codes:

| Code | HTTP Status | Description | |------|-------------|-------------| | VALIDATION_ERROR | 400 | The request payload failed schema validation. Check that all required fields are present and that amounts are valid strings. | | PLAN_REQUIRED | 403 | The workspace's current plan does not include API access. Upgrade is required. | | RATE_LIMITED | 429 | The request rate limit has been exceeded. Check retryAfter for when to retry. |


Error Handling

Rate limit retry pattern

The API enforces a rate limit of 120 requests per minute. When the limit is exceeded the server returns HTTP 429 with a Retry-After header. The SDK surfaces this as TallyrootApiError with code === 'RATE_LIMITED' and a populated retryAfter field.

import { TallyrootApiError } from '@tallyroot/sdk';

async function upsertWithRetry(
  transactions: IngestTransactionUpsert[],
  maxAttempts = 4,
): Promise<IngestResult> {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await client.upsertTransactions(transactions);
    } catch (err) {
      if (
        err instanceof TallyrootApiError &&
        err.code === 'RATE_LIMITED' &&
        attempt < maxAttempts
      ) {
        const waitMs = (err.retryAfter ?? 60) * 1000;
        console.warn(`Rate limited. Retrying in ${waitMs / 1000}s…`);
        await new Promise((resolve) => setTimeout(resolve, waitMs));
        continue;
      }
      throw err;
    }
  }
  throw new Error('Exhausted retry attempts');
}

Plan error handling

If your workspace does not have an active plan that includes API access, every request returns 403 PLAN_REQUIRED. Detect this early and surface it clearly rather than retrying.

try {
  await client.upsertTransactions([...]);
} catch (err) {
  if (err instanceof TallyrootApiError && err.code === 'PLAN_REQUIRED') {
    throw new Error(
      'Tallyroot API access requires a paid plan. ' +
      'Visit https://app.tallyroot.com/settings/billing to upgrade.',
    );
  }
  throw err;
}

Validation errors

When the API returns VALIDATION_ERROR, the message field contains details about which fields failed. Log the full error to aid debugging:

try {
  await client.upsertTransactions(transactions);
} catch (err) {
  if (err instanceof TallyrootApiError && err.code === 'VALIDATION_ERROR') {
    console.error('Payload rejected by API:', err.message);
    // Fix the payload before retrying — validation errors will not resolve on their own
  }
  throw err;
}

Money Helpers

All amount fields in the API use minor units as strings (e.g. cents for USD, pence for GBP). The helpers let you convert between human-readable decimal amounts and minor-unit strings without ever passing through a JavaScript number, which avoids floating-point rounding errors.

toMinor

toMinor(amount: string, decimals: number): string

Convert a decimal amount string to minor units.

import { toMinor } from '@tallyroot/sdk';

toMinor('45.50', 2)    // → "4550"    ($45.50 → 4550 cents)
toMinor('-45.50', 2)   // → "-4550"   (debit)
toMinor('1500', 2)     // → "150000"  ($1,500.00)
toMinor('0.99', 2)     // → "99"
toMinor('10', 0)       // → "10"      (zero-decimal currency, e.g. JPY)
toMinor('1.005', 2)    // → "100"     (truncates, does not round)

formatMinor

formatMinor(minor: string, decimals: number): string

Convert minor units back to a decimal string. Useful for logging and display.

import { formatMinor } from '@tallyroot/sdk';

formatMinor('4550', 2)    // → "45.50"
formatMinor('-4550', 2)   // → "-45.50"
formatMinor('150000', 2)  // → "1500.00"
formatMinor('99', 2)      // → "0.99"
formatMinor('10', 0)      // → "10"

negateMinor

negateMinor(minor: string): string

Flip the sign of a minor-unit string. Useful when a source reports all amounts as positive and you need to negate debits.

import { negateMinor } from '@tallyroot/sdk';

negateMinor('4550')   // → "-4550"
negateMinor('-4550')  // → "4550"
negateMinor('0')      // → "0"

Rate Limits

The Tallyroot API allows 120 requests per minute per API key. Requests that exceed the limit receive an HTTP 429 response.

Response headers on every request:

| Header | Description | |--------|-------------| | X-RateLimit-Limit | Maximum requests allowed in the current window (120). | | X-RateLimit-Remaining | Requests remaining in the current window. | | X-RateLimit-Reset | Unix timestamp (seconds) when the window resets. | | Retry-After | Present on 429 responses only. Seconds to wait before retrying. |

Tips for staying within limits:

  • Batch aggressively. Each of the upsert methods accepts an array. A single call with 500 transactions counts as one request, not 500.
  • Respect Retry-After. The SDK surfaces this as TallyrootApiError.retryAfter. Use it directly rather than guessing a back-off interval.
  • Avoid polling. Push transactions as events happen (or in scheduled batch jobs) rather than repeatedly checking and re-syncing the same window.
  • Use a single source per connector. The source field is for traceability, not for splitting traffic — all keys share the same per-workspace limit.

Use-Case Guides

Building a bank connector

A bank connector fetches new transactions from a bank API and pushes them to Tallyroot on a schedule (e.g. every 15 minutes). The key patterns are: fetch incrementally, convert amounts to minor units, deduplicate via externalTxnId, and push balance snapshots for reconciliation.

import { TallyrootClient, toMinor, TallyrootApiError } from '@tallyroot/sdk';

const client = new TallyrootClient({
  apiKey: process.env.TALLYROOT_API_KEY!,
  source: 'my-bank-connector',
});

async function sync(accountId: string) {
  let consecutiveFailures = 0;

  try {
    // 1. Fetch raw transactions from your bank API
    const bankTxns = await fetchFromBank({ since: lastSyncedAt() });

    // 2. Map to Tallyroot format
    const transactions = bankTxns.map((t) => ({
      externalTxnId: t.id,
      accountId,
      bookedAt: t.date,
      currency: t.currency,
      // Bank reports debit as positive — negate it
      amountMinor: t.type === 'debit'
        ? negateMinor(toMinor(t.amount, 2))
        : toMinor(t.amount, 2),
      merchantName: t.merchant ?? null,
      description: t.description,
      bookingStatus: t.pending ? 'pending' : 'booked',
      raw: t,
    }));

    // 3. Push in batches of 200 to stay well within rate limits
    for (let i = 0; i < transactions.length; i += 200) {
      const batch = transactions.slice(i, i + 200);
      const result = await client.upsertTransactions(batch);
      console.log(`Batch ${i / 200 + 1}: ${JSON.stringify(result)}`);
    }

    // 4. Push balance snapshot
    const balance = await fetchBalanceFromBank();
    await client.pushBalanceSnapshots([{
      accountId,
      currency: 'USD',
      balanceMinor: toMinor(balance.available, 2),
      snapshotAt: new Date().toISOString(),
    }]);

    consecutiveFailures = 0;
    saveLastSyncedAt(new Date().toISOString());
  } catch (err) {
    consecutiveFailures++;
    await client.sendAlert({
      error: err instanceof Error ? err.message : String(err),
      consecutiveFailures,
    });
    throw err;
  }
}

Syncing from a CSV

CSV exports from banks often contain decimal amounts and inconsistent date formats. Use toMinor for amounts and normalise dates to ISO 8601 before pushing.

import { parse } from 'csv-parse/sync';
import { readFileSync } from 'node:fs';
import { TallyrootClient, toMinor } from '@tallyroot/sdk';

const client = new TallyrootClient({
  apiKey: process.env.TALLYROOT_API_KEY!,
  source: 'csv-import',
});

// Example CSV row: date,description,amount,balance
// "03/15/2024","Corner Coffee","-4.50","1234.56"

function parseDate(raw: string): string {
  // Convert MM/DD/YYYY to ISO 8601
  const [month, day, year] = raw.split('/');
  return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T00:00:00Z`;
}

async function importCsv(filePath: string, accountId: string) {
  const content = readFileSync(filePath, 'utf8');
  const rows = parse(content, { columns: true, skip_empty_lines: true }) as Array<{
    date: string;
    description: string;
    amount: string;
    balance: string;
  }>;

  const transactions = rows.map((row, index) => ({
    // Stable ID: combine file name + row index (or use a hash of date+description+amount)
    externalTxnId: `csv-${filePath}-${index}`,
    accountId,
    bookedAt: parseDate(row.date),
    currency: 'USD',
    amountMinor: toMinor(row.amount, 2),
    description: row.description,
    bookingStatus: 'booked' as const,
  }));

  const result = await client.upsertTransactions(transactions);
  console.log(`Import complete: ${JSON.stringify(result)}`);

  // Push the final balance from the last row
  const lastRow = rows.at(-1)!;
  await client.pushBalanceSnapshots([{
    accountId,
    currency: 'USD',
    balanceMinor: toMinor(lastRow.balance, 2),
    snapshotAt: new Date().toISOString(),
  }]);
}

Handling the pending / booked / voided lifecycle

Bank transactions move through three states: pending (authorised but not yet settled), booked (settled), and voided (reversed or cancelled). Tallyroot tracks these transitions via the bookingStatus field and the stable externalTxnId.

pending ──→ booked   (transaction settled normally)
pending ──→ voided   (transaction was reversed or declined)

The critical rule: always use the same externalTxnId for all states of a transaction. Tallyroot uses this ID to match the pending record and update it in place.

// Day 1 — transaction appears as pending
await client.upsertTransactions([
  {
    externalTxnId: 'txn_abc123',
    accountId: 'acc_checking',
    bookedAt: '2024-03-14T18:00:00Z',
    currency: 'USD',
    amountMinor: '-4550',
    merchantName: 'Corner Coffee Shop',
    bookingStatus: 'pending',
  },
]);

// Day 3 — transaction has settled
await client.upsertTransactions([
  {
    externalTxnId: 'txn_abc123',      // Same ID — Tallyroot updates the existing record
    accountId: 'acc_checking',
    bookedAt: '2024-03-16T09:00:00Z', // Settlement date replaces the authorisation date
    currency: 'USD',
    amountMinor: '-4550',
    merchantName: 'Corner Coffee Shop',
    bookingStatus: 'booked',           // Status updated to booked
  },
]);

// Alternative Day 3 — transaction was reversed
await client.upsertTransactions([
  {
    externalTxnId: 'txn_abc123',
    accountId: 'acc_checking',
    bookedAt: '2024-03-14T18:00:00Z',
    currency: 'USD',
    amountMinor: '-4550',
    merchantName: 'Corner Coffee Shop',
    bookingStatus: 'voided',           // Tallyroot removes this from the ledger
  },
]);

ID stability across bank API pagination:

Some bank APIs assign different IDs to the pending and booked versions of a transaction. In that case, maintain a local mapping from the bank's pending ID to the booked ID and always translate back to the ID you originally used.

// Store mapping when you first see the pending transaction
const pendingToBookedId = new Map<string, string>();

function getStableId(bankTxn: BankTransaction): string {
  if (bankTxn.status === 'booked' && bankTxn.pendingId) {
    // Return the original pending ID so Tallyroot can match the record
    return pendingToBookedId.get(bankTxn.pendingId) ?? bankTxn.id;
  }
  if (bankTxn.status === 'pending') {
    pendingToBookedId.set(bankTxn.id, bankTxn.id);
  }
  return bankTxn.id;
}

License

MIT — see LICENSE.