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
VERIFIEDauthorization 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
31566704on Mainnet,10458941on Testnet) and funded with only what a few release tests cost. Export its recovery words asSELO_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 as1, 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 thepayTo 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:
--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 theexpected field of POST /v1/release-test. Every key is optional, and no other key is allowed:
contract.json
3. For example, every name in required must also appear under properties, so this contract is refused:
bad-contract.json
Terminal
—body
The JSON request body for aPOST 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
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--summaryand inGITHUB_STEP_SUMMARY. The full link is kept only in the run state file, which is written with mode600.
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 answers200, 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 wayselo 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 exits2 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, printsselo 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 rununresolved and prints, on stderr:
Terminal
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.
Examples
Related
- Run a release test is the step-by-step guide.
- Output files documents the state file, the summary and the attestation.
- GitHub Action reference runs this command in a protected CI job.
- selo verify checks the saved attestation offline.