A Paying Client That Cannot Overspend: Mandatory Caps for x402 Buyer Agents
An x402 buyer SDK that refuses to construct without PaymentCaps: per-call cap, session budget, strict accepts[] validation (never guess decimals), anti-loop guard, payer-binding headers, and an agentbadge pay CLI with --print-only.
@agentbadge/circle-payments ships a buyer-side x402 client that cannot be created without PaymentCaps {maxPaymentUsd, sessionBudgetUsd} — no uncapped mode. Every 402 response is validated before signing (x402Version=2, scheme/network/asset allowlists, extra.decimals===6 — mis-scale is refused, never inferred); spend is recorded at sign/broadcast, a second 402 after payment-signature throws PaymentNotAcceptedError, and paginateAll counts every page against the session budget. The agentbadge pay CLI enforces the same caps and --print-only shows amount/network/payTo before any signature.
An agent shopping a paid API in a loop is a small program holding a credit card. If the API starts returning 402s on every retry — or the agent mis-reads a price that shifted — the loop keeps signing payments until the wallet is empty. Every "agent payment" story eventually lands on the same incident report: the retry was correct, the signing was correct, and the money was still gone. The failure wasn't the payment rail. It was the absence of a ceiling.
We built the buyer side of our x402 SDK — @agentbadge/circle-payments — on a single premise: an agent client that can pay without limits is a bug, not a default. There is no uncapped mode. createPayingClient throws a TypeError the moment you omit PaymentCaps, before a single request leaves the process.

Why are spending caps mandatory instead of optional?
Because a cap you can forget is a cap you will forget. The paying client requires {maxPaymentUsd, sessionBudgetUsd} at construction — for example {maxPaymentUsd: "0.25", sessionBudgetUsd: "5"}. Passing an undefined caps object fails fast with TypeError: PaymentCaps required — refusing to create an uncapped paying client. An optional allowedNetworks array pins which chains the signer may touch (defaults to Arc mainnet and testnet).
The enforcement happens before signing, not after settlement. Each 402 response goes through validateAccepts → tracker.tryReserve(amount) → only then does the signer see a typed-data payload. A price above the per-call cap throws CapExceededError carrying {cap, requested} — and the signer's signTypedData is never invoked. In our test suite the cap-refusal test asserts exactly that: the wallet mock receives zero signing calls when the price exceeds the cap.
What does strict accepts validation reject?
Everything that isn't on the allowlist — with named values, not silent coercion. validateAccepts checks each entry of the accepts[] array in the 402 body against a fixed contract: x402Version must be 2; scheme must be one of exact, eip3009-client-broadcast, or gateway-batch; network must be in your allowlist; asset must equal the USDC address for that chain; and amount must fit under the cap.
The sharpest rule is extra.decimals: if a requirement declares decimals other than 6, the client refuses rather than guessing. A mis-scaled amount — 6 vs 18 decimals — is a classic footgun in token payments; the safe answer is rejection, not inference. Every refusal returns {ok: false, reason, field, value} so an agent can log which field killed the payment and with what value — debugging data for the next request, not a dead end.

How does the session budget stop a runaway loop?
The client keeps a cumulative SpendTracker for the session: spent += amount is recorded when a payment is signed or broadcast — not when a 402 arrives. Before every signing step the tracker checks spent + amount <= sessionBudgetUsd; when the check fails, the client throws BudgetExhaustedError with {spent, budget, requested} so the agent can see exactly how far the runway went. An onSpend(entry) hook persists each spend event to whatever store the operator owns — the SDK only keeps memory for the session.
Two compounding protections sit on the same path. First, an anti-loop guard: if the retried request (carrying payment-signature) comes back with another 402, the client throws PaymentNotAcceptedError — it never pays twice for the same request. Second, paginateAll treats every paginated page as a payment: cursor-based endpoints iterate until exhaustion, each page debit counts against the session budget, and the budget exception propagates mid-iteration instead of silently retrying.
What does the agent actually sign — and what if the wallet is short?
Signing is scheme-specific and transparent. For exact, the signer produces an EIP-3009 transferWithAuthorization typed-data signature. For eip3009-client-broadcast (Arc self-settle), the client broadcasts the USDC transfer itself — spend is recorded at broadcast — and when the requirement declares extra.payerBinding, the retry additionally carries X-Wallet, X-Sig, and X-Timestamp headers proving the payer bound the payment to this request (EIP-191, agentbadge-pay:v1 challenge). For gateway-batch the client signs the BatchEvmScheme payload; if the gateway balance is short, an opt-in gateway.autoDepositUsd option tops up the wallet before the request — the ensureFunded pattern — instead of failing at settle.

The whole seam in one picture: money can only move through the sign step — and you cannot reach it without passing caps, strict accepts validation, and the budget reserve. Every refusal exits with zero spend and a named reason.

How do you run it — and verify before signing?
From code:
const client = createPayingClient({
caps: { maxPaymentUsd: "0.25", sessionBudgetUsd: "5" },
signer: viemSigner(walletClient),
});
const res = await client.get("https://agentbadge.xyz/api/total-scan");
// 402 → validateAccepts → cap-check → sign → retry → spend recorded
From the shell, the same caps discipline is enforced by the agentbadge pay CLI — caps come from flags (--max-payment, --budget) or env (AGENTBADGE_MAX_PAYMENT, AGENTBADGE_BUDGET), the private key only from env, and exit codes distinguish refusal/cap (2) from network failure (3):
agentbadge pay https://agentbadge.xyz/api/total-scan \
--max-payment 0.25 --budget 5 --print-only
# prints amount, network, payTo — and exits without signing
--print-only is the buyer's smoke test: it decodes the 402, validates accepts, prints {amount, network, payTo}, and exits. You can wire it into any agent loop as a pre-flight check for exactly the same validation the paid path enforces.

Can a client verify the server's contract before paying?
Yes — the 402 body the client validates is now part of the machine-readable API contract. The server's committed openapi.yaml artifact carries the x402 components (X402PaymentRequired, X402PaymentRequirement, X402HonestRefusal) with x402Version: 2, the scheme enum, extra.decimals: 6, and charged: false pinned as consts; a drift-check in CI (bun run check:openapi) fails if the spec drifts from the live routes. A buyer can diff its validateAccepts expectations against the same artifact the CI enforces — the contract is verified on both sides of the wire, not documented in prose.
This is C18 in the Arc Campaign series. Previously: Never Charged for a No: a machine-checkable refusal contract. Next: agent skills — capability-scoped discovery for paid surfaces.