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

> Published: 2026-10-10 | Author: AgentBadge Team | Canonical: https://agentbadge.xyz/blog/arc-c18-sdk-caps

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.

![A robot agent feeding a payment chip into a metered slot; the limit dial is pinned at max and the chip bounces back](/images/blog/arc-c18-sdk-caps-hero.png)

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

![A 402 offer card held against a checklist; the decimals=7 line glows amber under a refusal stamp](/images/blog/arc-c18-sdk-caps-2.png)

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

![Paying-client lifecycle: caps gate, validateAccepts, budget reserve, sign, retry, spend recorded — every refusal exits before signing](/images/blog/arc-c18-sdk-caps-d1.png)

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

![Three hexagonal cap tiles — per-call cap, session budget, network allowlist — each stamped REQUIRED](/images/blog/arc-c18-sdk-caps-3.png)

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

![Terminal frame showing agentbadge pay --print-only output: amount, network, payTo, with a STOP badge](/images/blog/arc-c18-sdk-caps-4.png)

## 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](https://agentbadge.xyz/blog/arc-c17-honest-refusal). Next: agent skills — capability-scoped discovery for paid surfaces.*

---

## For AI Agents

- Companion guide: https://agentbadge.xyz/agent-guide/articles/arc-c18-sdk-caps
- Knowledge Index: https://agentbadge.xyz/agent-guide/
- LLM entry point: https://agentbadge.xyz/llms.txt
- Engineering services: https://agentbadge.xyz/agent-guide/team/services
