402 Payment Required, the client signs a transfer and retries with an X-PAYMENT header, the server settles on chain before returning the resource. This page gets you both halves of that flow as two small TypeScript files, running on The DevNet Grid, using nothing but the gen CLI and Node.
You’ll run the server on 127.0.0.1:4021 and the buyer in a second terminal. Both talk to DevNet through the same CLI configuration; nothing here requires the framework-preview repo or a local validator.
Prerequisites
- The
genCLI on yourPATH. See Install the CLI. - Node 22.6 or later (for
--experimental-strip-types). Node 20 works if you swapnode --experimental-strip-types <file>.tsfornpx tsx <file>.ts. - DevNet credentials (RPC URL and JWT). If you don’t have one yet, see Quickstart for how to obtain a token.
Step 1: Point the CLI at DevNet
DevNet access. Ask your Gen Labs contact for the RPC URL and a bearer token; substitute them for
<DEVNET_RPC_URL> and <your-jwt> below.gen config show should report rpc_url: "<DEVNET_RPC_URL>" and an authorization header. Both the server and the buyer below shell out to the same gen binary, so this setup applies to both.
Step 2: Create and fund two wallets
The buyer (alice) signs and spends. The merchant (server) receives. The faucet creates each account on first use and funds it; nothing else needs bootstrapping.
--plaintext stores the private key unencrypted on disk. Fine for automation accounts that hold operating funds only.
ALICE_ACCT is the buyer’s account; the buyer signs from this wallet. SERVER_HOLDER is the merchant’s four-segment Holder component id for the GEN token contract. That’s what gets advertised as payTo, because build-activation’s destination parameter requires a Holder, not an account. SERVER_ACCT is only used to derive SERVER_HOLDER above; you can drop it after capture.
The server
Save this asserver.ts. ~100 lines, Node 20+, no dependencies:
The buyer
Save this asbuyer.ts. ~60 lines:
Run it
Two terminals. In the first, start the server:Walking through it
The server
challenge() builds the 402 body once per request. It advertises scheme: "gen-exact" and network: "gen-devnet-1"; the label is just a name both halves agree on, so pick anything you want as long as the buyer matches. payTo is the merchant’s bech32m account locator: the same string the reader saw printed from gen wallet show.
settle() is the verify-and-submit pipeline: base64-decode the envelope, check the scheme and network match, SHA-256 the signed bytes against an in-memory dedup map (5-minute TTL), submit the activation through gen client submit-activation --signed-activation-file, then poll gen client get-activation until the chain reports status: "success" or failed. The poll loop has a 30 s ceiling; on DevNet, success usually arrives in well under a second.
The seen-set lives in process memory. A server restart drops it. For a production gate, replace it with a backing store keyed on the signed-bytes hash.
The buyer
gen() is a thin shell-out helper that calls gen <group> --json <subcommand> ... and parses the result. Every wallet operation goes through it.
The pay-then-fetch loop: probe the URL, parse the accepts[] array, pick the entry that advertises your scheme and network, build the unsigned activation with gen wallet build-activation, sign with gen wallet sign-activation and a fresh --activation-tag, wrap the result in the x402 envelope, retry with X-PAYMENT set.
Two details that matter:
--activation-tagis au32mixed into the signing pre-image. Two activations with identical fields and the same tag hash to the same bytes; the server’s seen-set rejects the second one as a replay. The buyer seeds fromDate.now() & 0x3fffffffand increments per call.- The inner
signed_activationis base64 of the raw signed bytes, not the hex string. The buyer’s encode chain is hex → bytes → base64, then envelope JSON → base64.
When the server says no
Every payment-side failure comes back as another402 with a specific error string. The ones worth handling explicitly:
error | Cause | Fix |
|---|---|---|
"X-PAYMENT header is required" | First request, no header attached. | Loop into the build / sign / retry path. |
"X-PAYMENT malformed" | Outer envelope is not valid base64-JSON. | You base64-encoded the inner bytes instead of the envelope. |
"unsupported scheme or network" | Your envelope’s scheme or network does not match any accepts[] entry. | Both must be exact string matches. |
"payment already used" | Tag collision. The server hashes signed bytes into a seen-set with a 5-minute TTL. | Generate a fresh --activation-tag. A client restart that re-seeds from the wall clock avoids this. |
"settlement failed" | The activation was submitted but the chain rejected it. | Wallet balance too low, or the signature was rejected. The failing activation_id is in the 402 response body; look it up with gen client get-activation --activation-id <id>. |
"settlement timed out" | Finalization exceeded 30 seconds. | Usually means the server’s poll path is wrong, not that the chain is slow. Check gen client get-activation against the failing activation_id from the response body. |
402 is non-x402 and should be surfaced to the caller as-is.
References
gen walletfor create / faucet / build-activation / sign-activation.- Sending transfers for the underlying submit / sign / get-activation surface.
- Quickstart for DevNet credentials and first-time setup.
- x402.org for the protocol spec.

