X402.Plug.PaymentGate (X402 v0.6.0)

Copy Markdown View Source

Plug middleware that gates configured routes behind x402 v2 payment verification.

For matching routes:

  1. Requests without a PAYMENT-SIGNATURE header receive 402 with a Base64-encoded PAYMENT-REQUIRED header (PaymentRequired v2 schema).
  2. Requests with PAYMENT-SIGNATURE are decoded as PaymentPayload v2.
  3. PaymentPayload.accepted and echoed extensions are matched against the complete requirements advertised by the route.
  4. Cheap local pre-checks run for the matched scheme/network — resolved through X402.Scheme.Registry — so certain mismatches answer 402 without a facilitator round-trip. The built-in EVM schemes check the EIP-3009-style authorization object (payTo binding, exact amount equality, validity window); see the :local_prechecks option. Kinds with no registered scheme module skip straight to the facilitator.
  5. Matched requirements are verified before the protected handler runs — optionally preceded by inline local verification through X402.Verify.EVM (see the :local_verification option).
  6. Successful handler responses are settled immediately before they are sent. A settlement_pending settle failure that already carries a transaction hash is retried once, so the facilitator's pending store can reconcile — mirroring the reference resource servers. Successful settlements attach a PAYMENT-RESPONSE header and assign :x402_payment_payload / :x402_payment_requirements on the conn.

HTTP status mapping (HTTP transport v2):

  • 402 — payment required, no matching requirements, or payment failed
  • 400 — malformed / invalid payment payload (including wrong x402Version)
  • 500 — facilitator transport failures or malformed facilitator responses

See the official x402 v2 specification and HTTP transport.

Browser paywall

With paywall: X402.Paywall.Default (or any X402.Paywall implementation), pre-handler 402 responses to browser page loads — Accept containing text/html and User-Agent containing Mozilla, the heuristic shared by the reference x402 middlewares — carry a human-usable HTML page instead of the {} JSON body. The PAYMENT-REQUIRED header is identical on both forms and all other responses are unchanged. See the "Browser Paywall" guide.

Replay protection

When :payment_identifier_cache is configured, the gate claims a canonical replay key for the payment proof through the X402.Extensions.PaymentIdentifier.Cache behaviour before settling. The key is derived from signature-covered content, so re-encoding the same signed authorization (JSON key order, whitespace, Base64 variant) cannot mint a fresh key:

  • "exact" on eip155:* — the EIP-3009 authorization's from + nonce
  • "upto" on eip155:* — the Permit2 authorization's from + nonce
  • "exact" on solana:* — the SHA-256 of the transaction's signed message bytes (immune to the mutable fee-payer signature slot)
  • everything else — the SHA-256 hash of the raw PAYMENT-SIGNATURE header, prefixed so families cannot collide. Re-encodings of the same proof are distinct keys here, exactly as before canonical keys existed.

The key is never derived from client-controlled unsigned fields — in particular not from the payment identifier extension's paymentId: a replayer could vary it to mint a fresh key and bypass deduplication, or squat another payment's id to deny it service. Duplicate proofs are rejected with 402 and the claim is released when the protected handler responds with a status >= 400 or settlement fails, so clients may retry a payment whose resource was never delivered.

The :claim_order option controls when the claim is taken relative to facilitator verification:

  • :after_verify (default) — the claim is taken only after the facilitator has verified the proof. A replayed proof can never strand a claim through verification, but every replayed request pays a full facilitator verify round-trip before it is rejected, so a replay storm translates directly into facilitator load.
  • :before_verify — the claim is taken before contacting the facilitator and released again if verification fails for any reason. Duplicates are rejected locally without any facilitator call, which sheds replay-storm load. The trade-off: a node that crashes between claiming and releasing (now including the verify round-trip window) strands the claim until the cache TTL expires, so a legitimate retry of that same payment is rejected with 402 until then.

Both orderings keep the existing release semantics: the claim is released when the handler responds with a status >= 400 or settlement fails, and a duplicate claim is rejected with the same 402 duplicate-payment error.

Clustered deployments can serve one payment twice

