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 path | Description |
|---|---|
github.com/okx/payments/go/x402 | Core: resource server X402ResourceServer, facilitator client interface, hooks, error types, Network / Price, etc. |
github.com/okx/payments/go/x402/types | Wire-format types (v1/v2): PaymentRequirements, PaymentPayload, PaymentRequired, SupportedKind, etc. |
github.com/okx/payments/go/x402/http | HTTP resource server HTTPServer, routing config RoutesConfig, HTTPFacilitatorClient / OKXFacilitatorClient |
github.com/okx/payments/go/x402/http/nethttp | net/http middleware |
github.com/okx/payments/go/x402/http/gin | Gin middleware |
github.com/okx/payments/go/x402/http/echo | Echo middleware |
github.com/okx/payments/go/x402/mechanisms/evm | EVM shared primitives: payload types, Permit2 / upto constants, AssetInfo / NetworkConfig |
github.com/okx/payments/go/x402/mechanisms/evm/exact/server | exact (EIP-3009 / Permit2) seller scheme |
github.com/okx/payments/go/x402/mechanisms/evm/upto/server | upto (cap + override) seller scheme |
github.com/okx/payments/go/x402/mechanisms/evm/deferred/server | aggr_deferred (TEE aggregation) seller scheme |
github.com/okx/payments/go/x402/adapters | Multi-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/clientexist, 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).
// 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:
func IsWildcardNetwork(network Network) bool
func MatchesNetwork(pattern Network, network Network) bool
ResourceInfo#
type ResourceInfo struct {
URL string `json:"url"`
Description string `json:"description,omitempty"`
MimeType string `json:"mimeType,omitempty"`
}
PaymentRequirements#
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:
| key | scheme | meaning |
|---|---|---|
assetTransferMethod | exact / upto | "eip3009" (default) or "permit2"; the upto server always writes "permit2" |
facilitatorAddress | upto | The upto proxy enforces witness.facilitator == msg.sender; injected automatically by UptoEvmScheme.EnhancePaymentRequirements from supportedKind.Extra |
name / version | exact (EIP-3009 path) | EIP-712 domain, for client-side signing |
In the
mechanisms/evm/upto/serverpackage these two keys also have named constants:AssetTransferMethodKey = "assetTransferMethod",ExtraFacilitatorAddressKey.
PaymentRequired#
The 402 response body (v2).
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).
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 interfacesPaymentRequirementsView/PaymentPayloadViewto 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#
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#
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#
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.
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.
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:
// 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 viaResourceServerOptions such asWithBeforeVerifyHook(...)(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.
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:
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#
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+ theOnSettlementTimeouthook + the facilitator'sGetSettleStatus, see below). ThePollResulttype itself does exist:
type PollResult string
const (
PollResultSuccess PollResult = "success"
PollResultFailed PollResult = "failed"
PollResultTimeout PollResult = "timeout"
)
ResourceConfig / SettlementOverrides#
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
Amountfield is documented as an atomic-unit integer string (must be<= authorized max).
OKX Facilitator client (OKXFacilitatorClient)#
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)
})
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, defaulttrue). The HTTPRouteConfigdoes not have aSyncSettlefield.
Generic HTTP facilitator client#
For non-OKX facilitators, use HTTPFacilitatorClient:
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:
// 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 byOKXFacilitatorClientbased onComputeSignature.
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#
// 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
SettlementOverridesHeaderto an exported constant; the three header namesPAYMENT-SIGNATURE/PAYMENT-REQUIRED/PAYMENT-RESPONSEare used internally.
Routing config#
RoutesConfig is a map[string]RouteConfig, where the key is in the form "GET /path". Each accept is a PaymentOption.
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 onRouteConfig; (2)PayTo/Priceareinterface{}— they can take either a static value or aDynamic*Funcdynamic-resolution callback.
Example (multiple schemes coexisting)#
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 + permit2is just theexactscheme + the routeExtra: {"assetTransferMethod":"permit2"}. Anuptoroute usually does not need a manually filledExtra.facilitatorAddress—UptoEvmScheme.EnhancePaymentRequirementsinjects it automatically from the facilitator/supportedstream.
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#
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)
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#
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):
// 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)
// 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#
- Match the request route cfg; no match → pass through to the inner handler
- No
PAYMENT-SIGNATURErequest header → return 402 +PAYMENT-REQUIRED(for browser requests, render the paywall HTML) - Decode and verify the payment payload, matching it against the route
accepts - Verify via the facilitator (
Verify) - Call the inner handler and buffer the response
- If the handler set a settlement override (upto, via
SetSettlementOverrides), the middleware parses it into*SettlementOverrides - Settle via the facilitator (
Settle) - Async (
status:"pending"/"timeout") → pollGetSettleStatuswithinpollDeadline - Still timing out → call the
OnSettlementTimeouthook (if configured) - Add the
PAYMENT-RESPONSEheader to the response
Hooks / settings attachable on HTTPServer#
After pre-building an HTTPServer, mount it with PaymentMiddlewareFromHTTPServer; you can register chained:
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
})
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
OnSettlementTimeoutHookis(ctx, txHash, network), returning(confirmed bool, err error).
EVM mechanisms (mechanisms/evm + scheme server subpackages)#
ExactEvmScheme#
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".
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#
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).
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#
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.Amountis the upper bound (cap), not the actual amount chargedEnhancePaymentRequirementsalways writesExtra.assetTransferMethod = "permit2"- It automatically injects from
supportedKind.Extra.facilitatorAddressinto the challenge, having the buyer pin the facilitator address intowitness.facilitator(the contract layer enforcesmsg.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 callsSettlePaymentwith the overrides, charging the balance by actual usage
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.
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
SimulateInSettlesemantics differ between the two facilitators:exactis a plainbool(zero value false, no re-run);uptois 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)#
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 functionsIsEIP3009Payload/IsPermit2Payload/IsUptoPermit2Payload.
Permit2 / Upto constants (mechanisms/evm)#
// 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.
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.
// 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):
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):
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/findByNetworkAndSchemeare 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.
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 withstore.ChannelState.- the new
ResourceURLfield (added in this branch): a per-endpoint revenue-aggregation tag, Charge mode only, passed through intochallenge.requestfor the SA.
Go module / package#
| Go module | package (import path) | Description |
|---|---|---|
github.com/okx/payments/go/mpp | .../mpp/server | High-level coordinator server.Mpp: Charge / SessionChallenge / VerifyCredential / VerifySession, EVMConfig, ChargeRouteConfig / SessionRouteConfig, ParseDollarAmount |
.../mpp/evm | EVM charge / session method: EVMChargeMethod (builder), EVMSessionMethod + EVMSessionMethodConfig, EIP-712 signing, Signer / PrivateKeySigner, Split / SessionSplit, EVMMethodDetails (including ResourceURL), constants | |
.../mpp/protocol | Protocol layer: PaymentChallenge / PaymentCredential / Receipt, ChargeVerifier / SessionVerifier interface, challenge/credential codec, HMAC ComputeChallengeID, VerificationError | |
.../mpp/saclient | SA-API client: SAClient interface, default implementation OKXSAClient, the test-use MockSAClient, request/response types, SAErrorCode | |
.../mpp/store | Generic store: Store[T], MemoryStore[T], FileStore[T], ChannelState, DeductFromChannel | |
.../mpp/errors | Stable error codes: MppError, MppErrorCode (including the new InvalidSplit), RFC 9457 PaymentErrorDetails | |
.../mpp/http/nethttp .../mpp/http/gin | drop-in middleware: ChargeMiddleware / SessionMiddleware / GetReceipt | |
.../mpp/adapters | The MPP adapter for dual-protocol routing: MppAdapter + MppRouteConfig | |
github.com/okx/payments/go/paymentrouter | .../paymentrouter | Dual-protocol (MPP + x402) routing core: ProtocolAdapter interface, RouteConfig, Config |
.../paymentrouter/nethttp | net/http drop-in PaymentGate: New(...).For(cfg)(handler) | |
github.com/okx/payments/go/x402 | .../x402/adapters | x402 protocol adapter: X402Adapter (wrapped into paymentrouter) |
Each package is referenced directly by the import path above.
Constants#
// 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.
type saclient.SAResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
SAResponseusesjson.RawMessagefor lazy decoding, with each endpoint method decoding it into a concrete type.
EVMMethodDetails / Split#
The methodDetails of a Charge challenge (base64url-encoded into request).
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;ResourceURLis a field newly added in this branch.
EVMSessionMethodDetails / SessionSplit#
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.
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
Eip3009Authorizationwith a self-nestedSplits, where each path has its own independent EIP-3009 authorization.
ChargeReceipt / SessionReceipt / SessionStatus#
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 intoprotocol.Receipt; thesaclient.SessionReceiptabove 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].
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/SessionClosePayloadrespectively, then wrapped into a request via the genericCredentialRequest[P].
SAClient interface#
A pluggable SA-API client interface; the default implementation is OKXSAClient, with MockSAClient used for testing.
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/voucherendpoint —— vouchers are handled locally in the SDK (the voucher branch ofEVMSessionMethod.VerifySession).
OKX SA-API client (saclient.OKXSAClient)#
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, ...):baseURLmust be passed explicitly (passhttps://web3.okx.comfor production, the corresponding URL for sandbox/staging).
Endpoints#
SAClient method | OKX 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.
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.
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 broadcaststransferWithAuthorizationon-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.
EVMChargeMethodonly handles "verification" (ChargeVerifier); challenge generation is delegated to the high-levelserver.Mpp.Charge(...), with configuration going throughserver.EVMConfig+server.ChargeRouteConfig(see below).
Constructing EVMSessionMethod#
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
EVMSessionMethodConfigstruct, withNewEVMSessionMethodvalidating the required fields in one pass and returning anerror.
Session — evm.EVMSessionMethod#
Implements protocol.SessionVerifier. Maintains local channel state, voucher local signature verification + cumulative charging, and merchant-initiated settle/close.
protocol.SessionVerifier implementation#
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#
// 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 onlyDeductFromSession;CloseWithAuthorizationtakes onlychannelID(whether it is a waiver is decided by whether there is a voucher in the store). Channel-state queries go throughsaclient.SAClient.SessionStatus, not listed separately onEVMSessionMethod.
Session action routing (inside VerifySession, by payload.action)#
action | behavior |
|---|---|
"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.
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#
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]#
// 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:
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 implementsStore[ChannelState]and inject it intoEVMSessionMethodConfig.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#
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#
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.Signerinterface, withPrivateKeySigneras the default; remote / KMS signers can be injected just by implementing these three methods.
Voucher signing / verification#
// 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
VerifyVoucherreturns abool, with the precheck handled separately viaValidateVoucherSignature(sig) error.
SettleAuthorization / CloseAuthorization signing#
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 viaprimaryType.
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).
// 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.
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".
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).
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).
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)#
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'sprotocol.ErrorCodeInsufficientBalance/ErrInsufficientBalance(...), and is not in theSAErrorCodeconstant set (local charging is checked byDeductFromSession/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.
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):
| Code | meaning |
|---|---|
MalformedCredential | malformed credential |
InvalidChallenge | invalid challenge |
InvalidSignature | signature verification failed |
InvalidSplit | [new] invalid split (total exceeds primary amount / count exceeds 10) |
InsufficientBalance | insufficient balance |
AmountExceedsDeposit | amount exceeds deposit |
DeltaTooSmall | voucher increment too small |
ChannelNotFound | channel does not exist |
ChannelClosed | channel already closed |
SignerMismatch | signer mismatch |
BadRequest | request parameter error |
Internal | internal 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 code | meaning | mapped MppErrorCode |
|---|---|---|
| 70000 | missing required field / format error | BadRequest |
| 70001 | chain not in the supported list | Internal |
| 70002 | payer on the blocklist | MalformedCredential |
| 70003 | source missing / feePayer+hash incompatible / txHash already used | MalformedCredential |
| 70004 | signature verification failed | InvalidSignature |
| 70005 | split total ≥ primary amount | InvalidSplit |
| 70006 | split count > 10 | InvalidSplit |
| 70007 | transaction not confirmed on-chain | Internal |
| 70008 | channel already closed | ChannelClosed |
| 70009 | challenge does not exist / has expired | InvalidChallenge |
| 70010 | channelId does not exist | ChannelNotFound |
| 70011 | escrow grace period not satisfied | Internal |
| 70012 | cumulativeAmount exceeds the deposit balance | AmountExceedsDeposit |
| 70013 | voucher increment < minVoucherDelta | DeltaTooSmall |
| 70014 | channel in CLOSING state | ChannelClosed |
| 8000 | internal API error | Internal |
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#
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
}
GetChallengesimply passescfg 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#
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
NewX402Adapteris one-stop; behaviors like poll deadline / settlement hook are configured on the*x402http.HTTPServer/ facilitator client passed in. The priority ofMppAdapteris built-in and fixed, not adjustable.
Per-adapter typed route config#
// 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
}
MppRouteConfigcarriesDecimals/Realmfields; the x402 side directly reuses the x402 SDK'sx402http.RouteConfig.
Router config#
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 aPaymentGate, then call.For(cfg)(handler)for each route to mount it explicitly onto the mux —— route matching is delegated tonet/http'sServeMux, with no pattern list maintained inside the router. The error-callback signature isfunc(err error, phase, protocol string), wherephasetakespr.PhaseDetect/PhaseChallenge/PhaseHandle.
End-to-end assembly example#
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 }
- Go SDK Reference (applies to exact, exact + permit2, upto, aggr_deferred)Modules / PackagesCore typesNetwork / Price / AssetAmountResourceInfoPaymentRequirementsPaymentRequiredPaymentPayloadFacilitator typesVerifyResponse / SettleResponseSupportedKind / SupportedResponseSettleStatusResponseInterfacesSchemeNetworkServerFacilitatorClientResourceServerExtension / FacilitatorExtensionServer API (X402ResourceServer)Construction and registrationMethodsResourceConfig / SettlementOverridesOKX Facilitator client (OKXFacilitatorClient)Generic HTTP facilitator clientHMAC authenticationHTTP utilitiesHeader encoding/decodingConstantsRouting configExample (multiple schemes coexisting)Middlewarenet/httpGinEchoMiddleware flowHooks / settings attachable on HTTPServerEVM mechanisms (mechanisms/evm + scheme server subpackages)ExactEvmSchemeAggrDeferredEvmSchemeUptoEvmSchemeSelf-hosted facilitator schemes (exact/facilitator, upto/facilitator)EVM Payload types (mechanisms/evm)Permit2 / Upto constants (mechanisms/evm)Asset / chain config (mechanisms/evm)Error typesUtility functions (x402 package)Schema validation (x402 package)Go SDK Reference (applies to charge, session)Go module / packageConstantsCore types (mpp/evm, mpp/saclient)SAResponseEVMMethodDetails / SplitEVMSessionMethodDetails / SessionSplitEip3009AuthorizationChargeReceipt / SessionReceipt / SessionStatusSession request payload (SA-API)SAClient interfaceOKX SA-API client (saclient.OKXSAClient)EndpointsCharge — evm.EVMChargeMethodConstructing EVMSessionMethodSession — evm.EVMSessionMethodprotocol.SessionVerifier implementationBusiness methodsSession action routing (inside VerifySession, by payload.action)Session — store.Store[T] interfaceChannelStateImplementations — MemoryStore[T] / FileStore[T]NonceProvider interfaceEIP-712 signing (mpp/evm)The Signer interface and default implementationVoucher signing / verificationSettleAuthorization / CloseAuthorization signingDecoding challenge.requestDrop-in middleware (mpp/http/nethttp, mpp/http/gin)High-level coordinator server.MppRoute configError typesSA-API business error-code mapping (saclient.SAErrorCode)errors package —— MppError / MppErrorCodeSA-API business code → MppErrorCode mapping (OKXSAClient.mapSAError)Dual-protocol routing (paymentrouter + mpp/adapters + x402/adapters)Adapter interfaceBuilt-in adaptersPer-adapter typed route configRouter configEnd-to-end assembly example
