Examples
Complete, copy-paste-ready examples for building and consuming x402 endpoints.
Handler Examples
Simple JSON API
Return a Response — Response.json() for JSON.
// x402/weather/index.ts
export default async function handler(req: Request): Promise<Response> {
const url = new URL(req.url);
const city = url.searchParams.get("city") ?? "New York";
return Response.json({
city,
temperature: 72,
conditions: "sunny",
timestamp: new Date().toISOString(),
});
}
External API Proxy
Pass a status code (or headers) as the second argument when you need one.
// x402/crypto-prices/index.ts
export default async function handler(req: Request): Promise<Response> {
const url = new URL(req.url);
const id = url.searchParams.get("id") ?? "bitcoin"; // CoinGecko coin id
const apiKey = process.env.COINGECKO_API_KEY;
const res = await fetch(
`https://api.coingecko.com/api/v3/simple/price?ids=${encodeURIComponent(id)}&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 Response.json({ id, price: data[id]?.usd });
}
POST Endpoint with Body Parsing
// x402/sentiment/index.ts
export default async function handler(req: Request): Promise<Response> {
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 Response.json({
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.LLM_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 directly 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);
}
LLM_API_KEY is a Bankr API key with LLM Gateway access, stored with bankr x402 env set LLM_API_KEY=bk_... (see Using Environment Variables).
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, or Token-Gated Discounts to vary the price by who's paying.
Token-Gated Tiered Discounts ($BNKR)
This endpoint charges in $BNKR and rewards holders — pay in $BNKR, and the more $BNKR your wallet holds, the less you pay. It combines two features: custom-token pricing (so the price is denominated in $BNKR, settled on the Permit2 rail) and the upto scheme with the x-402-payer header (the caller authorizes the full price, your handler scales it by the payer's on-chain balance, and only that amount settles).
List price is 1,000 $BNKR:
| $BNKR held | Discount | You pay |
|---|---|---|
| 100M+ | 80% | 200 $BNKR |
| 10M+ | 50% | 500 $BNKR |
| 1M+ | 20% | 800 $BNKR |
| < 1M | — | 1,000 $BNKR |
cd x402/bnkr-quote
bun add viem
// x402/bnkr-quote/index.ts
import {
createPublicClient,
http,
erc20Abi,
parseUnits,
formatUnits,
} from "viem";
import { base } from "viem/chains";
const BNKR = "0x22aF33FE49fD1Fa80c7149773dDe5890D3c76F3b"; // $BNKR on Base (18 decimals)
const PRICE_ATOMIC = parseUnits("1000", 18); // 1,000 $BNKR list price, in atomic units (18 decimals)
// Descending tiers: [minimum $BNKR held, discount fraction]
const TIERS: [bigint, number][] = [
[parseUnits("100000000", 18), 0.8], // 100M → 80% off
[parseUnits("10000000", 18), 0.5], // 10M → 50% off
[parseUnits("1000000", 18), 0.2], // 1M → 20% off
];
const client = createPublicClient({
chain: base,
transport: http(process.env.RPC_URL),
});
export default async function handler(req: Request): Promise<Response> {
const payer = req.headers.get("x-402-payer");
let discount = 0;
if (payer) {
const balance = await client.readContract({
address: BNKR,
abi: erc20Abi,
functionName: "balanceOf",
args: [payer as `0x${string}`],
});
discount = TIERS.find(([min]) => balance >= min)?.[1] ?? 0;
}
// Scale the price down by the discount, in atomic $BNKR. The router clamps to
// PRICE_ATOMIC, so a bad lookup can never overcharge — worst case is list price.
const settleAmount =
(PRICE_ATOMIC * BigInt(Math.round((1 - discount) * 1000))) / 1000n;
return new Response(
JSON.stringify({
quote: "...",
discountPct: discount * 100,
chargedBnkr: formatUnits(settleAmount, 18),
}),
{
headers: {
"Content-Type": "application/json",
"X-402-Settle-Amount": settleAmount.toString(),
},
},
);
}
Config — price in $BNKR via tokenAddress, and upto so the discounted settle amount is honored:
{
"services": {
"bnkr-quote": {
"price": "1000",
"tokenAddress": "0x22aF33FE49fD1Fa80c7149773dDe5890D3c76F3b",
"paymentScheme": "upto",
"methods": ["GET"]
}
}
}
X-402-Settle-Amount is in the atomic units of the endpoint's configured token — here 18 decimals for $BNKR (so 1000 $BNKR is parseUnits("1000", 18)); it's 6 decimals for USDC. The router clamps the value to the signed maximum (price), so you can discount below the list price but a bad lookup can never charge above it. Because $BNKR isn't an EIP-3009 token, payment settles on the Permit2 rail.
Handlers have a 30-second limit and a single balanceOf read is well within it, but a slow or rate-limited RPC eats into it. Use a reliable Base RPC endpoint (set it via an environment variable such as RPC_URL), and if you query many tokens, batch the reads.
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
// 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): Promise<Response> {
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-3.8-flash",
configuration: { baseURL: "https://llm.bankr.bot/v1" },
apiKey: process.env.LLM_API_KEY!,
});
const result = await llm.invoke(input.data.query);
return Response.json({ response: result.content });
}
Handlers have a 30-second execution limit, enforced by the platform and not configurable. For AI-powered endpoints, use fast models like gemini-3.8-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 can outlast the 30s limit.
Handler Patterns
Your handler receives a standard Web Request and must return a Response. Anything else — a plain object, a string — fails with an empty 500, and the caller isn't charged. Only the Content-Type, Cache-Control, ETag, Last-Modified and X-Request-Id response headers are passed through.
Identifying the Payer
Paid requests carry an x-402-payer header with the wallet address that paid, always lowercase. It's set by the Bankr router after payment verification and any caller-supplied value is stripped, so you can trust it as-is for allowlists, quotas, or personalization. Note it identifies the paying wallet (not necessarily a person), and it's Bankr-specific — not part of the x402 standard.
export default async function handler(req: Request): Promise<Response> {
const payer = req.headers.get("x-402-payer");
return Response.json({ message: `Thanks for the payment, ${payer}!` });
}
Returning JSON
// Status 200
return Response.json({ result: "success", data: [1, 2, 3] });
// Custom status code
return Response.json({ error: "not found" }, { status: 404 });
// Custom headers (only the ones listed above reach the caller)
return Response.json(
{ data: "ok" },
{
headers: { "Cache-Control": "max-age=60" },
},
);
Returning HTML or Plain Text
Set the Content-Type yourself for non-JSON text.
export default async function handler(req: Request): Promise<Response> {
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" },
});
}
For plain text, use "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 },
): Promise<Response> {
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 Response.json(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;
askAgent?: (prompt: string) => Promise<string>;
},
): Promise<Response> {
if (!ctx?.appKV) {
return Response.json({ error: "appKV access required" }, { status: 501 });
}
const { appId, slotIso, email } = await req.json();
const key = `record:bookings/${slotIso}`;
// 1. set() overwrites an existing key, so check first. This check-then-write
// isn't atomic: two simultaneous requests for one slot can both pass it.
if (await ctx.appKV.get(appId, key)) {
return Response.json({ error: "Slot already booked" }, { status: 409 });
}
await ctx.appKV.set(appId, key, {
slotIso,
email,
bookedAt: new Date().toISOString(),
});
// 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 Response.json({ ok: true, slotIso });
}
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:
appIdis required on every call. The runtime resolves the app and rejects with403if the app's owning wallet is not the endpoint's deploying wallet.- Plain keys are file-backed app storage.
record:-prefixed keys go to the queryable record store: one value per key, listable by prefix withlist. Either way,setoverwrites — there is no create-if-absent. - Defaults are
read: true,write: true,delete: false. Setdelete: trueonly when the endpoint genuinely needs to remove keys.
Agent Notifications (ctx.askAgent)
Add an agent block (agentAccess in the agent deploy tool) 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 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. An agent run can easily outlast 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.
In the booking handler above, after the booking is saved:
// Fire-and-forget the notification. Do NOT await.
if (ctx.askAgent) {
void ctx
.askAgent(`Send me a Telegram DM: "A call slot was just booked."`)
.catch((err) => {
console.error("askAgent fire-and-forget failed:", err?.message ?? err);
});
}
{
"services": {
"book-call": {
"price": "5.00",
"methods": ["POST"],
"appKV": { "enabled": true, "write": true },
"agent": { "enabled": true }
}
}
}
agent also takes maxPromptChars (default 16,000) and llmModelId (see below). The access blocks — files, appKV, agent — are read from the config on every deploy, so leaving one out turns that capability off.
Rate limits: 5 calls/min per deploying wallet. Free runs (no llmModelId) use the default model at no cost and share a daily cap with bankr.askAgent in your app scripts — 2/day, or 5/day with Bankr Club, resetting at 00:00 UTC. Free runs pause entirely — a daily cap of 0 — once the deploying wallet has gone 14 days without signed-in Bankr activity; sign in or send the agent a command to resume them. x402 traffic and other API calls don't count, so a wallet whose activity is all machine-driven reads as dormant, and a wallet with no activity signal at all is treated the same way. Setting llmModelId to a gateway model runs Max Mode: that model, billed to the deploying wallet's LLM credits, exempt from the daily cap. 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 every one of your handlers. Names starting with a reserved prefix such as BANKR_ are rejected.
bankr x402 env set LLM_API_KEY=bk_...
bankr x402 env set RPC_URL=https://...
export default async function handler(req: Request): Promise<Response> {
const apiKey = process.env.LLM_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/models", {
headers: { Authorization: `Bearer ${apiKey}` },
});
return Response.json(await res.json());
}
Client Examples
The quickest client is the CLI — bankr x402 call pays from your Bankr wallet.
TypeScript — @x402/fetch
Bankr endpoints speak x402 v2 (networks are CAIP-2 ids such as eip155:8453), so use the v2 client packages. They wrap the standard fetch API and handle the 402 payment flow automatically.
bun add @x402/fetch @x402/evm viem
import { wrapFetchWithPayment, x402Client } from "@x402/fetch";
import { ExactEvmScheme } from "@x402/evm";
import { privateKeyToAccount } from "viem/accounts";
// Paying wallet (needs USDC on Base)
const account = privateKeyToAccount("0xYOUR_PRIVATE_KEY");
const client = new x402Client().register(
"eip155:8453",
new ExactEvmScheme(account),
);
const paidFetch = wrapFetchWithPayment(fetch, client);
// 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());
This pays USDC and EURC endpoints on the default exact scheme. upto and custom-token endpoints settle through Permit2 and must be signed for the spender the 402 advertises in extra.permit2Spender; a stock client signs a different spender and is rejected with permit2_spender_mismatch — use bankr x402 call for those.
cURL — Inspect Payment Requirements
# See what an endpoint charges
curl -s https://x402.bankr.bot/0xOwnerWallet/weather | jq .
# Output (abridged):
# {
# "x402Version": 2,
# "error": "Payment Required",
# "accepts": [{
# "scheme": "exact",
# "network": "eip155:8453",
# "maxAmountRequired": "1000",
# "amount": "1000",
# "asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
# "payTo": "0x…",
# "maxTimeoutSeconds": 60,
# "extra": { "name": "USD Coin", "version": "2" }
# }],
# "facilitator": "https://api.bankr.bot/facilitator"
# }
payTo is Bankr's fee router contract, which splits each payment between the endpoint's payout wallet and the platform fee at settlement. The same JSON arrives base64-encoded in the payment-required response header.