Warning: This project is currently in alpha. APIs may change without notice and should not be used in production environments without thorough testing.
A production-ready payment settlement service for the x402 protocol. Built with Elysia and Node.js, it verifies cryptographic payment signatures and settles transactions on-chain for EVM, SVM (Solana), and Starknet networks.
- Overview
- Architecture
- Quick Start
- Configuration
- Custom Signers
- API Reference
- Payment Schemes
- Unified Client
- Upto Module
- Resource Tracking
- Resource Server
- Framework Middleware
- Testing
- Production Deployment
The x402 Facilitator acts as a trusted intermediary between clients making payments and resource servers providing paid content. It:
- Verifies payment signatures and authorizations
- Settles transactions on-chain (EVM/Solana)
- Manages batched payment sessions for efficient settlement (upto scheme)
| Network | CAIP-2 Identifier | Schemes |
|---|---|---|
| Base Mainnet | eip155:8453 |
exact, upto |
| Base Sepolia | eip155:84532 |
exact, upto |
| Ethereum | eip155:1 |
exact, upto |
| Optimism | eip155:10 |
exact, upto |
| Arbitrum | eip155:42161 |
exact, upto |
| Polygon | eip155:137 |
exact, upto |
| Starknet Mainnet | starknet:SN_MAIN |
exact |
| Starknet Sepolia | starknet:SN_SEPOLIA |
exact |
| Solana Devnet | solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 |
exact |
| Solana Mainnet | solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp |
exact |
┌─────────────────────────────────────────────────────────────────────┐
│ x402 Facilitator │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ /verify │ │ /settle │ │ /supported │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Payment Scheme Registry │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Exact (EVM) │ │ Upto (EVM) │ │ Exact (SVM) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────┼────────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ EVM Signer │ │ SVM Signer │ │Session Store│ │
│ │ (Viem/CDP) │ │(Solana Kit) │ │ (In-Memory) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
└─────────┼────────────────────┼────────────────────┼────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ EVM RPC │ │ Solana RPC │ │ Sweeper │
└─────────────┘ └─────────────┘ └─────────────┘
| Component | File | Responsibility |
|---|---|---|
| HTTP Server | src/app.ts |
Elysia server with endpoints and middleware |
| Facilitator Factory | src/factory.ts |
createFacilitator() with signer injection |
| CDP Signer | src/signers/cdp.ts |
Coinbase Developer Platform adapter |
| Upto Scheme | src/upto/evm/facilitator.ts |
Permit-based batched payments |
| Session Store | src/upto/store.ts |
In-memory session management |
| Sweeper | src/upto/sweeper.ts |
Background batch settlement |
| Elysia Middleware | src/elysia/ |
Payment middleware for Elysia |
| Hono Middleware | src/hono/ |
Payment middleware for Hono |
| Express Middleware | src/express/ |
Payment middleware for Express |
| Middleware Core | src/middleware/core.ts |
Shared payment processing logic |
Exact Payment (Immediate Settlement)
Client → POST /verify → Signature validation → VerifyResponse
Client → POST /settle → On-chain transfer → SettleResponse (tx hash)
Upto Payment (Batched Settlement)
Client → POST /verify → Permit validation → Session created/updated
↓
Accumulate pending spend across requests
↓
Sweeper triggers → POST /settle (batch) → Reset pending
Resource tracking is an optional module that records request, verification, and settlement metadata for analytics and auditing. It plugs into the framework middleware and follows the payment lifecycle end-to-end.
- Start:
startTracking()runs at request start and captures request metadata (headers, IP, user agent, etc.). - Update:
recordRequest()updatespaymentRequiredand attaches route config afterprocessHTTPRequest. - Verify:
recordVerification()records payment verification success/failure and payment details. - Track Upto:
recordUptoSession()stores Upto session metadata when used. - Settle:
recordSettlement()records settlement attempt and result for exact payments. - Finalize:
finalizeTracking()runs on response end (including 402 errors). For early exits,handlerExecutedis set tofalse.
Tracking is best effort by default. If asyncTracking is enabled (default), tracking errors are captured via onTrackingError and never block requests.
import {
createResourceTrackingModule,
InMemoryResourceTrackingStore,
PostgresResourceTrackingStore,
} from "@daydreamsai/facilitator/tracking";
import { createElysiaPaymentMiddleware } from "@daydreamsai/facilitator/elysia";
// Development: in-memory store
const tracking = createResourceTrackingModule({
store: new InMemoryResourceTrackingStore(),
captureHeaders: ["x-request-id"],
});
// Production: Postgres store
// const tracking = createResourceTrackingModule({
// store: new PostgresResourceTrackingStore(pgClient),
// asyncTracking: true,
// onTrackingError: (err, id) => console.error(`tracking error ${id}`, err),
// });
app.use(
createElysiaPaymentMiddleware({
httpServer,
resourceTracking: tracking,
})
);If you're using Drizzle with pg, you can reuse the same pool and adapt it to
the tracking store:
import { Pool } from "pg";
import { drizzle } from "drizzle-orm/node-postgres";
import type { PostgresClientAdapter } from "@daydreamsai/facilitator/tracking";
import { PostgresResourceTrackingStore } from "@daydreamsai/facilitator/tracking";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool);
const adapter: PostgresClientAdapter = {
query: async (sql, params) => (await pool.query(sql, params)).rows,
queryOne: async (sql, params) => (await pool.query(sql, params)).rows[0],
queryScalar: async (sql, params) => {
const row = (await pool.query(sql, params)).rows[0];
return row ? Object.values(row)[0] : undefined;
},
};
const store = new PostgresResourceTrackingStore(adapter);
await store.initialize();const recent = await tracking.list({
filters: { paymentVerified: true },
limit: 50,
});
const stats = await tracking.getStats(
new Date(Date.now() - 24 * 60 * 60 * 1000),
new Date()
);- Node.js v22+ or Bun
- CDP account (recommended) or EVM/SVM private keys
import { createFacilitator } from "@daydreamsai/facilitator";
import { createCdpEvmSigner } from "@daydreamsai/facilitator/signers/cdp";
import { CdpClient } from "@coinbase/cdp-sdk";
// Initialize CDP
const cdp = new CdpClient();
const account = await cdp.evm.getOrCreateAccount({ name: "facilitator" });
// Create signer
const signer = createCdpEvmSigner({
cdpClient: cdp,
account,
network: "base",
rpcUrl: process.env.EVM_RPC_URL_BASE,
});
// Create facilitator
const facilitator = createFacilitator({
evmSigners: [{ signer, networks: "eip155:8453", schemes: ["exact", "upto"] }],
});# Clone and install
git clone https://github.com/daydreamsai/facilitator
cd facilitator
bun install
# Configure environment
cp .env-local .env
# Edit .env with your CDP credentials or private keys
# Start development server
bun devcurl http://localhost:8090/supportedDeploy your own facilitator instance to Railway with one click:
Required environment variables (choose one):
| Mode | Variables |
|---|---|
| CDP (recommended) | CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET, CDP_ACCOUNT_NAME |
| Private Key | EVM_PRIVATE_KEY |
Optional variables:
EVM_NETWORKS- Networks to enable (default:base,base-sepolia)ALCHEMY_API_KEY- For better RPC reliabilitySVM_PRIVATE_KEY+SVM_NETWORKS- Enable Solana support
After deployment, verify at https://your-app.railway.app/supported.
This repo includes a single endpoint example that accepts EVM, Solana, and
Starknet payments in one accepts array.
Start the facilitator with Starknet enabled:
STARKNET_NETWORKS=starknet-mainnet,starknet-sepolia \
STARKNET_SPONSOR_ADDRESS=0x... \
STARKNET_PAYMASTER_ENDPOINT_STARKNET_MAINNET=https://starknet.paymaster.avnu.fi \
STARKNET_PAYMASTER_ENDPOINT_STARKNET_SEPOLIA=https://starknet.paymaster.avnu.fi \
STARKNET_PAYMASTER_API_KEY=your-avnu-api-key \
bun devRun the API example:
EVM_PRIVATE_KEY=... \
SVM_PRIVATE_KEY=... \
STARKNET_PAY_TO=0x... \
bun run examples/paidApiAll.tsSee examples/paidApiAll.ts for the full route config.
This repo includes a token-gated API example that checks ERC20 balances before allowing access to protected routes.
Run the example:
cd examples
bun run token-gated:apiCall a protected route with a wallet address:
curl -H "x-wallet-address: 0xYourWalletAddress" \
http://localhost:3000/api/premiumCDP Signer (Recommended)
| Variable | Required | Default | Description |
|---|---|---|---|
CDP_API_KEY_ID |
Yes | - | CDP API key ID |
CDP_API_KEY_SECRET |
Yes | - | CDP API key secret |
CDP_WALLET_SECRET |
Yes | - | CDP wallet secret |
CDP_ACCOUNT_NAME |
Yes | - | CDP account name |
Private Key Signer (Fallback)
| Variable | Required | Default | Description |
|---|---|---|---|
EVM_PRIVATE_KEY |
Yes* | - | Ethereum private key (hex format) |
SVM_PRIVATE_KEY |
Yes* | - | Solana private key (Base58 format) |
*Required when CDP credentials are not configured.
Starknet Paymaster (Exact Scheme)
| Variable | Required | Default | Description |
|---|---|---|---|
STARKNET_PAYMASTER_API_KEY |
No | - | Paymaster API key (AVNU hosted paymaster) |
STARKNET_SPONSOR_ADDRESS |
Yes* | - | Sponsor account address for /supported signers |
STARKNET_PAYMASTER_ENDPOINT_* |
No | - | Per-network paymaster endpoint override |
STARKNET_PAYMASTER_API_KEY_* |
No | - | Per-network paymaster API key override |
STARKNET_SPONSOR_ADDRESS_* |
No | - | Per-network sponsor address override |
*Required when enabling Starknet networks.
Server Configuration
| Variable | Required | Default | Description |
|---|---|---|---|
PORT |
No | 8090 |
Server port |
The facilitator uses a simplified network configuration system. Instead of manually specifying RPC URLs for each network, you can set API keys and enable networks with comma-separated lists.
Enabling Networks
| Variable | Default | Description |
|---|---|---|
EVM_NETWORKS |
base,base-sepolia |
Comma-separated EVM networks |
STARKNET_NETWORKS |
(empty) |
Comma-separated Starknet networks (opt-in) |
SVM_NETWORKS |
solana-devnet |
Comma-separated Solana networks |
Supported EVM Networks
| Name | CAIP-2 | Chain ID |
|---|---|---|
base |
eip155:8453 |
8453 |
base-sepolia |
eip155:84532 |
84532 |
ethereum |
eip155:1 |
1 |
sepolia |
eip155:11155111 |
11155111 |
optimism |
eip155:10 |
10 |
optimism-sepolia |
eip155:11155420 |
11155420 |
arbitrum |
eip155:42161 |
42161 |
arbitrum-sepolia |
eip155:421614 |
421614 |
polygon |
eip155:137 |
137 |
polygon-amoy |
eip155:80002 |
80002 |
avalanche |
eip155:43114 |
43114 |
avalanche-fuji |
eip155:43113 |
43113 |
abstract |
eip155:2741 |
2741 |
abstract-testnet |
eip155:11124 |
11124 |
Supported Starknet Networks
| Name | CAIP-2 |
|---|---|
starknet-mainnet |
starknet:SN_MAIN |
starknet-sepolia |
starknet:SN_SEPOLIA |
Supported SVM Networks
| Name | CAIP-2 |
|---|---|
solana-mainnet |
solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp |
solana-devnet |
solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 |
solana-testnet |
solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z |
RPC URLs are automatically resolved based on available API keys. Set a single API key to enable RPC access for all networks.
RPC Provider API Keys
| Variable | Provider | Description |
|---|---|---|
ALCHEMY_API_KEY |
Alchemy | EVM + Starknet RPC provider (alchemy.com) |
INFURA_API_KEY |
Infura | EVM RPC provider (infura.io) |
HELIUS_API_KEY |
Helius | Solana RPC provider (helius.dev) |
RPC Resolution Priority (EVM)
- Explicit override:
EVM_RPC_URL_<NETWORK>(e.g.,EVM_RPC_URL_BASE) - Alchemy (if
ALCHEMY_API_KEYis set) - Infura (if
INFURA_API_KEYis set) - Public RPC fallback
RPC Resolution Priority (Starknet)
- Explicit override:
STARKNET_RPC_URL_<NETWORK>(e.g.,STARKNET_RPC_URL_STARKNET_MAINNET) - Alchemy (if
ALCHEMY_API_KEYis set) - Public RPC fallback
RPC Resolution Priority (SVM)
- Explicit override:
SVM_RPC_URL_<NETWORK>(e.g.,SVM_RPC_URL_SOLANA_MAINNET) - Helius (if
HELIUS_API_KEYis set) - Public RPC fallback
Explicit RPC Overrides
Override specific networks when needed (hyphens become underscores in env var names):
# EVM overrides
EVM_RPC_URL_BASE=https://custom-base-rpc.example.com
EVM_RPC_URL_BASE_SEPOLIA=https://custom-sepolia-rpc.example.com
# Starknet overrides
STARKNET_RPC_URL_STARKNET_MAINNET=https://custom-starknet-mainnet.example.com
STARKNET_RPC_URL_STARKNET_SEPOLIA=https://custom-starknet-sepolia.example.com
# SVM overrides
SVM_RPC_URL_SOLANA_MAINNET=https://custom-solana-rpc.example.comMinimal (Base only with Alchemy)
CDP_API_KEY_ID=your-key-id
CDP_API_KEY_SECRET=your-secret
CDP_WALLET_SECRET=your-wallet-secret
ALCHEMY_API_KEY=your-alchemy-keyMulti-Network EVM
EVM_NETWORKS=base,optimism,arbitrum,polygon
ALCHEMY_API_KEY=your-alchemy-keyFull Stack (EVM + Solana)
EVM_NETWORKS=base,base-sepolia,optimism
SVM_NETWORKS=solana-mainnet,solana-devnet
ALCHEMY_API_KEY=your-alchemy-key
HELIUS_API_KEY=your-helius-key
SVM_PRIVATE_KEY=your-solana-private-keyStarknet (Opt-in)
STARKNET_NETWORKS=starknet-mainnet,starknet-sepolia
ALCHEMY_API_KEY=your-alchemy-key
STARKNET_SPONSOR_ADDRESS=0xyour-sponsor-address
STARKNET_PAYMASTER_API_KEY=your-paymaster-keyEnable distributed tracing:
export OTEL_SERVICE_NAME="x402-facilitator"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"Use Coinbase Developer Platform for managed key custody:
import { createFacilitator } from "@daydreamsai/facilitator";
import { createCdpEvmSigner } from "@daydreamsai/facilitator/signers/cdp";
import { CdpClient } from "@coinbase/cdp-sdk";
const cdp = new CdpClient();
const account = await cdp.evm.getOrCreateAccount({ name: "facilitator" });
const signer = createCdpEvmSigner({
cdpClient: cdp,
account,
network: "base",
rpcUrl: process.env.EVM_RPC_URL_BASE,
});
const facilitator = createFacilitator({
evmSigners: [
{ signer, networks: "eip155:8453", schemes: ["exact", "upto"] },
],
});import { createFacilitator } from "@daydreamsai/facilitator";
import { createMultiNetworkCdpSigners } from "@daydreamsai/facilitator/signers/cdp";
const signers = createMultiNetworkCdpSigners({
cdpClient: cdp,
account,
networks: {
base: process.env.EVM_RPC_URL_BASE,
"base-sepolia": process.env.BASE_SEPOLIA_RPC_URL,
optimism: process.env.OPTIMISM_RPC_URL,
},
});
const facilitator = createFacilitator({
evmSigners: [
{ signer: signers.base!, networks: "eip155:8453" },
{ signer: signers["base-sepolia"]!, networks: "eip155:84532" },
{ signer: signers.optimism!, networks: "eip155:10" },
],
});| CDP Network | CAIP-2 | Chain ID |
|---|---|---|
base |
eip155:8453 |
8453 |
base-sepolia |
eip155:84532 |
84532 |
ethereum |
eip155:1 |
1 |
ethereum-sepolia |
eip155:11155111 |
11155111 |
optimism |
eip155:10 |
10 |
arbitrum |
eip155:42161 |
42161 |
polygon |
eip155:137 |
137 |
avalanche |
eip155:43114 |
43114 |
Add custom logic at key points:
const facilitator = createFacilitator({
evmSigners: [{ signer, networks: "eip155:8453" }],
hooks: {
onBeforeVerify: async (ctx) => {
// Rate limiting, logging
},
onAfterVerify: async (ctx) => {
// Track verified payments
},
onVerifyFailure: async (ctx) => {
// Handle verification failures
},
onBeforeSettle: async (ctx) => {
// Validate before settlement
},
onAfterSettle: async (ctx) => {
// Analytics, notifications
},
onSettleFailure: async (ctx) => {
// Alerting, retry logic
},
},
});Returns supported payment schemes and networks.
Response:
{
"kinds": [
{ "x402Version": 2, "scheme": "exact", "network": "eip155:8453" },
{ "x402Version": 2, "scheme": "upto", "network": "eip155:8453" }
],
"signers": {
"eip155": ["0x..."]
}
}Validates a payment signature against requirements.
Request:
{
"paymentPayload": {
"x402Version": 2,
"accepted": {
"scheme": "exact",
"network": "eip155:8453",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"amount": "1000",
"payTo": "0x..."
},
"payload": { "signature": "0x...", "authorization": {} }
},
"paymentRequirements": {
"scheme": "exact",
"network": "eip155:8453",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"amount": "1000",
"payTo": "0x..."
}
}Response (Success):
{ "isValid": true, "payer": "0x..." }Response (Failure):
{ "isValid": false, "invalidReason": "invalid_signature" }Executes on-chain payment settlement.
Request: Same as /verify
Response (Success):
{
"success": true,
"transaction": "0x...",
"network": "eip155:8453",
"payer": "0x..."
}Response (Failure):
{
"success": false,
"errorReason": "insufficient_balance",
"network": "eip155:8453"
}Immediate, single-transaction settlement. Each payment request results in one on-chain transfer.
Supported tokens:
- USDC on Base (
0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) - SPL tokens on Solana
Starknet exact payments are gasless for users because a paymaster sponsors gas. The user still signs the transaction data.
Flow:
- Client receives
PaymentRequired(402 response). - Client calls the paymaster
paymaster_buildTransactionto get SNIP-12typed_data. - Client signs
typed_datawith their own Starknet account signer (private key or wallet). - Client sends
PaymentPayloadincludingtypedDatato the resource server/facilitator. - Facilitator verifies the payload and calls
paymaster_executeTransactionto submit the tx. - Paymaster pays gas from its sponsor account and broadcasts to Starknet.
Important: The paymaster never signs for the user. If the client cannot sign, the payment cannot be created. The facilitator rejects Starknet payloads without typedData. STARKNET_SPONSOR_ADDRESS identifies the paymaster sponsor account for /supported.
Permit-based flow for efficient EVM token payments:
- Client signs once - ERC-2612 Permit for a cap amount
- Multiple requests - Reuse the same Permit signature
- Automatic batching - Sweeper settles accumulated spend
- Settlement triggers:
- Idle timeout (2 minutes of inactivity)
- Deadline buffer (60 seconds before Permit expires)
- Cap threshold (90% of cap reached)
Session Lifecycle:
┌─────────┐ verify ┌─────────┐ sweep/close ┌─────────┐
│ None │ ───────────────▶│ Open │ ──────────────────▶ │ Closed │
└─────────┘ └────┬────┘ └─────────┘
│ settle
▼
┌─────────┐
│Settling │
└────┬────┘
│ success
▼
Back to Open (if cap/deadline allow)
Limitations:
- ERC-2612 Permit tokens only
- In-memory sessions (lost on restart without custom store)
The unified client wraps x402 client + HTTP helpers into a single
fetchWithPayment function. It handles 402 responses by creating a payment
payload and retrying the request with the PAYMENT-SIGNATURE header.
import { createUnifiedClient } from "@daydreamsai/facilitator/client";
import { createPublicClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";
const account = privateKeyToAccount(
process.env.CLIENT_EVM_PRIVATE_KEY as `0x${string}`
);
const publicClient = createPublicClient({
chain: base,
transport: http(process.env.RPC_URL),
});
const { fetchWithPayment, uptoScheme } = createUnifiedClient({
evmExact: { signer: account },
evmUpto: {
signer: account,
publicClient,
facilitatorUrl: process.env.FACILITATOR_URL,
// Optional: skip /supported lookup by setting a local signer map
// facilitatorSignerByNetwork: { "eip155:8453": "0x..." },
},
});
const response = await fetchWithPayment("https://api.example.com/premium");- ERC-2612 permits are cached per
(network, asset, owner, facilitator signer)and reused until close to expiry. - If a paid request still returns 402 with
cap_exhaustedorsession_closed, the unified client invalidates the cached permit and retries once. - You can force a new permit with
uptoScheme?.invalidatePermit("eip155:8453", "0x...").
Starknet exact requires typedData in the payment payload. The unified client
throws if typedData is missing.
The upto module provides components for batched payment tracking on resource servers.
When building a service that uses the upto scheme, understanding state ownership is critical. The client and facilitator each maintain different pieces of state:
┌─────────────────────────────────────────────────────────────────────────────┐
│ Upto Scheme State Ownership │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ YOUR SERVICE (Client) FACILITATOR │
│ ════════════════════ ═══════════ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ PermitCache │ │ Session Store │ │
│ │ ┌────────────┐ │ │ ┌────────────┐ │ │
│ │ │ Signed │ │ payment │ │ pendingSpent│ │ │
│ │ │ Permit │──┼───request────▶│ │ settledTotal│ │ │
│ │ │ (EIP-2612) │ │ │ │ cap/deadline│ │ │
│ │ └────────────┘ │ │ │ status │ │ │
│ │ │◀──────────────┼──└────────────┘ │ │
│ │ Invalidate on: │ cap_exhausted │ │ │
│ │ • cap_exhausted │ session_closed│ Sweeper settles │ │
│ │ • session_closed│ │ automatically │ │
│ │ • deadline near │ │ │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
| Aspect | Your Service (Client) | Facilitator |
|---|---|---|
| Stores | Signed permit (EIP-2612 signature) | Session metadata (cap, pending, settled) |
| Reuses | Same permit for multiple requests | Cap across payments until exhausted |
| Invalidates | On error codes or deadline | On deadline, cap exhaustion, or explicit close |
| Persists | Optional (in-memory is fine) | Required for production (Redis, PostgreSQL) |
Your service should maintain a permit cache for efficient permit reuse:
// Pseudocode for client-side permit management
interface CachedPermit {
signature: string;
cap: bigint;
deadline: bigint;
nonce: bigint;
network: string;
asset: string;
}
class PermitCache {
private cache = new Map<string, CachedPermit>();
// Key format: network:asset:owner:spender
get(key: string): CachedPermit | undefined {
const permit = this.cache.get(key);
if (!permit) return undefined;
// Pre-invalidate 60 seconds before deadline
const buffer = 60n;
if (BigInt(Math.floor(Date.now() / 1000)) + buffer >= permit.deadline) {
this.cache.delete(key);
return undefined;
}
return permit;
}
set(key: string, permit: CachedPermit): void {
this.cache.set(key, permit);
}
invalidate(key: string): void {
this.cache.delete(key);
}
}When to invalidate permits:
| Facilitator Response | Action |
|---|---|
cap_exhausted |
Invalidate permit, sign new one with fresh cap |
session_closed |
Invalidate permit, sign new one |
settling_in_progress |
Retry after short delay (session temporarily locked) |
| Success | Keep using cached permit |
The facilitator maintains session state internally. You don't need to track:
- Pending spend - How much has been charged but not yet settled on-chain
- Settled total - How much has been settled on-chain
- Settlement timing - When to batch and settle (handled by sweeper)
Session ID is deterministic: The same permit signature always maps to the same session. This is how the facilitator correlates multiple payments under one cap.
// Facilitator generates session ID from permit fields
function generateSessionId(permit: PaymentPayload): string {
const key = {
network, asset, owner, spender, cap, nonce, deadline, signature
};
return sha256(JSON.stringify(key));
}1. Client: Check cache for valid permit
└─ Cache miss → Sign new EIP-2612 permit (cap: 50 USDC, deadline: 1 hour)
└─ Cache hit → Reuse existing permit
2. Client: Send payment request with permit
POST /verify → Facilitator validates signature
3. Facilitator: Track payment internally
└─ First request → Create session (cap=50, pending=10)
└─ Later requests → Update session (pending += amount)
4. Facilitator Sweeper (background, every 30s):
└─ Idle > 2min with pending > 0 → Settle batch on-chain
└─ Deadline < 60s → Settle and close session
└─ (settled + pending) >= 90% cap → Settle batch
5. Client: Receives cap_exhausted
└─ Invalidate permit in cache
└─ Sign new permit
└─ Retry request
Option 1: Use the built-in client (simplest)
The createUnifiedClient handles permit caching automatically:
import { createUnifiedClient } from "@daydreamsai/facilitator/client";
const { fetchWithPayment } = createUnifiedClient({
evmUpto: {
signer: account,
publicClient,
facilitatorUrl: "http://localhost:8090",
},
});
// Permit caching is automatic
const response = await fetchWithPayment("https://api.example.com/premium");Option 2: Build custom permit management
Only do this if you need:
- Cross-service permit sharing
- Persistence across restarts
- Custom invalidation logic
// Custom integration pseudocode
async function makePayment(amount: bigint) {
const cacheKey = `${network}:${asset}:${owner}:${facilitatorSigner}`;
let permit = permitCache.get(cacheKey);
if (!permit) {
permit = await signPermit({ cap: amount * 10n, deadline: 3600 });
permitCache.set(cacheKey, permit);
}
const response = await fetch(paidEndpoint, {
headers: { "X-Payment": encodePayment(permit, amount) }
});
if (response.status === 402) {
const error = await response.json();
if (error.code === "cap_exhausted" || error.code === "session_closed") {
permitCache.invalidate(cacheKey);
return makePayment(amount); // Retry with new permit
}
}
return response;
}| Scenario | Client Storage | Facilitator Storage |
|---|---|---|
| Single instance, dev/test | In-memory | In-memory |
| Single instance, production | In-memory (permits are cheap to re-sign) | Redis/PostgreSQL |
| Multi-instance, production | Redis (share permits across instances) | Redis/PostgreSQL |
Key insight: Client-side permit caching is an optimization, not a requirement. If you lose your cache, you just sign a new permit. Facilitator-side session state is critical - losing it means losing track of pending payments.
import {
createUptoModule,
trackUptoPayment,
generateSessionId,
formatSession,
TRACKING_ERROR_MESSAGES,
TRACKING_ERROR_STATUS,
} from "@daydreamsai/facilitator/upto";import { createUptoModule } from "@daydreamsai/facilitator/upto";
import { HTTPFacilitatorClient } from "@x402/core/http";
const facilitatorClient = new HTTPFacilitatorClient({
url: "http://localhost:8090",
});
const upto = createUptoModule({
facilitatorClient,
// Optional: custom session store (defaults to InMemoryUptoSessionStore)
// store: new RedisUptoSessionStore(redis),
// Optional: sweeper configuration for auto settlement
// sweeperConfig: { intervalMs: 30_000, idleSettleMs: 120_000 },
});
// Use the sweeper plugin for automatic settlement
app.use(upto.createSweeper());import { trackUptoPayment, TRACKING_ERROR_STATUS } from "@daydreamsai/facilitator/upto";
const result = await trackUptoPayment(upto.store, paymentPayload, requirements);
if (!result.success) {
// Handle error
const status = TRACKING_ERROR_STATUS[result.error];
return { error: result.error, status };
}
// Payment tracked successfully
console.log(`Session ${result.sessionId} updated`);
console.log(`Pending: ${result.session.pendingSpent}`);Replace in-memory storage with persistent storage:
import type { UptoSessionStore, UptoSession } from "@daydreamsai/facilitator/upto";
class RedisSessionStore implements UptoSessionStore {
async get(id: string): Promise<UptoSession | undefined> {
/* Redis get */
}
async set(id: string, session: UptoSession): Promise<void> {
/* Redis set */
}
async delete(id: string): Promise<void> {
/* Redis del */
}
async *entries(): AsyncIterableIterator<[string, UptoSession]> {
/* Redis scan */
}
}Or use the built-in Redis store + global sweeper lock:
import {
RedisUptoSessionStore,
createRedisSweeperLock,
} from "@daydreamsai/facilitator/upto";
const store = new RedisUptoSessionStore(redis, {
keyPrefix: "facilitator:upto",
});
const sweeperLock = createRedisSweeperLock(redis, {
key: "facilitator:upto:sweeper:lock",
ttlMs: 60_000,
});
const upto = createUptoModule({
facilitatorClient,
store,
sweeperConfig: {
lock: sweeperLock,
settlingTimeoutMs: 300_000,
},
});Pre-configured resource server with all schemes registered:
import { createResourceServer } from "@daydreamsai/facilitator/server";
import { HTTPFacilitatorClient } from "@x402/core/http";
const facilitatorClient = new HTTPFacilitatorClient({
url: "http://localhost:8090",
});
const resourceServer = createResourceServer(facilitatorClient);
await resourceServer.initialize();
// Use with payment middleware
resourceServer.onAfterVerify(async (ctx) => {
if (ctx.requirements.scheme === "upto") {
// Track upto sessions
}
});Pre-built payment middleware for popular web frameworks. Each middleware handles:
- Payment verification via the facilitator
- Automatic settlement after successful requests
- Paywall HTML for browser-based payments
- Upto session tracking (optional)
import { Elysia } from "elysia";
import { node } from "@elysiajs/node";
import { HTTPFacilitatorClient } from "@x402/core/http";
import { createPaywall, evmPaywall, svmPaywall } from "@x402/paywall";
import { createElysiaPaidRoutes } from "@daydreamsai/facilitator/elysia";
import { createResourceServer } from "@daydreamsai/facilitator/server";
import { createUptoModule } from "@daydreamsai/facilitator/upto";
const facilitatorClient = new HTTPFacilitatorClient({ url: "http://localhost:8090" });
const resourceServer = createResourceServer(facilitatorClient);
const upto = createUptoModule({ facilitatorClient, autoSweeper: true });
const paywallProvider = createPaywall()
.withNetwork(evmPaywall)
.withNetwork(svmPaywall)
.build();
const app = new Elysia({ prefix: "/api", adapter: node() });
createElysiaPaidRoutes(app, {
basePath: "/api",
middleware: {
resourceServer,
upto,
paywallProvider,
paywallConfig: { appName: "My Paid API", testnet: true },
},
})
.get("/premium", () => ({ message: "premium content" }), {
payment: {
accepts: {
scheme: "exact",
network: "eip155:8453",
payTo: "0x...",
price: "$0.01",
},
description: "Premium content",
mimeType: "application/json",
},
});
app.listen(4022);import { Hono } from "hono";
import { HTTPFacilitatorClient } from "@x402/core/http";
import { createPaywall, evmPaywall, svmPaywall } from "@x402/paywall";
import { createHonoPaidRoutes } from "@daydreamsai/facilitator/hono";
import { createResourceServer } from "@daydreamsai/facilitator/server";
import { createUptoModule } from "@daydreamsai/facilitator/upto";
const facilitatorClient = new HTTPFacilitatorClient({ url: "http://localhost:8090" });
const resourceServer = createResourceServer(facilitatorClient);
const upto = createUptoModule({ facilitatorClient, autoSweeper: true });
const paywallProvider = createPaywall()
.withNetwork(evmPaywall)
.withNetwork(svmPaywall)
.build();
const app = new Hono().basePath("/api");
createHonoPaidRoutes(app, {
basePath: "/api",
middleware: {
resourceServer,
upto,
paywallProvider,
paywallConfig: { appName: "My Paid API", testnet: true },
},
})
.get("/premium", (c) => c.json({ message: "premium content" }), {
payment: {
accepts: {
scheme: "exact",
network: "eip155:8453",
payTo: "0x...",
price: "$0.01",
},
description: "Premium content",
mimeType: "application/json",
},
});
export default { port: 4023, fetch: app.fetch };import express from "express";
import { HTTPFacilitatorClient } from "@x402/core/http";
import { createPaywall, evmPaywall, svmPaywall } from "@x402/paywall";
import { createExpressPaidRoutes } from "@daydreamsai/facilitator/express";
import { createResourceServer } from "@daydreamsai/facilitator/server";
import { createUptoModule } from "@daydreamsai/facilitator/upto";
const facilitatorClient = new HTTPFacilitatorClient({ url: "http://localhost:8090" });
const resourceServer = createResourceServer(facilitatorClient);
const upto = createUptoModule({ facilitatorClient, autoSweeper: true });
const paywallProvider = createPaywall()
.withNetwork(evmPaywall)
.withNetwork(svmPaywall)
.build();
const app = express();
app.use(express.json());
createExpressPaidRoutes(app, {
basePath: "/api",
middleware: {
resourceServer,
upto,
paywallProvider,
paywallConfig: { appName: "My Paid API", testnet: true },
},
})
.get("/api/premium", (_req, res) => res.json({ message: "premium content" }), {
payment: {
accepts: {
scheme: "exact",
network: "eip155:8453",
payTo: "0x...",
price: "$0.01",
},
description: "Premium content",
mimeType: "application/json",
},
});
app.listen(4024);| Option | Type | Description |
|---|---|---|
resourceServer |
x402ResourceServer |
Pre-configured resource server instance |
upto |
UptoModule |
Optional upto module for batched payments |
paywallProvider |
PaywallProvider |
Optional paywall HTML generator |
paywallConfig |
PaywallConfig |
Paywall display options |
autoSettle |
boolean |
Auto-settle after successful requests (default: true) |
paymentHeaderAliases |
string[] |
Alternative header names for payment data |
Each route can specify payment requirements:
{
payment: {
accepts: {
scheme: "exact" | "upto",
network: "eip155:8453", // CAIP-2 network ID
payTo: "0x...", // Recipient address
price: "$0.01" | { // Price shorthand or detailed
amount: "10000",
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
extra: { name: "USD Coin", version: "2" }
}
},
description: "What this endpoint provides",
mimeType: "application/json"
}
}When a browser (Accept: text/html) requests a paid endpoint without payment, the middleware returns an interactive paywall page instead of a JSON error.
Setup:
-
Install the paywall package:
bun add @x402/paywall
-
Create and configure the provider:
import { createPaywall, evmPaywall, svmPaywall } from "@x402/paywall"; const paywallProvider = createPaywall() .withNetwork(evmPaywall) // EVM chains .withNetwork(svmPaywall) // Solana .build();
-
Pass to middleware config:
createElysiaPaidRoutes(app, { middleware: { resourceServer, paywallProvider, paywallConfig: { appName: "My App", testnet: true, // Show testnet warning }, }, });
Paywall Config Options:
| Option | Type | Description |
|---|---|---|
appName |
string |
Application name shown in paywall |
testnet |
boolean |
Display testnet warning banner |
# Run tests
bun test
# Watch mode
bun test:watch
# Coverage
bun test:coverage-
Start the facilitator:
bun dev
-
Start the demo paid API:
bun smoke:api
-
Run the smoke client:
export CLIENT_EVM_PRIVATE_KEY="0x..." bun smoke:upto
-
Private Key Management
- Use CDP for managed custody (recommended)
- Or use secrets managers (AWS Secrets Manager, HashiCorp Vault)
- Never commit
.envfiles with real keys
-
Network Security
- Run behind a reverse proxy (nginx, Cloudflare)
- Enable TLS/HTTPS
- Implement rate limiting
-
Signature Validation
- All signatures verified via EIP-712 typed data
- Permit deadlines enforced with buffer
- Network/chain ID validation prevents replay attacks
-
Session Persistence
- Replace
InMemoryUptoSessionStorewith Redis/PostgreSQL - Required for multi-instance deployments
- Replace
-
RPC Resilience
- Configure multiple RPC endpoints
- Implement retry logic with exponential backoff
-
Monitoring
- Enable OpenTelemetry tracing
- Set up alerts for settlement failures
# docker-compose.yml
services:
facilitator:
build: .
environment:
- CDP_API_KEY_ID=${CDP_API_KEY_ID}
- CDP_API_KEY_SECRET=${CDP_API_KEY_SECRET}
- CDP_WALLET_SECRET=${CDP_WALLET_SECRET}
- PORT=8090
ports:
- "8090:8090"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8090/supported"]
interval: 30s
timeout: 10s
retries: 3For quick cloud deployment, use the Railway deploy button in the Quick Start section, or follow these manual steps:
- Create a Railway template from this repository at railway.com
- Configure environment variables:
- For CDP: Set
CDP_API_KEY_ID,CDP_API_KEY_SECRET,CDP_WALLET_SECRET,CDP_ACCOUNT_NAMEfrom your Coinbase Developer Platform account - For private key: Set
EVM_PRIVATE_KEYwith your hex-formatted private key
- For CDP: Set
- Optional configuration:
EVM_NETWORKS- Comma-separated networks (e.g.,base,optimism,arbitrum)ALCHEMY_API_KEY- For better RPC reliabilitySVM_NETWORKS+SVM_PRIVATE_KEY- Enable Solana support
- Deploy and wait for the health check to pass
- Your facilitator is live at
https://your-app.railway.app
The repository includes a railway.toml configuration that uses the existing Dockerfile for builds.
MIT