Run reconciliation
Resolve charges left mid-lifecycle by crashes or unknown provider outcomes.
When a provider times out or a process dies between steps, a charge can be left reserved, settling, unknown, or refund_pending. toll.reconcile() resolves them by asking the provider — never by guessing.
const report = await toll.reconcile({ olderThanMs: 15 * 60_000 });
// { examined: 3, resolved: 2, pending: 1, charges: [...], errors: [...], truncated: false }charges lists every charge examined with its state before and after; errors lists what went wrong, also delivered to onEvent.
From the command line
Export your instance from a module and run it from cron, CI, or by hand:
import { toll } from "./src/toll.js";
export default toll;npx tollstile reconcile --older-than 15mReconciled 3 charges last updated more than 15m ago
chg_5701… GET /report $0.05 unknown/completed → settled/completed
chg_9a2c… GET /report $0.05 reserved/running → released/failed
chg_c41e… POST /summarize $0.50 unknown/completed · unknown/completed
2 resolved · 1 pending · 1 error
! PROVIDER_UNAVAILABLE chg_c41e…: Provider did not answer within 10000ms …| Option | Meaning |
|---|---|
--config <file> | Default export: the instance, { toll }, or a function returning either. Default tollstile.config.mjs |
--older-than <age> | 90s, 15m, 2h, 1d. Default 15m — longer than your slowest handler |
--json | The report as JSON |
--fail-on-pending | Exit 2 while charges stay unresolved, for alerting |
Exit codes: 0 done, 1 errors reported, 2 pending with --fail-on-pending. It needs a database ledger: a memory ledger in a separate process has nothing to reconcile.
Running several workers at once is safe: a charge another worker moves first is left to it.
Schedule it
setInterval(() => void toll.reconcile(), 60_000);export default {
fetch: app.fetch,
scheduled: (_event, _env, ctx) => ctx.waitUntil(toll.reconcile()),
};What it does
| Charge | Action |
|---|---|
| reserved, handler not completed | Release — the service may not exist |
| reserved, handler completed | Settle |
| settling or unknown | Look up at the provider; record what happened, or retry, or release |
| settled, handler not completed | Refund — money moved before the service was confirmed |
| refund pending or unknown | Look up; record the refund or retry it |
olderThanMs must exceed your slowest handler, so reconciliation never acts on a request still running. Charges it cannot resolve stay pending and emit error events.
Bounded runs
A run examines at most limit charges (500 by default), most recently updated first, and report.truncated says whether it stopped short. The order is deliberate: a charge touched a minute ago is mid-lifecycle and worth a provider call; one nobody has touched in days is the one that cannot be resolved — a rail no longer configured, a policy whose balance is unknown — and it is visited last, after everything that can be. A ledger that has grown does not make each run slower, and a backlog of unresolvable rows cannot starve the ones that need work.
npx tollstile reconcile keeps running while truncated is true, up to twenty rounds, so a cron drains the backlog; in your own code, loop on truncated or raise limit.
Retries
While a keyed charge is unknown, retries return 503 payment_outcome_unknown. After reconciliation records settled/completed, a retry returns 409 already_paid with the stored result reference; reconciliation does not rerun the handler or replay its response. Released/refunded attempts may run again. See Idempotency.
Charge per detection with SAM 3
Outcome pricing for a segmentation API — SAM 3 proposes every instance, a second opinion checks each crop, and only the detections that survive are charged for.
Test payment failures
Simulate provider outages, timeouts, and rejections with the test rail before real money is involved.