Portal deweloperski
Motyw

Node SDK Reference#

Node SDK Reference (for exact, aggr_deferred, period)#

Packages#

PackageDescription
@okxweb3/x402-coreCore: client, server, facilitator, types, subscription support
@okxweb3/x402-evmEVM schemes: exact, aggr_deferred, period (subscription)
@okxweb3/x402-expressExpress middleware (seller)
@okxweb3/x402-nextNext.js middleware (seller)
@okxweb3/x402-honoHono middleware (seller)
@okxweb3/x402-fastifyFastify middleware (seller)
@okxweb3/x402-fetchFetch wrapper (buyer)
@okxweb3/x402-axiosAxios wrapper (buyer)
@okxweb3/x402-mcpMCP integration
@okxweb3/x402-paywallBrowser paywall UI
@okxweb3/x402-extensionsProtocol extensions

Core Types#

Network#

typescript
type Network = `${string}:${string}`;
// CAIP-2 format, e.g., "eip155:196", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"

Money / Price / AssetAmount#

typescript
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#

typescript
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.

typescript
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:

SchemeExtra fieldTypeDescription
exact (EIP-3009)extra.eip712.namestringEIP-712 domain name (e.g., "USD Coin")
exact (EIP-3009)extra.eip712.versionstringEIP-712 domain version (e.g., "2")
period (subscription)extra.plan / extra.amountPerPeriod / extra.periodSec / extra.contracts / extra.facilitator / extra.domainSee "Subscription Payments" section

PaymentRequired#

HTTP 402 response body sent to the client.

typescript
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.

typescript
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:

typescript
{
  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:

typescript
{
  signature: `0x${string}`;     // Session key signature
  authorization: { /* same as EIP-3009 */ };
  // acceptedExtraOverrides includes sessionCert
}

period (subscription) payload:

typescript
{
  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#

typescript
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#

typescript
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#

typescript
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#

typescript
import { x402ResourceServer } from "@okxweb3/x402-core/server";

const server = new x402ResourceServer(facilitatorClients?);
// facilitatorClients: FacilitatorClient | FacilitatorClient[]

register(network, server)#

Register a server-side scheme. Chainable.

typescript
server
  .register("eip155:84532", new ExactEvmScheme())
  .register("eip155:196", new AggrDeferredEvmScheme())
  .register("eip155:196", new PermitSubscriptionScheme({ /* ... */ }));  // subscription

registerExtension(extension)#

typescript
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.

typescript
await server.initialize();

buildPaymentRequirements(config) → PaymentRequirements[]#

typescript
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.

typescript
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#

typescript
const result = await server.verifyPayment(paymentPayload, requirements);
// result.isValid: boolean

settlePayment(payload, requirements, ...) → SettleResponse#

typescript
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#

HookContextAbort / 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 }
typescript
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#

typescript
import { x402HTTPResourceServer } from "@okxweb3/x402-core/http";

const httpServer = new x402HTTPResourceServer(resourceServer, routes);

RoutesConfig#

typescript
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)#

typescript
type OnSettlementTimeoutHook = (txHash: string, network: string) => Promise<{ confirmed: boolean }>;

httpServer.onSettlementTimeout(async (txHash, network) => {
  // Custom recovery logic
  return { confirmed: false };
});

onProtectedRequest(hook)#

typescript
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.

typescript
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)#

typescript
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: "..." });
});
ParameterTypeDefaultDescription
routesRoutesConfigrequiredPath pattern → payment config map
serverx402ResourceServerrequiredPre-configured resource server
paywallConfigPaywallConfigundefinedBrowser paywall settings
paywallPaywallProviderundefinedCustom paywall renderer
syncFacilitatorOnStartbooleantrueFetch supported kinds on first request

Next.js (@okxweb3/x402-next)#

typescript
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)#

typescript
import { paymentMiddleware, paymentMiddlewareFromConfig, paymentMiddlewareFromHTTPServer } from "@okxweb3/x402-hono";

app.use("/*", paymentMiddleware(routes, server, paywallConfig?, paywall?, syncFacilitatorOnStart?));

Fastify (@okxweb3/x402-fastify)#

typescript
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)#

typescript
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)#

typescript
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)#

typescript
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:

PackageWrapsWhen to use
@okxweb3/x402-axiosAxiosInstanceExisting Axios codebase; needing interceptors / per-instance config
@okxweb3/x402-fetchglobalThis.fetchfetch-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#

bash
npm install @okxweb3/x402-axios @okxweb3/x402-evm @okxweb3/x402-core axios
typescript
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#

