Skip to main content

Examples

Complete, copy-paste-ready examples for building and consuming x402 endpoints.

Handler Examples

Simple JSON API

Return a plain object — Bankr auto-wraps it as a JSON response.

// x402/weather/index.ts
export default async function handler(req: Request) {
const url = new URL(req.url);
const city = url.searchParams.get("city") ?? "New York";

return {
city,
temperature: 72,
conditions: "sunny",
timestamp: new Date().toISOString(),
};
}

External API Proxy

Use a Response object when you need custom status codes or headers.

// x402/crypto-prices/index.ts
export default async function handler(req: Request) {
const url = new URL(req.url);
const symbol = url.searchParams.get("symbol")?.toUpperCase() ?? "BTC";

const apiKey = process.env.COINGECKO_API_KEY;
const res = await fetch(
`https://api.coingecko.com/api/v3/simple/price?ids=${symbol}&vs_currencies=usd`,
{ headers: { "x-cg-demo-api-key": apiKey! } },
);

if (!res.ok) {
return Response.json({ error: "Failed to fetch price" }, { status: 502 });
}

const data = await res.json();
return { symbol, price: data[symbol.toLowerCase()]?.usd };
}

POST Endpoint with Body Parsing

// x402/sentiment/index.ts
export default async function handler(req: Request) {
if (req.method !== "POST") {
return Response.json({ error: "POST required" }, { status: 405 });
}

const body = await req.json();
const text = body.text;

if (!text || typeof text !== "string") {
return Response.json({ error: "text field required" }, { status: 400 });
}

// Call an AI model for analysis
const analysis = await analyzeSentiment(text);

return {
text: text.slice(0, 100),
sentiment: analysis.sentiment,
score: analysis.score,
confidence: analysis.confidence,
};
}

async function analyzeSentiment(text: string) {
const res = await fetch("https://llm.bankr.bot/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BANKR_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "claude-sonnet-4-6",
messages: [
{
role: "system",
content:
"Analyze sentiment. Return a single JSON string that can be passed direclty to JSON.parse(): {sentiment, score, confidence} NO MARKDOWN",
},
{ role: "user", content: text },
],
response_format: { type: "json_object" },
}),
});

const data = await res.json();
return JSON.parse(data.choices[0].message.content);
}

Upto (Usage-Based) Handler

With the upto payment scheme, the caller authorizes a maximum payment but you settle only the actual cost. Set the X-402-Settle-Amount header to report the real cost in atomic USDC (6 decimals).

// x402/batch-process/index.ts
export default async function handler(req: Request): Promise<Response> {
const url = new URL(req.url);

// Variable-cost work — "count" controls how many items to process
const count = Math.min(
parseInt(url.searchParams.get("count") ?? "2", 10),
20,
);

// Per-item cost: 500 atomic USDC per item ($0.0005 each)
const costPerItem = 500;
const actualCost = count * costPerItem;

const items = Array.from({ length: count }, (_, i) => ({
id: i + 1,
result: `Item ${i + 1} processed`,
}));

// X-402-Settle-Amount tells the router the actual cost.
// Must be <= the max price in bankr.x402.json.
return new Response(
JSON.stringify({
items,
billing: {
itemCount: count,
costPerItem: "$0.0005",
totalCost: `$${(actualCost / 1_000_000).toFixed(6)}`,
},
}),
{
headers: {
"Content-Type": "application/json",
"X-402-Settle-Amount": String(actualCost),
},
},
);
}

Config (bankr.x402.json):

{
"services": {
"batch-process": {
"price": "0.01",
"paymentScheme": "upto",
"description": "Process 1-20 items at $0.0005 each",
"methods": ["GET"],
"schema": {
"input": {
"type": "object",
"properties": {
"count": {
"type": "integer",
"description": "Number of items to process (1-20)"
}
}
},
"output": {
"type": "object",
"properties": {
"items": { "type": "array", "description": "Processed items" },
"billing": { "type": "object", "description": "Cost breakdown" }
}
}
}
}
}
}

The caller authorizes up to $0.01, but if they request 5 items they're only charged $0.0025. See Payment Schemes for details.

Using npm Dependencies