The default X402.Extensions.PaymentIdentifier.ETSCache adapter is per-node: every node in a cluster keeps its own claim table, so a replayed proof load-balanced onto two nodes runs the protected handler on each of them even though only one settlement can ultimately succeed. If you deploy more than one node, configure a shared-store adapter instead — see the "Writing a distributed adapter" section in X402.Extensions.PaymentIdentifier.Cache for a Redis sketch.

Summary

Types

Claim ordering relative to facilitator verification.

Configuration map produced by init/1.

Functions

Gates matching requests behind x402 v2 payment verification.

Validates and compiles X402.Plug.PaymentGate options.

Stores the actual atomic amount to settle for an "upto" route.

Types

claim_order()

@type claim_order() :: :after_verify | :before_verify

Claim ordering relative to facilitator verification.

options()

@type options() :: %{
  facilitator: X402.Facilitator.server(),
  hooks: module(),
  payment_identifier_cache:
    X402.Extensions.PaymentIdentifier.Cache.adapter() | nil,
  claim_order: claim_order(),
  routes: [compiled_route()],
  schemes: [module()],
  local_prechecks: boolean(),
  local_verification: keyword() | nil,
  paywall: module() | nil
}

Configuration map produced by init/1.

Functions

call(conn, opts)

(since 0.1.0)
@spec call(Plug.Conn.t(), options()) :: Plug.Conn.t()

Gates matching requests behind x402 v2 payment verification.

init(opts)

(since 0.1.0)
@spec init(keyword()) :: options()

Validates and compiles X402.Plug.PaymentGate options.

