ledgerly
v0.18.1
Published
Turn Stripe events into balanced QuickBooks Online and Xero journal entries. Tested TypeScript library for your own webhook handler, plus a self-hosted receiver.
Maintainers
Readme
ledgerly
Writing your own Stripe to QuickBooks or Xero code? Ledgerly is a free, open-source TypeScript library that turns Stripe events into balanced double-entry journal entries, ready to post as a QuickBooks Online JournalEntry or a Xero ManualJournal. Call mapEvent inside your own webhook handler, or run the bundled self-hosted receiver.
It covers the cases hand-written sync code tends to get wrong. The engine handles net payouts through a Stripe clearing account, annual plans spread month by month, disputes won and lost, and sales tax and currency changes on refunds. The receiver adds the cases that need saved history: refunds of a partly used annual plan, duplicate webhook deliveries, and a refund that arrives before its invoice. Before wiring anything up, you can preview your own last 30 days of Stripe activity with a read-only key.
Ledgerly creates journal entries, not customer invoices, and does not replace accounting advice.
Stripe event ─▶ mapEvent ─▶ JournalEntry[] ─▶ toQbo / toXero934 tests · 18 event types · 57 fixtures · pnpm typecheck and pnpm lint clean.
What it does
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.00Then renders it as QBO JournalEntry JSON or Xero ManualJournal JSON, ready to push via each platform's accounting API.
Try it
Start with a complete sample. You need Node.js 22 or newer and internet access. Run this from an empty folder outside a cloned Ledgerly project. No Stripe, Xero or QuickBooks account is needed, and the sample does not move money or write to your books. npx downloads Ledgerly to its local cache. If you already cloned this project, use pnpm demo below instead.
On macOS or Linux:
curl -fsSL https://raw.githubusercontent.com/jakethehoffer/ledgerly/v0.18.0/test/fixtures/invoice_payment_succeeded_annual.event.json | npx --yes [email protected]On Windows PowerShell:
curl.exe -fsSL https://raw.githubusercontent.com/jakethehoffer/ledgerly/v0.18.0/test/fixtures/invoice_payment_succeeded_annual.event.json | npx --yes [email protected]You should see a $1,200 annual payment, a sample $36 fee, $1,164 left in Stripe, and 12 monthly entries of $100 each. The $36 is example data, not a quote for your Stripe fees. The schedule totals $1,200. Add --xero or --qbo to inspect the initial payment export with placeholder account codes. Those options omit the monthly schedule. Nothing is sent to either service.
See what changes when the customer upgrades or cancels. That guide also explains why a cancellation does not itself issue a refund. The single-event command above shows the initial annual schedule. Changes to an existing schedule require the bundled receiver and its saved history.
See your own recent Stripe payments. Once the sample works, Ledgerly can read the last 30 days of your Stripe activity and show the entries it would make. It only reads. Nothing is stored, and nothing is sent to QuickBooks or Xero. Create a restricted key in the Stripe Dashboard with Read access and no Write access, so it cannot change anything in your account. If the key is missing a permission, Stripe's error message names it.
On macOS or Linux (the key is typed without showing and stays out of your shell history):
read -rsp "Stripe restricted key: " STRIPE_SECRET_KEY; echo
STRIPE_SECRET_KEY="$STRIPE_SECRET_KEY" npx --yes [email protected] --stripe --days 30On Windows PowerShell 7:
$env:STRIPE_SECRET_KEY = Read-Host "Stripe restricted key" -MaskInput
npx --yes [email protected] --stripe --days 30
Remove-Item Env:STRIPE_SECRET_KEYStripe keeps events for 30 days, so older payments are not shown. Amounts appear in your Stripe settlement currency. Each event is mapped on its own. The receiver also uses saved history for later plan changes, cancellations and refunds of annual plans, so its entries for those can differ. Add --qbo or --xero to print the export JSON instead.
Tell us whether it helped. Share a trial result: did the sample run, which recurring task would you use it for, and what stopped you from taking the next step? You can describe the task in plain words. Do not include customer details, keys, or real payment records. A report that it did not fit is useful too.
Use your own prepared event. Once the sample works, pipe a pre-expanded Stripe event through the CLI:
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 JSONledgerly 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 list response ({ "data": [...] }) to map a whole batch at once. Event types Ledgerly does not map are skipped. Piped events must already have their nested objects expanded (balance_transaction, invoice.charge, credit_note.invoice), because the engine never calls Stripe. To have Ledgerly fetch and expand your recent events, use --stripe above. 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 demoThe 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.00The $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.00The 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.
Why ledgerly?
Stripe bookkeeping can involve a spreadsheet, a hosted sync tool, Stripe's own reports, or a bookkeeper. Ledgerly is for people who want to run and inspect the event-to-journal mapping themselves. Setup and accounting review are still needed.
- vs. a hosted sync tool. Those are managed SaaS with a monthly fee, and the mapping from Stripe events to journal entries is a closed box you can't inspect or change. ledgerly is open source and runs on your own infrastructure. The mapping engine is under 3,000 lines of TypeScript, every entry shape is pinned by a fixture test, and you own the chart of accounts. No monthly fee, no third party in your financial data path.
- vs. Stripe's native reporting. Stripe gives you summaries, CSV exports, and a separate paid Revenue Recognition product. ledgerly emits actual balanced double-entry journal entries, ready to POST to the QuickBooks Online or Xero API. Deferred revenue is released month by month, sales tax is drained proportionally on refunds, and realized FX gain/loss is booked when rates move between a charge and its refund. Credit notes reverse revenue whether the money is refunded to the card or kept as a customer credit balance, and net-terms (B2B) invoices recognize revenue at finalization against a receivable.
- vs. doing it by hand. The mapping from a Stripe event to a journal entry is deterministic, so it shouldn't be manual work. ledgerly makes that mapping a pure function, where the same event always produces the same balanced entry.
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.
Quick start
Run the service
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.18.0Use the engine as a library
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 stripeimport {
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).
}
}
}Pre-expand balance_transaction before calling mapEvent
ledgerly 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.
Supported events
| 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.
Currency support
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:
- Two-decimal currencies (USD, EUR, GBP, CAD, AUD, ...) → divide by 100.
See
charge_succeeded_eur. - Zero-decimal currencies (JPY, KRW, VND, ...) → no scaling; major unit
IS the smallest unit. See
charge_succeeded_jpy. - Three-decimal currencies (BHD, KWD, JOD, ...) → divide by 1000.
Unrecognized currency codes fall back to two-decimal, matching Stripe's default normalization.
Caveats:
- Realized FX gain/loss is recognized on refunds and dispute
withdrawals when the FX rate moved between the original charge and the
follow-up event. On both paths the cash leg posts at the follow-up's
rate, the actual clawback or refund deduction, while the receivable or
revenue-offset leg posts at the original-charge rate to match what was
originally booked, and account
7000 FX Gain/Lossabsorbs the rate-movement delta. See thecharge_refunded_fxanddispute_funds_withdrawn_fx_rate_driftfixtures. Both handlers fall back gracefully (no 7000 line) when the originalcharge.balance_transactionisn't expanded, so callers bypassing the receiver'sexpand.tssee no behavior change for same-currency events. - Multi-period FX recognition is not auto-computed, but the engine
exposes enough information that downstream tools with a home-currency
rate source can do it themselves. When an FX invoice is recognized
monthly, every cash entry AND every monthly recognition entry carries
an optional
fxContextfield ({ 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'ssettlementAmountagainst that month's rate and post their own FX gain/loss against thecustomerAmountbaseline. See theinvoice_payment_succeeded_annual_fxfixture: a USD-1200 invoice settled in CAD at rate 1.30 produces 12 recognition entries, each withfxContext: { customerAmount: 10000, settlementAmount: 13000, ... }. Same-currency events omitfxContextentirely, so their JSON is unchanged. - Cross-currency payouts (Stripe converting between settlement
currency and the destination bank's currency, e.g. a CAD-settling
account paying out to a USD bank account) are explicitly rejected
with a clear error. The receiver's
expand.tsexpandspayout.destinationso the engine can comparedestination.currencyagainstpayout.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 indocs/cross-currency-payouts.md; implementation follows once a real cross-currency payout payload is available. - The operator's QBO/Xero company file must have multi-currency enabled
(and the relevant accounts configured for the foreign currency) before
posting non-home-currency entries will succeed downstream. The QBO
exporter sets
JournalEntry.CurrencyRef = { value: entry.currency }on every entry; Xero infers the currency from each line's account.
Chart of accounts
| 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.
Architecture
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):
- Every emitted
JournalEntrybalances:sum(debits) === sum(credits)exactly (integer cents) - Every entry has a non-empty
memoand asourceEventIdstarting withevt_ - Every
accountCodeis in the canonicalAccountCodeliteral union (compile-time) - All
Centsvalues are integers (enforced by thecents()constructor) - Same input → byte-identical JSON output across runs
Engine assumptions:
- Integer minor units throughout.
Cents = number & { readonly __brand: 'cents' }prevents accidentally passing dollars where cents are expected. - Posts in the settlement currency. Entries are stamped with the currency from
balance_transaction, and realized FX gain/loss between a charge and its refund or dispute is booked to7000. Converting to a single home currency is left to downstream tools via the exposedfxContext. See Currency support. - Caller pre-expands nested objects.
balance_transactionand the refund, dispute, and invoice nested objects must be expanded before invocation, otherwise the engine throwsMissingExpansionError. - Caller handles event deduplication. Stripe redelivers, and ledgerly is stateless, so the caller stores processed
event.ids.
For the full design rationale, decisions log, and invariants, see docs/superpowers/specs/2026-05-16-ledgerly-engine-design.md.
Public API
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 |
Webhook receiver
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@^16The 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 startAfter installing the package and its server dependencies as above, run its installed launcher instead:
pnpm exec ledgerly-serverEndpoints:
POST /webhookis the Stripe event endpoint. It verifies the signature, dedupes byevent.id, expands nested fields via the Stripe API, then callsmapEvent. Returns200on success,200 { duplicate: true }for redeliveries,200 { unhandled: true }for events outside the supported list,400for missing/invalid signatures, and500for expansion or processing errors.GET /healthis the liveness probe. It returns{ ok: true, dedupSize }.GET /metricsreturns 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.
Persistence
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):
- In-memory. The default. Fine for tests and quick demos; loses everything on restart.
- SQLite (via
better-sqlite3). Opt-in by settingLEDGERLY_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 startWhen the variable is unset (or empty), the receiver falls back to in-memory and logs a warning at startup.
Schema
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. |
What gets persisted
After a successful mapEvent, the server calls storage.persistMapResult(eventId, result) which atomically (in a single SQLite transaction):
- Inserts every entry in
result.entriesintojournal_entries. - Inserts every entry in
result.schedule?.entries(if present) intoscheduled_entrieswithstatus='pending', unless a saved subscription end makes that rowheld. - Records the event ID in
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.
Querying
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'Scheduler
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 startThe 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.
QBO API 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 startAll 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.
Xero API dispatcher
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 startAll 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.
OAuth setup (QBO + Xero)
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):
- Create an app in the Intuit Developer dashboard
- Add a redirect URI:
- Production:
https://your-domain.example.com/oauth/qbo/callback - Development:
http://localhost:3000/oauth/qbo/callback
- Production:
- Copy the client ID + secret
Xero (https://developer.xero.com/app/manage):
- Create an app in the Xero developer portal
- Add a redirect URI:
https://your-domain.example.com/oauth/xero/callback(same pattern as QBO) - Copy the client ID + secret
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 483. 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 startWhen LEDGERLY_*_CLIENT_ID / _CLIENT_SECRET / _REDIRECT_URI are all set for a provider, ledgerly:
- Mounts
GET /oauth/<provider>/startandGET /oauth/<provider>/callbackroutes. - Uses the
managed<Qbo|Xero>Dispatcherfor 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/startOpen 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:
- HTTPS is required. Intuit and Xero both reject HTTP redirect URIs except for
localhost. Terminate TLS at a reverse proxy in front of ledgerly. - Redirect URI must match the registered value exactly. A trailing slash, port mismatch, or
httpvs.httpswill produce a hard error at consent. - Tokens live in SQLite. They sit in the
oauth_tokenstable atLEDGERLY_DB_PATH. Protect the file with appropriate filesystem permissions (mode0600, owned by the ledgerly service user) and include it in your backup strategy. - Single-tenant MVP. Storage is keyed by
(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 withGET /admin/oauthand remove the wrong row withDELETE /admin/oauth/<provider>/<tenantId>. Re-connecting the same realm / org does replace its own row. - Refresh token revocation. If an admin manually revokes the connection from the QBO / Xero side, the next refresh attempt will fail with
invalid_grant. The scheduler will dead-letter the entry aftermaxAttemptsfailures (default 10); the operator must re-run the consent flow to restore the connection.
Production caveats
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:
- No automatic backups.
cp ledgerly.db ledgerly.db.bakwhile the receiver is running is safe (SQLite WAL mode supports concurrent readers), but you need to schedule it yourself. - Local schema upgrades. Opening SQLite applies additive tables and indexes, upgrades legacy schedule columns, and records one-time upgrade markers. Back up persistent storage before a production upgrade.
- Single-writer. SQLite is fine for one webhook receiver process. Horizontal scaling with multiple instances behind a load balancer will need a real database. Implement the
Storageinterface against Postgres, MySQL, or DynamoDB to do that. - No retention policy.
processed_eventsandjournal_entriesgrow without bound. For a small SaaS that's many years of data before it matters, but plan for it. - No PII redaction.
JournalEntry.memomay 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.
Logging
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.
Metrics
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 invalidStripe-Signatureheaderledgerly_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>"},mapEventor 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 tick
Gauges (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 entries
Override 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_configssupports this trivially) or implement theMetricsinterface 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
Metricsinterface against it and pass tocreateServer({ ..., metrics }). The receiver only callsinc(...)/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.
Admin endpoints
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=Nlists retained notices by ID, type and failure status, without exposing their payment payloads.POST /admin/webhooks/:id/retryreprocesses a retained notice. Successful processing removes it. Failed attempts retain it. Concurrent retries return 409.GET /admin/entries?limit=Nlists immediate journal entries, newest-first.limitdefaults to 50, capped at 500.GET /admin/oauthlists 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=Nlists scheduled entries (recognition rows + immediate-dispatch rows).statusdefaults topending.GET /admin/scheduled/:idfetches one scheduled entry with full retry metadata (attempts, lastError, nextAttemptAt). 404 when not found.POST /admin/scheduled/:id/retryre-queues a dead-lettered entry. Resetsstatus='pending',attempts=0,lastAttemptedAt=null,nextAttemptAt=null,lastError=null. The next scheduler tick picks it up. Onlyfailedentries 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/retryReplaces the prior "edit SQLite by hand" recovery path documented under the scheduler's dead-letter section.
Deployment
Upgrading to 0.17.0
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.
Docker
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.18.0
# Or track latest stable:
docker pull ghcr.io/jakethehoffer/ledgerly:latestThe 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.18.0The 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 (local dev)
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.succeededCompose 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.
Verifying the image (build provenance)
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.18.0 \
--repo jakethehoffer/ledgerlyA 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.
Cross-platform builds
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.
Health and readiness probes
Two endpoints distinguish "process alive" from "ready to serve":
GET /healthalways returns 200. The body includes dedup size + entry counts. Suitable for Docker'sHEALTHCHECK(which the image already declares) and as a KuberneteslivenessProbe. Storage counts are observability sugar, not a readiness gate, so slow counts won't restart your pod.GET /readyzreturns 200 if the storage backend responds to a cheap reachability ping (SQLite:SELECT 1; in-memory: no-op), 503 otherwise with the error message underchecks.storage. Use as a KubernetesreadinessProbeso 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: 10Behind a reverse proxy
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.
Required environment
| 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.
Scripts
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)Tech stack
- Node 22+
- TypeScript (strict, NodeNext ESM,
verbatimModuleSyntax) - Vitest
- ESLint + Prettier
stripepackage (types only, no runtime dependency)
Status
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
