Skip to main content
selo test is the release gate. It runs a fresh preflight, shows every price, pays Selo once from the wallet in SELO_PAYER_MNEMONIC, and waits while Selo pays your endpoint as a real x402 client and runs the five release checks. It exits 0 only for a PASS on every check, each with evidence, with both payment legs in the report. Anything else blocks the release.

Before you start

  • A VERIFIED authorization for the exact URL and method. See selo auth.
  • A release contract file. See Write a release contract.
  • A dedicated payer wallet, opted in to USDC (ASA 31566704 on Mainnet, 10458941 on Testnet) and funded with only what a few release tests cost. Export its recovery words as SELO_PAYER_MNEMONIC.
  • On Mainnet, Selo’s payTo address for --pay-to. See Finding the payTo address.

Flags

Relative paths resolve against the directory you ran the command in.

—max-usdc

Your budget for what you pay Selo, compared with the price in Selo’s 402 before anything is signed. It does not limit what Selo spends on your endpoint: Selo’s own per-job cap does that. The value is a decimal string such as 1, 1.00 or 0.5, with at most six decimal places, and must be above zero. Whatever you pass, the CLI refuses any price above 5.00 USDC.

—network, —approve-mainnet and —pay-to

--network names the network you expect, and the CLI refuses a 402 on any other network. --network mainnet also needs --approve-mainnet and --pay-to, checked before any request:
--pay-to works on Testnet too: when you set it, a 402 that names any other payee is refused before signing. The public API at https://api.useselo.xyz runs on Mainnet; use --network testnet with a Selo deployment that runs on Testnet.

Finding the payTo address

Selo’s payee is the payTo of the single requirement in the 402 that Selo returns for POST /v1/release-test. An unpaid request to that route with a valid preflight id returns the 402 and charges nothing; see Pay with any x402 client. The Mainnet 402 captured from the public API on 30 September 2026 (g1-402-response.json) names:
If Selo’s 402 names a different address than --pay-to, the CLI refuses before signing and prints both addresses:

—release

--release names the build under test. Selo does not verify it: the summary and the attestation record it as asserted_by_client. The CLI sends it as deploymentId, Selo binds it into the signed attestation, and selo verify refuses an attestation for any other release. It also names the local run state file, .selo/runs/<release>.json.

—contract

A JSON object with the shape of the expected field of POST /v1/release-test. Every key is optional, and no other key is allowed:
contract.json
The CLI compiles the schema in strict mode before anything is paid, with the same compiler Selo uses. A schema that does not compile exits 3. For example, every name in required must also appear under properties, so this contract is refused:
bad-contract.json
Terminal
The file itself must be valid JSON of at most 64 KiB.

—body

The JSON request body for a POST endpoint: valid JSON other than null, at most 64 KiB in the file and 16 KiB once serialized by Selo. The CLI sends it with the preflight as requestBody, and Selo uses it for every request it makes to your endpoint. With --method GET, the CLI exits 3 with --body is only allowed with --method POST.

—summary and —attestation

--summary writes the selo.gate.v1 summary, sanitized and safe to upload as a CI artifact. --attestation saves Selo’s signed attestation, which holds no secret and is safe to publish. Parent directories are created, and an existing file is overwritten. If Selo returns no attestation, the CLI prints attestation none: Selo issued no signed attestation for this run and writes no file. Both are written only when the run reaches the payment stage. A run that stops at input validation, the run state check or the preflight writes neither.

—resume

Reruns the payment of the unconfirmed run recorded in .selo/runs/<release>.json, with the same preflight and the same Idempotency-Key. Selo never settles a duplicate for the same key. See Resuming an unconfirmed run.

What it does

1

Validate input

Every flag, the contract file and the body file are read and validated, and the contract schema is compiled. The Mainnet approval rule and the --body rule are checked. Then the CLI loads the payer wallet from SELO_PAYER_MNEMONIC. Any problem exits 3 before a single request.
2

Check the run state

The CLI reads .selo/runs/<release>.json. If the last run for this release is still paying or unresolved, it prints recovery instructions and exits 2 without contacting Selo. If the file cannot be read, it exits 2 and asks you to inspect it.
3

Run a fresh preflight

POST /v1/preflight with the authorization, URL, method and body. An ineligible or refused preflight exits 3, an unavailable one exits 2, and nothing is paid. With --resume, this step is skipped and the recorded preflight is reused.
4

Show the terms

