A Paying Client That Cannot Overspend: Mandatory Caps for x402 Buyer Agents

# ai# web3# usdc# arcblockchain
A Paying Client That Cannot Overspend: Mandatory Caps for x402 Buyer AgentsPaul Spread

An x402 buyer SDK that refuses to construct without PaymentCaps: per-call cap, session budget, strict accepts[] validation (never guess decimals), anti-loop guard, and an agentbadge pay CLI with --print-only.

This article originally appeared on the AgentBadge blog.

On October 10, 2026, we pointed our own agentbadge pay CLI at our own production API — and it refused to pay us. Three times, three different ways: once for the per-call cap, once for the network allowlist, once for the session budget. Every refusal named the exact field and the exact value. Every refusal happened before a signature existed. The wallet balance did not move by a cent. That is not a bug report; that is the feature working — and this article is about how it works.

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. The failure is rarely the payment rail. It is the absence of a ceiling. So we built the buyer side of @agentbadge/circle-payments on one premise: a paying client 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.

Hero

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 — e.g. {maxPaymentUsd: "0.50", sessionBudgetUsd: "5"}. Omitting them fails fast: TypeError: PaymentCaps required — refusing to create an uncapped paying client. Caps are USD decimal strings validated by regex; all money math is BigInt on 6-decimal atomic units, never floats. An optional allowedNetworks array pins which chains the signer may touch — the default is Arc mainnet (eip155:5042) and Arc testnet (eip155:5042002), enforced before anything else happens.

Caps tiles

What does strict accepts validation reject?

Everything that is not on the contract — and it names what it rejected. Each accepts[] entry must pass five checks: x402Version === 2; scheme ∈ {exact, eip3009-client-broadcast, gateway-batch}; network ∈ your allowlist; asset === the chain's USDC address; amount ≤ the per-call cap. Every refusal returns {ok:false, reason, field, value} so an agent can log which field killed the payment.

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.

These are not toy tests. The October 10 run against production produced three live refusals, each before any signing:

refused field=amount value="300000": amount 300000 exceeds per-call cap 10000 (atomic units)
refused field=network value="eip155:84532": network "eip155:84532" not in allowedNetworks
refused: Session budget exhausted: spent 0 + requested 300000 > budget 10000 (atomic units)
Enter fullscreen mode Exit fullscreen mode

Accepts checklist

How does the session budget stop a runaway loop?

The client keeps a cumulative SpendTracker for the session — and the reserve happens before signing: validateAccepts → tracker.tryReserve(amount) → only then does the signer see the typed-data payload. Over the cap → CapExceededError {cap, requested}; over the budget → BudgetExhaustedError {spent, budget, requested} — and signTypedData is never invoked. Our cap-refusal test asserts exactly that: zero signing calls on the wallet mock.

Spend is recorded when money commits — at settle for facilitator rails, at broadcast for self-settle — and an onSpend(entry) hook persists each event to whatever store you own.

Two compounding protections sit on the same path: an anti-loop guard throws PaymentNotAcceptedError if the retried request carrying payment-signature comes back 402 — never pay twice for one request; and paginateAll treats every page as a payment that counts against the same budget.

What does the agent sign — and when does money move?

Signing is scheme-specific — and signing is not spending. exact → EIP-3009 transferWithAuthorization signature; the facilitator settles later, so a signature alone moves no funds. eip3009-client-broadcast (Arc self-settle) → the client broadcasts the USDC transfer itself; when extra.payerBinding is declared, the retry carries X-Wallet/X-Sig/X-Timestamp (EIP-191, agentbadge-pay:v1). gateway-batch → BatchEvmScheme payload, with opt-in gateway.autoDepositUsd to top up the Gateway wallet instead of failing at settle.

The October 10 run put numbers on the signing-vs-spending gap: three paid attempts hit a server 500 after payment-signature was accepted, the facilitator never broadcast, and the payer balance moved only on a separate successful settle. Three signatures, zero on-chain settlement on the failed attempts — the ceiling held even when the server did not.

How do you run it — and verify before signing?

const client = createPayingClient({
  caps: { maxPaymentUsd: "0.50", sessionBudgetUsd: "5" },
  signer: viemSigner(walletClient),
});
await client.get("https://agentbadge.xyz/api/total-scan");
// 402 → validateAccepts → cap-check → sign → retry → spend recorded
Enter fullscreen mode Exit fullscreen mode

From the shell, agentbadge pay enforces the same caps discipline. Caps from flags (--max-payment, --budget) or env (AGENTBADGE_MAX_PAYMENT, AGENTBADGE_BUDGET); the key comes only from env (AGENTBADGE_PAYER_KEY) — never a flag, because argv is visible in ps. Exit codes: 0 ok · 2 refusal/cap/budget · 3 transport.

--print-only is the buyer's smoke test — it decodes the 402, validates accepts, prints the payment details, and exits without ever touching a signer. Real output from the October 10 run against production:

agentbadge pay https://agentbadge.xyz/api/total-scan \
  --max-payment 0.50 --budget 5 --network eip155:84532 --print-only
# {"scheme":"exact","network":"eip155:84532","asset":"0x036CbD53…",
#  "amountAtomic":"300000","priceUsd":"0.3","payTo":"0x9a66…95A5"}
Enter fullscreen mode Exit fullscreen mode

print-only

Can a client verify the server's contract before paying?

Yes — the 402 body the client validates is part of the machine-readable API contract. The server's committed openapi.yaml carries the x402 components (X402PaymentRequired, X402PaymentRequirement, X402HonestRefusal) with x402Version: 2, the scheme enum, extra.decimals: 6, and charged: false pinned as consts; a CI drift-check fails if the spec drifts from the live routes. The same contract is verified on both sides of the wire.

Dogfooding caught us too. On the same October 10 run, our catalog declared eip155:5042002 (Arc testnet) while the live 402 offered only Base Sepolia (eip155:84532) — the default Arc-only allowlist refused the offer until we passed --network explicitly. Our own validator flagged our own catalog drift, which is exactly the kind of failure you want a validator to cause.

This is C18 in the Arc Campaign series. Previously: Never Charged for a No — the machine-checkable refusal contract.


Links: Service catalog · openapi.yaml · llms.txt · @agentbadge/circle-payments · AgentBadge

Don't certify. Measure.