Pay only for what was delivered.
Gage is a verifier for x402 on Solana. The tool's 402 points the payment at a Gage vault instead of the tool. Gage holds the money, lets the tool answer, runs five mechanical checks on the response, then releases the hold to the tool or refunds the agent. Two headers on the agent side, one middleware on the tool side, and every verdict is recomputable by anyone.
…
None. Payments are signed by the agent; the verifier's own key signs settlements.
JSON bodies, JSON responses, CORS open. x402 headers carry base64-encoded JSON.
Check that the verifier you are talking to is up:
curl /infoQuickstart
Two SDKs, one line each. They live in the repository as @gage/agent and @gage/provider (npm release pending), and everything they do is plain HTTP, documented below, so any language works without them.
Pay for a tool (agent side)
Wrap fetch. When a tool answers 402 with Gage terms, the wrapper signs the payment, attaches what it expects back, retries, and reads the verdict from the response.
import { wrapFetchWithGage } from "@gage/agent";
// signer: any ed25519 key, 64-byte Solana layout (seed || pubkey)
const f = wrapFetchWithGage(fetch, {
signer: { secretKey },
maxAmount: "10000", // refuse any quote above 0.01 USDC (atomic units)
schema: searchSchema, // what a valid answer looks like (JSON schema)
rpcUrl: "https://api.devnet.solana.com", // only for chain modes
});
const res = await f("https://tool.example/v1/search?q=solana");
const body = await res.json();
// PAYMENT-RESPONSE on `res` carries the verdict: released to the tool, or refunded to you.# 1. call the tool, get a 402 with Gage terms
GET /v1/search?q=solana
→ 402, header PAYMENT-REQUIRED: base64({ x402Version: 2, accepts: [PaymentRequirements] })
accepts[0].payTo is the Gage vault, accepts[0].extra.gage.verifier is the verifier URL
# 2. retry with two headers
GET /v1/search?q=solana
PAYMENT-SIGNATURE: base64(PaymentPayload) # the signed payment to payTo, see Objects
GAGE-EXPECT: base64(Expectation) # requestHash, quotedAmount, schema, nonce
# 3. read the answer and the verdict
→ 200, body, header PAYMENT-RESPONSE: base64({ success, holdId, verdict })Get paid (tool side)
One middleware in front of the paid route. It answers 402 with the vault as payTo, opens the hold when a payment arrives, runs your handler, reports the delivery, and attaches the verdict.
import { gagePaid } from "@gage/provider";
app.use("/v1/search", gagePaid({
verifier: "",
providerId: PROVIDER_PUBKEY, // your identity in the registry
amount: "10000", // 0.01 USDC per call
schema: searchSchema, // the shape you commit to
}));
app.get("/v1/search", c => c.json({ query: c.req.query("q"), results: [...] }));# once: read the vault and network you will quote
GET /info
# no PAYMENT-SIGNATURE on the request → answer 402
PAYMENT-REQUIRED: base64({ x402Version: 2, accepts: [{ scheme: "exact", network, asset, amount, payTo: info.payTo,
maxTimeoutSeconds: 60, resource, extra: { gage: { verifier, providerId, schemaHash } } }] })
# PAYMENT-SIGNATURE + GAGE-EXPECT present → open the hold
POST /settle { paymentPayload, paymentRequirements, expectation }
→ { success: true, holdId, payer }
# run your handler, then report exactly what you sent
POST /holds/{holdId}/deliver
{ status: 200, contentType: "application/json", body: "<response text>", requestHash, priceCharged: "10000" }
→ { verdict } # put base64({ success: true, holdId, verdict }) in PAYMENT-RESPONSE/deliver, same. The agent is never charged for an answer that was not checked.The flow
x402 v2, unchanged, with one thing moved: where the settlement lands. Gage sits between the tool and the agent's money.
- Quote. The agent calls the tool. The tool answers
402withPAYMENT-REQUIRED: price, network, andpayToset to the Gage vault.extra.gagenames the verifier, the provider and the schema hash the tool commits to. - Pay with an expectation. The agent signs a payment to the vault and retries with
PAYMENT-SIGNATUREandGAGE-EXPECT: the hash of the request it is sending, the quote it accepted, the schema it wants. - Hold. The tool forwards both to
POST /settle. The verifier collects the payment into the vault and opens a hold with a timeout. Nobody can spend it yet. - Deliver. The tool runs its handler and reports the response to
POST /holds/:id/deliver: status, body, the request hash it processed, the price it charged. - Verdict. Five checks, in order, stop at the first failure. Pass: released to the provider minus the fee. Fail: refunded to the agent in full. No report before the timeout: refunded, even if the verifier is down.
Headers
All four carry base64(JSON). Decode, parse, done.
| Header | Direction | Carries |
|---|---|---|
| PAYMENT-REQUIRED | tool → agent, on the 402 | { x402Version: 2, accepts: PaymentRequirements[], error? } |
| PAYMENT-SIGNATURE | agent → tool, on the retry | PaymentPayload: the signed payment to the vault |
| GAGE-EXPECT | agent → tool, on the retry | Expectation: requestHash, quotedAmount, schema or schemaHash, nonce |
| PAYMENT-RESPONSE | tool → agent, on the answer | { success, network, holdId, verdict?, errorReason? } |
CORS exposes PAYMENT-REQUIRED and PAYMENT-RESPONSE, so a browser agent can read them.
Endpoints
Base URL . The first three are shaped like an x402 facilitator (/supported, /verify, /settle); the rest are Gage's holds, registry and counters. Buttons run live against this verifier.
Mode, network, asset and decimals, the vault address to quote as payTo, fee in basis points and the minimum fee. In anchor mode, the program id and its Config PDA.
The payment kinds this verifier settles: { kinds: [{ x402Version: 2, scheme: "exact", network, extra: { gage: true } }] }.
Validate a payment against requirements without moving funds. Body { paymentPayload, paymentRequirements }. Returns { isValid: true, payer } or { isValid: false, invalidReason }.
Collect the payment into the vault and open a hold. Called by the tool with what the agent sent.
POST /settle
{
"paymentPayload": PaymentPayload, // decoded PAYMENT-SIGNATURE
"paymentRequirements": PaymentRequirements, // the terms from your 402
"expectation": Expectation // decoded GAGE-EXPECT
}
200 { "success": true, "holdId": "42", "payer": "<agent pubkey>", "transaction": "<sig, chain modes>", "network": "..." }
402 { "success": false, "errorReason": "payTo must be the Gage vault" | "missing expectation" | "payment expired" | "insufficient balance" | ..., "network": "..." }The hold expires after paymentRequirements.maxTimeoutSeconds. An expired hold refunds the agent automatically.
Report what you sent the agent. The verifier runs the five checks and settles the hold in the same call.
POST /holds/42/deliver
{
"status": 200,
"contentType": "application/json",
"body": "{\"query\":\"solana\",\"results\":[{\"title\":\"…\",\"url\":\"https://…\"}]}",
"requestHash": "<sha256, see Hashes>", // of the request you actually processed
"priceCharged": "10000" // what you bill, atomic units
}
200 { "verdict": Verdict }
409 { "error": "hold is refunded" | "hold is expired" } // already settled, nothing to do
404 { "error": "unknown hold" }Recent holds, newest first: agent, provider, amount, quoted amount, request and schema hashes, resource, state (open · released · refunded · expired), timestamps, and the verdict once there is one.
One hold with its verdict. 404 when unknown.
Every provider the verifier has settled for: calls, passes, refunds, score (passes over calls), stake, slashed amount. The score is the tool's track record, computed from verdicts, not declared.
One provider. In anchor mode, adds onchain: the Provider PDA (stake, calls, passes, refunds, slashed) read from the program.
Body { amount }. On chain, staking goes through the program's register_provider instruction instead; the endpoint answers 400.
Counters: open, released, refunded, expired, total, providers, mode.
The event log, newest first: hold.opened, hold.released, hold.refunded, hold.expired, each with its hold id and data (amount, failed check, fee, transaction).
Balance of a public key in the asset, atomic units. On the local ledger that is the ledger; on chain, the token account.
Body { owner, amount? }. Credits a key on the local ledger so it can pay. Absent in chain modes.
Runs one scenario end to end with the verifier's own agent against the breakable tool and returns the whole trace. Body { outcome }, one of deliver · empty · schema · price · dup · drift. This is what the console calls.
curl -X POST /demo/run -H "content-type: application/json" -d '{"outcome":"price"}'
{ "outcome": "price",
"steps": [ { "t": 0.5, "label": "request sent", "detail": "GET /v1/search?q=…" }, … ],
"requirements": PaymentRequirements, "payment": PaymentPayload,
"verdict": { "pass": false, "failed": "price", "checks": [ … ], "action": "refunded", … },
"response": { "status": 200, … }, "totalMs": 31 }Objects
PaymentRequirements
One entry of accepts in the 402. Standard x402 v2 fields; Gage adds extra.gage.
| Field | Meaning |
|---|---|
| scheme | "exact" |
| network | solana:gage-local on the ledger, a CAIP-2 Solana id on chain |
| asset | token mint (or USDC-local) |
| amount | atomic units, decimal string. "10000" is 0.01 with 6 decimals |
| payTo | the vault from /info. Never the provider |
| maxTimeoutSeconds | the hold's lifetime |
| resource | URL of the paid resource, without query |
| extra.gage | { verifier, providerId, schemaHash?, program?, config? } |
PaymentPayload
{ x402Version: 2, scheme: "exact", network, payload }. On the local ledger, payload is { kind: "local", from, to, asset, amount, nonce, validUntil, signature }: an ed25519 signature by from over the canonical JSON of the other fields. On chain, { kind: "solana", transaction }: a fully signed transaction, base64, either a transferChecked into the vault's token account (custodial mode) or the program's open_hold (anchor mode). signPayment in @gage/agent builds all three.
Expectation
| Field | Meaning |
|---|---|
| requestHash | sha256 of the request the agent sends, see Hashes. The drift check compares it with what the provider processed |
| quotedAmount | the amount from the 402 the agent accepted. The price check compares it with priceCharged |
| schema / schemaHash | the JSON schema the response must satisfy. When the tool committed to a schemaHash in its 402, both must agree |
| nonce | random, per call |
Delivery
{ status, contentType?, body, requestHash, priceCharged }: the provider's report. body is the response as text, exactly what went to the agent.
Verdict
| Field | Meaning |
|---|---|
| holdId | the hold |
| pass | all five passed |
| failed | the first failing check, when pass is false |
| checks[] | { name, ok, ms, detail?, skipped? } in order; checks after a failure are skipped |
| action | "released" or "refunded" |
| amount · fee | atomic units. Fee is 1%, floor 0.0005, taken only on release |
| settlement | { mode, signature? }: the on-chain transaction when there is one |
| at · ms | timestamp, and the verifier's own time from delivery to settlement, in ms |
The five checks
In order, stop at the first failure, nothing subjective, nothing stored but hashes. Anyone with the same inputs recomputes the same verdict.
| Check | Passes when | Typical failure |
|---|---|---|
| 1 delivered | status 2xx, non-empty body, valid non-empty JSON when the content type says JSON | 500, empty 200, {}, an HTML error page |
| 2 schema | the body validates against the schema the agent committed to in GAGE-EXPECT (and the provider in its 402) | right status, wrong shape; results: "none" |
| 3 price | priceCharged ≤ quotedAmount | billed above the quote |
| 4 duplicate | this body's hash was never delivered by this provider for a different request | a cached answer replayed for new queries |
| 5 drift | the provider's requestHash equals the agent's | another request processed, parameters changed |
Hashes
All sha256, hex. Compute them yourself to recompute any verdict.
requestHash = sha256( METHOD + "\n" + path + search + "\n" + sha256(body) )
// METHOD upper-case, path and query from the URL, body "" for GET
bodyHash = sha256( canonicalJSON(body) ) // JSON bodies: keys sorted, no whitespace, so re-serialising is not a new answer
= sha256( body ) // anything else
schemaHash = sha256( canonicalJSON(schema) )Sandbox
This deployment ships a breakable tool: a paid search endpoint that misbehaves on demand. Point your agent at it to see every verdict before you touch a real tool.
GET /v1/search?q=solana // 402 with Gage terms, then a proper answer
GET /v1/search?q=solana&outcome=empty // empty · schema · price · dup · driftThe console runs the same scenarios with the verifier's own agent and shows every hold, check and event as they happen.
Settlement modes
/info tells you which one a verifier runs. The HTTP surface is the same; what changes is the payment the agent signs and where the money sits.
| mode | Vault | The agent signs | Settlement |
|---|---|---|---|
| local | a ledger inside the verifier | { kind: "local" }, ed25519 over the payload | ledger entries. Real holds and verdicts, no chain. The default of this deployment |
| solana | a keypair the verifier holds (custodial) | a transferChecked into the vault's token account | the verifier submits it, then pays out with the vault key. One transaction per release or refund |
| anchor | a PDA of the Gage program | the program's open_hold: amount, provider, request and schema hashes, ttl, on chain | the verifier key can only release or refund; past the ttl, expire is permissionless. extra.gage.program and config carry the ids |
Errors
| Status | Where | Meaning |
|---|---|---|
| 402 | /settle, and the tool | payment not accepted: errorReason says why (payTo is not the vault, wrong network or asset, bad signature, expired payment, insufficient balance, missing expectation). The tool relays it in a fresh 402 |
| 400 | /demo/run, /registry/:id/stake | bad outcome; stake requested on a chain mode |
| 404 | /holds/:id, /registry/:id | unknown id |
| 409 | /holds/:id/deliver | the hold is no longer open (expired or already settled) |
| 500 | any | { error }, the verifier's own failure |
| 502 | /demo/run | the breakable tool could not be reached |