Gage API
Checking the verifier…
Gage API · v0.1

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.

Base URL

…

Auth

None. Payments are signed by the agent; the verifier's own key signs settlements.

Format

JSON bodies, JSON responses, CORS open. x402 headers carry base64-encoded JSON.

Check that the verifier you are talking to is up:

curl /info
response

Quickstart

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.

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: [...] }));
Money follows the verdict, not your code. If your handler throws, the hold expires and refunds. If you never call /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.

  1. Quote. The agent calls the tool. The tool answers 402 with PAYMENT-REQUIRED: price, network, and payTo set to the Gage vault. extra.gage names the verifier, the provider and the schema hash the tool commits to.
  2. Pay with an expectation. The agent signs a payment to the vault and retries with PAYMENT-SIGNATURE and GAGE-EXPECT: the hash of the request it is sending, the quote it accepted, the schema it wants.
  3. 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.
  4. 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.
  5. 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.

HeaderDirectionCarries
PAYMENT-REQUIREDtool → agent, on the 402{ x402Version: 2, accepts: PaymentRequirements[], error? }
PAYMENT-SIGNATUREagent → tool, on the retryPaymentPayload: the signed payment to the vault
GAGE-EXPECTagent → tool, on the retryExpectation: requestHash, quotedAmount, schema or schemaHash, nonce
PAYMENT-RESPONSEtool → 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.

GET/infodiscovery

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.

response
GET/supportedx402 facilitator

The payment kinds this verifier settles: { kinds: [{ x402Version: 2, scheme: "exact", network, extra: { gage: true } }] }.

response
POST/verifyx402 facilitator

Validate a payment against requirements without moving funds. Body { paymentPayload, paymentRequirements }. Returns { isValid: true, payer } or { isValid: false, invalidReason }.

POST/settleopens a hold

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.

POST/holds/:id/deliverruns the checks

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" }
GET/holds?limit=50

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.

response
GET/holds/:id

One hold with its verdict. 404 when unknown.

response
GET/registryproviders

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.

response
GET/registry/:id

One provider. In anchor mode, adds onchain: the Provider PDA (stake, calls, passes, refunds, slashed) read from the program.

POST/registry/:id/stakelocal ledger only

Body { amount }. On chain, staking goes through the program's register_provider instruction instead; the endpoint answers 400.

GET/stats

Counters: open, released, refunded, expired, total, providers, mode.

response
GET/events?limit=50

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).

response
GET/balance/:owner

Balance of a public key in the asset, atomic units. On the local ledger that is the ledger; on chain, the token account.

POST/faucetlocal ledger only

Body { owner, amount? }. Credits a key on the local ledger so it can pay. Absent in chain modes.

POST/demo/runsandbox

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 }
response

Objects

PaymentRequirements

One entry of accepts in the 402. Standard x402 v2 fields; Gage adds extra.gage.

FieldMeaning
scheme"exact"
networksolana:gage-local on the ledger, a CAIP-2 Solana id on chain
assettoken mint (or USDC-local)
amountatomic units, decimal string. "10000" is 0.01 with 6 decimals
payTothe vault from /info. Never the provider
maxTimeoutSecondsthe hold's lifetime
resourceURL 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

FieldMeaning
requestHashsha256 of the request the agent sends, see Hashes. The drift check compares it with what the provider processed
quotedAmountthe amount from the 402 the agent accepted. The price check compares it with priceCharged
schema / schemaHashthe JSON schema the response must satisfy. When the tool committed to a schemaHash in its 402, both must agree
noncerandom, 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

FieldMeaning
holdIdthe hold
passall five passed
failedthe 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 · feeatomic units. Fee is 1%, floor 0.0005, taken only on release
settlement{ mode, signature? }: the on-chain transaction when there is one
at · mstimestamp, 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.

CheckPasses whenTypical failure
1 deliveredstatus 2xx, non-empty body, valid non-empty JSON when the content type says JSON500, empty 200, {}, an HTML error page
2 schemathe 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 pricepriceCharged ≤ quotedAmountbilled above the quote
4 duplicatethis body's hash was never delivered by this provider for a different requesta cached answer replayed for new queries
5 driftthe provider's requestHash equals the agent'sanother 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 · drift

The 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.

modeVaultThe agent signsSettlement
locala ledger inside the verifier{ kind: "local" }, ed25519 over the payloadledger entries. Real holds and verdicts, no chain. The default of this deployment
solanaa keypair the verifier holds (custodial)a transferChecked into the vault's token accountthe verifier submits it, then pays out with the vault key. One transaction per release or refund
anchora PDA of the Gage programthe program's open_hold: amount, provider, request and schema hashes, ttl, on chainthe verifier key can only release or refund; past the ttl, expire is permissionless. extra.gage.program and config carry the ids

Errors

StatusWhereMeaning
402/settle, and the toolpayment 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/stakebad outcome; stake requested on a chain mode
404/holds/:id, /registry/:idunknown id
409/holds/:id/deliverthe hold is no longer open (expired or already settled)
500any{ error }, the verifier's own failure
502/demo/runthe breakable tool could not be reached