The X402.Plug.PaymentGate module provides drop-in payment gating for any
Plug-compatible application, including Phoenix. It implements the
x402 v2 HTTP transport.
Configuration
The plug accepts these options (validated via NimbleOptions):
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
:facilitator | GenServer.server() | no | X402.Facilitator | Facilitator process name or pid for verify/settle calls |
:hooks | module() | no | X402.Hooks.Default | Lifecycle hook module implementing X402.Hooks |
:payment_identifier_cache | atom() | pid() | {module, cache} | no | nil | Replay-protection cache: an ETSCache server, or an adapter tuple whose module implements X402.Extensions.PaymentIdentifier.Cache (strongly recommended — see "Replay Protection") |
:claim_order | :after_verify | :before_verify | no | :after_verify | When the replay claim is taken relative to facilitator verification (see "Replay Protection") |
:routes | [map()] | yes | — | Route gate definitions (see below) |
:schemes | [module()] | no | [] | Additional X402.Scheme modules for custom schemes — see the Custom Payment Schemes guide |
:local_prechecks | boolean() | no | true | Cheap scheme-dispatched checks before the facilitator call; certain mismatches answer 402 without a round-trip |
:local_verification | atom() | keyword() | no | nil | Inline cryptographic verification of exact-EVM payments before the facilitator verify (see "Local Verification") |
:paywall | module() | no | nil | Browser paywall renderer implementing X402.Paywall — see the Browser Paywall guide |
:siwx | keyword() | no | nil | Sign-In-With-X configuration — X402.Extensions.SIWX.Server.new/1 options (:domain, :uri, :supported_chains required); see "Sign-In-With-X" |
:extensions | [module() | {module(), keyword()}] | no | [] | X402.Extension adapters that advertise, validate, and observe protocol extensions on every gated request; see "Extension Adapters" |
:rate_limit | keyword() | no | nil | Per-wallet verified-payment limit; requires :limit and :window_ms |
:auth_capture | keyword() | no | nil | Explicit escrow resource and buffered handler; see Auth-capture |
Important: When
:payment_identifier_cacheis not configured, the plug emits a runtime warning. Without it, concurrent identical requests can double-settle the same payment proof.
Route Definitions
Routes are a list of maps. Each map describes one gated endpoint:
plug X402.Plug.PaymentGate,
facilitator: MyApp.Facilitator,
routes: [
%{
method: :get,
path: "/api/data",
price: "10000",
network: "eip155:8453",
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
pay_to: "0xYourWalletAddress"
}
]Route options
| Option | Type | Required | Description |
|---|---|---|---|
:method | atom() | yes | HTTP method (:get, :post, :put, :delete, :patch, :head, :options, :trace, or :any for all) |
:path | String.t() | yes | Route path. Exact matches (/api/data), glob patterns (/api/*), or templates with :param segments (/api/users/:id) — see "Path Parameters" |
:accepts | [map()] | (conn -> [map()]) | no | Multiple payment options (see "Multiple Accepts" below), or a 1-arity function of the conn returning them (see "Dynamic Pricing") |
:scheme | String.t() | no | "exact" (default), "upto", or the scheme name of a module passed in the plug's :schemes option — see the Custom Payment Schemes guide |
:price | String.t() | (conn -> String.t()) | conditionally | Payment amount in atomic token units, or a 1-arity function of the conn returning one. Required when :accepts is empty |
:network | String.t() | conditionally | CAIP-2 network identifier (e.g. "eip155:8453") |
:asset | String.t() | conditionally | Token contract address |
:pay_to | String.t() | (conn -> String.t()) | conditionally | Recipient wallet address, or a 1-arity function of the conn returning one |
:description | String.t() | (conn -> String.t()) | no | Resource description (default: "Payment required"), or a 1-arity function of the conn returning one |
:mime_type | String.t() | no | Resource MIME type (default: "application/json") |
:service_name | String.t() | no | ResourceInfo.serviceName: non-empty printable ASCII, at most 32 characters (X402.Extensions.Bazaar.Metadata.valid_service_name?/1) |
:tags | [String.t()] | no | ResourceInfo.tags: at most 5 unique (case-insensitive) entries, each under the :service_name rule (X402.Extensions.Bazaar.Metadata.sanitize_tags/1) |
:icon_url | String.t() | no | ResourceInfo.iconUrl: absolute http(s) URL, no userinfo, not an IP literal or loopback host, at most 2048 characters (X402.Extensions.Bazaar.Metadata.valid_icon_url?/1) |
:max_timeout_seconds | pos_integer() | no | Max payment completion time (default: 60) |
:extra | map() | no | Scheme-specific extra fields |
:extensions | map() | no | Protocol extensions advertised in PAYMENT-REQUIRED |
:bazaar | keyword() | no | X402.Extensions.Bazaar.build_extension/1 options advertising the route in the bazaar under extensions["bazaar"] (see "Path Parameters") |
When :accepts is empty (the default), a single payment option is built from
the top-level :scheme, :price, :network, :asset, and :pay_to fields.
Amounts are strings in atomic token units; for six-decimal USDC, "10000"
represents 0.01 USDC. The bazaar metadata options are validated at init
with the rules the bazaar extension
prescribes — an invalid value is a configuration error and raises, so a
facilitator cataloging your service never has to soft-drop it.
The Plug currently implements the post-handler authorization flow. It rejects
requirements whose extra.paymentFlow is "upfront" or "escrow" because
those flows require different handler and cancellation semantics.
Multiple Accepts
For routes that accept multiple payment options (different schemes, networks, or
amounts), use the :accepts list:
%{
method: :post,
path: "/api/generate",
accepts: [
%{
scheme: "exact",
price: "10000",
network: "eip155:8453",
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
pay_to: "0xYourWallet"
},
%{
scheme: "exact",
price: "5000",
network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
pay_to: "YourSolanaAddress",
extra: %{"feePayer" => "YourFacilitatorFeePayer"}
}
]
}The client's PaymentPayload.accepted is matched against the complete
server-advertised requirement. Every core field, including
maxTimeoutSeconds, must be equal. Client metadata may be added under
accepted.extra, but it cannot remove or mutate fields advertised by the
server. Echoed protocol extensions are validated with the same fail-closed
rule.
Exact-SVM options require extra.feePayer — the facilitator-managed
sponsor account that co-signs and pays fees at settlement. A facilitator's
GET /supported response advertises its fee payer in each SVM kind's
extra.feePayer; copy it into the route (or build routes from that
response).
Metered "upto" settlement
For an "upto" option, price is the maximum authorization. The maximum is
sent to /verify; the protected handler can set the actual charge before
returning its response:
def create(conn, params) do
result = generate(params)
{:ok, conn} =
X402.Plug.PaymentGate.put_settlement_amount(conn, billable_atomic_units(result))
json(conn, %{result: result})
endThe amount may be a non-negative integer or a digit-only string. It is written
to PaymentRequirements.amount for /settle and must not exceed the
advertised maximum. The maximum is settled when no override is supplied.
Dynamic Pricing
:price, :pay_to, :description, and :accepts — and :price /
:pay_to inside each :accepts entry — may be 1-arity functions of the
Plug.Conn instead of static values. They are evaluated on every gated
request, once, before the 402 advertisement is built and before the
client's accepted requirements are matched, so a single request always
sees one set of terms:
%{
method: :get,
path: "/api/report/:format",
price: fn conn ->
case conn.assigns.x402_path_params["format"] do
"pdf" -> "20000"
_other -> "10000"
end
end,
pay_to: fn conn -> MyApp.Billing.receiver_for(conn.assigns.current_tenant) end,
network: "eip155:8453",
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
}The paying request evaluates the functions again on its own conn, and the
resulting requirements are what the echoed accepted must match — so
anything the function reads must be stable between the unpaid and paid
requests (path parameters, headers, a session), not a clock or a random
value. A function returns the plain value or {:ok, value}; returning
{:error, reason} — or a value that fails the same validation as its
static counterpart (atomic-unit amounts, known schemes, the
authorization payment flow) — answers 500 and emits
[:x402, :plug, :payment_rejected] with
reason: {:dynamic_route_error, reason}. The reason never reaches the
client.
Path Parameters
A route :path may be a template with :param segments. Each parameter
matches exactly one non-empty path segment, and the captured values are
assigned as :x402_path_params on every gated request — paid or not — so
they are available to dynamic pricing functions, hooks, and your handler.
Matching preserves Plug's segment boundaries, including forwarded prefixes,
and percent-decodes each segment once. For example, r%2F1 is one parameter
with value "r/1", not two path components.
Example:
%{
method: :get,
path: "/api/users/:id/reports/:report_id",
price: "10000",
network: "eip155:8453",
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
pay_to: "0xYourWalletAddress"
}
# GET /api/users/42/reports/7
conn.assigns.x402_path_params
#=> %{"id" => "42", "report_id" => "7"}The advertised resource.url is always the concrete request URL. To list
the route in a facilitator's bazaar, add the :bazaar option — a keyword
list of X402.Extensions.Bazaar.build_extension/1 options — and the 402
additionally advertises the discovery extension under
extensions["bazaar"]. For :param routes the extension carries the
template as the top-level routeTemplate catalog key and the captured
values as info.input.pathParams, per the
bazaar spec;
glob routes cannot be advertised:
%{
method: :get,
path: "/api/users/:id",
price: "10000",
network: "eip155:8453",
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
pay_to: "0xYourWalletAddress",
service_name: "MyApp Users",
bazaar: [method: :get, output: [type: "json"]]
}Lifecycle Hooks
Hooks let you intercept the payment flow for logging, custom validation, or
post-settlement logic. Implement the X402.Hooks behaviour:
defmodule MyApp.PaymentHooks do
@behaviour X402.Hooks
@impl true
def before_verify(context, _metadata) do
IO.inspect(context.payload, label: "Incoming payment")
{:cont, context}
end
@impl true
def after_verify(context, _metadata) do
{:cont, context}
end
@impl true
def after_settle(context, _metadata) do
# Post-settlement: update DB, send receipt, etc.
{:cont, context}
end
@impl true
def before_settle(context, _metadata), do: {:cont, context}
@impl true
def on_verify_failure(context, _metadata), do: {:cont, context}
@impl true
def on_settle_failure(context, _metadata), do: {:cont, context}
endPass the module to the plug:
plug X402.Plug.PaymentGate,
facilitator: MyApp.Facilitator,
hooks: MyApp.PaymentHooks,
routes: [...]Request hooks
Beyond the six facilitator callbacks, the same module may define two
optional resource-server callbacks, mirroring the reference resource
server's onProtectedRequest and onVerifiedPaymentCanceled. Both
receive an X402.Hooks.RequestContext — the conn, the matched route,
method, path, path_params, and the requirements and extensions
about to be advertised:
on_protected_request/2runs for every request that matches a gated route, before any payment processing. Return{:cont, context}to proceed — optionally withcontext.requirementsorcontext.extensionsreplaced for this request (a per-caller discount, say);{:halt, {status, body}}to answer directly with the JSON-encodedbody; or{:halt, :skip_payment}to run the handler unpaid. An exception or an unexpected return value fails closed with 500.on_verified_payment_canceled/2runs when a payment the facilitator already verified is not settled: the handler answered with a status of 400 or above (metadatareason: :handler_failedwith:response_status), or settlement failed before a transaction was broadcast (reason: :settlement_failedwith:error). Its return value is ignored and exceptions are logged, so it is the place for compensating side effects — undo a quota increment, log a refundable authorization. The context carries the verifiedpayloadandmatched_requirements.
defmodule MyApp.PaymentHooks do
@behaviour X402.Hooks
require Logger
# ... the six facilitator callbacks as above ...
@impl true
def on_protected_request(context, _metadata) do
case Plug.Conn.get_req_header(context.conn, "x-api-key") do
[key] ->
if MyApp.Partners.allowlisted?(key),
do: {:halt, :skip_payment},
else: {:cont, context}
_none ->
{:cont, context}
end
end
@impl true
def on_verified_payment_canceled(context, metadata) do
Logger.warning("verified payment not settled",
reason: metadata.reason,
path: context.path,
payer: get_in(context.payload, ["payload", "authorization", "from"])
)
end
endA {:halt, :skip_payment} emits [:x402, :plug, :pass_through] with
reason: :hook_skipped; a {:halt, {status, body}} emits
[:x402, :plug, :payment_rejected] with reason: {:hook_halted, status}.
X402.Hooks.Default implements neither callback, so existing hook modules
keep working unchanged.
Extension Adapters
The :extensions option takes X402.Extension adapters — module or
{module, opts} — that each package one protocol extension's server-side
lifecycle, so the gate advertises, validates, and observes it from a single
option instead of you wiring each step per route:
plug X402.Plug.PaymentGate,
facilitator: MyApp.Facilitator,
payment_identifier_cache: MyApp.PaymentCache,
extensions: [
{X402.Extensions.PaymentIdentifier.Adapter, required: true},
{X402.Extensions.BuilderCode.Adapter, app_code: "my_app", service_codes: ["my_svc"]}
],
routes: [...]On every 402 each adapter's advertisement is merged over the route's
static :extensions map; on every payment the adapter's validate/3
runs after the generic extension echo check (a failure answers 400
invalid_payload with telemetry reason {:extension_invalid, key, reason});
and after_verify/4 / after_settle/4 are notified with the
facilitator's results. Adapter options are validated once, at init, so an
invalid app_code: is a configuration error that raises.
Two adapters ship with the SDK:
X402.Extensions.PaymentIdentifier.Adapter(required:, defaultfalse) advertises the payment-identifier extension; the id validation,requiredrule, fingerprint binding, and:x402_payment_idassign described under "Payment Identifiers" apply exactly as they do to a static route entry.X402.Extensions.BuilderCode.Adapter(app_code:required,service_codes:at most 5) advertises the builder-code extension — ERC-8021 on-chain attribution — and checks the client's echo withX402.Extensions.BuilderCode.validate_echo/2.
Write your own by implementing X402.Extension: only key/0 is required;
init/1, advertise/2 (receives the X402.Hooks.RequestContext, may
return nil to advertise nothing), validate/3, after_verify/4, and
after_settle/4 are optional. Sign-In-With-X keeps its dedicated :siwx
option and bazaar discovery its :bazaar route option; both compose with
adapters.
Builder code
Whether or not a route advertises builder-code — statically through
X402.Extensions.BuilderCode.extension/2 or with the adapter above — a
payment that echoes extensions["builder-code"] is checked: malformed
codes (^[a-z0-9_]{1,32}$) and more than ten service codes answer 400
invalid_payload (telemetry reason {:invalid_builder_code, detail}),
and an app code that differs from the advertised one is an
:extension_echo_mismatch (400). The resource server does nothing
else with the codes: they travel to the facilitator inside the payload,
which encodes them into the settlement calldata.
Local Verification
Facilitator verification is a delegation: the gate trusts the facilitator's
verdict. The local_verification: option narrows that gap by running
X402.Verify.EVM inline, before the facilitator verify, on every exact-EVM
payment ("exact" scheme, eip155:* network). It accepts a bare level
(:structural, :signature, or :full) or a keyword list with :level,
:rpc (required for :full), and the other X402.Verify.EVM.verify/3
options:
plug X402.Plug.PaymentGate,
facilitator: MyApp.Facilitator,
local_verification: :signature,
routes: [...]Rejections answer 402 carrying the canonical invalidReason string, exactly
like a facilitator rejection; infrastructure failures (missing crypto
dependencies, RPC errors, chain-id mismatches) fail closed with 500; other
scheme/network kinds skip it silently, and the facilitator remains the
authority for every payment either way. See the
Local Payment Verification guide for the levels,
their capability requirements, and ERC-6492 counterfactual handling.
Per-wallet rate limits
Add rate_limit: [limit: 60, window_ms: 60_000] to the gate options to allow
60 verified payments per wallet per fixed window. Denied requests receive
HTTP 429, error: "rate_limited", and a Retry-After header in seconds.
The handler and settlement do not run; the replay claim is released.
The limiter counts requests after verification. An unsigned payer field cannot consume another wallet's allowance. This still costs a verification round-trip, so use an edge/IP limiter to protect against unauthenticated floods. SIWX access and requests exempted by hooks do not count as payments.
The default X402.RateLimiter.ETS store is per-node. Use store: {module, ref}
with an X402.RateLimiter implementation for shared limits. Store failures
allow requests by default; set on_error: :deny to fail closed. key: :ip
or a function of the verified request context supports other grouping policies.
See X402.RateLimiter for the full options.
Replay Protection
A valid payment proof can be presented many times — concurrently to the same server, or replayed after the client observed a response. Configure a cache so the gate atomically claims each proof before the protected handler runs:
# In your supervision tree
children = [
{X402.Extensions.PaymentIdentifier.ETSCache, name: MyApp.PaymentCache},
# ... other children
]
# In your plug config
plug X402.Plug.PaymentGate,
facilitator: MyApp.Facilitator,
payment_identifier_cache: MyApp.PaymentCache,
routes: [...]Canonical replay keys
The claim key is derived from the payment proof itself. For the built-in schemes the gate derives a canonical identity from the fields the payment's signature covers, so re-encoding the same signed authorization (JSON key order, whitespace, Base64 variant) cannot mint a fresh key:
| Kind | Key derives from |
|---|---|
"exact" on eip155:* | the EIP-3009 authorization's from + nonce |
"upto" on eip155:* | the Permit2 owner (from) + the nonce canonicalized to its 32-byte uint256 encoding, so the equivalent JSON forms 1, "1", and "01" mint the same key |
"exact" on solana:* | the SHA-256 of the transaction's signed message bytes — not the wire bytes, whose fee-payer signature slot is mutable (a co-signed or stripped slot must not mint a fresh key) |
| everything else | the SHA-256 of the raw PAYMENT-SIGNATURE header |
Every family's keys carry a distinct prefix, so keys from different families can never collide. The consequence of the fallback row: re-encoded duplicates of the same signed proof are caught for the built-in schemes, while custom schemes without a canonical derivation catch byte-identical replays only.
The key derives only from signature-covered content — never from
unsigned client-controlled fields, and in particular never from the payment
identifier extension's paymentId (see "Payment Identifiers" below). A
replayer could vary an unsigned field to mint a fresh key and bypass
deduplication, or squat another payment's id to deny it service.
Claim lifecycle
The claim is an atomic put_new through the
X402.Extensions.PaymentIdentifier.Cache behaviour. A duplicate claim
rejects the request with 402 ("payment already processed"). The claim is
released when the protected handler responds with a status >= 400 or
settlement fails — so a client may retry a payment whose resource was never
delivered — and retained after a successful settlement.
The :claim_order option controls when the claim is taken relative to
facilitator verification:
:after_verify(default) — verify first, then claim. 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— claim first, rejecting duplicates locally without any facilitator call, which sheds replay-storm load; the claim is released when verification fails for any reason. The trade-off: a node that crashes between claiming and releasing strands the claim until the cache TTL expires, so a legitimate retry of that same payment is rejected with 402 until then.
Cache adapters
The ETS cache is per-node: in a clustered deployment each node keeps its own
table, so a replayed proof routed to two nodes is served once per node. For
clusters, use the Redis adapter (requires the optional redix dependency;
you supervise the connection):
# In your supervision tree
children = [
{Redix, {System.fetch_env!("REDIS_URL"), name: MyApp.Redis}},
# ... other children
]
# In your plug config
{:ok, cache} = X402.Extensions.PaymentIdentifier.RedisCache.new(conn: MyApp.Redis)
plug X402.Plug.PaymentGate,
facilitator: MyApp.Facilitator,
payment_identifier_cache: {X402.Extensions.PaymentIdentifier.RedisCache, cache},
routes: [...]The claim is a single SET NX PX command, so it stays atomic across all
nodes; Redis or connection errors fail closed (the protected handler does not
run). Configure the Redis server with maxmemory-policy noeviction so live
claims are never evicted.
Payment Identifiers
The payment-identifier extension
gives the client a way to make a payment idempotent from its side: it
attaches an id it generated, and a server that sees the same id again knows
it is the same logical request. Advertise the extension on a route through
X402.Extensions.PaymentIdentifier.extension/1:
%{
method: :post,
path: "/api/generate",
price: "50000",
network: "eip155:8453",
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
pay_to: "0xYourWalletAddress",
extensions: %{
"payment-identifier" => X402.Extensions.PaymentIdentifier.extension(required: true)
}
}The advertisement carries info.required and the JSON schema of the id.
A client echoes it and adds its id under
extensions["payment-identifier"]["info"]["id"] (the
client guide shows the enricher that does this). The gate then:
- validates the id — 16 to 128 characters of
[A-Za-z0-9_-](X402.Extensions.PaymentIdentifier.valid_id?/1); anything else is rejected with 400invalid_payload; - rejects a request that echoes no id with 400
payment_identifier_requiredwhen the advertisement setrequired: true; - surfaces a valid id as
conn.assigns[:x402_payment_id], in the settlement context, and as:payment_idin the[:x402, :plug, :payment_verified]telemetry metadata.
With :payment_identifier_cache configured, the id is additionally
bound to the request it was first used with: the gate computes
X402.Extensions.PaymentIdentifier.fingerprint/2 over the matched
scheme, network, asset, amount, payTo, HTTP method, and path, and stores
it under a "pid:"-prefixed cache key. Reusing the id for a request with a
different fingerprint is rejected with 409
payment_identifier_conflict; the same id with the same fingerprint
proceeds normally and remains subject to the signature-derived replay key
above. A binding this request created is released together with the replay
claim (handler status >= 400, verification or settlement failure), so the
id may be retried. Without a cache only the validity and required checks
apply.
The id is deliberately not the deduplication key: it is client-controlled and covered by no signature, so building replay protection on it would let a replayer mint fresh keys at will — or squat another payment's id to deny it service. The replay key comes from the signed content above; the payment identifier is for idempotency and tracing a payment through your logs and the client's.
Legacy format. Releases before 0.7.0 used a "paymentIdentifier" key
carrying a Base64 JSON {"paymentId": ...} string or a
%{"paymentId" => ...} map (optionally wrapped in %{"info" => ...}).
The gate still accepts it with the same assign, telemetry, and fingerprint
binding — each legacy id emits [:x402, :payment_identifier, :legacy] and
the first logs a warning — but the format is deprecated and will be
removed in 1.0.0. Legacy ids satisfy required: true and are exempt from
the spec's length and character rules; a malformed one is rejected with
400.
Sign-In-With-X
The sign-in-with-x extension
lets a wallet that already paid for a resource come back without paying
again: it proves control of its address by signing a CAIP-122 message
(EIP-4361 for eip155:* chains, Sign-In-With-Solana for solana:*), and
the server checks its own payment history for that address. Configure it
with the :siwx option, a keyword list of
X402.Extensions.SIWX.Server.new/1 options:
# In your supervision tree
children = [
{X402.Extensions.PaymentIdentifier.ETSCache, name: MyApp.SIWXNonces},
{X402.Extensions.SIWX.ETSStorage, name: MyApp.SIWXStorage}
]
# In your plug config
plug X402.Plug.PaymentGate,
facilitator: MyApp.Facilitator,
siwx: [
domain: "api.example.com",
uri: "https://api.example.com",
supported_chains: [
%{chain_id: "eip155:8453"},
%{chain_id: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"}
],
statement: "Sign in to access premium data",
nonce_cache: MyApp.SIWXNonces,
storage: {X402.Extensions.SIWX.ETSStorage, MyApp.SIWXStorage},
ttl_ms: :timer.hours(24)
],
routes: [...]:domain, :uri, and :supported_chains are required. Each chain entry
takes a CAIP-2 :chain_id and derives its signature :type ("eip191"
for eip155, "ed25519" for solana) unless given. Other options:
:resources, :expiration_seconds (challenge lifetime, default 300),
:max_age_seconds (oldest accepted issuedAt, default 300),
:clock_skew_seconds (default 60), :verifier (EVM, default
X402.Extensions.SIWX.Verifier.Default — needs ex_secp256k1 and
ex_keccak), and :ed25519_verifier (Solana, default
X402.Extensions.SIWX.Verifier.Ed25519, OTP :crypto only).
The flow:
- Every 402 response advertises a fresh challenge under
extensions["sign-in-with-x"]— new nonce and timestamps each time (which is why this extension is exempt from the echo check applied to other extensions). - A client signs the challenge (
X402.Extensions.SIWX.sign/3) and sends the proof in aSIGN-IN-WITH-Xheader. The gate verifies it withX402.Extensions.SIWX.Server.authenticate/3against the HTTP method and full resource URL. - If the address has a payment record for that request, the handler runs
without payment;
:x402_siwx_addressand:x402_siwx_chain_idare assigned and[:x402, :plug, :siwx_authenticated]is emitted. If it has none, the request continues through the normal payment flow when it also carriesPAYMENT-SIGNATURE(identity and payment in one request), and otherwise receives 402 with a fresh challenge. - After a successful settlement the payer (the settle response's
payer, falling back to the EVM authorization'sfromselected by the matched scheme and transfer method, never an unsigned alternate field) is recorded for the method and resource URL through:storagefor:ttl_ms— from then on that address can sign in instead of paying, until the record expires.
Storage keys use "METHOD URL", for example
"GET https://api.example.com/api/resource?item=1". Method, origin, port,
raw path and query remain distinct authorization boundaries, even on an
:any route. Do not strip query parameters or merge hosts: they may select
different tenants or differently priced resources. Configure trusted proxy
URL rewriting before the gate. Old URL-only records are not accepted as
method-scoped grants; manually provisioning a grant must use its intended
method and full URL.
EVM payer addresses are normalized to lowercase for records, lookups and revocation. Solana addresses remain case-sensitive.
Error responses:
| Status | error | When |
|---|---|---|
| 400 | invalid_siwx_header | The header is not valid Base64 JSON, exceeds 8 KB, or lacks the required proof fields |
| 402 | invalid_siwx_domain_mismatch, invalid_siwx_uri_mismatch, invalid_siwx_issued_at, invalid_siwx_issued_at_too_old, invalid_siwx_issued_at_in_future, invalid_siwx_expiration_time, invalid_siwx_expired, invalid_siwx_not_before, invalid_siwx_not_yet_valid, invalid_siwx_nonce, invalid_siwx_chain_id, invalid_siwx_unsupported_chain, invalid_siwx_malformed_signature, invalid_siwx_signature, invalid_siwx_verifier_error | The proof failed the corresponding check (X402.Extensions.SIWX.verify/2 lists each one); telemetry reason: {:siwx, code} |
| 402 | — | The proof is valid but the address has no payment record and the request carries no PAYMENT-SIGNATURE |
Origin binding.
:domainand:urimust be your server's configured public origin — never values derived from the request'sHostheader, which the caller controls. A proof is only accepted when itsdomainanduriequal these values, so deriving them from the request would let an attacker choose the origin the proof is bound to.
Nonce cache. Without
:nonce_cache, a proof is only bounded by itsissuedAtwindow (:max_age_seconds) and can be replayed within it. With one — anyX402.Extensions.PaymentIdentifier.Cacheadapter — each challenge nonce is recorded as issued and consumed atomically when a proof verifies, so a proof authenticates exactly once and only against a challenge this server issued. Configure it in production, and use a shared adapter (Redis) in a cluster for the same reason as the replay cache. Custom adapters must accept the{:siwx_nonce, :issued | :used}value.
The pre-0.7.0 header format — a Base64 JSON {"message", "signature"}
object carrying the signed EIP-4361 text — is still accepted and verified
under the same rules, emitting [:x402, :siwx, :legacy] per proof and a
one-time warning; it is deprecated and removed in 1.0.0.
Extension responses
Facilitators may report per-extension processing outcomes through the
EXTENSION-RESPONSES sidechannel — a Base64 JSON header on /verify and
/settle responses keyed by extension name, for example
{"bazaar": {"status": "success"}} (see X402.ExtensionResponses). The
gate surfaces it without ever forwarding it to the buyer:
- verify-time outcomes are assigned as
:x402_extension_responsesbefore the handler runs; - settle-time outcomes are attached as
:extension_responsesto the[:x402, :plug, :payment_verified]telemetry metadata.
A malformed sidechannel is dropped (with a
[:x402, :extension_responses, :decode] telemetry event) rather than
failing the payment; when the facilitator sent none — or an invalid one —
the assign is absent and the metadata key is not set.
Conn Assigns
After successful verification, the Plug assigns these to the connection before the protected handler runs:
| Assign | Value |
|---|---|
:x402_payment_payload | The decoded PaymentPayload map |
:x402_payment_requirements | The matched PaymentRequirements map |
:x402_payment_id | The client's echoed payment identifier (only set when the extension was present) |
:x402_extension_responses | The facilitator's verify-time EXTENSION-RESPONSES outcomes, keyed by extension name (only set when the facilitator sent a valid sidechannel) |
:x402_siwx_address | The wallet address of a request authenticated through Sign-In-With-X instead of paying (only set on that path; the payment assigns above are absent then) |
:x402_siwx_chain_id | The CAIP-2 chain the Sign-In-With-X proof was signed for (set together with :x402_siwx_address) |
:x402_path_params | The values captured by the route's :param segments (set on every request matching a :param route, paid or not; absent for exact and glob routes) |
:x402_rate_limit | %{key: key, remaining: count, limit: limit, window_ms: window_ms} when the configured limiter permits the payment |
Your controller can access these:
def show(conn, _params) do
payload = conn.assigns.x402_payment_payload
requirements = conn.assigns.x402_payment_requirements
# The payer's wallet address, transaction hash, etc.
# are available in the payload
json(conn, %{data: "premium content"})
endPayment Response
Settlement runs in a before_send callback only when the protected handler has
produced a response below HTTP 400. On successful settlement, a
PAYMENT-RESPONSE header is attached to the response. On payment failure, the
response includes both
PAYMENT-REQUIRED (so the client can retry) and PAYMENT-RESPONSE (with
the error reason).
Settlement retries
A settle response of success: false with errorReason: "settlement_pending" and a transaction hash means the facilitator
broadcast the transaction but could not confirm it within its wait window.
The gate retries such a settle exactly once, with the identical payload —
mirroring the reference SDKs' settleWithPendingRetry — so a facilitator
with a pending-settlement store reconciles against the already-broadcast
transaction instead of broadcasting twice (see
Run Your Own Facilitator). A second pending verdict, or
any other failure, follows the normal failure path: the replay claim is
released and the response carries the failure headers described above.
Signed Offers and Receipts
The offer-and-receipt extension
lets a resource server cryptographically commit to the terms it advertises
(signed offers) and confirm delivery after payment (signed
receipts) — evidence for disputes, audits, and reputation systems.
X402.Extensions.OfferReceipt implements both artifact formats: EIP-712
(signed through X402.Signer, verified by signer recovery) and compact JWS
(ES256K/EdDSA via OTP :crypto).
Issue offers for the terms a route advertises and attach them through the
route's extensions: option (clients that ignore the extension are
unaffected):
alias X402.Extensions.OfferReceipt
{:ok, signer} = X402.Signer.LocalKey.new(System.fetch_env!("OFFER_SIGNING_KEY"))
{:ok, payload} =
OfferReceipt.offer_payload(
resource_url: "https://api.example.com/premium-data",
scheme: "exact",
network: "eip155:84532",
asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
pay_to: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
amount: "10000"
)
{:ok, offer} = OfferReceipt.sign_offer(payload, signer, accept_index: 0)
plug X402.Plug.PaymentGate,
routes: [
%{
method: :get,
path: "/premium-data",
price: "10000",
network: "eip155:84532",
asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
pay_to: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
extensions: %{"offer-receipt" => OfferReceipt.build_extension([offer])}
}
]Because route configuration is static, offers built this way should omit
valid_until (or set it generously); build the payment-required response
yourself with X402.PaymentRequired.encode/1 when you want short-lived,
per-request offers.
After a successful settlement, issue a receipt from your handler (the payer
is available in the payment payload assign) and return it to the client —
for example in the response body, or from your settlement pipeline as
extensions["offer-receipt"] of a settlement response you construct:
{:ok, receipt_payload} =
OfferReceipt.receipt_payload(
resource_url: "https://api.example.com/premium-data",
network: "eip155:84532",
payer: payer_address
)
{:ok, receipt} = OfferReceipt.sign_receipt(receipt_payload, signer)
extension = OfferReceipt.build_receipt_extension(receipt)Clients extract and verify the artifacts — and must apply an authorization
policy for the signer (spec §4.5.1); the simplest is requiring the offer's
payTo key:
{:ok, [offer]} = OfferReceipt.fetch_offers(payment_required)
{:ok, payload} = OfferReceipt.extract_payload(offer)
{:ok, %{signer: signer}} =
OfferReceipt.verify_offer(offer, expected_signer: payload["payTo"])JWS verification takes the resolved public key explicitly
(verify_offer(offer, public_key: key)) — the library carries the kid
DID URL but never resolves it over the network.
HTTP Status Codes
The plug follows the x402 v2 HTTP transport status mapping:
| Status | When |
|---|---|
| 402 | Payment required (no PAYMENT-SIGNATURE header), no matching requirements, a duplicate payment proof, a local pre-check or local-verification rejection, facilitator verification/settlement failure, or a SIGN-IN-WITH-X proof that fails verification or belongs to an address with no payment record |
| 400 | Malformed PAYMENT-SIGNATURE header, invalid Base64, invalid JSON, payload too large, wrong x402Version, a scheme payload validation failure, a malformed or missing-but-required payment identifier, an extension adapter or builder-code validation failure, or an undecodable SIGN-IN-WITH-X header |
| 409 | A payment-identifier id reused for a request with a different fingerprint (payment_identifier_conflict) |
| 429 | A verified payment exceeded :rate_limit; Retry-After gives the wait in seconds |
| 500 | Facilitator transport failure, malformed facilitator response, local-verification infrastructure failure (missing dependency, RPC error, chain-id mismatch), a dynamic route function returning an error or invalid value, an on_protected_request/2 hook that raises or returns an invalid value, invalid server-provided settlement amount, or response-encoding failure |
Telemetry Events
The plug emits these telemetry events:
| Event | When |
|---|---|
[:x402, :plug, :pass_through] | Route did not match — request passes through unguarded — or an on_protected_request/2 hook returned {:halt, :skip_payment} (reason: :hook_skipped, with :route) |
[:x402, :plug, :payment_required] | 402 returned — no PAYMENT-SIGNATURE header |
[:x402, :plug, :payment_verified] | Payment successfully verified and settled |
[:x402, :plug, :payment_rejected] | Payment rejected (invalid payload, no match, verification failed, a hook or dynamic-route error, etc.) |
[:x402, :plug, :siwx_authenticated] | A SIGN-IN-WITH-X proof from a previously paying address let the handler run without payment |
[:x402, :plug, :rate_limited] | A verified payment exceeded its limit; metadata includes :payer, :key, and :retry_after_ms |
Metadata always includes :method and :path. Every event except an
unmatched :pass_through adds :route; :payment_rejected adds
:reason — {:siwx, code} for a failed Sign-In-With-X proof,
{:hook_halted, status} when on_protected_request/2 answered the
request itself, {:dynamic_route_error, reason} when a dynamic route
function failed, {:extension_invalid, key, reason} when an extension
adapter rejected the echo, and {:invalid_builder_code, detail} for a
malformed builder code; :payment_verified adds :payment_id when the
client echoed a payment identifier extension and :extension_responses
when the facilitator's settle response carried the sidechannel; and
:siwx_authenticated adds :address and :chain_id. Deprecated wire
formats additionally emit [:x402, :payment_identifier, :legacy] and
[:x402, :siwx, :legacy].
LiveDashboard and local statistics
Applications using Phoenix LiveDashboard can add
{:telemetry_metrics, "~> 1.0"} to their dependencies and use its Metrics page:
live_dashboard "/dashboard", metrics: X402.Telemetry.MetricsThe SDK does not depend on Phoenix. Other reporters can use
X402.Telemetry.Metrics.metrics/1 with only: [:plug, :facilitator] or
except: filters. Definitions cover event counters and facilitator span
durations; they do not invent durations for events that only emit counts.
Without a metrics reporter, attach the in-memory aggregator once at application startup and read it when needed:
:ok = X402.Telemetry.Stats.attach()
stats = X402.Telemetry.Stats.snapshot()detach/0 stops collection and drops the table. These statistics are local
to the node and are not a durable payment ledger.
Full Example
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :paid_api do
plug X402.Plug.PaymentGate,
facilitator: MyApp.Facilitator,
hooks: MyApp.PaymentHooks,
payment_identifier_cache: MyApp.PaymentCache,
local_verification: :signature,
routes: [
%{
method: :get,
path: "/api/weather",
price: "5000",
network: "eip155:8453",
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
pay_to: "0xYourWalletAddress",
description: "Weather data API"
},
%{
method: :post,
path: "/api/generate",
price: "50000",
network: "eip155:8453",
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
pay_to: "0xYourWalletAddress",
description: "AI generation endpoint"
},
%{
method: :any,
path: "/api/premium/*",
accepts: [
%{
scheme: "exact",
price: "10000",
network: "eip155:8453",
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
pay_to: "0xYourWalletAddress"
},
%{
scheme: "upto",
price: "1000000",
network: "eip155:8453",
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
pay_to: "0xYourWalletAddress"
}
],
description: "Premium tier — flexible pricing",
service_name: "MyApp Premium",
tags: ["premium", "ai"]
}
]
end
scope "/api" do
pipe_through [:paid_api]
get "/weather", WeatherController, :show
post "/generate", GenerateController, :create
get "/premium/*path", PremiumController, :show
end
end