Skip to main content
x402 attaches per-request payment to HTTP. A server quotes a price in 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 gen CLI on your PATH. See Install the CLI.
  • Node 22.6 or later (for --experimental-strip-types). Node 20 works if you swap node --experimental-strip-types <file>.ts for npx 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 set rpc-url <DEVNET_RPC_URL>
gen config set header authorization "Bearer <your-jwt>"
gen config show
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.
gen wallet create alice  --plaintext
gen wallet create server --plaintext

gen wallet faucet --wallet alice  --amount 1000000000
gen wallet faucet --wallet server --amount 1000

ALICE_ACCT=$(gen wallet --json show alice  --account-address-only | jq -r .result.account)
SERVER_ACCT=$(gen wallet --json show server --account-address-only | jq -r .result.account)

# build-activation's `destination` requires the recipient's
# 4-segment Holder component id, not the account. There's no
# direct CLI that prints it today; capture it from one tiny
# self-transfer (which echoes sender_component_id in --json).
SERVER_HOLDER=$(gen wallet --json transfer --wallet server \
  --to "$SERVER_ACCT" --amount 1 --yes \
  | jq -r .result.sender_component_id)

echo "ALICE_ACCT=$ALICE_ACCT"
echo "SERVER_ACCT=$SERVER_ACCT"
echo "SERVER_HOLDER=$SERVER_HOLDER"
--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 as server.ts. ~100 lines, Node 20+, no dependencies:
import { spawnSync } from "node:child_process";
import { createServer } from "node:http";
import { createHash } from "node:crypto";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const PORT     = Number(process.env.PORT ?? 4021);
const PAY_TO   = process.env.PAY_TO;
const PRICE    = process.env.PRICE_SUBUNITS ?? "1000";
const NETWORK  = "gen-devnet-1";
const SCHEME   = "gen-exact";

if (!PAY_TO) {
  console.error("set PAY_TO to the merchant wallet's grd@... account locator");
  process.exit(1);
}

function gen(args: string[]) {
  const [group, ...rest] = args;
  const r = spawnSync("gen", [group, "--json", ...rest], { encoding: "utf8" });
  const j = JSON.parse(r.stdout);
  if (j.ok === false) throw new Error(`gen ${args.join(" ")}: ${j.error}`);
  return j.result;
}

const seen = new Map<string, number>();
const SEEN_TTL_MS = 5 * 60 * 1000;

function challenge(resource: string) {
  return {
    x402Version: 1,
    error: "X-PAYMENT header is required",
    accepts: [{
      scheme: SCHEME, network: NETWORK, asset: "GEN",
      payTo: PAY_TO, maxAmountRequired: PRICE,
      resource, mimeType: "application/json",
      maxTimeoutSeconds: 30, extra: {},
    }],
  };
}

async function settle(headerB64: string): Promise<string> {
  let envelope: any;
  try {
    envelope = JSON.parse(Buffer.from(headerB64, "base64").toString("utf8"));
  } catch {
    throw { code: 402, body: { x402Version: 1, error: "X-PAYMENT malformed" } };
  }
  if (envelope.scheme !== SCHEME || envelope.network !== NETWORK) {
    throw { code: 402, body: { x402Version: 1, error: "unsupported scheme or network" } };
  }
  const signedB64 = envelope.payload?.signed_activation;
  if (!signedB64) throw { code: 402, body: { x402Version: 1, error: "X-PAYMENT malformed" } };

  const bytes = Buffer.from(signedB64, "base64");
  const hash  = createHash("sha256").update(bytes).digest("hex");

  const now = Date.now();
  for (const [k, t] of seen) if (now - t > SEEN_TTL_MS) seen.delete(k);
  if (seen.has(hash)) throw { code: 402, body: { x402Version: 1, error: "payment already used" } };
  seen.set(hash, now);

  // gen client submit-activation reads signed bytes from a file, not stdin or argv.
  const dir = mkdtempSync(join(tmpdir(), "x402-"));
  const file = join(dir, "signed.txt");
  writeFileSync(file, "0x" + bytes.toString("hex"));
  let activationId: string;
  try {
    activationId = gen(["client", "submit-activation", "--signed-activation-file", file]).activation_id;
  } finally {
    rmSync(dir, { recursive: true, force: true });
  }

  // Poll until the chain finalizes.
  const deadline = Date.now() + 30_000;
  while (Date.now() < deadline) {
    const got = gen(["client", "get-activation", "--activation-id", activationId]);
    const status = got?.activation?.status;
    if (status === "success") return activationId;
    if (status === "failed")  throw { code: 402, body: { x402Version: 1, error: "settlement failed", activation_id: activationId } };
    await new Promise((r) => setTimeout(r, 200));
  }
  throw { code: 402, body: { x402Version: 1, error: "settlement timed out", activation_id: activationId } };
}

