Agents can pay for APIs now. With x402 and MPP a server answers 402 Payment Required with a price, the client signs a stablecoin authorization, retries, and gets the data. No API key, no invoice, no account. It works, and it is the part people demo.
The part nobody demos is who decides. Two patterns keep coming up:
- A funded key with a cap. Fast. The model can also burn the whole cap in a retry loop and you find out from the balance.
- Approve every payment. Safe. Nobody wants to approve two hundred $0.002 calls, so after a day the approval becomes a rubber stamp or the agent gets the key anyway.
Both put the money decision inside the agent's reach: in a prompt, in a tool argument, in an environment variable the model can read. I wanted the decision outside the model, in something a human writes once and can audit later. That became spendctl, a local sidecar. This post is about the design, including the one state that turned out to matter most.
The shape
The agent never holds a key. It sends its HTTP request through the sidecar instead: as a gateway URL, through HTTP_PROXY, with a wrapFetch in the SDK, or as an MCP pay tool. When the seller answers 402, the sidecar does three things:
- Evaluates a policy file written by a human: per-agent budgets (hourly, daily, weekly, monthly), a per-request cap, allowlisted hosts, pinned recipients and networks, a first-payment cap for new recipients, a price-jump check, and a threshold above which a human has to approve.
- Signs and pays with a wallet the agent cannot see, retries the request with the payment, and passes the response back.
-
Writes one ledger row per decision, with the reasons. Refusals come back as readable text the model can act on ("
$0.40would exceed the daily budget of$0.25for agentresearcher,$0.21already spent"), not as a stack trace.
agents:
researcher:
budget: { perRequest: "0.05", daily: "0.25" }
allow: ["api.example.com"]
requireApprovalAbove: "0.02"
The model sees a budget, a refusal or an approval id. It never sees a private key, and it cannot change the policy.
Approvals that fit how agents run
A payment that crosses the threshold needs a human. Two modes:
- Hold. The agent's request stays open until someone decides, up to four minutes by default. Good for CLIs and scripts.
-
Retry. The sidecar answers at once with an approval id. After a human approves, the agent retries with
X-Spendctl-Approval: <id>. One approval pays for exactly one retry, and it is bound to what the human saw: recipient, network, asset, amount. If the seller's next 402 asks for anything else, the retry is refused and nothing is paid.
Humans approve from the CLI, a local dashboard, or a review link in a Slack or Discord webhook. Agents cannot approve; the admin token lives in a file the agent does not read.
The state nobody models
Someone on r/x402 asked the question that every payment system eventually has to answer: the payment settles, but the paid API times out before the agent gets the result. Does the ledger know the difference?
Most agent payment demos do not model this at all. A retry is a second payment. spendctl has an explicit state for it:
- Once a signed payment has left the sidecar, the outcome is
settled(the seller answered 2xx with a settlement receipt),failed(the seller rejected it), orunknown: the request timed out or errored after the payment was sent, or the seller returned an error after taking it, or the settlement header says "pending". -
unknowncounts against the budget until a human reconciles it. An unconfirmed payment is never free spend. - The sidecar never retries a sent payment. The agent gets a 502 with the ledger id and a sentence saying so. If the agent resends with the same
Idempotency-Keyinside the window, the sidecar refuses and points at the original row, so paying twice takes deliberate effort. - A human closes it with
spendctl resolve <id> --settled --tx 0x…or--failedafter checking the chain or the seller's dashboard.
This is not clever. It is the boring bookkeeping that is missing from most "let the agent pay" code, and it is where the money actually leaks.
Try it without installing anything
There is a hosted sandbox: a real sidecar running against mock paid APIs, exposed as a remote MCP server at https://mcp.spendctl.dev/mcp. Add it to Claude, Claude Code, Cursor or VS Code, then ask the model to create a sandbox, make a paid call, blow the budget, hold a payment for approval and approve it. Throwaway wallet, mock facilitator, no outbound network, nothing real. The tools are sandbox_create, sandbox_pay, check_budget, list_receipts, get_policy and approve.
Locally:
npx spendctl init && npx spendctl start # sidecar on :8402
npx spendctl mock # mock facilitator + two paid APIs
npx spendctl fetch "http://localhost:4022/search?q=x402" --agent researcher
What it is not, yet
- The sidecar is a commercial product with a free tier (two agents, a monthly cap on governed payments). The client SDK and the editor plugins are Apache-2.0 on GitHub.
- Reconciliation of
unknownis manual today. Asking the facilitator or the chain automatically is under consideration. - The sandbox cannot simulate a seller that settles and then times out; that path is only testable locally.
- MCP clients cut tool calls at about 60 seconds, which is shorter than hold mode. For MCP agents, retry mode is the right setting today. A dedicated
await_approvaltool that long-polls under the client timeout is under consideration, after a good suggestion in the r/mcp thread.
If you are putting agents in front of paid APIs, I would like to hear which ledger fields you would want to see, and what your clients actually do when a payment is held. Docs are at spendctl.dev/docs; the sandbox needs no account.
Top comments (1)
The unknown state is the detail most agent payment designs skip, and counting it against budget until a human reconciles it is the right call. Without that, a sidecar writing off failed confirmations as unspent credit would inflate the available budget by one timeout at a time.
On the ledger question: the field I would want most is the original request-id or idempotency-key from the agent. A refusal message that pairs specific policy details with the request that triggered the check lets the model actually adapt its next call instead of retrying the same thing. The retry mode with explicit approval binding captures this: the approval is bound to what the human saw, so the agent cannot repurpose it for a different call. That split between proposing and authorizing is the pattern that keeps the model from vouching for itself.