Your handler can use any npm package. Add a package.json to your service directory and install packages with bun add. Dependencies are bundled automatically during deployment.

cd x402/my-service
bun add zod @langchain/openai @langchain/langgraph
// x402/my-service/index.ts
import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod";

const InputSchema = z.object({
query: z.string().min(1),
});

export default async function handler(req: Request) {
if (req.method !== "POST") {
return Response.json({ error: "POST required" }, { status: 405 });
}

const body = await req.json();
const input = InputSchema.safeParse(body);
if (!input.success) {
return Response.json({ error: input.error.message }, { status: 400 });
}

const llm = new ChatOpenAI({
model: "gemini-2.5-flash",
configuration: {
baseURL: "https://llm.bankr.bot/v1",
defaultHeaders: { "X-API-Key": process.env.BANKR_LLM_KEY! },
},
apiKey: process.env.BANKR_LLM_KEY!,
});

const result = await llm.invoke(input.data.query);
return { response: result.content };
}
Performance

Handlers have a 30-second execution limit, enforced by the platform and not configurable. For AI-powered endpoints, use fast models like gemini-2.5-flash through the Bankr LLM Gateway and keep external API calls to a minimum. If your handler makes multiple LLM calls, consider restructuring to use a single call with a detailed prompt. For agent-driven side effects (e.g. sending a Telegram notification via ctx.askAgent), use the fire-and-forget pattern documented under Agent Notifications — those calls routinely exceed 30s.

Handler Patterns

Your handler receives a standard Web Request. You can return plain objects (auto-wrapped as JSON) or full Response objects when you need custom status codes or headers. No special framework — just the platform APIs you already know.

Reading Query Parameters

export default async function handler(req: Request) {
const url = new URL(req.url);

const city = url.searchParams.get("city") ?? "New York";
const units = url.searchParams.get("units") ?? "fahrenheit";
const limit = parseInt(url.searchParams.get("limit") ?? "10");

return { city, units, limit };
}

Reading Headers

export default async function handler(req: Request) {
const lang = req.headers.get("Accept-Language") ?? "en";
const userAgent = req.headers.get("User-Agent") ?? "unknown";

return { lang, userAgent };
}

Parsing a JSON Body

export default async function handler(req: Request) {
if (req.method !== "POST") {
return Response.json({ error: "POST required" }, { status: 405 });
}

const body = await req.json();
// body is typed as `any` — validate what you need
const { text, language } = body;

if (!text) {
return Response.json({ error: "text is required" }, { status: 400 });
}

return { received: text, language: language ?? "auto" };
}

Parsing Form Data

export default async function handler(req: Request) {
const form = await req.formData();
const name = form.get("name") as string;
const file = form.get("file") as File;

return {
name,
fileName: file?.name,
fileSize: file?.size,
};
}

Returning JSON

The simplest pattern — return a plain object and Bankr wraps it as JSON automatically.

// Plain object — auto-wrapped as JSON with status 200
return { result: "success", data: [1, 2, 3] };

// Use Response.json() when you need a custom status code
return Response.json({ error: "not found" }, { status: 404 });

// Or custom headers
return Response.json(
{ data: "ok" },
{
headers: { "X-Request-Id": "abc123" },
},
);

Returning HTML

Use a Response object for non-JSON content types.

export default async function handler(req: Request) {
const html = `
<html>
<body>
<h1>Hello from x402</h1>
<p>This endpoint costs $0.001 per request.</p>
</body>
</html>
`;

return new Response(html, {
headers: { "Content-Type": "text/html" },
});
}

Returning an Image

export default async function handler(req: Request) {
// Fetch or generate an image
const res = await fetch("https://api.example.com/generate-chart?data=...");
const imageBuffer = await res.arrayBuffer();

return new Response(imageBuffer, {
headers: { "Content-Type": "image/png" },
});
}

Returning Plain Text

return new Response("Hello, world!", {
headers: { "Content-Type": "text/plain" },
});

Returning a Stream

export default async function handler(req: Request) {
const stream = new ReadableStream({
async start(controller) {
for (const chunk of ["Hello ", "from ", "x402!"]) {
controller.enqueue(new TextEncoder().encode(chunk));
await new Promise((r) => setTimeout(r, 500));
}
controller.close();
},
});

return new Response(stream, {
headers: { "Content-Type": "text/plain" },
});
}

