Node SDK Reference#

Node SDK Reference (for exact, aggr_deferred)#

Packages#

PackageDescription
@okxweb3/x402-coreCore: client, server, facilitator, types
@okxweb3/x402-evmEVM mechanisms: exact, aggr_deferred
@okxweb3/x402-expressExpress middleware (seller side)
@okxweb3/x402-nextNext.js middleware (seller side)
@okxweb3/x402-honoHono middleware (seller side)
@okxweb3/x402-fastifyFastify middleware (seller side)
@okxweb3/x402-fetchFetch wrapper (buyer side)
@okxweb3/x402-axiosAxios wrapper (buyer side)
@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 the payment options the seller accepts.

typescript
type PaymentRequirements = {
  scheme: string;           // Payment scheme: "exact" | "aggr_deferred"
  network: Network;         // CAIP-2 network identifier
  asset: string;            // Token contract address
  amount: string;           // Price in token's smallest unit
  payTo: string;            // Recipient wallet address
  maxTimeoutSeconds: number; // Payment authorization validity window
  extra: Record<string, unknown>; // Scheme-specific data
};

extra field per scheme:

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")

PaymentRequired#

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

The signed payment the client submits in 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 field per 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
}

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
};

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());

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

Fetch 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"
  payTo: string;                // Recipient wallet address
  price: Price;                 // "$0.01" or AssetAmount
  network: Network;             // "eip155:196"
  maxTimeoutSeconds?: number;   // Default: 300
  extra?: Record<string, unknown>;
}

const reqs = await server.buildPaymentRequirements({
  scheme: "exact",
  payTo: "0xSeller",
  price: "$0.01",
  network: "eip155:196",
});

buildPaymentRequirementsFromOptions(options, context) → PaymentRequirements[]#

Dynamic pricing and payTo. Functions receive a context parameter.

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

A higher-level wrapper that handles 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>;
}

interface PaymentOption {
  scheme: string;              // "exact" | "aggr_deferred" | "upto"
  payTo: string | DynamicPayTo;  // Static or dynamic recipient
  price: Price | DynamicPrice;   // Static or dynamic price
  network: Network;
  maxTimeoutSeconds?: number;
  extra?: Record<string, unknown>;
}

// Dynamic functions receive HTTPRequestContext
type DynamicPayTo = (context: HTTPRequestContext) => string | Promise<string>;
type DynamicPrice = (context: HTTPRequestContext) => Price | Promise<Price>;

onSettlementTimeout(hook)#

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 };
  }
});

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)
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
routesRoutesConfigrequiredRoute → payment-config map
serverx402ResourceServerrequiredPre-configured resource server
paywallConfigPaywallConfigundefinedBrowser paywall settings
paywallPaywallProviderundefinedCustom paywall renderer
syncFacilitatorOnStartbooleantrueFetch supported kinds on the 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?);

EVM mechanism 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

Client API (buyer side)#

The buyer-side packages handle 402 Payment Required responses automatically: parse the payment requirements → sign the payment payload via the configured EVM scheme → resend the request with the PAYMENT header.

Pick the package matching your existing HTTP client:

PackageWrapsUse case
@okxweb3/x402-axiosAxiosInstanceExisting Axios codebases; need interceptors / instance config
@okxweb3/x402-fetchglobalThis.fetchfetch-based runtimes (browsers, Edge, Node 18+)

The two have an identical API shape: wrapXxxWithPayment(client_or_fetch, x402Client) and wrapXxxWithPaymentFromConfig(client_or_fetch, config).

Axios — @okxweb3/x402-axios#

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 pattern with x402Client#

When you need to register multiple schemes / networks, or share one client across multiple transports, use the explicit builder.

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.

Reading the payment receipt#

After the resent request succeeds, the server returns a PAYMENT-RESPONSE header containing the on-chain receipt (txHash, actual settled amount, etc.). Decode it with decodePaymentResponseHeader:

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 entry pairs a network (e.g. "eip155:196", "eip155:*") with a scheme client (e.g. new ExactEvmScheme(signer)).
policiesPaymentPolicy[]Optional. See Policies below — applied in order to filter / transform accepts.
paymentRequirementsSelectorSelectPaymentRequirementsOptional. Picks the final entry from the filtered list. Default: (version, accepts) => accepts[0].

