Skip to content

Solana mainnet — payments use real USDC, go straight to the seller and are final. Unaudited software; use small amounts.

Nanotoll

Protocol

Solana mainnet · real USDC

For agent and client developers. A Nanotoll endpoint is an HTTP URL. It responds 402 until it sees a valid payment proof, then forwards your request to the seller's origin. The 402 body is shaped like an x402 v1 exact requirement, but settlement is verified directly on Solana rather than through a facilitator, so generic x402 clients may not work without the memo step below.

Flow

  1. Send your request (GET or POST). Without X-PAYMENT you receive 402 with JSON.
  2. Send one Solana transaction that transfers maxAmountRequired USDC to the payee's associated token account and includes the memo ntoll:v1:<eid>:<n>. Wait for confirmed.
  3. Repeat the identical request with X-PAYMENT. You receive the origin's response.

The 402 body

402 Payment Required
{
  "x402Version": 1,
  "error": "X-PAYMENT header is required",
  "code": "payment_required",
  "accepts": [
    {
      "scheme": "exact",
      "network": "solana",
      "asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
      "payTo": "<seller wallet (owner, not token account)>",
      "maxAmountRequired": "10000",
      "resource": "https://<your-deployment>/p/<token>",
      "description": "Paid call to Nanotoll endpoint <eid> (0.01 USDC)",
      "mimeType": "*/*",
      "maxTimeoutSeconds": 120,
      "extra": {
        "challenge": "<base64url(payload)>.<base64url(hmac-sha256)>",
        "memoPrefix": "ntoll:v1:",
        "memoProgram": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",
        "decimals": 6
      }
    }
  ]
}

This deployment runs on Solana mainnet (network: "solana"). USDC mint: EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v. Payments are real USDC and are final. maxAmountRequired is in base units with 6 decimals (10000 = 0.01 USDC), and payTo is the seller's wallet (the owner, not its token account). Prices range from 0.001 to 100 USDC.

Challenge

extra.challenge is base64url(payload) + "." + base64url(HMAC-SHA256(payload)). The payload is readable JSON; you need eid and n from it to build the memo. Do not modify it.

challenge payload
{
  "eid":   "<endpoint id>",
  "amt":   "10000", // price in base units
  "payTo": "<seller wallet>",
  "mint":  "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
  "exp":   1760000000, // unix seconds, issued + 120
  "n":     "<16 random bytes, base64url>",
  "rh":    "<request hash>"
}

rh is sha256(METHOD + "\n" + subPath + "\n" + query + "\n" + hex(sha256(body))). You don't compute it; it binds the proof to the exact request, so the paid retry must use the same method, sub-path, query string and body.

Paying

One transaction with three instructions, signed by the buyer:

  1. Associated Token Account program CreateIdempotent for the payee's USDC account (buyer pays rent if it doesn't exist).
  2. SPL Token TransferChecked of maxAmountRequired base units, 6 decimals, from the buyer's USDC account to the payee's.
  3. Memo program (MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr) with the UTF-8 string ntoll:v1:<eid>:<n>.

The server fetches the transaction with getTransaction (jsonParsed, confirmed), polls for up to about 10s, and requires: no error, the exact memo, and a USDC balance increase of at least the price for the payee (from pre/post token balances).

X-PAYMENT header

X-PAYMENT
// X-PAYMENT = base64( JSON )
{
  "challenge": "<accepts[0].extra.challenge>",
  "signature": "<base58 transaction signature>"
}

Paid response and receipt

The origin's status, Content-Type, Content-Disposition and body are streamed back, plus:

  • X-Nanotoll-Receipt: the payment transaction signature.
  • X-Nanotoll-Price: price in USDC base units.

Origin 4xx/5xx responses are passed through with the receipt. If the origin is unreachable after payment, you get 502 JSON { error, code, receipt }. There are no refunds.

Error codes

Errors are JSON { error, code }. Payment failures return 402 with a fresh accepts array so you can pay again.

  • 402payment_requiredNo X-PAYMENT header. Body contains payment requirements.
  • 402invalid_payment_headerX-PAYMENT is not base64 JSON {challenge, signature}.
  • 402invalid_challengeChallenge HMAC does not verify (altered or from another deployment).
  • 402challenge_mismatchChallenge was issued for another endpoint or different terms.
  • 402challenge_expiredMore than 120s since the challenge was issued.
  • 402request_mismatchMethod, sub-path, query or body differs from the request the challenge was issued for.
  • 402already_usedThis signature was already accepted by this server instance.
  • 402tx_not_foundTransaction not visible at 'confirmed' within ~10s. Safe to retry with the same proof (up to 5 attempts per challenge).
  • 402tx_failedTransaction failed on-chain (meta.err is set).
  • 402memo_missingNo memo starting with ntoll:v1:.
  • 402memo_mismatchA Nanotoll memo exists but does not equal the expected eid and nonce.
  • 402wrong_mintThe payee received a token other than USDC.
  • 402wrong_payeeUSDC went to a wallet other than the endpoint's payee.
  • 402amount_too_lowThe payee's USDC balance increased by less than the price.
  • 402rpc_errorThe server could not reach Solana RPC. Retry with the same proof.
  • 400invalid_tokenThe endpoint token is malformed or was altered.
  • 400wrong_networkThe token was created for a different Solana network than this deployment.
  • 400invalid_pathThe pass-through sub-path has '.' or '..' segments (raw or encoded), encoded slashes or backslashes.
  • 400origin_forbiddenThe origin resolves to a private, loopback, link-local or metadata address.
  • 410endpoint_blockedThe operator disabled this endpoint.
  • 410origin_blockedThe operator blocked the origin host.
  • 413body_too_largeRequest body is over 1 MB.
  • 429rate_limitedToo many 402 challenges (120/min per IP), payment verifications (30/min per IP) or creates (10/min per IP).
  • 429too_many_attemptsThis challenge was already tried 5 times. Retries with the same proof count too, so wait for 'confirmed' before retrying.
  • 502origin_unreachablePayment verified but the origin failed. Response includes receipt.
  • 502origin_timeoutPayment verified but the origin did not respond within 10s. Includes receipt.
  • 502too_many_redirectsOrigin redirected more than 3 times. Includes receipt.
  • 500server_misconfiguredNANOTOLL_SECRET is missing or shorter than 32 bytes.