Using Persistent Files (ctx.files)

Opt in via bankr.x402.json's files block (or fileAccess in the agent deploy tool) and the runtime injects a ctx.files bridge. Reads/writes target the deploying wallet's UserFile space — no storage credentials, no presigned URLs in handler code.

// bankr.x402.json
{
"services": {
"counter": {
"price": "0.001",
"methods": ["GET"],
"files": {
"enabled": true,
"roots": ["/x402/counter"],
"read": true,
"write": true,
"delete": false
}
}
}
}
// x402/counter/index.ts
export default async function handler(
req: Request,
ctx?: { files?: BankrX402Files },
) {
if (!ctx?.files) {
return Response.json(
{ error: "File access is not enabled for this endpoint" },
{ status: 501 },
);
}

const state = await ctx.files
.readJson<{ count?: number }>("/x402/counter/state.json")
.catch(() => ({ count: 0 }));
const next = { count: (state.count ?? 0) + 1 };
await ctx.files.writeJson("/x402/counter/state.json", next);
return next;
}

The ctx.files surface:

type BankrX402Files = {
list(path?: string): Promise<FileInfo[]>;
readText(path: string): Promise<string>;
readJson<T = unknown>(path: string): Promise<T>;
readBytes(path: string): Promise<ArrayBuffer>;
writeText(
path: string,
content: string,
opts?: { mimeType?: string },
): Promise<FileInfo>;
writeJson(
path: string,
value: unknown,
opts?: { pretty?: boolean; mimeType?: string },
): Promise<FileInfo>;
writeBytes(
path: string,
bytes: ArrayBuffer | Uint8Array,
opts?: { mimeType?: string },
): Promise<FileInfo>;
delete(path: string): Promise<{ deleted: boolean }>;
getDownloadUrl(path: string): Promise<{ url: string; expiresAt: string }>;
};

All paths must start with / and stay inside the configured roots. The default root is /x402/<service-name>. Defaults are read: true, write: true, delete: false.

Writing to App KV (ctx.appKV)

Endpoints can read/write the appKV of any Bankr app owned by the same wallet that deployed the endpoint. This is how a paid endpoint can drive the live state of a companion app — for example, a booking endpoint that flips a slot to booked so the app's iframe sees it on next refresh.

Opt in with appKV.enabled (or appKVAccess.enabled in the agent deploy tool):

{
"services": {
"book-call": {
"price": "5.00",
"methods": ["POST"],
"appKV": { "enabled": true, "write": true }
}
}
}
// x402/book-call/index.ts
export default async function handler(
req: Request,
ctx?: { appKV?: BankrX402AppKV },
) {
if (!ctx?.appKV) {
return Response.json({ error: "appKV access required" }, { status: 501 });
}

const { appId, slotIso, email } = await req.json();

// 1. Reserve the slot via a per-slot record: key. The record store
// enforces a unique (appId, walletId, key) constraint, so a second
// concurrent booking for the same slotIso loses the race and hits
// the catch. This is concurrency-safe — do NOT use a shared
// bookings.json that does a read-then-write under load.
try {
await ctx.appKV.set(appId, `record:bookings/${slotIso}`, {
slotIso,
email,
bookedAt: new Date().toISOString(),
});
} catch (err) {
if (err instanceof Error && err.message.includes("duplicate")) {
return Response.json({ error: "Slot already booked" }, { status: 409 });
}
throw err;
}

// 2. Flip the snapshot the app reads on refresh.
const snap = (await ctx.appKV.get(appId, "slots_snapshot")) as {
slots: Array<{ slot_iso: string; status: string }>;
} | null;
if (snap) {
const updated = {
...snap,
slots: snap.slots.map((s) =>
s.slot_iso === slotIso ? { ...s, status: "booked" } : s,
),
};
await ctx.appKV.set(appId, "slots_snapshot", updated);
}

return { ok: true, slotIso };
}