Selection pipeline#

After receiving a 402, the client decides what to sign in three steps:

  1. Filter by registered schemes — keep only accepts whose network + scheme are both registered via register().
  2. Apply policies in order — each PaymentPolicy further filters / transforms the list.
  3. Selector pick — choose the final entry to sign from the filtered list.

If step 1 or 2 leaves an empty array, the client throws — no signature is attempted.

Policies — PaymentPolicy#

typescript
type PaymentPolicy = (
  x402Version: number,
  paymentRequirements: PaymentRequirements[],
) => PaymentRequirements[];

A policy is a pure function: takes the current accepts, returns a filtered subset (or transformed copy). Common cases: amount caps, network whitelists, scheme preferences.

typescript
import {
  wrapAxiosWithPaymentFromConfig,
  type PaymentPolicy,
} from "@okxweb3/x402-axios";

// Reject single payments above 1 USDT (1_000_000 atomic units, 6 decimals)
const maxAmountPolicy: PaymentPolicy = (_version, reqs) =>
  reqs.filter(r => BigInt(r.amount) <= 1_000_000n);

// Allow X Layer mainnet only
const xLayerOnlyPolicy: PaymentPolicy = (_version, reqs) =>
  reqs.filter(r => r.network === "eip155:196");

// When both schemes are offered, prefer "exact"
const preferExactPolicy: PaymentPolicy = (_version, reqs) => {
  const exact = reqs.filter(r => r.scheme === "exact");
  return exact.length > 0 ? exact : reqs;
};

const api = wrapAxiosWithPaymentFromConfig(axios.create(), {
  schemes: [{ network: "eip155:196", client: new ExactEvmScheme(signer) }],
  policies: [maxAmountPolicy, xLayerOnlyPolicy, preferExactPolicy],
});

Policies run in array order; put "tightening" policies (amount caps, whitelists) first and "preference" policies last.

Custom selector — SelectPaymentRequirements#

typescript
type SelectPaymentRequirements = (
  x402Version: number,
  paymentRequirements: PaymentRequirements[],
) => PaymentRequirements;

The selector runs after policies. Use it when the filtered list still has multiple entries and you need explicit picking logic (e.g. cheapest):

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 instrumentation, last-mile gating, and error recovery. Register them via the builder form:

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 whole payment
  .onBeforePaymentCreation(async ({ paymentRequired, selectedRequirements }) => {
    const tooExpensive = BigInt(selectedRequirements.amount) > 5_000_000n;
    if (tooExpensive) {
      return { abort: true, reason: "Amount exceeds buyer policy cap" };
    }
  })
  // 2. After signing succeeds — observe only (logging, telemetry)
  .onAfterPaymentCreation(async ({ paymentPayload }) => {
    console.log("Signed payload nonce:", paymentPayload.payload?.authorization?.nonce);
  })
  // 3. On signing failure — can recover by returning a manually constructed payload
  .onPaymentCreationFailure(async ({ error }) => {
    console.error("payment creation failed:", error.message);
    // return { recovered: true, payload: fallbackPayload };
  });

const api = wrapAxiosWithPayment(axios.create(), client);
HookTriggered whenReturn semantics
onBeforePaymentCreationSelection done, before scheme signsReturn void to continue · { abort: true, reason } to cancel and reject
onAfterPaymentCreationAfter the scheme returns the signed payloadvoid only (observe, can't change)
onPaymentCreationFailureWhen the scheme throws while signingvoid to keep throwing · { recovered: true, payload } to recover with a substitute

Hooks within the same stage run in registration order.

Client extension — registerExtension#

Use this when the PaymentRequired response carries an extensions field and you need scheme-related payload enhancements (e.g. EIP-2612 permit signature for gas sponsoring). enrichPaymentPayload only fires when paymentRequired.extensions contains a matching key.

typescript
client.registerExtension({
  key: "eip2612GasSponsoring",
  async enrichPaymentPayload(payload, paymentRequired) {
    // Sign EIP-2612 permit and attach it to payload.extensions
    return { ...payload, extensions: { ...payload.extensions, /* ... */ } };
  },
});

Node SDK Reference (for charge, session)#

Install & import#

bash
npm install @okxweb3/mpp viem

@okxweb3/mpp re-exports the entire upstream mppx namespace, so app code typically only needs to import this single package. viem is used for session EIP-712 signing (SessionSigner); charge does not need it.

typescript
// Top level: mppx runtime + namespaces
import { Mppx, Errors } from '@okxweb3/mpp'

// Shared EVM: SA API client, EIP-712 helpers
import { SaApiClient, verifyVoucher, buildSettleAuth } from '@okxweb3/mpp/evm'

// EVM server-side factories
import { charge, session } from '@okxweb3/mpp/evm/server'

Charge — one-time payment#

Register#

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!,
})

