Skip to content

Spend policy

Policy is the reason to use tx402 rather than a fetch wrapper. It is a set of local rules that run before a signer is reachable, so a refused payment is a payment that was never authorized — not one that was authorized and then discarded.

TypeScript
const tx402 = createTx402Client({
signers: { evm },
policy: {
maxPerRequest: "0.10 USDC",
maxPerHour: "5.00 USDC",
maxTotal: "50.00 USDC", // lifetime ceiling — opt-in, 0.2.0
allowedDomains: ["api.example.com", "*.trusted.dev"],
allowedNetworks: ["eip155:8453", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"],
maxPaidAttempts: 2,
},
recipientPolicy: {
// Pin each merchant's payout address — opt-in, 0.2.0. `tofu` needs a shared store; this
// allowlist form is self-contained. Both are explained under "Recipient pinning" below.
mode: "allowlist",
allow: [{ host: "api.example.com", network: "eip155:8453", recipients: ["0x…"] }],
},
routing: { preferNetworks: ["eip155:8453"] },
});
Python
tx402 = Tx402Client(
evm_signer=evm,
policy=Policy(
max_per_request="0.10 USDC",
max_per_hour="5.00 USDC",
max_total="50.00 USDC", # lifetime ceiling — opt-in, 0.2.0
allowed_domains=["api.example.com", "*.trusted.dev"],
allowed_networks=["eip155:8453"],
max_paid_attempts=2,
),
recipient_policy=RecipientPolicy( # opt-in, 0.2.0 — see "Recipient pinning" below
mode="allowlist",
allow=[{"host": "api.example.com", "network": "eip155:8453", "recipients": ["0x…"]}],
),
routing=RoutingPolicy(prefer_networks=["eip155:8453"]),
)

Every field is optional and every default is conservative — maxTotal and recipientPolicy both default to off, so this section behaves exactly as v0.1 did until you set them. The full table is in the configuration reference; the two new controls have their own sections below.

The order is fixed, and both SDKs implement exactly it:

  1. Domain — the normalized host against allowedDomains.
  2. Network — the CAIP-2 network against allowedNetworks and the signed release manifest.
  3. Scheme and asset — the payment scheme and token against what tx402 supports and what the manifest declares for that network.
  4. Recipient — the merchant’s payout address against your pins or allowlist. This step runs only when recipientPolicy.mode is not "off", and it is advisory here — the authoritative check happens inside the reservation. See Recipient pinning.
  5. Per-request cap — the amount against maxPerRequest.
  6. Rolling hourly cap, then the cumulative cap — the amount against maxPerHour over committed spend plus active reservations in the last 3 600 000 ms, and against maxTotal over lifetime spend. See The cumulative cap.
  7. Challenge freshness — a timestamp in the challenge’s extra, if one is present.

The order is observable, and it is chosen so the cheapest and most specific refusals come first. A request to a domain you never allowed is refused without a network round trip, without a balance read, and without consulting the ledger. The recipient step is deliberately before the cap checks: a payment to the wrong address is refused for being to the wrong address, not for being over a limit.

Only after these checks pass may route planning read a balance. That matters more than it sounds: a balance query against a merchant-named chain is already an observable side effect of a request your policy would have refused.

Every amount in tx402 is an integer count of the token’s smallest unit. "0.10 USDC" is parsed once, at the edge, into 100000 atomic units (USDC has six decimals) and stays an integer from there to the signature.

Floating point is rejected, not tolerated.

policy: {
maxPerRequest: 0.1;
} // ✗ throws TX402_CONFIG_INVALID
policy: {
maxPerRequest: "0.10 USDC";
} // ✓
policy: {
maxPerRequest: "100000";
} // ✗ throws TX402_CONFIG_INVALID — invalid-format

A cap is always written <decimal> <SYMBOL>. One ASCII space, no sign, no exponent. There is no atomic-unit input form: "100000" on its own is rejected, in both languages, with configPath: "policy.maxPerRequest" and reason: "invalid-format". Atomic units are how tx402 represents money internally and what getBudgetState reports back — they are not how you write a limit.

Passing a JavaScript number or a Python float is a configuration error rather than a best-effort conversion. 0.1 + 0.2 !== 0.3 is a curiosity in most code and a discrepancy between the quote and the signature here — and a discrepancy in a signed authorization is not recoverable after the fact.

This is not negotiable anywhere in either SDK.

Patterns match against the normalized host: lowercased, punycoded, trailing root dot removed, and the port dropped.

Pattern Matches Does not match
api.example.com exactly that host evil-api.example.com
*.example.com api.example.com, a.b.example.com example.com
* everything —

An internationalized domain normalizes to its ASCII (A-label) form — the punycode a URL parser produces, which is also what goes on the wire. bücher.example and xn--bcher-kva.example are the same host to the allowlist, to the ledger, and to normalizePolicyHost / normalize_policy_host, so you may write either one in allowedDomains and query the budget with either one.

The default is ["*"], because a domain allowlist that is subtly wrong is worse than no allowlist — it fails closed on the wrong things and teaches people to disable it. Set it explicitly once you know your merchants.

maxPerHour is a rate: it bounds spend in any rolling 60-minute window and then forgets. maxTotal is a ceiling: it bounds lifetime spend against a scope and never resets. A long-running agent that stays under its hourly rate can still, over days, spend far more than you intended — maxTotal is the number that stops that.

policy: {
maxPerRequest: "0.10 USDC",
maxPerHour: "5.00 USDC",
maxTotal: "50.00 USDC", // this scope will never spend more than $50, ever
}

The three caps are ordered maxTotal ≥ maxPerHour ≥ maxPerRequest, checked at construction. A maxTotal below maxPerHour is a ConfigurationError (reason: "below-max-per-hour") rather than a silently ignored value: a ceiling under the rate could never bind, so it is a mistake, not a setting.

What counts against it is lifetime committed + exposed + reserved — the same three terms as the hourly cap, without the window. Two consequences are worth knowing:

  • A maybe-settled payment keeps consuming it. An ambiguous outcome leaves the reservation exposed (see the request lifecycle), and an exposed amount counts against maxTotal forever, until an operator reconciles it. The cap errs toward over-counting — it holds budget for a payment that may not have settled rather than let a possibly-settled one escape. Reconciling exposed payments is how that budget is released or committed.
  • There is no automatic reset in 0.2.0. maxTotal is a lifetime bound by design. An operator can reset it deliberately, but tx402 never rolls it over on a schedule.

maxTotal is optional and absent by default — omit it and behaviour is exactly v0.1’s rolling window. getBudgetState / get_budget_state reports both the cumulative figure and what remains under it.

Everything above is the caller’s config: the numbers this client passes in. In a fleet, that is only as trustworthy as the client — a worker that has drifted, or been steered by injected content, could construct itself with a laxer cap. So 0.2.0 lets an operator administer the authoritative caps in the store, through the admin plane, where application code cannot reach them.

When a scope has administered limits, reserve enforces min(administered, caller) per dimension, and the store — not the caller — decides which binds:

  • A caller cap stricter than the administered one is honoured. A worker that wants a tighter bound keeps it.
  • A caller cap that exceeds the administered one is refused with ConfigurationError (reason: "cap-exceeds-administered") — a drifted worker cannot widen the fleet cap by asking.
  • Lowering an administered cap below what is already spent is allowed and rolls nothing back (you cannot unspend); it clamps availability to zero and refuses new reserves.

tx402 budget reports which caps are in force and where they came from — limitSource: "administered" is the store’s own authoritative answer. See the shared-store runbook for provisioning them and the operator verbs for reading them.

An administered cap is keyed by (scope, asset), and — like a recipient pin — the asset is matched canonically: an eip155 asset’s erc20:0x… contract is compared lowercased, so a cap set with the checksummed manifest form and one set with the all-lowercase form bind the same ledger. Provisioning a cap under one casing and paying under another still enforces it (a Solana token: mint is case-sensitive base58 and matched verbatim).

When neither knob is administered, the caller’s config applies — and the guarantee is then only as strong as your trust in identically configured clients. That is the honest scope of a fleet without administered caps, stated rather than implied.

A merchant names its own payout address in each 402 challenge (payTo). Nothing in the protocol stops a compromised or malicious merchant from naming a different address on a later challenge — and an agent paying whatever it is told would pay the new one. Recipient pinning refuses that: with a pin in place, a payment to an address that is not the pinned one is refused before a signer is reached.

Three modes, set through recipientPolicy / recipient_policy:

Mode What it does
"off" (default) No recipient check. Behaviour identical to v0.1.
"allowlist" You declare the allowed (host, network, recipients) up front in allow. A recipient not on the list is refused.
"tofu" Trust on first use — the first recipient seen for a (scope, network) is pinned, and every later payment must match it. Needs a store that implements RecipientPinStore and TOFU enabled on the scope.
recipientPolicy: {
mode: "allowlist",
allow: [{ host: "api.example.com", network: "eip155:8453", recipients: ["0xYourMerchantPayout…"] }],
}

The pin is on the payout address, compared canonically. It is the challenge’s payTo, not a derived token account. An EVM address is compared lowercased (there is no checksum step); a Solana address is compared as its exact base58. So the address you write in allow and the one the merchant presents match regardless of letter case on EVM.

Advisory in policy, authoritative in the reservation. The recipient step in evaluate (order step 4 above) is a fast, read-only pre-filter that gives you an early rejection — but it is not the security boundary, and plan() / --dry-run never establish or mutate a pin. The authoritative check happens inside the atomic reserve, against the set the store holds, in the same operation as the budget reservation. That is what makes an administered pin impossible for a drifted worker to bypass.

A refused payment raises RecipientUnpinnedError (TX402_RECIPIENT_UNPINNED, exit 3), with reason one of:

  • not-allowlisted — the recipient is not in an administered allowlist.
  • pin-mismatch — the recipient differs from an established TOFU pin (the merchant “changed”).
  • assertion-required — the scope requires a recipient and the caller omitted it.

A pin-store outage is never this error: an unreachable store is infrastructure unavailability, so it surfaces as a retryable TransportError (fail-closed, no signature), exactly like every other pre-signature store outage.

A legitimate recipient change is now an operator action. Once a recipient is pinned, a merchant that genuinely moves its payout address is refused until an operator rotates the pin — that is the point, and the trade-off. Recipient rotation is the runbook, including the freeze-before-rotate procedure for a split pin/budget backend.

  • It is not a rate limiter. maxPerHour bounds spend, not request count.
  • The default ledger is per-process and does not survive a restart. MemorySpendStore is in-memory, so two processes have two budgets and a fresh client starts with a fresh window. Configure a durable shared store — Redis, a Durable Object, or a gateway — and a fleet shares one authoritative budget that does survive a restart. See Sharing one budget across processes and the shared-store runbook.
  • It cannot stop a merchant from asking. It stops tx402 from paying — and, with the kill switch, stops it store-wide on an operator’s command.

Both languages let you query the store for any scope, including one another process wrote. TypeScript additionally offers a synchronous snapshot of the most recent paid request, which the TypeScript client’s own contract requires of it; Python has no snapshot form and does not need one.

TypeScript — two calls, and the difference matters.

import { normalizePolicyHost } from "tx402";
// The snapshot: what this client last paid, and it says which ledger that was.
const last = tx402.getBudgetState();
// { storeKind, policyScope, assetId, committedAtomic, reservedAtomic, entries, reservations }
// The query: any scope, read from the store.
const state = await tx402.queryBudgetState({
policyScope: normalizePolicyHost("https://api.example.com/v1/x"), // → "api.example.com"
assetId: "eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
});

Python — one call, and it is the query. get_budget_state carries the shorter name and the querying behaviour: both keyword arguments are required, so there is no snapshot form to reach for and calling it bare raises TypeError rather than returning the last request.

from tx402 import normalize_policy_host
state = client.get_budget_state(
policy_scope=normalize_policy_host("https://api.example.com/v1/x"),
asset_id="eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
)

The asymmetry is deliberate rather than a gap waiting to be filled: the snapshot answers “what did this client object last pay”, which is a natural question of a TypeScript client that owns fetch(), and a much less natural one of a Python client that is an httpx transport wrapper. The store query answers everything the snapshot does and more, in both languages.

Budgets are scoped per host and per asset, so spending against one merchant does not consume another’s allowance, and USDC on Base is a different budget from USDC on Solana.

The scope key is the normalized merchant host in both languages, which is what makes a shared store actually shared: two processes calling api.example.com write the same ledger row. You supply a store, and the client reserves against it instead of the in-memory default.

You usually do not write one. 0.2.0 ships three reference stores, each behind its own optional import, off the size-gated core path:

Store Import Use when
Redis tx402/redis · tx402.stores.redis A fleet sharing one Redis (self-hosted or Upstash), cross-language.
Durable Object tx402/durable-object You run on Cloudflare Workers and want per-scope atomicity with no separate service.
Gateway tx402/gateway · tx402.stores.gateway A service holds the real credential and clients hold only a token — the recommended production boundary, and the only way for a Python or CLI client to reach a Durable Object.

The shared-store runbook stands one up end to end. All three implement the same v2 SpendStore contract — the v0.1 four methods plus expose, isFrozen, listExposed, and a capabilities property — so switching between them changes only construction.

If you do implement your own, the published conformance suite proves it against the contract:

from tx402 import check_spend_store
check_spend_store(lambda: MySpendStore()) # shipped conformance suite

check_spend_store is part of the published package, not this repository’s test suite, and it runs the whole contract — including twenty concurrent reservations against a five-unit cap, because the rule an adapter is most likely to break is that reserve must be atomic. TypeScript publishes the same contract as the SpendStore interface and a checkSpendStore twin. Both are documented in full on the type itself.