Options

  • :facilitator (term/0) - Facilitator server pid/name used for verification and settlement. The default value is X402.Facilitator.

  • :hooks - Lifecycle hook module implementing X402.Hooks. The default value is X402.Hooks.Default.

  • :payment_identifier_cache - Optional idempotency cache used for replay protection. Accepts either an ETSCache server pid/name (the default adapter), or a {module, cache} adapter tuple where module implements X402.Extensions.PaymentIdentifier.Cache and cache is passed to its callbacks (for example {MyApp.RedisPaymentCache, MyApp.Redis}). When set, the plug performs an atomic claim (via the adapter's put_new/3) on the payment proof hash before settling, preventing concurrent requests from double-settling the same payment. The default ETS adapter is per-node — see the "Replay protection" section above for the clustering hazard. The default value is nil.

  • :claim_order - When the replay claim is taken relative to facilitator verification. :after_verify (default) never strands a claim on verification but pays a facilitator verify round-trip per replayed request; :before_verify rejects duplicates before contacting the facilitator (shedding replay-storm load) and releases the claim if verification fails, at the cost that a node crash during verification strands the claim until the cache TTL expires. Only meaningful when :payment_identifier_cache is configured. The default value is :after_verify.

  • :routes - Required. Route gate definitions (see route options below).

  • :schemes - Additional X402.Scheme modules consulted (before the built-ins) for scheme-specific payload validation and local pre-checks — see X402.Scheme.Registry. Routes may use the scheme names these modules declare. The default value is [].

  • :local_prechecks (boolean/0) - Run cheap local checks before calling the facilitator, dispatched to the X402.Scheme module matching the requirements' scheme/network. The built-in EVM schemes check the EIP-3009-style payload.authorization object: to must equal the route's pay_to, value must equal the advertised amount for "exact" routes, and the validAfter/validBefore window must cover now (with a 6s settlement buffer). Fields absent from the payload are skipped — as are kinds with no registered scheme module — so payloads with other shapes pass through untouched. Failures answer 402 without a facilitator round-trip. The default value is true.

  • :local_verification - Optional inline local verification through X402.Verify.EVM, run before the facilitator verify. Accepts a level atom (:structural, :signature, or :full — shorthand for [level: level]) or a keyword list with :level, :rpc (an X402.RPC struct, required for :full), :simulate, :verify_chain_id, :eip6492_allowed_factories, and :multicall_address — see X402.Verify.EVM.verify/3 for their semantics. Local verification only understands exact-EVM payments: it runs when the matched requirements have scheme "exact" and an eip155:* network, and is skipped silently for every other scheme/network combination — the facilitator remains the authority, and still verifies and settles payments local verification accepted. Verification failures answer 402 exactly like a facilitator rejection (carrying the canonical invalidReason string); infrastructure failures — missing crypto dependencies, RPC errors, chain-id mismatches — fail closed with 500. A configured level never silently downgrades. The default value is nil.

  • :paywall - Optional browser paywall renderer implementing X402.Paywall (X402.Paywall.Default ships a self-contained wallet-enabled page). When set, pre-handler 402 responses to requests that look like a browser page load — Accept header containing text/html and User-Agent containing Mozilla, mirroring the reference x402 middlewares — carry the rendered HTML body instead of the default {} JSON body. The PAYMENT-REQUIRED header is identical on both forms, and every other response (API clients, absent Accept headers, 400/500 statuses, post-handler settlement failures) is byte-identical to running without :paywall. The default value is nil.

Route options

  • :method - Required. HTTP method for the route (:any matches all methods).

  • :path (String.t/0) - Required. Route path, supporting exact matches and * globs (for example /api/*).

  • :accepts (list of map/0) - Payment options advertised in PAYMENT-REQUIRED.accepts. When empty, a single option is built from the top-level :scheme, :price, :network, :asset, and :pay_to fields. The default value is [].

  • :scheme (String.t/0) - Single-option scheme (used when :accepts is empty): exact, upto, or the scheme name of a module passed in the plug's :schemes option. The default value is "exact".

  • :price - Single-option amount (required when :accepts is empty).

  • :network (String.t/0) - Single-option CAIP-2 network (required when :accepts is empty).

  • :asset (String.t/0) - Single-option asset (required when :accepts is empty).

  • :pay_to (String.t/0) - Single-option payTo (required when :accepts is empty).

  • :description (String.t/0) - ResourceInfo.description. The default value is "Payment required".

  • :mime_type (String.t/0) - ResourceInfo.mimeType. The default value is "application/json".

  • :service_name - ResourceInfo.serviceName (printable ASCII, max 32 characters recommended). The default value is nil.

  • :tags (list of String.t/0) - ResourceInfo.tags (max 5 recommended). The default value is [].

  • :icon_url - ResourceInfo.iconUrl (absolute http(s) URL). The default value is nil.

  • :max_timeout_seconds (pos_integer/0) - Default maxTimeoutSeconds for single-option routes. The default value is 60.

  • :extra - Default extra map for single-option routes. The default value is %{}.

  • :extensions - Protocol extensions advertised in PaymentRequired.extensions. The default value is %{}.

Accept option fields (inside :accepts)

  • :scheme (String.t/0) - Payment scheme (exact, upto, or the scheme name of a module passed in the plug's :schemes option). The default value is "exact".

  • :price - Required. Payment amount in atomic token units (PaymentRequirements amount). For exact this is the required amount; for upto it is the maximum authorized amount.

  • :network (String.t/0) - Required. Blockchain network in CAIP-2 format (for example eip155:84532).

  • :asset (String.t/0) - Required. Token contract address or asset identifier.

  • :pay_to (String.t/0) - Required. Recipient wallet address (payTo in the PaymentRequirements schema).

  • :max_timeout_seconds (pos_integer/0) - Maximum time allowed for payment completion. The default value is 60.

  • :extra - Scheme-specific extra fields (string or atom keys). The default value is %{}.

put_settlement_amount(conn, amount)

(since 0.4.0)
@spec put_settlement_amount(Plug.Conn.t(), String.t() | non_neg_integer()) ::
  {:ok, Plug.Conn.t()} | {:error, :invalid_settlement_amount}

Stores the actual atomic amount to settle for an "upto" route.

Call this from the protected handler after resource consumption is known. When omitted, the route's advertised maximum is settled.

Examples

iex> conn = Plug.Test.conn(:get, "/paid")
iex> {:ok, conn} = X402.Plug.PaymentGate.put_settlement_amount(conn, "7500")
iex> conn.private[:x402_settlement_amount]
"7500"

iex> X402.Plug.PaymentGate.put_settlement_amount(Plug.Test.conn(:get, "/paid"), "1.5")
{:error, :invalid_settlement_amount}