Skip to main content

Installation

npm install @key0ai/key0 express ioredis
import { key0Router, validateAccessToken } from "@key0ai/key0/express";

Quick start

1

Create the Express app and dependencies

import {
  X402Adapter,
  RedisChallengeStore,
  RedisSeenTxStore,
  AccessTokenIssuer,
} from "@key0ai/key0";
import { key0Router, validateAccessToken } from "@key0ai/key0/express";
import express from "express";
import Redis from "ioredis";

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

const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");
2

Mount the Key0 router

const SECRET = process.env.KEY0_ACCESS_TOKEN_SECRET!;
const tokenIssuer = new AccessTokenIssuer(SECRET);

app.use(
  key0Router({
    config: {
      agentName: "Photo Gallery Agent",
      agentDescription: "Premium photos via USDC payments on Base",
      agentUrl: "https://api.example.com",
      providerName: "Example Corp",
      providerUrl: "https://example.com",
      walletAddress: "0xYourWalletAddress" as `0x${string}`,
      network: "mainnet",
      plans: [
        { planId: "single-photo", unitAmount: "$0.10", description: "One photo." },
        { planId: "full-album", unitAmount: "$1.00", description: "Full album (24h)." },
      ],
      fetchResourceCredentials: async (params) => {
        return tokenIssuer.sign(
          {
            sub: params.requestId,
            jti: params.challengeId,
            resourceId: params.resourceId,
            planId: params.planId,
            txHash: params.txHash,
          },
          3600,
        );
      },
    },
    adapter: new X402Adapter({ network: "mainnet" }),
    store: new RedisChallengeStore({ redis }),
    seenTxStore: new RedisSeenTxStore({ redis }),
  }),
);
3

Protect your routes

app.use("/api", validateAccessToken({ secret: SECRET }));

app.get("/api/photos/:id", (req, res) => {
  res.json({ id: req.params.id, url: `https://cdn.example.com/${req.params.id}.jpg` });
});

app.listen(3000);

key0Router(opts)

Creates an Express Router that serves agent discovery, the A2A JSON-RPC endpoint, and the x402 HTTP payment endpoint.
import { key0Router } from "@key0ai/key0/express";

app.use(key0Router({ config, adapter, store, seenTxStore }));

Options

ParameterTypeDescription
configSellerConfigAgent identity, wallet, plans, and callbacks. See SellerConfig.
adapterX402AdapterOn-chain payment verification adapter.
storeIChallengeStoreChallenge state storage (e.g. RedisChallengeStore).
seenTxStoreISeenTxStoreDouble-spend prevention store (e.g. RedisSeenTxStore).

Routes served

The router mounts the following routes on your Express app:
MethodPathDescription
GET/.well-known/agent.jsonA2A agent card for discovery.
POST{basePath}/jsonrpcA2A JSON-RPC endpoint with x402 middleware.
POST{basePath}/restA2A REST endpoint.
POST/x402/accessSimple x402 HTTP endpoint (no JSON-RPC wrapping).
When mcp: true is set in config, two additional routes are mounted:
MethodPathDescription
GET/.well-known/mcp.jsonMCP discovery document.
POST/mcpMCP Streamable HTTP endpoint.

The /x402/access endpoint

This endpoint handles the full x402 payment flow over plain HTTP. It responds differently depending on the request body and headers:
CaseRequestResponse
DiscoveryEmpty body (no planId)402 with all available plans.
ChallengeBody with planId402 with payment requirements and a challengeId.
SettlementBody with planId + payment-signature header200 with an AccessGrant (JWT + metadata).
The requestId field is optional. If you omit it, the server generates one automatically. Include it when you need client-side idempotency.

validateAccessToken(config)

Express middleware that verifies the Bearer JWT on incoming requests and attaches the decoded payload to req.key0Token.
import { validateAccessToken } from "@key0ai/key0/express";

app.use("/api", validateAccessToken({ secret: process.env.KEY0_ACCESS_TOKEN_SECRET! }));

app.get("/api/resource", (req, res) => {
  const token = (req as Request & { key0Token?: unknown }).key0Token;
  res.json({ token });
});
ParameterTypeDescription
secretstringThe same secret used by AccessTokenIssuer (or your fetchResourceCredentials callback) to sign JWTs.
The middleware returns a 401 or 403 JSON error if the token is missing, expired, or invalid.

Customizing basePath

By default, A2A routes are mounted under /a2a. Set basePath in your SellerConfig to change this:
app.use(
  key0Router({
    config: {
      // ...
      basePath: "/payments",
    },
    adapter,
    store,
    seenTxStore,
  }),
);
This changes the JSON-RPC endpoint to POST /payments/jsonrpc and the REST endpoint to POST /payments/rest. The /x402/access and /.well-known/agent.json paths are not affected.

Enabling MCP mode

Set mcp: true in your SellerConfig to expose MCP (Model Context Protocol) routes alongside the standard A2A and x402 endpoints:
app.use(
  key0Router({
    config: {
      // ...
      mcp: true,
    },
    adapter,
    store,
    seenTxStore,
  }),
);
This adds /.well-known/mcp.json for discovery and POST /mcp for Streamable HTTP transport, exposing discover_plans and request_access as MCP tools.
MCP mode requires the @modelcontextprotocol/sdk peer dependency. Install it separately if you enable this option.
See the MCP protocol guide for details on how agents interact with these endpoints.

Full example

A complete working example is available at examples/express-seller. To run it locally:
cd examples/express-seller
bun install
REDIS_URL=redis://localhost:6379 KEY0_WALLET_ADDRESS=0x... KEY0_ACCESS_TOKEN_SECRET=your-secret bun run start