On a fresh run, the CLI prints the release, target, preflight id and expiry, the target fee, Selo’s cap, Selo’s price and network, your budget and the payer address. Then it records the attempt with phase paying, before sending anything to the paid route.
5

Read Selo's 402

An unpaid POST /v1/release-test with the body { preflightId, profile: "quick", expected, deploymentId }. The 402 must offer exactly one exact Algorand USDC requirement. The CLI prints its price and asset, network, payTo, your payer address and its tag.
6

Decide whether to sign

The CLI refuses to sign when the network differs from --network, the payee differs from --pay-to, the payer is the payee, the price is zero or above 5.00 USDC, or the price is above --max-usdc.
7

Pay once

The CLI signs exactly that one requirement and sends the same body once, with the payment header and Idempotency-Key: selo-cli-<48 hex characters>. The key is the first 48 hex characters of the SHA-256 of <preflight id>:<payer address>. Nothing is retried automatically. It prints the HTTP status and the settlement transaction id from Selo’s PAYMENT-RESPONSE header.
8

Conclude

The CLI parses the answer, recomputes the verdict and decides the gate. It records the outcome in the run state file, prints the verdict, each check, the job id, both transaction ids, the redacted report link and the gate line. If the outcome is unconfirmed, it also prints recovery instructions.
9

Write outputs and exit

It writes --summary, appends to GITHUB_STEP_SUMMARY when set, saves --attestation, and exits with the gate’s code.

Output

This is the output format of a passing run, filled in with the job, transaction and key values of the fixed-v2 run against the demo merchant on Algorand Mainnet on 26 October 2026. The preflight id, expiry, payer, payTo and paths are placeholders. All of it goes to stdout.
Terminal
The broken-v1 run at the same URL ended like this:
Terminal
inbound is the transaction in which you paid Selo, and downstream lists the transactions in which Selo paid your endpoint. Look them up at https://allo.info/tx/<txId>. Warnings from the report, if any, print as warning lines after the checks. See Proof on Algorand Mainnet for both runs.

What is never printed

  • The mnemonic in SELO_PAYER_MNEMONIC. It is never printed or written to any file.
  • The signed payment. It is sent once to Selo and never printed or stored.
  • The private report token. The report link is redacted to …/v1/reports/<private> in the terminal, in --summary and in GITHUB_STEP_SUMMARY. The full link is kept only in the run state file, which is written with mode 600.
A test in packages/cli/tests/ runs a full release test and scans the terminal output, the summary, the job summary and the state file for the mnemonic and the signed payment, and every published output for the report token.

How the gate decides

The CLI does not trust the verdict label. When Selo answers 200, it checks the report’s shape and then recomputes the verdict from the report’s own checks with the same pure reducer Selo uses. A FAIL counts only when a blocking check failed with evidence. A FAIL without evidence recomputes as INCONCLUSIVE, so the reported FAIL disagrees and the CLI exits 2. See Verdicts and the gate and The five release checks.

Exit paths

Every way selo test can end, in the order the CLI meets them. “Phase” is what the run state file records; a dash means no state is written.

Before any request

Run state

Preflight

The reason codes are listed on selo preflight.

Selo’s 402 (nothing signed yet)

These outcomes are definite: nothing was signed or sent. A new run for the release is allowed afterwards. While resuming, every one of them exits 2 and keeps the run unresolved.

The paid request

Once the signed payment is sent, only an answer from Selo can confirm what happened. A 402 usually means the payer holds too little USDC or is not opted in to it. See Errors and reason codes for what each API code means.

Anything else

An unexpected error, for example a summary path that cannot be written, prints selo stopped unexpectedly, the release is not proven: <reason> and exits 2.

Resuming an unconfirmed run

When the outcome of a sent payment is unconfirmed, the CLI keeps the run unresolved and prints, on stderr:
Terminal
Every later selo test for that release prints the same instructions and exits 2 without contacting Selo, until you resume:
Terminal
--resume skips the preflight, reads Selo’s 402 again, and sends the paid request with the recorded preflight and Idempotency-Key. If Selo already settled that request, it returns the existing job’s result. If the job is still running, the run stays unresolved: resume again later. Resuming works while the recorded preflight is still valid, for 15 minutes after it was created. After that Selo refuses the unpaid request with PREFLIGHT_EXPIRED and the run stays unresolved. Check the payer account for the expected transaction id the CLI printed. See Recover an interrupted run and Held payments and reconciliation.
A concluded or rejected run does not block the next one. Running selo test again for the same release starts a new preflight and a new payment.

Examples