Invoke#

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' }))
}

Invoke 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 address
    splits?: ChargeSplit[]        // Splits, max 10 entries
  }
}

type ChargeSplit = {
  amount: string                  // Split amount, base units
  recipient: string               // Split recipient EVM address
  memo?: string
}

Splits#

Just fill in methodDetails.splits:

typescript
methodDetails: {
  feePayer: true,
  splits: [
    { amount: '30', recipient: '0x...', memo: 'partner-a' },
    { amount: '20', recipient: '0x...', memo: 'partner-b' },
  ],
}

Constraints: split total must be strictly less than amount (the primary recipient must keep at least 1 base unit), max 10 entries; the client signs one EIP-3009 per split (auto-attached at payload.authorization.splits[]). Validation is enforced by SA API (codes 70005 / 70006 on violation).


Session — Pay-as-you-go#

For metered scenarios: open an escrow channel → submit vouchers at high frequency → the seller actively settles / closes at any time.

Register#

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 authorization on settle / close
  chainId?: number                       // Default 196
  escrowContract?: Hex                   // Default 0x5E550002e64FaF79B41D89fE8439eEb1be66CE3b
  domainName?: string                    // Default "EVM Payment Channel"
  domainVersion?: string                 // Default "1"
  store?: SessionStore                   // Default in-memory store
  minVoucherDelta?: string               // Default "0", base units
}

/** Seller signing capability. viem's LocalAccount / WalletClient.account both satisfy it. */
type SessionSigner = {
  signTypedData: <const td extends TypedDataDefinition>(p: td) => Promise<Hex>
}

escrowContract / domainName / domainVersion must exactly match the on-chain escrow contract's EIP712Domain, otherwise voucher / settle / close verification will all fail.

Invoke#

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-fall back to factory defaults
  })(request)

  if (result.status === 402) return result.challenge
  // The three management actions (open / topUp / close) are forced to return 204 by the SDK;
  // only the voucher action actually delivers a resource — the Response below is passed through.
  return result.withReceipt(Response.json({ data: 'metered content' }))
}

Each request takes a different path depending on payload.action:

actionBehavior
openVerify payee → call SA session/open on-chain → write local store
voucherLocal EIP-712 verify → bump highest voucher → atomic deduct (no SA API call, purely local)
topUpCall SA session/topUp → add to local deposit
closeSeller signs CloseAuth → call SA session/close → remove from local store

Invoke 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, base units
  methodDetails: {
    chainId?: number              // Falls back to factory default
    escrowContract?: string       // Falls back to factory default
    channelId?: string
    minVoucherDelta?: string      // Throttle: minimum voucher increment
    feePayer?: boolean
    splits?: SessionSplit[]       // Revenue split by bps
  }
}

type SessionSplit = {
  recipient: string
  bps: number                     // basis points (1‒9999), sum(bps) < 10000
  memo?: string
}

Extension methods: active settle / status#

The object returned by session({...}) exposes two extra methods on the mppx Method:

typescript
/** Settle on-chain using the local highest voucher (does not close the channel).
 *  Auto-signs SettleAuthorization and submits. */
mppx.session.settle(channelId: string): Promise<SessionReceipt>

/** Query the on-chain channel state. */
mppx.session.status(channelId: string): Promise<ChannelStatus>

interface SessionReceipt {
  method: string                  // "evm"
  intent: string                  // "session"
  status: string                  // "success"
  timestamp: string               // RFC 3339
  channelId: string
  chainId: number
  reference?: string              // tx hash (transaction mode)
  deposit: string                 // current total on-chain escrow deposit
}

