reference · x402

How a 402 unlock is actually verified.

Hedera has no public x402 facilitator, so this app is both halves: the endpoint issues the challenge and the endpoint verifies settlement against the ledger. Below is every record it reads, every check it runs, and every reason it says no. The live endpoint is /api/public/x402-paid-content.

The four moves

  • 01Challenge

    An unauthenticated GET returns HTTP 402 with an x402 v2 body: { x402Version: 2, accepts: [...] }. Two requirements are offered — native HBAR and HTS USDC — both scheme "exact" on network hedera:testnet. The same JSON is mirrored, base64-encoded, in a PAYMENT-REQUIRED header.

  • 02Settle first, then retry

    Hedera has no public facilitator, so the client pays on-chain itself, waits for consensus, and only then retries the resource. Retrying immediately produces settlement_not_found even though the payment is perfectly fine.

  • 03Envelope

    The retry carries PAYMENT-SIGNATURE: base64 of { x402Version: 2, accepted: <the requirement echoed back>, payload: { transactionId, payer } }. There is no signature to check here — the on-chain record is the proof, so the envelope only has to name it.

  • 04Verify against the ledger

    The server re-derives the requirement itself and compares scheme, network, asset, amount and payTo field by field. Only then does it read the mirror node. Nothing the client says about the payment is trusted.

// the retry header, decoded
{
  "x402Version": 2,
  "accepted": { "scheme": "exact", "network": "hedera:testnet",
                "asset": "0.0.429274", "amount": "10000",
                "payTo": "0.0.9822626" },
  "payload":  { "transactionId": "0x…", "payer": "0.0.x or 0x…" }
}

Three settlement paths, three records

The transaction id in the envelope decides which record is authoritative. A 0x hash means the payment went through the relay and lives on the EVM contract-result endpoint; a 0.0.x-seconds-nanos id means it was submitted natively and lives on the consensus transaction endpoint.

HBAR (via the JSON-RPC relay)

envelope id
0x… 64-hex ethereum hash
mirror-node record
/contracts/results/{hash}
checks, all must pass
  • result / status is SUCCESS or 0x1
  • tx.to is one of the payee's address forms
  • tx.from is one of the declared payer's address forms
  • tx.amount (tinybars) ≥ 1,000,000 = 0.01 HBAR

HTS USDC (via the JSON-RPC relay)

envelope id
0x… 64-hex ethereum hash
mirror-node record
/contracts/results/{hash} → logs[]
checks, all must pass
  • tx.to is the USDC contract 0x…068cda, not the payee
  • tx.from is one of the payer's address forms
  • an ERC-20 Transfer log on the USDC address, payer → payee
  • summed log data ≥ 10,000 atomic units = 0.01 USDC

HTS USDC (submitted natively)

envelope id
0.0.x-seconds-nanos consensus id
mirror-node record
/transactions/{mirrorId}
checks, all must pass
  • result is SUCCESS
  • token_transfers filtered to token 0.0.429274
  • payee credited exactly the amount, payer debited exactly −amount
  • the token_transfers net to zero

The USDC Transfer log, in full

This is the single most important record on the page, because it is the only place a relay-submitted HTS transfer proves its amount. Read logs[] off the contract result and match on address plus topic:

logs[i].address = 0x0000000000000000000000000000000000068cda   // USDC on Hedera testnet
topics[0]       = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
topics[1]       = payer  (32-byte padded)
topics[2]       = payee  (32-byte padded)
data            = amount (atomic, 6 decimals — 0x2710 = 10000 = 0.01 USDC)

const unpad = (t) => `0x${t.replace(/^0x/, "").slice(-40)}`;
credited += BigInt(log.data);   // only when payer AND payee forms both match

The consensus timestamp on the same record is what bounds the validity window: older than maxTimeoutSeconds (300s) or more than 60s in the future and the settlement is refused.

Every rejection reason

A failed verification returns another 402 with the same accepts[] plus an error string. Those strings are the only diagnostic a client gets, so they are printed verbatim in the demo's flow log.

invalid_payload: envelope shapex402Version is not 2, or accepted / transactionId / payer is missing.
invalid_payload: requirement mismatchThe echoed requirement disagrees with the server's own — a changed amount or payee.
invalid_payload: malformed transaction idNeither a 0x 64-hex hash nor a 0.0.x-seconds-nanos consensus id.
settlement_not_foundThe mirror node still has no record after the retry loop — usually retried before consensus.
settlement_failed: consensus result …The transaction reached consensus but failed, e.g. CONTRACT_REVERT_EXECUTED.
invalid_payload: settlement outside the validity windowConsensus timestamp older than 300s or more than 60s in the future.
invalid_payload: expected the transfer to reach …The recipient on the record is not the configured payee, in any address form.
invalid_payload: declared payer … is not the senderThe envelope names a payer who did not send the payment.
invalid_payload: no ERC-20 Transfer eventA USDC settlement with no Transfer log on the token address — nothing moved.
invalid_payload: transaction_already_usedReplay: a settled transaction unlocks the resource exactly once.

Two more guards sit in front of all of this: a per-IP rate limit (12 requests a minute) and a spent-transaction set, so one settlement can never unlock the resource twice.

Traps we walked into

A USDC payment verifies as amount 0

HTS movements caused by an ERC-20 call are NOT on the parent consensus record — its token_transfers array is empty — and /contracts/results/{hash} carries no transaction_id to hop to the child CRYPTOTRANSFER. The authoritative signal ships with the EVM record: the ERC-20 Transfer log.

A valid payment is rejected as the wrong payer or payee

tx.from / tx.to and the 32-byte event topics use different address shapes. Collect both the long-zero form and the ECDSA evm_address alias from /accounts/{id} and match against the set; un-pad topics with 0x + topic.slice(-40).

Verification flakes right after submitting

The mirror node lags consensus by seconds. Never read once — poll with a bounded retry loop (8–10 attempts, ~1.2–1.5s apart) on both /transactions/{id} and /contracts/results/{hash}.

The client reported success but nothing settled

A relay transfer at a low gas limit returns a hash and fails on-chain with INSUFFICIENT_GAS. Budget ~900,000 gas and treat a returned hash as "submitted", never "settled".

The address-form and gas traps are the same seam documented in detail on the EVM ↔ native page.

Check it yourself