Node SDK Reference#
Node SDK Reference (for exact, aggr_deferred)#
Packages#
| Package | Description |
|---|---|
@okxweb3/x402-core | Core: client, server, facilitator, types |
@okxweb3/x402-evm | EVM mechanisms: exact, aggr_deferred |
@okxweb3/x402-express | Express middleware (seller side) |
@okxweb3/x402-next | Next.js middleware (seller side) |
@okxweb3/x402-hono | Hono middleware (seller side) |
@okxweb3/x402-fastify | Fastify middleware (seller side) |
@okxweb3/x402-fetch | Fetch wrapper (buyer side) |
@okxweb3/x402-axios | Axios wrapper (buyer side) |
@okxweb3/x402-mcp | MCP integration |
@okxweb3/x402-paywall | Browser paywall UI |
@okxweb3/x402-extensions | Protocol extensions |
Core types#
Network#
type Network = `${string}:${string}`;
// CAIP-2 format, e.g., "eip155:196", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
Money / Price / AssetAmount#
type Money = string | number;
// User-friendly amount, e.g., "$0.01", "0.01", 0.01
type AssetAmount = {
asset: string; // Token contract address
amount: string; // Amount in token's smallest unit (e.g., "10000" for 0.01 USDC)
extra?: Record<string, unknown>; // Scheme-specific data (e.g., EIP-712 domain)
};
type Price = Money | AssetAmount;
// Either a user-friendly amount or a specific token amount
ResourceInfo#
interface ResourceInfo {
url: string; // Resource URL path
description?: string; // Human-readable description
mimeType?: string; // Response content type (e.g., "application/json")
}
PaymentRequirements#
Describes the payment options the seller accepts.
type PaymentRequirements = {
scheme: string; // Payment scheme: "exact" | "aggr_deferred"
network: Network; // CAIP-2 network identifier
asset: string; // Token contract address
amount: string; // Price in token's smallest unit
payTo: string; // Recipient wallet address
maxTimeoutSeconds: number; // Payment authorization validity window
extra: Record<string, unknown>; // Scheme-specific data
};
extra field per scheme:
| Scheme | Extra field | Type | Description |
|---|---|---|---|
exact (EIP-3009) | extra.eip712.name | string | EIP-712 domain name (e.g. "USD Coin") |
exact (EIP-3009) | extra.eip712.version | string | EIP-712 domain version (e.g. "2") |
PaymentRequired#
The HTTP 402 response body sent to the client.
type PaymentRequired = {
x402Version: number; // Protocol version (currently 2)
error?: string; // Optional error message
resource: ResourceInfo; // Protected resource metadata
accepts: PaymentRequirements[]; // List of accepted payment options
extensions?: Record<string, unknown>; // Extension data (e.g., Bazaar)
};
PaymentPayload#
The signed payment the client submits in the retry request.
type PaymentPayload = {
x402Version: number; // Must match server's version
resource?: ResourceInfo; // Optional resource reference
accepted: PaymentRequirements; // The chosen payment option from `accepts`
payload: Record<string, unknown>; // Scheme-specific signed data (see below)
extensions?: Record<string, unknown>; // Extension data
};
payload field per scheme:
exact (EIP-3009) payload:
{
signature: `0x${string}`; // EIP-712 signature
authorization: {
from: `0x${string}`; // Buyer wallet address
to: `0x${string}`; // Seller wallet address
value: string; // Amount in smallest unit
validAfter: string; // Unix timestamp (start validity)
validBefore: string; // Unix timestamp (end validity)
nonce: `0x${string}`; // 32-byte unique nonce
};
}
aggr_deferred payload:
{
signature: `0x${string}`; // Session key signature
authorization: { /* same as EIP-3009 */ };
// acceptedExtraOverrides includes sessionCert
}
VerifyResponse#
type VerifyResponse = {
isValid: boolean; // Whether signature is valid
invalidReason?: string; // Machine-readable reason code
invalidMessage?: string; // Human-readable error message
payer?: string; // Recovered payer address
extensions?: Record<string, unknown>;
};
SettleResponse#
type SettleResponse = {
success: boolean; // Whether settlement succeeded
status?: "pending" | "success" | "timeout"; // OKX extension
errorReason?: string; // Machine-readable error code
errorMessage?: string; // Human-readable error message
payer?: string; // Payer address
transaction: string; // On-chain transaction hash (empty for aggr_deferred)
network: Network; // Settlement network
amount?: string; // Actual settled amount (may differ for "upto")
extensions?: Record<string, unknown>;
};
SupportedKind / SupportedResponse#
type SupportedKind = {
x402Version: number;
scheme: string;
network: Network;
extra?: Record<string, unknown>;
};
type SupportedResponse = {
kinds: SupportedKind[];
extensions: string[]; // Supported extension keys
signers: Record<string, string[]>; // CAIP family → signer addresses
};
Server API (x402ResourceServer)#
Constructor#
import { x402ResourceServer } from "@okxweb3/x402-core/server";
const server = new x402ResourceServer(facilitatorClients?);
// facilitatorClients: FacilitatorClient | FacilitatorClient[]
register(network, server)#
Register a server-side scheme. Chainable.
server
.register("eip155:84532", new ExactEvmScheme())
.register("eip155:196", new AggrDeferredEvmScheme());
registerExtension(extension)#
interface ResourceServerExtension {
key: string;
enrichDeclaration?: (declaration: unknown, transportContext: unknown) => unknown;
enrichPaymentRequiredResponse?: (
declaration: unknown,
context: PaymentRequiredContext,
) => Promise<unknown>;
enrichSettlementResponse?: (
declaration: unknown,
context: SettleResultContext,
) => Promise<unknown>;
}
initialize()#
Fetch supported kinds from the facilitator. Call once at startup.
await server.initialize();
buildPaymentRequirements(config) → PaymentRequirements[]#
interface ResourceConfig {
scheme: string; // "exact" | "aggr_deferred" | "upto"
payTo: string; // Recipient wallet address
price: Price; // "$0.01" or AssetAmount
network: Network; // "eip155:196"
maxTimeoutSeconds?: number; // Default: 300
extra?: Record<string, unknown>;
}
const reqs = await server.buildPaymentRequirements({
scheme: "exact",
payTo: "0xSeller",
price: "$0.01",
network: "eip155:196",
});
buildPaymentRequirementsFromOptions(options, context) → PaymentRequirements[]#
Dynamic pricing and payTo. Functions receive a context parameter.
const reqs = await server.buildPaymentRequirementsFromOptions(
[
{
scheme: "exact",
network: "eip155:196",
payTo: (ctx) => ctx.sellerId === "A" ? "0xWalletA" : "0xWalletB",
price: (ctx) => ctx.premium ? "$0.10" : "$0.01",
},
],
requestContext
);
verifyPayment(payload, requirements) → VerifyResponse#
const result = await server.verifyPayment(paymentPayload, requirements);
// result.isValid: boolean
settlePayment(payload, requirements, ...) → SettleResponse#
const result = await server.settlePayment(
paymentPayload,
requirements,
declaredExtensions?, // Extension data from 402 response
transportContext?, // HTTP transport context
settlementOverrides?, // { amount: "$0.05" } for upto scheme
);
Server lifecycle hooks#
| Hook | Context | Abort / recover |
|---|---|---|
onBeforeVerify | { paymentPayload, requirements } | { abort: true, reason, message? } |
onAfterVerify | { paymentPayload, requirements, result } | No |
onVerifyFailure | { paymentPayload, requirements, error } | { recovered: true, result } |
onBeforeSettle | { paymentPayload, requirements } | { abort: true, reason, message? } |
onAfterSettle | { paymentPayload, requirements, result, transportContext? } | No |
onSettleFailure | { paymentPayload, requirements, error } | { recovered: true, result } |
server.onBeforeVerify(async (ctx) => {
// Log or gate verification
});
server.onAfterSettle(async (ctx) => {
console.log(`Settled: ${ctx.result.transaction} on ${ctx.result.network}`);
});
server.onSettleFailure(async (ctx) => {
if (ctx.error.message.includes("timeout")) {
return { recovered: true, result: { success: true, transaction: "", network: "eip155:196" } };
}
});
HTTP resource server (x402HTTPResourceServer)#
A higher-level wrapper that handles route matching, paywalls, and HTTP-specific logic.
Constructor#
import { x402HTTPResourceServer } from "@okxweb3/x402-core/http";
const httpServer = new x402HTTPResourceServer(resourceServer, routes);
RoutesConfig#
type RoutesConfig = Record<string, RouteConfig> | RouteConfig;
interface RouteConfig {
accepts: PaymentOption | PaymentOption[]; // Accepted payment methods
resource?: string; // Override resource name
description?: string; // Human-readable description
mimeType?: string; // Response MIME type
customPaywallHtml?: string; // Custom HTML for browser 402 page
unpaidResponseBody?: (ctx: HTTPRequestContext) => HTTPResponseBody | Promise<HTTPResponseBody>;
settlementFailedResponseBody?: (ctx, result) => HTTPResponseBody | Promise<HTTPResponseBody>;
extensions?: Record<string, unknown>;
}
interface PaymentOption {
scheme: string; // "exact" | "aggr_deferred" | "upto"
payTo: string | DynamicPayTo; // Static or dynamic recipient
price: Price | DynamicPrice; // Static or dynamic price
network: Network;
maxTimeoutSeconds?: number;
extra?: Record<string, unknown>;
}
// Dynamic functions receive HTTPRequestContext
type DynamicPayTo = (context: HTTPRequestContext) => string | Promise<string>;
type DynamicPrice = (context: HTTPRequestContext) => Price | Promise<Price>;
onSettlementTimeout(hook)#
type OnSettlementTimeoutHook = (txHash: string, network: string) => Promise<{ confirmed: boolean }>;
httpServer.onSettlementTimeout(async (txHash, network) => {
// Custom recovery logic
return { confirmed: false };
});
onProtectedRequest(hook)#
type ProtectedRequestHook = (
context: HTTPRequestContext,
routeConfig: RouteConfig,
) => Promise<void | { grantAccess: true } | { abort: true; reason: string }>;
httpServer.onProtectedRequest(async (ctx, config) => {
// Grant free access for certain users
if (ctx.adapter.getHeader("x-api-key") === "internal") {
return { grantAccess: true };
}
});
Middleware reference#
Express (@okxweb3/x402-express)#
import {
paymentMiddleware,
paymentMiddlewareFromConfig,
paymentMiddlewareFromHTTPServer,
setSettlementOverrides,
} from "@okxweb3/x402-express";
// From pre-configured server (recommended)
app.use(paymentMiddleware(routes, server, paywallConfig?, paywall?, syncFacilitatorOnStart?));
// From config (creates server internally)
app.use(paymentMiddlewareFromConfig(routes, facilitatorClients?, schemes?, paywallConfig?, paywall?, syncFacilitatorOnStart?));
// From HTTP server (most control)
app.use(paymentMiddlewareFromHTTPServer(httpServer, paywallConfig?, paywall?, syncFacilitatorOnStart?));
// Settlement override in handler (for "upto" scheme)
app.post("/api/generate", (req, res) => {
setSettlementOverrides(res, { amount: "$0.05" });
res.json({ result: "..." });
});
| Parameter | Type | Default | Description |
|---|---|---|---|
routes | RoutesConfig | required | Route → payment-config map |
server | x402ResourceServer | required | Pre-configured resource server |
paywallConfig | PaywallConfig | undefined | Browser paywall settings |
paywall | PaywallProvider | undefined | Custom paywall renderer |
syncFacilitatorOnStart | boolean | true | Fetch supported kinds on the first request |
Next.js (@okxweb3/x402-next)#
import {
paymentProxy,
paymentProxyFromConfig,
paymentProxyFromHTTPServer,
withX402,
withX402FromHTTPServer,
} from "@okxweb3/x402-next";
// As global middleware (middleware.ts)
const proxy = paymentProxy(routes, server, paywallConfig?, paywall?, syncFacilitatorOnStart?);
export async function middleware(request: NextRequest) { return proxy(request); }
export const config = { matcher: ["/api/:path*"] };
// Per-route wrapper (app/api/data/route.ts)
export const GET = withX402(handler, routeConfig, server, paywallConfig?, paywall?, syncFacilitatorOnStart?);
export const GET = withX402FromHTTPServer(handler, httpServer, paywallConfig?, paywall?, syncFacilitatorOnStart?);
Hono (@okxweb3/x402-hono)#
import { paymentMiddleware, paymentMiddlewareFromConfig, paymentMiddlewareFromHTTPServer } from "@okxweb3/x402-hono";
app.use("/*", paymentMiddleware(routes, server, paywallConfig?, paywall?, syncFacilitatorOnStart?));
Fastify (@okxweb3/x402-fastify)#
import { paymentMiddleware, paymentMiddlewareFromConfig, paymentMiddlewareFromHTTPServer } from "@okxweb3/x402-fastify";
// NOTE: Fastify registers hooks directly, returns void
paymentMiddleware(app, routes, server, paywallConfig?, paywall?, syncFacilitatorOnStart?);
EVM mechanism types#
ExactEvmScheme (server side)#
import { ExactEvmScheme } from "@okxweb3/x402-evm";
const scheme = new ExactEvmScheme(); // No constructor args for server-side
scheme.scheme; // "exact"
// Automatically handles price parsing, EIP-712 domain injection
AggrDeferredEvmScheme (server side)#
import { AggrDeferredEvmScheme } from "@okxweb3/x402-evm/deferred/server";
const scheme = new AggrDeferredEvmScheme();
scheme.scheme; // "aggr_deferred"
// Delegates to ExactEvmScheme for price parsing
Client API (buyer side)#
The buyer-side packages handle 402 Payment Required responses automatically: parse the payment requirements → sign the payment payload via the configured EVM scheme → resend the request with the PAYMENT header.
Pick the package matching your existing HTTP client:
| Package | Wraps | Use case |
|---|---|---|
@okxweb3/x402-axios | AxiosInstance | Existing Axios codebases; need interceptors / instance config |
@okxweb3/x402-fetch | globalThis.fetch | fetch-based runtimes (browsers, Edge, Node 18+) |
The two have an identical API shape: wrapXxxWithPayment(client_or_fetch, x402Client) and wrapXxxWithPaymentFromConfig(client_or_fetch, config).
Axios — @okxweb3/x402-axios#
npm install @okxweb3/x402-axios @okxweb3/x402-evm @okxweb3/x402-core axios
import axios from "axios";
import { wrapAxiosWithPaymentFromConfig } from "@okxweb3/x402-axios";
import { ExactEvmScheme, toClientEvmSigner } from "@okxweb3/x402-evm";
import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { xLayer } from "viem/chains";
// Build a viem signer from the buyer's private key
const signer = toClientEvmSigner(
createWalletClient({
account: privateKeyToAccount(process.env.EVM_PRIVATE_KEY as `0x${string}`),
chain: xLayer,
transport: http(),
}),
);
const api = wrapAxiosWithPaymentFromConfig(axios.create(), {
schemes: [
{
network: "eip155:196", // X Layer; use "eip155:*" to match any EVM chain
client: new ExactEvmScheme(signer),
},
],
});
// 402 → sign → retry, fully transparent to the caller
const response = await api.get("https://api.example.com/paid-endpoint");
Fetch — @okxweb3/x402-fetch#
npm install @okxweb3/x402-fetch @okxweb3/x402-evm @okxweb3/x402-core
import { wrapFetchWithPaymentFromConfig } from "@okxweb3/x402-fetch";
import { ExactEvmScheme, toClientEvmSigner } from "@okxweb3/x402-evm";
import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { xLayer } from "viem/chains";
const signer = toClientEvmSigner(
createWalletClient({
account: privateKeyToAccount(process.env.EVM_PRIVATE_KEY as `0x${string}`),
chain: xLayer,
transport: http(),
}),
);
const fetchWithPayment = wrapFetchWithPaymentFromConfig(fetch, {
schemes: [
{
network: "eip155:196",
client: new ExactEvmScheme(signer),
},
],
});
const response = await fetchWithPayment("https://api.example.com/paid-endpoint");
Builder pattern with x402Client#
When you need to register multiple schemes / networks, or share one client across multiple transports, use the explicit builder.
import axios from "axios";
import { wrapAxiosWithPayment, x402Client } from "@okxweb3/x402-axios";
import { ExactEvmScheme, toClientEvmSigner } from "@okxweb3/x402-evm";
const client = new x402Client()
.register("eip155:196", new ExactEvmScheme(signer));
const api = wrapAxiosWithPayment(axios.create(), client);
x402Client is also re-exported from @okxweb3/x402-fetch, so the same instance can serve both transports.
Reading the payment receipt#
After the resent request succeeds, the server returns a PAYMENT-RESPONSE header containing the on-chain receipt (txHash, actual settled amount, etc.). Decode it with decodePaymentResponseHeader:
import { decodePaymentResponseHeader } from "@okxweb3/x402-axios"; // or "@okxweb3/x402-fetch"
// Axios
const paymentResponse = response.headers["payment-response"];
// Fetch
// const paymentResponse = response.headers.get("PAYMENT-RESPONSE");
if (paymentResponse) {
const receipt = decodePaymentResponseHeader(paymentResponse);
console.log("Payment receipt:", receipt);
}
x402ClientConfig#
| Field | Type | Description |
|---|---|---|
schemes | SchemeRegistration[] | Required. Each entry pairs a network (e.g. "eip155:196", "eip155:*") with a scheme client (e.g. new ExactEvmScheme(signer)). |
policies | PaymentPolicy[] | Optional. See Policies below — applied in order to filter / transform accepts. |
paymentRequirementsSelector | SelectPaymentRequirements | Optional. Picks the final entry from the filtered list. Default: (version, accepts) => accepts[0]. |
Selection pipeline#
After receiving a 402, the client decides what to sign in three steps:
- Filter by registered schemes — keep only
acceptswhosenetwork+schemeare both registered viaregister(). - Apply policies in order — each
PaymentPolicyfurther filters / transforms the list. - Selector pick — choose the final entry to sign from the filtered list.
If step 1 or 2 leaves an empty array, the client throws — no signature is attempted.
Policies — PaymentPolicy#
type PaymentPolicy = (
x402Version: number,
paymentRequirements: PaymentRequirements[],
) => PaymentRequirements[];
A policy is a pure function: takes the current accepts, returns a filtered subset (or transformed copy). Common cases: amount caps, network whitelists, scheme preferences.
import {
wrapAxiosWithPaymentFromConfig,
type PaymentPolicy,
} from "@okxweb3/x402-axios";
// Reject single payments above 1 USDT (1_000_000 atomic units, 6 decimals)
const maxAmountPolicy: PaymentPolicy = (_version, reqs) =>
reqs.filter(r => BigInt(r.amount) <= 1_000_000n);
// Allow X Layer mainnet only
const xLayerOnlyPolicy: PaymentPolicy = (_version, reqs) =>
reqs.filter(r => r.network === "eip155:196");
// When both schemes are offered, prefer "exact"
const preferExactPolicy: PaymentPolicy = (_version, reqs) => {
const exact = reqs.filter(r => r.scheme === "exact");
return exact.length > 0 ? exact : reqs;
};
const api = wrapAxiosWithPaymentFromConfig(axios.create(), {
schemes: [{ network: "eip155:196", client: new ExactEvmScheme(signer) }],
policies: [maxAmountPolicy, xLayerOnlyPolicy, preferExactPolicy],
});
Policies run in array order; put "tightening" policies (amount caps, whitelists) first and "preference" policies last.
Custom selector — SelectPaymentRequirements#
type SelectPaymentRequirements = (
x402Version: number,
paymentRequirements: PaymentRequirements[],
) => PaymentRequirements;
The selector runs after policies. Use it when the filtered list still has multiple entries and you need explicit picking logic (e.g. cheapest):
const cheapestFirst: SelectPaymentRequirements = (_version, reqs) =>
[...reqs].sort((a, b) => Number(BigInt(a.amount) - BigInt(b.amount)))[0];
const api = wrapAxiosWithPaymentFromConfig(axios.create(), {
schemes: [{ network: "eip155:*", client: new ExactEvmScheme(signer) }],
paymentRequirementsSelector: cheapestFirst,
});
Lifecycle hooks#
x402Client exposes three lifecycle hooks for instrumentation, last-mile gating, and error recovery. Register them via the builder form:
import { wrapAxiosWithPayment, x402Client } from "@okxweb3/x402-axios";
import { ExactEvmScheme } from "@okxweb3/x402-evm";
const client = new x402Client()
.register("eip155:196", new ExactEvmScheme(signer))
// 1. Before signing — can abort the whole payment
.onBeforePaymentCreation(async ({ paymentRequired, selectedRequirements }) => {
const tooExpensive = BigInt(selectedRequirements.amount) > 5_000_000n;
if (tooExpensive) {
return { abort: true, reason: "Amount exceeds buyer policy cap" };
}
})
// 2. After signing succeeds — observe only (logging, telemetry)
.onAfterPaymentCreation(async ({ paymentPayload }) => {
console.log("Signed payload nonce:", paymentPayload.payload?.authorization?.nonce);
})
// 3. On signing failure — can recover by returning a manually constructed payload
.onPaymentCreationFailure(async ({ error }) => {
console.error("payment creation failed:", error.message);
// return { recovered: true, payload: fallbackPayload };
});
const api = wrapAxiosWithPayment(axios.create(), client);
| Hook | Triggered when | Return semantics |
|---|---|---|
onBeforePaymentCreation | Selection done, before scheme signs | Return void to continue · { abort: true, reason } to cancel and reject |
onAfterPaymentCreation | After the scheme returns the signed payload | void only (observe, can't change) |
onPaymentCreationFailure | When the scheme throws while signing | void to keep throwing · { recovered: true, payload } to recover with a substitute |
Hooks within the same stage run in registration order.
Client extension — registerExtension#
Use this when the PaymentRequired response carries an extensions field and you need scheme-related payload enhancements (e.g. EIP-2612 permit signature for gas sponsoring). enrichPaymentPayload only fires when paymentRequired.extensions contains a matching key.
client.registerExtension({
key: "eip2612GasSponsoring",
async enrichPaymentPayload(payload, paymentRequired) {
// Sign EIP-2612 permit and attach it to payload.extensions
return { ...payload, extensions: { ...payload.extensions, /* ... */ } };
},
});
Node SDK Reference (for charge, session)#
Install & import#
npm install @okxweb3/mpp viem
@okxweb3/mpp re-exports the entire upstream mppx namespace, so app code typically only needs to import this single package. viem is used for session EIP-712 signing (SessionSigner); charge does not need it.
// Top level: mppx runtime + namespaces
import { Mppx, Errors } from '@okxweb3/mpp'
// Shared EVM: SA API client, EIP-712 helpers
import { SaApiClient, verifyVoucher, buildSettleAuth } from '@okxweb3/mpp/evm'
// EVM server-side factories
import { charge, session } from '@okxweb3/mpp/evm/server'
Charge — one-time payment#
Register#
const saClient = new SaApiClient({
apiKey: process.env.OKX_API_KEY!,
secretKey: process.env.OKX_SECRET_KEY!,
passphrase: process.env.OKX_PASSPHRASE!,
})
const mppx = Mppx.create({
methods: [charge({ saClient })],
realm: 'demo.merchant.com',
secretKey: process.env.MPPX_SECRET_KEY!,
})
Invoke#
async function premium(request: Request): Promise<Response> {
const result = await mppx.charge({
amount: '100',
currency: '0xA8CE8aee21bC2A48a5EF670afCc9274C7bbbC035',
recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00',
methodDetails: { feePayer: true }, // chainId defaults to 196
})(request)
if (result.status === 402) return result.challenge
return result.withReceipt(Response.json({ data: 'premium content' }))
}
Invoke options#
type ChargeOptions = {
amount: string // Charge amount, base-units integer string
currency: string // ERC-20 contract EVM address
recipient: string // Recipient EVM address
description?: string // Description, written into the challenge
externalId?: string // Merchant order ID, echoed back on the receipt
methodDetails: {
chainId?: number // Default 196 (X Layer)
feePayer?: boolean // true = server pays gas (transaction mode only)
permit2Address?: string // Uniswap Permit2 contract address
splits?: ChargeSplit[] // Splits, max 10 entries
}
}
type ChargeSplit = {
amount: string // Split amount, base units
recipient: string // Split recipient EVM address
memo?: string
}
Splits#
Just fill in methodDetails.splits:
methodDetails: {
feePayer: true,
splits: [
{ amount: '30', recipient: '0x...', memo: 'partner-a' },
{ amount: '20', recipient: '0x...', memo: 'partner-b' },
],
}
Constraints: split total must be strictly less than amount (the primary recipient must keep at least 1 base unit), max 10 entries; the client signs one EIP-3009 per split (auto-attached at payload.authorization.splits[]). Validation is enforced by SA API (codes 70005 / 70006 on violation).
Session — Pay-as-you-go#
For metered scenarios: open an escrow channel → submit vouchers at high frequency → the seller actively settles / closes at any time.
Register#
import { privateKeyToAccount } from 'viem/accounts'
const sellerSigner = privateKeyToAccount(process.env.MERCHANT_PK as `0x${string}`)
const mppx = Mppx.create({
methods: [session({ saClient, signer: sellerSigner })],
realm: 'demo.merchant.com',
secretKey: process.env.MPPX_SECRET_KEY!,
})
Factory parameters#
type SessionParameters = {
saClient: SaApiClient // Required
signer: SessionSigner // Required; signs EIP-712 authorization on settle / close
chainId?: number // Default 196
escrowContract?: Hex // Default 0x5E550002e64FaF79B41D89fE8439eEb1be66CE3b
domainName?: string // Default "EVM Payment Channel"
domainVersion?: string // Default "1"
store?: SessionStore // Default in-memory store
minVoucherDelta?: string // Default "0", base units
}
/** Seller signing capability. viem's LocalAccount / WalletClient.account both satisfy it. */
type SessionSigner = {
signTypedData: <const td extends TypedDataDefinition>(p: td) => Promise<Hex>
}
escrowContract/domainName/domainVersionmust exactly match the on-chain escrow contract's EIP712Domain, otherwise voucher / settle / close verification will all fail.
Invoke#
async function meter(request: Request): Promise<Response> {
const result = await mppx.session({
amount: '100',
currency: '0x...',
recipient: '0x...',
unitType: 'request',
suggestedDeposit: '10000',
methodDetails: {}, // chainId / escrowContract auto-fall back to factory defaults
})(request)
if (result.status === 402) return result.challenge
// The three management actions (open / topUp / close) are forced to return 204 by the SDK;
// only the voucher action actually delivers a resource — the Response below is passed through.
return result.withReceipt(Response.json({ data: 'metered content' }))
}
Each request takes a different path depending on payload.action:
action | Behavior |
|---|---|
open | Verify payee → call SA session/open on-chain → write local store |
voucher | Local EIP-712 verify → bump highest voucher → atomic deduct (no SA API call, purely local) |
topUp | Call SA session/topUp → add to local deposit |
close | Seller signs CloseAuth → call SA session/close → remove from local store |
Invoke options#
type SessionOptions = {
amount: string // Unit price, base units
currency: string // ERC-20 EVM address
recipient: string // Recipient EVM address
description?: string
externalId?: string
unitType?: string // "request" | "byte" | "llm_token" | ...
suggestedDeposit?: string // Suggested initial deposit, base units
methodDetails: {
chainId?: number // Falls back to factory default
escrowContract?: string // Falls back to factory default
channelId?: string
minVoucherDelta?: string // Throttle: minimum voucher increment
feePayer?: boolean
splits?: SessionSplit[] // Revenue split by bps
}
}
type SessionSplit = {
recipient: string
bps: number // basis points (1‒9999), sum(bps) < 10000
memo?: string
}
Extension methods: active settle / status#
The object returned by session({...}) exposes two extra methods on the mppx Method:
/** Settle on-chain using the local highest voucher (does not close the channel).
* Auto-signs SettleAuthorization and submits. */
mppx.session.settle(channelId: string): Promise<SessionReceipt>
/** Query the on-chain channel state. */
mppx.session.status(channelId: string): Promise<ChannelStatus>
interface SessionReceipt {
method: string // "evm"
intent: string // "session"
status: string // "success"
timestamp: string // RFC 3339
channelId: string
chainId: number
reference?: string // tx hash (transaction mode)
deposit: string // current total on-chain escrow deposit
}
interface ChannelStatus {
channelId: string
payer: string
payee: string
token: string
deposit: string
settledOnChain: string // amount already settled on-chain (only updated after settle)
sessionStatus: 'OPEN' | 'CLOSING' | 'CLOSED'
remainingBalance: string // = deposit - cumulativeAmount
}
Custom SessionStore#
The default memoryStore() is fine for single-process, but all channel state is lost on restart. For long-lived channels / multi-instance / hot-reload deployments, implement a persistent store yourself (Redis / Postgres / KV / DynamoDB / etcd all work).
interface SessionStore {
get(channelId: string):
Promise<ChannelState | null> | ChannelState | null
set(channelId: string, state: ChannelState):
Promise<void> | void
delete(channelId: string):
Promise<void> | void
/** Read-modify-write must be atomic as a whole.
* When state doesn't exist, do not call mutator and return null directly.
* Implementations must guarantee no concurrent writes during the mutator call. */
update(channelId: string, mutator: ChannelMutator):
Promise<ChannelState | null> | ChannelState | null
}
/** Synchronous pure function that mutates state in place; throw to roll back without writing.
* Do not perform async IO inside the mutator (the implementation may call it multiple times). */
type ChannelMutator = (state: ChannelState) => void
update() is the key to correctness: in-process use a per-id mutex; Redis use Lua; Postgres use SELECT ... FOR UPDATE transactions; DynamoDB / etcd retry with CAS.
ChannelState#
interface ChannelState {
channelId: Hex // Primary key = on-chain channelId
chainId: number
escrowContract: Hex
domainName: string
domainVersion: string
signer: Hex // Expected voucher signer
deposit: bigint // Current on-chain escrow deposit
spent: bigint // Cumulative local deductions
units: number // Number of billings
highestVoucherAmount: bigint // Highest accepted voucher amount
highestVoucher: // Bytes value (used for idempotency + idle close)
| { cumulativeAmount: string; signature: Hex }
| null
challengeEcho: ChallengeEcho
createdAt: string // ISO 8601
}
SessionStore/ChannelMutatorare not exported via subpath in v0.1.0. Structural typing matches — implement the interfaces above and pass tosession({ store: ... }).
EIP-712 helpers#
For building and verifying session voucher / settle / close authorizations. Default values for the on-chain escrow contract's EIP712Domain:
DEFAULT_DOMAIN_NAME = 'EVM Payment Channel'
DEFAULT_DOMAIN_VERSION = '1'
verifyVoucher#
Verifies whether the signature was made by expectedSigner (uses viem verifyTypedData / ecrecover).
function verifyVoucher(params: {
chainId: number
escrowContract: Hex
channelId: Hex
cumulativeAmount: string | bigint
signature: Hex
expectedSigner: Hex
domainName?: string // Default "EVM Payment Channel"
domainVersion?: string // Default "1"
}): Promise<boolean>
buildSettleAuth / buildCloseAuth#
Does not sign — only constructs a viem TypedDataDefinition to feed into signer.signTypedData(...) to obtain a 65-byte signature. Both take the same parameters:
function buildSettleAuth(p: AuthMessageParams): TypedDataDefinition
function buildCloseAuth(p: AuthMessageParams): TypedDataDefinition
interface AuthMessageParams {
chainId: number
escrowContract: Hex
channelId: Hex
cumulativeAmount: string | bigint
nonce: string | bigint
deadline: string | bigint
domainName?: string
domainVersion?: string
}
randomU256 / unixDeadline#
/** A 256-bit cryptographically secure random number, decimal string. */
function randomU256(): string
/** Unix seconds, decimal string; defaults to now + 1 hour. */
function unixDeadline(secondsFromNow?: number): string
The on-chain used-nonce set is keyed by (payee, channelId, nonce). Reuse reverts with NonceAlreadyUsed; the SDK does not track the used set — it only generates random values that are unlikely to have been used.
SaApiClient#
The OKX SA API HTTP client; the underlying dependency of the charge / session factories. Users only need to instantiate it and pass it to a factory — they don't need to call its methods directly.
new SaApiClient({
apiKey: string
secretKey: string
passphrase: string
baseUrl?: string // Default "https://web3.okx.com"
onError?: (info: SaApiErrorInfo) => void
})
interface SaApiErrorInfo {
method: 'GET' | 'POST'
path: string
requestBody?: string
httpStatus: number
code?: number // SA business error code; undefined if parsing fails
msg?: string
responseBody?: string
}
onError fires on HTTP non-2xx, JSON parse failure, or non-zero business code; it is isolated by try/catch and does not affect the main flow — use it for business-side logging / reporting. After unwrapping internally, the SDK throws the corresponding PaymentError subclass per error code (see the next section).
Error handling#
The SDK throws PaymentError subclasses under the top-level Errors namespace; mppx auto-converts them to RFC 9457 ProblemDetails responses.
import { Errors } from '@okxweb3/mpp'
SA API error codes → PaymentError subclasses#
| code | Meaning | Thrown PaymentError |
|---|---|---|
| 8000 | Internal API service error | VerificationFailedError |
| 70000 | Missing field or invalid format | VerificationFailedError |
| 70001 | Chain not in supported list | VerificationFailedError |
| 70002 | Payer is blocklisted | VerificationFailedError |
| 70003 | source missing / feePayer conflicts with hash mode / txHash already used | VerificationFailedError |
| 70004 | Signature verification failed | InvalidSignatureError |
| 70005 | Split total >= primary amount | InvalidPayloadError |
| 70006 | Split count > 10 | InvalidPayloadError |
| 70007 | Transaction not on-chain | VerificationFailedError |
| 70008 | On-chain channel closed | ChannelClosedError |
| 70009 | Challenge does not exist / expired | InvalidChallengeError |
| 70010 | channelId not found | ChannelNotFoundError |
| 70011 | escrow grace period configuration not met | InvalidPayloadError |
| 70012 | cumulativeAmount > deposit | AmountExceedsDepositError |
| 70013 | Voucher increment < minVoucherDelta | DeltaTooSmallError |
| 70014 | Channel in CLOSING state | ChannelClosedError |
Error code constants:
import { SA_ERROR_CODES, type SaErrorCode } from '@okxweb3/mpp/evm'
SA_ERROR_CODES[70004] // "invalid_signature"
Insufficient session voucher balance#
When the voucher action deducts locally, if highestVoucherAmount - spent < amount, Errors.InsufficientBalanceError is thrown and mppx returns 402; if the channel doesn't exist, Errors.ChannelNotFoundError is thrown.
- Node SDK Reference (for exact, aggr_deferred)PackagesCore typesServer API (x402ResourceServer)HTTP resource server (x402HTTPResourceServer)Middleware referenceEVM mechanism typesClient API (buyer side)Node SDK Reference (for charge, session)Install & importCharge — one-time paymentSession — Pay-as-you-goEIP-712 helpersSaApiClientError handling