API
x402 Payments
On this page
https://tendrilregister.007575.xyzPayment-gated HTTP API for renting compute. Every endpoint that costs money speaks
x402 V2, exact scheme, AVM (Algorand) mechanism.
- Base URL —
http://localhost:4000in development; whateverPUBLIC_BASE_URLsays in production. - Asset — Algorand USDC. Testnet ASA
10458941, mainnet31566704, 6 decimals. - Network — CAIP-2
algorand:SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=(testnet). - Content type —
application/jsonon every request and response.
All amounts are integer strings of atomic units. "250000" is 0.25 USDC. They are strings
because a JSON number cannot carry a uint64 safely; never parse one into a float before doing
arithmetic on it.
Looking for the free endpoints —
/explorer,/platform,/metrics,/wallet, sign-in, lease status and release? Those are plain HTTP with copy-pastecurl: api.md. This document covers only the three that move money.
Overview
How payment works
Three round trips at most, and the middle one is where the money would be lost if the ordering were wrong.
client server facilitator chain
│ POST /topup?amount=5000000 │ │ │
│───────────────────────────────>│ │ │
│ 402 + PaymentRequired │ │ │
│<───────────────────────────────│ │ │
│ │ │ │
build atomic group: │ │ │
[0] USDC transfer (signed) │ │ │
[1] fee payer txn (unsigned) │ │ │
│ │ │ │
│ POST … PAYMENT-SIGNATURE │ │ │
│───────────────────────────────>│ verify() ──────────────────>│ │
│ │<──────────── isValid ────────│ (simulate, │
│ │ │ no submit) │
│ ...do the work... │ │
│ (provision sandbox / run job) │ │
│ │ │ │
│ │ settle() ──────────────────>│───signs [1]─>│
│ │<──────────── txid ───────────│ │
│ 200 + PAYMENT-RESPONSE │ │ │
│<───────────────────────────────│ │ │
Rent: verify, provision, then settle. verify() only simulates the transaction group — nothing
reaches the chain. A rented sandbox that fails to start returns 503 with nothing settled.
Leaseless runs settle their gate fee before provisioning so long jobs do not outlive the signed
transaction's validity window. A subsequent run startup failure does not undo that gate fee;
execution usage starts billing only after sandbox readiness.
Fees are sponsored. The 402 carries extra.feePayer, the facilitator's address. The client
builds a two-transaction group — its own transfer plus an unsigned self-payment from the fee payer
whose fee covers both — and signs only its own. The facilitator signs the other and submits.
A client therefore needs USDC and zero ALGO. The fee is never itemised and never charged on.
Payer identity comes off the settled transaction. The server decodes the transaction at
paymentIndex and takes its sender. That address is what gets credited, billed, and refunded —
nothing a client says about who it is, is trusted.
Headers
Request
| Header | When | Value |
|---|---|---|
PAYMENT-SIGNATURE |
retrying a 402 | base64 JSON of a PaymentPayload |
X-PAYMENT |
— | accepted as a legacy alias for the above |
Authorization: Bearer <token> |
optional / required per route | a session token or a lease token — see Authentication |
Content-Type: application/json |
with a body |
Response
| Header | When | Value |
|---|---|---|
PAYMENT-REQUIRED |
on every 402 challenge |
base64 JSON of the PaymentRequired body |
PAYMENT-RESPONSE |
on a 200 that settled a payment |
base64 JSON of a SettleResponse |
X-PAYMENT-RESPONSE |
same | legacy alias, identical value |
PAYMENT-REQUIRED and the 402 body carry the same object; read whichever is convenient.
Browsers:
PAYMENT-SIGNATUREis a non-simple header, so every paid request is preflighted. The three response headers above are inAccess-Control-Expose-Headers, or a paid response would look unpaid to JavaScript.
Authentication
Three independent credentials. None of them is how you pay.
| Credential | Obtained from | Used by | Lifetime |
|---|---|---|---|
| Session token | POST /auth/wallet-login |
spending an existing credit balance | 7 days |
| Lease token | the leaseToken field of a successful rent |
/lease/:id/* |
24 hours |
| A payment | your wallet | everything that costs money | per request |
Signing in proves you control an address so you can spend credit already sitting against it. It is
not required to pay, and it never was: a brand-new address with no history can call POST /topup and
be credited on its first ever request.
CORS
Every payable route answers Access-Control-Allow-Origin: *. A browser on any origin is as much
a first-class client as the Tendril web app — the 402 is what protects these routes, and the price is
the same whoever asks.
| Open to any origin | Behind CORS_ORIGIN |
|---|---|
POST /topup, POST /x402/topup |
GET /platform |
POST /x402/rent (+ legacy /rent/:nodeId) |
GET /explorer, GET /nodes |
POST /x402/run (+ legacy /lease/:id/run) |
GET /wallet, GET /metrics |
POST /lease/:id/release, DELETE /x402/leases/:id |
GET /lease/:id, /auth/* |
CORS constrains browsers only — a headless agent was never subject to it and can call anything.
Common schemas
PaymentRequired
The body of every 402 challenge, and the base64 payload of PAYMENT-REQUIRED.
{
"x402Version": 2,
"error": "Payment required", // or a facilitator reason, e.g. "insufficient_funds"
"resource": {
"url": "https://api.tendril.xyz/topup?amount=5000000",
"description": "Credit 5 USDC to the paying address. Pay from a wallet you control: …",
"mimeType": "application/json"
},
"accepts": [ /* PaymentRequirements */ ]
}
accepts always has exactly one entry: Tendril takes one asset on one network.
PaymentRequirements
{
"scheme": "exact",
"network": "algorand:SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=",
"amount": "5000000", // atomic units — pay exactly this
"asset": "10458941", // ASA id
"payTo": "PLATFORM…ADDRESS",
"maxTimeoutSeconds": 60, // how long this challenge stays payable
"extra": {
"decimals": 6,
"name": "USDC",
"feePayer": "ZMFK2OI7…RA22AA" // absent if the facilitator sponsors nothing
}
}
If extra.feePayer is absent, build a one-transaction group and pay your own fee — you will need
ALGO. If present, build the two-transaction group described above and sign only index 0.
PaymentPayload
What you base64-encode into PAYMENT-SIGNATURE.
{
"x402Version": 2,
"scheme": "exact",
"network": "algorand:SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=",
"payload": {
"paymentGroup": ["<base64 msgpack txn>", "<base64 msgpack txn>"],
"paymentIndex": 0 // which entry is YOUR transfer
}
}
The group must already carry a valid group id. paymentIndex names your signed transfer; the other
entry is the fee payer's, left unsigned.
SettleResponse
Base64 payload of PAYMENT-RESPONSE.
{
"success": true,
"transaction": "ABC123…", // on-chain txid
"network": "algorand:SGO1…cOUJOiI=",
"payer": "AGENT7…XYZ"
}
AssetInfo
{ "id": "10458941", "decimals": 6, "symbol": "USDC" }
PaymentReceipt
{ "txid": "ABC123…", "network": "algorand:SGO1…cOUJOiI=" }
SandboxAccess
{
"kind": "ssh",
"host": "bore.pub",
"port": 41823,
"username": "root",
"authMethod": "publickey", // or "password"
"password": null, // your address, under "password"
"command": "ssh root@bore.pub -p 41823"
}
Error envelope
Every non-402 failure, and the two 402s that are not challenges:
{ "error": "node_busy" } // machine-readable code
{ "error": "insufficient_credit", "detail": "…" } // some carry extra context
Two shapes of 402. A 402 that means "pay this" has a PaymentRequired body. A 402 that means "your payment cannot proceed" —
settlement_failed,insufficient_credit— has an error envelope. Branch on the presence ofaccepts, not on the status code alone.
Endpoints
POST /topup
Buy credit. Alias: POST /x402/topup — identical behaviour.
Credit is keyed to the sender of the settled transaction, not to any session. Pay from a wallet you control: money sent from an address you cannot sign for (an exchange withdrawal, say) creates a balance nobody can spend.
| Auth | none |
| CORS | open |
| Price | whatever you ask for |
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
amount |
integer string, atomic units | no | How much credit to buy. Between MIN_TOPUP_ATOMIC (default 100000 = 0.10 USDC) and MAX_TOPUP_ATOMIC (default 1000000000 = 1000 USDC). Omit it and you get DEFAULT_TOPUP_ATOMIC (default 1000000 = 1 USDC). |
Request body
None.
200 OK
Payment settled. One topups row written and the address's credit balance raised by the same amount,
in one database transaction. Carries PAYMENT-RESPONSE.
{
"address": "AGENT7XYZ…", // the payer, read off the settled transaction
"credited": "5000000",
"balance": "7250000", // after crediting
"asset": { "id": "10458941", "decimals": 6, "symbol": "USDC" },
"payment": { "txid": "ABC123…", "network": "algorand:SGO1…cOUJOiI=" }
}
Replays return 200 too. Send the same PAYMENT-SIGNATURE twice and the second call returns the
original receipt, credits nothing further, and omits the PAYMENT-RESPONSE header — there was no new
settlement to report.
402 Payment Required
No payment attached, or the payment did not verify. Body is PaymentRequired. Nothing was submitted.
Also 402 with an error envelope when settlement itself failed:
{ "error": "settlement_failed", "detail": "insufficient_funds" }
400 Bad Request
error |
Meaning |
|---|---|
invalid_amount |
?amount= was not a whole number of atomic units. Carries detail. |
amount_below_minimum |
Below MIN_TOPUP_ATOMIC. Carries minimum. |
amount_above_maximum |
Above MAX_TOPUP_ATOMIC. Carries maximum. |
malformed_payment |
PAYMENT-SIGNATURE was not decodable, or is not an AVM transaction group. |
502 Bad Gateway
{ "error": "facilitator_unavailable", "detail": "fetch failed" }
The facilitator could not be reached. Nothing was submitted and nothing was credited.
Example
Step 1 — ask, with curl. Reading the quote needs no signer, so this half is plain HTTP.
curl -isS -X POST "$API/topup?amount=5000000"
HTTP/1.1 402 Payment Required
payment-required: eyJ4NDAyVmVyc2lvbiI6MiwiZXJyb3IiOiJQYXltZW50IHJlcXVpcmVkIiwi…
content-type: application/json; charset=utf-8
The body is the same object as the header, so jq it directly:
curl -sS -X POST "$API/topup?amount=5000000" | jq '{amount:.accepts[0].amount, payTo:.accepts[0].payTo, asset:.accepts[0].asset}'
{
"amount": "5000000",
"payTo": "PLATFORM7ADDRESS7EXAMPLE7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"asset": "10458941"
}
Or decode the header, which is what a v2 client actually reads:
curl -sS -D - -o /dev/null -X POST "$API/topup?amount=5000000" \
| grep -i '^payment-required:' | cut -d' ' -f2 | base64 -d | jq .resource
{
"url": "https://api.tendril.example/topup?amount=5000000",
"description": "Credit 5 USDC to the paying address. Pay from a wallet you control: the balance is keyed to the sender and can only be spent by signing from that address.",
"mimeType": "application/json",
"serviceName": "TENDRIL",
"tags": ["x402-global-challenge", "compute", "ssh", "sandbox"]
}
Step 2 — pay. curl cannot build this header: it is a base64-msgpack Algorand transaction
group with your signature over one of its transactions. Mint it with the helper below, then the
request itself is curl again.
# sign.mjs prints a PAYMENT-SIGNATURE value for a given 402 body
SIG=$(curl -sS -X POST "$API/topup?amount=5000000" | node sign.mjs)
curl -isS -X POST "$API/topup?amount=5000000" -H "PAYMENT-SIGNATURE: $SIG"
HTTP/1.1 200 OK
payment-response: eyJzdWNjZXNzIjp0cnVlLCJ0cmFuc2FjdGlvbiI6…
{
"address": "YOUR7ADDRESS7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"credited": "5000000",
"balance": "5000000",
"asset": { "id": "10458941", "decimals": 6, "symbol": "USDC" },
"payment": { "txid": "ABC123…", "network": "algorand:SGO1…cOUJOiI=" }
}
sign.mjs — reads a 402 body on stdin, prints the header value
// npm i @x402/core @x402/avm algosdk
import { x402Client } from "@x402/core/client";
import { ExactAvmScheme } from "@x402/avm/exact/client";
import { encodePaymentSignatureHeader } from "@x402/core/http";
import algosdk from "algosdk";
const sk = new Uint8Array(Buffer.from(process.env.AVM_PRIVATE_KEY, "base64"));
const address = algosdk.encodeAddress(sk.slice(32));
const signer = {
address,
// Sign only the indexes asked for — the fee-payer transaction is left unsigned
// for the facilitator, which is why this account needs USDC but no ALGO.
async signTransactions(txns, indexes) {
const wanted = indexes ?? txns.map((_, i) => i);
return txns.map((b, i) =>
wanted.includes(i) ? algosdk.decodeUnsignedTransaction(b).signTxn(sk) : null,
);
},
};
const challenge = JSON.parse(await new Response(process.stdin).text());
const client = new x402Client().register(
challenge.accepts[0].network,
new ExactAvmScheme(signer),
);
process.stdout.write(
encodePaymentSignatureHeader(await client.createPaymentPayload(challenge)),
);
The signed header is single-use: replaying it returns the original receipt for /topup
(idempotent) but 409 payment_already_used on /rent and /run, which buy something.
Errors, with curl
All three are plain requests — no signer needed to see them.
curl -sS -X POST "$API/topup?amount=1" # below MIN_TOPUP_ATOMIC
curl -sS -X POST "$API/topup?amount=99999999999" # above MAX_TOPUP_ATOMIC
curl -sS -X POST "$API/topup?amount=abc" # not an integer
{"error":"amount_below_minimum","minimum":"100000"}
{"error":"amount_above_maximum","maximum":"1000000000"}
{"error":"invalid_amount","detail":"?amount= must be a whole number of atomic units"}
POST /x402/rent
Open a metered session on a node. The machine is named by ?nodeId=, not by a path segment —
one URL for every rent, so the endpoint is one entry in the Bazaar catalog rather than one per node.
Legacy aliases: POST /rent/:nodeId and POST /x402/rent/:nodeId still work unchanged.
There is no duration to choose and no block to buy. Renting pays one flat on-chain gate fee — that is the x402 payment, and the only thing that moves up front. From there the clock simply runs, and the seconds actually used are billed from credit once, when the session closes.
The session lives exactly as long as the renter's credit can pay for it. The watchdog stops it at
fundedUntil, which is credit ÷ rate. Top up and that moment moves out — nobody is disconnected
at the end of a block they guessed wrong.
| Auth | none |
| CORS | open |
| Price | FLAT_RENT_ATOMIC (0.01 USDC) on-chain, then pricePerHourUsd billed from credit |
Query parameters
| Name | Required | Description |
|---|---|---|
nodeId |
yes | Which machine to rent, from GET /explorer. May also be sent as a body field. Missing it is 400 node_required. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
nodeId |
string | no | Alternative to ?nodeId=. The query parameter wins if both are given. |
sshPubKey |
string | no | An OpenSSH public key line, e.g. ssh-ed25519 AAAAC3Nza…. Installed as the sandbox's authorized_keys; the response then has authMethod: "publickey" and password: null. Without a session this is the only usable auth — there is no address to use as a password. |
{ "sshPubKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI… you@host" }
200 OK
Sandbox is up, the gate fee has settled, and the meter is running. Carries PAYMENT-RESPONSE.
{
"leaseId": "lease_9k2m",
"leaseToken": "eyJhbGciOi…", // required by /x402/run and /release
"node": {
"id": "node_7f2",
"cpu": 8,
"memoryGb": 32,
"gpu": null,
"pricePerHourUsd": 1.0
},
"ssh": {
"kind": "ssh",
"host": "bore.pub",
"port": 41823,
"username": "root",
"authMethod": "publickey",
"password": null,
"command": "ssh root@bore.pub -p 41823"
},
"startedAt": "2026-08-01T10:14:02.000Z",
"fundedUntil": "2026-08-01T11:14:02.000Z", // or "never" on a free node
"billing": {
"rateAtomicPerHour": "1000000",
"gateFeeAtomic": "10000",
"creditAtomic": "1000000", // credit at open — what funded the window
"fundedSeconds": 3600, // null on a free node
"asset": { "id": "10458941", "decimals": 6, "symbol": "USDC" }
},
"payment": { "txid": "DEF456…", "network": "algorand:SGO1…cOUJOiI=" }
}
fundedUntil is a projection, not a promise of disconnection at that instant: the watchdog checks
every METER_INTERVAL_MS, so a session can overrun by up to one tick. That overrun is clamped to the
balance at billing time and absorbed by the platform, never charged past what the renter holds.
Reaching it does not kill the session outright either. The watchdog re-reads the payer's live balance
first, so a top-up made mid-session extends the window instead of waiting for the next rent. Only if
the credit really is spent does it open a grace window — GRACE_ATOMIC (default 1.00 USDC) of
runtime at that lease's rate, enough to push your work somewhere — and destroy the sandbox when the
window closes. GET /lease/:id reports it as graceUntil. Top up during it and it clears.
Responses
| Status | error |
Meaning |
|---|---|---|
402 |
(PaymentRequired body) | Pay the gate fee, or your payment did not verify. Nothing submitted. |
402 |
insufficient_credit |
Verified, but the payer's credit funds less than MIN_LEASE_SECONDS at this node's rate. Nothing settled — top up first. Carries detail, creditAtomic, rateAtomicPerHour. |
402 |
credit_exhausted |
The payer's balance is negative — an earlier /x402/run overdrew it. Nothing settled. Top up by at least the amount owed. Carries creditAtomic. |
402 |
settlement_failed |
Sandbox came up but the gate fee would not settle. Sandbox torn down, node freed. Carries detail. |
400 |
invalid_ssh_key |
sshPubKey is not a valid OpenSSH public key line. |
400 |
malformed_payment |
PAYMENT-SIGNATURE undecodable. |
404 |
node_not_found |
No such node. |
409 |
node_unavailable |
Node is offline or has missed heartbeats. |
409 |
node_busy |
Someone else took the node — including while you were away paying. Nothing was settled. |
409 |
payment_already_used |
That payment already bought something. Carries txid. |
503 |
provisioning_failed |
Sandbox did not come up within SANDBOX_READY_TIMEOUT_MS. Nothing was settled — retry on another node. Carries detail. |
502 |
facilitator_unavailable |
Facilitator unreachable. Nothing submitted. |
The credit check runs after verify() and before settle(). An address that cannot fund a
minimum session is turned away without paying the gate fee, rather than being charged for a session
the watchdog would kill on its next tick.
Example
Pick a node and read the gate-fee quote — both plain curl:
NODE=$(curl -sS $API/explorer | jq -r '[.nodes[] | select(.status=="online")]
| sort_by(.pricePerHourUsd) | .[0].id')
curl -sS -X POST "$API/x402/rent?nodeId=$NODE" | jq '.accepts[0].amount'
"10000"
Then pay it, sending your public key so you can actually log in (see the note below):
BODY=$(jq -n --arg k "$(cat ~/.ssh/id_ed25519.pub)" '{sshPubKey:$k}')
SIG=$(curl -sS -X POST "$API/x402/rent?nodeId=$NODE" -H 'content-type: application/json' -d "$BODY" | node sign.mjs)
curl -sS -X POST "$API/x402/rent?nodeId=$NODE" \
-H 'content-type: application/json' \
-H "PAYMENT-SIGNATURE: $SIG" \
-d "$BODY" | tee lease.json | jq '{cmd:.ssh.command, until:.fundedUntil}'
{
"cmd": "ssh root@bore.pub -p 41823",
"until": "2026-08-01T11:14:02.000Z"
}
Keep the token — it is the only credential for status, /run and release, and it is returned once:
export LEASE=$(jq -r .leaseId lease.json)
export LEASE_TOKEN=$(jq -r .leaseToken lease.json)
eval "$(jq -r .ssh.command lease.json)" # actually connect
Without a session, omitting sshPubKey leaves you locked out: the fallback password is the
session address, and there isn't one. The rent still succeeds and still bills.
Failures you can reproduce with curl alone, no signer:
curl -sS -X POST "$API/x402/rent?nodeId=node_doesnotexist"
{"error":"node_not_found"}
POST /x402/run
Run some code on a Linux machine and get its stdout back. Flat price per job.
You do not need a lease. Send no lease token and Tendril picks the best-value idle machine itself, starts a throwaway sandbox, runs your code, destroys the sandbox, and bills the seconds it took. Nothing to rent, choose or release.
Send a lease token and it runs inside a machine you already hold instead; that time is billed with
the lease, not here. Both are the same URL, so the endpoint is one entry in the Bazaar.
Legacy alias: POST /lease/:id/run, where :id must match the token, as before.
Leaseless jobs settle the gate fee before provisioning. Their signed payment must not expire during a long run. Jobs inside an existing lease settle after execution. Startup failures consume no execution credit, but do not undo an already-settled leaseless gate fee.
| Auth | none, or a lease token |
| CORS | open |
| Price | FLAT_RUN_ATOMIC (default 10000 = 0.01 USDC) plus execution time at the chosen node's hourly rate, billed from credit |
Which machine you get
Not the cheapest — the best value. Nodes are scored (cores + RAM_GB / 4) / pricePerHourUsd among
those online and idle, highest first, ties broken on the lower price. A machine at half the rate that
takes three times as long is not a saving, and you have no way to see that happen, so the pick
optimises capability per unit of rate. A free node (pricePerHourUsd = 0) always wins.
Billing, and how you can end up owing money
The gate fee is on-chain. The execution is not: it comes out of your credit balance, charged once
when the job finishes, at elapsed / 3600 × rate.
A script run can overdraw credit. So if 0.50 USDC of credit meets a job that costs 0.60, the job finishes and the balance lands at −0.10 USDC. That debt is real:
POST /x402/rentrefuses with402 credit_exhausteduntil it is cleared,POST /x402/runrefuses with402 insufficient_creditfor the same reason,- topping up by at least the amount owed restores both.
You need a positive balance to start a leaseless run at all. RUN_TIMEOUT_MS (default 120s) caps
how long one job can run, which is also the cap on how far a single run can overdraw you.
For leaseless runs the configured timeout is capped at 15 minutes. Notebook jobs additionally stop
at the prepaid budget and cannot overdraw execution credit.
Request headers
| Header | Required | Description |
|---|---|---|
Authorization: Bearer <leaseToken> |
no | Runs inside that lease instead of a fresh sandbox. A token that is neither a valid lease nor a valid session is 401 invalid_token — never silently downgraded to a leaseless run on some other machine. |
PAYMENT-SIGNATURE |
on the retry |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
payload |
string | one of payload/notebook | Python source to execute. Its stdout comes back in result. |
notebook |
object | one of payload/notebook | Python nbformat 4 notebook, up to 1.5 MB and 500 cells. |
lane |
contributor, priority or e2b |
no | Notebook only. Omitted prefers priority when Modal is configured, then e2b when E2B is configured; otherwise contributor. |
e2b |
{ "vCpu": 1|2|4|6|8, "memGiB": 1|2|4|8 } |
no | e2b lane only. Sandbox size; default 2 vCPU · 4 GiB. Priced per size (see GET /explorer e2bPrices). |
runtime |
object | no | Notebook only. Per-run environment, pip packages, timeout, and an approved custom image. |
runtime fields:
| Field | Type | Description |
|---|---|---|
env |
object of strings | Up to 32 variables. Names must be valid shell identifiers; sandbox control variables cannot be overridden. |
pipPackages |
string[] | Up to 32 package requirements, installed into an isolated per-run directory before the kernel starts. Package names, extras, and version constraints are accepted. |
timeoutMs |
number | Requested notebook timeout. Server, 15-minute global, and prepaid-credit caps still apply. |
image |
string | Optional image reference allowed by server configuration. Not supported on E2B or an already-rented lease. |
{ "notebook": { "nbformat": 4, "metadata": {}, "cells": [] },
"lane": "priority",
"runtime": {
"env": { "DATASET": "demo" },
"pipPackages": ["seaborn", "openpyxl==3.1.5"],
"timeoutMs": 180000
}
}
Asynchronous notebook response
Both lanes use IPython with %pip, shell/cell magics, top-level await, inline plots, and process
pools. Python syntax is checked before code runs, without altering source. Non-Python kernels are
unsupported. Runtime errors stop later cells and preserve earlier outputs when available.
A notebook without a lease token returns immediately after gate-fee settlement:
{ "jobId": "notebook-job", "jobToken": "<bearer token>", "status": "starting" }
Poll GET /x402/run/notebook-job with Authorization: Bearer <jobToken>:
{
"jobId": "notebook-job",
"status": "ended",
"run": {
"jobId": "notebook-job",
"ok": true,
"result": "Notebook completed.",
"notebook": { /* executed nbformat 4 JSON */ },
"artifacts": [{ "name": "result.txt", "mediaType": "text/plain", "base64": "ZG9uZQ==" }],
"execution": { "nodeId": "node", "seconds": 5, "costAtomic": "139", "balance": "999861" }
}
}
Wait for run or error, not terminal status alone: teardown claims ended before billing and
result collection finish. Failed executions can still carry an executed notebook and artifacts.
Hard timeouts or sandbox failure can prevent partial results. Polling is free and requires the
matching job token; expired/evicted records return 404.
Notebook execution is capped by prepaid credit and RUN_TIMEOUT_MS (at most 15 minutes).
Release existing sessions first; another notebook or session cannot share the same payer's credit
while this notebook is running (409 payer_busy). Limits are 2 MB cell output, 4 MB total artifacts,
and 12 MB transport. Artifacts come from /work; hidden paths, symlinks, and special files are
excluded. Oversized artifacts are skipped with a notice. Results are retained in-memory for up to
one hour, subject to oldest-first eviction at 32 jobs or 64 MB total, and are lost on restart.
200 OK
Carries PAYMENT-RESPONSE.
{
"jobId": "V1StGXR8Z5",
"ok": true,
"result": "4950\n"
}
ok: false means the job ran and failed; result holds whatever it printed. It is still a 200,
and it is still charged — the compute was consumed.
A leaseless run carries one extra object naming the machine it found and what the time cost:
{
"jobId": "V1StGXR8Z5",
"ok": true,
"result": "4950\n",
"execution": {
"nodeId": "wbVu3T-ru3",
"seconds": 12, // sandbox uptime — what was billed
"costAtomic": "1667", // 12s at that node's rate
"balance": "4998333" // after the charge; MAY be negative
}
}
Responses
| Status | error |
Meaning |
|---|---|---|
402 |
(PaymentRequired body) | Pay FLAT_RUN_ATOMIC. |
402 |
insufficient_credit |
Leaseless run with a zero or negative balance. Nothing settled — top up first. Carries creditAtomic. |
402 |
settlement_failed |
Gate fee could not settle; leaseless job does not start. Carries detail. |
400 |
(validation detail) | Pass exactly one payload/notebook; notebook must be valid Python nbformat 4 within upload limits. |
400 |
malformed_payment |
PAYMENT-SIGNATURE undecodable. |
401 |
invalid_token |
An Authorization header that is neither a lease nor a session token. |
401 |
invalid or missing lease token |
Lease path: token malformed or for a different lease. |
404 |
lease not found |
Token valid but the lease is gone. |
409 |
lease not active |
Lease has not started, or has already ended. |
409 |
payer_busy or node_or_payer_busy |
Credit or selected node is already reserved. Nothing settled. |
409 |
payment_already_used |
That payment already bought something. Carries txid. |
503 |
no_node_available |
Leaseless run and every machine is busy or offline. Nothing settled. |
503 |
provisioning_failed |
Synchronous run sandbox did not come up. Gate fee may already be settled; no execution time billed. Async notebooks report this through polling. |
502 |
(message from the agent) | Synchronous job timed out or node dropped; started leaseless execution is billed. Async notebooks report failure through polling. |
Example — no lease, no setup
Two commands and you have run code on somebody else's machine.
RUN=$(jq -n '{payload:"print(sum(range(100)))"}')
SIG=$(curl -sS -X POST "$API/x402/run" \
-H 'content-type: application/json' -d "$RUN" | node sign.mjs)
curl -sS -X POST "$API/x402/run" \
-H 'content-type: application/json' \
-H "PAYMENT-SIGNATURE: $SIG" \
-d "$RUN" | jq
{
"jobId": "a1b2c3d4e5",
"ok": true,
"result": "4950\n",
"execution": { "nodeId": "wbVu3T-ru3", "seconds": 9, "costAtomic": "1250", "balance": "4998750" }
}
Example — inside a lease you hold
Add the lease token and the job goes to your own machine; the time is billed with the lease when you
release it, so there is no execution block.
curl -sS -X POST "$API/x402/run" \
-H "authorization: Bearer $LEASE_TOKEN" \
-H 'content-type: application/json' \
-H "PAYMENT-SIGNATURE: $SIG" \
-d "$RUN" | jq
{ "jobId": "a1b2c3d4e5", "ok": true, "result": "4950\n" }
If you have an SSH session open you do not need this endpoint at all — /x402/run exists for
clients that want one-shot execution without holding a shell.
DELETE /x402/leases/:id
Stop the meter. Alias: POST /lease/:id/release.
Free — closing costs nothing. This is the moment compute is billed, and the only one: nothing was taken when the session opened, so there is nothing to refund.
usedSeconds = wall-clock seconds the sandbox was up
usedAtomic = ceil(usedSeconds / 3600 × rate)
charged = min(usedAtomic, balance) -> taken from the payer's credit
payout = charged × (1 − PLATFORM_FEE_PCT/100) -> contributor, on-chain USDC
charged is clamped to the balance because the watchdog only ticks every METER_INTERVAL_MS, so a
session can legitimately overrun its funding by up to one tick — and because of the grace window,
which is deliberately unfunded. The platform absorbs both; the contributor is paid out of what was
collected, never out of what was merely owed.
The clamp is what makes a session different from a leaseless POST /x402/run. A session can be
stopped at any second, so it is, and your balance floors at zero. A one-shot run cannot be stopped
part-way, so it bills in full and can leave you owing — see
the run endpoint.
| Auth | lease token, required |
| CORS | open |
| Price | free |
200 OK
{
"leaseId": "lease_9k2m",
"usedSeconds": 300,
"usedAtomic": "83334",
"chargedAtomic": "83334", // what was actually taken from credit
"balance": "916666", // payer's credit after the charge
"asset": { "id": "10458941", "decimals": 6, "symbol": "USDC" }
}
Idempotent. charges.lease_id is unique, so a concurrent release and watchdog tick cannot bill the
same session twice — the loser returns zeros and the current balance.
Responses
| Status | error |
Meaning |
|---|---|---|
401 |
invalid or missing lease token |
Token absent, malformed, or for a different lease. |
404 |
lease not found |
Already gone. |
GET /lease/:id
Lease status. Free.
| Auth | lease token, required |
| CORS | CORS_ORIGIN |
200 OK
{
"lease": {
"id": "lease_9k2m",
"nodeId": "node_7f2",
"renterAddr": "AGENT7XYZ…",
"payerAddr": "AGENT7XYZ…",
"payToAddr": "CONTRIB…",
"access": { /* SandboxAccess */ },
"status": "active", // starting | active | ended | failed
"rateAtomicPerHour": 1000000,
"gateFeeAtomic": 10000,
"fundingAtomic": 1000000,
"paymentTxid": "DEF456…",
"startedAt": 1785542042000,
"expiresAt": 1785542942000,
"graceUntil": null, // set once credit runs out — see below
"createdAt": 1785542040000
}
}
401 / 404 as above.
GET /platform
What the platform charges in and where. Free, no auth. Useful for showing prices before anyone
pays — though you do not need it to pay, since the 402 challenge carries payTo, asset and
network itself.
200 OK
{
"payTo": "PLATFORM…ADDRESS",
"network": "algorand:SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=",
"asset": { "id": "10458941", "decimals": 6, "symbol": "USDC" },
"facilitatorUrl": "https://facilitator.goplausible.xyz",
"minTopUpAtomic": 100000,
"maxTopUpAtomic": 1000000000,
"minWithdrawAtomic": 5000000 // least a contributor may withdraw at once
}
GET /explorer
Nodes available to rent. Free, no auth.
200 OK
{
"nodes": [
{
"id": "node_7f2",
"ownerAddr": "OWNER…",
"payToAddr": "CONTRIB…",
"payoutBlocked": false, // true = payout address never opted into the ASA
"label": "workstation",
"cpuCores": 8,
"ramMb": 32768,
"gpu": null,
"pricePerHourUsd": 1.0,
"status": "online"
}
]
}
payoutBlocked: true means the contributor's address has not opted into the payment asset. They
still earn normally — the opt-in is only required to withdraw those earnings.
Reference
Error index
error |
Status | Where | Notes |
|---|---|---|---|
amount_above_maximum |
400 | topup | carries maximum |
amount_below_minimum |
400 | topup | carries minimum |
facilitator_unavailable |
502 | all paid | nothing submitted; carries detail |
invalid_amount |
400 | topup | carries detail |
insufficient_credit |
402 | rent | verified but unfunded; nothing settled; carries detail, creditAtomic, rateAtomicPerHour |
invalid_ssh_key |
400 | rent | |
invalid or missing lease token |
401 | lease routes | |
lease not active |
409 | run | |
lease not found |
404 | lease routes | |
malformed_payment |
400 | all paid | header undecodable or not an AVM group |
node_busy |
409 | rent | nothing settled |
node_not_found |
404 | rent | |
node_unavailable |
409 | rent | offline or missed heartbeats |
payment_already_used |
409 | rent, run | carries txid |
payload (string) required |
400 | run | |
provisioning_failed |
503 | rent | nothing settled; carries detail |
settlement_failed |
402 | all paid | carries detail |
The first-run failure
A payment that is built correctly but cannot be simulated comes back as a fresh 402 whose
error is the facilitator's own message. By far the most common one:
{
"x402Version": 2,
"error": "Transaction simulation failed: transaction KOAJRO…: asset 10458941 missing from T42FOE…",
"accepts": [ … ]
}
The paying address has never opted into the asset. An Algorand account cannot receive or send an ASA it has not opted into, and simulation catches that before anything is submitted — so nothing settled and nothing was charged. Opt in, fund the address, retry the same request.
The sibling of this one is asset 10458941 missing from <PLATFORM_PAYTO>, which means the
server's address is not opted in. That is an operator problem, not a client one.
What is safe to retry
| Outcome | Money moved? | Retry? |
|---|---|---|
402 with accepts |
no | yes — pay the amount quoted |
402 insufficient_credit |
no | yes — after topping up |
402 settlement_failed |
no | yes — with a fresh payment |
409 node_busy |
no | yes — another node |
409 payment_already_used |
yes, earlier | no — build a new payment |
503 provisioning_failed |
no | yes — another node |
502 facilitator_unavailable |
no | yes — after a pause |
Configuration
Server-side environment variables that change what clients see.
| Variable | Default | Effect |
|---|---|---|
X402_NETWORK |
testnet CAIP-2 | Network quoted in every challenge. Must match the facilitator's /supported byte for byte — checked loudly at boot. |
X402_ASSET_ID |
10458941 |
ASA every price is denominated in. |
X402_ASSET_DECIMALS |
6 |
|
X402_ASSET_SYMBOL |
USDC |
|
X402_FACILITATOR_URL |
https://facilitator.goplausible.xyz |
Verifies, settles, sponsors fees. |
X402_MAX_TIMEOUT_SECONDS |
60 |
maxTimeoutSeconds in the challenge. |
PLATFORM_PAYTO |
— | payTo. Required; must be opted into the asset. |
PLATFORM_FEE_PCT |
1 |
Platform's cut of used time; the rest goes to the contributor. |
MIN_TOPUP_ATOMIC |
100000 |
0.10 USDC |
MAX_TOPUP_ATOMIC |
1000000000 |
1000 USDC |
DEFAULT_TOPUP_ATOMIC |
1000000 |
Used when ?amount= is omitted. |
FLAT_RENT_ATOMIC |
10000 |
Gate fee to open a session on POST /x402/rent. |
FLAT_RUN_ATOMIC |
10000 |
Gate fee for one job on POST /x402/run. Execution time is billed on top, from credit. |
RUN_TIMEOUT_MS |
120000 |
Hard ceiling on one job — and so on how far a single leaseless run can overdraw a balance. |
GRACE_ATOMIC |
1000000 |
Runtime given to a session whose credit ran out, so work can be saved. Converted to seconds at that lease's rate. Platform absorbs it. |
MIN_LEASE_SECONDS |
60 |
Least credit (as seconds of runtime) needed to open a session. |
SANDBOX_READY_TIMEOUT_MS |
45000 |
Wait before provisioning_failed. |
CORS_ORIGIN |
* |
Guards everything except the payable routes. |
PUBLIC_BASE_URL |
request host | resource.url in challenges. |
Renting from the CLI
No browser, no wallet extension, no sign-in. An address holding USDC is the whole account.
What curl can and cannot do
Read this first, because it is the thing that surprises people. Paying an x402 endpoint means
building an Algorand atomic transaction group, signing your transaction in it with an ed25519
key, and base64-msgpack encoding the group into PAYMENT-SIGNATURE. curl cannot do that. There
is no header you can hand-write that stands in for a signature.
| You want to | curl alone? |
|---|---|
| List nodes, read prices, check platform config | yes — GET /explorer, /platform, /metrics, /nodes are free |
| See a price quote before paying | yes — the unpaid POST returns 402 with the exact amount |
| Read lease status | yes — GET /lease/:id with the lease token |
| Stop a session | yes — DELETE /x402/leases/:id is free |
| Top up, rent, or run a job | no — those settle a payment and need a signer |
So: everything except the three paid calls is plain HTTP. For those three you need ~20 lines of JavaScript, below.
Option A — the bundled agent
The repo ships a working headless renter. It tops up, picks the cheapest node meeting a RAM floor, opens a session, runs a script inside it, releases, and prints what it was billed.
npm run keygen # prints an Address + AVM_PRIVATE_KEY
# fund that address: opt into ASA 10458941, then use the testnet USDC dispenser
echo 'AVM_PRIVATE_KEY=<base64 64-byte key>' >> .env
echo 'REGISTRY_URL=https://api.your-domain.com' >> .env
npm run client
Knobs: AGENT_TOPUP_ATOMIC (default 500000 = 0.50 USDC), AGENT_MIN_RAM_MB (default 1024).
Source: example-buyer/src/index.ts — the same code path the
browser runs, differing only in the signer.
Option B — rent a box and SSH into it
The agent above executes a script and leaves. If what you want is a shell, you never touch
/run at all: rent, ssh in, work, release. Save as rent.mjs in a directory with
@x402/fetch @x402/avm @x402/core algosdk installed:
import { wrapFetchWithPayment, x402Client } from "@x402/fetch";
import { ExactAvmScheme } from "@x402/avm/exact/client";
import algosdk from "algosdk";
import { readFileSync } from "node:fs";
const API = process.env.REGISTRY_URL ?? "http://localhost:4000";
const sk = new Uint8Array(Buffer.from(process.env.AVM_PRIVATE_KEY, "base64"));
const address = algosdk.encodeAddress(sk.slice(32));
// Sign only the indexes asked for. The fee-payer transaction in the group is
// left unsigned on purpose — the facilitator signs that one and pays the fee,
// which is why this account needs USDC but no ALGO.
const signer = {
address,
async signTransactions(txns, indexes) {
const wanted = indexes ?? txns.map((_, i) => i);
return txns.map((b, i) =>
wanted.includes(i) ? algosdk.decodeUnsignedTransaction(b).signTxn(sk) : null,
);
},
};
const { network } = await (await fetch(`${API}/platform`)).json();
const pay = wrapFetchWithPayment(
fetch,
new x402Client().register(network, new ExactAvmScheme(signer)),
);
// 1. Buy credit. Renting bills from credit, so this must cover the time you want.
// Skip it if the address already has a balance.
await pay(`${API}/topup?amount=1000000`, { method: "POST" }); // 1.00 USDC
// 2. Pick a node. Free endpoint — no payment, no auth.
const { nodes } = await (await fetch(`${API}/explorer`)).json();
const node = nodes
.filter((n) => n.status === "online" && n.ramMb >= 1024)
.sort((a, b) => a.pricePerHourUsd - b.pricePerHourUsd)[0];
if (!node) throw new Error("no node available");
// 3. Open the session. Send a public key — without a session token it is the
// only usable auth, since there is no wallet address to use as a password.
const res = await pay(`${API}/x402/rent?nodeId=${node.id}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
sshPubKey: readFileSync(`${process.env.HOME}/.ssh/id_ed25519.pub`, "utf8").trim(),
}),
});
if (!res.ok) throw new Error(`rent failed: ${res.status} ${await res.text()}`);
const lease = await res.json();
console.log(lease.ssh.command); // ssh root@bore.pub -p 41823
console.log("lease ", lease.leaseId);
console.log("token ", lease.leaseToken); // KEEP THIS — needed to release
console.log("funded ", lease.fundedUntil); // when credit runs out at this rate
node rent.mjs
# ssh root@bore.pub -p 41823
No key yet? ssh-keygen -t ed25519 first. Omitting sshPubKey falls back to password auth, but
the password is the session address — so without signing in there is nothing to log in with. Send
the key.
Working, then stopping
leaseToken is the credential for everything after the rent. Both calls below are free, so plain
curl is fine:
LEASE=lease_9k2m
TOKEN=eyJhbGciOi…
# Is it still up, and how long is it funded for?
curl -s "$API/lease/$LEASE" -H "authorization: Bearer $TOKEN" | jq
# Stop the meter. This is when compute is billed — nothing was taken up front.
curl -s -X DELETE "$API/x402/leases/$LEASE" -H "authorization: Bearer $TOKEN" | jq
# { "usedSeconds": 300, "chargedAtomic": "83334", "balance": "916666", … }
Always release. If you walk away the watchdog stops the session at fundedUntil and bills the
time used, so you cannot be charged past your credit — but the sandbox keeps running (and keeps
billing) until then.
Things that bite
- Losing
leaseTokenmeans you cannot release. It is returned once and stored nowhere you can read back. If you lose it, the session runs until credit is exhausted. Print it, save it. - Credit is per address, and the address comes from the settled transaction — never from anything you send. Paying from an exchange withdrawal address credits an account nobody can spend.
- A headless client cannot read its own balance.
GET /walletneeds a session token, which needs a signed login. Without one, credit surfaces two ways:billing.creditAtomicin a successful rent, and the402 insufficient_creditbody when it is too low. 402 insufficient_creditis not "pay more", it is "top up first". It means the gate fee verified but your credit funds less thanMIN_LEASE_SECONDSat that node's rate. Nothing settled.409 node_busycan happen after you paid nothing. The node is not held while you are away paying, so a retry may find it taken. Nothing settled — pick another node.
Client recipes
Node — the whole flow, no sign-in
import { wrapFetchWithPayment, x402Client } from "@x402/fetch";
import { ExactAvmScheme } from "@x402/avm/exact/client";
import algosdk from "algosdk";
const sk = new Uint8Array(Buffer.from(process.env.AVM_PRIVATE_KEY!, "base64"));
const address = algosdk.encodeAddress(sk.slice(32));
// Sign only the transactions asked for — the fee payer's is left for the facilitator.
const signer = {
address,
async signTransactions(txns: Uint8Array[], indexes?: number[]) {
const wanted = indexes ?? txns.map((_, i) => i);
return txns.map((b, i) =>
wanted.includes(i) ? algosdk.decodeUnsignedTransaction(b).signTxn(sk) : null,
);
},
};
const { network } = await (await fetch(`${API}/platform`)).json();
const client = new x402Client().register(network, new ExactAvmScheme(signer));
const pay = wrapFetchWithPayment(fetch, client);
// Every 402 below is answered automatically.
const top = await (await pay(`${API}/topup?amount=5000000`, { method: "POST" })).json();
const lease = await (await pay(`${API}/x402/rent?nodeId=${nodeId}`, { method: "POST" })).json();
// lease.fundedUntil — when credit runs out at this rate. Top up and it moves.
const run = await (
await pay(`${API}/x402/run`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${lease.leaseToken}` },
body: JSON.stringify({ payload: "print(1+1)" }),
})
).json();
await pay(`${API}/x402/leases/${lease.leaseId}`, {
method: "DELETE",
headers: { authorization: `Bearer ${lease.leaseToken}` },
});
Browser — same code, different signer
@txnlab/use-wallet's signTransactions already matches the signer interface, so only that line
changes:
const { activeAddress, signTransactions } = useWallet();
const client = new x402Client().register(
network,
new ExactAvmScheme({ address: activeAddress!, signTransactions }),
);
const pay = wrapFetchWithPayment(fetch, client);
Wallet approvals, end to end:
| Action | Popups |
|---|---|
| Top up | 1 |
| Rent (opens the meter) | 1 |
| Each job execution | 1 |
| Release, SSH, lease status | 0 |
Testnet setup
- Fund an account with testnet ALGO from the dispenser — needed only to opt in, not to pay.
- Opt that address into ASA
10458941. - Get testnet USDC from the asset dispenser.
- From there on the facilitator covers fees, so the account can run to zero ALGO and still pay.