Skip to content

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.

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).

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 pay tool the same message is the tool result, with Ledger 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.failures consecutive failed or unknown outcomes (3 by default) payments to that host pause for circuitBreaker.cooldown (5 minutes by default) and are refused with circuit_open. A settled payment resets the count.

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-Key header on a retry (the MCP pay tool takes it as idempotencyKey). If the same agent already sent that key to the same host and path within idempotencyWindow (10 minutes by default) and that attempt is reserved, settled, unknown or pending_approval, the retry is refused with duplicate_request and 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 idempotencyKey and requestId, next to attempt;
  • spendctl export has idempotency_key and request_id as its last two columns, so existing CSV column positions do not move;
  • the MCP pay tool names them in its summary and refusal text, and list_receipts adds 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).

Reconciliation is a human step today.

Terminal window
spendctl ledger --limit 20 # unknown rows are highlighted
spendctl trace pay_… # the attempt, with any agent→agent chain around it
spendctl resolve pay_… --settled --tx 0x… # the payment went through
spendctl resolve pay_… --failed # it did not; the amount leaves the budget windows

Check 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.

  • unknown means 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 set approvals.hold: false so the agent gets the approval id back at once, as the sandbox does. From 0.6 the await_approval MCP tool long-polls for the decision under the client’s timeout and returns pending when it runs out, so the agent waits without re-issuing the paid call; from 0.7 withdraw_approval lets 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).