Replay limits

Nanotoll has no database. A payment proof is bound to the challenge nonce, the endpoint and the request hash, and the challenge expires after 120s. Each warm server instance also remembers signatures it has accepted and answers already_used on reuse. Within the 120s window, an identical request with the same proof may be served again by a different instance. Strict one-payment-one-call is not guaranteed.

Forwarding rules

  • Methods: GET and POST. Request body up to 1 MB.
  • If the seller enabled pass-through, /p/<token>/a/b?x=1 calls <origin>/a/b?x=1; otherwise sub-paths and queries are not forwarded. Sub-paths cannot climb above the seller's base path: ./.. segments (including %2e%2e) and encoded slashes return 400 invalid_path.
  • Stripped from your request: hop-by-hop headers, Cookie, Authorization, X-PAYMENT, X-Forwarded-*. The seller's optional origin header is added.
  • Origins must be public http(s) on port 80 or 443. Redirects are followed up to 3 times, each re-checked. 10s timeout.

curl

curl
# 1) Unpaid request: returns HTTP 402 with payment requirements
curl -i "https://<your-deployment>/p/<token>"

# 2) Pay on-chain (see the TypeScript client), then retry with the proof.
#    CHALLENGE = accepts[0].extra.challenge from step 1
#    SIG       = your payment transaction signature
PROOF=$(printf '{"challenge":"%s","signature":"%s"}' "$CHALLENGE" "$SIG" \
  | base64 | tr -d '\n')
curl -i "https://<your-deployment>/p/<token>" -H "X-PAYMENT: $PROOF"

TypeScript client

Request, read the 402, pay, retry. Uses @solana/web3.js and @solana/spl-token. Fund the keypair with SOL and USDC on the active network first.

pay.mts
// npm i @solana/web3.js @solana/spl-token
// save as pay.mts, then: KEYPAIR=./id.json npx tsx pay.mts
import { readFileSync } from "node:fs";
import * as web3 from "@solana/web3.js";
import * as spl from "@solana/spl-token";
const { PublicKey, Transaction, TransactionInstruction } = web3;

const ENDPOINT = "https://<your-deployment>/p/<token>";
const conn = new web3.Connection("https://api.mainnet-beta.solana.com", "confirmed");
const secret = JSON.parse(readFileSync(process.env.KEYPAIR ?? "id.json", "utf8"));
const payer = web3.Keypair.fromSecretKey(Uint8Array.from(secret));
const me = payer.publicKey;

const first = await fetch(ENDPOINT); // 1) request
if (first.status !== 402) throw new Error(`Expected 402, got ${first.status}`);
const req = (await first.json()).accepts[0]; // 2) payment requirements
const { challenge, decimals, memoPrefix, memoProgram } = req.extra;
const body = Buffer.from(challenge.split(".")[0], "base64url").toString();
const { eid, n } = JSON.parse(body);

const mint = new PublicKey(req.asset);
const payTo = new PublicKey(req.payTo);
const payeeAta = spl.getAssociatedTokenAddressSync(mint, payTo);
const myAta = spl.getAssociatedTokenAddressSync(mint, me);
const amount = BigInt(req.maxAmountRequired);
const memo = Buffer.from(`${memoPrefix}${eid}:${n}`);

const tx = new Transaction().add( // 3) pay: payee ATA, USDC transfer, memo
  spl.createAssociatedTokenAccountIdempotentInstruction(me, payeeAta, payTo, mint),
  spl.createTransferCheckedInstruction(myAta, mint, payeeAta, me, amount, decimals),
  new TransactionInstruction({ programId: new PublicKey(memoProgram), keys: [], data: memo }),
);
const signature = await web3.sendAndConfirmTransaction(conn, tx, [payer], {
  commitment: "confirmed",
});

const proof = Buffer.from(JSON.stringify({ challenge, signature })).toString("base64");
const paid = await fetch(ENDPOINT, { headers: { "X-PAYMENT": proof } }); // 4) retry
console.log(paid.status, "receipt:", paid.headers.get("x-nanotoll-receipt"));
console.log(await paid.text());

Prefer a browser? Use the try-it page.