Portail dév.
Thème

Go SDK Reference#

Go SDK Reference (applies to exact, exact + permit2, upto, aggr_deferred)#

Modules / Packages#

All import paths are prefixed with github.com/okx/payments/go/x402/.... The table below is organized by Go module / package.

Package pathDescription
github.com/okx/payments/go/x402Core: resource server X402ResourceServer, facilitator client interface, hooks, error types, Network / Price, etc.
github.com/okx/payments/go/x402/typesWire-format types (v1/v2): PaymentRequirements, PaymentPayload, PaymentRequired, SupportedKind, etc.
github.com/okx/payments/go/x402/httpHTTP resource server HTTPServer, routing config RoutesConfig, HTTPFacilitatorClient / OKXFacilitatorClient
github.com/okx/payments/go/x402/http/nethttpnet/http middleware
github.com/okx/payments/go/x402/http/ginGin middleware
github.com/okx/payments/go/x402/http/echoEcho middleware
github.com/okx/payments/go/x402/mechanisms/evmEVM shared primitives: payload types, Permit2 / upto constants, AssetInfo / NetworkConfig
github.com/okx/payments/go/x402/mechanisms/evm/exact/serverexact (EIP-3009 / Permit2) seller scheme
github.com/okx/payments/go/x402/mechanisms/evm/upto/serverupto (cap + override) seller scheme
github.com/okx/payments/go/x402/mechanisms/evm/deferred/serveraggr_deferred (TEE aggregation) seller scheme
github.com/okx/payments/go/x402/adaptersMulti-protocol entry adapter X402Adapter (used for unified dispatch with MPP, etc.)

The Go SDK currently provides primarily server-side (seller) and facilitator client capabilities. Buyer-side packages such as mechanisms/evm/exact/client, upto/client, deferred/client exist, but this reference focuses on the seller side; buyer payment-signing capabilities are governed by those client packages and are not covered here.


Core types#

Network / Price / AssetAmount#

Network is a named string type with wildcard-matching methods, and Price is an empty interface (can take string, a number, or AssetAmount).

go
// Network is a chain identifier in CAIP-2 format, e.g. "eip155:196".
type Network string

func ParseNetwork(s string) Network
func (n Network) Match(pattern Network) bool                  // "eip155:1" matches "eip155:*"
func (n Network) Parse() (namespace, reference string, err error)

// Price can be "$0.01" / "0.01" / a number / AssetAmount.
type Price interface{}

type AssetAmount struct {
	Asset  string                 `json:"asset"`  // token contract address
	Amount string                 `json:"amount"` // amount in smallest unit
	Extra  map[string]interface{} `json:"extra,omitempty"`
}

Package-level network helper functions:

go
func IsWildcardNetwork(network Network) bool
func MatchesNetwork(pattern Network, network Network) bool

ResourceInfo#

go
type ResourceInfo struct {
	URL         string `json:"url"`
	Description string `json:"description,omitempty"`
	MimeType    string `json:"mimeType,omitempty"`
}

PaymentRequirements#

go
type PaymentRequirements struct {
	Scheme            string                 `json:"scheme"`  // "exact" | "aggr_deferred" | "upto"
	Network           string                 `json:"network"` // CAIP-2
	Asset             string                 `json:"asset"`   // token contract address
	Amount            string                 `json:"amount"`  // price (the cap for upto), in smallest unit
	PayTo             string                 `json:"payTo"`   // payee address
	MaxTimeoutSeconds int                    `json:"maxTimeoutSeconds"`
	Extra             map[string]interface{} `json:"extra,omitempty"` // scheme-specific data
}

Common fields in Extra:

keyschememeaning
assetTransferMethodexact / upto"eip3009" (default) or "permit2"; the upto server always writes "permit2"
facilitatorAddressuptoThe upto proxy enforces witness.facilitator == msg.sender; injected automatically by UptoEvmScheme.EnhancePaymentRequirements from supportedKind.Extra
name / versionexact (EIP-3009 path)EIP-712 domain, for client-side signing

In the mechanisms/evm/upto/server package these two keys also have named constants: AssetTransferMethodKey = "assetTransferMethod", ExtraFacilitatorAddressKey.

PaymentRequired#

The 402 response body (v2).

go
type PaymentRequired struct {
	X402Version int                    `json:"x402Version"`
	Error       string                 `json:"error,omitempty"`
	Resource    *ResourceInfo          `json:"resource,omitempty"`
	Accepts     []PaymentRequirements  `json:"accepts"`
	Extensions  map[string]interface{} `json:"extensions,omitempty"`
}

PaymentPayload#

The client's signed payment (v2). The contents of Payload differ by scheme (EIP-3009 / Permit2 / upto Permit2).

go
type PaymentPayload struct {
	X402Version int                    `json:"x402Version"`
	Payload     map[string]interface{} `json:"payload"`  // see the EVM Payload section below
	Accepted    PaymentRequirements    `json:"accepted"`
	Resource    *ResourceInfo          `json:"resource,omitempty"`
	Extensions  map[string]interface{} `json:"extensions,omitempty"`
}

func (p PaymentPayload) GetVersion() int
func (p PaymentPayload) GetScheme() string
func (p PaymentPayload) GetNetwork() string
func (p PaymentPayload) GetPayload() map[string]interface{}

The Go SDK explicitly retains the v1 types (PaymentRequirementsV1 / PaymentPayloadV1 / PaymentRequiredV1 / SupportedKindV1), and uses the two interfaces PaymentRequirementsView / PaymentPayloadView to make hooks version-agnostic. New integrations default to v2.


Facilitator types#

The Go SDK's facilitator interface passes []byte at the network boundary (the SDK routes internally by version); the inputs to verify/settle are not the typed VerifyRequest / SettleRequest but the raw bytes of payload + requirements. The response types are still typed.

VerifyResponse / SettleResponse#

go
type VerifyResponse struct {
	IsValid        bool   `json:"isValid"`
	InvalidReason  string `json:"invalidReason,omitempty"`
	InvalidMessage string `json:"invalidMessage,omitempty"`
	Payer          string `json:"payer,omitempty"`
}

type SettleResponse struct {
	Success      bool    `json:"success"`
	ErrorReason  string  `json:"errorReason,omitempty"`
	ErrorMessage string  `json:"errorMessage,omitempty"`
	Payer        string  `json:"payer,omitempty"`
	Transaction  string  `json:"transaction"`        // transaction hash (empty for aggr_deferred)
	Network      Network `json:"network"`
	Status       string  `json:"status,omitempty"`   // OKX extension: "pending" | "success" | "timeout"
	// In scenarios like upto, the actual settled amount may be strictly less than the signed cap.
	Amount       string  `json:"amount,omitempty"`
}

The sync/async settlement switch lives on the facilitator client config (OKXFacilitatorConfig.SyncSettle, see below), not on each settle call or on each route.

SupportedKind / SupportedResponse#

go
type SupportedKind struct {
	X402Version int                    `json:"x402Version"`
	Scheme      string                 `json:"scheme"`
	Network     string                 `json:"network"`
	// upto: the facilitator address is exposed via extra.facilitatorAddress; the seller scheme
	// injects it into the challenge's extra during EnhancePaymentRequirements.
	Extra       map[string]interface{} `json:"extra,omitempty"`
}

type SupportedResponse struct {
	Kinds      []SupportedKind     `json:"kinds"`
	Extensions []string            `json:"extensions"`
	Signers    map[string][]string `json:"signers"` // CAIP family → signer address
}

SettleStatusResponse#

go
type SettleStatusResponse struct {
	Success      bool    `json:"success"`
	Status       string  `json:"status,omitempty"` // "pending" | "success" | "failed"
	ErrorReason  string  `json:"errorReason,omitempty"`
	ErrorMessage string  `json:"errorMessage,omitempty"`
	Payer        string  `json:"payer,omitempty"`
	Transaction  string  `json:"transaction,omitempty"`
	Network      Network `json:"network,omitempty"`
}

Interfaces#

SchemeNetworkServer#

The server-side scheme implementation. exact / aggr_deferred / upto all implement this interface.

go
type SchemeNetworkServer interface {
	Scheme() string
	ParsePrice(price Price, network Network) (AssetAmount, error)
	EnhancePaymentRequirements(
		ctx context.Context,
		requirements types.PaymentRequirements,
		supportedKind types.SupportedKind,
		extensions []string,
	) (types.PaymentRequirements, error)
}

FacilitatorClient#

The network boundary for communicating with a remote facilitator. Note that the inputs are bytes, and the version is detected internally by the SDK.

go
type FacilitatorClient interface {
	Verify(ctx context.Context, payloadBytes []byte, requirementsBytes []byte) (*VerifyResponse, error)
	Settle(ctx context.Context, payloadBytes []byte, requirementsBytes []byte) (*SettleResponse, error)
	GetSupported(ctx context.Context) (SupportedResponse, error)
}

// Optional interface: supports querying settlement status by transaction hash (used for timeout-recovery polling).
type SettleStatusChecker interface {
	GetSettleStatus(ctx context.Context, txHash string) (*SettleStatusResponse, error)
}

ResourceServerExtension / FacilitatorExtension#

Go's extension interfaces are stripped down as follows:

go
// In the types package.
type ResourceServerExtension interface {
	Key() string
	EnrichDeclaration(declaration interface{}, transportContext interface{}) interface{}
}

// In the x402 package, the facilitator-side extension.
type FacilitatorExtension interface {
	Key() string
}
func NewFacilitatorExtension(key string) FacilitatorExtension

Enrichment for verify/settle extensions is implemented through a set of hooks (BeforeVerifyHook / AfterVerifyHook / OnVerifyFailureHook / BeforeSettleHook / AfterSettleHook / OnSettleFailureHook), registered via ResourceServerOptions such as WithBeforeVerifyHook(...) (see below).


Server API (X402ResourceServer)#

Construction and registration#

Construction uses functional options (opts ...ResourceServerOption); schemes are registered directly via the chained Register(network, scheme) — multiple schemes can coexist on the same network, and the routing side selects by scheme name.

go
import (
	"github.com/okx/payments/go/x402"
	exact "github.com/okx/payments/go/x402/mechanisms/evm/exact/server"
	deferred "github.com/okx/payments/go/x402/mechanisms/evm/deferred/server"
	uptoserver "github.com/okx/payments/go/x402/mechanisms/evm/upto/server"
)

server := x402.Newx402ResourceServer(
	x402.WithFacilitatorClient(fac),
).
	Register("eip155:196", exact.NewExactEvmScheme()).        // exact (EIP-3009 / Permit2)
	Register("eip155:196", deferred.NewAggrDeferredEvmScheme()). // aggr_deferred
	Register("eip155:196", uptoserver.NewUptoEvmScheme())     // upto (cap + override)

Construction options:

go
type ResourceServerOption func(*x402ResourceServer)

