Custom Payment Schemes

Copy Markdown View Source

Everything scheme-specific in this library — client signing, incoming payload validation, and the payment gate's local pre-checks — dispatches through the X402.Scheme behaviour. Supporting a new chain or payment scheme means writing one module and passing it as an option; no core module needs to change.

The built-ins cover exact (X402.Scheme.ExactEVM) and upto (X402.Scheme.UptoEVM) on EVM (eip155:*) networks, and exact (X402.Scheme.ExactSVM) on Solana (solana:*) networks. Resolution — exact CAIP-2 matches beating wildcards, user modules beating built-ins — is handled by X402.Scheme.Registry.

Writing a scheme module

Implement X402.Scheme. Only X402.Scheme.scheme/0 and X402.Scheme.networks/0 are required; implement the callbacks for the roles your scheme plays. A fourth optional callback, X402.Scheme.signable?/1, lets X402.Client.select_requirements/2 skip advertised entries the module cannot sign (the built-in upto scheme uses it to skip entries missing extra.facilitatorAddress):

defmodule MyApp.SolanaExact do
  @behaviour X402.Scheme

  @impl X402.Scheme
  def scheme, do: "exact"

  @impl X402.Scheme
  def networks, do: ["solana:*"]

  # Client role: build the scheme payload carried as PaymentPayload.payload.
  @impl X402.Scheme
  def sign(requirements, signer, _opts) do
    with {:ok, transaction} <- build_and_sign_transaction(requirements, signer) do
      {:ok, %{"transaction" => transaction}}
    end
  end

  # Server role: structural validation of incoming PAYMENT-SIGNATURE payloads.
  @impl X402.Scheme
  def validate_payload(payload, _requirements, _opts) do
    case get_in(payload, ["payload", "transaction"]) do
      transaction when is_binary(transaction) -> :ok
      _missing -> {:error, {:invalid_scheme_payment, :missing_transaction}}
    end
  end

  # Server role: cheap local pre-checks before the facilitator round-trip.
  @impl X402.Scheme
  def precheck(_payload, _requirements, _opts), do: :ok

  defp build_and_sign_transaction(_requirements, _signer), do: {:error, :not_implemented}
end

(exact on solana:* now ships built-in as X402.Scheme.ExactSVM — the sketch above shows the shape such a module takes. A user module listed in :schemes is consulted before the built-ins, so registering your own overrides it.)

Registering it

There is no global registry and no application environment — scheme modules are passed explicitly wherever you use the SDK, so two gates (or two clients) in the same VM can support different scheme sets:

# Resource server: routes may now use the scheme on its networks.
plug X402.Plug.PaymentGate,
  schemes: [MyApp.SolanaExact],
  routes: [
    %{
      method: :get,
      path: "/api/*",
      scheme: "exact",
      price: "10000",
      network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
      asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
      pay_to: "CKPKJWNdJEqa81x7CkZ14BVPiY6y16Sxs7owznqtWYp5"
    }
  ]

# Payer client (also available on X402.Client.Finch.request/3).
X402.Client.build_payment(payment_required, signer, schemes: [MyApp.SolanaExact])

# Standalone header validation.
X402.PaymentSignature.validate(payload, requirements, schemes: [MyApp.SolanaExact])

Kinds with no registered module keep their historical neutral behavior: validation passes through with :ok, the gate skips pre-checks (the facilitator remains the authority), and the client returns {:error, {:unsupported_kind, scheme, network}}.

Replay keys for custom schemes

X402.Plug.PaymentGate's replay protection derives canonical, signature-covered replay keys only for the built-in kinds (exact and upto on eip155:*, exact on solana:*). Every other kind — custom schemes included — falls back to hashing the raw PAYMENT-SIGNATURE header, so only byte-identical replays are deduplicated: re-encoding the same signed proof (different JSON key order, whitespace, or Base64 variant) mints a distinct key, and catching such duplicates is left to the facilitator.

See the X402.Scheme module documentation for the full callback reference and error conventions.