Plug middleware that gates configured routes behind x402 v2 payment verification.
For matching routes:
- Requests without a
PAYMENT-SIGNATUREheader receive 402 with a Base64-encodedPAYMENT-REQUIREDheader (PaymentRequiredv2 schema). - Requests with
PAYMENT-SIGNATUREare decoded asPaymentPayloadv2. PaymentPayload.acceptedand echoed extensions are matched against the complete requirements advertised by the route.- Cheap local pre-checks run for the matched scheme/network — resolved
through
X402.Scheme.Registry— so certain mismatches answer 402 without a facilitator round-trip. The built-in EVM schemes check the EIP-3009-style authorization object (payTo binding, exact amount equality, validity window); see the:local_prechecksoption. Kinds with no registered scheme module skip straight to the facilitator. - Matched requirements are verified before the protected handler runs —
optionally preceded by inline local verification through
X402.Verify.EVM(see the:local_verificationoption). - Successful handler responses are settled immediately before they are
sent. A
settlement_pendingsettle failure that already carries a transaction hash is retried once, so the facilitator's pending store can reconcile — mirroring the reference resource servers. Successful settlements attach aPAYMENT-RESPONSEheader and assign:x402_payment_payload/:x402_payment_requirementson the conn. When the facilitator reports extension outcomes through theEXTENSION-RESPONSESsidechannel (seeX402.ExtensionResponses), the verify-time outcomes are assigned as:x402_extension_responsesand settle-time outcomes are attached to the[:x402, :plug, :payment_verified]telemetry metadata; the sidechannel is never forwarded to the buyer.
HTTP status mapping (HTTP transport v2):
- 402 — payment required, no matching requirements, or payment failed
- 400 — malformed / invalid payment payload (including wrong
x402Version) - 409 — a
payment-identifierid reused for a different request - 429 — the payer exceeded the configured
:rate_limit(withRetry-After) - 500 — facilitator transport failures or malformed facilitator responses
See the official x402 v2 specification and HTTP transport.
Route patterns and path parameters
A route :path is matched against the decoded request path in one of
three ways:
- an exact path (
/api/report); - a
*glob (/api/*), where*matches any run of characters; - a template with
:paramsegments (/api/users/:id), where each parameter matches one non-empty path segment. The captured values are assigned as:x402_path_params(%{"id" => "42"}) on every gated request, paid or not.
Parameter matching preserves Plug's script_name ++ path_info segment
boundaries while decoding each segment once. An encoded slash such as
r%2F1 remains one captured value ("r/1"), not an extra path component.
The advertised resource.url is always the concrete request URL. With
a :bazaar route option (a keyword list of
X402.Extensions.Bazaar.build_extension/1 options), the 402 response
additionally advertises the discovery extension under
extensions["bazaar"]; for :param routes it carries the route's
template as the top-level routeTemplate catalog key and the captured
values as info.input.pathParams, per the bazaar spec:
%{
method: :get,
path: "/api/users/:id",
price: "10000",
...,
bazaar: [method: :get, output: [type: "json"]]
}Dynamic pricing
The route fields :price, :pay_to, :description, and :accepts
(as well as :price and :pay_to inside each :accepts entry) may be
1-arity functions of the Plug.Conn. They are evaluated on every gated
request — once, before the 402 advertisement 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 ->
if conn.assigns.x402_path_params["format"] == "pdf", do: "20000", else: "10000"
end,
network: "eip155:8453",
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
pay_to: "0x1111111111111111111111111111111111111111"
}The paying request evaluates the functions again on its own conn; the
resulting requirements are what the client's echoed accepted must
match. A function returns the plain value or {:ok, value}; returning
{:error, reason} (or an invalid value) answers 500 and emits
[:x402, :plug, :payment_rejected] with
reason: {:dynamic_route_error, reason} — the reason never reaches the
client. Dynamic values are validated like their static counterparts
(atomic-unit amounts, known schemes, the authorization payment flow).
Lifecycle hooks
Beyond the facilitator hooks (X402.Hooks before_* / after_* /
on_*_failure), the :hooks module may define two optional
resource-server callbacks:
X402.Hooks.on_protected_request/2runs for every request that matches a gated route, before any payment processing, with anX402.Hooks.RequestContextcarrying the conn, the matched route, the resolved requirements, and the extensions about to be advertised. It may continue (optionally replacingrequirementsorextensionsfor this request — a per-caller discount, say), halt with{:halt, {status, body}}(the JSON-encoded body is sent as-is), or halt with{:halt, :skip_payment}to let the handler run unpaid, which emits[:x402, :plug, :pass_through]withreason: :hook_skipped.X402.Hooks.on_verified_payment_canceled/2runs when a payment the facilitator verified is not settled: the handler answered with a status of 400 or above (reason: :handler_failed, with:response_status), or settlement failed before a transaction was broadcast (reason: :settlement_failed, with:error). A handler that raises does not reach the gate's before-send callback; when an error handler renders a 500 for it the callback runs with:handler_failed.
Extension adapters
The :extensions option takes X402.Extension adapters —
[module | {module, opts}] — that advertise, validate, and observe one
protocol extension each:
plug X402.Plug.PaymentGate,
routes: [...],
extensions: [
{X402.Extensions.PaymentIdentifier.Adapter, required: true},
{X402.Extensions.BuilderCode.Adapter, app_code: "my_app"}
]Adapter advertisements are merged over each route's static
extensions map on every 402, validate/3 runs after the generic
echo check (a failure answers 400 invalid_payload with reason
{:extension_invalid, key, reason}), and after_verify/4 /
after_settle/4 are notified with the facilitator's results.
Sign-In-With-X keeps its dedicated :siwx option (see below) and
bazaar discovery its :bazaar route option; both compose with
adapters.
Builder code
Routes may advertise the
builder-code extension
— statically through X402.Extensions.BuilderCode.extension/2 or with
the adapter above. Whether or not it is advertised, a payload that
echoes extensions["builder-code"] is checked with
X402.Extensions.BuilderCode.validate_echo/2: malformed codes and more
than ten service codes answer 400 invalid_payload (reason
{:invalid_builder_code, detail}), and an app code that differs from
the advertised one is an :extension_echo_mismatch (400). The
codes are then forwarded to the facilitator inside the payload, which
encodes them into the settlement calldata.
Browser paywall
With paywall: X402.Paywall.Default (or any X402.Paywall
implementation), pre-handler 402 responses to browser page loads —
Accept containing text/html and User-Agent containing Mozilla,
the heuristic shared by the reference x402 middlewares — carry a
human-usable HTML page instead of the {} JSON body. The
PAYMENT-REQUIRED header is identical on both forms and all other
responses are unchanged. See the "Browser Paywall" guide.
Sign-In-With-X
With :siwx configured (a keyword list of
X402.Extensions.SIWX.Server.new/1 options), the gate implements the
sign-in-with-x extension
so a wallet that already paid can skip payment:
plug X402.Plug.PaymentGate,
routes: [...],
siwx: [
domain: "api.example.com",
uri: "https://api.example.com",
supported_chains: [%{chain_id: "eip155:8453"}],
nonce_cache: MyApp.SIWXNonces,
storage: {X402.Extensions.SIWX.ETSStorage, MyApp.SIWXStorage}
]- Every 402 response advertises a fresh challenge under
extensions["sign-in-with-x"](new nonce and timestamps each time). Because it changes per response it is exempt from the extension echo check. - A request carrying a
SIGN-IN-WITH-Xheader is verified byX402.Extensions.SIWX.Server.authenticate/3against the HTTP method and full resource URL. When the address has a payment record for that request the handler runs without payment,:x402_siwx_addressand:x402_siwx_chain_idare assigned, and[:x402, :plug, :siwx_authenticated]is emitted. When it has none, the request proceeds through the normal payment flow if it also carriesPAYMENT-SIGNATURE, and otherwise receives 402 with a fresh challenge. A proof that fails verification receives 402 with the spec'sinvalid_siwx_*code as theerrorstring (telemetryreason: {:siwx, code}); a header that cannot be decoded receives 400invalid_siwx_header. - After a successful settlement the payer (the settle response's
payer, falling back to the authorization'sfrom) is recorded for the method and resource URL through the configured:storage, for:ttl_ms.
Access keys have the form "GET https://api.example.com/resource?item=1".
The full URL, including origin, port, raw path and query, remains part
of the key: those fields can identify different paid resources. A GET
payment never grants POST access, even when both match an :any route.
Configure trusted proxy URL rewriting before this gate. Old URL-only
records are not accepted as method-scoped grants.
The deprecated pre-0.7.0 {message, signature} header format is still
accepted; each one emits [:x402, :siwx, :legacy] and the first logs a
warning. Configure a :nonce_cache in production: without it a proof
can be replayed within its issuedAt window.
Replay protection
When :payment_identifier_cache is configured, the gate claims a
canonical replay key for the payment proof through the
X402.Extensions.PaymentIdentifier.Cache behaviour before settling. The
key is derived from signature-covered content, so re-encoding the same
signed authorization (JSON key order, whitespace, Base64 variant) cannot
mint a fresh key:
"exact"oneip155:*— the EIP-3009 authorization'sfrom+nonce, or for the Permit2 transfer method (extra.assetTransferMethod"permit2") the Permit2 authorization'sfrom+nonce"upto"oneip155:*— the Permit2 authorization'sfrom+nonce(Permit2 nonces are per owner across spenders, so an exact-Permit2 and an upto authorization sharing owner and nonce share one key)"exact"onsolana:*— the SHA-256 of the transaction's signed message bytes (immune to the mutable fee-payer signature slot)- everything else — the SHA-256 hash of the raw
PAYMENT-SIGNATUREheader, prefixed so families cannot collide. Re-encodings of the same proof are distinct keys here, exactly as before canonical keys existed.
The key is never derived from client-controlled unsigned fields — in particular not from the payment identifier extension's id: a replayer could vary it to mint a fresh key and bypass deduplication, or squat another payment's id to deny it service. Duplicate proofs are rejected with 402 and the claim is released when the protected handler responds with a status >= 400 or settlement fails, so clients may retry a payment whose resource was never delivered.
Payment identifier
Routes may advertise the
payment-identifier extension
through X402.Extensions.PaymentIdentifier.extension/1:
extensions: %{"payment-identifier" => X402.Extensions.PaymentIdentifier.extension(required: true)}The id the client echoes under extensions["payment-identifier"]["info"]["id"]
is validated (16–128 characters of [A-Za-z0-9_-], otherwise 400
invalid_payload), assigned as :x402_payment_id, and attached as
:payment_id to the [:x402, :plug, :payment_verified] telemetry
metadata. When the advertisement sets required: true and no id is
echoed, the request is rejected with 400 payment_identifier_required.
With :payment_identifier_cache configured, the id is additionally
bound to a request fingerprint
(X402.Extensions.PaymentIdentifier.fingerprint/2 over the matched
scheme, network, asset, amount, payTo, HTTP method, and path) under a
"pid:"-prefixed cache key. Reusing an 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, settlement failure, verification
failure), so the id may be retried. Without a cache the binding is
skipped and only the validity and required checks apply.
The pre-0.7.0 "paymentIdentifier" format (a Base64 JSON
{"paymentId": ...} string or a %{"paymentId" => ...} map, optionally
wrapped in %{"info" => ...}) is still accepted, with the same assign,
telemetry, and fingerprint binding, but is deprecated and removed in
1.0.0: each legacy id emits [:x402, :payment_identifier, :legacy] and
the first one logs a warning. Legacy ids satisfy required: true and are
not subject to the spec's length and character rules.
The :claim_order option controls when the claim is taken relative to
facilitator verification:
:after_verify(default) — the claim is taken only after the facilitator has verified the proof. 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— the claim is taken before contacting the facilitator and released again if verification fails for any reason. Duplicates are rejected locally without any facilitator call, which sheds replay-storm load. The trade-off: a node that crashes between claiming and releasing (now including the verify round-trip window) strands the claim until the cache TTL expires, so a legitimate retry of that same payment is rejected with 402 until then.
Both orderings keep the existing release semantics: the claim is released when the handler responds with a status >= 400 or settlement fails, and a duplicate claim is rejected with the same 402 duplicate-payment error.
Clustered deployments can serve one payment twice
The default X402.Extensions.PaymentIdentifier.ETSCache adapter is
per-node: every node in a cluster keeps its own claim table, so a
replayed proof load-balanced onto two nodes runs the protected handler on
each of them even though only one settlement can ultimately succeed. If
you deploy more than one node, configure a shared-store adapter instead —
see the "Writing a distributed adapter" section in
X402.Extensions.PaymentIdentifier.Cache for a Redis sketch.
Rate limiting
With :rate_limit configured, each verified payment counts one hit
against its payer before settlement, bounding how many payments a wallet
can spend on the gated routes per window:
plug X402.Plug.PaymentGate,
routes: [...],
rate_limit: [limit: 60, window_ms: 60_000]| Option | Default | Meaning |
|---|---|---|
:limit | required | Hits allowed per window |
:window_ms | required | Window length in milliseconds |
:key | :payer | :payer, :ip, or a 1-arity function of the request context |
:store | X402.RateLimiter.ETS | X402.RateLimiter module or {module, ref} |
:on_error | :allow | :allow or :deny when the store fails |
The limiter runs after verification (local and facilitator) succeeded and before the resource handler and settlement. The payer of an unverified payload is whatever the sender typed, so counting it earlier would let anyone exhaust a victim wallet's allowance with forged proofs; enforcing after verification means only proofs the wallet actually signed count against it. The trade-off is that over-limit requests still cost a verify round-trip — pair the gate with an edge/IP limiter to shed unauthenticated floods. A denied payment is never settled and its replay claim is released, so the same proof can be retried once the window rolls over.
:payer keys on the payer the facilitator reported (payer in the
verify response) or, failing that, the authorization.from /
permit2Authorization.from of the verified payload (lower-cased for
EVM addresses), and falls back to the remote IP when neither names
one; a :key function receives
%{conn: conn, payer: payer, payment_payload: payload, requirements: requirements}
and may return nil to exempt the request. Allowed requests get an
:x402_rate_limit assign (%{key: key, remaining: n, limit: limit, window_ms: window_ms}); denied requests answer 429 with the usual
PAYMENT-REQUIRED header (error rate_limited), a Retry-After
header in whole seconds (rounded up), and emit
[:x402, :plug, :rate_limited] with :payer, :key, :retry_after_ms,
:limit, :window_ms, and the route metadata. The default
X402.RateLimiter.ETS store is per-node; see X402.RateLimiter for
shared stores.
Summary
Types
Claim ordering relative to facilitator verification.
A route value that is either static or computed from the request.
Functions
Gates matching requests behind x402 v2 payment verification.
Validates and compiles X402.Plug.PaymentGate options.
Stores the actual atomic amount to settle for an "upto" route.
Types
@type claim_order() :: :after_verify | :before_verify
Claim ordering relative to facilitator verification.
@type dynamic(value) :: value | (Plug.Conn.t() -> value | {:ok, value} | {:error, term()})
A route value that is either static or computed from the request.
@type options() :: %{ auth_capture: map() | nil, facilitator: X402.Facilitator.server(), hooks: module(), payment_identifier_cache: X402.Extensions.PaymentIdentifier.Cache.adapter() | nil, claim_order: claim_order(), routes: [compiled_route()], schemes: [module()], local_prechecks: boolean(), local_verification: keyword() | nil, paywall: module() | nil, siwx: X402.Extensions.SIWX.Server.t() | nil, extensions: [X402.Extension.spec()], rate_limit: X402.RateLimiter.config() | nil }
Configuration map produced by init/1.
Functions
@spec call(Plug.Conn.t(), options()) :: Plug.Conn.t()
Gates matching requests behind x402 v2 payment verification.
Validates and compiles X402.Plug.PaymentGate options.
Options
:auth_capture- Local escrow execution,[resource: resource, handler: fn conn -> conn end]. The handler must set an actual amount withput_settlement_amount/2. Responses and callbacks are buffered. Streaming, files, upgrades and early hints are unsupported. Deferred results do not grant SIWX access. See the auth-capture guide for durable recovery and output limits. The default value isnil.:facilitator(term/0) - Facilitator server pid/name used for verification and settlement. The default value isX402.Facilitator.:hooks- Lifecycle hook module implementingX402.Hooks. The default value isX402.Hooks.Default.:payment_identifier_cache- Optional idempotency cache used for replay protection. Accepts either anETSCacheserver pid/name (the default adapter), or a{module, cache}adapter tuple wheremoduleimplementsX402.Extensions.PaymentIdentifier.Cacheandcacheis passed to its callbacks (for example{MyApp.RedisPaymentCache, MyApp.Redis}). When set, the plug performs an atomic claim (via the adapter'sput_new/3) on the payment proof hash before settling, preventing concurrent requests from double-settling the same payment, and binds each echoedpayment-identifierid to its request fingerprint (see "Payment identifier" above). The default ETS adapter is per-node — see the "Replay protection" section above for the clustering hazard. The default value isnil.:claim_order- When the replay claim is taken relative to facilitator verification.:after_verify(default) never strands a claim on verification but pays a facilitator verify round-trip per replayed request;:before_verifyrejects duplicates before contacting the facilitator (shedding replay-storm load) and releases the claim if verification fails, at the cost that a node crash during verification strands the claim until the cache TTL expires. Only meaningful when:payment_identifier_cacheis configured. The default value is:after_verify.:routes- Required. Route gate definitions (see route options below).:schemes- AdditionalX402.Schememodules consulted (before the built-ins) for scheme-specific payload validation and local pre-checks — seeX402.Scheme.Registry. Routes may use the scheme names these modules declare. The default value is[].:local_prechecks(boolean/0) - Run cheap local checks before calling the facilitator, dispatched to theX402.Schememodule matching the requirements' scheme/network. The built-in EVM schemes check the EIP-3009-stylepayload.authorizationobject:tomust equal the route'spay_to,valuemust equal the advertised amount for"exact"routes, and thevalidAfter/validBeforewindow must cover now (with a 6s settlement buffer). Fields absent from the payload are skipped — as are kinds with no registered scheme module — so payloads with other shapes pass through untouched. Failures answer 402 without a facilitator round-trip. The default value istrue.:local_verification- Optional inline local verification throughX402.Verify.EVM, run before the facilitator verify. Accepts a level atom (:structural,:signature, or:full— shorthand for[level: level]) or a keyword list with:level,:rpc(anX402.RPCstruct, required for:full),:simulate,:verify_chain_id,:eip6492_allowed_factories, and:multicall_address— seeX402.Verify.EVM.verify/3for their semantics. Local verification only understands exact-EVM payments: it runs when the matched requirements have scheme"exact"and aneip155:*network, and is skipped silently for every other scheme/network combination — the facilitator remains the authority, and still verifies and settles payments local verification accepted. Verification failures answer 402 exactly like a facilitator rejection (carrying the canonicalinvalidReasonstring); infrastructure failures — missing crypto dependencies, RPC errors, chain-id mismatches — fail closed with 500. A configured level never silently downgrades. The default value isnil.:paywall- Optional browser paywall renderer implementingX402.Paywall(X402.Paywall.Defaultships a self-contained wallet-enabled page). When set, pre-handler 402 responses to requests that look like a browser page load —Acceptheader containingtext/htmlandUser-AgentcontainingMozilla, mirroring the reference x402 middlewares — carry the rendered HTML body instead of the default{}JSON body. ThePAYMENT-REQUIREDheader is identical on both forms, and every other response (API clients, absentAcceptheaders, 400/500 statuses, post-handler settlement failures) is byte-identical to running without:paywall. The default value isnil.:siwx- Optional Sign-In-With-X configuration, a keyword list ofX402.Extensions.SIWX.Server.new/1options (:domain,:uri, and:supported_chainsare required). When set, every 402 response advertises a fresh challenge underextensions["sign-in-with-x"], requests carrying aSIGN-IN-WITH-Xheader are authenticated against it, and successful settlements record the payer so later proofs from that address skip payment — see "Sign-In-With-X" above. The default value isnil.:extensions-X402.Extensionadapters —moduleor{module, opts}— that advertise, validate, and observe protocol extensions on every gated request (for exampleX402.Extensions.PaymentIdentifier.AdapterandX402.Extensions.BuilderCode.Adapter). Advertisements are merged over each route's static:extensionsmap — see "Extension adapters" above. The default value is[].:rate_limit- Optional per-wallet rate limit, a keyword list ofX402.RateLimiteroptions::limitand:window_ms(both required),:key(:payer— default —:ip, or a 1-arity function of the request context),:store(anX402.RateLimitermodule or{module, ref}, defaultX402.RateLimiter.ETS), and:on_error. The limit is applied to verified payments only — after the facilitator verify round-trip and before settlement — so a forged payer cannot burn a victim's allowance; a denied request answers 429 with aRetry-Afterheader and is never settled — see "Rate limiting" above. The default value isnil.
Route options
:method- Required. HTTP method for the route (:anymatches all methods).:path- Required. Route path: an exact path, a*glob (/api/*), or a template with:paramsegments (/api/users/:id) whose captured values are assigned as:x402_path_params.:accepts- Payment options advertised inPAYMENT-REQUIRED.accepts, or a 1-arity function of thePlug.Connreturning them. When empty, a single option is built from the top-level:scheme,:price,:network,:asset, and:pay_tofields. The default value is[].:scheme(String.t/0) - Single-option scheme (used when:acceptsis empty):exact,upto, or the scheme name of a module passed in the plug's:schemesoption. The default value is"exact".:price- Single-option amount (required when:acceptsis empty), or a 1-arity function of thePlug.Connreturning one.:network(String.t/0) - Single-option CAIP-2 network (required when:acceptsis empty).:asset(String.t/0) - Single-option asset (required when:acceptsis empty).:pay_to- Single-option payTo (required when:acceptsis empty), or a 1-arity function of thePlug.Connreturning one.:description- ResourceInfo.description, or a 1-arity function of thePlug.Connreturning it. The default value is"Payment required".:mime_type(String.t/0) - ResourceInfo.mimeType. The default value is"application/json".:service_name- ResourceInfo.serviceName: non-empty printable ASCII, at most 32 characters (X402.Extensions.Bazaar.Metadata.valid_service_name?/1). The default value isnil.:tags- ResourceInfo.tags: at most 5 unique (case-insensitive) entries, each non-empty printable ASCII of at most 32 characters (X402.Extensions.Bazaar.Metadata.sanitize_tags/1). The default value is[].:icon_url- ResourceInfo.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). The default value isnil.:max_timeout_seconds(pos_integer/0) - Default maxTimeoutSeconds for single-option routes. The default value is60.:extra- Default extra map for single-option routes. The default value is%{}.:extensions- Protocol extensions advertised in PaymentRequired.extensions. The default value is%{}.:bazaar-X402.Extensions.Bazaar.build_extension/1options advertising the route in the bazaar. For:paramroutes the extension carries the path asrouteTemplateand the captured values asinfo.input.pathParams; globs cannot be advertised. The default value isnil.
Accept option fields (inside :accepts)
:scheme(String.t/0) - Payment scheme (exact,upto, or the scheme name of a module passed in the plug's:schemesoption). The default value is"exact".:price- Required. Payment amount in atomic token units (PaymentRequirementsamount), or a 1-arity function of thePlug.Connreturning one. Forexactthis is the required amount; foruptoit is the maximum authorized amount.:network(String.t/0) - Required. Blockchain network in CAIP-2 format (for exampleeip155:84532).:asset(String.t/0) - Required. Token contract address or asset identifier.:pay_to- Required. Recipient wallet address (payToin the PaymentRequirements schema), or a 1-arity function of thePlug.Connreturning one.:max_timeout_seconds(pos_integer/0) - Maximum time allowed for payment completion. The default value is60.:extra- Scheme-specific extra fields (string or atom keys). The default value is%{}.
@spec put_settlement_amount(Plug.Conn.t(), String.t() | non_neg_integer()) :: {:ok, Plug.Conn.t()} | {:error, :invalid_settlement_amount}
Stores the actual atomic amount to settle for an "upto" route.
Call this from the protected handler after resource consumption is known. When omitted, the route's advertised maximum is settled.
Examples
iex> conn = Plug.Test.conn(:get, "/paid")
iex> {:ok, conn} = X402.Plug.PaymentGate.put_settlement_amount(conn, "7500")
iex> conn.private[:x402_settlement_amount]
"7500"
iex> X402.Plug.PaymentGate.put_settlement_amount(Plug.Test.conn(:get, "/paid"), "1.5")
{:error, :invalid_settlement_amount}