APP x402 Flow on X Layer
OKX's APP Pay Per Use uses the x402 "exact" scheme on EVM. The payer
signs an EIP-3009 TransferWithAuthorization off-chain. OKX's
facilitator verifies and settles the transfer on chain 196 (zero gas to
the payer).
Table of Contents
- Step 0: Prerequisites
- Step 1: Helpers and Validation
- Step 2: Confirm Pre-Signing State
- Step 3: Generate Nonce and Deadline
- Step 3.5: User Confirmation Gate
- Step 4: Sign the EIP-3009 Authorization
- Step 5: Construct the X-PAYMENT Payload
- Step 6: Retry the Original Request
- Step 7: Interpret the Response
Step 0: Prerequisites
Before running any block in this document, assert required CLI tools are
installed. bc is needed for human-readable amount formatting and is
not preinstalled on every macOS:
set -euo pipefail
command -v cast >/dev/null || { echo "cast required (foundry)" >&2; exit 1; }
command -v jq >/dev/null || { echo "jq required" >&2; exit 1; }
command -v bc >/dev/null || { echo "bc required (brew install bc)" >&2; exit 1; }
command -v openssl >/dev/null || { echo "openssl required" >&2; exit 1; }
command -v curl >/dev/null || { echo "curl required" >&2; exit 1; }
command -v node >/dev/null || { echo "node 18+ required (used by viem)" >&2; exit 1; }
command -v npm >/dev/null || { echo "npm required (used to install viem)" >&2; exit 1; }The X Layer RPC URL is overridable for rate-limiting or failover:
RPC_URL="${X_LAYER_RPC_URL:-https://rpc.xlayer.tech}"Resolve a viem-capable Node environment
Step 4 signs the EIP-3009 authorization with viem. Pick the directory the signer script will run from, in this order:
- The user's current working directory, if
viem/accountsis already resolvable there (zero install). - A cached scratch directory at
~/.cache/uniswap-pay-with-app/signer/(or whateverX402_SIGNER_DIRis set to). Persists across runs. - If neither has viem, prompt the user via
AskUserQuestionbefore installing. The user must know what is being installed on their machine. The summary you present must include: "package=viem, target=$X402_SIGNER_DIR, command=npm install viem, footprint=~13 packages and ~5 MB". Only run the install on an explicityes. If the user declines, exit cleanly before any signing happens.
set -euo pipefail
X402_SIGNER_DIR="${X402_SIGNER_DIR:-$HOME/.cache/uniswap-pay-with-app/signer}"
viem_resolves_in() {
( cd "$1" && node -e "require.resolve('viem/accounts')" ) >/dev/null 2>&1
}
if viem_resolves_in .; then
SIGNER_CWD=.
elif viem_resolves_in "$X402_SIGNER_DIR"; then
SIGNER_CWD="$X402_SIGNER_DIR"
else
# Agent: invoke AskUserQuestion FIRST. Do not run the install lines below
# without an explicit user 'yes'. After confirmation:
mkdir -p "$X402_SIGNER_DIR"
( cd "$X402_SIGNER_DIR" && [ -f package.json ] || npm init -y >/dev/null )
( cd "$X402_SIGNER_DIR" && npm install viem --no-audit --no-fund --loglevel=error )
SIGNER_CWD="$X402_SIGNER_DIR"
fi
echo "viem resolved in: $SIGNER_CWD"The signer script in Step 4 must be invoked with cd "$SIGNER_CWD" so
Node's module resolution finds viem there.
Step 1: Helpers and Validation
set -euo pipefail
get_token_decimals() {
local token_addr="$1" rpc_url="${2:-${X_LAYER_RPC_URL:-https://rpc.xlayer.tech}}"
local out
out=$(cast call "$token_addr" "decimals()(uint8)" --rpc-url "$rpc_url") || {
echo "ERROR: decimals() call failed for $token_addr on $rpc_url" >&2
return 1
}
[[ "$out" =~ ^[0-9]+$ ]] || {
echo "ERROR: non-numeric decimals returned: $out" >&2
return 1
}
echo "$out"
}
format_token_amount() {
local amount="$1" decimals="$2"
local result
result=$(echo "scale=$decimals; $amount / (10 ^ $decimals)" | bc -l | sed 's/0*$//' | sed 's/\.$//')
[ -z "$result" ] && result="0"
echo "$result"
}Always show the user human-readable amounts (e.g.
0.005 USDT0), not raw base units.get_token_decimalsfails loudly on RPC error. Never default to6: a wrong decimals value silently misleads the user-facing confirmation gate. Every call site MUST check the exit code:X402_DECIMALS=$(get_token_decimals "$X402_ASSET" "$RPC_URL") || exit 1.
Validate every value pulled from the 402 body before using it in shell commands or signing payloads. The block in Step 2 below enforces these as hard gates, not advisory notes:
- Addresses match
^0x[a-fA-F0-9]{40}$ - Amounts match
^[0-9]+$ - URLs start with
https:// - Nonce matches
^0x[a-fA-F0-9]{64}$ - Reject any value containing
;,|,&,$, backtick, parentheses, redirection, backslash, quotes, newlines
Step 2: Confirm Pre-Signing State
set -euo pipefail
# Required environment
: "${X402_SCHEME:?missing}" # must be "exact"
: "${X402_NETWORK:?missing}" # x-layer / xlayer / eip155:196 / 196
: "${X402_ASSET:?missing}" # token contract on X Layer
: "${X402_AMOUNT:?missing}" # base units, integer string
: "${X402_PAY_TO:?missing}" # recipient
: "${X402_RESOURCE:?missing}" # original URL (or accepts[].resource override)
: "${WALLET_ADDRESS:?missing}" # source wallet
# 0) Hard input-validation gates (no advisory notes; enforced)
[[ "$X402_ASSET" =~ ^0x[a-fA-F0-9]{40}$ ]] || { echo "bad asset address" >&2; exit 1; }
[[ "$X402_PAY_TO" =~ ^0x[a-fA-F0-9]{40}$ ]] || { echo "bad payTo address" >&2; exit 1; }
[[ "$WALLET_ADDRESS" =~ ^0x[a-fA-F0-9]{40}$ ]] || { echo "bad wallet address" >&2; exit 1; }
[[ "$X402_AMOUNT" =~ ^[0-9]+$ ]] || { echo "bad amount" >&2; exit 1; }
[[ "$X402_RESOURCE" =~ ^https://[A-Za-z0-9._~:/?\#@%=+,\&\;-]+$ ]] || { echo "bad resource URL" >&2; exit 1; }
# Defense in depth: the regex bracket class above already constrains the
# character set. This case block is a hard backstop against the
# highest-impact characters that could escape a quote, in case a future
# edit relaxes the regex. Note: `&` and `;` are valid sub-delims in real
# query strings (?a=1&b=2) and are admitted by the regex; they are NOT
# rejected here because $X402_RESOURCE is only ever interpolated inside
# double-quoted shell contexts (e.g. curl "$X402_RESOURCE"), where they
# cannot trigger word-splitting or command separation.
case "$X402_RESOURCE" in
*\`*|*\\*|*\"*|*\'*|*\|*|*\$*|*\(*|*\)*|*\<*|*\>*|*\**|*\!*|*$'\n'*)
echo "bad resource URL: contains shell metacharacter" >&2; exit 1 ;;
esac
# Note: $X402_RESOURCE should be set from accepts[selected].resource if the 402 challenge
# provides one (it can override the original request URL for proxied or redirected
# resources); fall back to the originally requested URL only if accepts[].resource is absent.
# If accepts[selected].resource's host differs from the host of the originally-requested
# URL, surface the mismatch to the user and require explicit confirmation before
# continuing. A facilitator-mediated redirect is legitimate but should not be silent.
if [ -n "${ORIGINAL_REQUEST_URL:-}" ]; then
# Strip userinfo (user:pass@) and port (:NNNN) before comparing so that
# equivalent hosts don't trip the gate.
strip_host() {
echo "$1" | awk -F/ '{print $3}' | sed -E 's/^[^@]+@//' | sed -E 's/:[0-9]+$//'
}
RESOURCE_HOST=$(strip_host "$X402_RESOURCE")
ORIGINAL_HOST=$(strip_host "$ORIGINAL_REQUEST_URL")
[ -n "$RESOURCE_HOST" ] && [ -n "$ORIGINAL_HOST" ] || {
echo "ERROR: could not parse host from URLs (resource=$X402_RESOURCE, original=$ORIGINAL_REQUEST_URL)" >&2
exit 1
}
if [ "$RESOURCE_HOST" != "$ORIGINAL_HOST" ]; then
if [ "${X402_HOST_MISMATCH_ACK:-}" != "yes" ]; then
echo "ERROR: accepts[].resource host ($RESOURCE_HOST) differs from original request host ($ORIGINAL_HOST)." >&2
echo "Surface this via AskUserQuestion. After explicit user confirmation, re-invoke with X402_HOST_MISMATCH_ACK=yes." >&2
exit 1
fi
fi
fi
# 1) Only "exact" scheme is supported in v1.0.0
[ "$X402_SCHEME" = "exact" ] || { echo "Unsupported scheme: $X402_SCHEME" >&2; exit 1; }
# 2) Network must resolve to chain 196
case "$X402_NETWORK" in
x-layer|xlayer|"eip155:196"|196) X402_CHAIN_ID=196 ;;
*) echo "Network is not X Layer. Use pay-with-any-token instead." >&2; exit 1 ;;
esac
# 3) Wallet must hold enough of the requested asset on X Layer
RPC_URL="${X_LAYER_RPC_URL:-https://rpc.xlayer.tech}"
ASSET_BALANCE=$(cast call "$X402_ASSET" \
"balanceOf(address)(uint256)" "$WALLET_ADDRESS" --rpc-url "$RPC_URL") || {
echo "ERROR: balanceOf call failed; check RPC connectivity" >&2; exit 1;
}
[[ "$ASSET_BALANCE" =~ ^[0-9]+$ ]] || {
echo "ERROR: balanceOf returned non-integer: $ASSET_BALANCE" >&2; exit 1;
}
if [ "$ASSET_BALANCE" -lt "$X402_AMOUNT" ]; then
X402_DECIMALS=$(get_token_decimals "$X402_ASSET" "$RPC_URL") || exit 1
HAVE=$(format_token_amount "$ASSET_BALANCE" "$X402_DECIMALS")
NEED=$(format_token_amount "$X402_AMOUNT" "$X402_DECIMALS")
echo "Insufficient asset balance on X Layer. Have $HAVE, need $NEED."
echo "Run the funding flow (references/funding-x-layer.md), then return here."
exit 1
fi
# When the funding flow runs, it captures SOURCE_TX_HASH from the Trading
# API /swap response. See funding-x-layer.md for the capture + validate
# pattern (jq with `// empty` fallback plus a 0x[a-fA-F0-9]{64} shape
# check); a literal "null" or empty string must fail the funding flow
# rather than propagating into this script.
# Capture 402 challenge freshness for the sign-time gate (see Step 4).
# Named CHALLENGE_FETCHED_AT to disambiguate from the Trading API "quote"
# concept used in funding-x-layer.md; here it refers to the 402 body itself.
CHALLENGE_FETCHED_AT=$(date +%s)Step 3: Generate Nonce and Deadline
set -euo pipefail
X402_NONCE="0x$(openssl rand -hex 32)" # 32-byte random nonce
# Assert the nonce is the right shape. If openssl is missing or fails,
# command substitution can yield "0x" (empty), which signs against
# nonce: 0. First time works, second is a replay. Fail loud here.
[ ${#X402_NONCE} -eq 66 ] || { echo "openssl missing or failed: nonce length=${#X402_NONCE}" >&2; exit 1; }
[[ "$X402_NONCE" =~ ^0x[a-fA-F0-9]{64}$ ]] || { echo "bad nonce shape: $X402_NONCE" >&2; exit 1; }
X402_VALID_AFTER=0 # immediately valid
# Default the requested timeout to 5 minutes if not provided by the challenge.
X402_TIMEOUT="${X402_TIMEOUT:-300}"
# `maxTimeoutSeconds` from the challenge body is an upper bound on
# (validBefore - validAfter). Default the ceiling to the requested
# timeout if the challenge omits it, then clamp.
X402_MAX_TIMEOUT="${X402_MAX_TIMEOUT:-$X402_TIMEOUT}"
if [ "$X402_TIMEOUT" -lt "$X402_MAX_TIMEOUT" ]; then
X402_EFFECTIVE_TIMEOUT="$X402_TIMEOUT"
else
X402_EFFECTIVE_TIMEOUT="$X402_MAX_TIMEOUT"
fi
X402_VALID_BEFORE=$(( $(date +%s) + X402_EFFECTIVE_TIMEOUT ))The challenge body's maxTimeoutSeconds is an upper bound on
validBefore - validAfter. The clamp above keeps the request inside
the bound while leaving the facilitator time to settle.
Step 3.5: User Confirmation Gate
This step is mandatory and not optional. Do not auto-submit. Use
AskUserQuestion (or the equivalent confirmation primitive in the host
agent) to surface a payment summary and obtain explicit yes/no consent
before signing:
- Token:
$X402_TOKEN_NAME($X402_ASSET) on X Layer (chain 196) - Amount: human-readable amount + base units
- Recipient:
$X402_PAY_TO - Resource:
$X402_RESOURCE - Expiry:
validBefore(UTC + epoch) - Nonce (first 10 chars of
$X402_NONCE, for traceability)
If the user declines, abort. If the user does not respond, abort. Do not proceed to Step 4 without an affirmative answer in the transcript.
Step 4: Sign the EIP-3009 Authorization
The EIP-712 domain uses the token contract's own name and
version (taken verbatim from the challenge's extra field).
verifyingContract is the token contract itself.
Unicode warning. USDT0's domain
nameis"USD₮0"with the Unicode trademark sign₮(U+20AE), not an ASCIIT. EIP-712 hashes the domainnameas byte-exact UTF-8: pass it through unchanged fromextra.name. Do not normalize. Do not substitute ASCIIT. Any mutation produces a signature the facilitator will reject.
Freshness gate (refuse-to-sign-when-stale): a signed-but-stale
authorization burns a nonce on the facilitator side; refusing to sign
preserves the nonce. Run this gate before invoking signTypedData,
not after. The challenge body's prices and accepts[] parameters are
short-lived (the SKILL doc states roughly 60 seconds); 45 is a safe
ceiling that leaves margin:
set -euo pipefail
if [ $(($(date +%s) - CHALLENGE_FETCHED_AT)) -ge 45 ]; then
echo "402 challenge is older than 45 seconds; refetch before signing." >&2
exit 1
fiSign with viem:
import { privateKeyToAccount } from 'viem/accounts';
// Validate every input before constructing the typed-data payload.
// `process.env.FOO!` casts hide undefined and empty-string bugs; an
// empty domain or message field produces a valid-looking signature
// the facilitator will reject, burning a fresh nonce.
function requireEnv(key: string): string {
const v = process.env[key];
if (!v || !v.trim()) {
throw new Error(
`${key} is unset or empty. Re-parse the corresponding field from the 402 challenge.`
);
}
return v;
}
// Shape-validating wrappers: catch the case where a string is non-empty but
// the wrong shape (e.g. truncated address, scientific-notation amount). An
// empty-only check still produces a valid-looking signature the facilitator
// will reject, burning a fresh nonce.
function requireAddress(key: string): `0x${string}` {
const v = requireEnv(key);
if (!/^0x[a-fA-F0-9]{40}$/.test(v)) {
throw new Error(`${key} is not a valid 0x address: ${v}`);
}
return v as `0x${string}`;
}
function requireUint(key: string): bigint {
const v = requireEnv(key);
if (!/^[0-9]+$/.test(v)) {
throw new Error(`${key} is not a non-negative integer: ${v}`);
}
return BigInt(v);
}
function requireBytes32(key: string): `0x${string}` {
const v = requireEnv(key);
if (!/^0x[a-fA-F0-9]{64}$/.test(v)) {
throw new Error(`${key} is not a 0x bytes32: ${v}`);
}
return v as `0x${string}`;
}
// Free-text fields (extra.name, extra.version) feed the EIP-712 domain
// bit-exact and may also be surfaced to the user. Per the SKILL.md
// Input Validation Rules, reject the whole challenge rather than
// mutating the value if it contains shell metacharacters.
function requireSafeText(key: string): string {
const v = requireEnv(key);
if (/[;|&$`()<>\\'"\n]/.test(v)) {
throw new Error(
`${key} contains shell metacharacters; reject the whole challenge per skill policy.`
);
}
return v;
}
const privateKey = requireEnv('PRIVATE_KEY');
const tokenName = requireSafeText('X402_TOKEN_NAME'); // from extra.name (e.g. "USD₮0")
const tokenVersion = requireSafeText('X402_TOKEN_VERSION'); // from extra.version (e.g. "1")
const walletAddress = requireAddress('WALLET_ADDRESS');
const x402Asset = requireAddress('X402_ASSET');
const x402PayTo = requireAddress('X402_PAY_TO');
const x402Amount = requireUint('X402_AMOUNT');
const x402ValidAfter = requireUint('X402_VALID_AFTER');
const x402ValidBefore = requireUint('X402_VALID_BEFORE');
const x402Nonce = requireBytes32('X402_NONCE');
const account = privateKeyToAccount(privateKey as `0x${string}`);
const domain = {
name: tokenName,
version: tokenVersion,
chainId: 196,
verifyingContract: x402Asset,
};
// REQUIRED: AskUserQuestion confirmation already happened in Step 3.5.
// Do not reach this line without an affirmative user answer.
const signature = await account.signTypedData({
domain,
types: {
TransferWithAuthorization: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'validAfter', type: 'uint256' },
{ name: 'validBefore', type: 'uint256' },
{ name: 'nonce', type: 'bytes32' },
],
},
primaryType: 'TransferWithAuthorization',
message: {
from: walletAddress,
to: x402PayTo,
value: x402Amount,
validAfter: x402ValidAfter,
validBefore: x402ValidBefore,
nonce: x402Nonce,
},
});
process.env.X402_SIGNATURE = signature;To execute the snippet above, write it to a .mjs file and run Node
from the directory Step 0 resolved viem in ($SIGNER_CWD). Capture
stdout into X402_SIGNATURE, then verify the shape before continuing,
because set -euo pipefail does not fire on a failed command
substitution unless inherit_errexit is set (which is bash 4.4+ only,
not available on default macOS bash 3.2):
set -euo pipefail
SIGNER_SCRIPT=$(mktemp -t x402-signer-XXXXXX).mjs
trap 'rm -f "$SIGNER_SCRIPT"' EXIT
cat > "$SIGNER_SCRIPT" <<'JS'
// Paste the signing snippet above here, with `process.stdout.write(signature)`
// at the end instead of assigning to process.env.
JS
SIG_FILE=$(mktemp)
( cd "$SIGNER_CWD" && node "$SIGNER_SCRIPT" > "$SIG_FILE" )
X402_SIGNATURE=$(cat "$SIG_FILE")
rm -f "$SIG_FILE"
if ! [[ "$X402_SIGNATURE" =~ ^0x[0-9a-fA-F]{130}$ ]]; then
echo "ERROR: signer returned bad signature shape" >&2
exit 1
fi
export X402_SIGNATUREDomain warning.
verifyingContractis the token contract (X402_ASSET), not a separate verifier. Usenameandversionfromextra. Do not assume defaults. An incorrect domain produces a signature the facilitator will reject with another 402.
Step 5: Construct the X-PAYMENT Payload
The wire shape MUST match the x402 v1 spec §5.2 PaymentPayload
schema exactly:
{ x402Version, scheme, network, payload: { signature, authorization } }Per spec §5.2.1, value, validAfter, and validBefore are all
string-typed (uint256-as-decimal-string for value,
Unix-timestamp-as-decimal-string for the timestamps). Use --arg, not
--argjson, so jq emits JSON strings rather than numbers.
x402Version is an integer per spec (not a string-typed field like the
EIP-3009 timestamps): --argjson x402Version 1 emits a JSON number,
which is correct. If a future spec revision retypes this field as a
string, switch to --arg instead.
set -euo pipefail
X402_PAYMENT_JSON=$(jq -n \
--argjson x402Version 1 \
--arg scheme "$X402_SCHEME" \
--arg network "$X402_NETWORK" \
--arg from "$WALLET_ADDRESS" \
--arg to "$X402_PAY_TO" \
--arg value "$X402_AMOUNT" \
--arg validAfter "$X402_VALID_AFTER" \
--arg validBefore "$X402_VALID_BEFORE" \
--arg nonce "$X402_NONCE" \
--arg sig "$X402_SIGNATURE" \
'{
x402Version: $x402Version,
scheme: $scheme,
network: $network,
payload: {
signature: $sig,
authorization: {
from: $from,
to: $to,
value: $value,
validAfter: $validAfter,
validBefore: $validBefore,
nonce: $nonce
}
}
}')
# Base64-encode and strip whitespace (header spec requires no newlines)
X402_PAYMENT=$(echo "$X402_PAYMENT_JSON" | base64 | tr -d '[:space:]')value, validAfter, and validBefore MUST be strings (--arg, not
--argjson): uint256 amounts exceed JSON's safe integer range, and the
spec's PaymentPayload schema types all three as string. Top-level
chainId and asset are intentionally omitted: network already
encodes the chain, and the asset is implicit in the requirement
matching.
Step 6: Retry the Original Request
The freshness gate has already run in Step 4 (refuse-to-sign-when-stale). By the time control reaches this step, the signature is fresh and the nonce is committed.
set -euo pipefail
# Capture status and headers explicitly. `head -1 | grep -o '[0-9]\{3\}'`
# is fragile (HTTP/2, 100-continue interim responses), so use curl's
# own status-code writer.
RETRY_HEADERS=$(mktemp)
RETRY_BODY_FILE=$(mktemp)
trap 'rm -f "$RETRY_HEADERS" "$RETRY_BODY_FILE"' EXIT
RETRY_STATUS=$(curl -s -o "$RETRY_BODY_FILE" -D "$RETRY_HEADERS" \
-w '%{http_code}' \
"$X402_RESOURCE" \
-H "X-PAYMENT: $X402_PAYMENT" \
-H "Content-Type: application/json")
RETRY_BODY=$(cat "$RETRY_BODY_FILE")
# Use awk for header parsing rather than `cut -d' ' -f2-`: header names
# may have variable whitespace after the colon, and `cut` mishandles
# tabs and folded continuations.
X402_PAYMENT_RESPONSE=$(awk 'BEGIN{IGNORECASE=1} /^x-payment-response:/ { sub(/^[^:]+:[ \t]*/, ""); print }' \
"$RETRY_HEADERS" | tr -d '\r\n')
echo "HTTP status: $RETRY_STATUS"Retry policy (two attempts maximum)
The 402 path is bounded: at most one retry after the initial 402, and only against a freshly re-parsed challenge. The retry budget is tracked at the agent level, not via a bash counter. Bash variable state does not survive across separate Bash tool invocations, so a counter would silently reset and permit unbounded retries.
Agent-level retry instruction: If the retry returns 402, you may
attempt one more retry, but only after re-deriving X402_NONCE and
X402_VALID_BEFORE from a freshly fetched 402 challenge. Do not retry a
third time. After the second 402, surface the rejection to the user with
the exact body OKX returned and stop.
To enforce the cap across separate Bash tool invocations (where shell
variable state does not persist), use a tmpfile-based stamp and pass
X402_ATTEMPT_FILE through to subsequent invocations so the budget is
shared:
# IMPORTANT: $$ would evaluate to a fresh PID on every Bash tool invocation,
# defeating the cap. The agent MUST export X402_ATTEMPT_FILE explicitly before
# the first invocation of this block (e.g. /tmp/x402-attempt-${WALLET_ADDRESS}-${X402_NONCE_PREFIX})
# and reuse the SAME path across retries.
: "${X402_ATTEMPT_FILE:?missing: agent must set a stable path so the retry budget persists across Bash invocations}"
[ -e "$X402_ATTEMPT_FILE" ] && [ ! -r "$X402_ATTEMPT_FILE" ] && {
echo "ERROR: X402_ATTEMPT_FILE exists but is unreadable: $X402_ATTEMPT_FILE" >&2
exit 1
}
attempts=$(wc -l < "$X402_ATTEMPT_FILE" 2>/dev/null || echo 0)
[ "$attempts" -lt 2 ] || { echo "Retry budget exhausted (max 2 attempts)." >&2; exit 1; }
date +%s >> "$X402_ATTEMPT_FILE"Variable lifecycle on retry or re-confirmation. On any retry, you MUST regenerate the following from a freshly fetched 402 challenge body before re-signing:
X402_NONCE(Step 3): never reuse a prior nonce; the facilitator rejects replays and you would burn the new attempt for nothing.X402_VALID_BEFORE(Step 3): recompute from the freshdate +%sso the freshness gate in Step 4 does not trip on an old deadline.CHALLENGE_FETCHED_AT(Step 2): re-capture fromdate +%sat the moment the new 402 body is read; this is the timestamp the Step 4 gate compares against.
The user-confirmation gate (Step 3.5) MUST also re-prompt; do not silently re-sign on prior consent.
Step 7: Interpret the Response
| Status | Meaning | Action |
|---|---|---|
| 200 | Payment accepted, resource delivered | If X402_PAYMENT_RESPONSE is non-empty, decode it: echo "$X402_PAYMENT_RESPONSE" | base64 --decode | jq . and surface the receipt. If empty, report "Payment accepted but no receipt header returned. The resource was delivered." |
| 402 | Payment rejected | Most common causes: wrong domain name / version, expired validBefore, reused nonce, amount mismatch. Re-derive from the fresh challenge and try once more (max two attempts total) |
| 400 | Malformed payload | Verify JSON structure and base64 encoding (no whitespace), confirm x402Version: 1 and payload.{signature, authorization} shape |
| 5xx | Facilitator or origin error | Surface raw body to the user. Do not auto-retry |
Do not retry indefinitely on 402. Two attempts maximum, then surface
the rejection details to the user with the exact message OKX returned.
Empty X402_PAYMENT_RESPONSE is not an error: skip the decode step
and report success without a receipt rather than feeding empty input
to base64 --decode.