Skip to content

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
Terminal window
npx tx402 call https://api.example.com/paid --max-spend "0.10 USDC" --dry-run

It 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 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:

Terminal window
npx tx402 call "$URL" --max-spend "0.10 USDC" > response.json

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

{
"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. Exit 0, and the paid half of exit 9.
  • "status": "unknown" — the signature was transmitted and the outcome could not be determined. This is exit 8. transaction is null, 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 exit 9.

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.

#!/usr/bin/env bash
set -uo pipefail
npx tx402 call "$URL" --max-spend "0.10 USDC" --json > out.json
code=$?
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 ;;
esac

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

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

Terminal window
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.

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.

freeze / unfreeze report the scope and its new state; pins reports the pinned recipient set:

Terminal window
tx402 freeze api.merchant.example --json
{ "schemaVersion": 1, "ok": true, "exitCode": 0, "scope": "api.merchant.example", "frozen": true }
Terminal window
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:

Terminal window
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, so budget reports the consumed amounts honestly and leaves every *LimitAtomic and available*Atomic null rather 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:

Terminal window
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.

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.