The auth-capture payment scheme: flows, operations, deadlines, lifecycle
payloads, and reason strings.
auth-capture adds refundable, two-phase payments to x402 on top of
Base's commerce-payments escrow. The client signs one token
authorization; how it settles follows extra.paymentFlow:
"escrow"(default) — the collect is anauthorizethat holds the funds; the resource server later relayscapture,void, orrefundlifecycle payloads through the facilitator."authorization"— the collect is a terminalchargethe server completes with an amount, fee, and authorizer signature; onlyrefundfollows.
This module is chain-agnostic in shape but the only binding today is EVM
(X402.AuthCapture.EVM, X402.Scheme.AuthCaptureEVM,
X402.Verify.AuthCaptureEVM), so the
lifecycle builders here produce the EVM binding's wire payloads.
Lifecycle payloads
A resource server authors lifecycle settles from the stored PaymentInfo
and client saltNonce:
{:ok, capture} =
X402.AuthCapture.capture_payload(requirements, payment_info,
salt_nonce: salt_nonce,
amount: "750000",
expected_capturable_amount: "1000000",
expected_refundable_amount: "0",
authorizer: receiver_authorizer_signer
)These builders do not broadcast or persist the operation. Submit the
resulting envelope through an auth-capture-capable facilitator only
after authenticating the request and establishing any refund funding
agreement. An explicit :authorizer signer is required; implicit
receiver-authorizer delegation is not supported.
Reason strings
Every reason this scheme defines is namespaced
invalid_auth_capture_evm_*; standard x402 reasons keep their canonical
names. See reason_string/1.
Summary
Types
When the post-resource finalize of an escrow route runs.
The escrow call a payload settles as.
The kind of extra.captureAuthorizer.
The settlement lifecycle a requirements entry selects.
A reason atom this scheme reports (see reason_string/1).
deadlines
Checks a client authorization's time window against the capture deadline:
validAfter <= now, validBefore > now + skew, and
validBefore <= captureDeadline.
Checks the requirements' deadline ordering:
now + maxTimeoutSeconds <= captureDeadline <= refundDeadline and
captureDeadline > now + skew.
lifecycle
Builds a capture lifecycle payload (v2 envelope) for the facilitator.
The token collector a deployment uses for an asset transfer method.
Completes a client payment payload for a charge settle.
Builds a refund lifecycle payload (v2 envelope) for the facilitator.
Builds a void lifecycle payload (v2 envelope) for the facilitator.
reasons
Converts a reason atom to its canonical wire string.
Every reason atom this scheme reports, standard ones first.
requirements
Resolves extra.assetTransferMethod (default "eip3009").
Resolves extra.captureMode (default "sync"), which is only meaningful
under the escrow flow.
Whether the payload is a lifecycle payload (carries payload.type).
Resolves the escrow operation a payload settles as.
Resolves extra.operatorType (default "delegated").
Resolves extra.paymentFlow (default "escrow").
The scheme identifier.
The clock-skew floor (seconds) the deadline checks apply.
Types
@type capture_mode() :: :sync | :deferred
When the post-resource finalize of an escrow route runs.
@type operation() :: :authorize | :charge | :capture | :void | :refund
The escrow call a payload settles as.
@type operator_type() :: :delegated | :custom | :policy
The kind of extra.captureAuthorizer.
@type payment_flow() :: :escrow | :authorization
The settlement lifecycle a requirements entry selects.
@type reason() :: atom()
A reason atom this scheme reports (see reason_string/1).
deadlines
@spec check_authorization_window(term(), term(), term(), keyword()) :: :ok | {:error, :authorization_expired | :authorization_not_yet_valid | :deadline_ordering | :payload_format | {:invalid_options, String.t()}}
Checks a client authorization's time window against the capture deadline:
validAfter <= now, validBefore > now + skew, and
validBefore <= captureDeadline.
valid_after is nil for Permit2 (which has no lower bound). The
:now and :skew_seconds options must be non-negative integers;
the skew is never lowered below six seconds.
Examples
iex> X402.AuthCapture.check_authorization_window("0", "1600", 2_000, now: 1_000)
:ok
iex> X402.AuthCapture.check_authorization_window("0", "1005", 2_000, now: 1_000)
{:error, :authorization_expired}
iex> X402.AuthCapture.check_authorization_window("0", "2500", 2_000, now: 1_000)
{:error, :deadline_ordering}
iex> X402.AuthCapture.check_authorization_window("1100", "1600", 2_000, now: 1_000)
{:error, :authorization_not_yet_valid}
@spec check_deadlines( map(), keyword() ) :: :ok | {:error, :deadline_ordering | :capture_deadline_expired | :extra | {:invalid_options, String.t()}}
Checks the requirements' deadline ordering:
now + maxTimeoutSeconds <= captureDeadline <= refundDeadline and
captureDeadline > now + skew.
Options
:now— Unix seconds (default: current time):skew_seconds— clock-skew floor (default6)
Examples
iex> requirements = %{
...> "maxTimeoutSeconds" => 600,
...> "extra" => %{"captureDeadline" => 2_000, "refundDeadline" => 3_000}
...> }
iex> X402.AuthCapture.check_deadlines(requirements, now: 1_000)
:ok
iex> X402.AuthCapture.check_deadlines(
...> %{"maxTimeoutSeconds" => 600, "extra" => %{"captureDeadline" => 3_000, "refundDeadline" => 2_000}},
...> now: 1_000
...> )
{:error, :deadline_ordering}
iex> X402.AuthCapture.check_deadlines(
...> %{"maxTimeoutSeconds" => 1_500, "extra" => %{"captureDeadline" => 2_000, "refundDeadline" => 3_000}},
...> now: 1_000
...> )
{:error, :deadline_ordering}
iex> X402.AuthCapture.check_deadlines(
...> %{"maxTimeoutSeconds" => 0, "extra" => %{"captureDeadline" => 1_005, "refundDeadline" => 3_000}},
...> now: 1_000
...> )
{:error, :capture_deadline_expired}
lifecycle
Builds a capture lifecycle payload (v2 envelope) for the facilitator.
Options
:salt_nonce(String.t/0) - Required. The client'ssaltNonce(32-byte0xhex) that reopens the salt binding.:amount- Atomic amount to capture or refund (required for those operations).:fee- Submitted fee forcapture:feeAmount(atomic units) on v1.1 orfeeBpson v1.0. Defaults to the deployment's minimum (amount * minFeeBps / 10000, orminFeeBps).:fee_receiver(String.t/0) - Submitted fee receiver forcapture. Defaults topaymentInfo.feeReceiver.:expected_capturable_amount- ThecapturableAmountthe authorizer expects to find (capture and refund).:expected_refundable_amount- TherefundableAmountthe authorizer expects to find (capture and refund).:authorizer(term/0) - Required. The receiver authorizer'sX402.Signer, required for explicit consent.:void_remainder(boolean/0) - Forcapture: also sign aVoidso the same settle releases the remaining hold (sync partial close-out). Requires:authorizer. The default value isfalse.
:amount, :expected_capturable_amount, and :expected_refundable_amount
are required.
@spec collector(X402.AuthCapture.EVM.deployment(), :eip3009 | :permit2) :: String.t()
The token collector a deployment uses for an asset transfer method.
Examples
iex> deployment = X402.AuthCapture.EVM.deployment(:v1_1)
iex> X402.AuthCapture.collector(deployment, :permit2)
"0xD69831Aed5bfe262067ec4c751f4F830EcdD446e"
Completes a client payment payload for a charge settle.
Appends amount, the deployment's fee field, feeReceiver, and an
explicit authorizerSignature over exactly those values, leaving the
client's fields untouched. envelope is the v2 envelope whose
accepted requirements select the deployment.
Options
:amount- Amount to charge, at mostrequirements.amount(the default).:fee- Submitted fee (feeAmounton v1.1,feeBpson v1.0); defaults to the minimum.:fee_receiver(String.t/0) - Submitted fee receiver; defaults toextra.feeRecipient.:authorizer(term/0) - Required. The receiver authorizer'sX402.Signer, required for explicit consent.
Builds a refund lifecycle payload (v2 envelope) for the facilitator.
Requires :salt_nonce, :amount, :expected_capturable_amount, and
:expected_refundable_amount; the token collector is always the
deployment's operator refund collector and is not carried on the wire.
Builds a void lifecycle payload (v2 envelope) for the facilitator.
Accepts :salt_nonce and :authorizer from the lifecycle options.
reasons
Converts a reason atom to its canonical wire string.
Standard x402 reasons keep their name; everything else is namespaced
invalid_auth_capture_evm_*.
Examples
iex> X402.AuthCapture.reason_string(:nonce_mismatch)
"invalid_auth_capture_evm_nonce_mismatch"
iex> X402.AuthCapture.reason_string(:invalid_network)
"invalid_network"
@spec reasons() :: [reason()]
Every reason atom this scheme reports, standard ones first.
requirements
@spec asset_transfer_method(map()) :: {:ok, :eip3009 | :permit2} | {:error, :unsupported_asset_transfer_method}
Resolves extra.assetTransferMethod (default "eip3009").
Examples
iex> X402.AuthCapture.asset_transfer_method(%{"extra" => %{}})
{:ok, :eip3009}
iex> X402.AuthCapture.asset_transfer_method(%{"extra" => %{"assetTransferMethod" => "permit2"}})
{:ok, :permit2}
iex> X402.AuthCapture.asset_transfer_method(%{"extra" => %{"assetTransferMethod" => "eip2612"}})
{:error, :unsupported_asset_transfer_method}
@spec capture_mode(map()) :: {:ok, capture_mode()} | {:error, :extra}
Resolves extra.captureMode (default "sync"), which is only meaningful
under the escrow flow.
Examples
iex> X402.AuthCapture.capture_mode(%{"extra" => %{}})
{:ok, :sync}
iex> X402.AuthCapture.capture_mode(%{"extra" => %{"captureMode" => "deferred"}})
{:ok, :deferred}
iex> X402.AuthCapture.capture_mode(%{
...> "extra" => %{"paymentFlow" => "authorization", "captureMode" => "sync"}
...> })
{:error, :extra}
Whether the payload is a lifecycle payload (carries payload.type).
Examples
iex> X402.AuthCapture.lifecycle?(%{"payload" => %{"type" => "capture"}})
true
iex> X402.AuthCapture.lifecycle?(%{"payload" => %{"authorization" => %{}}})
false
@spec operation(map(), map()) :: {:ok, operation()} | {:error, :payload_type | :unsupported_payment_flow}
Resolves the escrow operation a payload settles as.
Client payloads settle as :authorize (escrow flow) or :charge
(authorization flow); lifecycle payloads name their payload.type, with
capture and void admitted only under the escrow flow.
Examples
iex> X402.AuthCapture.operation(%{"payload" => %{"authorization" => %{}}}, %{"extra" => %{}})
{:ok, :authorize}
iex> X402.AuthCapture.operation(
...> %{"payload" => %{"authorization" => %{}}},
...> %{"extra" => %{"paymentFlow" => "authorization"}}
...> )
{:ok, :charge}
iex> X402.AuthCapture.operation(%{"payload" => %{"type" => "refund"}}, %{"extra" => %{}})
{:ok, :refund}
iex> X402.AuthCapture.operation(
...> %{"payload" => %{"type" => "capture"}},
...> %{"extra" => %{"paymentFlow" => "authorization"}}
...> )
{:error, :payload_type}
iex> X402.AuthCapture.operation(%{"payload" => %{"type" => "settle"}}, %{"extra" => %{}})
{:error, :payload_type}
@spec operator_type(map()) :: {:ok, operator_type()} | {:error, :unsupported_operator_type}
Resolves extra.operatorType (default "delegated").
Examples
iex> X402.AuthCapture.operator_type(%{"extra" => %{}})
{:ok, :delegated}
iex> X402.AuthCapture.operator_type(%{"extra" => %{"operatorType" => "custom"}})
{:ok, :custom}
iex> X402.AuthCapture.operator_type(%{"extra" => %{"operatorType" => "dao"}})
{:error, :unsupported_operator_type}
@spec payment_flow(map()) :: {:ok, payment_flow()} | {:error, :unsupported_payment_flow}
Resolves extra.paymentFlow (default "escrow").
The removed v1.0 autoCapture: true is rejected as unsupported.
Examples
iex> X402.AuthCapture.payment_flow(%{"extra" => %{}})
{:ok, :escrow}
iex> X402.AuthCapture.payment_flow(%{"extra" => %{"paymentFlow" => "authorization"}})
{:ok, :authorization}
iex> X402.AuthCapture.payment_flow(%{"extra" => %{"autoCapture" => true}})
{:error, :unsupported_payment_flow}
iex> X402.AuthCapture.payment_flow(%{"extra" => %{"paymentFlow" => "instant"}})
{:error, :unsupported_payment_flow}
@spec scheme() :: String.t()
The scheme identifier.
Examples
iex> X402.AuthCapture.scheme()
"auth-capture"
@spec skew_seconds() :: pos_integer()
The clock-skew floor (seconds) the deadline checks apply.
Examples
iex> X402.AuthCapture.skew_seconds()
6