interface ChannelStatus {
  channelId: string
  payer: string
  payee: string
  token: string
  deposit: string
  settledOnChain: string          // amount already settled on-chain (only updated after settle)
  sessionStatus: 'OPEN' | 'CLOSING' | 'CLOSED'
  remainingBalance: string        // = deposit - cumulativeAmount
}

Custom SessionStore#

The default memoryStore() is fine for single-process, but all channel state is lost on restart. For long-lived channels / multi-instance / hot-reload deployments, implement a persistent store yourself (Redis / Postgres / KV / DynamoDB / etcd all work).

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 must be atomic as a whole.
   *  When state doesn't exist, do not call mutator and return null directly.
   *  Implementations must guarantee no concurrent writes during the mutator call. */
  update(channelId: string, mutator: ChannelMutator):
    Promise<ChannelState | null> | ChannelState | null
}

/** Synchronous pure function that mutates state in place; throw to roll back without writing.
 *  Do not perform async IO inside the mutator (the implementation may call it multiple times). */
type ChannelMutator = (state: ChannelState) => void

update() is the key to correctness: in-process use a per-id mutex; Redis use Lua; Postgres use SELECT ... FOR UPDATE transactions; DynamoDB / etcd retry with CAS.

ChannelState#

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                   // Cumulative local deductions
  units: number                   // Number of billings
  highestVoucherAmount: bigint    // Highest accepted voucher amount
  highestVoucher:                 // Bytes value (used for idempotency + idle close)
    | { cumulativeAmount: string; signature: Hex }
    | null
  challengeEcho: ChallengeEcho
  createdAt: string               // ISO 8601
}

SessionStore / ChannelMutator are not exported via subpath in v0.1.0. Structural typing matches — implement the interfaces above and pass to session({ store: ... }).


EIP-712 helpers#

For building and verifying session voucher / settle / close authorizations. Default values for the on-chain escrow contract's EIP712Domain:

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

verifyVoucher#

Verifies whether the signature was made by expectedSigner (uses 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#

Does not sign — only constructs a viem TypedDataDefinition to feed into signer.signTypedData(...) to obtain a 65-byte signature. Both take the same parameters:

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
/** A 256-bit cryptographically secure random number, decimal string. */
function randomU256(): string

/** Unix seconds, decimal string; defaults to now + 1 hour. */
function unixDeadline(secondsFromNow?: number): string

The on-chain used-nonce set is keyed by (payee, channelId, nonce). Reuse reverts with NonceAlreadyUsed; the SDK does not track the used set — it only generates random values that are unlikely to have been used.


SaApiClient#

The OKX SA API HTTP client; the underlying dependency of the charge / session factories. Users only need to instantiate it and pass it to a factory — they don't need to call its methods directly.

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 parsing fails
  msg?: string
  responseBody?: string
}

onError fires on HTTP non-2xx, JSON parse failure, or non-zero business code; it is isolated by try/catch and does not affect the main flow — use it for business-side logging / reporting. After unwrapping internally, the SDK throws the corresponding PaymentError subclass per error code (see the next section).


Error handling#

The SDK throws PaymentError subclasses under the top-level Errors namespace; mppx auto-converts them to RFC 9457 ProblemDetails responses.

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

SA API error codes → PaymentError subclasses#

codeMeaningThrown PaymentError
8000Internal API service errorVerificationFailedError
70000Missing field or invalid formatVerificationFailedError
70001Chain not in supported listVerificationFailedError
70002Payer is blocklistedVerificationFailedError
70003source missing / feePayer conflicts with hash mode / txHash already usedVerificationFailedError
70004Signature verification failedInvalidSignatureError
70005Split total >= primary amountInvalidPayloadError
70006Split count > 10InvalidPayloadError
70007Transaction not on-chainVerificationFailedError
70008On-chain channel closedChannelClosedError
70009Challenge does not exist / expiredInvalidChallengeError
70010channelId not foundChannelNotFoundError
70011escrow grace period configuration not metInvalidPayloadError
70012cumulativeAmount > depositAmountExceedsDepositError
70013Voucher increment < 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"

Insufficient session voucher balance#

When the voucher action deducts locally, if highestVoucherAmount - spent < amount, Errors.InsufficientBalanceError is thrown and mppx returns 402; if the channel doesn't exist, Errors.ChannelNotFoundError is thrown.