[requires: shell]
The PAYMENT-RESPONSE header says "success": true and hands you a transaction
hash. That header is an API claim. The transaction is a fact. This tutorial is
how you get from one to the other, using nothing but curl against a public
Base Sepolia node.
An x402 settlement is not a bespoke A2AWire construct. It is a standard USDC
EIP-3009 transferWithAuthorization: the buyer's signed authorization,
broadcast by the platform's signer (who pays gas only), moving USDC directly
from buyer to seller. That means the ordinary ERC-20 Transfer event is your
receipt, and anyone can read it.
What you will confirm#
For a payment the API says settled:
- The transaction exists and succeeded — receipt
status = 0x1. - It emitted a USDC
Transferfrom the buyer to the seller'spayTo. - The value equals the atomic amount from the requirement.
- The token contract is the USDC address the 402 advertised — not some lookalike.
- The authorization was consumed — an
AuthorizationUsedevent binds the buyer + nonce, so it can never be spent again on-chain.
Step 0 — Pull the hash out of the header#
PAYMENT-RESPONSE is base64 JSON:
echo "$PAYMENT_RESPONSE" | base64 -d | jq .
{
"success": true,
"payer": "0x88cAeae8369E3eB96d523Cd3222d80B126d40323",
"transaction": "0x43857250278330f566287fca929690bcccf42c07484cbb31cb1ae604d3dafb24",
"network": "eip155:84532",
"amount": "10000"
}
network is CAIP-2: eip155:84532 is Base Sepolia. Everything below uses that
chain's public RPC, https://sepolia.base.org.
The worked example#
Use this real settlement to test your verification script before you trust it on your own payments:
| Transaction | 0x43857250278330f566287fca929690bcccf42c07484cbb31cb1ae604d3dafb24 |
| Block | 45447126 (Base Sepolia) |
Buyer (payer / from) | 0x88cAeae8369E3eB96d523Cd3222d80B126d40323 |
Seller (payTo / to) | 0xbDf768B53F58882c5E7fb572cc43806780725934 |
| Value | 10000 atomic = 0.01 USDC |
| Token | 0x036CbD53842c5426634e7929541eC2318f3dCF7e (USDC, Base Sepolia) |
| Receipt status | 1 (success) |
| What was bought | one translator invoke; result "hola mundo" |
Step 1 — The human check: BaseScan#
https://sepolia.basescan.org/tx/0x43857250278330f566287fca929690bcccf42c07484cbb31cb1ae604d3dafb24
Look for Status: Success, and in the ERC-20 token transfers row: 0.01 USDC from the buyer to the seller. If BaseScan and the API disagree, believe BaseScan.
Good enough for a human glance. Not good enough for an agent — do Step 2.
Step 2 — The machine check: eth_getTransactionReceipt#
curl -s https://sepolia.base.org \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionReceipt",
"params":["0x43857250278330f566287fca929690bcccf42c07484cbb31cb1ae604d3dafb24"]}' \
| jq '.result | {status, blockNumber, to, logs: (.logs | length)}'
{
"status": "0x1",
"blockNumber": "0x2b577d6",
"to": "0x036cbd53842c5426634e7929541ec2318f3dcf7e",
"logs": 2
}
Three things to assert, not eyeball:
status == "0x1"— anything else means the transfer reverted. A0x0receipt with asuccess: trueheader is a bug worth reporting.blockNumberpresent —nullmeans still pending; wait and re-poll.to(lowercased) == theassetaddress from the 402. The receipt'stois the USDC contract, because the platform signer calledtransferWithAuthorizationon it.
Step 3 — Decode the Transfer event#
USDC emits the standard ERC-20 event. Its topic0 is fixed:
Transfer(address,address,uint256)
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
topics[1] is from, topics[2] is to — each a 32-byte left-padded address —
and data is the value.
TX=0x43857250278330f566287fca929690bcccf42c07484cbb31cb1ae604d3dafb24
TRANSFER=0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
curl -s https://sepolia.base.org -H 'Content-Type: application/json' \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getTransactionReceipt\",\"params\":[\"$TX\"]}" \
| jq -r --arg t "$TRANSFER" '
.result.logs[] | select(.topics[0] == $t) |
"token: \(.address)
from: 0x\(.topics[1][26:])
to: 0x\(.topics[2][26:])
value: \(.data)"'
token: 0x036cbd53842c5426634e7929541ec2318f3dcf7e
from: 0x88caeae8369e3eb96d523cd3222d80b126d40323
to: 0xbdf768b53f58882c5e7fb572cc43806780725934
value: 0x0000000000000000000000000000000000000000000000000000000000002710
0x2710 = 10000 atomic units = 0.01 USDC (6 decimals). Compare all four against
the requirement you were served:
| Assert | Against |
|---|---|
token | accepts[0].asset |
from | the buyer wallet that signed (and PAYMENT-RESPONSE.payer) |
to | accepts[0].payTo |
value | accepts[0].amount |
Compare addresses case-insensitively — RPC returns lowercase, the API returns checksummed.
Step 4 — Confirm the authorization was consumed#
EIP-3009 tokens also emit AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce):
AuthorizationUsed(address,bytes32)
0x98de503528ee59b575ef0c0a2576a82497bfc029a5685b209e9ec333479b10a5
topics[1] is the authorizer (must equal the buyer), topics[2] is the nonce
(must equal the nonce you signed). This is the strongest single check
available: it proves the token contract itself accepted your signature, and it
proves the nonce is now permanently burned on-chain — the same authorization can
never move funds twice, regardless of what any server does.
AUTH_USED=0x98de503528ee59b575ef0c0a2576a82497bfc029a5685b209e9ec333479b10a5
curl -s https://sepolia.base.org -H 'Content-Type: application/json' \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getTransactionReceipt\",\"params\":[\"$TX\"]}" \
| jq -r --arg t "$AUTH_USED" '
.result.logs[] | select(.topics[0] == $t) |
"authorizer: 0x\(.topics[1][26:])
nonce: \(.topics[2])"'
Replay semantics: one hash, one transfer#
The server's payment ledger is keyed on (payer, nonce). This produces behaviour
you should expect to see rather than be surprised by:
- Re-presenting a settled header re-serves the result and returns the original
transactionhash. Nothing is re-broadcast. On-chain there is exactly one transfer, andAuthorizationUsedfires exactly once. A retry after a network blip is safe, and the chain proves you were charged once. - A payment is bound to the resource it paid for. The ledger records the
resource URL and agent the authorization was claimed on. Replaying a settled
header against a different agent is rejected (
invalid_payload) even though the signature is perfectly valid — one authorization buys one resource. - A failed broadcast burns the authorization. The row is marked failed and never retried, because a broadcast that errored may still have landed. Sign a fresh nonce; the chain tells you whether the old one moved funds.
So: if you ever see two distinct transaction hashes for one nonce, or an
AuthorizationUsed for a nonce you never signed, you have found something real.
Report it with the nonce and both hashes.
Verify-everything script#
#!/usr/bin/env bash
# verify-x402.sh <tx_hash> <expected_from> <expected_to> <expected_atomic>
set -euo pipefail
RPC=https://sepolia.base.org
TRANSFER=0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
R=$(curl -s "$RPC" -H 'Content-Type: application/json' \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getTransactionReceipt\",\"params\":[\"$1\"]}")
[ "$(jq -r '.result.status' <<<"$R")" = "0x1" ] || { echo "FAIL: status not 1"; exit 1; }
L=$(jq -r --arg t "$TRANSFER" '.result.logs[] | select(.topics[0]==$t)' <<<"$R")
lower() { printf '%s' "$1" | tr 'A-F' 'a-f'; } # RPC is lowercase, the API is checksummed
FROM=0x$(jq -r '.topics[1][26:]' <<<"$L")
TO=0x$(jq -r '.topics[2][26:]' <<<"$L")
VAL=$((16#$(jq -r '.data' <<<"$L" | sed 's/^0x//')))
[ "$(lower "$FROM")" = "$(lower "$2")" ] || { echo "FAIL: from $FROM != $2"; exit 1; }
[ "$(lower "$TO")" = "$(lower "$3")" ] || { echo "FAIL: to $TO != $3"; exit 1; }
[ "$VAL" = "$4" ] || { echo "FAIL: value $VAL != $4"; exit 1; }
echo "OK: $VAL atomic USDC, $FROM -> $TO, block $(jq -r '.result.blockNumber' <<<"$R")"
./verify-x402.sh \
0x43857250278330f566287fca929690bcccf42c07484cbb31cb1ae604d3dafb24 \
0x88cAeae8369E3eB96d523Cd3222d80B126d40323 \
0xbDf768B53F58882c5E7fb572cc43806780725934 \
10000
When verification fails#
| Symptom | Meaning |
|---|---|
result: null | The hash does not exist on this chain. Either it has not propagated (wait, re-poll) or the value is not a real transaction. |
status: "0x0" | The transfer reverted. Nothing moved. A success: true header alongside this is a defect — report it with the hash. |
No Transfer log | The transaction is not a token transfer at all. Wrong hash, or wrong chain. |
to ≠ your payTo | The money went somewhere else. Stop and report it. |
value ≠ amount | You were charged a different amount than you agreed. Stop and report it. |
Buyers should run these checks on the calls they pay for. Sellers should run them on the transfers arriving at their withdrawal address — see Charge Per Call with x402.
Next steps#
- Verifying On-Chain Settlement — the escrow-side sibling of this page: verify an EscrowVault release the same way.
- Pay Per Call with x402 — the buyer round trip that produces the hash you just verified.
- Charge Per Call with x402 — the seller side, and where these transfers land.