func WithFacilitatorClient(client FacilitatorClient) ResourceServerOption
func WithSchemeServer(network Network, schemeServer SchemeNetworkServer) ResourceServerOption // equivalent to the chained Register
func WithCacheTTL(ttl time.Duration) ResourceServerOption
func WithBeforeVerifyHook(hook BeforeVerifyHook) ResourceServerOption
func WithAfterVerifyHook(hook AfterVerifyHook) ResourceServerOption
func WithOnVerifyFailureHook(hook OnVerifyFailureHook) ResourceServerOption
func WithBeforeSettleHook(hook BeforeSettleHook) ResourceServerOption
func WithAfterSettleHook(hook AfterSettleHook) ResourceServerOption
func WithOnSettleFailureHook(hook OnSettleFailureHook) ResourceServerOption

Methods#

go
func Newx402ResourceServer(opts ...ResourceServerOption) *x402ResourceServer

func (s *x402ResourceServer) Register(network Network, schemeServer SchemeNetworkServer) *x402ResourceServer
func (s *x402ResourceServer) RegisterExtension(extension types.ResourceServerExtension) *x402ResourceServer

// Fetch the facilitator's supported kinds and cache them. Initialize is not optional:
// the HTTP layer drives it when the middleware is mounted / on the first request (see below).
func (s *x402ResourceServer) Initialize(ctx context.Context) error

func (s *x402ResourceServer) HasRegisteredScheme(network Network, scheme string) bool
func (s *x402ResourceServer) HasFacilitatorSupport(network Network, scheme string) bool
func (s *x402ResourceServer) GetFacilitatorClient(network Network, scheme string) FacilitatorClient

// Generate a challenge from one ResourceConfig + supportedKind (internally dispatches to
// the corresponding scheme's ParsePrice + EnhancePaymentRequirements).
func (s *x402ResourceServer) BuildPaymentRequirements(
	ctx context.Context,
	config ResourceConfig,
	supportedKind types.SupportedKind,
	extensions []string,
) (types.PaymentRequirements, error)

func (s *x402ResourceServer) VerifyPayment(
	ctx context.Context,
	payload types.PaymentPayload,
	requirements types.PaymentRequirements,
) (*VerifyResponse, error)

// overrides: used by the upto scheme — the business handler decides the actual amount charged this
// time (≤ cap), passing it through to the middleware via a response header; the middleware parses
// it into *SettlementOverrides and then calls this method.
func (s *x402ResourceServer) SettlePayment(
	ctx context.Context,
	payload types.PaymentPayload,
	requirements types.PaymentRequirements,
	overrides *SettlementOverrides,
) (*SettleResponse, error)

func (s *x402ResourceServer) FindMatchingRequirements(
	available []types.PaymentRequirements,
	payload types.PaymentPayload,
) *types.PaymentRequirements

There is no standalone core method for polling settlement status; the logic is folded into the HTTP layer (HTTPServer.SetPollDeadline + the OnSettlementTimeout hook + the facilitator's GetSettleStatus, see below). The PollResult type itself does exist:

go
type PollResult string
const (
	PollResultSuccess PollResult = "success"
	PollResultFailed  PollResult = "failed"
	PollResultTimeout PollResult = "timeout"
)

ResourceConfig / SettlementOverrides#

go
type ResourceConfig struct {
	Scheme            string                 `json:"scheme"`
	PayTo             string                 `json:"payTo"`
	Price             Price                  `json:"price"`
	Network           Network                `json:"network"`
	MaxTimeoutSeconds int                    `json:"maxTimeoutSeconds,omitempty"`
	Extra             map[string]interface{} `json:"extra,omitempty"`
}

// Set by the business handler via a response header; the middleware reads it out and applies it at settle time.
type SettlementOverrides struct {
	// The actual settled amount (atomic units), must be <= the authorized cap.
	Amount string `json:"amount,omitempty"`
}

The Amount field is documented as an atomic-unit integer string (must be <= authorized max).


OKX Facilitator client (OKXFacilitatorClient)#

go
import x402http "github.com/okx/payments/go/x402/http"

syncSettle := true
fac, err := x402http.NewOKXFacilitatorClient(&x402http.OKXFacilitatorConfig{
	Auth: x402http.OKXAuthConfig{
		APIKey:     apiKey,
		SecretKey:  secretKey,
		Passphrase: passphrase,
		// BaseURL / BasePath optional
	},
	BaseURL:    "https://web3.okx.com", // default value; can be overridden for sandbox/staging
	SyncSettle: &syncSettle,            // default true: returns after on-chain confirmation (exact)
})
go
type OKXAuthConfig struct {
	APIKey     string
	SecretKey  string
	Passphrase string
	BaseURL    string // default "https://web3.okx.com"
	BasePath   string // e.g. "/api/v6/x402"
}

type OKXFacilitatorConfig struct {
	Auth       OKXAuthConfig
	BaseURL    string        // default "https://web3.okx.com"
	SyncSettle *bool         // default true
	HTTPClient *http.Client
	Timeout    time.Duration
}

func NewOKXFacilitatorClient(config *OKXFacilitatorConfig) (*OKXFacilitatorClient, error)

func (c *OKXFacilitatorClient) GetSupported(ctx context.Context) (x402.SupportedResponse, error)
func (c *OKXFacilitatorClient) Verify(ctx context.Context, payloadBytes []byte, requirementsBytes []byte) (*x402.VerifyResponse, error)
func (c *OKXFacilitatorClient) Settle(ctx context.Context, payloadBytes []byte, requirementsBytes []byte) (*x402.SettleResponse, error)
func (c *OKXFacilitatorClient) GetSettleStatus(ctx context.Context, txHash string) (*x402.SettleStatusResponse, error)

NewOKXFacilitatorClient returns an error when APIKey / SecretKey / Passphrase is missing. The client automatically adds HMAC-SHA256 OKX authentication headers to requests and automatically unwraps the {"code":0,"data":{...},"msg":""} envelope.

sync vs async: sync/async is controlled by OKXFacilitatorConfig.SyncSettle (per-client, default true). The HTTP RouteConfig does not have a SyncSettle field.

Generic HTTP facilitator client#

For non-OKX facilitators, use HTTPFacilitatorClient:

go
type FacilitatorConfig struct {
	URL          string
	HTTPClient   *http.Client
	AuthProvider AuthProvider
	Timeout      time.Duration // default 30s
	Identifier   string
}

func NewHTTPFacilitatorClient(config *FacilitatorConfig) *HTTPFacilitatorClient
func NewFacilitatorClient(config *FacilitatorConfig) *HTTPFacilitatorClient // alias

const DefaultFacilitatorURL = "https://x402.org/facilitator"

HMAC authentication#

Go encapsulates authentication inside OKXFacilitatorClient; what is exposed externally is a low-level signing function and an authentication abstraction interface:

go
// Compute the OKX signature: Base64(HMAC-SHA256(secret, timestamp + METHOD + path + body)).
func ComputeSignature(secretKey, timestamp, method, path, body string) string

type AuthProvider interface {
	GetAuthHeaders(ctx context.Context) (AuthHeaders, error)
}

type AuthHeaders struct {
	// the set of authentication headers for each endpoint
}

There is no public function for "one-click adding the full set of OK-ACCESS-* headers to an arbitrary request"; the specific headers are assembled internally by OKXFacilitatorClient based on ComputeSignature.


HTTP utilities#

Header encoding/decoding#

The Go SDK currently does not expose header encode/decode functions as public API — the base64 encoding/decoding of PAYMENT-SIGNATURE / PAYMENT-REQUIRED / PAYMENT-RESPONSE is done internally by HTTPServer.ProcessHTTPRequest / the middleware. These header names are string literals in the source code, not exported constants.

Constants#

go
// http package
const DefaultPollInterval = 1 * time.Second
const DefaultPollDeadline = 5 * time.Second
const SettlementOverridesHeader = "settlement-overrides"

const (
	ResultNoPaymentRequired = "no-payment-required"
	ResultPaymentVerified   = "payment-verified"
	ResultPaymentError      = "payment-error"
)

Go only promotes SettlementOverridesHeader to an exported constant; the three header names PAYMENT-SIGNATURE / PAYMENT-REQUIRED / PAYMENT-RESPONSE are used internally.


Routing config#

RoutesConfig is a map[string]RouteConfig, where the key is in the form "GET /path". Each accept is a PaymentOption.

go
type RoutesConfig map[string]RouteConfig

type RouteConfig struct {
	Accepts PaymentOptions `json:"accepts"`

	Resource          string                 `json:"resource,omitempty"`    // manually pin ResourceInfo.url; auto-assembled from the request when empty
	Description       string                 `json:"description,omitempty"`
	MimeType          string                 `json:"mimeType,omitempty"`
	CustomPaywallHTML string                 `json:"customPaywallHtml,omitempty"`
	Extensions        map[string]interface{} `json:"extensions,omitempty"`
	AcceptedDomains   []string               `json:"acceptedDomains,omitempty"` // allowlist of payload.resource.url hosts (tolerates reverse-proxy/CDN Host rewriting)
	UnpaidResponseBody UnpaidResponseBodyFunc `json:"-"`                         // custom unpaid response body
}

type PaymentOptions = []PaymentOption

type PaymentOption struct {
	Scheme            string                 `json:"scheme"`            // "exact" | "aggr_deferred" | "upto"
	PayTo             interface{}            `json:"payTo"`             // string or DynamicPayToFunc
	Price             interface{}            `json:"price"`             // x402.Price or DynamicPriceFunc
	Network           x402.Network           `json:"network"`
	MaxTimeoutSeconds int                    `json:"maxTimeoutSeconds,omitempty"`
	Extra             map[string]interface{} `json:"extra,omitempty"`
}

// PayTo / Price support runtime dynamic evaluation.
type DynamicPayToFunc func(context.Context, HTTPRequestContext) (string, error)
type DynamicPriceFunc func(context.Context, HTTPRequestContext) (x402.Price, error)

Two notes: (1) sync/async is controlled by the facilitator client OKXFacilitatorConfig.SyncSettle, not on RouteConfig; (2) PayTo / Price are interface{} — they can take either a static value or a Dynamic*Func dynamic-resolution callback.

Example (multiple schemes coexisting)#

go
routes := x402http.RoutesConfig{
	"GET /api/data": {
		Accepts: x402http.PaymentOptions{
			// 1) default exact + EIP-3009 (USD₮0 on X Layer)
			{
				Scheme:  "exact",
				Price:   "$0.01",
				Network: "eip155:196",
				PayTo:   "0xSeller",
			},
			// 2) exact + Permit2 (generic ERC-20)
			{
				Scheme:  "exact",
				Price:   "$0.01",
				Network: "eip155:196",
				PayTo:   "0xSeller",
				Extra:   map[string]any{"assetTransferMethod": "permit2"},
			},
			// 3) aggr_deferred (TEE aggregation)
			{
				Scheme:  "aggr_deferred",
				Price:   "$0.001",
				Network: "eip155:196",
				PayTo:   "0xSeller",
			},
		},
		Description: "Premium data",
		MimeType:    "application/json",
	},
}

exact + permit2 is just the exact scheme + the route Extra: {"assetTransferMethod":"permit2"}. An upto route usually does not need a manually filled Extra.facilitatorAddressUptoEvmScheme.EnhancePaymentRequirements injects it automatically from the facilitator /supported stream.


Middleware#

A middleware package is provided for each of net/http / Gin / Echo. All three share the same API shape: each has X402Payment(Config), PaymentMiddleware(...), PaymentMiddlewareFromConfig(...), PaymentMiddlewareFromHTTPServer(...), SimpleX402Payment(...).

net/http#

go
import (
	nethttpmw "github.com/okx/payments/go/x402/http/nethttp"
	evm "github.com/okx/payments/go/x402/mechanisms/evm/exact/server"
)

mux := http.NewServeMux()
mux.HandleFunc("/api/data", handler)

handler := nethttpmw.X402Payment(nethttpmw.Config{
	Routes:      routes,
	Facilitator: fac,
	Schemes: []nethttpmw.SchemeConfig{
		{Network: "eip155:*", Server: evm.NewExactEvmScheme()},
	},
	SyncFacilitatorOnStart: true,
	Timeout:                30 * time.Second,
})(mux)
go
func X402Payment(config Config) func(http.Handler) http.Handler

type Config struct {
	Routes                 x402http.RoutesConfig
	Facilitator            x402.FacilitatorClient   // use Facilitator or Facilitators, pick one
	Facilitators           []x402.FacilitatorClient
	Schemes                []SchemeConfig
	PaywallConfig          *x402http.PaywallConfig
	SyncFacilitatorOnStart bool                     // default true: fetch supported kinds on startup
	Timeout                time.Duration            // default 30s
	ErrorHandler           func(w http.ResponseWriter, r *http.Request, err error)
	SettlementHandler      func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse)
}

