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()]yesRoute 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

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) or glob patterns (/api/*)
:accepts[map()]noMultiple payment options (see "Multiple Accepts" below)
: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()conditionallyPayment amount in atomic token units. Required when :accepts is empty
:networkString.t()conditionallyCAIP-2 network identifier (e.g. "eip155:8453")
:assetString.t()conditionallyToken contract address
:pay_toString.t()conditionallyRecipient wallet address
:descriptionString.t()noResource description (default: "Payment required")
:mime_typeString.t()noResource MIME type (default: "application/json")
:service_nameString.t()noService name for display (max 32 chars recommended)
:tags[String.t()]noResource tags (max 5 recommended)
:icon_urlString.t()noAbsolute URL to a service icon
:max_timeout_secondspos_integer()noMax payment completion time (default: 60)
:extramap()noScheme-specific extra fields
:extensionsmap()noProtocol extensions advertised in PAYMENT-REQUIRED

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 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.

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: [...]

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.

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

Separate from replay protection, a client may echo the payment identifier extension under extensions["paymentIdentifier"] in its payment payload. The gate decodes it — both the bare form and the %{"info" => ...} envelope (X402.Extensions.PaymentIdentifier) — and rejects a malformed one (bad Base64, invalid JSON, missing paymentId) with 400. A valid identifier is surfaced for correlation:

  • conn.assigns[:x402_payment_id] in the protected handler,
  • the settlement context the gate carries into its before-send settlement,
  • the [:x402, :plug, :payment_verified] telemetry metadata, as :payment_id.

It is deliberately not the deduplication key: paymentId is client-controlled and covered by no signature, so building replay protection on it would let a replayer mint fresh keys at will. The replay key comes from the signed content above; the payment identifier is for tracing a payment through your logs and the client's.

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)

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, or facilitator verification/settlement failure
400Malformed PAYMENT-SIGNATURE header, invalid Base64, invalid JSON, payload too large, wrong x402Version, a scheme payload validation failure, or a malformed payment identifier extension
500Facilitator transport failure, malformed facilitator response, local-verification infrastructure failure (missing dependency, RPC error, chain-id mismatch), 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
[: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, etc.)

Metadata always includes :method and :path. Every event except :pass_through adds :route; :payment_rejected adds :reason; and :payment_verified adds :payment_id when the client echoed a payment identifier extension.

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