---
title: "The CLI"
description: "tx402 call and the five operator verbs — flags, output contract, exit codes, and how to use them in a script."
source: https://docs.tx402.io/guides/cli/
---

# 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](#operator-verbs) at the end.

```text
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
```

:::caution[`--network` is mandatory on a testnet]
The default policy allows only **production** networks — Base and Solana mainnet. That is not
an oversight: a silent fallback from production to a testnet is the worst failure this SDK
could have, so a testnet has to be asked for by name. Every testnet command on this site
therefore carries `--network eip155:84532` or
`--network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`. Without it you get
`TX402_SCHEME_UNSUPPORTED` and exit `5`, and the error lists what the merchant offered.
:::

## `--dry-run` is the one to start with

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

:::tip[The same evaluation, with the rules shown in order]
[The 402 Policy Playground](https://tools.tx402.io/policy) runs this same `PolicyEngine` against a
challenge you paste and shows which rule fired, in evaluation order, with the typed error your own
code would raise. `--dry-run` is the answer for your real endpoint and your real key; the playground
is the answer for "why would this policy reject that challenge?"
:::

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

:::note[The one case `--json` does not produce a document]
A **usage error — exit `2`** — is reported as human-readable help on stderr, with stdout empty,
even when `--json` was passed. A misspelled flag or a missing URL is a mistake in the
invocation, so the flag it would be reported through has not been established as meaningful
yet. Both languages behave identically here.

A script that consumes `--json` should therefore branch on the exit code first and parse stdout
only for exit codes other than `2`. Every other outcome — including every policy, protocol,
signer, transport and ambiguity failure — emits exactly one JSON object, as above.
:::

That split is what makes this work even when the call is noisy:

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

## `--json`

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

## Exit codes in a script

```bash
#!/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.

:::danger[Never retry on exit 8]
Exit `8` means the signature reached the merchant and tx402 could not determine the outcome. It
has its own code precisely so a script can **stop**. Retrying can pay twice. Reconcile against
the merchant, then decide.
:::

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](/reference/errors/).

## Keys

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](/security/keys/).

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

```bash
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

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

```text
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

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](/operations/gateway/), or `redis://…` / `rediss://…` for a [raw Redis DSN](/operations/shared-store/). |
| `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`. |

:::caution[A Durable Object is not a CLI store]
A Durable Object is reached through a Worker **binding**, not dialled over the network, so the
standalone CLI cannot address one. A bare `do://<binding>` is refused with `TX402_CONFIG_INVALID`,
`reason: "durable-object-not-a-cli-dsn"`, exit `2`. Govern a DO through **wrangler**, or stand up
the reference gateway in front of it and point `TX402_SPEND_STORE` at that — see the
[DO topology](/operations/durable-object/) and [gateway deploy](/operations/gateway/)
runbooks.
:::

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

```json
{
  "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](/security/) says what the boundary does and does not do.

### The `--json` shapes

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

```bash
tx402 freeze api.merchant.example --json
```

```json
{ "schemaVersion": 1, "ok": true, "exitCode": 0, "scope": "api.merchant.example", "frozen": true }
```

```bash
tx402 pins api.merchant.example --network eip155:8453 --json
```

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

```bash
tx402 budget api.merchant.example --network eip155:8453 --json
```

```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](/guides/policy/#administered-caps), 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](/operations/exposed-reconciliation/) rather than waiting for it to clear, because it
never expires.

### `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:

```bash
tx402 rotate-recipient api.merchant.example --network eip155:8453 \
  --to 0xAbCdEf0000000000000000000000000000000001 0x0000000000000000000000000000000000000002 --json
```

```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](/operations/recipient-rotation/) 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

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](/reference/errors/).

## More documentation

- Documentation index (Markdown): https://docs.tx402.io/sitemap.md
- Machine index: https://docs.tx402.io/llms.txt · full text: https://docs.tx402.io/llms-full.txt
- This page: https://docs.tx402.io/guides/cli/

