The CLI
Both packages install the same tx402 command with the same flags, the same --json document,
and the same exit codes. npx tx402 works with no install at all.
The command has two modes. tx402 call makes a payment, and is the whole of the first part of
this page. The five operator verbs — freeze, unfreeze, budget, pins, and
rotate-recipient — govern a shared spend store a fleet reserves against, and are documented
under Operator verbs at the end.
tx402 call <URL> [options]
--method <METHOD> HTTP method (default: GET) --body @<file> Request body, read from a file --max-spend <MONEY> Per-request cap, e.g. "0.10 USDC" --network <CAIP2> Allow only this network. Required to pay on a testnet: the default policy allows production networks only. --dry-run Parse, evaluate policy, and plan routes. Never signs. Needs a configured key — planning reads your balance. --json Emit one JSON object on stdout --timeout <MS> Paid-retry timeout in whole milliseconds -h, --help Show this message -v, --version Show version--dry-run is the one to start with
Section titled “--dry-run is the one to start with”npx tx402 call https://api.example.com/paid --max-spend "0.10 USDC" --dry-runIt runs the real decision path — decode, policy, route planning, ranking — and stops before the budget reservation. No signer is invoked and no budget is consumed, so you can run it freely.
The guarantee is structural, not a convention: on the dry-run path the signer is wrapped in a
guard that raises if it is ever reached, and the test suite asserts the signature count is zero
and that the merchant never received a PAYMENT-SIGNATURE header.
Route planning does read your payer address and balance, because a route cannot be scored without knowing whether it is fundable. “Never invokes a signer” means never produces a signature.
stdout and stderr are a contract
Section titled “stdout and stderr are a contract”stdout carries the response body — or, with --json, exactly one JSON object. Nothing else,
ever.
stderr carries everything else: warnings, the human-readable plan, and errors.
That split is what makes this work even when the call is noisy:
npx tx402 call "$URL" --max-spend "0.10 USDC" > response.jsonThe SDK itself never writes to the console at all. The CLI renders from the structured event stream, which is why a warning can never corrupt your redirected output.
--json
Section titled “--json”{ "schemaVersion": 1, "ok": true, "exitCode": 0, "dryRun": false, "inspection": { "requirementCount": 2, "headerHash": "sha256:…" }, "route": { "network": "eip155:8453", "scheme": "exact", "healthScore": 0.84, "rank": 1, "candidateCount": 2 }, "status": 200, "body": "…", "settlement": { "status": "committed", "transaction": "0x8a01f2027e5af993977c5c4c7dded4e8a031aa0a07578aa2f5d429f670af5af0", "payer": "0xaad1566216D2447B530E04945dfEefD04C84967B" }, "timings": { "elapsedMs": 452, "events": 10 }, "error": null}One object, on both success and failure. On failure ok is false, exitCode names the
classification, and error carries the typed error — code, message, retryability, and its
required detail keys.
settlement is what you reconcile with. transaction is the settlement identifier the
merchant reported — the hash you paste into a block explorer — and payer is the address that
paid, on the chain the route selected.
"status": "committed"— the money moved. Exit0, and the paid half of exit9."status": "unknown"— the signature was transmitted and the outcome could not be determined. This is exit8.transactionisnull, because tx402 has none."settlement": null— tx402 holds no settlement. Usually that is because nothing was ever signed: a dry run, or any policy or protocol refusal. It is also what you get when a signature was transmitted and the merchant reported the settlement as unsuccessful, which is the unpaid half of exit9.
settlement alone therefore does not tell you whether you paid. error.context.paid does, and
it is the field the CLI itself branches on:
context.paid |
Meaning | Where it appears |
|---|---|---|
| absent | No signature was ever produced | Exits 2–7, and dry runs |
false |
A signature was transmitted and no money moved — the merchant refused the settlement, or re-challenged until maxPaidAttempts ran out |
Exit 9 |
"unknown" |
A signature was transmitted and the outcome cannot be determined | Exit 8 |
true |
The payment settled | Exit 0, and exit 9 when the resource then failed |
A settlement object is emitted only for the last two rows, which is why its absence on exit 9
is information rather than an omission: it means the money is still yours.
transaction can also be null on a committed payment, when the merchant supplied no
identifier at all: the pinned protocol marks PAYMENT-RESPONSE optional, and that case commits
with a warning rather than failing. The warning is a payment.completed event at warn with
reason: "payment-response-absent".
Read null there as “tx402 has no identifier”, not as proof that money moved. A 200 to a
signature-bearing request is the merchant asserting it accepted payment, and tx402 takes it at
its word — it never calls a facilitator to check, by design. So status: "committed" with
transaction: null means the merchant claimed settlement and offered nothing to verify it with,
and tx402 cannot distinguish that from a merchant that accepted without settling at all.
This errs in the safe direction for you: your local ledger records the spend, so it can
over-count what you have paid but never under-count it, and no cap is loosened by a merchant
behaving this way. It matters only when you are reconciling against the chain, and it is the one
committed outcome for which you have no on-chain identifier to reconcile with. If that matters
for a given merchant, treat an absent PAYMENT-RESPONSE as a reason to ask them, not as a
receipt.
The raw identifier is reported here, and only here. The structured event stream carries a
settlementIdHash instead, because events are the thing that reaches a log aggregator and a
settlement identifier there is a payment graph handed to whoever operates it. --json goes to
your own stdout, which is the same boundary as your own spend ledger.
On exits 8 and 9 the same two values are printed to stderr as well, so the documented
“reconcile before you retry” does not require re-running a payment to obtain them.
error never carries the underlying cause, which is where a URL with credentials or a
signer payload would live. That is enforced by the error’s own serializer, not by the CLI
remembering to strip it.
schemaVersion changes only on a breaking shape change, so a script can pin it.
Exit codes in a script
Section titled “Exit codes in a script”#!/usr/bin/env bashset -uo pipefail
npx tx402 call "$URL" --max-spend "0.10 USDC" --json > out.jsoncode=$?
case $code in 0) jq -r .body out.json ;; 3) echo "refused by local policy — this is the guardrail working" >&2; exit 0 ;; 4) echo "fund the wallet" >&2; exit 1 ;; 7) echo "network failure — safe to retry" >&2; exit 75 ;; 8) echo "AMBIGUOUS: payment may have settled. Do not retry." >&2 jq -r '"payer \(.settlement.payer)"' out.json >&2; exit 1 ;; 9) # Not delivered — but exit 9 alone does not say whether you paid for it. if [ "$(jq -r '.error.context.paid' out.json)" = "true" ]; then echo "PAID but not delivered. Do not retry." >&2 jq -r '"payer \(.settlement.payer) tx \(.settlement.transaction)"' out.json >&2 else echo "not delivered, and nothing settled — safe to retry" >&2 jq -r '"reason \(.error.details.reason)"' out.json >&2 fi exit 1 ;; *) echo "failed with $code" >&2; exit 1 ;;esacThe *) catch-all is load-bearing: every code that is not named still stops the script rather
than falling through to the success path.
Exit 1 is never used by tx402 — it is reserved for the runtime crashing. Every code and every
error that maps to it is in the error reference.
The CLI accepts no flag that carries a private key, and it never will. Anything on a command
line lands in shell history, in ps output, and in CI logs.
For development only, it reads TX402_DEV_PRIVATE_KEY (EVM) and TX402_DEV_SOLANA_KEYPAIR
(Solana), and prints a warning to stderr every single time it does. The warning is not
behind a verbosity flag and does not fire only once per session: a once-per-session warning is a
warning people learn to stop seeing.
For anything beyond a low-balance test wallet, use the SDK with an external signer. See key management.
Bodies
Section titled “Bodies”--body takes @<file> only. An inline body is refused, because a body is exactly the kind of
thing that contains a token and exactly the kind of thing that ends up in ~/.bash_history.
npx tx402 call "$URL" --method POST --body @payload.json --max-spend "0.10 USDC"A missing or unreadable file is a usage error raised before any network request, so a dry run does not first cost the merchant a round trip to discover your file is not there. The underlying OS error is not forwarded — it quotes an absolute path, which ends up in CI logs more often than anyone intends.
Operator verbs
Section titled “Operator verbs”tx402 call makes payments. The other five verbs govern the shared spend store a fleet of
callers reserves against — they are how an operator freezes spending, reads a budget, and manages
recipient pins from a shell rather than from code. They do nothing useful against the default
in-process MemorySpendStore, whose state dies with the process; they exist for a durable shared
store — Redis, or a capability gateway — which is where a fleet’s one budget actually lives.
tx402 freeze <host | "*"> (admin)tx402 unfreeze <host | "*"> (admin)tx402 budget <url|host> --network <CAIP2> [--asset <ADDR>] [--max-per-hour <ATOMIC>] [--max-total <ATOMIC>] (data)tx402 pins <url|host> --network <CAIP2> (data)tx402 rotate-recipient <url|host> --network <CAIP2> --to <addr…> (admin)The store comes from the environment, never a flag
Section titled “The store comes from the environment, never a flag”Every verb needs to know which store to talk to and with what credential, and reads all of it from
the environment — for the same reason call takes no key flag. A Redis DSN or a bearer token on a
command line lands in shell history and ps output.
| Variable | What it is |
|---|---|
TX402_SPEND_STORE |
The store. https://<gateway>/… for a capability gateway, or redis://… / rediss://… for a raw Redis DSN. |
TX402_SPEND_STORE_TOKEN |
The data-plane credential — a gateway bearer token. For raw Redis, the data-user credential is the DSN in TX402_SPEND_STORE itself. |
TX402_SPEND_STORE_ADMIN |
The admin credential — a gateway admin bearer token, or a raw Redis admin-user DSN. An agent process must not hold this; that is the whole point of the split. |
TX402_SPEND_STORE_NAMESPACE |
Deployment isolation prefix. Default tx402. |
Two planes, and the admin one fails closed without a credential
Section titled “Two planes, and the admin one fails closed without a credential”freeze, unfreeze, and rotate-recipient change admin state, so they require
TX402_SPEND_STORE_ADMIN. budget and pins read only data-plane state, so a data credential is
enough. An admin verb invoked with only a data credential is refused before the store is
touched:
{ "schemaVersion": 1, "ok": false, "exitCode": 2, "error": { "code": "TX402_CONFIG_INVALID", "message": "An admin credential is required for this operation. Set TX402_SPEND_STORE_ADMIN (a gateway admin bearer token, or a raw Redis admin-user DSN).", "details": { "configPath": "TX402_SPEND_STORE_ADMIN", "reason": "admin-credential-required" } }}This is the same admin-credential-required a gateway raises for a data token on an admin
method, and a raw Redis admin-user ACL raises for a denied write — one identity across all three
topologies, so a script branches on one thing. It does not close the compromised-application
spending path; the security model says what the boundary does and does not do.
The --json shapes
Section titled “The --json shapes”freeze / unfreeze report the scope and its new state; pins reports the pinned recipient set:
tx402 freeze api.merchant.example --json{ "schemaVersion": 1, "ok": true, "exitCode": 0, "scope": "api.merchant.example", "frozen": true }tx402 pins api.merchant.example --network eip155:8453 --json{ "schemaVersion": 1, "ok": true, "exitCode": 0, "scope": "api.merchant.example", "network": "eip155:8453", "recipients": [ "0x1111111111111111111111111111111111111111", "0x2222222222222222222222222222222222222222" ]}budget reports the whole ledger for one (scope, network, asset) — committed, reserved, exposed,
and the two cumulative figures — plus, where it can source them, the caps and what is still
available:
tx402 budget api.merchant.example --network eip155:8453 --json{ "schemaVersion": 1, "ok": true, "exitCode": 0, "scope": "api.merchant.example", "network": "eip155:8453", "asset": "eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "committedAtomic": "300000", "reservedAtomic": "0", "exposedAtomic": "200000", "cumulativeCommittedAtomic": "300000", "cumulativeConsumedAtomic": "500000", "limitSource": "administered", "perHourLimitAtomic": "1000000", "cumulativeLimitAtomic": "5000000", "availablePerHourAtomic": "500000", "availableCumulativeAtomic": "4500000", "frozen": false}limitSource names where the caps came from and is the field to read before you trust available*:
"administered"— the store’s own administered limits, which a drifted worker cannot widen. This is the authoritative answer."value-flags"— you supplied--max-per-hour/--max-total, and availability is computed against your numbers, not the store’s."unknown"— neither is set, sobudgetreports the consumed amounts honestly and leaves every*LimitAtomicandavailable*Atomicnullrather than inventing a ceiling.
The per-request --max-spend from call is deliberately not a source: it caps one payment,
not the hour or the lifetime, and deriving availability from it would report a limit that does not
exist. exposedAtomic is money that a maybe-settled payment is still holding against both caps —
reconcile it rather than waiting for it to clear, because it
never expires.
rotate-recipient canonicalizes, and warns when it cannot be atomic
Section titled “rotate-recipient canonicalizes, and warns when it cannot be atomic”rotate-recipient overwrites the pinned set for a (scope, network) with the --to addresses,
after canonicalizing them (an EVM address is lowercased, so 0xABCD… and 0xabcd… are one pin),
and prints the stored set:
tx402 rotate-recipient api.merchant.example --network eip155:8453 \ --to 0xAbCdEf0000000000000000000000000000000001 0x0000000000000000000000000000000000000002 --json{ "schemaVersion": 1, "ok": true, "exitCode": 0, "scope": "api.merchant.example", "network": "eip155:8453", "recipients": [ "0xabcdef0000000000000000000000000000000001", "0x0000000000000000000000000000000000000002" ]}When the pin store and the budget store are one backend — the reference stores are — an in-flight
reserve asserts either the old pin or the new one, never a torn state, so the rotation is race-free
on its own. When they are separate backends the CLI cannot guarantee that, so it prints a
freeze-before-rotate warning to stderr (leaving --json a clean artifact) and the
recipient-rotation runbook is the procedure: freeze the scope,
rotate, then unfreeze. It warns only when it detects a gateway backend that is not already frozen;
against a raw Redis store, known to be one backend, it stays quiet.
Exit codes are the same set
Section titled “Exit codes are the same set”The verbs reuse call’s exit codes; there is no verb-only number. 0 is success, 2 is a usage
or configuration error (a bad flag, a do:// store, a missing admin credential), and 7 is a
store outage — a verb whose store is unreachable raises a TransportError, retryable under your
own policy, exactly as a reserve against that store would. The full table is in the
error reference.