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.
Configuring it
Section titled “Configuring it”const tx402 = createTx402Client({ signers: { evm }, policy: { maxPerRequest: "0.10 USDC", maxPerHour: "5.00 USDC", allowedDomains: ["api.example.com", "*.trusted.dev"], allowedNetworks: ["eip155:8453", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"], maxPaidAttempts: 2, }, routing: { preferNetworks: ["eip155:8453"] },});tx402 = Tx402Client( evm_signer=evm, policy=Policy( max_per_request="0.10 USDC", max_per_hour="5.00 USDC", allowed_domains=["api.example.com", "*.trusted.dev"], allowed_networks=["eip155:8453"], max_paid_attempts=2, ), routing=RoutingPolicy(prefer_networks=["eip155:8453"]),)Every field is optional and every default is conservative. The full table is in the configuration reference.
Evaluation order is fixed
Section titled “Evaluation order is fixed”SPEC §6.3 fixes the order, and both SDKs implement exactly it:
- Domain — the normalized host against
allowedDomains. - Network — the CAIP-2 network against
allowedNetworksand the signed release manifest. - Scheme and asset — the payment scheme and token against what tx402 supports and what the manifest declares for that network.
- Per-request cap — the amount against
maxPerRequest. - Rolling hourly cap — the amount against
maxPerHour, over committed spend plus active reservations in the last 3 600 000 ms. - 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.
Only after all six 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.
Money is always an integer
Section titled “Money is always an integer”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_INVALIDpolicy: { maxPerRequest: "0.10 USDC";} // ✓policy: { maxPerRequest: "100000";} // ✓ atomic units, if you preferPassing 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 ADR-006, and it is not negotiable anywhere in either SDK.
Domain patterns
Section titled “Domain patterns”Patterns match against the normalized host: lowercased, IDN-decoded, trailing 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 | — |
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.
What policy does not do
Section titled “What policy does not do”- It is not a rate limiter.
maxPerHourbounds spend, not request count. - It is not shared across processes by default. The default ledger is in-memory, so two
processes have two budgets. Supply your own
SpendStoreand they share one — see below. - It does not survive a restart. A fresh client starts with a fresh hourly window. This is a deliberate v0.1 scope decision (ADR-007), not an oversight.
- It cannot stop a merchant from asking. It stops tx402 from paying.
Checking your budget
Section titled “Checking your budget”Two calls, and the difference matters. getBudgetState() is a synchronous snapshot of the
most recent paid request; queryBudgetState() reads the store, so it can answer about any
scope — including one another process wrote.
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",});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",)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.
Sharing one budget across processes
Section titled “Sharing one budget across processes”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. Supply any object with the four SpendStore methods.
from tx402 import check_spend_store
check_spend_store(lambda: MySpendStore()) # shipped conformance suitecheck_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. Both are documented in
full on the type itself; the rules are in ADR-018.