Concurrency note. Read-modify-write against a single file-backed key (e.g., reading a bookings array, checking for the slot, writing it back) is fine for low-rate booking pages but loses the race when two callers hit the same slot simultaneously. The example above uses a per-slot record: key so the unique-key constraint enforces single-writer semantics for free. The slots_snapshot write that follows is eventual-consistency presentation only — readers see the booked slot on next refresh; the booking itself is already durable.

The ctx.appKV surface:

type BankrX402AppKV = {
get(appId: string, key: string): Promise<unknown>;
set(appId: string, key: string, value: unknown): Promise<void>;
delete(appId: string, key: string): Promise<boolean>;
list(
appId: string,
prefix?: string,
): Promise<Array<{ key: string; value: unknown }>>;
};

Rules:

  • appId is required on every call. The runtime resolves the app and rejects with 403 if the app's owning wallet is not the endpoint's deploying wallet.
  • Writes to file-backed keys (default storage) require the endpoint's wallet to own the app. record:-prefixed keys go to the queryable record store, which enforces atomic uniqueness on (appId, walletId, key) and supports prefix-list queries.
  • Defaults are read: true, write: true, delete: false. Set delete: true only when the endpoint genuinely needs to remove keys.

Agent Notifications (ctx.askAgent)

Opt into agentAccess: { enabled: true } and the runtime exposes ctx.askAgent(prompt: string): Promise<string>. The prompt is handed to the deploying wallet's Bankr agent with send_telegram_message (and any other write tools tagged appInvokeWrite) bound. This is the supported way to deliver post-payment receipts, booking confirmations, or any "ping me" flow the handler can't do directly.

Always fire-and-forget. Agent runs routinely take 30–90 seconds — well past the 30-second handler timeout. If you await the call, the caller sees a 503 and the payment fails to settle. The notification still lands either way: the askAgent runtime completes the run server-side regardless of whether your handler is still listening, so the Telegram message arrives even after your handler has returned.

export default async function handler(req: Request, ctx) {
const body = await req.json();

// Persist the durable state synchronously — that's what the caller paid for.
await ctx.appKV.set(appId, `record:bookings/${body.slotId}`, {
buyer: body.buyer,
bookedAt: new Date().toISOString(),
});

// Fire-and-forget the notification. Do NOT await.
if (ctx.askAgent) {
void ctx
.askAgent(
`Send me a Telegram DM: "Slot ${body.slotId} booked by ${body.buyer}."`,
)
.catch((err) => {
console.error("askAgent fire-and-forget failed:", err?.message ?? err);
});
}

// Return immediately so the caller sees a fast 200 and the payment settles.
return Response.json({ ok: true, slotId: body.slotId });
}

The corresponding deploy payload needs agentAccess enabled — repeat all three access blocks if you're redeploying:

{
"fileAccess": {
"enabled": true,
"roots": ["/x402/schedule"],
"read": true,
"write": true
},
"appKVAccess": { "enabled": true, "read": true, "write": true },
"agentAccess": { "enabled": true }
}
Redeploy preserves nothing

fileAccess, appKVAccess, and agentAccess are full-replace on every redeploy — omit any of them and that capability is silently cleared. Always repeat all three blocks unchanged when redeploying, even if only the source changed.

Rate limits: 5 calls/min, 50/day per deploying wallet (shared with bankr.askAgent in app scripts). Calls cost agent credits. Treat the prompt like a system-prompt addendum — don't interpolate untrusted caller input directly. Don't return the agent's reply to the x402 client; the agent runs with the deploying wallet's read-tools and its free-form text can leak owner-private data.

Using Environment Variables

Set secrets via the CLI or dashboard — they're available as process.env in your handler.

bankr x402 env set BANKR_API_KEY=sk_...
bankr x402 env set DATABASE_URL=postgres://...
export default async function handler(req: Request) {
const apiKey = process.env.BANKR_API_KEY;
if (!apiKey) {
return Response.json({ error: "API key not configured" }, { status: 500 });
}

// Use the secret in your handler logic
const res = await fetch("https://llm.bankr.bot/v1/chat/completions", {
headers: { Authorization: `Bearer ${apiKey}` },
// ...
});

return await res.json();
}

Config for Multiple Services

