Node SDK Reference#
Node SDK Reference (for exact, aggr_deferred, period)#
Packages#
| Package | Description |
|---|---|
@okxweb3/x402-core | Core: client, server, facilitator, types, subscription support |
@okxweb3/x402-evm | EVM schemes: exact, aggr_deferred, period (subscription) |
@okxweb3/x402-express | Express middleware (seller) |
@okxweb3/x402-next | Next.js middleware (seller) |
@okxweb3/x402-hono | Hono middleware (seller) |
@okxweb3/x402-fastify | Fastify middleware (seller) |
@okxweb3/x402-fetch | Fetch wrapper (buyer) |
@okxweb3/x402-axios | Axios wrapper (buyer) |
@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 an accepted payment option from the seller.
type PaymentRequirements = {
scheme: string; // Payment scheme: "exact" | "aggr_deferred" | "period"
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 fields by 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") |
period (subscription) | extra.plan / extra.amountPerPeriod / extra.periodSec / extra.contracts / extra.facilitator / extra.domain | — | See "Subscription Payments" section |
PaymentRequired#
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#
Signed payment submitted by the client on 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 fields by 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
}
period (subscription) payload:
{
permitSingle: { // Permit2 PermitSingle authorization
details: { token, amount, expiration, nonce };
spender: string; // subscription contract address
sigDeadline: string;
};
permitSingleSignature: `0x${string}`;
terms: { // SubscriptionTerms EIP-712 message (see subscription section)
payer, merchant, facilitator, token, amountPerPeriod, periodSec, periodMode,
maxPeriods, startAt, initialChargePeriods, initialChargeAmount,
planTier, changeFromSubId, changeEffectiveAt, permitHash, salt, termsDeadline,
};
termsSignature: `0x${string}`;
}
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
};
For the period scheme, SupportedKind.extra additionally carries three fields: facilitatorAddress / subscriptionContract / permit2Contract (see "Subscription Payments" section).
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())
.register("eip155:196", new PermitSubscriptionScheme({ /* ... */ })); // subscription
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()#
Fetches supported kinds from the facilitator. Call once at startup.
await server.initialize();
buildPaymentRequirements(config) → PaymentRequirements[]#
interface ResourceConfig {
scheme: string; // "exact" | "aggr_deferred" | "upto" | "period"
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 argument.
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)#
Higher-level wrapper handling 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>;
// ── Subscription (period) specific ──────────────────
/**
* Marks this as a subscription special-operation route:
* "change" — plan switch (up/downgrade; two phases on the same URL)
* "cancel" — cancel subscription
* "cancel-pending-change" — cancel a queued downgrade
* Omit → normal access route.
*/
operation?: "change" | "cancel" | "cancel-pending-change";
/**
* Route-scoped onBeforeAccess. Same semantics as httpServer.onBeforeAccess(),
* scoped to this route only; runs AFTER all seller-global hooks.
*/
onBeforeAccess?: OnBeforeAccessHook;
}
interface PaymentOption {
scheme: string; // "exact" | "aggr_deferred" | "upto" | "period"
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 };
}
});
onBeforeAccess(hook) — Subscription only#
Seller-level chain-of-responsibility firing after verifyAccess succeeds and before the handler runs. Only invoked on subscription (period-scheme) access-verified requests. Register multiple times — hooks run in registration order, the first { ok: false } denies (→ 402). RouteConfig.onBeforeAccess runs after all global hooks. See "Subscription Payments" section for details.
httpServer.onBeforeAccess(async (ctx) => {
if (banList.has(ctx.subscription.subId)) return { ok: false, error: "banned" };
return { ok: true };
});
HTTPAdapter.getHeaders?()#
Optional new method on HTTPAdapter returning Record<string, string> (lowercase keys). All four official adapters (express / fastify / hono / next) implement it. Subscription onBeforeAccess reads ctx.request.headers via this method.
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) — use this to attach onBeforeAccess for subscriptions
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 | Path pattern → 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 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?);
All four middlewares support the two new subscription dispatch branches (payment-presettle / access-verified) — see "Subscription Payments" section.
EVM Scheme 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
PermitSubscriptionScheme (subscription, server-side)#
import { PermitSubscriptionScheme } from "@okxweb3/x402-evm/subscription";
const scheme = new PermitSubscriptionScheme({
facilitator, // SubscriptionFacilitatorClient
network: "eip155:196",
store, // SubscriptionStore (share the same instance with SubscriptionClient)
accessProofWindowSec: 300, // AccessProof window, default ±300s
});
scheme.scheme; // "period"
Implements the full SubscriptionCapability interface — see the "Subscription Payments" section.
Client API (Buyer)#
The buyer packages transparently handle 402 Payment Required responses: parse requirements → sign a payment payload via the configured EVM scheme → retry the request with a PAYMENT header.
Pick the package matching your HTTP client:
| Package | Wraps | When to use |
|---|---|---|
@okxweb3/x402-axios | AxiosInstance | Existing Axios codebase; needing interceptors / per-instance config |
@okxweb3/x402-fetch | globalThis.fetch | fetch-based runtimes (browser, Edge, Node 18+) |
The API shape is identical: 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 mode via x402Client#
Use the explicit builder when you need to register multiple schemes / networks, or share the same client across multiple transports.
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 simultaneously.
Reading the payment receipt#
After the retry succeeds, the server returns a PAYMENT-RESPONSE header carrying 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 item pairs a network (e.g. "eip155:196", "eip155:*") with a scheme client (e.g. new ExactEvmScheme(signer)). |
policies | PaymentPolicy[] | Optional. See Policies below — filters / transforms accepts in order. |
paymentRequirementsSelector | SelectPaymentRequirements | Optional. Picks the final item from the filtered list. Defaults to (version, accepts) => accepts[0]. |
Selection Pipeline#
On receiving a 402, the client picks what to sign in three steps:
- Filter by registered schemes — keep only
acceptswhosenetwork+schemewere both registered viaregister(). - Apply policies in order — each
PaymentPolicyfurther filters / transforms the list. - Selector chooses — pick the final item to sign from the filtered list.
If step 1 or step 2 yields an empty array, the client throws — no signing happens.
Policies — PaymentPolicy#
type PaymentPolicy = (
x402Version: number,
paymentRequirements: PaymentRequirements[],
) => PaymentRequirements[];
A policy is a pure function: take the current accepts, return a filtered subset (or a transformed copy). Common uses: amount caps, network allowlists, scheme preference.
import {
wrapAxiosWithPaymentFromConfig,
type PaymentPolicy,
} from "@okxweb3/x402-axios";
// Reject any single payment over 1 USDT (1_000_000 atomic units, 6 decimals)
const maxAmountPolicy: PaymentPolicy = (_version, reqs) =>
reqs.filter(r => BigInt(r.amount) <= 1_000_000n);
// Allow only X Layer mainnet
const xLayerOnlyPolicy: PaymentPolicy = (_version, reqs) =>
reqs.filter(r => r.network === "eip155:196");
// Prefer "exact" when both schemes are offered
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, allowlists) first, "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 candidates and you want explicit choice logic (e.g. pick the 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 telemetry, last-mile gating, and error recovery. Register them via the builder:
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 payment entirely
.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 — observe only (logs / metrics)
.onAfterPaymentCreation(async ({ paymentPayload }) => {
console.log("signed payload nonce:", paymentPayload.payload?.authorization?.nonce);
})
// 3. On signing failure — may recover with a hand-crafted payload
.onPaymentCreationFailure(async ({ error }) => {
console.error("payment creation failed:", error.message);
// return { recovered: true, payload: fallbackPayload };
});
const api = wrapAxiosWithPayment(axios.create(), client);
| Hook | Fires when | Return semantics |
|---|---|---|
onBeforePaymentCreation | Selection done, before the scheme signs | void continues · { abort: true, reason } cancels and rejects |
onAfterPaymentCreation | After the scheme returns a signed payload | void only (observe, cannot mutate) |
onPaymentCreationFailure | Scheme throws during signing | void re-throws · { recovered: true, payload } recovers with an alternative payload |
Hooks within the same phase run in registration order.
Client extensions — registerExtension#
Use when the PaymentRequired response carries an extensions field and needs a scheme-related payload enrichment (e.g., gas-sponsoring permit signature). enrichPaymentPayload only fires when paymentRequired.extensions contains a matching key.
client.registerExtension({
key: "eip2612GasSponsoring",
async enrichPaymentPayload(payload, paymentRequired) {
// Sign an EIP-2612 permit and attach to payload.extensions
return { ...payload, extensions: { ...payload.extensions, /* ... */ } };
},
});
Subscription Payments (period scheme)#
period is the third scheme supported by the x402 v2 SDK (the first two being exact / aggr_deferred). A single buyer-side double-signature (Permit2 PermitSingle + SubscriptionTerms) authorises N periods of pulls; the merchant middleware then handles the five sub-flows automatically: subscribe / access / change plan / cancel / scheduled charge. Because this scheme adds a Store / Client / hook layer plus extra wire fields, it gets its own section below.
Top-level entry points#
import { OKXFacilitatorClient } from "@okxweb3/x402-core";
import { x402HTTPResourceServer, x402ResourceServer } from "@okxweb3/x402-core/server";
import {
InMemoryStore,
SubscriptionClient,
type Subscription, type SubscriptionState, type PendingPlanChange,
type PlanCatalogEntry, type PlanInitialCharge,
type AccessProof, type CancelAuth, type PendingChangeCancelAuth,
type ChargeResult,
type SettleSubscribeResult, type SettleChangeResult,
type SettleCancelResult, type SettleCancelPendingChangeResult,
type SubscriptionCapability, type SubscriptionStore,
type OnBeforeAccessHook, type OnBeforeAccessContext, type OnBeforeAccessResult,
type AccessRouteRequirements,
} from "@okxweb3/x402-core/subscription";
import { PermitSubscriptionScheme } from "@okxweb3/x402-evm/subscription";
import { paymentMiddleware, paymentMiddlewareFromHTTPServer } from "@okxweb3/x402-express";
PermitSubscriptionScheme
new PermitSubscriptionScheme({
facilitator: SubscriptionFacilitatorClient, // OKXFacilitatorClient / HTTPFacilitatorClient
network: Network, // "eip155:196"
store: SubscriptionStore, // share with SubscriptionClient
accessProofWindowSec?: number, // default 300 (±300s)
});
Implements every SubscriptionCapability method. Register on x402ResourceServer via register(network, scheme).
SubscriptionClient
High-level wrapper for seller-initiated operations (scheduled charges / merchant-initiated cancel / on-chain sync).
class SubscriptionClient {
constructor(config: { scheme: SubscriptionCapability; store: SubscriptionStore });
charge(subId: string): Promise<ChargeResult>;
cancelBySeller(subId: string, auth: CancelAuth, reason?: string): Promise<void>;
syncFromChain(subId: string): Promise<Subscription | null>;
getSubscription(subId: string): Promise<Subscription | null>;
}
InMemoryStore / SubscriptionStore
interface SubscriptionStore {
get(subId: string): Promise<Subscription | null>;
put(sub: Subscription): Promise<void>;
delete(subId: string): Promise<void>;
list(): Promise<Subscription[]>;
}
InMemoryStore is demo / single-process only; production requires a persistent store (Redis / Postgres / KV etc.).
OKXFacilitatorClient (subscription methods)
Beyond verifyPayment / settlePayment / getSupported, OKXFacilitatorClient also implements SubscriptionFacilitatorClient:
interface SubscriptionFacilitatorClient {
subscribe(payload, requirements): Promise<...>;
changeSubscription(payload, requirements): Promise<...>;
cancelSubscription(subId, auth): Promise<...>;
cancelPendingChange(subId, auth): Promise<...>;
chargeSubscription(subId): Promise<...>;
getSubscription(subId): Promise<...>;
finalizeExpired(subId): Promise<...>;
getCharges(subId): Promise<...>;
getPendingChange(subId): Promise<...>;
}
Core types#
Subscription
Local projection of the facilitator GET /subscriptions/detail; the store never holds data the facilitator can't refresh.
interface Subscription {
subId: string;
payer: string;
merchant: string;
token: string;
amountPerPeriod: string;
periodMode: number; // 0 fixed_seconds / 1 calendar_month
periodSec: number;
billingAnchorAt?: number; // calendar-month anchor (Unix s); undefined or 0 in fixed_seconds mode
maxPeriods: number;
startAt: number;
state: SubscriptionState;
lastChargedPeriod: number;
totalPulled: string; // total pulled so far
planId: string;
planTier: number;
changedToSubId?: string; // when state === "changed"
pendingPlanChange?: PendingPlanChange;
// Read-time derived (drift with wall clock; use elapsedPeriods for expiry checks)
isActive?: boolean;
serviceEnded?: boolean;
currentPeriod?: number;
elapsedPeriods?: number;
nextChargeableAt?: number; // next chargeable boundary (Unix s); null once all periods are charged
}
SubscriptionState
type SubscriptionState =
| "pending" // 0 — on-chain, awaiting activation
| "active" // 1 — live
| "completed" // 2 — all maxPeriods pulled
| "canceled" // 3 — cancelled
| "changed" // 4 — replaced by another sub (changedToSubId points to it)
| "failed"; // 99 — on-chain failure / exceptional
PendingPlanChange
Scheduled downgrade (upgrades activate immediately and do not queue).
interface PendingPlanChange {
subId: string; // current sub id
newSubId: string; // downgrade target sub id
effectiveFromPeriod: number; // switches on this period boundary
state: number; // 0 pending / 1 activated / 2 canceled / 3 expired
}
PlanCatalogEntry / PlanInitialCharge
Seller-side plan definitions, raw input for RouteConfig.accepts.
interface PlanCatalogEntry {
id: string; // business plan id (checked on access)
tier: number; // higher = higher tier
amountPerPeriod: string; // per-period charge (token base units)
periodMode?: 0 | 1; // 0 fixed_seconds / 1 calendar_month
periodSec: number; // period length in seconds
maxPeriods: number; // max chargeable periods
asset?: string; // ERC-20; omit → SDK uses the network default stablecoin
payTo: string;
initialCharge?: PlanInitialCharge;
name?: string;
}
interface PlanInitialCharge {
periodCount: number; // leading periods covered by first charge
totalAmount: string; // total first charge (≤ periodCount × amountPerPeriod)
}
AccessProof
Buyer credential for access / change / cancel routes. EIP-191 personal_sign, ±accessProofWindowSec window.
interface AccessProof {
kind: "subscription-id";
subId: string;
payer: string;
timestamp: number;
signature: string;
}
Header: APP-Access: base64url(JSON.stringify(accessProof)).
CancelAuth
EIP-712 cancel authorisation; either payer or merchant may sign.
interface CancelAuth {
action: 0; // locked to 0 = cancel_subscription
subId: string;
initiator: 0 | 1; // 0=payer / 1=merchant
nonce: string;
deadline: number;
signature: string;
}
TypeHash: CancelAuth(uint8 action, bytes32 subId, uint8 initiator, bytes32 nonce, uint64 deadline).
PendingChangeCancelAuth
EIP-712 authorisation to cancel a queued downgrade. Payer only.
interface PendingChangeCancelAuth {
subId: string;
newSubId: string; // MUST equal current pendingPlanChange.newSubId
nonce: string;
deadline: number;
signature: string;
}
TypeHash: PendingChangeCancelAuth(bytes32 subId, bytes32 newSubId, bytes32 nonce, uint64 deadline).
Verify / settle result shapes#
type VerifyResultOk = { ok: true };
type VerifyResultFail = { ok: false; error: string };
interface VerifyAccessOk { ok: true; subscription: Subscription; }
interface VerifyOwnershipOk { ok: true; subId: string; payer: string; subscription: Subscription; }
interface VerifyChangeOk { ok: true; oldSubId: string; direction: "upgrade" | "downgrade"; }
type SettleResultFail = {
success: false;
error: string;
subId?: string; // when pending=true, seller may syncFromChain(subId) later
pending?: boolean;
};
interface SettleSubscribeOk { success: true; subId: string; subscription: Subscription; headers: Record<string, string>; }
interface SettleChangeOk { success: true; oldSubId: string; newSubId: string; operationType: "upgrade" | "downgrade"; scheduledFromPeriod?: number; headers: Record<string, string>; }
interface SettleCancelOk { success: true; subId: string; headers: Record<string, string>; }
interface SettleCancelPendingChangeOk { success: true; subId: string; headers: Record<string, string>; }
interface ChargeResult {
success: true;
period: number; // period advanced to
amount: string; // amount pulled (base units)
txHash?: string;
planChangeTriggered?: boolean; // a queued downgrade activated this call
newSubId?: string; // when planChangeTriggered=true
}
SubscriptionCapability#
interface SubscriptionCapability {
readonly settlementMode: "pre"; // middleware uses "settle then handler"
verifySubscribe(payload, requirements): Promise<VerifyResult>;
settleSubscribe(payload, requirements): Promise<SettleSubscribeResult>;
enrichAcceptsForChange(accepts: PaymentRequirements[], currentSubId: string): Promise<PaymentRequirements[] | null>;
verifyChange(payload, requirements): Promise<VerifyChangeResult>;
settleChange(payload, requirements): Promise<SettleChangeResult>;
verifyCancel(auth: CancelAuth, subId: string): Promise<VerifyResult>;
settleCancel(auth: CancelAuth, subId: string): Promise<SettleCancelResult>;
verifyCancelPendingChange(auth: PendingChangeCancelAuth, subId: string): Promise<VerifyResult>;
settleCancelPendingChange(auth: PendingChangeCancelAuth, subId: string): Promise<SettleCancelPendingChangeResult>;
verifyAccess(proof: AccessProof, route: AccessRouteRequirements): Promise<VerifyAccessResult>;
verifyOwnership(proof: AccessProof): Promise<VerifyOwnershipResult>;
charge(subId: string): Promise<ChargeResult>;
getSubscription(subId: string): Promise<Subscription | null>;
}
OnBeforeAccessContext / Result / Hook#
interface OnBeforeAccessContext {
subscription: Subscription; // full snapshot of the matched sub
request: {
path: string; // request pathname (real value)
method: string; // HTTP method (real value)
headers: Record<string, string>; // lowercase-keyed; {} if the adapter has no getHeaders
};
route: AccessRouteRequirements; // route plan declarations
}
type OnBeforeAccessResult =
| { ok: true }
| {
ok: false;
error?: string; // becomes the 402 body
retryAfter?: number; // becomes the Retry-After header
upgradeOffers?: PaymentRequirements[]; // suggest alternative accepts
};
type OnBeforeAccessHook = (ctx: OnBeforeAccessContext) => Promise<OnBeforeAccessResult>;
AccessRouteRequirements#
interface AccessRouteRequirements {
acceptedPlanIds?: string[]; // plan-id allowlist derived from route accepts
accepts?: PaymentRequirements[]; // full accepts; extra.plan = { id, tier, name }
// plus extra.amountPerPeriod / extra.periodSec /
// extra.periodMode / extra.maxPeriods
}
An undefined acceptedPlanIds means "no plan restriction" — any active subscription passes. Use sparingly.
/supported extra fields#
Each kinds[] entry the facilitator returns from GET /supported. The period scheme caches these three fields:
| Field | Type | Meaning |
|---|---|---|
facilitatorAddress | string | Facilitator EOA; signed into Permit2 witness.facilitator, the only address the on-chain contract will trust as a settle trigger |
subscriptionContract | string | A2APaySubscription contract address (EIP-712 verifyingContract) |
permit2Contract | string | Uniswap Permit2 contract address |
Any missing field → SDK throws period supportedKind.extra is missing required fields ....
PaymentRequirements.extra (subscription wire format)#
Full shape of accepts[].extra on the seller's 402 response (SubscriptionRequirementsExtra):
{
contracts: { subscription: string; permit2: string };
facilitator: string; // copied from /supported.extra.facilitatorAddress
amountPerPeriod: string;
periodSec: number;
periodMode?: number; // 0 fixed_seconds / 1 calendar_month
maxPeriods: number;
startAt?: number;
initialCharge?: PlanInitialCharge;
plan: { id: string; tier: number; name?: string };
changeFrom?: ChangeFromExtra; // only in `operation="change"` 402 responses
domain: TypedDataDomain; // EIP-712 domain (name / version / chainId / verifyingContract)
}
changeFrom (only in the phase-1 402 of a change flow):
{
fromSubId: string;
fromPlanId: string;
fromPlanTier: number;
direction: "upgrade" | "downgrade";
effectiveAt: "immediate" | "period_end";
}
SubscriptionTerms EIP-712 struct#
The buyer's second signature (the first is Permit2 PermitSingle), binding plan terms:
SubscriptionTerms(
address payer,
address merchant,
address facilitator,
address token,
uint256 amountPerPeriod,
uint256 periodSec,
uint8 periodMode,
uint256 maxPeriods,
uint256 startAt,
uint256 initialChargePeriods,
uint256 initialChargeAmount,
uint256 planTier,
bytes32 changeFromSubId,
uint8 changeEffectiveAt, // 0=none / 1=immediate / 2=period_end
bytes32 permitHash,
bytes32 salt,
uint64 termsDeadline
)Domain: (name="A2APaySubscription", version="1", chainId, verifyingContract=subscriptionContract).
Middleware subscription branches#
All four official middlewares (express / fastify / hono / next) support the two new subscription dispatch branches:
| Result type | Trigger | Middleware behaviour |
|---|---|---|
payment-presettle | subscribe / change / cancel / cancel-pending-change routes | Settle first (call facilitator + on-chain); on success attach settleResult.data.subscription / subId onto req.x402, then run the handler. On failure → 402 |
access-verified | Request with APP-Access header hitting a subscription resource route | No facilitator / no chain call; only local verifyAccess. On success attach req.x402.subscription, then run the handler |
Seller handler reads from req.x402:
app.post("/subscription/cancel", (req, res) => {
const x402 = (req as any).x402;
res.json({ subId: x402.settleResult?.data?.subId });
});
Response header: on successful settle, the middleware writes a PAYMENT-RESPONSE header (JSON base64url) carrying { subId, txHash, state, ... }.
Node SDK Reference (for charge, session)#
Install & Import#
npm install @okxweb3/mpp viem
@okxweb3/mpp re-exports the entire upstream mppx namespace, so application code typically only needs to import this one 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'
// EVM shared: 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-shot Payment#
Registration#
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!,
})
Invocation#
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' }))
}
Call 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
splits?: ChargeSplit[] // splits, max 10
resourceUrl?: string // Endpoint URL for per-URL analytics (see "resourceUrl" below)
}
}
type ChargeSplit = {
amount: string // split amount in base units
recipient: string // split recipient EVM address
memo?: string
}
Splits#
Fill methodDetails.splits:
methodDetails: {
feePayer: true,
splits: [
{ amount: '30', recipient: '0x...', memo: 'partner-a' },
{ amount: '20', recipient: '0x...', memo: 'partner-b' },
],
}
Constraints: total split amount must be strictly less than amount (leave the main recipient at least 1 base unit), max 10 splits; the client signs a separate EIP-3009 for each split (auto-populated in payload.authorization.splits[]). SA API enforces this (violations → 70005 / 70006).
resourceUrl (per-endpoint analytics)#
To let merchants bucket charge volume / revenue by endpoint, pass resourceUrl in methodDetails. The SDK base64url-encodes it into challenge.request and passes it through to SA API /charge/settle and /charge/verifyHash so the backend can persist it.
async function handler(request: Request): Promise<Response> {
const result = await mppx.charge({
amount: '100',
currency: '0xA8CE8aee21bC2A48a5EF670afCc9274C7bbbC035',
recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00',
methodDetails: {
feePayer: true,
resourceUrl: 'https://api.myshop.com/v1/reports', // stats bucketed by this URL
},
})(request)
if (result.status === 402) return result.challenge
return result.withReceipt(Response.json({ data: '...' }))
}
Pipeline:
Seller SDK (mppx.charge)
└── methodDetails.resourceUrl
↓ base64url-encoded into ChargeRequest → PaymentChallenge.request
Buyer echoes (ChallengeEcho)
↓ POST /charge/settle | /charge/verifyHash
SA API backend (bucketed by resourceUrl)Constraints:
- Not supported for session — a single session may voucher over multiple URLs, so per-URL stats would be blurred.
- SA API tolerates a missing
resource_url(empty → not persisted).
Session - Metered Payment#
Metered billing: open an escrow channel → submit vouchers at high frequency → seller settles / closes at will.
Registration#
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 auth for settle / close
chainId?: number // default 196
escrowContract?: Hex // default 0x5E550002e64FaF79B41D89fE8439eEb1be66CE3b
domainName?: string // default "EVM Payment Channel"
domainVersion?: string // default "1"
store?: SessionStore // default in-process memory store
minVoucherDelta?: string // default "0", base units
}
/** Seller signing capability. viem LocalAccount / WalletClient.account both work. */
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 signature verification all fail.
Invocation#
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-inherited from the factory
})(request)
if (result.status === 402) return result.challenge
// The SDK forces 204 for the three management actions (open / topUp / close);
// only voucher actually delivers the resource, so the Response below is only forwarded for voucher.
return result.withReceipt(Response.json({ data: 'metered content' }))
}
Each request dispatches by payload.action:
action | Behaviour |
|---|---|
open | Verify payee → call SA session/open on-chain → write local store |
voucher | Local EIP-712 verify → raise highest voucher → atomic charge (no SA API call, purely local) |
topUp | Call SA session/topUp → accumulate local deposit |
close | Seller signs CloseAuth → call SA session/close → delete local store |
Call 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 for open, base units
methodDetails: {
chainId?: number // factory default fallback
escrowContract?: string // factory default fallback
channelId?: string
minVoucherDelta?: string // throttle: minimum voucher increment
feePayer?: boolean
splits?: SessionSplit[] // pro-rata split by bps
}
}
type SessionSplit = {
recipient: string
bps: number // basis points (1‒9999); sum(bps) < 10000
memo?: string
}
Session does not support
resourceUrl: a single session can voucher over multiple URLs, so per-URL stats would be blurred. Use charge for per-endpoint reporting.
Extension methods: manual settle / status#
The object returned by session({...}) exposes two extra calls on the mppx Method:
/** Settle the highest local voucher on-chain (channel stays open).
* Automatically signs SettleAuthorization and submits. */
mppx.session.settle(channelId: string): Promise<SessionReceipt>
/** Query on-chain channel status. */
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 on-chain escrow deposit total
}
interface ChannelStatus {
channelId: string
payer: string
payee: string
token: string
deposit: string
settledOnChain: string // on-chain settled amount (updated only after settle)
sessionStatus: 'OPEN' | 'CLOSING' | 'CLOSED'
remainingBalance: string // = deposit - cumulativeAmount
}
Custom SessionStore#
The default memoryStore() works for a single process, but loses all channel state on restart. Long-lived channels / multi-instance / hot-reload deployments need a persistent store (Redis / Postgres / KV / DynamoDB / etcd — any works).
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, atomic as a whole.
* If state doesn't exist, do NOT call mutator, just return null.
* Implementer must guarantee no concurrent writes during the mutator call. */
update(channelId: string, mutator: ChannelMutator):
Promise<ChannelState | null> | ChannelState | null
}
/** Synchronous pure function; mutates state in place; throws roll back without writing.
* Do NOT do async IO inside the mutator (the implementation may call it multiple times). */
type ChannelMutator = (state: ChannelState) => void
update() is where correctness lives: in-process, use a per-id mutex; Redis, use Lua; Postgres, use SELECT ... FOR UPDATE inside a transaction; DynamoDB / etcd, use CAS retries.
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 // local cumulative spent
units: number // billed count
highestVoucherAmount: bigint // highest accepted voucher amount
highestVoucher: // byte value (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 is enough; implement one matching the interface above and pass tosession({ store: ... }).
EIP-712 Helpers#
For building and verifying session voucher / settle / close authorizations. On-chain escrow contract's EIP712Domain defaults:
DEFAULT_DOMAIN_NAME = 'EVM Payment Channel'
DEFAULT_DOMAIN_VERSION = '1'
verifyVoucher#
Verify the signature was produced by expectedSigner (via 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#
Do not sign; only construct a viem TypedDataDefinition, which you feed to signer.signTypedData(...) for a 65-byte signature. Both take the same params:
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#
/** 256-bit cryptographically secure random number, decimal string. */
function randomU256(): string
/** Unix seconds, decimal string; default = now + 1 hour. */
function unixDeadline(secondsFromNow?: number): string
The contract-side nonce used-set key is (payee, channelId, nonce). Reusing one reverts with NonceAlreadyUsed; the SDK does not maintain a used-set, only generates values that are "almost certainly unused".
SaApiClient#
OKX SA API HTTP client, underlying dependency for charge / session factories. Users only need to instantiate it and pass to the factory — no 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 parse fails
msg?: string
responseBody?: string
}
onError fires on HTTP non-2xx, JSON parse failure, or non-zero business code; try/catch-isolated so it does not affect the main flow; use for business-side logging / reporting. Internally the SDK unpacks and throws the appropriate PaymentError subclass by code (see next section).
Error Handling#
The SDK throws PaymentError subclasses under the top-level mppx Errors namespace; mppx automatically translates them into RFC 9457 ProblemDetails responses.
import { Errors } from '@okxweb3/mpp'
SA API error code → PaymentError subclass#
| code | Meaning | Thrown PaymentError |
|---|---|---|
| 8000 | API service internal error | VerificationFailedError |
| 70000 | Missing field or bad format | VerificationFailedError |
| 70001 | Chain not in supported list | VerificationFailedError |
| 70002 | Payer on blacklist | VerificationFailedError |
| 70003 | source missing / feePayer conflicts with hash mode / txHash reused | VerificationFailedError |
| 70004 | Signature verification failed | InvalidSignatureError |
| 70005 | Split total ≥ main amount | InvalidPayloadError |
| 70006 | More than 10 splits | InvalidPayloadError |
| 70007 | Transaction not on-chain | VerificationFailedError |
| 70008 | On-chain channel closed | ChannelClosedError |
| 70009 | Challenge missing / expired | InvalidChallengeError |
| 70010 | channelId does not exist | ChannelNotFoundError |
| 70011 | Escrow grace period config below threshold | InvalidPayloadError |
| 70012 | cumulativeAmount > deposit | AmountExceedsDepositError |
| 70013 | Voucher delta < 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"
Session voucher insufficient balance#
When the voucher action attempts to charge locally, if highestVoucherAmount - spent < amount it throws Errors.InsufficientBalanceError, which mppx converts to 402; if the channel doesn't exist it throws Errors.ChannelNotFoundError.
- Node SDK Reference (for exact, aggr_deferred, period)PackagesCore TypesNetworkMoney / Price / AssetAmountResourceInfoPaymentRequirementsPaymentRequiredPaymentPayloadVerifyResponseSettleResponseSupportedKind / SupportedResponseServer API (x402ResourceServer)Constructorregister(network, server)registerExtension(extension)initialize()buildPaymentRequirements(config) → PaymentRequirements[]buildPaymentRequirementsFromOptions(options, context) → PaymentRequirements[]verifyPayment(payload, requirements) → VerifyResponsesettlePayment(payload, requirements, ...) → SettleResponseServer Lifecycle HooksHTTP Resource Server (x402HTTPResourceServer)ConstructorRoutesConfigonSettlementTimeout(hook)onProtectedRequest(hook)onBeforeAccess(hook) — Subscription onlyHTTPAdapter.getHeaders?()Middleware ReferenceExpress (@okxweb3/x402-express)Next.js (@okxweb3/x402-next)Hono (@okxweb3/x402-hono)Fastify (@okxweb3/x402-fastify)EVM Scheme TypesExactEvmScheme (server-side)AggrDeferredEvmScheme (server-side)PermitSubscriptionScheme (subscription, server-side)Client API (Buyer)Axios — @okxweb3/x402-axiosFetch — @okxweb3/x402-fetchBuilder mode via x402ClientReading the payment receiptx402ClientConfigSelection PipelinePolicies — PaymentPolicyCustom selector — SelectPaymentRequirementsLifecycle HooksClient extensions — registerExtensionSubscription Payments (period scheme)Top-level entry pointsCore typesVerify / settle result shapesSubscriptionCapabilityOnBeforeAccessContext / Result / HookAccessRouteRequirements/supported extra fieldsPaymentRequirements.extra (subscription wire format)SubscriptionTerms EIP-712 structMiddleware subscription branchesNode SDK Reference (for charge, session)Install & ImportCharge - One-shot PaymentRegistrationInvocationCall OptionsSplitsresourceUrl (per-endpoint analytics)Session - Metered PaymentRegistrationFactory ParametersInvocationCall OptionsExtension methods: manual settle / statusCustom SessionStoreChannelStateEIP-712 HelpersverifyVoucherbuildSettleAuth / buildCloseAuthrandomU256 / unixDeadlineSaApiClientError HandlingSA API error code → PaymentError subclassSession voucher insufficient balance
