A pure TypeScript engine that converts Stripe webhook events into balanced double-entry journal entries, ready to export as QuickBooks Online JournalEntry JSON or Xero ManualJournal JSON. Built for indie SaaS founders.
TypeScript
0
175 commits
updated Sep 21, 2026
A pure TypeScript engine that converts Stripe webhook events into balanced double-entry journal entries, ready to export as QuickBooks Online JournalEntry JSON or Xero ManualJournal JSON.
Built for indie SaaS founders who want clean books without paying an accountant $500 to $2,000 a month to reconcile Stripe data by hand.
Stripe event ─▶ mapEvent ─▶ JournalEntry[] ─▶ toQbo / toXero
912 tests · 18 event types · 57 fixtures · pnpm typecheck and pnpm lint clean.
Given a Stripe charge.succeeded event:
{
"id": "evt_3OqXYZ...",
"type": "charge.succeeded",
"data": { "object": {
"id": "ch_3OqXYZ...",
"amount": 10000,
"balance_transaction": { "fee": 320, "net": 9680, /* ... */ },
/* ... */
}}
}
ledgerly produces a balanced 3-line journal entry:
Dr 1010 Stripe Clearing $96.80
Dr 6000 Stripe Processing Fees $3.20
Cr 4000 Subscription Revenue $100.00
Then renders it as QBO JournalEntry JSON or Xero ManualJournal JSON, ready to push via each platform's accounting API.
Fastest, zero install. Pipe any (pre-expanded) Stripe event through the CLI and watch it become a balanced journal entry:
cat event.json | npx ledgerly # readable table
cat event.json | npx ledgerly --json # raw MapResult JSON
cat event.json | npx ledgerly --qbo # QuickBooks JournalEntry JSON
cat event.json | npx ledgerly --xero # Xero ManualJournal JSON
ledgerly reads a Stripe event on stdin (or a file argument) and prints the entry it maps to — as a readable table, or the exact JSON you'd POST to QuickBooks or Xero (with placeholder account codes you swap for your own). Pipe a JSON array of events or a stripe events list export ({ "data": [...] }) to map a whole batch at once — handy for backfilling books from Stripe history. The engine never calls Stripe, so expand nested objects (balance_transaction, invoice.charge, credit_note.invoice) first. Any of the 57 fixtures is a ready-made input: cat test/fixtures/charge_succeeded_standard.event.json | npx ledgerly.
Or clone and run the demo. No Stripe account needed. It runs two events through the engine, a one-time charge and an annual subscription with revenue recognition.
git clone https://github.com/jakethehoffer/ledgerly
cd ledgerly
pnpm install
pnpm demo
The charge produces a balanced three-line entry, which the demo also renders as QBO and Xero JSON:
2025-01-15 Stripe charge ch_demo_001 (customer cus_demo_001)
------------------------------------------------------------
Account Debit Credit
------------------------------------------------------------
1010 Stripe Clearing $96.80
6000 Stripe Processing Fees $3.20
4000 Subscription Revenue $100.00
------------------------------------------------------------
Totals $100.00 $100.00
balanced: debits $100.00 == credits $100.00
The $1,200 annual subscription is the more interesting case. The cash lands in Deferred Revenue, a liability, and the engine emits a 12-month schedule that releases it to Subscription Revenue $100 at a time, summing back to exactly what was deferred:
RECOGNITION SCHEDULE releases the $1200 deferred over 12 months
each entry: Dr 2100 Deferred Revenue / Cr 4000 Subscription Revenue
----------------------------------------
2025-02-15 $100.00
2025-03-15 $100.00
... (months 3–11) ...
2026-01-15 $100.00
----------------------------------------
total recognized $1200.00
The script is examples/quickstart.mjs. It imports the same public API you'd use after npm i ledgerly. Refunds, disputes, payouts, and multi-currency charges all live in test/fixtures/, and the refund fixtures cover proportional sales-tax drains and realized FX gain/loss. Feed any of the 57 fixtures through mapEvent to see its entry shape.
Want the accounting behind the annual schedule? Read Stripe annual subscriptions: how to recognize the revenue correctly, with the actual journal entries.
Indie SaaS founders reconcile Stripe a few different ways. By hand in a spreadsheet, with a hosted sync tool like A2X or Synder, with Stripe's own reporting exports, or by paying a bookkeeper $500 to $2,000 a month. ledgerly fills the gap between those.
ledgerly is probably not for you if you want a turnkey hosted product with a dashboard and zero ops. It's a library plus an optional self-hosted webhook receiver, not a SaaS. It assumes you or a developer can run a small service and map 14 account codes to your real QBO/Xero accounts once. It handles B2B net-terms invoicing (invoice now, pay later) in a single currency, but not yet cross-currency payouts or cross-currency net-terms settlement. Those are documented as explicit gaps rather than quietly approximated.
ledgerly's primary form is a webhook receiver and scheduler that maps Stripe events and posts to QBO/Xero. The published Docker image carries a signed build provenance attestation and is the fastest path. See Deployment for the full docker run and Docker Compose setup:
docker pull ghcr.io/jakethehoffer/ledgerly:v0.17.0
The pure mapping functions mapEvent, toQbo, and toXero can also be embedded directly in your own webhook handler. Install the package alongside the Stripe SDK:
pnpm add ledgerly stripe
import {
mapEvent,
toQbo,
toXero,
type QboAccountMap,
type XeroAccountMap,
} from 'ledgerly';
// Map ledgerly's 14 account codes to your real QBO / Xero accounts.
const qboAccountMap: QboAccountMap = {
'1000': { qboId: '83', name: 'Checking' },
'1010': { qboId: '84', name: 'Stripe Clearing' },
'4000': { qboId: '101', name: 'Subscription Revenue' },
'6000': { qboId: '201', name: 'Merchant Fees' },
// ... (all 14 codes, see Chart of Accounts below)
};
const xeroAccountMap: XeroAccountMap = {
'1000': { accountCode: '610' },
'1010': { accountCode: '611' },
'4000': { accountCode: '200' },
'6000': { accountCode: '404' },
// ... (all 14 codes)
};
// In your Stripe webhook handler:
function handleWebhook(event: Stripe.Event) {
const result = mapEvent(event);
for (const entry of result.entries) {
const qboEntry = toQbo(entry, qboAccountMap);
const xeroEntry = toXero(entry, xeroAccountMap);
// POST to QBO Accounting API or Xero Accounting API,
// or persist for later batch sync.
}
// Annual subscriptions also produce a recognition schedule:
if (result.schedule) {
for (const futureEntry of result.schedule.entries) {
// Schedule each entry for posting on futureEntry.date
// (this is a monthly Dr 2100 / Cr 4000 over 12 months).
}
}
}
balance_transaction before calling mapEventledgerly is a pure function. It performs no I/O and does not call the Stripe API. Your webhook receiver must expand the balance_transaction field and other nested objects before invoking the engine, otherwise it throws MissingExpansionError.
Recommended pattern with the stripe Node SDK:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
app.post('/webhook', async (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'] as string,
process.env.STRIPE_WEBHOOK_SECRET!,
);
// Expand the balance_transaction for charge events.
if (event.type.startsWith('charge.')) {
const chargeId = (event.data.object as Stripe.Charge).id;
const expanded = await stripe.charges.retrieve(chargeId, {
expand: ['balance_transaction'],
});
event.data.object = expanded;
}
// Similar expansion for invoice/dispute events, see Architecture below.
const result = mapEvent(event);
// ... persist / forward
res.json({ ok: true });
});
Tax-aware refunds: to correctly book refunds of Stripe Tax-bearing charges (drains 2000 Sales Tax Payable proportionally instead of leaving phantom tax liability), expand the charge's invoice when handling charge.refunded:
if (event.type === 'charge.refunded') {
const chargeId = (event.data.object as Stripe.Charge).id;
const expanded = await stripe.charges.retrieve(chargeId, {
expand: ['balance_transaction', 'refunds.data.balance_transaction', 'invoice'],
});
event.data.object = expanded;
}
If charge.invoice is not expanded, meaning a string ID or null, refunds are booked as a flat Dr 4900 / Cr 1010 with no tax split. This matches the engine's prior behavior for backwards compatibility. The built-in expandEvent helper in src/server/expand.ts already includes 'invoice' in its charge.refunded expansion.
| Event | Variants covered |
|---|---|
charge.succeeded | standard, with-app-fee (Connect), zero-amount, trial-conversion |
charge.refunded | partial, full, multi-refund-sequence; refunds of a deferred-schedule invoice reconciled statefully by the server (repays 2100 first, 4900 only for the recognized excess, remaining months re-spread) |
charge.failed | informational (no entries) |
charge.dispute.created | informational |
charge.dispute.funds_withdrawn | standard |
charge.dispute.funds_reinstated | won-path |
charge.dispute.closed | lost, won, warning_closed |
invoice.finalized | B2B net-terms (send_invoice): monthly, annual-deferred; charge_automatically no-op |
invoice.marked_uncollectible | B2B net-terms write-off to bad debt; charge_automatically no-op |
invoice.voided | B2B net-terms reversal; charge_automatically no-op; deferred-schedule voids reconciled statefully by the server (pure engine refuses them) |
invoice.payment_succeeded | monthly, annual-deferred, with-tax, annual-with-tax, with-app-fee, monthly proration invoices, paid same-currency mid-term annual upgrades (the bundled server rebuilds the remaining schedule), one-time-only, paid-from-credit-balance (drains 2200; a deferred invoice defers over its term), out-of-band no-op, B2B send_invoice (clears AR) |
credit_note.created | B2B net-terms pre-payment credit reduces AR + reverses revenue/tax; whole-to-balance post-payment credit books 2200 + reverses revenue/tax; deferred-schedule credit drawn down statefully by the server (pure engine refuses); refund-backed post-payment / charge_automatically pre-payment / split acknowledged as no-op |
credit_note.voided | reverses a voided credit note the engine booked — pre-payment (restores AR) or post-payment-to-balance (claws back 2200) + revenue/tax; deferred-schedule draw-downs un-done statefully by the server (invert + re-inflate schedule); no-op for the shapes credit_note.created didn't book |
invoice.payment_failed | informational |
customer.subscription.updated | informational; the paid invoice or credit note carries the accounting change |
customer.subscription.deleted | the bundled server keeps recognition through Stripe's actual ended_at date and holds every later unposted month; planned end-of-period cancellation stays informational until service ends; the pure engine emits no entry |
payout.paid | standard (USD) |
payout.failed | standard |
Events outside this list throw UnhandledEventError.
Ledgerly accepts charges, refunds, invoices, disputes, and payouts in any
currency Stripe supports. The handler reads the source currency directly from
the Stripe object and stamps entry.currency accordingly. For FX scenarios
(customer-facing currency ≠ account settlement currency, e.g. a Canadian-default
account charging in USD), handlers that have access to the underlying
balance_transaction derive amounts and currency from bt.amount / bt.currency
so the entry posts in the settlement currency and stays balanced.
The QBO and Xero exporters scale amounts according to the currency's minor-unit
precision via currencyMinorUnits:
charge_succeeded_eur.charge_succeeded_jpy.Unrecognized currency codes fall back to two-decimal, matching Stripe's default normalization.
Caveats:
7000 FX Gain/Loss absorbs the
rate-movement delta.
See the charge_refunded_fx and dispute_funds_withdrawn_fx_rate_drift
fixtures. Both handlers fall back gracefully (no 7000 line) when the
original charge.balance_transaction isn't expanded, so callers
bypassing the receiver's expand.ts see no behavior change for
same-currency events.fxContext field ({ customerCurrency, customerAmount, settlementCurrency, settlementAmount }) with pro-rated amounts,
so an operator with a USD-home rate source for, say, a USD→CAD
account can revalue each month's settlementAmount against that
month's rate and post their own FX gain/loss against the
customerAmount baseline. See the invoice_payment_succeeded_annual_fx
fixture: a USD-1200 invoice settled in CAD at rate 1.30 produces 12
recognition entries, each with fxContext: { customerAmount: 10000, settlementAmount: 13000, ... }. Same-currency events omit fxContext
entirely, so their JSON is unchanged.expand.ts expands
payout.destination so the engine can compare destination.currency
against payout.currency; on a mismatch the handler throws. The
alternative was worse than refusing. It would silently produce a
1000/1010 transfer in the source currency that doesn't account for
Stripe's FX fee. The design analysis, the recommended entry shape, and the
exact payload to capture when reporting a real one live in
docs/cross-currency-payouts.md;
implementation follows once a real cross-currency payout payload is
available.JournalEntry.CurrencyRef = { value: entry.currency } on
every entry; Xero infers the currency from each line's account.| Code | Name | Type | Purpose |
|---|---|---|---|
| 1000 | Operating Bank | Asset | Real bank where Stripe payouts settle |
| 1010 | Stripe Clearing | Asset | Stripe balance received, not yet paid out |
| 1100 | Accounts Receivable | Asset | B2B net-terms: revenue accrued at invoice finalization, cleared when paid |
| 1200 | Disputes Receivable | Asset | Funds withdrawn for active disputes, pending outcome |
| 2000 | Sales Tax Payable | Liability | Stripe Tax / VAT collected, owed to authorities |
| 2100 | Deferred Revenue | Liability | Annual sub unearned portion, drawn down monthly |
| 2200 | Customer Credit Balance | Liability | Credit owed to a customer (post-payment credit to balance), drained when spent on a later invoice |
| 4000 | Subscription Revenue | Revenue | Recognized recurring revenue |
| 4100 | Application Fee Revenue | Revenue | Connect platform cut on connected charges |
| 4900 | Refunds Issued | Contra-Revenue | Refund offsets (separate from 4000 for net-revenue reporting) |
| 6000 | Stripe Processing Fees | Expense | Per-transaction Stripe fees from balance_transaction.fee |
| 6100 | Payment Disputes | Expense | Closed-lost writeoffs + non-refundable dispute fees |
| 6200 | Bad Debt Expense | Expense | B2B net-terms receivable written off when marked uncollectible |
| 7000 | FX Gain/Loss | Other Income | Realized currency gain/loss between a charge and its refund or dispute |
Map these codes to your own QBO and Xero account IDs at integration time via the accountMap parameter on each exporter.
For the rationale behind every entry, why each Stripe event produces the debits and credits it does in plain bookkeeping terms, see docs/accounting.md. It's written to be audited by an accountant without reading the code.
mapEvent(event: Stripe.Event) ─▶ MapResult { entries: JournalEntry[], schedule: RecognitionSchedule | null }
Pure function. No state, no I/O, no Stripe API calls. Deterministic, so the same input always produces the same output.
Core invariants (test-enforced):
JournalEntry balances: sum(debits) === sum(credits) exactly (integer cents)memo and a sourceEventId starting with evt_accountCode is in the canonical AccountCode literal union (compile-time)Cents values are integers (enforced by the cents() constructor)Engine assumptions:
Cents = number & { readonly __brand: 'cents' } prevents accidentally passing dollars where cents are expected.balance_transaction, and realized FX gain/loss between a charge and its refund or dispute is booked to 7000. Converting to a single home currency is left to downstream tools via the exposed fxContext. See Currency support.balance_transaction and the refund, dispute, and invoice nested objects must be expanded before invocation, otherwise the engine throws MissingExpansionError.event.ids.For the full design rationale, decisions log, and invariants, see docs/superpowers/specs/2026-05-16-ledgerly-engine-design.md.
Imported from ledgerly (after build, the barrel is at dist/index.js):
| Symbol | Kind | Purpose |
|---|---|---|
mapEvent | function | Dispatch a Stripe event to its handler |
UnhandledEventError, MissingExpansionError | class | Engine errors |
requireExpanded | function | Helper for handlers that consume nested objects |
cents, ZERO_CENTS | function/const | Constructor + zero value for the Cents type |
Cents | type | Branded integer minor units |
ACCOUNTS | const | Canonical chart of accounts (14 entries) |
AccountCode, AccountType, AccountDef, PostingSide | type | Account types |
checkBalance, assertBalanced | function | Balance validators |
JournalLine, JournalEntry, RecognitionSchedule, MapResult, BalanceReport | type | Core data shapes |
toQbo, toQboSchedule | function | QBO API JournalEntry exporters |
toXero, toXeroSchedule | function | Xero ManualJournal exporters |
QboAccountMap, XeroAccountMap | type | Per-tenant account-ID mapping |
QboJournalEntry, QboLine, XeroManualJournal, XeroJournalLine | type | Exporter output shapes |
ledgerly ships with an optional Express-based webhook receiver that wraps the pure engine with everything you need to run a production Stripe webhook endpoint, including signature verification, event deduplication, and per-event-type Stripe API expansion of the nested objects the engine requires. The receiver lives in src/server/ and is intentionally not re-exported from the main ledgerly barrel, so importing mapEvent keeps the Express server out of your bundle. The server's runtime dependencies, express, better-sqlite3, dotenv, and stripe, are declared as peerDependencies, so engine-only consumers don't install them. npm i ledgerly pulls in no Express and no native SQLite module. To run the receiver yourself, install them alongside ledgerly:
pnpm add ledgerly express@^4.22.2 better-sqlite3@^13.0.3 dotenv@^17.4.2 stripe@^16
The published Docker image already bundles them.
Required environment variables:
| Variable | Purpose |
|---|---|
STRIPE_SECRET_KEY | Used for API expansion calls (e.g. stripe.charges.retrieve with expand) |
STRIPE_WEBHOOK_SECRET | Used to verify the Stripe-Signature header (HMAC over the raw body) |
PORT | Optional; defaults to 3000 |
From a source checkout, build and run it:
pnpm build
pnpm start
After installing the package and its server dependencies as above, run its installed launcher instead:
pnpm exec ledgerly-server
Endpoints:
POST /webhook is the Stripe event endpoint. It verifies the signature, dedupes by event.id, expands nested fields via the Stripe API, then calls mapEvent. Returns 200 on success, 200 { duplicate: true } for redeliveries, 200 { unhandled: true } for events outside the supported list, 400 for missing/invalid signatures, and 500 for expansion or processing errors.GET /health is the liveness probe. It returns { ok: true, dedupSize }.GET /metrics returns Prometheus text exposition format (see Metrics below).When working from a source checkout, you can embed the receiver by importing
createServer from the built server. The published package exports the core
engine only, so its server subpaths are not public package imports:
import Stripe from 'stripe';
import { createServer } from './dist/server/index.js';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const { app } = createServer({
stripe,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
});
app.listen(3000);
Production caveat: the default deduplicator is an in-memory Map with a 7-day TTL. That's fine for development and single-instance deployments but loses state on restart and doesn't survive horizontal scaling. Either set LEDGERLY_DB_PATH to enable the bundled SQLite backend (see Persistence) or pass a custom storage implementation via createServer({ ..., storage }) for other backends.
The receiver persists two things: a record of processed Stripe event IDs (so redeliveries are deduplicated across restarts) and every journal entry emitted by mapEvent (so a downstream poster can batch-sync them to QBO/Xero, an audit job can review them, or a recognition-schedule poster can post future-dated entries on their scheduled date).
Two backends ship in the box, both implementing the same Storage interface (src/server/storage/types.ts):
better-sqlite3). Opt-in by setting LEDGERLY_DB_PATH. Survives restarts, uses WAL mode, durable across crashes by SQLite default.Enable SQLite:
export LEDGERLY_DB_PATH=/var/lib/ledgerly/ledgerly.db
pnpm start
When the variable is unset (or empty), the receiver falls back to in-memory and logs a warning at startup.
The SQLite backend manages the following tables. openSqliteDatabase(path) applies the schema on open (idempotent via CREATE TABLE IF NOT EXISTS):
| Table | Purpose |
|---|---|
storage_metadata | One-time upgrade markers, including protection for uncertain legacy dispatches. |
webhook_inbox | Signed events awaiting successful processing, retained for operator retry. |
processed_events | One row per Stripe event.id we've successfully processed. Backs the deduplicator. |
journal_entries | One row per emitted immediate JournalEntry. Full entry JSON in payload; date, currency, memo, source_event_type, source_object_id denormalized for indexed querying. |
scheduled_entries | Future-dated entries from a RecognitionSchedule (e.g. monthly draws against an annual subscription's deferred-revenue balance). status starts as 'pending', transitions to 'posted' after dispatch, or becomes 'held' when it falls after an ended subscription's last service date. |
subscription_ends | One durable actual end date per ended Stripe subscription. It keeps late, out-of-order invoice schedules from re-opening post-end recognition. |
oauth_tokens | QBO and Xero access and refresh tokens, keyed by provider and tenant. |
After a successful mapEvent, the server calls storage.persistMapResult(eventId, result) which atomically (in a single SQLite transaction):
result.entries into journal_entries.result.schedule?.entries (if present) into scheduled_entries with status='pending', unless a saved subscription end makes that row held.processed_events.If any insert throws, like disk full or a constraint violation, the entire bundle rolls back and the event ID is not recorded, so Stripe's next redelivery retries cleanly.
For unhandled event types, where mapEvent throws UnhandledEventError because there's nothing to emit, the event ID is recorded but no entries are written, so Stripe redeliveries of the same unhandled event still get the dedup short-circuit.
The JournalEntryStore interface exposes three query methods used by downstream consumers:
import { openSqliteDatabase, sqliteStorage } from 'ledgerly/dist/server/storage/sqlite.js';
const db = openSqliteDatabase('/var/lib/ledgerly/ledgerly.db');
const storage = sqliteStorage(db);
// All immediate entries for a single Stripe event:
storage.entries.findByEventId('evt_3OqXYZ...');
// Every future-dated entry due on or before today (for a daily recognition job):
storage.entries.findPendingScheduled('2026-05-16');
// Mark one as posted once you've pushed it to QBO/Xero:
storage.entries.markScheduledPosted(42);
For ad-hoc audit queries, the schema is straightforward and the SQLite CLI works fine:
sqlite3 /var/lib/ledgerly/ledgerly.db \
'SELECT id, date, memo FROM journal_entries ORDER BY id DESC LIMIT 20'
ledgerly's engine emits future-dated recognition entries for annual subscriptions (12 monthly Dr 2100 / Cr 4000 entries spread over the year). The receiver persists them to the scheduled_entries table as pending. The scheduler is a background loop that polls for due entries and dispatches them via a pluggable handler.
When Stripe says a subscription has actually ended, the receiver keeps rows on
or before ended_at and changes later unposted rows to held. Held rows are not
dispatched and cannot be re-queued. They still count as deferred revenue when a
later credit note rebuilds the schedule. The deletion itself does not guess a
refund or write-off entry because Stripe sends those money changes separately.
Enable it by setting LEDGERLY_SCHEDULER_ENABLED=true alongside LEDGERLY_DB_PATH:
LEDGERLY_DB_PATH=./ledger.db \
LEDGERLY_SCHEDULER_ENABLED=true \
LEDGERLY_SCHEDULER_INTERVAL_MS=60000 \
pnpm start
The CLI requires a complete QBO or Xero dispatcher configuration before starting the scheduler. Missing or partial setup stops startup before any row can be marked posted. For deliberate local log-only development, set LEDGERLY_DISPATCHER=console. That explicit mode marks logged rows posted and does not send them to accounting software.
Contract: dispatchers must be idempotent. The scheduler may invoke a dispatcher more than once for the same entry if a prior attempt failed after dispatch but before the database recorded the success.
Retry behavior: when a dispatcher throws, the scheduler increments the entry's attempt counter and schedules the next retry via exponential backoff. The default is 60s × 2^(attempts-1), capped at 24h, so attempt 1 waits 60s, attempt 2 waits 120s, and attempt 10 waits ~8.5h. After maxAttempts failures (default 10, configurable via LEDGERLY_SCHEDULER_MAX_ATTEMPTS), the entry is moved to the failed state and needs operator intervention.
Dead-letter queue: entries in status='failed' are surfaced via the /health endpoint's failedScheduled counter and via raw SQL:
SELECT id, event_id, subscription_id, scheduled_date, attempts, last_error
FROM scheduled_entries WHERE status = 'failed';
After fixing the underlying issue, use the bearer-protected
POST /admin/scheduled/:id/retry endpoint documented below. It accepts only
failed entries and rejects every other state without changing the row. Avoid
editing retry state directly in SQLite, because that can erase an uncertain
send or reopen completed accounting work.
Multi-process safety: the scheduler assumes single-writer access to scheduled_entries. Running multiple scheduler instances against the same SQLite database may double-post entries. For multi-process deployments, use a separate locking mechanism or a queue-based dispatcher.
The default scheduler dispatcher logs entries to console. For production, swap in the QBO dispatcher which POSTs each entry to QuickBooks Online's JournalEntry endpoint.
Configure via env vars alongside the scheduler:
LEDGERLY_DB_PATH=./ledger.db \
LEDGERLY_SCHEDULER_ENABLED=true \
LEDGERLY_QBO_ACCESS_TOKEN=ya29.a0AfH6S... \
LEDGERLY_QBO_REALM_ID=4620816365209... \
LEDGERLY_QBO_ACCOUNT_MAP_JSON='{"1010":{"qboId":"83","name":"Stripe Clearing"}, ...}' \
LEDGERLY_QBO_API_BASE=https://sandbox-quickbooks.api.intuit.com \
pnpm start
All three of LEDGERLY_QBO_ACCESS_TOKEN, LEDGERLY_QBO_REALM_ID, and LEDGERLY_QBO_ACCOUNT_MAP_JSON must be set to enable the QBO dispatcher; if only some are set the CLI stops before dispatching. The OAuth path also requires an account map. LEDGERLY_QBO_API_BASE is optional and defaults to the QBO production base URL. Point it at https://sandbox-quickbooks.api.intuit.com for testing.
The LEDGERLY_QBO_ACCOUNT_MAP_JSON maps ledgerly's 14 account codes to your real QBO account IDs and display names. All 14 codes must be present.
OAuth is not handled by ledgerly. The access token must be obtained out-of-band (via QBO's OAuth 2.0 authorization code flow) and refreshed before expiry (QBO tokens expire hourly). For a real SaaS deployment, you'll need a separate OAuth service that stores refresh tokens per-tenant and rotates access tokens; that's a future iteration.
Idempotency: QBO requests carry a stable requestid based on the source event, object, posting date and lines. Retries reuse it, while separate recognition months have different IDs. This uses QBO request IDs, rather than relying on DocNumber uniqueness.
Like the QBO dispatcher, but for Xero's ManualJournals endpoint. Useful for indie SaaS founders outside the US, where Xero is strong in the UK, AU, and NZ.
Configure via env vars alongside the scheduler:
LEDGERLY_DB_PATH=./ledger.db \
LEDGERLY_SCHEDULER_ENABLED=true \
LEDGERLY_XERO_ACCESS_TOKEN=eyJhbGciOiJSUzI1NiI... \
LEDGERLY_XERO_TENANT_ID=70784a6d-c1c5-... \
LEDGERLY_XERO_ACCOUNT_MAP_JSON='{"1010":{"accountCode":"611"}, ...}' \
LEDGERLY_XERO_STATUS=DRAFT \
pnpm start
All three of LEDGERLY_XERO_ACCESS_TOKEN, LEDGERLY_XERO_TENANT_ID, and LEDGERLY_XERO_ACCOUNT_MAP_JSON must be set to enable the Xero dispatcher; if only some are set the CLI stops before dispatching. The OAuth path also requires an account map. LEDGERLY_XERO_API_BASE is optional and defaults to https://api.xero.com. Xero has no separate sandbox base, since the demo company is a flag on the user's tenant.
The LEDGERLY_XERO_ACCOUNT_MAP_JSON maps ledgerly's 14 account codes to your Xero account codes. All 14 codes must be present.
LEDGERLY_XERO_STATUS is DRAFT or POSTED. The default DRAFT lands entries as drafts for user review, and POSTED sends them straight into the ledger. DRAFT is safer for initial integration; switch to POSTED once you trust the mapping.
OAuth is not handled by ledgerly. Obtain access tokens via Xero's OAuth 2.0 authorization code flow out-of-band, store refresh tokens per-tenant, and refresh access tokens before they expire (Xero tokens expire in 30 minutes). For a real SaaS deployment, you'll need a separate OAuth service; that's a future iteration.
Idempotency: Xero sends use a stable source-based key and add a Ledgerly marker to the narration. Before each send, the dispatcher checks for that narration in the connected company. This also covers retries after Xero's six-minute key lifetime. A failed lookup prevents the send. Keep one scheduler writer, and preserve these narrations when editing journals in Xero. At first open after this upgrade, SQLite holds attempted but unposted legacy rows for manual reconciliation. They cannot be retried through the admin route. This prevents replaying an old send whose remote result is unknown; newer attempts remain eligible for normal retries.
Precedence: if both QBO and Xero env vars are configured, the QBO dispatcher wins (CLI selects the first match). For multi-target deployments, run two ledgerly processes, one per target, each with its own env config.
The static-token dispatchers above are fine for one-off testing, but for production you don't want to be copy-pasting access tokens every hour. ledgerly ships a built-in OAuth 2.0 authorization-code flow for both QBO and Xero: an operator clicks "Connect" once, completes consent in their browser, and ledgerly stores the tokens in its database. The managed dispatcher refreshes access tokens automatically before they expire and retries once on 401 after a fresh refresh.
Why OAuth?
Without it, you'd have to obtain access tokens out-of-band (via Postman, a curl script, the provider's playground, etc.) and rotate them every 60 minutes (QBO) or 30 minutes (Xero) by manually exchanging refresh tokens. The OAuth flow automates all of that: storage holds the refresh token, the managed dispatcher exchanges it for a new access token whenever needed.
1. Register an OAuth app in each provider's developer console.
QBO (https://developer.intuit.com/app/developer/dashboard):
https://your-domain.example.com/oauth/qbo/callbackhttp://localhost:3000/oauth/qbo/callbackXero (https://developer.xero.com/app/manage):
https://your-domain.example.com/oauth/xero/callback (same pattern as QBO)The redirect URI you register must match the URI ledgerly advertises EXACTLY (down to scheme, host, port, and path). Mismatches produce opaque errors at the provider's end.
2. Generate a state signing secret.
The OAuth state parameter is HMAC-signed to prevent callback hijacking. The secret must be at least 32 characters; generate one with:
openssl rand -base64 48
3. Set environment variables.
LEDGERLY_DB_PATH=/var/lib/ledgerly/ledgerly.db \
LEDGERLY_SCHEDULER_ENABLED=true \
LEDGERLY_OAUTH_STATE_SECRET=<48+ char secret> \
LEDGERLY_QBO_CLIENT_ID=ABcd1234... \
LEDGERLY_QBO_CLIENT_SECRET=A1b2C3d4... \
LEDGERLY_QBO_REDIRECT_URI=https://your-domain.example.com/oauth/qbo/callback \
LEDGERLY_QBO_ACCOUNT_MAP_JSON='{"1010":{"qboId":"83","name":"Stripe Clearing"}, ...}' \
LEDGERLY_XERO_CLIENT_ID=ABCDEF... \
LEDGERLY_XERO_CLIENT_SECRET=GHIJKL... \
LEDGERLY_XERO_REDIRECT_URI=https://your-domain.example.com/oauth/xero/callback \
LEDGERLY_XERO_ACCOUNT_MAP_JSON='{"1010":{"accountCode":"611"}, ...}' \
pnpm start
When LEDGERLY_*_CLIENT_ID / _CLIENT_SECRET / _REDIRECT_URI are all set for a provider, ledgerly:
GET /oauth/<provider>/start and GET /oauth/<provider>/callback routes.managed<Qbo|Xero>Dispatcher for scheduler dispatch (reads tokens from storage, refreshes automatically). The account map env var is still required.The static-token variables (LEDGERLY_QBO_ACCESS_TOKEN, LEDGERLY_XERO_ACCESS_TOKEN, etc.) continue to work for environments that prefer to manage tokens outside ledgerly. The OAuth client config takes precedence when both are set.
4. Complete the consent flow.
Starting a connection is an operator action, so GET /oauth/<provider>/start is gated behind LEDGERLY_ADMIN_TOKEN — the same bearer token as the /admin/* routes. The CLI refuses to start if OAuth client config is set without it, and the route is not mounted at all when it is missing. Ask for the consent link with your token, then open the URL it returns:
curl -sS -o /dev/null -w '%{redirect_url}\n' \
-H "Authorization: Bearer $LEDGERLY_ADMIN_TOKEN" \
https://your-domain.example.com/oauth/qbo/start
Open that URL in a browser (use /oauth/xero/start for Xero). Sign in to the QBO / Xero org you want ledgerly to manage, approve the requested scopes, and the provider redirects back to the callback URL. On success you'll see a one-line "Connected" page; the receiver has now persisted the token set to the oauth_tokens table.
The link is single-use and expires in 10 minutes: the callback only honours a state that this process issued, and only once. If you need to connect again, ask for a fresh link. A receiver restart also invalidates any link still in flight.
5. The scheduler dispatches automatically.
From this point on, the background scheduler dispatches due scheduled entries to QBO / Xero using the stored tokens. Access tokens are refreshed proactively (60 seconds before expiry) and reactively (on a 401 from the provider). Xero refresh tokens rotate on every use, and ledgerly always persists the new pair before issuing further calls.
Production caveats:
localhost. Terminate TLS at a reverse proxy in front of ledgerly.http vs. https will produce a hard error at consent.oauth_tokens table at LEDGERLY_DB_PATH. Protect the file with appropriate filesystem permissions (mode 0600, owned by the ledgerly service user) and include it in your backup strategy.(provider, tenant_id) and ready for multi-tenant deployments, but the managed dispatchers and CLI expect exactly one token set per provider. Connecting a different QBO realm / Xero org does not replace the existing row — it adds a second one, and the dispatcher then refuses to guess which company to post into, so every dispatch fails until one is removed. List what is connected with GET /admin/oauth and remove the wrong row with DELETE /admin/oauth/<provider>/<tenantId>. Re-connecting the same realm / org does replace its own row.invalid_grant. The scheduler will dead-letter the entry after maxAttempts failures (default 10); the operator must re-run the consent flow to restore the connection.The persistence layer is intentionally minimal. It keeps you from losing events on restart and gives you a queryable audit log of every journal entry, without dragging in a separate database server. Things it does not do:
cp ledgerly.db ledgerly.db.bak while the receiver is running is safe (SQLite WAL mode supports concurrent readers), but you need to schedule it yourself.Storage interface against Postgres, MySQL, or DynamoDB to do that.processed_events and journal_entries grow without bound. For a small SaaS that's many years of data before it matters, but plan for it.JournalEntry.memo may contain customer references inherited from Stripe (subscriptionId, chargeId). The receiver does not redact, encrypt, or otherwise sanitize. Treat the database with the same care you'd give a Stripe export.The receiver, scheduler, and dispatchers all log through a small Logger interface so you can wire ledgerly to pino, winston, datadog, etc. without dragging a logger dependency into ledgerly itself:
interface Logger {
debug(message: string, meta?: unknown): void;
info(message: string, meta?: unknown): void;
warn(message: string, meta?: unknown): void;
error(message: string, meta?: unknown): void;
}
The default logger writes to console.* with an info-level threshold. Override the threshold by setting LEDGERLY_LOG_LEVEL to debug, info, warn, or error (invalid values warn once and fall back to info). The CLI constructs one logger at startup and threads it through createServer, createScheduler, and the dispatcher factories.
For containerized / cloud deployments, set LEDGERLY_LOG_FORMAT=json to switch the CLI to the built-in jsonLogger: one JSON object per line, written to stdout (debug/info) or stderr (warn/error), with the schema {ts, level, msg, ...meta}. Object-valued meta is merged into the root record (pino convention), Error instances are converted to {name, message, stack} so their fields survive JSON.stringify, and the standard fields (ts, level, msg) always win against meta keys with the same name. This format ingests directly into Datadog, CloudWatch, Loki, Splunk, and Vector without any parser configuration.
To plug in pino:
import pino from 'pino';
import { createServer } from 'ledgerly/dist/server/index.js';
import type { Logger } from 'ledgerly/dist/server/logger.js';
const p = pino();
const log: Logger = {
debug: (msg, meta) => p.debug(meta, msg),
info: (msg, meta) => p.info(meta, msg),
warn: (msg, meta) => p.warn(meta, msg),
error: (msg, meta) => p.error(meta, msg),
};
const { app } = createServer({ stripe, webhookSecret, storage, log });
For tests, ledgerly also exports silentLogger(), a no-op Logger that discards everything.
The receiver exposes a GET /metrics endpoint in Prometheus text exposition format (v0.0.4). The default backend is an in-memory implementation with zero runtime dependencies. Point a Prometheus scraper at the receiver and you get counters and gauges for free.
# prometheus.yml
scrape_configs:
- job_name: ledgerly
scrape_interval: 30s
static_configs:
- targets: ['localhost:3000']
Exposed metrics:
Counters (monotonically increasing; _total suffix per Prometheus convention):
ledgerly_webhook_received_total, every inbound POST /webhookledgerly_webhook_duplicate_total, events suppressed by the deduplicatorledgerly_webhook_signature_error_total, missing or invalid Stripe-Signature headerledgerly_webhook_expansion_error_total, Stripe API expansion failedledgerly_webhook_processed_total{type="<event.type>"}, successful map + persist, partitioned by event typeledgerly_webhook_unhandled_total{type="<event.type>"}, event type outside the supported listledgerly_webhook_error_total{type="<event.type>"}, mapEvent or persistence threwledgerly_scheduler_ticks_total, scheduler tick invocationsledgerly_scheduler_attempts_total, dispatcher invocations across all ticksledgerly_scheduler_posted_total, successful dispatchesledgerly_scheduler_failed_total, failed dispatches (sums retries and dead-letters)ledgerly_scheduler_deadlettered_total, entries transitioned to 'failed' on this tickGauges (snapshot values; refreshed from storage on every scrape):
ledgerly_dedup_size, current number of recorded event IDsledgerly_journal_entries, count of persisted immediate journal entriesledgerly_scheduled_pending, pending future-dated entriesledgerly_scheduled_failed, dead-lettered scheduled entriesOverride the namespace prefix (ledgerly_) by setting LEDGERLY_METRICS_NAMESPACE. Example: LEDGERLY_METRICS_NAMESPACE=myapp exposes myapp_webhook_received_total, etc.
Production caveats:
In-memory state is per-process. Multi-process or horizontally scaled deployments will see each instance reporting its own counters. Either scrape each instance individually (Prometheus' static_configs supports this trivially) or implement the Metrics interface against a shared backend (statsd, push gateway).
No authentication on /metrics. Anyone who can reach the endpoint can read the metrics. Use network-level access control (only your scraper can reach the port) or a reverse proxy with basic auth.
Bring your own backend. To use prom-client, OpenTelemetry, or any other library, implement the Metrics interface against it and pass to createServer({ ..., metrics }). The receiver only calls inc(...) / setGauge(...) / render(), so swapping the implementation is a few lines:
import client from 'prom-client';
import type { Metrics } from 'ledgerly/dist/server/metrics.js';
const registry = new client.Registry();
const counters = new Map<string, client.Counter<string>>();
const gauges = new Map<string, client.Gauge<string>>();
const metrics: Metrics = {
inc(name, labels, value = 1) { /* lookup or create counter, .inc(labels, value) */ },
setGauge(name, value, labels) { /* lookup or create gauge, .set(labels, value) */ },
render() { return registry.metrics(); },
};
Signed notices are saved before expansion or mapping. SQLite retains failures across restarts. The in-memory backend retains them only for the life of the process. /health reports pendingWebhooks, and /metrics reports webhooks_pending. Invalid signatures are rejected before storage. Unreadable scheduled payloads are kept as failed rows for repair while healthy rows continue.
The receiver fetches every page of invoice lines, including invoices nested in refund and credit-note events. Newer raw invoice payloads are retrieved in the mapper's supported Stripe schema before use. Pending refunds are retained for retry after settlement, and failed or canceled refunds are not booked.
A refund of a deferred-revenue invoice that arrives before its invoice schedule is retained with a retryable error, without changing the books. After the invoice event is processed, Stripe can redeliver the refund or an operator can retry the saved notice. If the invoice predates Ledgerly, reconcile its opening balance and recognition history before retrying rather than assuming that all revenue was earned.
When LEDGERLY_ADMIN_TOKEN is set (min 32 characters), the receiver mounts
operator-facing endpoints, all gated behind a constant-time bearer
comparison. When the env var is unset, the routes are not mounted at all, so
unauthenticated requests get a generic 404 and the admin surface is invisible
to scanners.
GET /admin/webhooks?limit=N lists retained notices by ID, type and failure status, without exposing their payment payloads.
POST /admin/webhooks/:id/retry reprocesses a retained notice. Successful processing removes it. Failed attempts retain it. Concurrent retries return 409.
GET /admin/entries?limit=N lists immediate journal entries, newest-first.
limit defaults to 50, capped at 500.
GET /admin/oauth lists the connected QBO / Xero companies — provider, tenant
id, access-token expiry, and granted scope. Token values are never returned.
DELETE /admin/oauth/<provider>/<tenantId> removes one connection. This is the
recovery path when two companies are connected for one provider and dispatch has
stopped. Idempotent, so repeating it is safe.
GET /admin/scheduled?status=pending|posted|cancelled|failed|held&limit=N lists
scheduled entries (recognition rows + immediate-dispatch rows). status
defaults to pending.
GET /admin/scheduled/:id fetches one scheduled entry with full retry
metadata (attempts, lastError, nextAttemptAt). 404 when not found.
POST /admin/scheduled/:id/retry re-queues a dead-lettered entry. Resets
status='pending', attempts=0, lastAttemptedAt=null, nextAttemptAt=null,
lastError=null. The next scheduler tick picks it up. Only failed entries
can be retried; other states return 409 without changing the row. Unknown ids
return 404.
# See the most recent failed dispatches
curl -H "Authorization: Bearer $LEDGERLY_ADMIN_TOKEN" \
http://localhost:3000/admin/scheduled?status=failed
# Re-queue scheduled entry id=42 after fixing the underlying issue
# (e.g., revoked OAuth grant, missing account map entry)
curl -X POST -H "Authorization: Bearer $LEDGERLY_ADMIN_TOKEN" \
http://localhost:3000/admin/scheduled/42/retry
Replaces the prior "edit SQLite by hand" recovery path documented under the scheduler's dead-letter section.
Use Node 22 or newer. Direct receiver installs also need better-sqlite3@^13.0.3;
the Docker image includes Node 24 and the updated SQLite binding. This replaces
the old binding that could crash on current Node 24.
Back up the database before upgrading. The first startup holds old attempted sends whose outcome is uncertain. Check those entries against QuickBooks or Xero before releasing or recreating them. The upgrade does not correct old entries already sent to your books.
Configure a complete QBO or Xero dispatcher before enabling the scheduler.
The receiver can collect events with LEDGERLY_SCHEDULER_ENABLED=false.
OAuth connections require LEDGERLY_ADMIN_TOKEN. Custom Storage backends
must implement the new inbox and persistRefundReversal contracts.
Failed verified webhook payloads remain in SQLite until handled successfully. Protect this database and its backups as payment data. Refunds for deferred invoices from before Ledgerly need reconciled opening balances and recognition history before retry. See the release notes.
Pre-built multi-arch images (linux/amd64 + linux/arm64) are published to GitHub Container Registry on every tagged release:
# Pull a specific release (recommended for production):
docker pull ghcr.io/jakethehoffer/ledgerly:v0.17.0
# Or track latest stable:
docker pull ghcr.io/jakethehoffer/ledgerly:latest
The image is built from a multi-stage Dockerfile: the build stage installs
all dependencies, compiles TypeScript, and prunes devDependencies; the
runtime stage carries only node:24-slim + the pruned node_modules +
compiled dist/. It runs as a non-root user (UID 10001), exposes port
3000, and declares a HEALTHCHECK against /health. You can also build
locally from source:
docker build -t ledgerly:local .
Run the receiver with persistent SQLite state, collecting events without dispatching to an accounting provider yet:
docker volume create ledgerly-data
docker run -d --name ledgerly \
-p 3000:3000 \
-v ledgerly-data:/data \
-e STRIPE_SECRET_KEY=sk_test_... \
-e STRIPE_WEBHOOK_SECRET=whsec_... \
-e LEDGERLY_OAUTH_STATE_SECRET="$(openssl rand -base64 48)" \
-e LEDGERLY_ADMIN_TOKEN="$(openssl rand -base64 48)" \
-e LEDGERLY_SCHEDULER_ENABLED=false \
ghcr.io/jakethehoffer/ledgerly:v0.17.0
The image's default LEDGERLY_DB_PATH=/data/ledger.db matches the volume
mount point above. Add a complete QBO/Xero configuration from .env.example,
including the account map, before setting LEDGERLY_SCHEDULER_ENABLED=true.
Missing or partial dispatcher setup stops startup when the scheduler is enabled.
For deliberate log-only development, explicitly set LEDGERLY_DISPATCHER=console.
That mode marks logged rows posted without sending them to accounting software.
docker-compose.yml ships in the repo for running ledgerly locally
without installing Node. You'll use two terminals. stripe listen on the host
mints a fresh webhook signing secret per session, and you feed it to
ledgerly via .env:
# Terminal 1: forward Stripe webhooks to local ledgerly.
$ stripe listen --forward-to localhost:3000/webhook
> Ready! Your webhook signing secret is whsec_...
# Terminal 2: paste the whsec_ value into .env, then bring up ledgerly.
$ cp .env.example .env
$ vi .env # set STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET (and others as needed)
$ docker compose up
# Anytime later, in any terminal, trigger a synthetic event.
$ stripe trigger charge.succeeded
Compose uses the GHCR image by default (ghcr.io/jakethehoffer/ledgerly:latest).
To build from the local Dockerfile instead, for example when iterating on
ledgerly itself, uncomment the build: line in docker-compose.yml
and comment out image:, then docker compose up --build.
SQLite state lives in a named volume (ledgerly-data) that survives
docker compose down. To wipe state between tests, use
docker compose down -v.
No stripe-cli sidecar by design. stripe listen mints a new
webhook signing secret on every startup, which ledgerly needs at boot
to verify signatures. Wiring them together inside Compose would
require a shared volume and entrypoint wait script in the ledgerly
container. That's more complexity than it earns for a problem the documented
Stripe dev workflow already handles cleanly with the host-side stripe listen above.
Every published image carries a SLSA-style build provenance attestation signed via Sigstore by the release workflow's OIDC identity. The attestation binds the image's digest to the exact workflow run, commit SHA, and Dockerfile that produced it. No long-lived signing key, nothing to rotate.
Verify before pulling into production:
gh attestation verify oci://ghcr.io/jakethehoffer/ledgerly:v0.17.0 \
--repo jakethehoffer/ledgerly
A passing verification confirms the image was built by this repo's release workflow on a tagged commit, not by an attacker who compromised the registry.
The published GHCR images cover both linux/amd64 and linux/arm64 natively
(M-series Macs deploying to ARM cloud instances, and vice versa, both pull
the right binary). If you're building locally with docker build on macOS
arm64 but deploying to linux/amd64, force the target platform:
docker buildx build --platform linux/amd64 -t ledgerly:local .
better-sqlite3 is a native module; the published images ship prebuilt
bindings for both supported architectures.
Two endpoints distinguish "process alive" from "ready to serve":
GET /health always returns 200. The body includes dedup size + entry counts. Suitable for Docker's HEALTHCHECK (which the image already declares) and as a Kubernetes livenessProbe. Storage counts are observability sugar, not a readiness gate, so slow counts won't restart your pod.GET /readyz returns 200 if the storage backend responds to a cheap reachability ping (SQLite: SELECT 1; in-memory: no-op), 503 otherwise with the error message under checks.storage. Use as a Kubernetes readinessProbe so a corrupt or unmounted SQLite file pulls the pod out of the load balancer without triggering a liveness restart.# Kubernetes pod-spec excerpt
livenessProbe:
httpGet: { path: /health, port: 3000 }
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet: { path: /readyz, port: 3000 }
initialDelaySeconds: 5
periodSeconds: 10
The Stripe webhook handler verifies signatures against the raw request body.
Any reverse proxy in front of ledgerly (nginx, Caddy, Cloudflare, Traefik,
...) must pass POST /webhook through unmodified, with no buffering, no body
rewrites, and no JSON normalization. Other routes are well-behaved JSON and need
no special handling.
| Variable | Required for |
|---|---|
STRIPE_SECRET_KEY | Webhook expansion (always) |
STRIPE_WEBHOOK_SECRET | Signature verification (always) |
LEDGERLY_DB_PATH | SQLite persistence (defaults to /data/ledger.db in the image) |
LEDGERLY_OAUTH_STATE_SECRET | Required if using QBO/Xero OAuth |
LEDGERLY_ADMIN_TOKEN | Required if using /admin/* endpoints (≥32 chars) |
LEDGERLY_SCHEDULER_ENABLED | Set to true to actually post entries to QBO/Xero |
The CLI exits at startup if STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET is
missing, or if any partial-config conditions are detected (e.g. some QBO
OAuth vars set but not all). See .env.example for the complete inventory.
pnpm test # Run all tests
pnpm test:watch # Vitest in watch mode
pnpm typecheck # tsc --noEmit (strict mode + verbatimModuleSyntax)
pnpm lint # eslint over src/ and test/
pnpm format # prettier --write
pnpm e2e:fixtures # Just the fixture-driven engine + exporter tests
pnpm build # Emit dist/ for library publication
pnpm start # Run the built webhook receiver (requires pnpm build first)
verbatimModuleSyntax)stripe package (types only, no runtime dependency)Both layers are built and documented above. The pure mapping engine handles 18 event types, multi-currency with realized FX gain/loss, and the QBO and Xero exporters. The optional webhook receiver adds signature verification, idempotent persistence, a recognition scheduler with retry and dead-letter, QBO/Xero OAuth, Prometheus metrics, and admin endpoints. It's published to npm and GHCR with signed build provenance.
A few things are deferred deliberately, and called out in the code where they'd otherwise have to be guessed at rather than quietly approximated:
docs/cross-currency-payouts.md.fxContext so a downstream tool with a home-currency rate source can do it. See Currency support.send_invoice) invoice billed in one currency but paid in another, is rejected rather than mixing currencies in the 1100 receivable. Same-currency net-terms invoicing is fully modeled (revenue accrues to 1100 at invoice.finalized, cleared at payment).The accounting is the part most worth scrutinizing. The reasoning behind every entry is written up in plain bookkeeping terms in docs/accounting.md, meant to be audited without reading the code. If you keep books for a SaaS, or you know where the Stripe API gets strange, that's the place to start.
Copyright 2026 Jake Hoffman
Licensed under the Apache License, Version 2.0. See LICENSE for the full text.
Need this wired into your own stack? I build custom Stripe billing and accounting integrations for SaaS teams. Clean books, exports to QuickBooks and Xero, and multi-currency reconciliation that ties out. Email me at jakehoffman.dev@gmail.com.
TypeScript
99.5%
A pure TypeScript engine that converts Stripe webhook events into balanced double-entry journal entries, ready to export as QuickBooks Online JournalEntry JSON or Xero ManualJournal JSON. Built for indie SaaS founders.
TypeScript
0
175 commits
updated Sep 21, 2026
A pure TypeScript engine that converts Stripe webhook events into balanced double-entry journal entries, ready to export as QuickBooks Online JournalEntry JSON or Xero ManualJournal JSON.
Built for indie SaaS founders who want clean books without paying an accountant $500 to $2,000 a month to reconcile Stripe data by hand.
Stripe event ─▶ mapEvent ─▶ JournalEntry[] ─▶ toQbo / toXero
912 tests · 18 event types · 57 fixtures · pnpm typecheck and pnpm lint clean.
Given a Stripe charge.succeeded event:
{
"id": "evt_3OqXYZ...",
"type": "charge.succeeded",
"data": { "object": {
"id": "ch_3OqXYZ...",
"amount": 10000,
"balance_transaction": { "fee": 320, "net": 9680, /* ... */ },
/* ... */
}}
}
ledgerly produces a balanced 3-line journal entry:
Dr 1010 Stripe Clearing $96.80
Dr 6000 Stripe Processing Fees $3.20
Cr 4000 Subscription Revenue $100.00
Then renders it as QBO JournalEntry JSON or Xero ManualJournal JSON, ready to push via each platform's accounting API.
Fastest, zero install. Pipe any (pre-expanded) Stripe event through the CLI and watch it become a balanced journal entry:
cat event.json | npx ledgerly # readable table
cat event.json | npx ledgerly --json # raw MapResult JSON
cat event.json | npx ledgerly --qbo # QuickBooks JournalEntry JSON
cat event.json | npx ledgerly --xero # Xero ManualJournal JSON
ledgerly reads a Stripe event on stdin (or a file argument) and prints the entry it maps to — as a readable table, or the exact JSON you'd POST to QuickBooks or Xero (with placeholder account codes you swap for your own). Pipe a JSON array of events or a stripe events list export ({ "data": [...] }) to map a whole batch at once — handy for backfilling books from Stripe history. The engine never calls Stripe, so expand nested objects (balance_transaction, invoice.charge, credit_note.invoice) first. Any of the 57 fixtures is a ready-made input: cat test/fixtures/charge_succeeded_standard.event.json | npx ledgerly.
Or clone and run the demo. No Stripe account needed. It runs two events through the engine, a one-time charge and an annual subscription with revenue recognition.
git clone https://github.com/jakethehoffer/ledgerly
cd ledgerly
pnpm install
pnpm demo
The charge produces a balanced three-line entry, which the demo also renders as QBO and Xero JSON:
2025-01-15 Stripe charge ch_demo_001 (customer cus_demo_001)
------------------------------------------------------------
Account Debit Credit
------------------------------------------------------------
1010 Stripe Clearing $96.80
6000 Stripe Processing Fees $3.20
4000 Subscription Revenue $100.00
------------------------------------------------------------
Totals $100.00 $100.00
balanced: debits $100.00 == credits $100.00
The $1,200 annual subscription is the more interesting case. The cash lands in Deferred Revenue, a liability, and the engine emits a 12-month schedule that releases it to Subscription Revenue $100 at a time, summing back to exactly what was deferred:
RECOGNITION SCHEDULE releases the $1200 deferred over 12 months
each entry: Dr 2100 Deferred Revenue / Cr 4000 Subscription Revenue
----------------------------------------
2025-02-15 $100.00
2025-03-15 $100.00
... (months 3–11) ...
2026-01-15 $100.00
----------------------------------------
total recognized $1200.00
The script is examples/quickstart.mjs. It imports the same public API you'd use after npm i ledgerly. Refunds, disputes, payouts, and multi-currency charges all live in test/fixtures/, and the refund fixtures cover proportional sales-tax drains and realized FX gain/loss. Feed any of the 57 fixtures through mapEvent to see its entry shape.
Want the accounting behind the annual schedule? Read Stripe annual subscriptions: how to recognize the revenue correctly, with the actual journal entries.
Indie SaaS founders reconcile Stripe a few different ways. By hand in a spreadsheet, with a hosted sync tool like A2X or Synder, with Stripe's own reporting exports, or by paying a bookkeeper $500 to $2,000 a month. ledgerly fills the gap between those.
ledgerly is probably not for you if you want a turnkey hosted product with a dashboard and zero ops. It's a library plus an optional self-hosted webhook receiver, not a SaaS. It assumes you or a developer can run a small service and map 14 account codes to your real QBO/Xero accounts once. It handles B2B net-terms invoicing (invoice now, pay later) in a single currency, but not yet cross-currency payouts or cross-currency net-terms settlement. Those are documented as explicit gaps rather than quietly approximated.
ledgerly's primary form is a webhook receiver and scheduler that maps Stripe events and posts to QBO/Xero. The published Docker image carries a signed build provenance attestation and is the fastest path. See Deployment for the full docker run and Docker Compose setup:
docker pull ghcr.io/jakethehoffer/ledgerly:v0.17.0
The pure mapping functions mapEvent, toQbo, and toXero can also be embedded directly in your own webhook handler. Install the package alongside the Stripe SDK:
pnpm add ledgerly stripe
import {
mapEvent,
toQbo,
toXero,
type QboAccountMap,
type XeroAccountMap,
} from 'ledgerly';
// Map ledgerly's 14 account codes to your real QBO / Xero accounts.
const qboAccountMap: QboAccountMap = {
'1000': { qboId: '83', name: 'Checking' },
'1010': { qboId: '84', name: 'Stripe Clearing' },
'4000': { qboId: '101', name: 'Subscription Revenue' },
'6000': { qboId: '201', name: 'Merchant Fees' },
// ... (all 14 codes, see Chart of Accounts below)
};
const xeroAccountMap: XeroAccountMap = {
'1000': { accountCode: '610' },
'1010': { accountCode: '611' },
'4000': { accountCode: '200' },
'6000': { accountCode: '404' },
// ... (all 14 codes)
};
// In your Stripe webhook handler:
function handleWebhook(event: Stripe.Event) {
const result = mapEvent(event);
for (const entry of result.entries) {
const qboEntry = toQbo(entry, qboAccountMap);
const xeroEntry = toXero(entry, xeroAccountMap);
// POST to QBO Accounting API or Xero Accounting API,
// or persist for later batch sync.
}
// Annual subscriptions also produce a recognition schedule:
if (result.schedule) {
for (const futureEntry of result.schedule.entries) {
// Schedule each entry for posting on futureEntry.date
// (this is a monthly Dr 2100 / Cr 4000 over 12 months).
}
}
}
balance_transaction before calling mapEventledgerly is a pure function. It performs no I/O and does not call the Stripe API. Your webhook receiver must expand the balance_transaction field and other nested objects before invoking the engine, otherwise it throws MissingExpansionError.
Recommended pattern with the stripe Node SDK:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
app.post('/webhook', async (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'] as string,
process.env.STRIPE_WEBHOOK_SECRET!,
);
// Expand the balance_transaction for charge events.
if (event.type.startsWith('charge.')) {
const chargeId = (event.data.object as Stripe.Charge).id;
const expanded = await stripe.charges.retrieve(chargeId, {
expand: ['balance_transaction'],
});
event.data.object = expanded;
}
// Similar expansion for invoice/dispute events, see Architecture below.
const result = mapEvent(event);
// ... persist / forward
res.json({ ok: true });
});
Tax-aware refunds: to correctly book refunds of Stripe Tax-bearing charges (drains 2000 Sales Tax Payable proportionally instead of leaving phantom tax liability), expand the charge's invoice when handling charge.refunded:
if (event.type === 'charge.refunded') {
const chargeId = (event.data.object as Stripe.Charge).id;
const expanded = await stripe.charges.retrieve(chargeId, {
expand: ['balance_transaction', 'refunds.data.balance_transaction', 'invoice'],
});
event.data.object = expanded;
}
If charge.invoice is not expanded, meaning a string ID or null, refunds are booked as a flat Dr 4900 / Cr 1010 with no tax split. This matches the engine's prior behavior for backwards compatibility. The built-in expandEvent helper in src/server/expand.ts already includes 'invoice' in its charge.refunded expansion.
| Event | Variants covered |
|---|---|
charge.succeeded | standard, with-app-fee (Connect), zero-amount, trial-conversion |
charge.refunded | partial, full, multi-refund-sequence; refunds of a deferred-schedule invoice reconciled statefully by the server (repays 2100 first, 4900 only for the recognized excess, remaining months re-spread) |
charge.failed | informational (no entries) |
charge.dispute.created | informational |
charge.dispute.funds_withdrawn | standard |
charge.dispute.funds_reinstated | won-path |
charge.dispute.closed | lost, won, warning_closed |
invoice.finalized | B2B net-terms (send_invoice): monthly, annual-deferred; charge_automatically no-op |
invoice.marked_uncollectible | B2B net-terms write-off to bad debt; charge_automatically no-op |
invoice.voided | B2B net-terms reversal; charge_automatically no-op; deferred-schedule voids reconciled statefully by the server (pure engine refuses them) |
invoice.payment_succeeded | monthly, annual-deferred, with-tax, annual-with-tax, with-app-fee, monthly proration invoices, paid same-currency mid-term annual upgrades (the bundled server rebuilds the remaining schedule), one-time-only, paid-from-credit-balance (drains 2200; a deferred invoice defers over its term), out-of-band no-op, B2B send_invoice (clears AR) |
credit_note.created | B2B net-terms pre-payment credit reduces AR + reverses revenue/tax; whole-to-balance post-payment credit books 2200 + reverses revenue/tax; deferred-schedule credit drawn down statefully by the server (pure engine refuses); refund-backed post-payment / charge_automatically pre-payment / split acknowledged as no-op |
credit_note.voided | reverses a voided credit note the engine booked — pre-payment (restores AR) or post-payment-to-balance (claws back 2200) + revenue/tax; deferred-schedule draw-downs un-done statefully by the server (invert + re-inflate schedule); no-op for the shapes credit_note.created didn't book |
invoice.payment_failed | informational |
customer.subscription.updated | informational; the paid invoice or credit note carries the accounting change |
customer.subscription.deleted | the bundled server keeps recognition through Stripe's actual ended_at date and holds every later unposted month; planned end-of-period cancellation stays informational until service ends; the pure engine emits no entry |
payout.paid | standard (USD) |
payout.failed | standard |
Events outside this list throw UnhandledEventError.
Ledgerly accepts charges, refunds, invoices, disputes, and payouts in any
currency Stripe supports. The handler reads the source currency directly from
the Stripe object and stamps entry.currency accordingly. For FX scenarios
(customer-facing currency ≠ account settlement currency, e.g. a Canadian-default
account charging in USD), handlers that have access to the underlying
balance_transaction derive amounts and currency from bt.amount / bt.currency
so the entry posts in the settlement currency and stays balanced.
The QBO and Xero exporters scale amounts according to the currency's minor-unit
precision via currencyMinorUnits:
charge_succeeded_eur.charge_succeeded_jpy.Unrecognized currency codes fall back to two-decimal, matching Stripe's default normalization.
Caveats:
7000 FX Gain/Loss absorbs the
rate-movement delta.
See the charge_refunded_fx and dispute_funds_withdrawn_fx_rate_drift
fixtures. Both handlers fall back gracefully (no 7000 line) when the
original charge.balance_transaction isn't expanded, so callers
bypassing the receiver's expand.ts see no behavior change for
same-currency events.fxContext field ({ customerCurrency, customerAmount, settlementCurrency, settlementAmount }) with pro-rated amounts,
so an operator with a USD-home rate source for, say, a USD→CAD
account can revalue each month's settlementAmount against that
month's rate and post their own FX gain/loss against the
customerAmount baseline. See the invoice_payment_succeeded_annual_fx
fixture: a USD-1200 invoice settled in CAD at rate 1.30 produces 12
recognition entries, each with fxContext: { customerAmount: 10000, settlementAmount: 13000, ... }. Same-currency events omit fxContext
entirely, so their JSON is unchanged.expand.ts expands
payout.destination so the engine can compare destination.currency
against payout.currency; on a mismatch the handler throws. The
alternative was worse than refusing. It would silently produce a
1000/1010 transfer in the source currency that doesn't account for
Stripe's FX fee. The design analysis, the recommended entry shape, and the
exact payload to capture when reporting a real one live in
docs/cross-currency-payouts.md;
implementation follows once a real cross-currency payout payload is
available.JournalEntry.CurrencyRef = { value: entry.currency } on
every entry; Xero infers the currency from each line's account.| Code | Name | Type | Purpose |
|---|---|---|---|
| 1000 | Operating Bank | Asset | Real bank where Stripe payouts settle |
| 1010 | Stripe Clearing | Asset | Stripe balance received, not yet paid out |
| 1100 | Accounts Receivable | Asset | B2B net-terms: revenue accrued at invoice finalization, cleared when paid |
| 1200 | Disputes Receivable | Asset | Funds withdrawn for active disputes, pending outcome |
| 2000 | Sales Tax Payable | Liability | Stripe Tax / VAT collected, owed to authorities |
| 2100 | Deferred Revenue | Liability | Annual sub unearned portion, drawn down monthly |
| 2200 | Customer Credit Balance | Liability | Credit owed to a customer (post-payment credit to balance), drained when spent on a later invoice |
| 4000 | Subscription Revenue | Revenue | Recognized recurring revenue |
| 4100 | Application Fee Revenue | Revenue | Connect platform cut on connected charges |
| 4900 | Refunds Issued | Contra-Revenue | Refund offsets (separate from 4000 for net-revenue reporting) |
| 6000 | Stripe Processing Fees | Expense | Per-transaction Stripe fees from balance_transaction.fee |
| 6100 | Payment Disputes | Expense | Closed-lost writeoffs + non-refundable dispute fees |
| 6200 | Bad Debt Expense | Expense | B2B net-terms receivable written off when marked uncollectible |
| 7000 | FX Gain/Loss | Other Income | Realized currency gain/loss between a charge and its refund or dispute |
Map these codes to your own QBO and Xero account IDs at integration time via the accountMap parameter on each exporter.
For the rationale behind every entry, why each Stripe event produces the debits and credits it does in plain bookkeeping terms, see docs/accounting.md. It's written to be audited by an accountant without reading the code.
mapEvent(event: Stripe.Event) ─▶ MapResult { entries: JournalEntry[], schedule: RecognitionSchedule | null }
Pure function. No state, no I/O, no Stripe API calls. Deterministic, so the same input always produces the same output.
Core invariants (test-enforced):
JournalEntry balances: sum(debits) === sum(credits) exactly (integer cents)memo and a sourceEventId starting with evt_accountCode is in the canonical AccountCode literal union (compile-time)Cents values are integers (enforced by the cents() constructor)Engine assumptions:
Cents = number & { readonly __brand: 'cents' } prevents accidentally passing dollars where cents are expected.balance_transaction, and realized FX gain/loss between a charge and its refund or dispute is booked to 7000. Converting to a single home currency is left to downstream tools via the exposed fxContext. See Currency support.balance_transaction and the refund, dispute, and invoice nested objects must be expanded before invocation, otherwise the engine throws MissingExpansionError.event.ids.For the full design rationale, decisions log, and invariants, see docs/superpowers/specs/2026-05-16-ledgerly-engine-design.md.
Imported from ledgerly (after build, the barrel is at dist/index.js):
| Symbol | Kind | Purpose |
|---|---|---|
mapEvent | function | Dispatch a Stripe event to its handler |
UnhandledEventError, MissingExpansionError | class | Engine errors |
requireExpanded | function | Helper for handlers that consume nested objects |
cents, ZERO_CENTS | function/const | Constructor + zero value for the Cents type |
Cents | type | Branded integer minor units |
ACCOUNTS | const | Canonical chart of accounts (14 entries) |
AccountCode, AccountType, AccountDef, PostingSide | type | Account types |
checkBalance, assertBalanced | function | Balance validators |
JournalLine, JournalEntry, RecognitionSchedule, MapResult, BalanceReport | type | Core data shapes |
toQbo, toQboSchedule | function | QBO API JournalEntry exporters |
toXero, toXeroSchedule | function | Xero ManualJournal exporters |
QboAccountMap, XeroAccountMap | type | Per-tenant account-ID mapping |
QboJournalEntry, QboLine, XeroManualJournal, XeroJournalLine | type | Exporter output shapes |
ledgerly ships with an optional Express-based webhook receiver that wraps the pure engine with everything you need to run a production Stripe webhook endpoint, including signature verification, event deduplication, and per-event-type Stripe API expansion of the nested objects the engine requires. The receiver lives in src/server/ and is intentionally not re-exported from the main ledgerly barrel, so importing mapEvent keeps the Express server out of your bundle. The server's runtime dependencies, express, better-sqlite3, dotenv, and stripe, are declared as peerDependencies, so engine-only consumers don't install them. npm i ledgerly pulls in no Express and no native SQLite module. To run the receiver yourself, install them alongside ledgerly:
pnpm add ledgerly express@^4.22.2 better-sqlite3@^13.0.3 dotenv@^17.4.2 stripe@^16
The published Docker image already bundles them.
Required environment variables:
| Variable | Purpose |
|---|---|
STRIPE_SECRET_KEY | Used for API expansion calls (e.g. stripe.charges.retrieve with expand) |
STRIPE_WEBHOOK_SECRET | Used to verify the Stripe-Signature header (HMAC over the raw body) |
PORT | Optional; defaults to 3000 |
From a source checkout, build and run it:
pnpm build
pnpm start
After installing the package and its server dependencies as above, run its installed launcher instead:
pnpm exec ledgerly-server
Endpoints:
POST /webhook is the Stripe event endpoint. It verifies the signature, dedupes by event.id, expands nested fields via the Stripe API, then calls mapEvent. Returns 200 on success, 200 { duplicate: true } for redeliveries, 200 { unhandled: true } for events outside the supported list, 400 for missing/invalid signatures, and 500 for expansion or processing errors.GET /health is the liveness probe. It returns { ok: true, dedupSize }.GET /metrics returns Prometheus text exposition format (see Metrics below).When working from a source checkout, you can embed the receiver by importing
createServer from the built server. The published package exports the core
engine only, so its server subpaths are not public package imports:
import Stripe from 'stripe';
import { createServer } from './dist/server/index.js';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const { app } = createServer({
stripe,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
});
app.listen(3000);
Production caveat: the default deduplicator is an in-memory Map with a 7-day TTL. That's fine for development and single-instance deployments but loses state on restart and doesn't survive horizontal scaling. Either set LEDGERLY_DB_PATH to enable the bundled SQLite backend (see Persistence) or pass a custom storage implementation via createServer({ ..., storage }) for other backends.
The receiver persists two things: a record of processed Stripe event IDs (so redeliveries are deduplicated across restarts) and every journal entry emitted by mapEvent (so a downstream poster can batch-sync them to QBO/Xero, an audit job can review them, or a recognition-schedule poster can post future-dated entries on their scheduled date).
Two backends ship in the box, both implementing the same Storage interface (src/server/storage/types.ts):
better-sqlite3). Opt-in by setting LEDGERLY_DB_PATH. Survives restarts, uses WAL mode, durable across crashes by SQLite default.Enable SQLite:
export LEDGERLY_DB_PATH=/var/lib/ledgerly/ledgerly.db
pnpm start
When the variable is unset (or empty), the receiver falls back to in-memory and logs a warning at startup.
The SQLite backend manages the following tables. openSqliteDatabase(path) applies the schema on open (idempotent via CREATE TABLE IF NOT EXISTS):
| Table | Purpose |
|---|---|
storage_metadata | One-time upgrade markers, including protection for uncertain legacy dispatches. |
webhook_inbox | Signed events awaiting successful processing, retained for operator retry. |
processed_events | One row per Stripe event.id we've successfully processed. Backs the deduplicator. |
journal_entries | One row per emitted immediate JournalEntry. Full entry JSON in payload; date, currency, memo, source_event_type, source_object_id denormalized for indexed querying. |
scheduled_entries | Future-dated entries from a RecognitionSchedule (e.g. monthly draws against an annual subscription's deferred-revenue balance). status starts as 'pending', transitions to 'posted' after dispatch, or becomes 'held' when it falls after an ended subscription's last service date. |
subscription_ends | One durable actual end date per ended Stripe subscription. It keeps late, out-of-order invoice schedules from re-opening post-end recognition. |
oauth_tokens | QBO and Xero access and refresh tokens, keyed by provider and tenant. |
After a successful mapEvent, the server calls storage.persistMapResult(eventId, result) which atomically (in a single SQLite transaction):
result.entries into journal_entries.result.schedule?.entries (if present) into scheduled_entries with status='pending', unless a saved subscription end makes that row held.processed_events.If any insert throws, like disk full or a constraint violation, the entire bundle rolls back and the event ID is not recorded, so Stripe's next redelivery retries cleanly.
For unhandled event types, where mapEvent throws UnhandledEventError because there's nothing to emit, the event ID is recorded but no entries are written, so Stripe redeliveries of the same unhandled event still get the dedup short-circuit.
The JournalEntryStore interface exposes three query methods used by downstream consumers:
import { openSqliteDatabase, sqliteStorage } from 'ledgerly/dist/server/storage/sqlite.js';
const db = openSqliteDatabase('/var/lib/ledgerly/ledgerly.db');
const storage = sqliteStorage(db);
// All immediate entries for a single Stripe event:
storage.entries.findByEventId('evt_3OqXYZ...');
// Every future-dated entry due on or before today (for a daily recognition job):
storage.entries.findPendingScheduled('2026-05-16');
// Mark one as posted once you've pushed it to QBO/Xero:
storage.entries.markScheduledPosted(42);
For ad-hoc audit queries, the schema is straightforward and the SQLite CLI works fine:
sqlite3 /var/lib/ledgerly/ledgerly.db \
'SELECT id, date, memo FROM journal_entries ORDER BY id DESC LIMIT 20'
ledgerly's engine emits future-dated recognition entries for annual subscriptions (12 monthly Dr 2100 / Cr 4000 entries spread over the year). The receiver persists them to the scheduled_entries table as pending. The scheduler is a background loop that polls for due entries and dispatches them via a pluggable handler.
When Stripe says a subscription has actually ended, the receiver keeps rows on
or before ended_at and changes later unposted rows to held. Held rows are not
dispatched and cannot be re-queued. They still count as deferred revenue when a
later credit note rebuilds the schedule. The deletion itself does not guess a
refund or write-off entry because Stripe sends those money changes separately.
Enable it by setting LEDGERLY_SCHEDULER_ENABLED=true alongside LEDGERLY_DB_PATH:
LEDGERLY_DB_PATH=./ledger.db \
LEDGERLY_SCHEDULER_ENABLED=true \
LEDGERLY_SCHEDULER_INTERVAL_MS=60000 \
pnpm start
The CLI requires a complete QBO or Xero dispatcher configuration before starting the scheduler. Missing or partial setup stops startup before any row can be marked posted. For deliberate local log-only development, set LEDGERLY_DISPATCHER=console. That explicit mode marks logged rows posted and does not send them to accounting software.
Contract: dispatchers must be idempotent. The scheduler may invoke a dispatcher more than once for the same entry if a prior attempt failed after dispatch but before the database recorded the success.
Retry behavior: when a dispatcher throws, the scheduler increments the entry's attempt counter and schedules the next retry via exponential backoff. The default is 60s × 2^(attempts-1), capped at 24h, so attempt 1 waits 60s, attempt 2 waits 120s, and attempt 10 waits ~8.5h. After maxAttempts failures (default 10, configurable via LEDGERLY_SCHEDULER_MAX_ATTEMPTS), the entry is moved to the failed state and needs operator intervention.
Dead-letter queue: entries in status='failed' are surfaced via the /health endpoint's failedScheduled counter and via raw SQL:
SELECT id, event_id, subscription_id, scheduled_date, attempts, last_error
FROM scheduled_entries WHERE status = 'failed';
After fixing the underlying issue, use the bearer-protected
POST /admin/scheduled/:id/retry endpoint documented below. It accepts only
failed entries and rejects every other state without changing the row. Avoid
editing retry state directly in SQLite, because that can erase an uncertain
send or reopen completed accounting work.
Multi-process safety: the scheduler assumes single-writer access to scheduled_entries. Running multiple scheduler instances against the same SQLite database may double-post entries. For multi-process deployments, use a separate locking mechanism or a queue-based dispatcher.
The default scheduler dispatcher logs entries to console. For production, swap in the QBO dispatcher which POSTs each entry to QuickBooks Online's JournalEntry endpoint.
Configure via env vars alongside the scheduler:
LEDGERLY_DB_PATH=./ledger.db \
LEDGERLY_SCHEDULER_ENABLED=true \
LEDGERLY_QBO_ACCESS_TOKEN=ya29.a0AfH6S... \
LEDGERLY_QBO_REALM_ID=4620816365209... \
LEDGERLY_QBO_ACCOUNT_MAP_JSON='{"1010":{"qboId":"83","name":"Stripe Clearing"}, ...}' \
LEDGERLY_QBO_API_BASE=https://sandbox-quickbooks.api.intuit.com \
pnpm start
All three of LEDGERLY_QBO_ACCESS_TOKEN, LEDGERLY_QBO_REALM_ID, and LEDGERLY_QBO_ACCOUNT_MAP_JSON must be set to enable the QBO dispatcher; if only some are set the CLI stops before dispatching. The OAuth path also requires an account map. LEDGERLY_QBO_API_BASE is optional and defaults to the QBO production base URL. Point it at https://sandbox-quickbooks.api.intuit.com for testing.
The LEDGERLY_QBO_ACCOUNT_MAP_JSON maps ledgerly's 14 account codes to your real QBO account IDs and display names. All 14 codes must be present.
OAuth is not handled by ledgerly. The access token must be obtained out-of-band (via QBO's OAuth 2.0 authorization code flow) and refreshed before expiry (QBO tokens expire hourly). For a real SaaS deployment, you'll need a separate OAuth service that stores refresh tokens per-tenant and rotates access tokens; that's a future iteration.
Idempotency: QBO requests carry a stable requestid based on the source event, object, posting date and lines. Retries reuse it, while separate recognition months have different IDs. This uses QBO request IDs, rather than relying on DocNumber uniqueness.
Like the QBO dispatcher, but for Xero's ManualJournals endpoint. Useful for indie SaaS founders outside the US, where Xero is strong in the UK, AU, and NZ.
Configure via env vars alongside the scheduler:
LEDGERLY_DB_PATH=./ledger.db \
LEDGERLY_SCHEDULER_ENABLED=true \
LEDGERLY_XERO_ACCESS_TOKEN=eyJhbGciOiJSUzI1NiI... \
LEDGERLY_XERO_TENANT_ID=70784a6d-c1c5-... \
LEDGERLY_XERO_ACCOUNT_MAP_JSON='{"1010":{"accountCode":"611"}, ...}' \
LEDGERLY_XERO_STATUS=DRAFT \
pnpm start
All three of LEDGERLY_XERO_ACCESS_TOKEN, LEDGERLY_XERO_TENANT_ID, and LEDGERLY_XERO_ACCOUNT_MAP_JSON must be set to enable the Xero dispatcher; if only some are set the CLI stops before dispatching. The OAuth path also requires an account map. LEDGERLY_XERO_API_BASE is optional and defaults to https://api.xero.com. Xero has no separate sandbox base, since the demo company is a flag on the user's tenant.
The LEDGERLY_XERO_ACCOUNT_MAP_JSON maps ledgerly's 14 account codes to your Xero account codes. All 14 codes must be present.
LEDGERLY_XERO_STATUS is DRAFT or POSTED. The default DRAFT lands entries as drafts for user review, and POSTED sends them straight into the ledger. DRAFT is safer for initial integration; switch to POSTED once you trust the mapping.
OAuth is not handled by ledgerly. Obtain access tokens via Xero's OAuth 2.0 authorization code flow out-of-band, store refresh tokens per-tenant, and refresh access tokens before they expire (Xero tokens expire in 30 minutes). For a real SaaS deployment, you'll need a separate OAuth service; that's a future iteration.
Idempotency: Xero sends use a stable source-based key and add a Ledgerly marker to the narration. Before each send, the dispatcher checks for that narration in the connected company. This also covers retries after Xero's six-minute key lifetime. A failed lookup prevents the send. Keep one scheduler writer, and preserve these narrations when editing journals in Xero. At first open after this upgrade, SQLite holds attempted but unposted legacy rows for manual reconciliation. They cannot be retried through the admin route. This prevents replaying an old send whose remote result is unknown; newer attempts remain eligible for normal retries.
Precedence: if both QBO and Xero env vars are configured, the QBO dispatcher wins (CLI selects the first match). For multi-target deployments, run two ledgerly processes, one per target, each with its own env config.
The static-token dispatchers above are fine for one-off testing, but for production you don't want to be copy-pasting access tokens every hour. ledgerly ships a built-in OAuth 2.0 authorization-code flow for both QBO and Xero: an operator clicks "Connect" once, completes consent in their browser, and ledgerly stores the tokens in its database. The managed dispatcher refreshes access tokens automatically before they expire and retries once on 401 after a fresh refresh.
Why OAuth?
Without it, you'd have to obtain access tokens out-of-band (via Postman, a curl script, the provider's playground, etc.) and rotate them every 60 minutes (QBO) or 30 minutes (Xero) by manually exchanging refresh tokens. The OAuth flow automates all of that: storage holds the refresh token, the managed dispatcher exchanges it for a new access token whenever needed.
1. Register an OAuth app in each provider's developer console.
QBO (https://developer.intuit.com/app/developer/dashboard):
https://your-domain.example.com/oauth/qbo/callbackhttp://localhost:3000/oauth/qbo/callbackXero (https://developer.xero.com/app/manage):
https://your-domain.example.com/oauth/xero/callback (same pattern as QBO)The redirect URI you register must match the URI ledgerly advertises EXACTLY (down to scheme, host, port, and path). Mismatches produce opaque errors at the provider's end.
2. Generate a state signing secret.
The OAuth state parameter is HMAC-signed to prevent callback hijacking. The secret must be at least 32 characters; generate one with:
openssl rand -base64 48
3. Set environment variables.
LEDGERLY_DB_PATH=/var/lib/ledgerly/ledgerly.db \
LEDGERLY_SCHEDULER_ENABLED=true \
LEDGERLY_OAUTH_STATE_SECRET=<48+ char secret> \
LEDGERLY_QBO_CLIENT_ID=ABcd1234... \
LEDGERLY_QBO_CLIENT_SECRET=A1b2C3d4... \
LEDGERLY_QBO_REDIRECT_URI=https://your-domain.example.com/oauth/qbo/callback \
LEDGERLY_QBO_ACCOUNT_MAP_JSON='{"1010":{"qboId":"83","name":"Stripe Clearing"}, ...}' \
LEDGERLY_XERO_CLIENT_ID=ABCDEF... \
LEDGERLY_XERO_CLIENT_SECRET=GHIJKL... \
LEDGERLY_XERO_REDIRECT_URI=https://your-domain.example.com/oauth/xero/callback \
LEDGERLY_XERO_ACCOUNT_MAP_JSON='{"1010":{"accountCode":"611"}, ...}' \
pnpm start
When LEDGERLY_*_CLIENT_ID / _CLIENT_SECRET / _REDIRECT_URI are all set for a provider, ledgerly:
GET /oauth/<provider>/start and GET /oauth/<provider>/callback routes.managed<Qbo|Xero>Dispatcher for scheduler dispatch (reads tokens from storage, refreshes automatically). The account map env var is still required.The static-token variables (LEDGERLY_QBO_ACCESS_TOKEN, LEDGERLY_XERO_ACCESS_TOKEN, etc.) continue to work for environments that prefer to manage tokens outside ledgerly. The OAuth client config takes precedence when both are set.
4. Complete the consent flow.
Starting a connection is an operator action, so GET /oauth/<provider>/start is gated behind LEDGERLY_ADMIN_TOKEN — the same bearer token as the /admin/* routes. The CLI refuses to start if OAuth client config is set without it, and the route is not mounted at all when it is missing. Ask for the consent link with your token, then open the URL it returns:
curl -sS -o /dev/null -w '%{redirect_url}\n' \
-H "Authorization: Bearer $LEDGERLY_ADMIN_TOKEN" \
https://your-domain.example.com/oauth/qbo/start
Open that URL in a browser (use /oauth/xero/start for Xero). Sign in to the QBO / Xero org you want ledgerly to manage, approve the requested scopes, and the provider redirects back to the callback URL. On success you'll see a one-line "Connected" page; the receiver has now persisted the token set to the oauth_tokens table.
The link is single-use and expires in 10 minutes: the callback only honours a state that this process issued, and only once. If you need to connect again, ask for a fresh link. A receiver restart also invalidates any link still in flight.
5. The scheduler dispatches automatically.
From this point on, the background scheduler dispatches due scheduled entries to QBO / Xero using the stored tokens. Access tokens are refreshed proactively (60 seconds before expiry) and reactively (on a 401 from the provider). Xero refresh tokens rotate on every use, and ledgerly always persists the new pair before issuing further calls.
Production caveats:
localhost. Terminate TLS at a reverse proxy in front of ledgerly.http vs. https will produce a hard error at consent.oauth_tokens table at LEDGERLY_DB_PATH. Protect the file with appropriate filesystem permissions (mode 0600, owned by the ledgerly service user) and include it in your backup strategy.(provider, tenant_id) and ready for multi-tenant deployments, but the managed dispatchers and CLI expect exactly one token set per provider. Connecting a different QBO realm / Xero org does not replace the existing row — it adds a second one, and the dispatcher then refuses to guess which company to post into, so every dispatch fails until one is removed. List what is connected with GET /admin/oauth and remove the wrong row with DELETE /admin/oauth/<provider>/<tenantId>. Re-connecting the same realm / org does replace its own row.invalid_grant. The scheduler will dead-letter the entry after maxAttempts failures (default 10); the operator must re-run the consent flow to restore the connection.The persistence layer is intentionally minimal. It keeps you from losing events on restart and gives you a queryable audit log of every journal entry, without dragging in a separate database server. Things it does not do:
cp ledgerly.db ledgerly.db.bak while the receiver is running is safe (SQLite WAL mode supports concurrent readers), but you need to schedule it yourself.Storage interface against Postgres, MySQL, or DynamoDB to do that.processed_events and journal_entries grow without bound. For a small SaaS that's many years of data before it matters, but plan for it.JournalEntry.memo may contain customer references inherited from Stripe (subscriptionId, chargeId). The receiver does not redact, encrypt, or otherwise sanitize. Treat the database with the same care you'd give a Stripe export.The receiver, scheduler, and dispatchers all log through a small Logger interface so you can wire ledgerly to pino, winston, datadog, etc. without dragging a logger dependency into ledgerly itself:
interface Logger {
debug(message: string, meta?: unknown): void;
info(message: string, meta?: unknown): void;
warn(message: string, meta?: unknown): void;
error(message: string, meta?: unknown): void;
}
The default logger writes to console.* with an info-level threshold. Override the threshold by setting LEDGERLY_LOG_LEVEL to debug, info, warn, or error (invalid values warn once and fall back to info). The CLI constructs one logger at startup and threads it through createServer, createScheduler, and the dispatcher factories.
For containerized / cloud deployments, set LEDGERLY_LOG_FORMAT=json to switch the CLI to the built-in jsonLogger: one JSON object per line, written to stdout (debug/info) or stderr (warn/error), with the schema {ts, level, msg, ...meta}. Object-valued meta is merged into the root record (pino convention), Error instances are converted to {name, message, stack} so their fields survive JSON.stringify, and the standard fields (ts, level, msg) always win against meta keys with the same name. This format ingests directly into Datadog, CloudWatch, Loki, Splunk, and Vector without any parser configuration.
To plug in pino:
import pino from 'pino';
import { createServer } from 'ledgerly/dist/server/index.js';
import type { Logger } from 'ledgerly/dist/server/logger.js';
const p = pino();
const log: Logger = {
debug: (msg, meta) => p.debug(meta, msg),
info: (msg, meta) => p.info(meta, msg),
warn: (msg, meta) => p.warn(meta, msg),
error: (msg, meta) => p.error(meta, msg),
};
const { app } = createServer({ stripe, webhookSecret, storage, log });
For tests, ledgerly also exports silentLogger(), a no-op Logger that discards everything.
The receiver exposes a GET /metrics endpoint in Prometheus text exposition format (v0.0.4). The default backend is an in-memory implementation with zero runtime dependencies. Point a Prometheus scraper at the receiver and you get counters and gauges for free.
# prometheus.yml
scrape_configs:
- job_name: ledgerly
scrape_interval: 30s
static_configs:
- targets: ['localhost:3000']
Exposed metrics:
Counters (monotonically increasing; _total suffix per Prometheus convention):
ledgerly_webhook_received_total, every inbound POST /webhookledgerly_webhook_duplicate_total, events suppressed by the deduplicatorledgerly_webhook_signature_error_total, missing or invalid Stripe-Signature headerledgerly_webhook_expansion_error_total, Stripe API expansion failedledgerly_webhook_processed_total{type="<event.type>"}, successful map + persist, partitioned by event typeledgerly_webhook_unhandled_total{type="<event.type>"}, event type outside the supported listledgerly_webhook_error_total{type="<event.type>"}, mapEvent or persistence threwledgerly_scheduler_ticks_total, scheduler tick invocationsledgerly_scheduler_attempts_total, dispatcher invocations across all ticksledgerly_scheduler_posted_total, successful dispatchesledgerly_scheduler_failed_total, failed dispatches (sums retries and dead-letters)ledgerly_scheduler_deadlettered_total, entries transitioned to 'failed' on this tickGauges (snapshot values; refreshed from storage on every scrape):
ledgerly_dedup_size, current number of recorded event IDsledgerly_journal_entries, count of persisted immediate journal entriesledgerly_scheduled_pending, pending future-dated entriesledgerly_scheduled_failed, dead-lettered scheduled entriesOverride the namespace prefix (ledgerly_) by setting LEDGERLY_METRICS_NAMESPACE. Example: LEDGERLY_METRICS_NAMESPACE=myapp exposes myapp_webhook_received_total, etc.
Production caveats:
In-memory state is per-process. Multi-process or horizontally scaled deployments will see each instance reporting its own counters. Either scrape each instance individually (Prometheus' static_configs supports this trivially) or implement the Metrics interface against a shared backend (statsd, push gateway).
No authentication on /metrics. Anyone who can reach the endpoint can read the metrics. Use network-level access control (only your scraper can reach the port) or a reverse proxy with basic auth.
Bring your own backend. To use prom-client, OpenTelemetry, or any other library, implement the Metrics interface against it and pass to createServer({ ..., metrics }). The receiver only calls inc(...) / setGauge(...) / render(), so swapping the implementation is a few lines:
import client from 'prom-client';
import type { Metrics } from 'ledgerly/dist/server/metrics.js';
const registry = new client.Registry();
const counters = new Map<string, client.Counter<string>>();
const gauges = new Map<string, client.Gauge<string>>();
const metrics: Metrics = {
inc(name, labels, value = 1) { /* lookup or create counter, .inc(labels, value) */ },
setGauge(name, value, labels) { /* lookup or create gauge, .set(labels, value) */ },
render() { return registry.metrics(); },
};
Signed notices are saved before expansion or mapping. SQLite retains failures across restarts. The in-memory backend retains them only for the life of the process. /health reports pendingWebhooks, and /metrics reports webhooks_pending. Invalid signatures are rejected before storage. Unreadable scheduled payloads are kept as failed rows for repair while healthy rows continue.
The receiver fetches every page of invoice lines, including invoices nested in refund and credit-note events. Newer raw invoice payloads are retrieved in the mapper's supported Stripe schema before use. Pending refunds are retained for retry after settlement, and failed or canceled refunds are not booked.
A refund of a deferred-revenue invoice that arrives before its invoice schedule is retained with a retryable error, without changing the books. After the invoice event is processed, Stripe can redeliver the refund or an operator can retry the saved notice. If the invoice predates Ledgerly, reconcile its opening balance and recognition history before retrying rather than assuming that all revenue was earned.
When LEDGERLY_ADMIN_TOKEN is set (min 32 characters), the receiver mounts
operator-facing endpoints, all gated behind a constant-time bearer
comparison. When the env var is unset, the routes are not mounted at all, so
unauthenticated requests get a generic 404 and the admin surface is invisible
to scanners.
GET /admin/webhooks?limit=N lists retained notices by ID, type and failure status, without exposing their payment payloads.
POST /admin/webhooks/:id/retry reprocesses a retained notice. Successful processing removes it. Failed attempts retain it. Concurrent retries return 409.
GET /admin/entries?limit=N lists immediate journal entries, newest-first.
limit defaults to 50, capped at 500.
GET /admin/oauth lists the connected QBO / Xero companies — provider, tenant
id, access-token expiry, and granted scope. Token values are never returned.
DELETE /admin/oauth/<provider>/<tenantId> removes one connection. This is the
recovery path when two companies are connected for one provider and dispatch has
stopped. Idempotent, so repeating it is safe.
GET /admin/scheduled?status=pending|posted|cancelled|failed|held&limit=N lists
scheduled entries (recognition rows + immediate-dispatch rows). status
defaults to pending.
GET /admin/scheduled/:id fetches one scheduled entry with full retry
metadata (attempts, lastError, nextAttemptAt). 404 when not found.
POST /admin/scheduled/:id/retry re-queues a dead-lettered entry. Resets
status='pending', attempts=0, lastAttemptedAt=null, nextAttemptAt=null,
lastError=null. The next scheduler tick picks it up. Only failed entries
can be retried; other states return 409 without changing the row. Unknown ids
return 404.
# See the most recent failed dispatches
curl -H "Authorization: Bearer $LEDGERLY_ADMIN_TOKEN" \
http://localhost:3000/admin/scheduled?status=failed
# Re-queue scheduled entry id=42 after fixing the underlying issue
# (e.g., revoked OAuth grant, missing account map entry)
curl -X POST -H "Authorization: Bearer $LEDGERLY_ADMIN_TOKEN" \
http://localhost:3000/admin/scheduled/42/retry
Replaces the prior "edit SQLite by hand" recovery path documented under the scheduler's dead-letter section.
Use Node 22 or newer. Direct receiver installs also need better-sqlite3@^13.0.3;
the Docker image includes Node 24 and the updated SQLite binding. This replaces
the old binding that could crash on current Node 24.
Back up the database before upgrading. The first startup holds old attempted sends whose outcome is uncertain. Check those entries against QuickBooks or Xero before releasing or recreating them. The upgrade does not correct old entries already sent to your books.
Configure a complete QBO or Xero dispatcher before enabling the scheduler.
The receiver can collect events with LEDGERLY_SCHEDULER_ENABLED=false.
OAuth connections require LEDGERLY_ADMIN_TOKEN. Custom Storage backends
must implement the new inbox and persistRefundReversal contracts.
Failed verified webhook payloads remain in SQLite until handled successfully. Protect this database and its backups as payment data. Refunds for deferred invoices from before Ledgerly need reconciled opening balances and recognition history before retry. See the release notes.
Pre-built multi-arch images (linux/amd64 + linux/arm64) are published to GitHub Container Registry on every tagged release:
# Pull a specific release (recommended for production):
docker pull ghcr.io/jakethehoffer/ledgerly:v0.17.0
# Or track latest stable:
docker pull ghcr.io/jakethehoffer/ledgerly:latest
The image is built from a multi-stage Dockerfile: the build stage installs
all dependencies, compiles TypeScript, and prunes devDependencies; the
runtime stage carries only node:24-slim + the pruned node_modules +
compiled dist/. It runs as a non-root user (UID 10001), exposes port
3000, and declares a HEALTHCHECK against /health. You can also build
locally from source:
docker build -t ledgerly:local .
Run the receiver with persistent SQLite state, collecting events without dispatching to an accounting provider yet:
docker volume create ledgerly-data
docker run -d --name ledgerly \
-p 3000:3000 \
-v ledgerly-data:/data \
-e STRIPE_SECRET_KEY=sk_test_... \
-e STRIPE_WEBHOOK_SECRET=whsec_... \
-e LEDGERLY_OAUTH_STATE_SECRET="$(openssl rand -base64 48)" \
-e LEDGERLY_ADMIN_TOKEN="$(openssl rand -base64 48)" \
-e LEDGERLY_SCHEDULER_ENABLED=false \
ghcr.io/jakethehoffer/ledgerly:v0.17.0
The image's default LEDGERLY_DB_PATH=/data/ledger.db matches the volume
mount point above. Add a complete QBO/Xero configuration from .env.example,
including the account map, before setting LEDGERLY_SCHEDULER_ENABLED=true.
Missing or partial dispatcher setup stops startup when the scheduler is enabled.
For deliberate log-only development, explicitly set LEDGERLY_DISPATCHER=console.
That mode marks logged rows posted without sending them to accounting software.
docker-compose.yml ships in the repo for running ledgerly locally
without installing Node. You'll use two terminals. stripe listen on the host
mints a fresh webhook signing secret per session, and you feed it to
ledgerly via .env:
# Terminal 1: forward Stripe webhooks to local ledgerly.
$ stripe listen --forward-to localhost:3000/webhook
> Ready! Your webhook signing secret is whsec_...
# Terminal 2: paste the whsec_ value into .env, then bring up ledgerly.
$ cp .env.example .env
$ vi .env # set STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET (and others as needed)
$ docker compose up
# Anytime later, in any terminal, trigger a synthetic event.
$ stripe trigger charge.succeeded
Compose uses the GHCR image by default (ghcr.io/jakethehoffer/ledgerly:latest).
To build from the local Dockerfile instead, for example when iterating on
ledgerly itself, uncomment the build: line in docker-compose.yml
and comment out image:, then docker compose up --build.
SQLite state lives in a named volume (ledgerly-data) that survives
docker compose down. To wipe state between tests, use
docker compose down -v.
No stripe-cli sidecar by design. stripe listen mints a new
webhook signing secret on every startup, which ledgerly needs at boot
to verify signatures. Wiring them together inside Compose would
require a shared volume and entrypoint wait script in the ledgerly
container. That's more complexity than it earns for a problem the documented
Stripe dev workflow already handles cleanly with the host-side stripe listen above.
Every published image carries a SLSA-style build provenance attestation signed via Sigstore by the release workflow's OIDC identity. The attestation binds the image's digest to the exact workflow run, commit SHA, and Dockerfile that produced it. No long-lived signing key, nothing to rotate.
Verify before pulling into production:
gh attestation verify oci://ghcr.io/jakethehoffer/ledgerly:v0.17.0 \
--repo jakethehoffer/ledgerly
A passing verification confirms the image was built by this repo's release workflow on a tagged commit, not by an attacker who compromised the registry.
The published GHCR images cover both linux/amd64 and linux/arm64 natively
(M-series Macs deploying to ARM cloud instances, and vice versa, both pull
the right binary). If you're building locally with docker build on macOS
arm64 but deploying to linux/amd64, force the target platform:
docker buildx build --platform linux/amd64 -t ledgerly:local .
better-sqlite3 is a native module; the published images ship prebuilt
bindings for both supported architectures.
Two endpoints distinguish "process alive" from "ready to serve":
GET /health always returns 200. The body includes dedup size + entry counts. Suitable for Docker's HEALTHCHECK (which the image already declares) and as a Kubernetes livenessProbe. Storage counts are observability sugar, not a readiness gate, so slow counts won't restart your pod.GET /readyz returns 200 if the storage backend responds to a cheap reachability ping (SQLite: SELECT 1; in-memory: no-op), 503 otherwise with the error message under checks.storage. Use as a Kubernetes readinessProbe so a corrupt or unmounted SQLite file pulls the pod out of the load balancer without triggering a liveness restart.# Kubernetes pod-spec excerpt
livenessProbe:
httpGet: { path: /health, port: 3000 }
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet: { path: /readyz, port: 3000 }
initialDelaySeconds: 5
periodSeconds: 10
The Stripe webhook handler verifies signatures against the raw request body.
Any reverse proxy in front of ledgerly (nginx, Caddy, Cloudflare, Traefik,
...) must pass POST /webhook through unmodified, with no buffering, no body
rewrites, and no JSON normalization. Other routes are well-behaved JSON and need
no special handling.
| Variable | Required for |
|---|---|
STRIPE_SECRET_KEY | Webhook expansion (always) |
STRIPE_WEBHOOK_SECRET | Signature verification (always) |
LEDGERLY_DB_PATH | SQLite persistence (defaults to /data/ledger.db in the image) |
LEDGERLY_OAUTH_STATE_SECRET | Required if using QBO/Xero OAuth |
LEDGERLY_ADMIN_TOKEN | Required if using /admin/* endpoints (≥32 chars) |
LEDGERLY_SCHEDULER_ENABLED | Set to true to actually post entries to QBO/Xero |
The CLI exits at startup if STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET is
missing, or if any partial-config conditions are detected (e.g. some QBO
OAuth vars set but not all). See .env.example for the complete inventory.
pnpm test # Run all tests
pnpm test:watch # Vitest in watch mode
pnpm typecheck # tsc --noEmit (strict mode + verbatimModuleSyntax)
pnpm lint # eslint over src/ and test/
pnpm format # prettier --write
pnpm e2e:fixtures # Just the fixture-driven engine + exporter tests
pnpm build # Emit dist/ for library publication
pnpm start # Run the built webhook receiver (requires pnpm build first)
verbatimModuleSyntax)stripe package (types only, no runtime dependency)Both layers are built and documented above. The pure mapping engine handles 18 event types, multi-currency with realized FX gain/loss, and the QBO and Xero exporters. The optional webhook receiver adds signature verification, idempotent persistence, a recognition scheduler with retry and dead-letter, QBO/Xero OAuth, Prometheus metrics, and admin endpoints. It's published to npm and GHCR with signed build provenance.
A few things are deferred deliberately, and called out in the code where they'd otherwise have to be guessed at rather than quietly approximated:
docs/cross-currency-payouts.md.fxContext so a downstream tool with a home-currency rate source can do it. See Currency support.send_invoice) invoice billed in one currency but paid in another, is rejected rather than mixing currencies in the 1100 receivable. Same-currency net-terms invoicing is fully modeled (revenue accrues to 1100 at invoice.finalized, cleared at payment).The accounting is the part most worth scrutinizing. The reasoning behind every entry is written up in plain bookkeeping terms in docs/accounting.md, meant to be audited without reading the code. If you keep books for a SaaS, or you know where the Stripe API gets strange, that's the place to start.
Copyright 2026 Jake Hoffman
Licensed under the Apache License, Version 2.0. See LICENSE for the full text.
Need this wired into your own stack? I build custom Stripe billing and accounting integrations for SaaS teams. Clean books, exports to QuickBooks and Xero, and multi-currency reconciliation that ties out. Email me at jakehoffman.dev@gmail.com.
TypeScript
99.5%