Skip to main content
By the end of this guide you will have a running server that:
  • Exposes a plan catalog at GET /discover
  • Issues a USDC payment challenge at POST /x402/access
  • Settles the on-chain payment and returns a signed JWT (AccessGrant)
  • Protects your own routes with validateAccessToken middleware
New to Key0? Read Core Concepts first to understand terms like Plan, Challenge, AccessGrant, and EIP-3009.

Prerequisites

Before you begin, make sure you have:
  • Bun v1.3+ (or Node.js 18+)
  • A wallet address on Base Sepolia (testnet) — get testnet USDC from faucet.circle.com (select Base Sepolia)
  • Redis running locally or remotely (Postgres is also supported — see Storage)
Never use mainnet for development or testing. Base Sepolia testnet USDC has no real value and is free from the faucet.

Setup

1

Install dependencies

bun add @key0ai/key0
bun add ioredis  # for Redis storage
2

Configure and mount

Create a server.ts file. Key0 mounts as middleware on your existing server — it adds the agent card endpoint (/.well-known/agent.json), the plan discovery endpoint (/discover), the payment endpoint (/x402/access), and leaves your existing routes untouched.
import express from "express";
import { key0Router, validateAccessToken } from "@key0ai/key0/express";
import {
  X402Adapter,
  AccessTokenIssuer,
  RedisChallengeStore,
  RedisSeenTxStore,
} from "@key0ai/key0";
import Redis from "ioredis";

const app = express();
app.use(express.json());

const adapter = new X402Adapter({ network: "testnet" });
const tokenIssuer = new AccessTokenIssuer(process.env.ACCESS_TOKEN_SECRET!);

const redis = new Redis(process.env.REDIS_URL!);
const store = new RedisChallengeStore({ redis });
const seenTxStore = new RedisSeenTxStore({ redis });

app.use(
  key0Router({
    config: {
      agentName: "My Agent",
      agentDescription: "A payment-gated API",
      agentUrl: "https://my-agent.example.com",
      providerName: "My Company",
      providerUrl: "https://example.com",
      walletAddress: "0xYourWalletAddress" as `0x${string}`,
      network: "testnet",
      plans: [
        {
          planId: "basic",
          unitAmount: "$0.10",
          description: "Basic API access.",
        },
      ],
      fetchResourceCredentials: async (params) => {
        return tokenIssuer.sign(
          {
            sub: params.requestId,
            jti: params.challengeId,
            resourceId: params.resourceId,
          },
          3600,
        );
      },
    },
    adapter,
    store,
    seenTxStore,
  }),
);

// Protect your existing routes with the access token middleware
app.use(
  "/api",
  validateAccessToken({ secret: process.env.ACCESS_TOKEN_SECRET! }),
);

app.get("/api/data/:id", (req, res) => {
  res.json({ data: "premium content" });
});

app.listen(3000);
3

Set environment variables

Create a .env file in your project root:
.env
KEY0_NETWORK=testnet
KEY0_WALLET_ADDRESS=0xYourWalletAddress
ACCESS_TOKEN_SECRET=your-secret-min-32-chars
REDIS_URL=redis://localhost:6379
PORT=3000
ACCESS_TOKEN_SECRET must be at least 32 characters. This secret is used to sign JWTs issued to agents after payment. Use a cryptographically random string.
4

Run and test

Start your server:
bun run server.ts
# Server starts on http://localhost:3000
# Agent card at http://localhost:3000/.well-known/agent.json
Test the agent card, plan discovery, and payment endpoints with curl:
Agent card
curl http://localhost:3000/.well-known/agent.json
Expected output
{
  "name": "My Agent",
  "description": "A payment-gated API",
  "url": "https://my-agent.example.com",
  "skills": [
    { "id": "basic", "name": "Basic API access.", "tags": ["x402", "usdc"] }
  ]
}
Browse plans (returns 200 with plan catalog)
curl http://localhost:3000/discover
Expected output
{
  "agentName": "My Agent",
  "description": "A payment-gated API",
  "plans": [
    { "planId": "basic", "unitAmount": "$0.10", "description": "Basic API access." }
  ],
  "routes": []
}
Request access (returns 402 challenge)
curl -X POST http://localhost:3000/x402/access \
  -H "Content-Type: application/json" \
  -d '{"planId": "basic"}'
Expected output (HTTP 402)
{
  "x402Version": 2,
  "accepts": [ { "amount": "100000", "payTo": "0xYourWallet...", "network": "eip155:84532" } ],
  "challengeId": "http-a1b2c3d4-...",
  "error": "Payment required"
}
The agent card describes your service. The discovery endpoint lists all plans — use the planId values when requesting access. The access endpoint creates a PENDING challenge containing the payment amount, destination wallet, and chain ID. A client uses the challenge to sign an EIP-3009 authorization and submit payment. See Paying for Access for the full client walkthrough.

Optional: Pay-Per-Request Routes

The quickstart above uses a subscription plan — the agent pays once and receives a signed JWT that grants access for the token’s lifetime. Key0 also supports pay-per-call routes, where each API call is paid for individually and no JWT is issued. The difference in configuration is adding a paid route to top-level routes:
// Subscription plan (default) — agent pays once, receives a JWT
{ planId: "basic", unitAmount: "$0.10", description: "API access token." }

// Pay-per-call route — agent pays per call, receives the route response directly
{
  routeId: "single-call",
  method: "GET",
  path: "/api/data/:id",
  unitAmount: "$0.01",
  description: "One API call — $0.01.",
}
On the server side, instead of protecting routes with validateAccessToken, you gate them with key0.payPerRequest() middleware. The middleware handles the entire payment cycle inline: it returns a 402 if no payment header is present, settles on-chain if it is, then calls next() so your route handler runs normally:
// Subscription plans and pay-per-call routes can coexist in the same config
plans: [
  { planId: "basic", unitAmount: "$0.10" },             // subscription
],
routes: [
  { routeId: "single-call", method: "GET", path: "/api/data/:id", unitAmount: "$0.01" },
],

// Subscription route — protected with JWT middleware (as shown above)
app.use("/api", validateAccessToken({ secret: process.env.ACCESS_TOKEN_SECRET! }));

// Per-request route — protected with payPerRequest middleware
// No JWT needed; payment settles inline. req.key0Payment contains txHash, planId, etc.
app.get("/api/data/:id", key0.payPerRequest("single-call"), (req, res) => {
  res.json({ data: "paid content", txHash: req.key0Payment?.txHash });
});
See the Express integration guide for a complete per-request example with all three frameworks, and PPR Embedded for a runnable demo.

What’s next

Pay-Per-Request (Embedded)

Full example: weather and joke routes with inline payment settlement.

Integrations

Detailed integration guides for Express, Hono, Fastify, and MCP.

Storage Setup

Configure Redis or Postgres for challenge and transaction storage.

Building a Seller

End-to-end guide covering plans, storage, mainnet, and production deployment.