Plug middleware that gates configured routes behind x402 v2 payment verification.
For matching routes:
- Requests without a
PAYMENT-SIGNATUREheader receive 402 with a Base64-encodedPAYMENT-REQUIREDheader (PaymentRequiredv2 schema). - Requests with
PAYMENT-SIGNATUREare decoded asPaymentPayloadv2. PaymentPayload.acceptedand echoed extensions are matched against the complete requirements advertised by the route.- 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_prechecksoption. Kinds with no registered scheme module skip straight to the facilitator. - Matched requirements are verified before the protected handler runs —
optionally preceded by inline local verification through
X402.Verify.EVM(see the:local_verificationoption). - Successful handler responses are settled immediately before they are
sent. A
settlement_pendingsettle 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 aPAYMENT-RESPONSEheader and assign:x402_payment_payload/:x402_payment_requirementson 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"oneip155:*— the EIP-3009 authorization'sfrom+nonce"upto"oneip155:*— the Permit2 authorization'sfrom+nonce"exact"onsolana:*— 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-SIGNATUREheader, 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.
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
@type claim_order() :: :after_verify | :before_verify
Claim ordering relative to facilitator verification.
@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
@spec call(Plug.Conn.t(), options()) :: Plug.Conn.t()
Gates matching requests behind x402 v2 payment verification.
Validates and compiles X402.Plug.PaymentGate options.
Options
:facilitator(term/0) - Facilitator server pid/name used for verification and settlement. The default value isX402.Facilitator.:hooks- Lifecycle hook module implementingX402.Hooks. The default value isX402.Hooks.Default.:payment_identifier_cache- Optional idempotency cache used for replay protection. Accepts either anETSCacheserver pid/name (the default adapter), or a{module, cache}adapter tuple wheremoduleimplementsX402.Extensions.PaymentIdentifier.Cacheandcacheis passed to its callbacks (for example{MyApp.RedisPaymentCache, MyApp.Redis}). When set, the plug performs an atomic claim (via the adapter'sput_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 isnil.: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_verifyrejects 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_cacheis configured. The default value is:after_verify.:routes- Required. Route gate definitions (see route options below).:schemes- AdditionalX402.Schememodules consulted (before the built-ins) for scheme-specific payload validation and local pre-checks — seeX402.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 theX402.Schememodule matching the requirements' scheme/network. The built-in EVM schemes check the EIP-3009-stylepayload.authorizationobject:tomust equal the route'spay_to,valuemust equal the advertised amount for"exact"routes, and thevalidAfter/validBeforewindow 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 istrue.:local_verification- Optional inline local verification throughX402.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(anX402.RPCstruct, required for:full),:simulate,:verify_chain_id,:eip6492_allowed_factories, and:multicall_address— seeX402.Verify.EVM.verify/3for their semantics. Local verification only understands exact-EVM payments: it runs when the matched requirements have scheme"exact"and aneip155:*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 canonicalinvalidReasonstring); infrastructure failures — missing crypto dependencies, RPC errors, chain-id mismatches — fail closed with 500. A configured level never silently downgrades. The default value isnil.:paywall- Optional browser paywall renderer implementingX402.Paywall(X402.Paywall.Defaultships a self-contained wallet-enabled page). When set, pre-handler 402 responses to requests that look like a browser page load —Acceptheader containingtext/htmlandUser-AgentcontainingMozilla, mirroring the reference x402 middlewares — carry the rendered HTML body instead of the default{}JSON body. ThePAYMENT-REQUIREDheader is identical on both forms, and every other response (API clients, absentAcceptheaders, 400/500 statuses, post-handler settlement failures) is byte-identical to running without:paywall. The default value isnil.
Route options
:method- Required. HTTP method for the route (:anymatches all methods).:path(String.t/0) - Required. Route path, supporting exact matches and*globs (for example/api/*).:accepts(list ofmap/0) - Payment options advertised inPAYMENT-REQUIRED.accepts. When empty, a single option is built from the top-level:scheme,:price,:network,:asset, and:pay_tofields. The default value is[].:scheme(String.t/0) - Single-option scheme (used when:acceptsis empty):exact,upto, or the scheme name of a module passed in the plug's:schemesoption. The default value is"exact".:price- Single-option amount (required when:acceptsis empty).:network(String.t/0) - Single-option CAIP-2 network (required when:acceptsis empty).:asset(String.t/0) - Single-option asset (required when:acceptsis empty).:pay_to(String.t/0) - Single-option payTo (required when:acceptsis 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 isnil.:tags(list ofString.t/0) - ResourceInfo.tags (max 5 recommended). The default value is[].:icon_url- ResourceInfo.iconUrl (absolute http(s) URL). The default value isnil.:max_timeout_seconds(pos_integer/0) - Default maxTimeoutSeconds for single-option routes. The default value is60.: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:schemesoption). The default value is"exact".:price- Required. Payment amount in atomic token units (PaymentRequirementsamount). Forexactthis is the required amount; foruptoit is the maximum authorized amount.:network(String.t/0) - Required. Blockchain network in CAIP-2 format (for exampleeip155:84532).:asset(String.t/0) - Required. Token contract address or asset identifier.:pay_to(String.t/0) - Required. Recipient wallet address (payToin the PaymentRequirements schema).:max_timeout_seconds(pos_integer/0) - Maximum time allowed for payment completion. The default value is60.:extra- Scheme-specific extra fields (string or atom keys). The default value is%{}.
@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}