createServer(async (req, res) => {
  if (!req.url?.startsWith("/api/quote/")) { res.writeHead(404).end(); return; }
  const symbol = decodeURIComponent(req.url.slice("/api/quote/".length));
  const header = req.headers["x-payment"];
  if (typeof header !== "string") {
    res.writeHead(402, { "content-type": "application/json" })
       .end(JSON.stringify(challenge(req.url)));
    return;
  }
  try {
    const tx = await settle(header);
    const responseHeader = Buffer.from(JSON.stringify({
      success: true, transaction: tx, network: NETWORK,
    }), "utf8").toString("base64");
    res.writeHead(200, {
      "content-type": "application/json",
      "x-payment-response": responseHeader,
    }).end(JSON.stringify({ symbol, price: 1234.56, paid_via: tx }));
  } catch (e: any) {
    res.writeHead(e?.code ?? 500, { "content-type": "application/json" })
       .end(JSON.stringify(e?.body ?? { error: "internal" }));
  }
}).listen(PORT, () => console.log(`x402 server: http://127.0.0.1:${PORT} payTo=${PAY_TO}`));

The buyer

Save this as buyer.ts. ~60 lines:
import { spawnSync } from "node:child_process";

const WALLET  = "alice";
const NETWORK = "gen-devnet-1";

function gen(args: string[]) {
  const [group, ...rest] = args;
  const r = spawnSync("gen", [group, "--json", ...rest], { encoding: "utf8" });
  const j = JSON.parse(r.stdout);
  if (j.ok === false) throw new Error(`gen ${args.join(" ")}: ${j.error}`);
  return j.result;
}

const myAccount = gen(["wallet", "show", WALLET]).account;
let tag = (Date.now() & 0x3fffffff) >>> 0;

export async function payAndFetch(url: string): Promise<Response> {
  const probe = await fetch(url);
  if (probe.status !== 402) return probe;

  const { accepts } = (await probe.json()) as { accepts: any[] };
  const reqs = accepts.find((a) => a.scheme === "gen-exact" && a.network === NETWORK);
  if (!reqs) throw new Error(`no gen-exact requirement for ${NETWORK} in 402 response`);

  // The merchant's 4-segment payTo is
  //   <recipient-entity>,<token-code-id>,<token-contract>,<role-id>
  // Segment 2 (the third) is the token contract. That's all we need
  // to feed build-activation a 2-segment caller id; the chain
  // auto-routes (account, token) to the Holder.
  const tokenContract = reqs.payTo.split(",")[2];
  const built = gen([
    "wallet", "build-activation", WALLET,
    "--component-id", `${myAccount},${tokenContract}`,
    "--method", "transfer",
    "--params", JSON.stringify({ amount: [reqs.maxAmountRequired], destination: reqs.payTo }),
  ]);

  const signed = gen([
    "wallet", "sign-activation", WALLET,
    "--unsigned-activation", built.unsigned_activation_hex,
    "--activation-tag", String(tag++),
  ]).signed_activation_hex.replace(/^0x/, "");

  const envelope = {
    x402Version: 1, scheme: "gen-exact", network: NETWORK,
    payload: { signed_activation: Buffer.from(signed, "hex").toString("base64") },
  };
  const header = Buffer.from(JSON.stringify(envelope), "utf8").toString("base64");

  return await fetch(url, { headers: { "x-payment": header } });
}

// Run a few paid calls.
for (const sym of ["gen1", "gen2", "gen3"]) {
  const r = await payAndFetch(`http://127.0.0.1:4021/api/quote/${sym}`);
  const xpr = r.headers.get("x-payment-response");
  const tx  = xpr ? JSON.parse(Buffer.from(xpr, "base64").toString("utf8")).transaction : "(none)";
  console.log(sym, r.status, "tx=" + tx, await r.text());
}

Run it

Two terminals. In the first, start the server:
PAY_TO="$SERVER_HOLDER" node --experimental-strip-types server.ts
x402 server: http://127.0.0.1:4021 payTo=grd@1qgqqqqqgcte6a...
In the second, run the buyer:
node --experimental-strip-types buyer.ts
Expected output:
gen1 200 tx=0x... { "symbol": "gen1", "price": 1234.56, "paid_via": "0x..." }
gen2 200 tx=0x... { ... }
gen3 200 tx=0x... { ... }
Three paid calls, three on-chain transfers visible in the server logs.

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-tag is a u32 mixed 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 from Date.now() & 0x3fffffff and increments per call.
  • The inner signed_activation is 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 another 402 with a specific error string. The ones worth handling explicitly:
errorCauseFix
"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.
Anything that is not 402 is non-x402 and should be surfaced to the caller as-is.

References

  • gen wallet for 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.