discover → 402 → pay → retry → result
Current payment scope: InvokePort first-party tools only. Third-party HTTP, MCP, contract and AI execution remains closed even if a listing is approved.
Free tools and local MCP setupThe catalog, provider dashboard, API keys, encrypted credentials and signed SDK sync are available. Mainnet settlement requires a separate launch switch. PayAI verifies the authorization; the gateway then confirms the ERC-20 transfer independently through Base RPC before delivery. A paid upstream failure is not automatically refunded.
The whole protocol surface, end to end. Examples target the configured gateway on Base. There is no sandbox mode, no mock facilitator, and no bypass header — if the payment does not settle, the call does not succeed.
Embed your app
Create a Developer App in the provider dashboard, store its one-time project key in your server secret manager, and wrap each route you want to sell. Signed sync creates or updates the marketplace capability; the wrapper refuses direct-origin calls.
import { createInvokePort } from "invokeport-server-sdk";
const gate = createInvokePort({
appId: process.env.INVOKEPORT_APP_ID!,
projectSecret: process.env.INVOKEPORT_PROJECT_SECRET!,
controlPlaneUrl: process.env.INVOKEPORT_ORIGIN!,
});
export const POST = gate.protect({
key: "summarize",
name: "Summarize text",
path: "/api/summarize",
method: "POST",
priceAtomic: "20000", // 0.02 USDC
payoutAddress: "0x…your verified 90/10 PullSplit contract…",
inputSchema: { type: "object", properties: { text: { type: "string" } } },
}, async (request) => {
const { text } = await request.json();
return Response.json({ summary: await summarize(text) });
});
// Run during deploy/startup. The signed definition becomes a marketplace capability.
await gate.sync();payoutAddress. Each real USDC payment is priced above your seller price; 90% of the gross is allocated to you and 10% to InvokePort. Creation, distribution and withdrawal require Base ETH for gas. Funds stay in the split or Warehouse until you distribute and withdraw them. See the Splits withdrawal guide.Discover
GET /api/catalog returns a stable, versioned JSON document with server-side text search, kind/tag filters and bounded pages. Its capabilities array contains concise summaries with a callable resourceUri and schema-presence flags; full schemas and example input are returned only by GET /api/catalog/:service/:route. services is a legacy compatibility array and is empty for new registrations. No HTML scraping and no hardcoded URLs. The provider's destination — upstream URL, MCP server URL, RPC origin — is deliberately absent from both.
GET /api/catalog?q=status&kind=HTTP&tag=monitoring&page=1&limit=12
{
"schemaVersion": 2,
"mode": "real-payments",
"filters": { "q": "status", "kind": "HTTP", "tag": "monitoring", "sort": "name" },
"pagination": { "page": 1, "limit": 12, "total": 1, "totalPages": 1,
"hasPrevious": false, "hasNext": false },
"services": [],
"capabilities": [{
"kind": "HTTP",
"serviceRoutingKey": "example",
"routingKey": "status",
"resourceUri": "https://your-domain.example/api/gateway/example/status",
"tags": ["monitoring"],
"invocation": { "kind": "HTTP", "mode": "passthrough", "method": "GET" },
"hasInputSchema": false,
"hasOutputSchema": true,
"hasDemoInput": false,
"asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"assetDecimals": 6,
"network": "eip155:8453"
}]
}
GET /api/catalog/example/status
// One selected capability, now including inputSchema, outputSchema and demoInput.Abridged. Search is literal text matching over bounded public metadata; no embeddings or universal task-understanding claim is involved.
The four kinds
A service is one of four kinds and owns many capabilities. A capability is the priced, invocable unit. Its routingKey is half of its payment identity: immutable, never reused, and the only thing the resource URI is built from. A display slug is a marketplace label and is never routed.
- HTTP API
Passthrough forwards your request unchanged. Templated maps declared parameters onto a fixed request and refuses anything undeclared before a 402 is issued.
- MCP Server
One capability is one tools/call over streamable HTTP. POST the tool arguments as a JSON object; they are validated against the discovered schema pre-payment.
- Smart Contract
A single eth_call against a view/pure function on a chain the operator allowlisted. InvokePort never sends a transaction, never requests a signature or approval, and has no write path. Results carry the block number they were read at and are explicitly not final — they may be reorganised away.
- Automated Task
Automated tasks use one request and one response against declared input and output schemas. Streaming, polling and callback jobs are refused at registration. Provider output is untrusted third-party content — validate it before acting on it.
The 402
An unpaid request to an enabled, priced route returns HTTP 402 with a machine-readable requirement in the body and, base64-encoded, in the PAYMENT-REQUIRED header. A disabled route does not return 402 at all — it is refused outright and never reaches upstream.
HTTP/1.1 402 Payment Required
content-type: application/json; charset=utf-8
PAYMENT-REQUIRED: <base64 of the full payment-required document>
invokeport-request-id: 0f5c…
{
"error": "payment_required",
"accepts": [
{
"scheme": "exact",
"network": "eip155:8453",
"amount": "11112",
"asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"payTo": "0x…verified immutable split contract…",
"maxTimeoutSeconds": 120
}
]
}Exact means exact. Underpayment and overpayment are both refused — there is no refund path to unwind an overpayment.
The payment names the resource it buys. An authorization signed for a cheap route cannot be spent on an equally priced expensive one.
A consumed payment nonce cannot buy a second call. Replays are rejected before the facilitator is contacted.
Pay and retry
Sign an EIP-3009 TransferWithAuthorization for the quoted amount against real USDC, base64 the x402 v2 payload into PAYMENT-SIGNATURE, and resend the identical request. The gateway verifies with the facilitator before touching upstream; if verification fails, times out, or is ambiguous, upstream is never called.
import { createWalletClient, http } from "viem";
import { randomBytes } from "node:crypto";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";
const GATEWAY = process.env.INVOKEPORT_ORIGIN ?? "http://localhost:3000";
const MAX_PRICE_ATOMIC = 20_000n; // budget policy, atomic USDC
// 1. DISCOVER — pick a service from the machine-readable catalog, never a hardcoded URL.
const catalog = await (await fetch(`${GATEWAY}/api/catalog`)).json();
const service = catalog.capabilities
.filter((s) => s.kind === "HTTP" && s.invocation.method === "GET" && s.network === "eip155:8453")
.find((s) => BigInt(s.priceAtomic) <= MAX_PRICE_ATOMIC);
if (!service) throw new Error("no service within budget");
// 2. UNPAID CALL — expect a real 402, not an error to retry blindly.
const unpaid = await fetch(service.resourceUri, { method: "GET" });
if (unpaid.status !== 402) throw new Error(`expected 402, got ${unpaid.status}`);
// 3. PARSE THE REQUIREMENT — price, asset, network and payTo all come from the response.
const { accepts } = await unpaid.json();
const requirement = accepts[0];
if (BigInt(requirement.amount) > MAX_PRICE_ATOMIC) {
console.log("over budget, refusing"); process.exit(0);
}
if (requirement.network !== "eip155:8453" || requirement.asset.toLowerCase() !== "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" || requirement.amount !== service.priceAtomic || requirement.payTo.toLowerCase() !== service.payoutAddress.toLowerCase()) throw new Error("quote mismatch");
// 4. PAY — sign an EIP-3009 transfer authorization with the agent's own key.
const account = privateKeyToAccount(process.env.AGENT_PRIVATE_KEY as `0x${string}`);
const wallet = createWalletClient({ account, chain: base, transport: http() });
const now = Math.floor(Date.now() / 1000);
const authorization = {
from: account.address.toLowerCase(),
to: requirement.payTo.toLowerCase(),
value: requirement.amount,
validAfter: String(now - 60),
validBefore: String(now + 120),
nonce: `0x${randomBytes(32).toString("hex")}`,
};
const signature = await wallet.signTypedData({
domain: { name: "USDC", version: "2", chainId: 8453, verifyingContract: requirement.asset },
types: { TransferWithAuthorization: [
{ name: "from", type: "address" }, { name: "to", type: "address" },
{ name: "value", type: "uint256" }, { name: "validAfter", type: "uint256" },
{ name: "validBefore", type: "uint256" }, { name: "nonce", type: "bytes32" },
] },
primaryType: "TransferWithAuthorization",
message: {
from: account.address, to: requirement.payTo, value: BigInt(authorization.value),
validAfter: BigInt(authorization.validAfter), validBefore: BigInt(authorization.validBefore),
nonce: authorization.nonce,
},
});
// 5. RETRY — the identical request, now carrying the payment.
const header = Buffer.from(JSON.stringify({
x402Version: 2,
resource: { url: service.resourceUri },
accepted: requirement,
payload: { signature, authorization },
})).toString("base64");
const paid = await fetch(service.resourceUri, {
method: "GET",
headers: { "PAYMENT-SIGNATURE": header },
});
// 6. RESULT — upstream body, plus a settlement reference in PAYMENT-RESPONSE.
console.log(paid.status, await paid.text());
console.log("settlement:", paid.headers.get("PAYMENT-RESPONSE"));Result
You get the upstream status, body and content type, plus a PAYMENT-RESPONSE header carrying the settlement reference. That reference resolves on the Base explorer and appears in the provider's dashboard against the same request record.
The upstream status is preserved and the outcome is recorded distinctly as paid_upstream_error. The gateway has no automatic refund mechanism, and says so rather than pretending otherwise.
Runnable example
examples/agent-client is a complete autonomous client — its own Base key, its own real USDC, no shared secrets. It discovers a service from the catalog, applies a budget policy, pays, retries, and logs a trace of every step.
git clone <this repo> && cd examples/agent-client
cp .env.example .env # AGENT_PRIVATE_KEY, INVOKEPORT_ORIGIN
npm install && npm start
# discovered market-data/spot-price 0.011112 USDC
# 402 amount=… asset=0x833589fcd6edb6e08f4c7c32d4f71b54bda02913 payTo=0x… network=eip155:8453
# policy price <= configured budget -> proceed
# payment nonce=0x… signature=0x…
# retry HTTP 200 settlement=0x…
# result {"pair":"ETH-USD","price":"…"}0x833589fcd6edb6e08f4c7c32d4f71b54bda02913. If the payment cannot settle, the demo fails — by design.Provider API reference
Session endpoints are scoped server-side to the address in the session cookie. A provider cannot read or modify another provider's endpoints, records or revenue.
| Endpoint | Auth | Purpose |
|---|---|---|
| GET /api/catalog | public | Machine-readable listing of enabled routes |
| GET /api/auth/nonce | public | Single-use SIWE nonce plus server-known fields |
| POST /api/auth/verify | public | Exchange a signed SIWE message for a session |
| POST /api/auth/logout | session | Revoke the server-side session record |
| GET / POST /api/providers/me | session | Read or upsert your provider profile |
| GET / POST /api/services | session | List your services, or register one of the four kinds |
| GET / PATCH / DELETE /api/services/:id | session | Read, edit presentation, or retire (never frees a routing key) |
| POST /api/services/:id/capabilities | session | Add a priced capability to a service |
| GET / PATCH / DELETE /api/capabilities/:id | session | Read, reprice, enable/disable, or retire |
| POST /api/services/import/openapi | session | Discovery preview — returns selectable operations |
| POST /api/services/import/mcp | session | Discovery preview — initialize + tools/list |
| POST /api/services/import/abi | session | Parse an ABI; returns view/pure functions and the chain allowlist |
| GET / POST /api/api-keys | session | List keys, or issue one (plaintext returned exactly once) |
| DELETE /api/api-keys/:id | session | Revoke a key |
| GET / POST /api/credentials | session | List masked credentials, or register one (write-only) |
| PUT / DELETE /api/credentials/:id | session | Rotate or revoke; no route can read a value back |
| GET / POST /api/developer-apps | session | List or securely connect an embedded server application |
| POST / DELETE /api/developer-apps/:id | session | Rotate its project key or revoke the app |
| POST /api/integrations/v1/apps/:id/sync | signed project key | Publish SDK definitions into the marketplace |
| GET / POST /api/endpoints | session | v1: list your endpoints, or register one |
| GET / PATCH /api/endpoints/:id | session | v1: read one, change price / payout / enabled |
| GET /api/endpoints/:id/requests | session | Request records with outcome classification |
| GET /api/endpoints/:id/payments | session | Settled payments with explorer references |
| ANY /api/gateway/:service/:route | x402 | The paid proxy itself — what agents call |
| POST /api/tools/openapi-security | public, free preview | Static checks of OpenAPI 3.0/3.1 JSON; maximum 128 KiB |
ivp_test_ keys provide optional caller identity, request attribution and per-key budgets; keyed catalog reads enforce catalog:read. A priced capability still requires x402 payment with or without a key. The separate ivp_project_test_ project key connects a provider's server and is never a buyer credential.