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()nonilETSCache server for idempotency (strongly recommended)
:routes[map()]yesRoute gate definitions (see below)

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) or "upto"
: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"
    }
  ]
}

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.

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

Idempotency (Payment Identifier Cache)

To prevent double-settlement of the same payment proof from concurrent requests, configure an ETS cache:

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

The Plug performs an atomic put_new claim on the payment proof hash after verification and before the handler. A handler error or failed settlement releases the claim; a successful settlement retains it. If the claim fails (duplicate), the request is rejected with "payment already processed".

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

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

HTTP Status Codes

The plug follows the x402 v2 HTTP transport status mapping:

StatusWhen
402Payment required (no PAYMENT-SIGNATURE header), no matching requirements, or payment verification/settlement failed
400Malformed PAYMENT-SIGNATURE header, invalid Base64, invalid JSON, payload too large, or wrong x402Version
500Facilitator transport failure, malformed facilitator response, 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 includes %{method: atom(), path: String.t()} and for :payment_required / :payment_rejected also :route and :reason.

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