Plug/Phoenix Integration

Copy Markdown View Source

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):

OptionTypeRequiredDefaultDescription
:facilitatorGenServer.server()noX402.FacilitatorFacilitator process name or pid for verify/settle calls
:hooksmodule()noX402.Hooks.DefaultLifecycle hook module implementing X402.Hooks
:payment_identifier_cacheatom() | pid() | {module, cache}nonilReplay-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_verifyno:after_verifyWhen 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_prechecksboolean()notrueCheap scheme-dispatched checks before the facilitator call; certain mismatches answer 402 without a round-trip
:local_verificationatom() | keyword()nonilInline cryptographic verification of exact-EVM payments before the facilitator verify (see "Local Verification")
:paywallmodule()nonilBrowser paywall renderer implementing X402.Paywall — see the Browser Paywall guide
:siwxkeyword()nonilSign-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_limitkeyword()nonilPer-wallet verified-payment limit; requires :limit and :window_ms
:auth_capturekeyword()nonilExplicit escrow resource and buffered handler; see Auth-capture

Important: When :payment_identifier_cache is 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

OptionTypeRequiredDescription
:methodatom()yesHTTP method (:get, :post, :put, :delete, :patch, :head, :options, :trace, or :any for all)
:pathString.t()yesRoute path. Exact matches (/api/data), glob patterns (/api/*), or templates with :param segments (/api/users/:id) — see "Path Parameters"
:accepts[map()] | (conn -> [map()])noMultiple payment options (see "Multiple Accepts" below), or a 1-arity function of the conn returning them (see "Dynamic Pricing")
:schemeString.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
:priceString.t() | (conn -> String.t())conditionallyPayment amount in atomic token units, or a 1-arity function of the conn returning one. Required when :accepts is empty
:networkString.t()conditionallyCAIP-2 network identifier (e.g. "eip155:8453")
:assetString.t()conditionallyToken contract address
:pay_toString.t() | (conn -> String.t())conditionallyRecipient wallet address, or a 1-arity function of the conn returning one
:descriptionString.t() | (conn -> String.t())noResource description (default: "Payment required"), or a 1-arity function of the conn returning one
:mime_typeString.t()noResource MIME type (default: "application/json")
:service_nameString.t()noResourceInfo.serviceName: non-empty printable ASCII, at most 32 characters (X402.Extensions.Bazaar.Metadata.valid_service_name?/1)
:tags[String.t()]noResourceInfo.tags: at most 5 unique (case-insensitive) entries, each under the :service_name rule (X402.Extensions.Bazaar.Metadata.sanitize_tags/1)
:icon_urlString.t()noResourceInfo.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_secondspos_integer()noMax payment completion time (default: 60)
:extramap()noScheme-specific extra fields
:extensionsmap()noProtocol extensions advertised in PAYMENT-REQUIRED
:bazaarkeyword()noX402.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})
end

The 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}
end

Pass 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/2 runs for every request that matches a gated route, before any payment processing. Return {:cont, context} to proceed — optionally with context.requirements or context.extensions replaced for this request (a per-caller discount, say); {:halt, {status, body}} to answer directly with the JSON-encoded body; or {:halt, :skip_payment} to run the handler unpaid. An exception or an unexpected return value fails closed with 500.
  • on_verified_payment_canceled/2 runs when a payment the facilitator already verified is not settled: the handler answered with a status of 400 or above (metadata reason: :handler_failed with :response_status), or settlement failed before a transaction was broadcast (reason: :settlement_failed with :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 verified payload and matched_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
end

A {: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:

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:

KindKey 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 elsethe 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 400 invalid_payload;
  • rejects a request that echoes no id with 400 payment_identifier_required when the advertisement set required: true;
  • surfaces a valid id as conn.assigns[:x402_payment_id], in the settlement context, and as :payment_id in 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:

  1. 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).
  2. A client signs the challenge (X402.Extensions.SIWX.sign/3) and sends the proof in a SIGN-IN-WITH-X header. The gate verifies it with X402.Extensions.SIWX.Server.authenticate/3 against the HTTP method and full resource URL.
  3. If the address has a payment record for that request, the handler runs without payment; :x402_siwx_address and :x402_siwx_chain_id are assigned and [:x402, :plug, :siwx_authenticated] is emitted. If it has none, the request continues through the normal payment flow when it also carries PAYMENT-SIGNATURE (identity and payment in one request), and otherwise receives 402 with a fresh challenge.
  4. After a successful settlement the payer (the settle response's payer, falling back to the EVM authorization's from selected by the matched scheme and transfer method, never an unsigned alternate field) is recorded for the method and resource URL through :storage for :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:

StatuserrorWhen
400invalid_siwx_headerThe header is not valid Base64 JSON, exceeds 8 KB, or lacks the required proof fields
402invalid_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_errorThe 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. :domain and :uri must be your server's configured public origin — never values derived from the request's Host header, which the caller controls. A proof is only accepted when its domain and uri equal 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 its issuedAt window (:max_age_seconds) and can be replayed within it. With one — any X402.Extensions.PaymentIdentifier.Cache adapter — 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_responses before the handler runs;
  • settle-time outcomes are attached as :extension_responses to 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:

AssignValue
:x402_payment_payloadThe decoded PaymentPayload map
:x402_payment_requirementsThe matched PaymentRequirements map
:x402_payment_idThe client's echoed payment identifier (only set when the extension was present)
:x402_extension_responsesThe facilitator's verify-time EXTENSION-RESPONSES outcomes, keyed by extension name (only set when the facilitator sent a valid sidechannel)
:x402_siwx_addressThe 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_idThe CAIP-2 chain the Sign-In-With-X proof was signed for (set together with :x402_siwx_address)
:x402_path_paramsThe 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"})
end

Payment 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:

StatusWhen
402Payment 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
400Malformed 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
409A payment-identifier id reused for a request with a different fingerprint (payment_identifier_conflict)
429A verified payment exceeded :rate_limit; Retry-After gives the wait in seconds
500Facilitator 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:

EventWhen
[: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.Metrics

The 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