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", 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"] },});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.
Evaluation order is fixed
Section titled “Evaluation order is fixed”The order is fixed, 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.
- Recipient — the merchant’s payout address against your pins or allowlist. This step runs
only when
recipientPolicy.modeis not"off", and it is advisory here — the authoritative check happens inside the reservation. See Recipient pinning. - Per-request cap — the amount against
maxPerRequest. - Rolling hourly cap, then the cumulative cap — the amount against
maxPerHourover committed spend plus active reservations in the last 3 600 000 ms, and againstmaxTotalover lifetime spend. See The cumulative cap. - 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.
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";} // ✗ throws TX402_CONFIG_INVALID — invalid-formatA 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.
Domain patterns
Section titled “Domain patterns”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.
The cumulative cap
Section titled “The cumulative cap”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
maxTotalforever, 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.
maxTotalis 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.
Administered caps
Section titled “Administered caps”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.
Recipient pinning
Section titled “Recipient pinning”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.
What policy does not do
Section titled “What policy does not do”- It is not a rate limiter.
maxPerHourbounds spend, not request count. - The default ledger is per-process and does not survive a restart.
MemorySpendStoreis 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.
Checking your budget
Section titled “Checking your budget”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.
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. 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 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 and a checkSpendStore
twin. Both are documented in full on the type itself.