bash
npm install @okxweb3/x402-fetch @okxweb3/x402-evm @okxweb3/x402-core
typescript
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.

typescript
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:

typescript
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#

FieldTypeDescription
schemesSchemeRegistration[]Required. Each item pairs a network (e.g. "eip155:196", "eip155:*") with a scheme client (e.g. new ExactEvmScheme(signer)).
policiesPaymentPolicy[]Optional. See Policies below — filters / transforms accepts in order.
paymentRequirementsSelectorSelectPaymentRequirementsOptional. 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:

  1. Filter by registered schemes — keep only accepts whose network + scheme were both registered via register().
  2. Apply policies in order — each PaymentPolicy further filters / transforms the list.
  3. 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#

typescript
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.

typescript
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#

typescript
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):

typescript
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:

typescript
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);
HookFires whenReturn semantics
onBeforePaymentCreationSelection done, before the scheme signsvoid continues · { abort: true, reason } cancels and rejects
onAfterPaymentCreationAfter the scheme returns a signed payloadvoid only (observe, cannot mutate)
onPaymentCreationFailureScheme throws during signingvoid 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.

typescript
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#

typescript
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
typescript
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).

typescript
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
typescript
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:

typescript
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.

typescript
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
typescript
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).

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
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#

typescript
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#

typescript
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#

typescript
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#

typescript
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:

FieldTypeMeaning
facilitatorAddressstringFacilitator EOA; signed into Permit2 witness.facilitator, the only address the on-chain contract will trust as a settle trigger
subscriptionContractstringA2APaySubscription contract address (EIP-712 verifyingContract)
permit2ContractstringUniswap 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):

typescript
{
  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):

typescript
{
  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 typeTriggerMiddleware behaviour
payment-presettlesubscribe / change / cancel / cancel-pending-change routesSettle first (call facilitator + on-chain); on success attach settleResult.data.subscription / subId onto req.x402, then run the handler. On failure → 402
access-verifiedRequest with APP-Access header hitting a subscription resource routeNo facilitator / no chain call; only local verifyAccess. On success attach req.x402.subscription, then run the handler

Seller handler reads from req.x402:

typescript
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#

bash
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.

typescript
// 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#

typescript
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#

typescript
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#

typescript
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:

typescript
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.

typescript
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#

typescript
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#

typescript
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 / domainVersion must exactly match the on-chain escrow contract's EIP712Domain, otherwise voucher / settle / close signature verification all fail.

Invocation#

typescript
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:

actionBehaviour
openVerify payee → call SA session/open on-chain → write local store
voucherLocal EIP-712 verify → raise highest voucher → atomic charge (no SA API call, purely local)
topUpCall SA session/topUp → accumulate local deposit
closeSeller signs CloseAuth → call SA session/close → delete local store

Call Options#

typescript
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:

typescript
/** 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).

typescript
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#

typescript
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 / ChannelMutator are not exported via subpath in v0.1.0. Structural typing is enough; implement one matching the interface above and pass to session({ store: ... }).


EIP-712 Helpers#

For building and verifying session voucher / settle / close authorizations. On-chain escrow contract's EIP712Domain defaults:

typescript
DEFAULT_DOMAIN_NAME    = 'EVM Payment Channel'
DEFAULT_DOMAIN_VERSION = '1'

verifyVoucher#

Verify the signature was produced by expectedSigner (via viem verifyTypedData / ecrecover).

typescript
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:

typescript
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#

typescript
/** 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.

typescript
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.

typescript
import { Errors } from '@okxweb3/mpp'

SA API error code → PaymentError subclass#

codeMeaningThrown PaymentError
8000API service internal errorVerificationFailedError
70000Missing field or bad formatVerificationFailedError
70001Chain not in supported listVerificationFailedError
70002Payer on blacklistVerificationFailedError
70003source missing / feePayer conflicts with hash mode / txHash reusedVerificationFailedError
70004Signature verification failedInvalidSignatureError
70005Split total ≥ main amountInvalidPayloadError
70006More than 10 splitsInvalidPayloadError
70007Transaction not on-chainVerificationFailedError
70008On-chain channel closedChannelClosedError
70009Challenge missing / expiredInvalidChallengeError
70010channelId does not existChannelNotFoundError
70011Escrow grace period config below thresholdInvalidPayloadError
70012cumulativeAmount > depositAmountExceedsDepositError
70013Voucher delta < minVoucherDeltaDeltaTooSmallError
70014Channel in CLOSING stateChannelClosedError

Error code constants:

typescript
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.

Table of contents