type SchemeConfig struct {
	Network x402.Network
	Server  x402.SchemeNetworkServer
}

// Use a pre-built server / HTTPServer (convenient for attaching hooks first):
func PaymentMiddleware(routes x402http.RoutesConfig, server *x402.X402ResourceServer, opts ...MiddlewareOption) func(http.Handler) http.Handler
func PaymentMiddlewareFromHTTPServer(httpServer *x402http.HTTPServer, opts ...MiddlewareOption) func(http.Handler) http.Handler
func PaymentMiddlewareFromConfig(routes x402http.RoutesConfig, opts ...MiddlewareOption) func(http.Handler) http.Handler

// Minimal-config version (single route + single facilitator URL):
func SimpleX402Payment(payTo string, price string, network x402.Network, facilitatorURL string) func(http.Handler) http.Handler

// Retrieve the verified payload / requirements from inside the handler:
func PayloadFromContext(ctx context.Context) (*types.PaymentPayload, bool)
func RequirementsFromContext(ctx context.Context) (*types.PaymentRequirements, bool)

The MiddlewareOption forms (equivalent to Config): WithFacilitatorClient / WithScheme(network, server) / WithPaywallConfig / WithSyncFacilitatorOnStart / WithTimeout / WithErrorHandler / WithSettlementHandler.

Gin#

go
import ginmw "github.com/okx/payments/go/x402/http/gin"

r.Use(ginmw.X402Payment(ginmw.Config{
	Routes:      routes,
	Facilitator: fac,
	Schemes: []ginmw.SchemeConfig{
		{Network: "eip155:*", Server: evm.NewExactEvmScheme()},
	},
	SyncFacilitatorOnStart: true,
	Timeout:                30 * time.Second,
}))

All three frameworks provide the upto partial-settlement helper SetSettlementOverrides — the business handler uses it to report the actual amount charged this time back to the middleware (the middleware reads it out before settle, and strips the response header out of the client response):

go
// Same-named function across the frameworks; the signature varies with each framework's request/response handle:
func ginmw.SetSettlementOverrides(c *gin.Context, overrides *x402.SettlementOverrides)
func echomw.SetSettlementOverrides(c echo.Context, overrides *x402.SettlementOverrides)
func nethttpmw.SetSettlementOverrides(w http.ResponseWriter, overrides *x402.SettlementOverrides)
go
// Inside a handler (upto) — using Gin as an example:
ginmw.SetSettlementOverrides(c, &x402.SettlementOverrides{Amount: "1234000"})

Echo#

The echo package is isomorphic to the above: X402Payment(Config) echo.MiddlewareFunc, PaymentMiddleware*, SimpleX402Payment, with identical Config / SchemeConfig / MiddlewareOption fields (the callback signatures change to func(echo.Context, ...)).

Middleware flow#

  1. Match the request route cfg; no match → pass through to the inner handler
  2. No PAYMENT-SIGNATURE request header → return 402 + PAYMENT-REQUIRED (for browser requests, render the paywall HTML)
  3. Decode and verify the payment payload, matching it against the route accepts
  4. Verify via the facilitator (Verify)
  5. Call the inner handler and buffer the response
  6. If the handler set a settlement override (upto, via SetSettlementOverrides), the middleware parses it into *SettlementOverrides
  7. Settle via the facilitator (Settle)
  8. Async (status:"pending" / "timeout") → poll GetSettleStatus within pollDeadline
  9. Still timing out → call the OnSettlementTimeout hook (if configured)
  10. Add the PAYMENT-RESPONSE header to the response

Hooks / settings attachable on HTTPServer#

After pre-building an HTTPServer, mount it with PaymentMiddlewareFromHTTPServer; you can register chained:

go
httpServer := x402http.Wrappedx402HTTPResourceServer(routes, resourceServer).
	OnProtectedRequest(requestHook).
	SetPollDeadline(8 * time.Second).
	OnSettlementTimeout(func(ctx context.Context, txHash, network string) (confirmed bool, err error) {
		// timeout observation: on-chain re-confirmation / logging / metrics reporting
		return false, nil
	})
go
func (s *HTTPServer) OnProtectedRequest(hook ProtectedRequestHook) *HTTPServer
func (s *HTTPServer) SetPollDeadline(deadline time.Duration) *HTTPServer
func (s *HTTPServer) OnSettlementTimeout(hook OnSettlementTimeoutHook) *HTTPServer
func (s *HTTPServer) RegisterPaywallProvider(provider PaywallProvider) *HTTPServer
func (s *HTTPServer) AddRoutes(routes RoutesConfig) *HTTPServer
func (s *HTTPServer) Initialize(ctx context.Context) error // fetch supported kinds + validate the route config

// Timeout-recovery hook: parameters are (ctx, txHash, network); the returned confirmed decides whether to deliver the resource.
type OnSettlementTimeoutHook func(ctx context.Context, txHash string, network string) (confirmed bool, err error)

The parameter order of OnSettlementTimeoutHook is (ctx, txHash, network), returning (confirmed bool, err error).


EVM mechanisms (mechanisms/evm + scheme server subpackages)#

ExactEvmScheme#

go
import exact "github.com/okx/payments/go/x402/mechanisms/evm/exact/server"

scheme := exact.NewExactEvmScheme()
scheme.Scheme() // "exact"

Responsible for: price parsing ("$0.01" / "0.01" / AssetAmount); converting to atomic units by token decimals; looking up the default asset by network; injecting the EIP-712 domain (name / version) into Extra for EIP-3009 signing; the buyer goes through the Permit2 flow when Extra.assetTransferMethod="permit2".

go
func NewExactEvmScheme() *ExactEvmScheme
func (s *ExactEvmScheme) Scheme() string
func (s *ExactEvmScheme) ParsePrice(price x402.Price, network x402.Network) (x402.AssetAmount, error)
func (s *ExactEvmScheme) EnhancePaymentRequirements(ctx context.Context, requirements types.PaymentRequirements, supportedKind types.SupportedKind, extensionKeys []string) (types.PaymentRequirements, error)
func (s *ExactEvmScheme) ConvertToTokenAmount(decimalAmount string, network string) (string, error)
func (s *ExactEvmScheme) ConvertFromTokenAmount(tokenAmount string, network string) (string, error)
func (s *ExactEvmScheme) GetDisplayAmount(amount string, network string, asset string) (string, error)
func (s *ExactEvmScheme) GetSupportedNetworks() []string
func (s *ExactEvmScheme) ValidatePaymentRequirements(requirements x402.PaymentRequirements) error
func (s *ExactEvmScheme) RegisterMoneyParser(parser x402.MoneyParser) *ExactEvmScheme // custom price→asset conversion chain

AggrDeferredEvmScheme#

go
import deferred "github.com/okx/payments/go/x402/mechanisms/evm/deferred/server"

scheme := deferred.NewAggrDeferredEvmScheme()
scheme.Scheme() // "aggr_deferred"

All price / requirements logic is delegated to ExactEvmScheme; the seller config is exactly the same as exact, and on-chain settlement is aggregated by the facilitator TEE (the Facilitator converts session-key signatures into EOA signatures and batches them on-chain).

go
func NewAggrDeferredEvmScheme() *AggrDeferredEvmScheme
func (s *AggrDeferredEvmScheme) Scheme() string // "aggr_deferred"
func (s *AggrDeferredEvmScheme) ParsePrice(price x402.Price, network x402.Network) (x402.AssetAmount, error)
func (s *AggrDeferredEvmScheme) EnhancePaymentRequirements(ctx context.Context, requirements types.PaymentRequirements, supportedKind types.SupportedKind, extensions []string) (types.PaymentRequirements, error)

UptoEvmScheme#

go
import uptoserver "github.com/okx/payments/go/x402/mechanisms/evm/upto/server"

scheme := uptoserver.NewUptoEvmScheme()
scheme.Scheme() // "upto"

upto is a Permit2-only cap-and-override mode:

  • PaymentRequirements.Amount is the upper bound (cap), not the actual amount charged
  • EnhancePaymentRequirements always writes Extra.assetTransferMethod = "permit2"
  • It automatically injects from supportedKind.Extra.facilitatorAddress into the challenge, having the buyer pin the facilitator address into witness.facilitator (the contract layer enforces msg.sender == witness.facilitator)
  • The business handler decides the actual amount charged via SetSettlementOverrides (available in net/http / Gin / Echo); the middleware reads it out and calls SettlePayment with the overrides, charging the balance by actual usage
go
func NewUptoEvmScheme() *UptoEvmScheme
func (s *UptoEvmScheme) Scheme() string // "upto"
func (s *UptoEvmScheme) ParsePrice(price x402.Price, network x402.Network) (x402.AssetAmount, error)
func (s *UptoEvmScheme) EnhancePaymentRequirements(ctx context.Context, requirements types.PaymentRequirements, supportedKind types.SupportedKind, extensionKeys []string) (types.PaymentRequirements, error)
func (s *UptoEvmScheme) RegisterMoneyParser(parser x402.MoneyParser) *UptoEvmScheme
// ... ConvertToTokenAmount / ConvertFromTokenAmount / GetDisplayAmount / GetSupportedNetworks / ValidatePaymentRequirements same as exact

// Server-side structural / cross-field validation (runs before the payload is forwarded to the facilitator):
func ValidateUptoPayload(payload types.PaymentPayload, requirements types.PaymentRequirements) error

// Extra key constants
const AssetTransferMethodKey = "assetTransferMethod"
const ExtraFacilitatorAddressKey = /* re-export from upto/client */

