x402 has two halves: servers that require payment, and clients that pay. This
guide covers the payer side — calling an x402-protected API from Elixir and
letting the SDK handle the 402 → sign → retry dance.
How a payment happens
- Your client requests a protected resource and receives 402 Payment
Required with a
PAYMENT-REQUIREDheader describing acceptable payments. - The client picks one entry from
accepts, signs an EIP-3009TransferWithAuthorizationfor it (an off-chain signature — no gas, no transaction), and retries the request with the signed payment in aPAYMENT-SIGNATUREheader. - The server verifies and settles the payment through its facilitator and
responds with the resource, plus a
PAYMENT-RESPONSEheader containing the settlement receipt.
The SDK signs the exact scheme (EIP-3009) and the upto scheme
(Permit2) on EVM (eip155:*) networks, and the exact scheme on Solana
(solana:*) networks (see below).
Quick start with Finch
Add the optional dependencies the payer needs — finch for HTTP and
ex_secp256k1/ex_keccak for signing:
def deps do
[
{:x402, "~> 0.9.0"},
{:finch, "~> 0.19"},
{:ex_secp256k1, "~> 0.8.0"},
{:ex_keccak, "~> 0.7.8"}
]
endStart a Finch pool with TLS verification, build a signer, and make the request:
{:ok, _pid} =
Finch.start_link(
name: MyApp.Finch,
pools: %{default: X402.Facilitator.HTTP.secure_pool_opts()}
)
# A raw secp256k1 private key — load it from a secret store, never from code.
{:ok, signer} = X402.Signer.LocalKey.new(System.fetch_env!("PAYER_PRIVATE_KEY"))
{:ok, %{status: 200, body: body, payment_response: receipt}} =
X402.Client.Finch.request(MyApp.Finch, "https://api.example.com/premium-data",
signer: signer,
max_amount: "10000"
)
IO.inspect(receipt["transaction"], label: "settlement tx")request/3 performs the request; when it hits a 402 it builds, signs, and
retries once — a payment is never signed or sent twice for the same call.
Responses that do not require payment pass through untouched.
Spend controls and consent
An automated payer signs whatever a server asks for unless you cap it. Four options do, and they compose:
:max_amount— an atomic-unit ceiling per payment. Payment options above it are never selected; if nothing affordable is offered you get{:error, :no_acceptable_requirements}.:policies— selection policies (see "Policies" below).:budget— a session budget shared across requests (see "Budgets" below).:on_payment_required— a consent hook invoked with the decodedPaymentRequiredmap before anything is signed. Return:cancelto abort with{:error, :payment_cancelled}:
X402.Client.Finch.request(MyApp.Finch, url,
signer: signer,
on_payment_required: fn payment_required ->
case MyApp.Approvals.approve(payment_required["accepts"]) do
:ok -> :ok
:denied -> :cancel
end
end
)When none of :max_amount, :policies, or :budget is given,
X402.Client.Finch and X402.MCP.Client log a warning once per VM.
You can also pin the payment with :network, :scheme, and :asset filters.
Policies
A policy is a 2-arity function of a candidate accepts entry and the
decoded PaymentRequired map (nil when selecting from a bare list). It
returns true to accept the entry, false to skip it, or
{:error, reason} to abort selection with that error. Policies run last,
after the scheme and :max_amount filters, and every policy must accept
an entry for it to be selected. X402.Client.Policy ships the common ones:
alias X402.Client.Policy
X402.Client.Finch.request(MyApp.Finch, url,
signer: signer,
policies: [
Policy.max_amount("1000000"),
Policy.networks(["eip155:8453", "solana:*"]),
Policy.assets(["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"]),
Policy.schemes(["exact"]),
fn requirements, _payment_required ->
requirements["payTo"] in MyApp.trusted_receivers()
end
]
)networks/1 accepts a trailing * as a prefix wildcard; assets/1
compares case-insensitively. The same option is accepted by
X402.Client.select_requirements/2, X402.Client.build_payment/3, and
X402.MCP.Client.call/3.
Budgets
:max_amount and policies judge one payment at a time. X402.Client.Budget
caps what a client commits to across requests: a process with a hard
:limit and optional :per_asset limits, in atomic units. Start it in
your supervision tree and pass it to the drivers:
# In your supervision tree
children = [
{X402.Client.Budget,
name: MyApp.PayerBudget,
limit: "5000000",
per_asset: %{"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" => "1000000"}}
]
# Per request
X402.Client.Finch.request(MyApp.Finch, url,
signer: signer,
max_amount: "10000",
budget: MyApp.PayerBudget
)
X402.Client.Budget.spent(MyApp.PayerBudget)
#=> %{total: 10000, per_asset: %{"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" => 10000}}The driver reserves the selected amount atomically after the payload is
built and before the paid retry is sent, so concurrent requests cannot
collectively overspend; a reservation that does not fit fails the request
with {:error, {:budget_exceeded, details}} (details.scope is :total
or :asset) and nothing is sent. The reservation is released when the
payment is not accepted — a transport error on the retry, or a non-2xx
response without a successful PAYMENT-RESPONSE receipt. Everything else
counts as spent, whether or not the facilitator actually settled: the
budget is a cap on what the client has authorized, not a ledger of
on-chain transfers. reserve/3, release/3, and spent/1 are public for
transports you drive yourself.
Auth-capture is an exception to release-on-error: after dispatch, its entire maximum stays reserved on every outcome, including transport failures and renewed payment challenges. A hold or charge may already exist. Release exposure only after application reconciliation against trusted payment state. See Auth-capture on EVM for signing options and operational limits.
Lifecycle hooks
For anything beyond selection — auditing, pinning a different entry,
recovering from a signing failure — implement X402.Client.Hooks and pass
it as hooks: to X402.Client.build_payment/3,
X402.Client.Finch.request/3, or X402.MCP.Client.call/3. The callbacks
mirror the reference client's onBeforePaymentCreation,
onAfterPaymentCreation, and onPaymentCreationFailure:
defmodule MyApp.PaymentHooks do
@behaviour X402.Client.Hooks
require Logger
@impl true
def before_payment(context, _metadata) do
# context.requirements is the selected entry; replace it or halt.
if context.requirements["payTo"] in MyApp.trusted_receivers(),
do: {:cont, context},
else: {:halt, :untrusted_receiver}
end
@impl true
def after_payment(context, metadata) do
Logger.info("signed payment", scheme: metadata.scheme, network: metadata.network)
{:cont, context}
end
@impl true
def on_payment_failure(context, _metadata), do: {:cont, context}
endbefore_payment/2runs after an entry is selected and before anything is signed.{:cont, context}continues — withcontext.requirementsreplaced, if you changed it;{:halt, reason}aborts with{:error, {:hook_halted, :before_payment, reason}}.after_payment/2runs once the payload is signed and enriched;context.payloadmay be replaced and becomes the returned payload.on_payment_failure/2runs when signing or enrichment fails;{:cont, context}continues the failure (withcontext.errorpossibly replaced) and{:recover, payload}turns it into{:ok, payload}.
Selection failures (:no_acceptable_requirements) happen before any hook
runs. A callback that raises yields
{:error, {:hook_callback_failed, callback, reason}}, and one returning
outside its contract {:error, {:hook_invalid_return, callback, value}}.
Bring your own HTTP client
X402.Client is pure — no processes, no HTTP. Use it with Req, Tesla,
httpc, or anything else:
alias X402.{Client, PaymentRequired}
# 1. You made a request and got a 402 with a PAYMENT-REQUIRED header:
{:ok, payment_required} = PaymentRequired.decode(header_value)
# 2. Build and sign the payment:
{:ok, payload} = Client.build_payment(payment_required, signer, max_amount: "10000")
{:ok, header} = Client.encode_payment(payload)
# 3. Retry the request with {"payment-signature", header}.Client.select_requirements/2 is also public if you want to inspect or
choose the payment option yourself before signing.
exact payments through Permit2
The exact-EVM scheme defines several asset transfer methods, selected by
the requirements' extra.assetTransferMethod. Absent (or "eip3009")
means the default EIP-3009 TransferWithAuthorization flow shown above.
When a server advertises "permit2" — for tokens without EIP-3009
support — build_payment/3 signs a Permit2 PermitWitnessTransferFrom
instead, with no change to your code:
# One entry of the 402's `accepts`:
%{
"scheme" => "exact",
"network" => "eip155:8453",
"amount" => "10000",
"asset" => "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"payTo" => "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
"maxTimeoutSeconds" => 300,
"extra" => %{"assetTransferMethod" => "permit2"}
}
{:ok, payload} = X402.Client.build_payment(payment_required, signer)
payload["payload"]
#=> %{
# "signature" => "0x...",
# "permit2Authorization" => %{
# "from" => "0x<payer>",
# "permitted" => %{"token" => <the requirements' "asset">, "amount" => "10000"},
# "spender" => "0x402085c248EeA27D92E8b30b2C58ed07f9E20001",
# "nonce" => "0x...",
# "deadline" => "1789...",
# "witness" => %{"to" => "0x2096...287C", "validAfter" => "0"}
# }
# }The scheme payload carries permit2Authorization rather than
authorization. Its spender is the x402ExactPermit2Proxy
(X402.Permit2.exact_proxy_address/0), the only contract able to consume
the permit; the witness binds the server's payTo; permitted.amount is
the exact amount that will be settled; and the permit is valid immediately
and expires after maxTimeoutSeconds. The EIP-712 domain is the canonical
Permit2 domain, so — unlike EIP-3009 — the requirements need no
extra.name / extra.version. As with upto, the payer must have
approved the canonical Permit2 contract for the token once (see the
gas-sponsoring extensions for
facilitator-funded alternatives), and X402.Permit2.sign_exact/2 is
available if you want to sign without going through the client.
Any other transfer method is rejected: an accepts entry declaring
"erc7710" is not signable (X402.Scheme.ExactEVM.transfer_method/1
returns {:error, {:unsupported_transfer_method, "erc7710"}}), so
selection skips it in favour of another entry — or fails with
{:error, :no_acceptable_requirements} when it is the only one — and
signing such requirements directly returns the
{:unsupported_transfer_method, _} error.
Metered upto payments
For variable-cost resources (LLM tokens, bandwidth, compute), servers
advertise the upto scheme: the client authorizes a maximum amount
and the server settles for the actual usage, up to that ceiling. The
SDK signs upto requirements out of the box — build_payment/3 (and
X402.Client.Finch.request/3) picks them up like any other entry, with
:max_amount guarding the ceiling you are willing to authorize:
{:ok, payload} =
X402.Client.build_payment(payment_required, signer,
scheme: "upto",
max_amount: "5000000"
)Under the hood the client signs a Permit2 PermitWitnessTransferFrom
(X402.Permit2) against the canonical Permit2 contract, with the
requirements' amount as permitted.amount (the ceiling) and a witness
binding the server's payTo and the facilitator's address, so only that
facilitator can settle it. Two things to know:
extra.facilitatorAddressis required. Facilitators announce their address viaGET /supported(X402.Facilitator.supported/1) and resource servers forward it in each upto entry'sextra. Entries without it are never selected, and signing one directly returns{:error, {:missing_extra, "facilitatorAddress"}}.- Permit2 needs a one-time on-chain approval. The payer's wallet
must have approved the canonical Permit2 contract for the token once
(
approve(Permit2, ...)); see the gas-sponsoring extensions below for facilitator-funded alternatives.
The signed maximum is not what you pay — the server meters actual usage and settles for less (or nothing). See the Plug/Phoenix Integration guide for the server half.
Gas-sponsoring extensions
Permit2-based payments need a one-time on-chain approve(Permit2, ...)
from the payer's wallet — which costs gas the wallet may not have. Two x402
extensions let the facilitator sponsor that approval; when a server
advertises them in its PAYMENT-REQUIRED extensions, the client can attach
the corresponding data through build_payment/3's :extensions option
(also accepted by X402.Client.Finch.request/3).
EIP-2612 tokens (X402.Extensions.EIP2612GasSponsoring): the client
signs an off-chain EIP-2612 Permit authorizing the canonical Permit2
contract, and the facilitator submits it on-chain, paying the gas. The SDK
signs the permit itself — you only supply the owner's current EIP-2612
nonce (read from the token contract's nonces(owner); the SDK has no
chain access):
alias X402.Extensions.EIP2612GasSponsoring
{:ok, payload} =
X402.Client.build_payment(payment_required, signer,
extensions: [EIP2612GasSponsoring.enricher(signer, nonce: "0")]
)Plain ERC-20 tokens (X402.Extensions.ERC20ApprovalGasSponsoring):
tokens without EIP-2612 have no gasless approval, so the client signs — but
does not broadcast — a normal approve(Permit2, amount) transaction, and
the facilitator funds the wallet's gas if needed, broadcasts it, and
settles atomically. Signing that transaction needs the wallet's live
on-chain nonce and network fees, so it happens outside the SDK; the
enricher wraps the pre-signed transaction in the extension data:
alias X402.Extensions.ERC20ApprovalGasSponsoring
{:ok, payload} =
X402.Client.build_payment(payment_required, signer,
extensions: [
ERC20ApprovalGasSponsoring.enricher(
from: wallet_address,
signed_transaction: signed_approve_tx_hex
)
]
)Both enrichers are no-ops when the server did not advertise the extension,
and both preserve the server's echoed declaration — client data is only
added alongside it, per the spec's append-only rule. Resource servers
declare support with build_extension/0 and validate a client's echoed
data with extract_info/1 and validate_info/1 on either module.
Payment identifiers
The payment-identifier extension
makes a payment idempotent from the client's side: you attach an id you
generated, and a server that sees it again treats the request as the same
one (a retry after a lost response is not charged twice, and the same id
can never be reused for a different request — the server answers 409).
X402.Extensions.PaymentIdentifier.enricher/1 attaches it through the
same :extensions option:
alias X402.Extensions.PaymentIdentifier
{:ok, payload} =
X402.Client.build_payment(payment_required, signer,
extensions: [PaymentIdentifier.enricher()]
)
payload["extensions"]["payment-identifier"]["info"]["id"]
#=> "k3Jm9ZQvT2xW8bNcRfLpHsD4aY7eUqGi"Each invocation generates a fresh X402.Extensions.PaymentIdentifier.generate_id/0
(32 URL-safe characters); pass id: my_id to reuse one across retries —
that is the point of the extension — provided it is 16 to 128 characters
of [A-Za-z0-9_-] (valid_id?/1), or the enricher returns
{:error, :invalid_payment_id}. The enricher echoes the server's
advertisement (info.required and the schema) unchanged and only adds
info.id; it is a no-op when the server did not advertise the extension
unless you pass always: true. A server that advertised
required: true rejects payments without an id.
Which requirements the client selects
build_payment/3 picks the first accepts entry it can sign, in the
server's order, filtered by :network and :scheme. Entries whose
extra.paymentFlow names a flow the client cannot run are skipped: only
the default "authorization" flow (explicit or omitted) is recognized —
upfront and escrow commit funds before the resource executes and the
protocol forbids constructing a payment for a flow you do not implement. A
PAYMENT-REQUIRED offering only such entries yields
{:error, :no_acceptable_requirements}.
Builder codes
The builder-code extension
carries ERC-8021 attribution codes that the facilitator encodes into the
settlement transaction's calldata. A server advertises its app code under
extensions["builder-code"]; a client echoes it and attaches up to 5
service codes of its own through
X402.Extensions.BuilderCode.enricher/1, on the same :extensions option:
alias X402.Extensions.BuilderCode
{:ok, payload} =
X402.Client.build_payment(payment_required, signer,
extensions: [BuilderCode.enricher(service_codes: "my_client")]
)
payload["extensions"]["builder-code"]["info"]
#=> %{"a" => "my_app", "s" => ["my_client"]}Codes match ^[a-z0-9_]{1,32}$; the enricher raises on a malformed one.
When the server advertised the extension, its info (including a) and
schema are echoed unchanged and your codes are prepended to any server
codes, client first and deduplicated, as the reference client merges
them. When it did not, only %{"info" => %{"s" => codes}} is attached —
never an a — as the spec's client behaviour prescribes; pass
always: false to make the enricher a no-op for such servers instead.
The resource server validates the echo (a changed a is rejected) and
forwards the codes to the facilitator.
Signing in with X
When a server advertises the
sign-in-with-x extension
under extensions["sign-in-with-x"] of its 402, a wallet that already
paid for the resource can come back and prove its identity instead of
paying again. X402.Client.Finch.request/3 (and X402.MCP.Client.call/3)
drive it automatically with the :siwx option:
{:ok, %{status: 200, siwx_authenticated: true}} =
X402.Client.Finch.request(MyApp.Finch, "https://api.example.com/premium-data",
signer: signer,
max_amount: "10000",
siwx: [chain_id: :auto]
)With :siwx set, a 402 that advertises a challenge is answered before
anything is paid: the client signs the challenge
(X402.Client.SIWX.authenticate/4) and retries the request with the
SIGN-IN-WITH-X header and no payment. A response that is not another
402 comes back as-is, with siwx_authenticated: true for a 2xx — the
server remembered your address. A second 402 means the address has not
paid yet (or its record expired): the normal payment flow continues from
that response, and the paid request carries a proof for the new
challenge so the server records the payer for next time. The response's
siwx_authenticated is false on that path.
The option is a keyword list of X402.Client.SIWX options:
chain_id:(required) — the CAIP-2 chain to sign for, or:autoto pick the first entry of the challenge'ssupportedChainsthe signer can sign (EVM signers map toeip155:*, Solana signers tosolana:*).domain:— the challengedomainyou expect. Defaults to the resource URL's host on HTTP; required for MCP, where there is no URL.address:— the address placed in the proof (the signer's when omitted).signature_scheme:— an optionalsignatureSchemehint copied into the proof.
A challenge whose domain or uri is not bound to the resource's origin
is refused ({:error, {:siwx, :domain_mismatch}} /
{:siwx, :uri_mismatch}), as is one listing no chain the signer can sign
({:siwx, :unsupported_chain}). Domain matching ignores host case, but
does not ignore port differences. Direct authenticate/4 calls also
require a trusted domain: or resource_url:; neither may be inferred
from the untrusted challenge. Every attempt emits
[:x402, :client, :siwx] with :transport, :chain_id, and :outcome
(:authenticated or :payment_required) — or :reason on error.
Signing a challenge yourself
With another HTTP client, or to control the flow, the underlying pieces are three calls:
alias X402.Extensions.SIWX
{:ok, payment_required} = X402.PaymentRequired.decode(header_value)
challenge = payment_required["extensions"]["sign-in-with-x"]
{:ok, signed} = SIWX.sign(challenge, signer, chain_id: "eip155:8453")
{:ok, header} = SIWX.encode_signed(signed)
Finch.build(:get, url, [{"sign-in-with-x", header}])
|> Finch.request(MyApp.Finch)sign/3 copies the challenge's fields (domain, uri, nonce,
issuedAt, expirationTime, ...) into the proof, adds your signer's
address and the chain, builds the CAIP-122 message, and signs it — EIP-4361
text with EIP-191 personal_sign on eip155:* chains
(X402.Signer.sign_message/2, implemented by X402.Signer.LocalKey), or
Sign-In-With-Solana text with Ed25519 on solana:* chains
(X402.Signer.sign_ed25519/2, implemented by X402.Signer.SolanaKey).
The :chain_id must be one the challenge's supportedChains lists
({:error, :unsupported_chain} otherwise). Send the encoded proof in the
SIGN-IN-WITH-X header of the next request: the server serves it without
payment if the address paid before, or answers 402 with a fresh challenge
(you may send PAYMENT-SIGNATURE in the same request to sign in and pay
at once). Challenges are single-use — sign the one from the latest 402,
not a cached copy.
Custom EVM signers implement the optional X402.Signer.sign_message/2
callback to support this; signers without it return
{:error, :unsupported_signer}.
Paying on Solana (SVM)
The client also signs the exact scheme on solana:* networks out of the
box, following the x402 SVM scheme specification: it builds a v0 Solana
transaction — compute budget instructions, an SPL Token / Token-2022
TransferChecked to the Associated Token Account derived from the server's
payTo and asset, and a Memo for transaction uniqueness — signs it with
the payer's Ed25519 key, and leaves the fee payer's signature slot empty.
The server's sponsor (extra.feePayer, required in the advertised
requirements) verifies and co-signs at settlement, so the payer never pays
network fees. On-chain verification and settlement stay with the
facilitator; the signing path never talks to a Solana RPC node. The SDK
can also be that facilitator — see
Run Your Own Facilitator.
{:ok, signer} = X402.Signer.SolanaKey.new(System.fetch_env!("SOLANA_PAYER_KEY"))
{:ok, payload} =
X402.Client.build_payment(payment_required, signer,
network: "solana:*",
# Only needed when the server's 402 does not include an
# extra.recentBlockhash hint: bring a blockhash yourself...
svm_blockhash: recent_blockhash
# ...or let the client fetch one on demand — see :svm_blockhash_fetcher
# below.
)X402.Signer.SolanaKey.new/1 accepts a raw 32-byte Ed25519 seed, a
64-byte solana-keygen keypair, or the Base58/Base64 encoding of either.
Signing uses OTP's :crypto — no extra dependencies.
Two option pairs matter for less common setups:
- Blockhash — servers should advertise
extra.recentBlockhashin their requirements (it saves the client an RPC round-trip); when they do, no option is needed. Otherwise pass:svm_blockhash, or:svm_blockhash_fetcher— a 1-arity fun receiving the CAIP-2 network and returning{:ok, blockhash}. - Asset metadata —
TransferCheckedneeds the mint's decimals and owning token program. Well-known stablecoins (USDC, USDT, USDG, PYUSD, CASH on mainnet/devnet/testnet) are built in; for other mints pass:svm_decimalsand:svm_token_program.
X402.Solana.RPC.get_latest_blockhash/2 — Solana JSON-RPC over an
X402.RPC endpoint — makes a ready-made fetcher:
{:ok, rpc} =
X402.RPC.new(rpc_url: "https://api.mainnet-beta.solana.com", finch: MyApp.Finch)
{:ok, payload} =
X402.Client.build_payment(payment_required, signer,
network: "solana:*",
svm_blockhash_fetcher: fn _network ->
with {:ok, %{blockhash: blockhash}} <- X402.Solana.RPC.get_latest_blockhash(rpc) do
{:ok, blockhash}
end
end
)Custom Solana signers implement the optional
X402.Signer.sign_ed25519/2 callback instead of sign_eip712/3 — see
below.
Custom signers
X402.Signer.LocalKey holds a raw private key in memory — fine for testing
and low-value automation. For production payers implement the X402.Signer
behaviour over your KMS, hardware wallet, or signing service:
defmodule MyApp.KMSSigner do
@behaviour X402.Signer
defstruct [:key_id, :address]
@impl true
def address(%__MODULE__{address: address}), do: {:ok, address}
@impl true
def sign_eip712(%__MODULE__{key_id: key_id}, digest, _typed_data) do
# Ask the KMS to sign the 32-byte digest; return the 65-byte r || s || v
# signature. Implementations that can only sign full EIP-712 typed data
# can use the third argument instead of the digest.
MyApp.KMS.sign(key_id, digest)
end
endAnything that returns {:ok, address} and {:ok, signature} plugs into
X402.Client.build_payment/3 and X402.Client.Finch.request/3 unchanged.
The chain-family callbacks are optional: implement sign_eip712/3 for EVM
payments, sign_ed25519/2 (a 64-byte Ed25519 signature over the Solana
transaction message bytes) for SVM payments, or both. A scheme asked to
sign with a signer that lacks its callback returns
{:error, :unsupported_signer}.
Telemetry
The client emits [:x402, :client, :select], [:x402, :client, :sign],
[:x402, :client, :build], and [:x402, :client, :request] events with a
:status of :ok or :error, and [:x402, :client, :siwx] when it
answers a Sign-In-With-X challenge — see X402.Telemetry.