x402 turns HTTP 402 Payment Required into something a program can act on: the server answers 402 with a machine-readable price, the client signs a payment, and it retries the same request with the payment in a header. There are SDKs for this, including a Python one in the x402 repo. But the wire format is small, and once you've built a client by hand, a failed payment is much easier to debug.
This post builds a minimal client with requests and eth-account, about 150 lines, that handles both protocol versions:
-
v1: the price is in the JSON body, and you pay with an
X-PAYMENTheader. -
v2: the price is in a base64
PAYMENT-REQUIREDheader, and you pay withPAYMENT-SIGNATURE.
The script runs as a dry run by default. It fetches the challenge, validates it, signs locally and prints what it would send. It only pays when you give it --pay, a wallet key and the payTo address you expect.
The live example is the key-less top-up endpoint of StorkHost, a hosting API for agents that we run. (The previous post walks through signup, top-up, deploy and teardown end to end.) It's a convenient example because it answers any unpaid request with a 402, and it serves v1 and v2 in the same response. Everything below applies to any server that offers the exact scheme with USDC. Spec references are to x402-foundation/x402 at commit 4241d56.
One 402, two shapes
curl -i "https://api.storkhost.cloud/v1/x402/topup?amountUsd=1.00"
HTTP/2 402
content-type: application/json
payment-required: eyJ4NDAyVmVyc2lvbiI6MiwiZXJyb3IiOiJQYXltZW50IHJlcXVpcmVkIiwi... (2120 chars)
{"x402Version":1,"error":"Payment required","accepts":[{ ... }], ...}
The v1 challenge is the body. This is the accepts[0] entry, trimmed:
{
"scheme": "exact",
"network": "base",
"maxAmountRequired": "1000000",
"resource": "https://api.storkhost.cloud/v1/x402/topup?amountUsd=1.00",
"payTo": "0x810770C050FeBEA7D1E6f643855e619a8076d1d3",
"maxTimeoutSeconds": 300,
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"extra": {"name": "USD Coin", "version": "2"}
}
The v2 challenge is the header. Base64-decode it and you get:
{
"x402Version": 2,
"error": "Payment required",
"resource": {
"url": "https://api.storkhost.cloud/v1/x402/topup?amountUsd=1.00",
"description": "StorkHost hosting credit for the account bound to the paying wallet ...",
"mimeType": "application/json", "serviceName": "StorkHost", "tags": ["hosting", "agents", "compute", "credits"]
},
"accepts": [{
"scheme": "exact",
"network": "eip155:8453",
"amount": "1000000",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"payTo": "0x810770C050FeBEA7D1E6f643855e619a8076d1d3",
"maxTimeoutSeconds": 300,
"extra": {"name": "USD Coin", "version": "2"}
}],
"extensions": {"bazaar": { ... discovery metadata and its JSON schema ... }}
}
It's the same offer both times. The differences are the ones that trip up hand-written clients:
| v1 | v2 | |
|---|---|---|
| Where the challenge lives | JSON body |
PAYMENT-REQUIRED header (base64 JSON) |
| Amount field | maxAmountRequired |
amount |
| Network | name ("base") |
CAIP-2 id ("eip155:8453") |
| Resource |
resource string on each requirement |
top-level resource object with url
|
| Payment header | X-PAYMENT |
PAYMENT-SIGNATURE |
| Receipt header | X-PAYMENT-RESPONSE |
PAYMENT-RESPONSE |
Amounts are atomic token units as strings. USDC has 6 decimals, so "1000000" is 1.00 USDC. In v2 the header is the canonical location and the body is "a server implementation concern" (specs/transports-v2/http.md), so a v2-only server may send {} as the body. Read the header first and fall back to the body.
Check before you sign
Signing is the moment you spend. An EIP-3009 authorization is a bearer instrument: anyone holding it can submit it to the token contract until it expires. The funds can only go to the to address you signed, but once you've signed, the decision is out of your hands. So every check happens before signing:
if req["asset"].lower() != USDC_ON_BASE.lower(): problems.append("asset is not USDC on Base")
if not 0 < amount <= cap_atomic: problems.append("amount outside our cap")
if pay_to and req["payTo"].lower() != pay_to.lower(): problems.append("unexpected payTo")
if urlsplit(resource).netloc != urlsplit(url).netloc: problems.append("resource on another host")
What that boils down to:
- Pin the asset and network. A challenge for a different token, or a different chain, isn't the payment you meant to make.
- Cap every request. Set an atomic-unit ceiling per call. Don't just trust whatever price the server sends.
-
Know who you're paying. The script refuses
--payunless you pass thepayToyou expect. - Never sign a challenge you didn't ask for. Only sign in response to a request you made, for a resource on the host you called.
Build and sign the EIP-712 message
The exact scheme on EVM uses EIP-3009 transferWithAuthorization (specs/schemes/exact/scheme_exact_evm.md). You sign EIP-712 typed data, and whoever settles submits it on chain and pays the gas, so the payer needs USDC but no ETH.
Every input comes from the challenge:
-
domain:
nameandversionfromextra("USD Coin","2"),chainIdfrom the network (8453for Base), andverifyingContract=asset. -
message:
from(you),to=payTo,value= the amount,validAfter,validBefore, and a 32-bytenonce.
For the validity window, the reference Python client sets validAfter to 0 and validBefore to now + maxTimeoutSeconds, and we do the same. The nonce is 32 random bytes. EIP-3009 tracks nonces per payer, so a fresh nonce means a new, independent payment. That's why a retry must resend the same signed authorization rather than sign a new one.
auth = {"from": acct.address, "to": req["payTo"], "value": str(amount), "validAfter": "0",
"validBefore": str(int(time.time()) + int(req["maxTimeoutSeconds"])),
"nonce": "0x" + secrets.token_hex(32)}
typed = {"types": TYPES, "primaryType": "TransferWithAuthorization",
"domain": {"name": req["extra"]["name"], "version": req["extra"]["version"],
"chainId": CHAIN_IDS[req["network"]], "verifyingContract": req["asset"]},
"message": {..., "value": amount, "validAfter": 0, "validBefore": ..., "nonce": bytes.fromhex(...)}}
sig = "0x" + bytes(acct.sign_typed_data(full_message=typed).signature).hex()
Some notes from doing this with eth-account 0.13.7:
-
LocalAccount.sign_typed_data(full_message=...)takes the full EIP-712 dict, including theEIP712Domaintype list. - This version has no
recover_typed_data. To check your own signature, useAccount.recover_message(encode_typed_data(full_message=typed), signature=sig). The script does that before it sends anything. - Newer
hexbytesreturns.hex()without a0xprefix, so the script builds the prefix itself. -
uint256fields are ints in the signed message and decimal strings in the JSON payload. Thebytes32nonce is bytes when signing and a0xhex string on the wire.
If you're unsure whether extra.name is right, there's a cheap check. encode_typed_data(...).header is the EIP-712 domain separator, and the token exposes its own as DOMAIN_SEPARATOR(). For USDC on Base both are 0x02fa7265…834f. If they differ, every signature you produce will be rejected.
One server detail: the x402 v1 spec has the verifier check that the amount "meets or exceeds" the requirement, but StorkHost requires value to equal the amount exactly. Signing exactly what was asked is the right budget habit anyway.
Encode the payment: X-PAYMENT vs PAYMENT-SIGNATURE
Both versions send base64 JSON. What changes is the envelope:
if version == 1:
header = "X-PAYMENT", b64json({"x402Version": 1, "scheme": "exact", "network": req["network"],
"payload": {"signature": sig, "authorization": auth}})
else:
header = "PAYMENT-SIGNATURE", b64json({"x402Version": 2, "resource": challenge["resource"],
"accepted": req, # the offer, unchanged
"payload": {"signature": sig, "authorization": auth},
"extensions": challenge["extensions"]}) # if present
- v1 names the scheme and network at the top level.
-
v2 echoes the whole requirement you chose as
accepted. Echo it unchanged. StorkHost compares it field by field (scheme, network, amount, asset, payTo, maxTimeoutSeconds, extra) and rejects any mismatch. - v2 extensions: the spec says clients echo the extensions the server declared, so the script does too. Here that's the Bazaar discovery block, which is why our v2 header is 2,640 characters against 628 for v1.
Send exactly one payment header per request.
Read the result
The receipt comes back in PAYMENT-RESPONSE (v2) or X-PAYMENT-RESPONSE (v1). It's base64 JSON with success, transaction, network and payer. The status codes you need to handle:
- 200: settled. Decode the receipt for the transaction hash.
-
202: settlement is pending. Poll the
LocationURL no faster thanRetry-After. Don't sign a second payment while you wait. A v2 server may also send a receipt withsuccess: falseanderrorReason: "settlement_pending", plus the broadcast transaction hash. -
503 with
Retry-After: StorkHost uses this for "delayed". Retry the same authorization, or keep polling. -
402 again: the payment was refused. The body or receipt says why. The spec maps a malformed payload to 400, and StorkHost answers 402 with an
errorstring, so handle both.
for _ in range(40):
r = requests.get(target, headers=headers, timeout=120)
if r.status_code not in (202, 503):
break
if r.status_code == 202: # pending: poll the status URL, never sign a second payment
target = urljoin(target, r.headers.get("Location") or r.json()["pollUrl"])
headers = {}
time.sleep(retry_after(r))
What's specific to StorkHost (and what isn't universal)
What StorkHost does with this endpoint, per its llms.txt and openapi.json:
-
No API key. The account credited is the one whose signup wallet signed the authorization (
authorization.from). An API key sent along is ignored. - The payer needs an account. A wallet with no account is refused with a 402 before anything is submitted or charged. Signup is free: you sign a nonce with the wallet, nothing else.
-
Self-settled. There's no facilitator. StorkHost verifies the signature, calls
transferWithAuthorizationitself and pays the gas. - Limits. USDC on Base only, minimum top-up $1.00, maximum $1000.00, whole cents. A smaller amount gets a 400 before any challenge is issued.
-
Responses show payment status only. Read the balance with your API key at
GET /v1/account. - Beta. StorkHost is in beta, and top-ups are on-chain and final.
What other x402 servers may do differently:
- Many delegate to a facilitator that exposes
/verifyand/settle. In the defaultauthorizationflow, settlement happens after the resource runs. - They may offer other networks, or tokens without EIP-3009. Those use Permit2 and set
extra.assetTransferMethod. - They may use other schemes, such as
upto.
The script refuses anything that isn't exact + EIP-3009 + USDC on Base mainnet. Widening it means adding to CHAIN_IDS and the asset check deliberately, not loosening them.
Running it
$ python x402_pay.py "https://api.storkhost.cloud/v1/x402/topup?amountUsd=1.00"
x402 v2: 1.000000 USDC to 0x810770C050FeBEA7D1E6f643855e619a8076d1d3 on eip155:8453
resource: https://api.storkhost.cloud/v1/x402/topup?amountUsd=1.00
signer: 0x8C00ebb234dD9Ac15EE7BF460C421332f6DDbBC2 (throwaway key)
PAYMENT-SIGNATURE: eyJ4NDAyVmVyc2lvbiI6MiwicmVzb3VyY2UiOnsidXJsIjoi... (2640 chars), decoded:
{
"x402Version": 2,
"resource": { ... },
"accepted": { "scheme": "exact", "network": "eip155:8453", "amount": "1000000", ... },
"payload": {
"signature": "0x16e5c61b...2cac1b",
"authorization": {
"from": "0x8C00ebb234dD9Ac15EE7BF460C421332f6DDbBC2",
"to": "0x810770C050FeBEA7D1E6f643855e619a8076d1d3",
"value": "1000000",
"validAfter": "0",
"validBefore": "1792838296",
"nonce": "0x3b91b997...59482d"
}
},
"extensions": { "bazaar": "(echoed from the 402)" }
}
dry run: nothing was sent
With no X402_WALLET_KEY set, the dry run signs with a throwaway key that is never saved, so you can inspect the exact bytes before any real key is involved. --v1 builds the X-PAYMENT form instead. --max-usd 0.50 against this $1.00 challenge prints refusing to sign: amount 1.000000 USDC is outside (0, 0.500000].
For transparency: we didn't send a payment while writing this. We verified the signatures offline. They recover to the signer, the domain separator matches the one the USDC contract reports on chain, and changing value by one unit breaks recovery. The paying branch, including the 202 → poll → 200 path, was run only against a local mock server.
The script
Save it as x402_pay.py and run pip install requests eth-account.
"""Minimal x402 client for the `exact` scheme (EIP-3009 USDC on Base). Dry run unless told otherwise.
pip install requests eth-account
python x402_pay.py URL # fetch the 402, check it, sign with a throwaway key, send nothing
python x402_pay.py URL --v1 # same, but answer the v1 JSON body with X-PAYMENT
X402_WALLET_KEY=0x... python x402_pay.py URL --pay --pay-to 0xExpectedPayTo --max-usd 1.00
"""
import argparse, base64, json, os, secrets, sys, time
from decimal import Decimal
from urllib.parse import urljoin, urlsplit
import requests
from eth_account import Account
from eth_account.messages import encode_typed_data
USDC_ON_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
CHAIN_IDS = {"base": 8453, "eip155:8453": 8453} # v1 network name and v2 CAIP-2 id -> EIP-155 chain id
TYPES = {
"EIP712Domain": [{"name": "name", "type": "string"}, {"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"}, {"name": "verifyingContract", "type": "address"}],
"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"}],
}
def b64json(obj):
return base64.b64encode(json.dumps(obj, separators=(",", ":")).encode()).decode()
def unb64json(text):
return json.loads(base64.b64decode(text))
def read_challenge(resp, want_v1):
"""Return (version, PaymentRequired object, chosen requirement) from a 402 response."""
if resp.status_code != 402:
sys.exit(f"expected HTTP 402, got {resp.status_code}: {resp.text[:200]}")
header = resp.headers.get("PAYMENT-REQUIRED")
version, challenge = (2, unb64json(header)) if header and not want_v1 else (1, resp.json())
if challenge.get("x402Version") != version:
sys.exit(f"challenge says x402Version={challenge.get('x402Version')}, expected {version}")
offers = [r for r in challenge.get("accepts", [])
if r.get("scheme") == "exact" and r.get("network") in CHAIN_IDS]
if not offers:
sys.exit("no 'exact' offer on Base mainnet in accepts[]")
return version, challenge, offers[0]
def check(version, challenge, req, url, pay_to, cap_atomic):
"""Refuse to sign anything we did not expect. Returns (amount in atomic units, resource URL)."""
amount = int(req["amount"] if version == 2 else req["maxAmountRequired"])
resource = challenge["resource"]["url"] if version == 2 else req["resource"]
extra = req.get("extra") or {}
problems = []
if req["asset"].lower() != USDC_ON_BASE.lower():
problems.append(f"asset {req['asset']} is not USDC on Base")
if not 0 < amount <= cap_atomic:
problems.append(f"amount {Decimal(amount).scaleb(-6)} USDC is outside (0, {Decimal(cap_atomic).scaleb(-6)}]")
if pay_to and req["payTo"].lower() != pay_to.lower():
problems.append(f"payTo {req['payTo']} is not the expected {pay_to}")
if urlsplit(resource).netloc != urlsplit(url).netloc:
problems.append(f"resource {resource} is not on the host we called")
if not extra.get("name") or not extra.get("version"):
problems.append("extra.name / extra.version (the token's EIP-712 domain) missing")
if extra.get("assetTransferMethod", "eip3009") != "eip3009":
problems.append(f"transfer method {extra['assetTransferMethod']} is not EIP-3009")
if not 0 < int(req["maxTimeoutSeconds"]) <= 3600:
problems.append(f"maxTimeoutSeconds {req['maxTimeoutSeconds']} looks wrong")
if problems:
sys.exit("refusing to sign:\n " + "\n ".join(problems))
return amount, resource
def sign(acct, req, amount):
"""EIP-712 sign an EIP-3009 transferWithAuthorization for exactly this offer."""
auth = {"from": acct.address, "to": req["payTo"], "value": str(amount), "validAfter": "0",
"validBefore": str(int(time.time()) + int(req["maxTimeoutSeconds"])),
"nonce": "0x" + secrets.token_hex(32)}
typed = {"types": TYPES, "primaryType": "TransferWithAuthorization",
"domain": {"name": req["extra"]["name"], "version": req["extra"]["version"],
"chainId": CHAIN_IDS[req["network"]], "verifyingContract": req["asset"]},
"message": {"from": auth["from"], "to": auth["to"], "value": amount, "validAfter": 0,
"validBefore": int(auth["validBefore"]), "nonce": bytes.fromhex(auth["nonce"][2:])}}
sig = "0x" + bytes(acct.sign_typed_data(full_message=typed).signature).hex()
if Account.recover_message(encode_typed_data(full_message=typed), signature=sig) != acct.address:
sys.exit("signature does not recover to our own address; not sending it")
return {"signature": sig, "authorization": auth}
def payment_header(version, challenge, req, payload):
if version == 1:
return "X-PAYMENT", b64json({"x402Version": 1, "scheme": "exact", "network": req["network"],
"payload": payload})
body = {"x402Version": 2, "resource": challenge["resource"], "accepted": req, "payload": payload}
if challenge.get("extensions"):
body["extensions"] = challenge["extensions"] # v2: echo what the server declared
return "PAYMENT-SIGNATURE", b64json(body)
def retry_after(resp):
value = resp.headers.get("Retry-After", "5")
return max(1, int(value)) if value.isdigit() else 5
def main():
ap = argparse.ArgumentParser(description="Pay an x402 'exact' challenge (dry run by default).")
ap.add_argument("url")
ap.add_argument("--v1", action="store_true", help="answer the v1 body with X-PAYMENT")
ap.add_argument("--max-usd", default="1.00", help="refuse challenges above this amount (default 1.00)")
ap.add_argument("--pay-to", help="the payTo address you expect; required with --pay")
ap.add_argument("--pay", action="store_true", help="actually send the signed payment")
args = ap.parse_args()
key = os.environ.get("X402_WALLET_KEY")
if args.pay and not (key and args.pay_to):
sys.exit("--pay needs X402_WALLET_KEY in the environment and --pay-to")
acct = Account.from_key(key) if key else Account.create() # no key: throwaway, never saved
cap_atomic = int(Decimal(args.max_usd) * 10**6)
version, challenge, req = read_challenge(requests.get(args.url, timeout=30), args.v1)
amount, resource = check(version, challenge, req, args.url, args.pay_to, cap_atomic)
print(f"x402 v{version}: {Decimal(amount).scaleb(-6)} USDC to {req['payTo']} on {req['network']}")
print(f"resource: {resource}\nsigner: {acct.address}{'' if key else ' (throwaway key)'}")
name, value = payment_header(version, challenge, req, sign(acct, req, amount))
shown = unb64json(value)
if "extensions" in shown:
shown["extensions"] = {k: "(echoed from the 402)" for k in shown["extensions"]}
print(f"\n{name}: {value[:48]}... ({len(value)} chars), decoded:\n{json.dumps(shown, indent=2)}")
if not args.pay:
print("\ndry run: nothing was sent")
return
target, headers = args.url, {name: value}
for _ in range(40):
r = requests.get(target, headers=headers, timeout=120)
if r.status_code not in (202, 503):
break
if r.status_code == 202: # pending: poll the status URL, never sign a second payment
target = urljoin(target, r.headers.get("Location") or r.json()["pollUrl"])
headers = {}
print(f"HTTP {r.status_code}: waiting {retry_after(r)}s, same authorization")
time.sleep(retry_after(r))
receipt = r.headers.get("PAYMENT-RESPONSE") or r.headers.get("X-PAYMENT-RESPONSE")
print(f"HTTP {r.status_code}\nreceipt: {json.dumps(unb64json(receipt)) if receipt else None}\n{r.text[:500]}")
if __name__ == "__main__":
main()
To pay for real: create a StorkHost account for the wallet first, hold at least the amount in USDC on Base, then run:
X402_WALLET_KEY=0x... python x402_pay.py "https://api.storkhost.cloud/v1/x402/topup?amountUsd=1.00" \
--pay --pay-to 0x810770C050FeBEA7D1E6f643855e619a8076d1d3 --max-usd 1.00
Treat the payTo above as an example. Confirm it against the live challenge and the docs, not against a blog post.
References: x402 specs at commit 4241d56 (v1, v2, HTTP transport v1 / v2, exact on EVM), EIP-3009, EIP-712, and StorkHost's llms.txt.
Top comments (0)