ValidateUptoPayload validates in order: scheme=="upto", network consistency, payload structure is upto Permit2 (discriminated by witness.facilitator), spender is X402UptoPermit2ProxyAddress, witness to==PayTo, witness facilitator==Extra.facilitatorAddress, token==Asset, permitted.amount==Amount (the signed cap), deadline is sufficient, and the signature recovers to from. This is facilitator-free structural validation; on-chain simulation + signature verification still happen on the facilitator side.

Self-hosted facilitator schemes (exact/facilitator, upto/facilitator)#

If you are not using the OKX-managed facilitator (OKXFacilitatorClient) but instead running your own facilitator (holding the signer, doing verify + on-chain submission yourself), use these two packages. They implement x402.SchemeNetworkFacilitator, and at construction you inject an evm.FacilitatorEvmSigner (providing the signing address + RPC primitives) + an optional config.

go
import (
	exactfac "github.com/okx/payments/go/x402/mechanisms/evm/exact/facilitator"
	uptofac "github.com/okx/payments/go/x402/mechanisms/evm/upto/facilitator"
)

// exact: SimulateInSettle is a plain bool (zero value false, no re-run).
type exactfac.ExactEvmSchemeConfig struct {
	DeployERC4337WithEIP6492 bool // automatically deploy the ERC-4337 smart wallet (EIP-6492)
	SimulateInSettle         bool // re-run the eth_call simulation at settle time (verify always simulates)
}
func exactfac.NewExactEvmScheme(signer evm.FacilitatorEvmSigner, config *ExactEvmSchemeConfig) *ExactEvmScheme

// upto: SimulateInSettle is a *bool —— nil ⇒ default true.
type uptofac.UptoEvmSchemeConfig struct {
	SimulateInSettle *bool // re-run the eth_call simulation at settle time; nil ⇒ true, pass *false to disable
}
func uptofac.NewUptoEvmScheme(signer evm.FacilitatorEvmSigner, config *UptoEvmSchemeConfig) *UptoEvmScheme
// When config = nil, all defaults are used (SimulateInSettle defaults to true).

// The underlying Permit2 settlement-layer config (passed through by UptoEvmScheme):
type uptofac.UptoPermit2FacilitatorConfig struct {
	SimulateInSettle *bool // same as above, nil ⇒ true
}

The SimulateInSettle semantics differ between the two facilitators: exact is a plain bool (zero value false, no re-run); upto is a *bool (nil ⇒ default true) — because the upto actual charge may be strictly < cap, re-running the simulation before settle is safer.

EVM Payload types (mechanisms/evm)#

go
type AssetTransferMethod string
const (
	AssetTransferMethodEIP3009 AssetTransferMethod = "eip3009"
	AssetTransferMethodPermit2 AssetTransferMethod = "permit2"
)

// ---- EIP-3009 (default) ----
type ExactEIP3009Authorization struct {
	From        string `json:"from"`
	To          string `json:"to"`
	Value       string `json:"value"`
	ValidAfter  string `json:"validAfter"`
	ValidBefore string `json:"validBefore"`
	Nonce       string `json:"nonce"`
}

type ExactEIP3009Payload struct {
	Signature     string                    `json:"signature,omitempty"`
	Authorization ExactEIP3009Authorization `json:"authorization"`
}
func PayloadFromMap(data map[string]interface{}) (*ExactEIP3009Payload, error)
func (p *ExactEIP3009Payload) ToMap() map[string]interface{}

// v1/v2 are aliases of the same struct in Go:
type ExactEvmPayloadV1 = ExactEIP3009Payload
type ExactEvmPayloadV2 = ExactEIP3009Payload

// ---- Exact + Permit2 ----
type Permit2TokenPermissions struct {
	Token  string `json:"token"`
	Amount string `json:"amount"`
}
type Permit2Witness struct {
	To         string `json:"to"`
	ValidAfter string `json:"validAfter"`
}
func (w Permit2Witness) WitnessTypeString() string
type Permit2Authorization struct {
	From      string                  `json:"from"`
	Permitted Permit2TokenPermissions `json:"permitted"`
	Spender   string                  `json:"spender"`
	Nonce     string                  `json:"nonce"`
	Deadline  string                  `json:"deadline"`
	Witness   Permit2Witness          `json:"witness"`
}
func (a Permit2Authorization) WitnessTypeString() string
type ExactPermit2Payload struct {
	Signature            string               `json:"signature"`
	Permit2Authorization Permit2Authorization `json:"permit2Authorization"`
}
func Permit2PayloadFromMap(data map[string]interface{}) (*ExactPermit2Payload, error)
func (p *ExactPermit2Payload) ToMap() map[string]interface{}

// ---- Upto + Permit2 (cap mode) ----
// The upto witness has an extra facilitator, letting the upto proxy enforce msg.sender == witness.facilitator on-chain.
type UptoPermit2Witness struct {
	To          string `json:"to"`
	Facilitator string `json:"facilitator"`
	ValidAfter  string `json:"validAfter"`
}
func (w UptoPermit2Witness) WitnessTypeString() string
type UptoPermit2Authorization struct {
	From      string                  `json:"from"`
	Permitted Permit2TokenPermissions `json:"permitted"` // permitted.amount is the cap
	Spender   string                  `json:"spender"`
	Nonce     string                  `json:"nonce"`
	Deadline  string                  `json:"deadline"`
	Witness   UptoPermit2Witness      `json:"witness"`
}
func (a UptoPermit2Authorization) WitnessTypeString() string
type UptoPermit2Payload struct {
	Signature            string                   `json:"signature"`
	Permit2Authorization UptoPermit2Authorization `json:"permit2Authorization"`
}
func UptoPermit2PayloadFromMap(data map[string]interface{}) (*UptoPermit2Payload, error)
func (p *UptoPermit2Payload) ToMap() map[string]interface{}

// payload shape discrimination:
func IsEIP3009Payload(data map[string]interface{}) bool
func IsPermit2Payload(data map[string]interface{}) bool
func IsUptoPermit2Payload(data map[string]interface{}) bool

The payload is represented as a map[string]interface{}, distinguished into EIP3009 / Permit2 / upto Permit2 via the three discriminator functions IsEIP3009Payload / IsPermit2Payload / IsUptoPermit2Payload.

Permit2 / Upto constants (mechanisms/evm)#

go
// Permit2 is a CREATE2-vanity deployment; the address is the same on every EVM chain.
const PERMIT2Address = "0x000000000022D473030F116dDEE9F6B43aC78BA3"
const MULTICALL3Address = "0xcA11bde05977b3631167028862bE2a173976CA11"

// The Permit2 proxy contracts deployed by x402 (vanity addresses).
const X402ExactPermit2ProxyAddress = "0x402085c248EeA27D92E8b30b2C58ed07f9E20001"
const X402UptoPermit2ProxyAddress  = "0x4020e7393B728A3939659E5732F87fdd8e680002"

// Permit2 witness typehash strings —— the field order is ABI-significant.
const Permit2ExactWitnessTypeString = "Witness(address to,uint256 validAfter)"
const Permit2UptoWitnessTypeString  = "Witness(address to,address facilitator,uint256 validAfter)"

// scheme names / default parameters
const SchemeExact        = "exact"
const SchemeUpto         = "upto"
const SchemeAggrDeferred = "aggr_deferred"
const DefaultDecimals    = 6
const DefaultValidityPeriod = 3600 // seconds

Asset / chain config (mechanisms/evm)#

Asset info uses AssetInfo + GetAssetInfo; chain info is in NetworkConfig + GetNetworkConfig, with a preset NetworkConfigs map.

go
type AssetInfo struct {
	Address             string
	Name                string              // EIP-712 domain name (USD₮0 uses U+20AE)
	Version             string
	Decimals            int
	AssetTransferMethod AssetTransferMethod // forced to "permit2"
    v
	SupportsEip2612     bool
}
func GetAssetInfo(network string, assetSymbolOrAddress string) (*AssetInfo, error)

type NetworkConfig struct {
	ChainID      *big.Int
	DefaultAsset AssetInfo
}
func GetNetworkConfig(network string) (*NetworkConfig, error)
func GetEvmChainId(network string) (*big.Int, error)

// Preset chain IDs (including X Layer mainnet eip155:196 and testnet eip155:1952):
var ChainIDXLayer        = big.NewInt(196)
var ChainIDXLayerTestnet = big.NewInt(1952)
// as well as ChainIDBase / ChainIDBaseSepolia / ChainIDStable / ChainIDMonad / ... see NetworkConfigs

Error types#

The errors in the x402 package are a set of concrete error types + string error-code constants.

go
// generic payment error
type PaymentError struct {
	Code    string                 `json:"code"`
	Message string                 `json:"message"`
	Details map[string]interface{} `json:"details,omitempty"`
}
func NewPaymentError(code, message string, details map[string]interface{}) *PaymentError
func (e *PaymentError) Error() string

// verification failure
type VerifyError struct {
	InvalidReason  string
	Payer          string
	InvalidMessage string
}
func NewVerifyError(reason, payer, message string) *VerifyError

// settlement failure
type SettleError struct {
	ErrorReason  string
	Payer        string
	Network      Network
	Transaction  string
	ErrorMessage string
}
func NewSettleError(reason, payer string, network Network, transaction, message string) *SettleError

// the facilitator returned a malformed success body
type FacilitatorResponseError struct { /* unexported fields */ } // in the http package
func (e *FacilitatorResponseError) Error() string
func (e *FacilitatorResponseError) Unwrap() error

// route config validation error (returned by HTTPServer.Initialize)
type RouteConfigurationError struct { Errors []RouteValidationError }
type RouteValidationError struct {
	RoutePattern string
	Scheme       string
	Network      x402.Network
	Reason       string // "missing_scheme" | "missing_facilitator"
	Message      string
}

Error-code constants (x402 package):

go
const (
	ErrCodeInvalidPayment     = "invalid_payment"
	ErrCodePaymentRequired    = "payment_required"
	ErrCodeInsufficientFunds  = "insufficient_funds"
	ErrCodeNetworkMismatch    = "network_mismatch"
	ErrCodeSchemeMismatch     = "scheme_mismatch"
	ErrCodeSignatureInvalid   = "signature_invalid"
	ErrCodePaymentExpired     = "payment_expired"
	ErrCodeSettlementFailed   = "settlement_failed"
	ErrCodeUnsupportedScheme  = "unsupported_scheme"
	ErrCodeUnsupportedNetwork = "unsupported_network"
)

Utility functions (x402 package)#

The x402 package exposes relatively few utility functions (some find helpers are unexported generic functions):

go
func DeepEqual(a, b interface{}) bool
func IsWildcardNetwork(network Network) bool
func MatchesNetwork(pattern Network, network Network) bool

// Matching of payload.resource.url against the request URL (with an optional host allowlist to tolerate reverse-proxy rewrites).
func ResourceMatches(payloadURL, requestURL string, acceptedDomains []string) bool