{
"network": "base",
"services": {
"weather": {
"description": "Real-time weather data for any city",
"price": "0.001",
"methods": ["GET"],
"schema": {
"input": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name" }
},
"required": ["city"]
},
"output": {
"type": "object",
"properties": {
"temperature": { "type": "number" },
"conditions": { "type": "string" }
}
}
}
},
"sentiment": {
"description": "AI-powered sentiment analysis",
"price": "0.01",
"methods": ["POST"],
"schema": {
"input": {
"type": "object",
"properties": {
"text": { "type": "string", "description": "Text to analyze" }
},
"required": ["text"]
},
"output": {
"type": "object",
"properties": {
"sentiment": { "type": "string" },
"score": { "type": "number" }
}
}
}
},
"crypto-prices": {
"description": "Real-time cryptocurrency prices",
"price": "0.001",
"methods": ["GET"]
}
}
}

Client Examples

TypeScript — x402-fetch

The simplest way to call an x402 endpoint. x402-fetch wraps the standard fetch API and handles the 402 payment flow automatically.

bun add x402-fetch viem
import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";
import { wrapFetchWithPayment } from "x402-fetch";

// Set up wallet (needs USDC on Base)
const account = privateKeyToAccount("0xYOUR_PRIVATE_KEY" as `0x${string}`);
const wallet = createWalletClient({ account, chain: base, transport: http() });

// Wrap fetch — max 1 USDC per request
const paidFetch = wrapFetchWithPayment(fetch, wallet, BigInt(1_000_000));

// GET request
const weather = await paidFetch(
"https://x402.bankr.bot/0xOwnerWallet/weather?city=London",
);
console.log(await weather.json());

// POST request
const sentiment = await paidFetch(
"https://x402.bankr.bot/0xOwnerWallet/sentiment",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "Bankr is amazing!" }),
},
);
console.log(await sentiment.json());

TypeScript — Manual 402 Flow

If you want to handle the payment flow yourself:

import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";

const ENDPOINT = "https://x402.bankr.bot/0xOwnerWallet/weather?city=NYC";

// 1. Make initial request — expect 402
const initial = await fetch(ENDPOINT);

if (initial.status === 402) {
const { accepts, facilitator } = await initial.json();
const requirements = accepts[0]; // First accepted payment option

console.log("Payment required:");
console.log(" Amount:", requirements.amount, "atomic units");
console.log(" Asset:", requirements.asset);
console.log(" Network:", requirements.network);
console.log(" Pay to:", requirements.payTo);

// 2. Sign the payment (use x402-fetch or x402 SDK for this)
// ... signing logic ...

// 3. Retry with payment header
const paid = await fetch(ENDPOINT, {
headers: { "PAYMENT-SIGNATURE": signedPaymentBase64 },
});

console.log("Response:", await paid.json());
}

cURL — Inspect Payment Requirements

# See what an endpoint charges
curl -s https://x402.bankr.bot/0xOwnerWallet/weather | jq .

# Output:
# {
# "x402Version": 2,
# "error": "Payment Required",
# "accepts": [{
# "scheme": "exact",
# "network": "base",
# "amount": "1000",
# "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
# "payTo": "0xOwnerWallet",
# "maxTimeoutSeconds": 60,
# "extra": { "name": "USD Coin", "version": "2" }
# }],
# "facilitator": "https://api.bankr.bot/facilitator"
# }

Bankr Agent Integration

Bankr AI agents can discover and call x402 endpoints automatically. If your endpoint has a description and schema in the config, agents can find it and call it on behalf of users.

{
"services": {
"weather": {
"description": "Get real-time weather data for any city worldwide",
"price": "0.001",
"methods": ["GET"],
"category": "data",
"tags": ["weather", "forecast", "temperature"],
"schema": {
"input": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name or zip code" }
},
"required": ["city"]
},
"output": {
"type": "object",
"properties": {
"temperature": {
"type": "number",
"description": "Current temperature"
},
"conditions": {
"type": "string",
"description": "Weather conditions"
},
"humidity": {
"type": "number",
"description": "Humidity percentage"
}
}
}
}
}
}
}

With this schema, an agent can:

  1. Search for "weather data" → finds your endpoint
  2. Show the user the price ($0.001)
  3. Call it with payment → return the results