Ledger and reconciliation
Every request that reaches the sidecar ends as one row in the ledger, a SQLite file under .spendctl/. A row is an attempt. Its id comes back to the agent in the X-Spendctl-Attempt response header, in refusal bodies and in the summary line of the MCP pay tool. spendctl ledger, the dashboard, spendctl export and the MCP list_receipts tool all read the same rows.
Statuses
Section titled “Statuses”| Status | Meaning | Counts against budgets |
|---|---|---|
denied |
Policy refused the request, or a human denied the approval. Nothing was signed. | no |
dry_run |
--dry-run / dryRun: the decision was computed, nothing was sent. |
no |
pending_approval |
Waiting on the wire for a human (hold mode). | no |
held |
Handed back to the agent with an approval id (retry mode, or holdMax passed). |
no |
superseded |
The retry that carried the approval replaced this row; the retry is its own row. | no |
expired |
The approval timed out before anyone decided. | no |
reserved |
Allowed. The amount is reserved while the payment is signed and sent. | yes |
settled |
The seller answered 2xx with a settlement receipt; the transaction hash is kept. | yes |
failed |
Signing failed, or the seller rejected the payment (another 402, or a settlement header that says so). | no |
unknown |
The signed payment left the sidecar and the outcome could not be confirmed. | yes, until reconciled |
Budget windows (budget.hourly and friends) add up reserved, settled and unknown; the other statuses never consume budget (Policy reference).
When the outcome is unknown
Section titled “When the outcome is unknown”Three things put an attempt in unknown:
- the paid request failed or timed out after the signed payment had left the sidecar (a network error, or
server.upstreamTimeout, 30 seconds by default); - the seller answered an error other than 402 after the payment;
- the x402 settlement header came back marked pending. The transaction hash, when the seller sent one, is kept.
Without the seller’s receipt the sidecar cannot tell “settled, response lost” from “never settled”, so it asserts neither. The row keeps the error text and whatever the seller did return.
What follows from unknown:
-
It stays in the budget. An unconfirmed payment is counted until a human reconciles it; it is never free spend.
-
The agent is told, and nothing is retried. The sidecar answers
502:{"error": "spendctl_upstream_error","attempt": "pay_…","message": "The paid request failed (…). spendctl will not retry automatically to avoid paying twice."}Through the MCP
paytool the same message is the tool result, withLedger entry: pay_…as its last line, so the model can quote the id to a human. -
It counts as a failure for the host’s circuit breaker. After
circuitBreaker.failuresconsecutivefailedorunknownoutcomes (3 by default) payments to that host pause forcircuitBreaker.cooldown(5 minutes by default) and are refused withcircuit_open. Asettledpayment resets the count.
Never paid twice
Section titled “Never paid twice”The sidecar signs once per decision and never re-sends a payment on its own. For the agent’s own retries it keeps an idempotency window:
- Put the same
Idempotency-Keyheader on a retry (the MCPpaytool takes it asidempotencyKey). If the same agent already sent that key to the same host and path withinidempotencyWindow(10 minutes by default) and that attempt isreserved,settled,unknownorpending_approval, the retry is refused withduplicate_requestand the message names the original attempt. The agent has to look at that attempt instead of paying again. - A retry without a key is a new decision and, if policy allows it, a new payment. Give agents a key whenever the call is not naturally idempotent.
Matching rows to the agent’s own requests
Section titled “Matching rows to the agent’s own requests”Since spendctl 0.5, if the agent puts an Idempotency-Key or an X-Request-Id header on its request, the sidecar keeps both on the attempt (trimmed, at most 255 characters each) and hands them back wherever the attempt shows up:
- every refusal, approval-required and error body carries them as
idempotencyKeyandrequestId, next toattempt; spendctl exporthasidempotency_keyandrequest_idas its last two columns, so existing CSV column positions do not move;- the MCP
paytool names them in its summary and refusal text, andlist_receiptsadds them to each line.
An agent that fired several calls can then tell which refusal belongs to which of its calls without matching on host and path. Ledgers written by earlier releases gain the request_id column when the sidecar opens them (schema version 3).
Reconciling
Section titled “Reconciling”Reconciliation is a human step today.
spendctl ledger --limit 20 # unknown rows are highlightedspendctl trace pay_… # the attempt, with any agent→agent chain around itspendctl resolve pay_… --settled --tx 0x… # the payment went throughspendctl resolve pay_… --failed # it did not; the amount leaves the budget windowsCheck the seller’s dashboard or the chain first; the row records the payer address and, when the seller sent one, the transaction hash (spendctl export --format json prints them). resolve accepts only attempts that are unknown and refuses anything else with 409. --settled keeps the amount counted and stores the hash; --failed releases it. Like approvals, resolve needs the admin token in .spendctl/admin.token; agents cannot call it. spendctl export carries the status column, so unreconciled rows appear as unknown in expense exports.
Limits today, and what is under consideration
Section titled “Limits today, and what is under consideration”What the ledger does not do yet. Items marked under consideration have no date; they are listed so you can plan around them.
unknownmeans unconfirmed, not “settled but undelivered”. The sidecar does not assert settlement without the seller’s receipt, and reconciliation is manual. Under consideration: automatic reconciliation that asks the facilitator or the chain about the recorded payment and resolves the attempt without a human.- The hosted sandbox cannot produce an
unknown. Its mock sellers run in-process and never time out, so this path can only be exercised locally against a seller that hangs after settling. Under consideration: a sandbox endpoint that settles and then times out, so agent-side handling can be rehearsed with no real funds. - Approvals from MCP clients. Hold mode keeps the HTTP request open for up to
holdMax(4 minutes by default). That suits CLIs and scripts, not MCP clients that cut tool calls at about 60 seconds; for MCP agents setapprovals.hold: falseso the agent gets the approval id back at once, as the sandbox does. From 0.6 theawait_approvalMCP tool long-polls for the decision under the client’s timeout and returnspendingwhen it runs out, so the agent waits without re-issuing the paid call; from 0.7withdraw_approvallets the agent take back a held payment its task no longer needs. Denied, expired and withdrawn are distinct outcomes (approval_denied,approval_timeout,approval_withdrawn).