There are no exported base64 encode/decode functions (base64 encoding/decoding is an internal detail of the HTTP layer). findSchemesByNetwork / findByNetworkAndScheme are unexported generic helpers and not part of the public API.


Schema validation (x402 package)#

The x402 package provides package-level validation functions ValidatePaymentRequirements / ValidatePaymentPayload, and the payload validation is version-aware (handles both v1/v2); there is no exported validation function for PaymentRequired.

go
func ValidatePaymentRequirements(r PaymentRequirements) error
func ValidatePaymentPayload(p PaymentPayload) error // version-aware: handles both v1/v2


Go SDK Reference (applies to charge, session)#

This section covers the seller-side implementation of MPP (charge / session). A few key design points:

  • module path: referenced by module path; below it is organized using a Go module / package table.
  • challenge generation + validation converge into the high-level coordinator server.Mpp (Charge / SessionChallenge / VerifyCredential / VerifySession), paired with framework middleware.
  • builder style: charge uses chained WithXxx, session uses a single config struct (evm.EVMSessionMethodConfig).
  • generic store: store.Store[T] / store.FileStore[T] / store.MemoryStore[T] are Go generics; channel state is instantiated with store.ChannelState.
  • the new ResourceURL field (added in this branch): a per-endpoint revenue-aggregation tag, Charge mode only, passed through into challenge.request for the SA.

Go module / package#

Go modulepackage (import path)Description
github.com/okx/payments/go/mpp.../mpp/serverHigh-level coordinator server.Mpp: Charge / SessionChallenge / VerifyCredential / VerifySession, EVMConfig, ChargeRouteConfig / SessionRouteConfig, ParseDollarAmount
.../mpp/evmEVM charge / session method: EVMChargeMethod (builder), EVMSessionMethod + EVMSessionMethodConfig, EIP-712 signing, Signer / PrivateKeySigner, Split / SessionSplit, EVMMethodDetails (including ResourceURL), constants
.../mpp/protocolProtocol layer: PaymentChallenge / PaymentCredential / Receipt, ChargeVerifier / SessionVerifier interface, challenge/credential codec, HMAC ComputeChallengeID, VerificationError
.../mpp/saclientSA-API client: SAClient interface, default implementation OKXSAClient, the test-use MockSAClient, request/response types, SAErrorCode
.../mpp/storeGeneric store: Store[T], MemoryStore[T], FileStore[T], ChannelState, DeductFromChannel
.../mpp/errorsStable error codes: MppError, MppErrorCode (including the new InvalidSplit), RFC 9457 PaymentErrorDetails
.../mpp/http/nethttp .../mpp/http/gindrop-in middleware: ChargeMiddleware / SessionMiddleware / GetReceipt
.../mpp/adaptersThe MPP adapter for dual-protocol routing: MppAdapter + MppRouteConfig
github.com/okx/payments/go/paymentrouter.../paymentrouterDual-protocol (MPP + x402) routing core: ProtocolAdapter interface, RouteConfig, Config
.../paymentrouter/nethttpnet/http drop-in PaymentGate: New(...).For(cfg)(handler)
github.com/okx/payments/go/x402.../x402/adaptersx402 protocol adapter: X402Adapter (wrapped into paymentrouter)

Each package is referenced directly by the import path above.


Constants#

go
// X Layer mainnet chain ID.
const evm.XLayerChainID uint64 = 196

// X Layer mainnet escrow contract address (the fallback when EscrowContract is not passed).
const evm.DefaultEscrowContract = "0x5E550002e64FaF79B41D89fE8439eEb1be66CE3b"

// EIP-712 voucher signing domain (the default when not explicitly overridden).
const evm.DefaultDomainName    = "EVM Payment Channel"
const evm.DefaultDomainVersion = "1"

// challenge default validity period (minutes).
const evm.DefaultExpiresMinutes = 5

// method / intent name.
const evm.MethodNameEVM = "evm"

// session action constants.
const evm.ActionOpen, evm.ActionTopUp, evm.ActionVoucher, evm.ActionClose, evm.ActionSettle = "open", "topUp", "voucher", "close", "settle"

// session receipt status constants.
const evm.StatusOpen, evm.StatusClosed = "open", "closed"

// maximum number of split paths.
const evm.MaxSplits = 10

Core types (mpp/evm, mpp/saclient)#

SAResponse#

The SA-API unified response wrapper; the client unwraps data automatically.

go
type saclient.SAResponse struct {
    Code int             `json:"code"`
    Msg  string          `json:"msg"`
    Data json.RawMessage `json:"data"`
}

SAResponse uses json.RawMessage for lazy decoding, with each endpoint method decoding it into a concrete type.

EVMMethodDetails / Split#

The methodDetails of a Charge challenge (base64url-encoded into request).

go
type evm.EVMMethodDetails struct {
    ChainID  *uint64 `json:"chainId,omitempty"`
    FeePayer *bool   `json:"feePayer,omitempty"`   // server pays gas (transaction mode)
    Memo     *string `json:"memo,omitempty"`
    Splits   []Split `json:"splits,omitempty"`
    // ResourceURL —— the endpoint URL protected by this charge (e.g. "https://api.shop.com/photo").
    // Passed through verbatim into challenge.request for the SA, letting the merchant aggregate
    // transaction volume / revenue by URL.
    // Held by the merchant; the SDK does not fill it automatically. Charge mode only; Session mode
    // does not support it (one session may span multiple URLs).
    ResourceURL *string `json:"resourceUrl,omitempty"`
}

func (d *EVMMethodDetails) IsFeePayer() bool
func evm.ParseEVMMethodDetails(methodDetails json.RawMessage) (*EVMMethodDetails, error)

// Constraints: sum(splits[].amount) < totalAmount; the primary recipient must retain a positive balance; splits ≤ MaxSplits.
type evm.Split struct {
    Amount    string  `json:"amount"`              // base-units integer string
    Memo      *string `json:"memo,omitempty"`
    Recipient string  `json:"recipient"`           // 40-hex address
}

The split type for charge is evm.Split; ResourceURL is a field newly added in this branch.

EVMSessionMethodDetails / SessionSplit#

go
type evm.EVMSessionMethodDetails struct {
    EscrowContract  string         `json:"escrowContract"`
    ChannelID       *string        `json:"channelId,omitempty"`
    MinVoucherDelta *string        `json:"minVoucherDelta,omitempty"`
    ChainID         *uint64        `json:"chainId,omitempty"`
    FeePayer        *bool          `json:"feePayer,omitempty"`
    Splits          []SessionSplit `json:"splits,omitempty"`
}

func (d *EVMSessionMethodDetails) IsFeePayer() bool
func evm.ParseEVMSessionMethodDetails(methodDetails json.RawMessage) (*EVMSessionMethodDetails, error)

// Constraints: bps in [1, 9999]; sum(splits[].bps) < 10000.
type evm.SessionSplit struct {
    Recipient string  `json:"recipient"`
    Bps       uint32  `json:"bps"`
    Memo      *string `json:"memo,omitempty"`
}

Eip3009Authorization#

The Charge payload.authorization shape (filled in by the client after signing EIP-3009). For splits, each path has its own independent EIP-3009 in Splits.

go
type saclient.Eip3009Authorization struct {
    Type        string                 `json:"type"`       // always "eip-3009"
    From        string                 `json:"from"`
    To          string                 `json:"to"`
    Value       string                 `json:"value"`
    ValidAfter  string                 `json:"validAfter"`
    ValidBefore string                 `json:"validBefore"`
    Nonce       string                 `json:"nonce"`
    Signature   string                 `json:"signature,omitempty"`
    Splits      []Eip3009Authorization `json:"splits,omitempty"`
}

Splits directly use Eip3009Authorization with a self-nested Splits, where each path has its own independent EIP-3009 authorization.

ChargeReceipt / SessionReceipt / SessionStatus#

go
type saclient.ChargeReceipt struct {
    Method      string `json:"method"`
    Reference   string `json:"reference"`     // on-chain tx hash
    Status      string `json:"status"`
    Timestamp   string `json:"timestamp"`
    ChainID     uint64 `json:"chainId"`
    ChallengeID string `json:"challengeId"`
    ExternalID  string `json:"externalId"`
}

type saclient.SessionReceipt struct {
    Method    string `json:"method"`
    Intent    string `json:"intent"`
    Status    string `json:"status"`
    Timestamp string `json:"timestamp"`
    ChannelID string `json:"channelId"`
    ChainID   uint64 `json:"chainId"`
    Reference string `json:"reference"`
    Deposit   string `json:"deposit"`          // the current on-chain known deposit
}

// The response of GET /session/status.
type saclient.SessionStatus struct {
    ChannelID        string `json:"channelId"`
    Payer            string `json:"payer"`
    Payee            string `json:"payee"`
    Token            string `json:"token"`
    Deposit          string `json:"deposit"`
    CumulativeAmount string `json:"cumulativeAmount"`
    SettledOnChain   string `json:"settledOnChain"`
    RemainingBalance string `json:"remainingBalance"`
    SessionStatus    string `json:"sessionStatus"`   // OPEN, CLOSING, CLOSED
}

The evm package additionally has a receipt-header-oriented evm.SessionReceipt (ChannelID/CumulativeAmount/EscrowContract/Status/Reference + ToBaseReceipt), used to encode the settlement into protocol.Receipt; the saclient.SessionReceipt above is the SA-API endpoint response body.

Session request payload (SA-API)#

The body of /session/settle / /session/close that the SDK actively calls (flat, without a challenge wrapper); uniformly wrapped in the generic envelope saclient.CredentialRequest[P].

go
type saclient.CredentialRequest[P any] struct {
    Challenge *protocol.ChallengeEcho `json:"challenge,omitempty"`
    Payload   P                       `json:"payload"`
    Source    string                  `json:"source,omitempty"`
}

type saclient.SessionSettlePayload struct {
    Action           string `json:"action,omitempty"`   // "settle"
    ChannelID        string `json:"channelId"`
    CumulativeAmount string `json:"cumulativeAmount"`
    VoucherSignature string `json:"voucherSignature"`   // payer 65-byte r‖s‖v hex
    PayeeSignature   string `json:"payeeSignature"`      // payee 65-byte r‖s‖v hex
    Nonce            string `json:"nonce"`
    Deadline         string `json:"deadline"`
}

type saclient.SessionClosePayload struct {
    Action           string `json:"action,omitempty"`   // "close"
    ChannelID        string `json:"channelId"`
    CumulativeAmount string `json:"cumulativeAmount"`
    VoucherSignature string `json:"voucherSignature"`   // "" for the waiver branch
    PayeeSignature   string `json:"payeeSignature"`
    Nonce            string `json:"nonce"`
    Deadline         string `json:"deadline"`
}

// Type aliases: each endpoint instantiates the envelope with its concrete payload.
type saclient.SessionSettleRequest = CredentialRequest[SessionSettlePayload]
type saclient.SessionCloseRequest  = CredentialRequest[SessionClosePayload]
type saclient.SessionOpenRequest   = CredentialRequest[SessionOpenPayload]
type saclient.SessionTopUpRequest  = CredentialRequest[SessionTopUpPayload]
type saclient.ChargeSettleRequest     = CredentialRequest[ChargeTransactionPayload]
type saclient.ChargeVerifyHashRequest = CredentialRequest[ChargeHashPayload]

The bodies of settle / close are SessionSettlePayload / SessionClosePayload respectively, then wrapped into a request via the generic CredentialRequest[P].


SAClient interface#

A pluggable SA-API client interface; the default implementation is OKXSAClient, with MockSAClient used for testing.

go
type saclient.SAClient interface {
    // Charge (client-facing —— passes the credential through)
    Settle(ctx context.Context, req *ChargeSettleRequest) (*ChargeReceipt, error)
    VerifyHash(ctx context.Context, req *ChargeVerifyHashRequest) (*ChargeReceipt, error)

    // Session (client-facing —— passes the credential through)
    SessionOpen(ctx context.Context, req *SessionOpenRequest) (*SessionReceipt, error)
    SessionTopUp(ctx context.Context, req *SessionTopUpRequest) (*SessionReceipt, error)

    // Session (merchant-facing —— the server constructs the request)
    SessionSettle(ctx context.Context, req *SessionSettleRequest) (*SessionReceipt, error)
    SessionClose(ctx context.Context, req *SessionCloseRequest) (*SessionReceipt, error)

    // Session (read-only)
    SessionStatus(ctx context.Context, channelID string) (*SessionStatus, error)
}

charge's settle / verify-hash correspond to Settle / VerifyHash. Go has no /session/voucher endpoint —— vouchers are handled locally in the SDK (the voucher branch of EVMSessionMethod.VerifySession).


OKX SA-API client (saclient.OKXSAClient)#

go
type saclient.OKXSAClient struct { /* private */ }

func saclient.NewOKXSAClient(baseURL, apiKey, secretKey, passphrase string, opts ...Option) *OKXSAClient

// functional option.
type saclient.Option func(*OKXSAClient)
func saclient.WithHTTPClient(c *http.Client) Option   // replace the default http.Client

Implements SAClient; each request automatically adds the OKX API key + HMAC-SHA256 signature + passphrase authentication headers.

Construction goes through the single NewOKXSAClient(baseURL, ...): baseURL must be passed explicitly (pass https://web3.okx.com for production, the corresponding URL for sandbox/staging).

Endpoints#

SAClient methodOKX path
Settle()POST /api/v6/pay/mpp/charge/settle
VerifyHash()POST /api/v6/pay/mpp/charge/verifyHash
SessionOpen()POST /api/v6/pay/mpp/session/open
SessionTopUp()POST /api/v6/pay/mpp/session/topUp
SessionSettle()POST /api/v6/pay/mpp/session/settle
SessionClose()POST /api/v6/pay/mpp/session/close
SessionStatus(channelID)GET /api/v6/pay/mpp/session/status?channelId=...

OKX responses are wrapped in {"code": 0, "data": {...}, "msg": ""}, which the client unwraps automatically.

The test-use MockSAClient: accepts all credentials, performs no on-chain validation, and returns synthetic receipts.

go
func saclient.NewMockSAClient(chainID uint64) *MockSAClient

Charge — evm.EVMChargeMethod#

Implements protocol.ChargeVerifier, passing the credential through to the SA-API. Configured with a chained builder.

go
type evm.EVMChargeMethod struct { /* private */ }

func evm.NewEVMChargeMethod() *EVMChargeMethod

func (m *EVMChargeMethod) WithChainID(chainID uint64) *EVMChargeMethod
func (m *EVMChargeMethod) WithRecipient(recipient string) *EVMChargeMethod
func (m *EVMChargeMethod) WithSAClient(c saclient.SAClient) *EVMChargeMethod  // nil → local-only mode
func (m *EVMChargeMethod) WithFeePayer(feePayer bool) *EVMChargeMethod        // true → transaction mode

// protocol.ChargeVerifier implementation:
func (m *EVMChargeMethod) Method() string
func (m *EVMChargeMethod) ChallengeMethodDetails() *EVMMethodDetails
func (m *EVMChargeMethod) PrepareRequest(request protocol.ChargeRequest, _ *protocol.PaymentCredential) protocol.ChargeRequest
func (m *EVMChargeMethod) Verify(ctx context.Context, cred *protocol.PaymentCredential, request *protocol.ChargeRequest) (*protocol.Receipt, error)

payload.type routing (inside Verify):

  • "transaction"SAClient.Settle (SA-API broadcasts transferWithAuthorization on-chain)
  • "hash"SAClient.VerifyHash (the client has already broadcast it itself; the SA-API verifies the tx hash)

Splits pass through payload.authorization.splits[]; the SA-API owns split validation.

EVMChargeMethod only handles "verification" (ChargeVerifier); challenge generation is delegated to the high-level server.Mpp.Charge(...), with configuration going through server.EVMConfig + server.ChargeRouteConfig (see below).

Constructing EVMSessionMethod#

go
type evm.EVMSessionMethod struct { /* private */ }

func evm.NewEVMSessionMethod(cfg EVMSessionMethodConfig) (*EVMSessionMethod, error)

type evm.EVMSessionMethodConfig struct {
    // required.
    Recipient string            // payee wallet address
    SAClient  saclient.SAClient // SA-API client

    // optional (zero value = default).
    ChainID         uint64                          // default 196 (X Layer)
    EscrowContract  string                          // default DefaultEscrowContract
    Signer          Signer                          // payee signer; nil → settle/close disabled
    Store           store.Store[store.ChannelState] // default in-memory store
    PerRequestCost  *big.Int                        // per-request charge amount
    MinVoucherDelta *big.Int                        // minimum voucher increment
    NonceProvider   NonceProvider                   // default UuidNonceProvider
    Deadline        *big.Int                        // default U256 MAX (never expires)
    DomainName      string                          // default "EVM Payment Channel"
    DomainVersion   string                          // default "1"
    FeePayer        bool                            // whether the payee covers gas
}

The configuration converges into a single EVMSessionMethodConfig struct, with NewEVMSessionMethod validating the required fields in one pass and returning an error.


Session — evm.EVMSessionMethod#

Implements protocol.SessionVerifier. Maintains local channel state, voucher local signature verification + cumulative charging, and merchant-initiated settle/close.

protocol.SessionVerifier implementation#

go
func (m *EVMSessionMethod) Method() string
func (m *EVMSessionMethod) ChallengeMethodDetails() json.RawMessage  // returns nil when there is no escrow config
func (m *EVMSessionMethod) VerifySession(ctx context.Context, cred *protocol.PaymentCredential, request *protocol.SessionRequest) (*protocol.Receipt, error)
func (m *EVMSessionMethod) Respond(cred *protocol.PaymentCredential, receipt *protocol.Receipt) any

Respond returns a management response for open/topUp/close, and nil for voucher (serve-resource).

Business methods#

go
// the underlying channel store.
func (m *EVMSessionMethod) ChannelStore() store.Store[store.ChannelState]

// Atomic charge: available = highestVoucher - spent; returns an insufficient-balance error when insufficient.
func (m *EVMSessionMethod) DeductFromSession(ctx context.Context, channelID string, amount *big.Int) (*store.ChannelState, error)

// Take the local highest voucher → sign SettleAuthorization → call /session/settle.
func (m *EVMSessionMethod) SettleWithAuthorization(ctx context.Context, channelID string) (*saclient.SessionReceipt, error)

// Sign CloseAuthorization → call /session/close → remove from store on success.
func (m *EVMSessionMethod) CloseWithAuthorization(ctx context.Context, channelID string) (*saclient.SessionReceipt, error)

Voucher signature verification is inlined into the voucher branch of VerifySession, exposing only DeductFromSession; CloseWithAuthorization takes only channelID (whether it is a waiver is decided by whether there is a voucher in the store). Channel-state queries go through saclient.SAClient.SessionStatus, not listed separately on EVMSessionMethod.

Session action routing (inside VerifySession, by payload.action)#

actionbehavior
"open"payee validation → SA SessionOpen → write to local store
"voucher"local signature verification + bump highest voucher → charge (DeductFromSession)
"topUp"SA SessionTopUp → add to local deposit
"close"take the voucher provided by the payer → local close flow

session payload types (inside the credential, by action): evm.OpenPayload / evm.VoucherPayload / evm.TopUpPayload / evm.ClosePayload, each with Validate() error.


Session — store.Store[T] interface#

Go's store is a generic key-value interface. Channel state is instantiated with store.ChannelState.

go
type store.Store[T any] interface {
    Get(ctx context.Context, key string) (*T, error)   // returns (nil, nil) when not present
    Put(ctx context.Context, key string, value *T) error
    Delete(ctx context.Context, key string) error
}

// Atomic-charge helper:
// read channel → check constraints → update Spent → write back.
func store.DeductFromChannel(ctx context.Context, s Store[ChannelState], channelID string, amount *big.Int) (*ChannelState, error)

ChannelState#

go
type store.ChannelState struct {
    ChannelID               string   `json:"channelId"`
    ChainID                 uint64   `json:"chainId"`
    EscrowContract          string   `json:"escrowContract"`
    Payer                   string   `json:"payer"`
    Payee                   string   `json:"payee"`
    Token                   string   `json:"token"`
    AuthorizedSigner        string   `json:"authorizedSigner"`   // address(0) at open → payer
    Deposit                 *big.Int `json:"deposit"`
    HighestVoucherAmount    *big.Int `json:"highestVoucherAmount"`
    HighestVoucherSignature []byte   `json:"highestVoucherSignature,omitempty"`
    MinVoucherDelta         *big.Int `json:"minVoucherDelta,omitempty"`   // nil disables throttling
    Spent                   *big.Int `json:"spent"`               // invariant: spent ≤ highestVoucherAmount
    Units                   uint64   `json:"units"`               // number of deducts
    Finalized               bool     `json:"finalized"`
    CloseRequestedAt        uint64   `json:"closeRequestedAt"`
    CreatedAt               string   `json:"createdAt"`
}

// On-chain view (the on-chain channel pulled back by SA).
type store.OnChainChannel struct {
    Payer            string   `json:"payer"`
    Payee            string   `json:"payee"`
    Token            string   `json:"token"`
    AuthorizedSigner string   `json:"authorizedSigner"`
    Deposit          *big.Int `json:"deposit"`
    Settled          *big.Int `json:"settled"`
    CloseRequestedAt uint64   `json:"closeRequestedAt"`
    Finalized        bool     `json:"finalized"`
}

Channel state is represented with store.ChannelState; amount fields use *big.Int, and optional byte fields such as signatures use []byte (nil indicates absent).

Implementations — MemoryStore[T] / FileStore[T]#

go
// Default: an in-process map; values are deep-copied via a JSON round-trip to prevent the caller from tampering.
type store.MemoryStore[T any] struct { /* private */ }
func store.NewMemoryStore[T any]() *MemoryStore[T]

// File persistence: one JSON file per key, with a per-key mutex guaranteeing concurrency safety for the same key.
type store.FileStore[T any] struct { /* private */ }
func store.NewFileStore[T any](dir string) (*FileStore[T], error)  // creates the directory automatically

For the channel scenario, it is always instantiated with store.ChannelState, for example:

go
chStore, err := store.NewFileStore[store.ChannelState]("/var/lib/mpp/channels")

The two caveats of MemoryStore:

  • lost on restart: a process restart / crash loses all channel state. For long-lived channels / multi-instance HA / hot-reload scenarios, switch to FileStore, or implement a custom persistent store (SQLite / Redis / Postgres / ...) that implements Store[ChannelState] and inject it into EVMSessionMethodConfig.Store.
  • abandoned channel accumulation: when the payer never calls close, records stay around forever —— a general session-lifecycle problem; merchants should have a cleanup / TTL strategy.

NonceProvider interface#

go
type evm.NonceProvider interface {
    Allocate(payee common.Address, channelID [32]byte) (*big.Int, error)
}

// Default implementation: UUID v4 → big.Int (128-bit random, stateless, safe across multiple instances / restarts).
type evm.UuidNonceProvider struct{}
func evm.NewUuidNonceProvider() *UuidNonceProvider
func (p *UuidNonceProvider) Allocate(_ common.Address, _ [32]byte) (*big.Int, error)

The contract layer's used-nonce set has key = (payee, channelId, nonce), and reuse reverts with NonceAlreadyUsed. The SDK is only responsible for allocating a nonce that is "very likely unused"; it does not track the used set.


EIP-712 signing (mpp/evm)#

The Signer interface and default implementation#

go
type evm.Signer interface {
    Sign(hash []byte) ([]byte, error)
    SignTypedData(typedData apitypes.TypedData) ([]byte, error)
    Address() common.Address
}

type evm.PrivateKeySigner struct { /* private */ }
func evm.NewPrivateKeySigner(key *ecdsa.PrivateKey) *PrivateKeySigner
func evm.NewPrivateKeySignerFromHex(hexKey string) (*PrivateKeySigner, error)  // with or without "0x"

Go defines its own evm.Signer interface, with PrivateKeySigner as the default; remote / KMS signers can be injected just by implementing these three methods.

Voucher signing / verification#

go
// The EIP-712 voucher struct corresponds 1:1 to the contract's Voucher{ bytes32 channelId; uint128 cumulativeAmount }.
func evm.SignVoucher(
    signer Signer,
    channelID [32]byte,
    cumulativeAmount *big.Int,
    escrowContract common.Address,
    chainID uint64,
    domainName, domainVersion string,   // empty → DefaultDomainName / DefaultDomainVersion
) ([]byte, error)                       // 65-byte signature, v encoded as 27/28

// 1) len == 65  2) low-s precheck  3) EIP-712 digest  4) ecrecover + strict address comparison.
func evm.VerifyVoucher(
    escrowContract common.Address,
    chainID uint64,
    channelID [32]byte,
    cumulativeAmount *big.Int,
    sig []byte,
    expectedSigner common.Address,
    domainName, domainVersion string,
) bool                                  // v accepts 27/28 or 0/1

func evm.ValidateVoucherSignature(sig []byte) error   // 65 bytes + low-s check

VerifyVoucher returns a bool, with the precheck handled separately via ValidateVoucherSignature(sig) error.

SettleAuthorization / CloseAuthorization signing#

go
type evm.SignedAuthorization struct {
    ChannelID        [32]byte
    CumulativeAmount *big.Int
    Nonce            *big.Int
    Deadline         *big.Int
    Signature        []byte    // 65-byte r||s||v
}

// primaryType is either "SettleAuthorization" or "CloseAuthorization".
func evm.SignAuthorization(
    signer Signer,
    primaryType string,
    channelID [32]byte,
    cumulativeAmount *big.Int,
    nonce *big.Int,
    deadline *big.Int,
    escrowContract common.Address,
    chainID uint64,
    domainName string,
    domainVersion string,
) (*SignedAuthorization, error)

// Deterministic channelId = keccak256(abi.encode(payer, payee, token, salt, authorizedSigner, escrowContract, chainID)).
func evm.ComputeChannelID(
    payer, payee, token common.Address,
    salt [32]byte,
    authorizedSigner, escrowContract common.Address,
    chainID uint64,
) [32]byte

The settle / close authorization signing is merged into a single SignAuthorization, selecting settle / close via primaryType.


Decoding challenge.request#

In the protocol package, use a set of top-level codec functions to decode a typed request from PaymentChallenge.Request (base64url JSON).

go
// Decode the request from the challenge.
func protocol.RequestFromChallenge(c *PaymentChallenge) (json.RawMessage, error)
func protocol.RequestFromChallengeTyped(c *PaymentChallenge, v interface{}) error

// Decode the base64url string directly.
func protocol.DeserializeRequest(encoded string) (json.RawMessage, error)
func protocol.DeserializeRequestTyped(encoded string, v interface{}) error

// Reverse: encode a typed request into a base64url string.
func protocol.SerializeRequest(v interface{}) (string, error)

// Usage:
var req protocol.SessionRequest
if err := protocol.RequestFromChallengeTyped(ch, &req); err != nil { /* ... */ }

protocol.ChargeRequest / protocol.SessionRequest also carry helpers such as WithBaseUnits() (decimal → base units), ValidateMaxAmount(max), etc.


Drop-in middleware (mpp/http/nethttp, mpp/http/gin)#

Go provides two sets of framework middleware, net/http and gin, that wrap the business handler directly; the payment logic converges in server.Mpp.

go
import mpphttp "github.com/okx/payments/go/mpp/http/nethttp"

// net/http:
func nethttp.ChargeMiddleware(m *server.Mpp, cfg server.ChargeRouteConfig) func(http.Handler) http.Handler
func nethttp.SessionMiddleware(m *server.Mpp, cfg server.SessionRouteConfig) func(http.Handler) http.Handler
func nethttp.GetReceipt(r *http.Request) *protocol.Receipt   // retrieve the receipt from ctx after successful verification

// gin (same-named functions, signatures changed to gin):
import mppgin "github.com/okx/payments/go/mpp/http/gin"
func gin.ChargeMiddleware(m *server.Mpp, cfg server.ChargeRouteConfig) gin.HandlerFunc
func gin.SessionMiddleware(m *server.Mpp, cfg server.SessionRouteConfig) gin.HandlerFunc
func gin.GetReceipt(c *gin.Context) *protocol.Receipt

On validation failure, the HTTP status code is mapped automatically per protocol.VerificationError.HTTPStatus() (400 payload/format, 410 channel-not-found/closed, 402 by default for the rest).


High-level coordinator server.Mpp#

Challenge generation for both charge and session is unified into server.Mpp: inject one EVMConfig + charge/session verifiers, and it exposes externally two groups of methods, "generate challenge" and "verify credential".

go
type server.EVMConfig struct {
    ChainID   uint64   // the expected chain ID (196 = X Layer)
    Recipient string   // the expected payee address (with/without 0x)
    SecretKey string   // the HMAC key for the challenge ID; empty = empty key (deterministic but not authenticated)
    Realm     string   // the WWW-Authenticate realm; defaults to "mpp" when empty
}

type server.Mpp struct { /* private */ }
func server.NewMpp(cfg EVMConfig, charge protocol.ChargeVerifier, session protocol.SessionVerifier) *Mpp
// Either verifier may be nil (when only one intent is used).

// Generate a challenge (returns the WWW-Authenticate header value):
func (m *Mpp) Charge(ctx context.Context, cfg ChargeRouteConfig) (string, error)
func (m *Mpp) SessionChallenge(ctx context.Context, cfg SessionRouteConfig) (string, error)

// Verify a credential:
func (m *Mpp) VerifyCredential(ctx context.Context, challengeHeader, authHeader string) (*protocol.Receipt, error)
func (m *Mpp) VerifySession(ctx context.Context, challengeHeader, authHeader string) (*protocol.SessionVerifyResult, error)

// Low-level variants (with their own request / options):
func (m *Mpp) ChargeWithOptions(ctx context.Context, req protocol.ChargeRequest, opts ChargeOptions) (string, error)
func (m *Mpp) SessionChallengeWithDetails(ctx context.Context, req protocol.SessionRequest, opts SessionChallengeOptions) (string, error)

Route config#

Per-route parameters. Note that ResourceURL is newly added in this branch (Charge only).

go
type server.ChargeRouteConfig struct {
    Amount      string      // human-readable decimal, e.g. "0.01"
    Currency    string      // ERC-20 contract address
    Decimals    uint32      // token precision (USDC = 6)
    Description string
    ExternalID  string      // caller-defined reference
    Splits      []evm.Split // secondary recipients; the primary recipient gets total - sum(splits); ≤ 10
    ResourceURL string      // [new] the endpoint URL protected by this charge, passed to the SA to aggregate revenue by URL; not reported when empty. Charge mode only.
}

type server.SessionRouteConfig struct {
    Amount           string  // human-readable decimal, e.g. "0.001"
    Currency         string  // ERC-20 contract address
    Decimals         uint32
    Description      string
    ExternalID       string
    UnitType         string  // billing unit: "request" / "second" / "byte" ...
    SuggestedDeposit string  // suggested initial deposit (base units)
}

The server package also has ParseDollarAmount(amount string, decimals uint32) (string, error): human-readable decimal → integer base-units (e.g. ParseDollarAmount("1.50", 6) → "1500000").


Error types#

Go uses three layers of errors: protocol.VerificationError (verifier layer), saclient.SAErrorCode (SA-API business codes), errors.MppError / MppErrorCode (stable string codes + RFC 9457 problem details; OKXSAClient maps SAErrorCode into it).

go
type protocol.VerificationError struct {
    Message   string    `json:"message"`
    Code      ErrorCode `json:"code,omitempty"`
    Retryable bool      `json:"retryable"`
}
func (e *VerificationError) Error() string
func (e *VerificationError) HTTPStatus() int       // 400 / 410 / 402 (default)
func (e *VerificationError) WithRetryable() *VerificationError

// Machine-readable error codes (strings), with .SpecCode() / .String():
type protocol.ErrorCode string
const (
    ErrorCodeExpired, ErrorCodeInvalidAmount, ErrorCodeInvalidRecipient,
    ErrorCodeTransactionFailed, ErrorCodeNotFound, ErrorCodeInvalidCredential,
    ErrorCodeNetworkError, ErrorCodeChainIdMismatch, ErrorCodeCredentialMismatch,
    ErrorCodeChannelNotFound, ErrorCodeChannelClosed, ErrorCodeInsufficientBalance,
    ErrorCodeInvalidPayload, ErrorCodeInvalidSignature, ErrorCodeAmountExceedsDeposit,
    ErrorCodeDeltaTooSmall ErrorCode = /* ... */
)
// Accompanying constructors: protocol.ErrSig / ErrAmount / ErrChannelNotFound / ErrInsufficientBalance / ...
// as well as a set of VerificationErrorXxx(msg).

SA-API business error-code mapping (saclient.SAErrorCode)#

go
type saclient.SAErrorCode int
const (
    SACodeSuccess              = 0
    SACodeInvalidParams        = 70000   // missing required field or format error
    SACodeUnsupportedChain     = 70001   // chain not in the supported list
    SACodePayerBlocked         = 70002   // payer on the blocklist
    SACodeInvalidCredential    = 70003   // source missing / feePayer=true does not support hash mode / txHash already used
    SACodeInvalidSignature     = 70004   // signature verification failed
    SACodeSplitSumExceedsTotal = 70005   // split total ≥ primary amount
    SACodeSplitCountExceeded   = 70006   // split count > 10
    SACodeTxNotConfirmed       = 70007   // transaction not confirmed on-chain
    SACodeChannelClosed        = 70008   // on-chain channel already closed
    SACodeChallengeInvalid     = 70009   // challenge does not exist or has expired
    SACodeChannelNotFound      = 70010   // channelId does not exist
    SACodeGracePeriodTooShort  = 70011   // escrow grace period < 10 minutes, opening rejected
    SACodeAmountExceedsDeposit = 70012   // cumulativeAmount exceeds the deposit balance
    SACodeVoucherDeltaTooSmall = 70013   // voucher increment below minVoucherDelta
    SACodeChannelClosing       = 70014   // channel in CLOSING state, not accepting new vouchers
    SACodeInternalError        = 8000    // internal API service error
)

Insufficient local account balance for charging (available < amount) is expressed by the verifier layer's protocol.ErrorCodeInsufficientBalance / ErrInsufficientBalance(...), and is not in the SAErrorCode constant set (local charging is checked by DeductFromSession / store.DeductFromChannel, not an SA-API business code).

errors package —— MppError / MppErrorCode#

saclient.OKXSAClient maps the SA-API business codes (SAErrorCode) into stable string error codes errors.MppErrorCode, wrapped and returned in *errors.MppError.

go
type errors.MppErrorCode string

type errors.MppError struct {
    Code    MppErrorCode `json:"code"`
    Message string       `json:"message"`
    Reason  string       `json:"reason,omitempty"`
}
func (e *MppError) Error() string
func (e *MppError) ToProblemDetails(challengeID string) *PaymentErrorDetails // RFC 9457

MppErrorCode values (excerpt, including the new InvalidSplit in this branch):

Codemeaning
MalformedCredentialmalformed credential
InvalidChallengeinvalid challenge
InvalidSignaturesignature verification failed
InvalidSplit[new] invalid split (total exceeds primary amount / count exceeds 10)
InsufficientBalanceinsufficient balance
AmountExceedsDepositamount exceeds deposit
DeltaTooSmallvoucher increment too small
ChannelNotFoundchannel does not exist
ChannelClosedchannel already closed
SignerMismatchsigner mismatch
BadRequestrequest parameter error
Internalinternal error

The complete set also includes AmountExceedsMax / InvalidAmount / InvalidConfig / Http / ChainIdMismatch / Json / HexDecode / Base64Decode / UnsupportedPaymentMethod / MissingHeader / InvalidBase64Url / VerificationFailed / PaymentExpired / PaymentRequired / InvalidPayload / Io / InvalidUtf8 / SystemTime.

SA-API business code → MppErrorCode mapping (OKXSAClient.mapSAError)#

SA codemeaningmapped MppErrorCode
70000missing required field / format errorBadRequest
70001chain not in the supported listInternal
70002payer on the blocklistMalformedCredential
70003source missing / feePayer+hash incompatible / txHash already usedMalformedCredential
70004signature verification failedInvalidSignature
70005split total ≥ primary amountInvalidSplit
70006split count > 10InvalidSplit
70007transaction not confirmed on-chainInternal
70008channel already closedChannelClosed
70009challenge does not exist / has expiredInvalidChallenge
70010channelId does not existChannelNotFound
70011escrow grace period not satisfiedInternal
70012cumulativeAmount exceeds the deposit balanceAmountExceedsDeposit
70013voucher increment < minVoucherDeltaDeltaTooSmall
70014channel in CLOSING stateChannelClosed
8000internal API errorInternal

The default branch (codes not listed) → Internal.


Dual-protocol routing (paymentrouter + mpp/adapters + x402/adapters)#

Lets a single net/http app serve both MPP + x402, with the business handler being protocol-agnostic. Go uses the adapter pattern + PaymentGate middleware.

Adapter interface#

go
type paymentrouter.ProtocolAdapter interface {
    Name() string                                                       // "mpp" | "x402" | custom
    Priority() int                                                      // smaller Detects first (MPP < x402)
    Detect(r *http.Request) bool                                        // check whether the request headers belong to this protocol
    GetChallenge(ctx context.Context, r *http.Request, cfg any) (http.Header, error)  // generate the 402 challenge header for this protocol
    Handle(w http.ResponseWriter, r *http.Request, cfg any) error       // verify + write the receipt / error
}

GetChallenge simply passes cfg any, and the adapter type-asserts internally to its own config struct. The middleware wraps it with a closure, with no extra registration hook needed.

Built-in adapters#

go
import (
    mppadapters "github.com/okx/payments/go/mpp/adapters"
    x402adapters "github.com/okx/payments/go/x402/adapters"
)

// MppAdapter takes *server.Mpp (assembled with server.NewMpp(...)).
func mppadapters.NewMppAdapter(mpp *server.Mpp) *MppAdapter
func (a *MppAdapter) Name() string
func (a *MppAdapter) Priority() int
func (a *MppAdapter) Detect(r *http.Request) bool
func (a *MppAdapter) GetChallenge(ctx context.Context, r *http.Request, cfg any) (http.Header, error)
func (a *MppAdapter) Handle(w http.ResponseWriter, r *http.Request, cfg any) error

// X402Adapter takes *x402http.HTTPServer (routes can be nil, lazily registered by the route config).
func x402adapters.NewX402Adapter(server *x402http.HTTPServer) *X402Adapter
func (a *X402Adapter) Name() string
func (a *X402Adapter) Priority() int
func (a *X402Adapter) Detect(r *http.Request) bool
func (a *X402Adapter) GetChallenge(ctx context.Context, r *http.Request, cfg any) (http.Header, error)
func (a *X402Adapter) Handle(w http.ResponseWriter, r *http.Request, cfg any) error

NewX402Adapter is one-stop; behaviors like poll deadline / settlement hook are configured on the *x402http.HTTPServer / facilitator client passed in. The priority of MppAdapter is built-in and fixed, not adjustable.

Per-adapter typed route config#

go
// MPP per-route config (MppAdapter type-asserts internally to this type).
type mppadapters.MppRouteConfig struct {
    Intent           string `json:"intent"`          // "charge" or "session" (empty → "charge")
    Amount           string `json:"amount"`          // base-units integer string
    Currency         string `json:"currency"`
    Decimals         uint32 `json:"decimals"`
    Description      string `json:"description,omitempty"`
    ExternalID       string `json:"externalId,omitempty"`     // charge only
    Realm            string `json:"realm,omitempty"`
    UnitType         string `json:"unitType,omitempty"`        // session only
    SuggestedDeposit string `json:"suggestedDeposit,omitempty"` // session only
}

// x402 per-route config (X402Adapter type-asserts internally to this type).
type x402http.RouteConfig struct {
    Accepts           x402http.PaymentOptions `json:"accepts"`
    Resource          string                  `json:"resource,omitempty"`
    Description       string                  `json:"description,omitempty"`
    MimeType          string                  `json:"mimeType,omitempty"`
    CustomPaywallHTML string                  `json:"customPaywallHtml,omitempty"`
    AcceptedDomains   []string                `json:"acceptedDomains,omitempty"`
    // ... see the x402 reference
}

MppRouteConfig carries Decimals / Realm fields; the x402 side directly reuses the x402 SDK's x402http.RouteConfig.

Router config#

go
import (
    pr "github.com/okx/payments/go/paymentrouter"
    prhttp "github.com/okx/payments/go/paymentrouter/nethttp"
)

// RouteConfig is map[adapterName]config —— adapters not listed are not enabled on this route.
type pr.RouteConfig map[string]any

// Core config (used for the underlying CompiledRouter / Detect / MergeChallenges).
type pr.Config struct {
    Routes    []RouteEntry
    Protocols []ProtocolAdapter
    OnError   func(err error, phase, protocol string)
}
type pr.RouteEntry struct {
    Pattern string        // "GET /path" or "/path"
    Config  RouteConfig
}

const (
    pr.PhaseDetect    = "detect"
    pr.PhaseChallenge = "challenge"
    pr.PhaseHandle    = "handle"
)

// Startup-time validation: a route referencing an unregistered adapter Name → panic (surface config errors as early as possible).
func pr.ValidateRouteKeys(routes []RouteEntry, protocols []ProtocolAdapter)

// net/http drop-in gate:
type prhttp.PaymentGate struct { /* private */ }
func prhttp.New(protocols []pr.ProtocolAdapter, opts ...Option) *PaymentGate
func (g *PaymentGate) For(cfg pr.RouteConfig) func(http.Handler) http.Handler   // middleware for a single route

type prhttp.Option func(*PaymentGate)
func prhttp.WithOnError(fn func(err error, phase, protocol string)) Option

Use prhttp.New(protocols, ...) to build a PaymentGate, then call .For(cfg)(handler) for each route to mount it explicitly onto the mux —— route matching is delegated to net/http's ServeMux, with no pattern list maintained inside the router. The error-callback signature is func(err error, phase, protocol string), where phase takes pr.PhaseDetect / PhaseChallenge / PhaseHandle.

End-to-end assembly example#

go
package main

import (
    "net/http"

    mppadapters "github.com/okx/payments/go/mpp/adapters"
    "github.com/okx/payments/go/mpp/server"
    pr "github.com/okx/payments/go/paymentrouter"
    prhttp "github.com/okx/payments/go/paymentrouter/nethttp"
    x402adapters "github.com/okx/payments/go/x402/adapters"
    x402http "github.com/okx/payments/go/x402/http"
)

func main() {
    // mpp *server.Mpp and x402Server *x402http.HTTPServer are constructed per their respective references (omitted).
    var mpp *server.Mpp
    var x402Server *x402http.HTTPServer

    gate := prhttp.New(
        []pr.ProtocolAdapter{
            mppadapters.NewMppAdapter(mpp),
            x402adapters.NewX402Adapter(x402Server),
        },
        prhttp.WithOnError(func(err error, phase, protocol string) {
            // log protocol errors at each of the detect / challenge / handle phases
        }),
    )

    routeCfg := pr.RouteConfig{
        "mpp": mppadapters.MppRouteConfig{
            Intent:   "charge",
            Amount:   "100",
            Currency: "0x...",
            Decimals: 6,
            Description: "photo",
        },
        "x402": x402http.RouteConfig{
            Description: "photo",
            MimeType:    "image/png",
            // Accepts: ... see the x402 reference
        },
    }

    mux := http.NewServeMux()
    mux.Handle("GET /photo", gate.For(routeCfg)(photoHandler()))
    http.ListenAndServe(":8080", mux)
}

func photoHandler() http.Handler { /* protocol-agnostic business handler */ return nil }
Table of contents