Skip to content

Payer quickstart — TypeScript

This quickstart wires the SubEtha payment scheme into the official x402 v2 client so that a plain fetch call pays for a 402-gated resource automatically. The example uses loopback URLs, chain 31337, and dev placeholder keys. The code is adapted from the pinned protocol spec and in-repo payer apps; the flow expectations at the end describe what those sources define — run the local end-to-end tutorial to see them for real.

applies to @subetha/x402-scheme 0.1.0 (npm, verified 2026-08-07)
  • Node.js >= 20@subetha/x402-scheme is ESM-only and targets Node 20+.
  • A running local stack for the reproducible path this page follows:
    • anvil on chain 31337 at http://127.0.0.1:8545,
    • a provider answering 402s (the in-repo demo provider listens on :4031),
    • a facilitator settling for that provider.

Without the provider and facilitator running, the script below starts fine but has no 402-gated resource to pay for.

Terminal window
npm install @subetha/[email protected] @x402/[email protected] @x402/[email protected] viem
npm install --save-dev tsx
  • @subetha/x402-scheme is the payer-side scheme plugin (Apache-2.0, ESM, Node.js >= 20). viem arrives with it as a regular dependency.
  • @x402/core and @x402/fetch are the official x402 packages; the pinned product commit builds its payer apps against ^2.17.0 of both.
  • No other SubEtha package is needed to pay. In particular there is no facilitator package to install — the facilitator is a source-only external service.

2. Configure the environment (local placeholders)

Section titled “2. Configure the environment (local placeholders)”

Set the paying account and RPC endpoint in the environment, never in code. These are the same SUBETHA_* names the product’s agent tools and the Python client read — the full table is in the configuration reference:

Terminal window
# Local anvil dev account only — placeholder; never a real key, never committed.
export SUBETHA_PAYER_PK=0x
# Local anvil chain (31337).
export SUBETHA_RPC_URL=http://127.0.0.1:8545

The 0x… value is a placeholder — the script will not run with it. On the local stack, replace it with one of the dev account private keys anvil prints at startup, and make sure that account holds test tokens to pay with (the tutorial covers funding). Anvil’s dev keys are well-known shared keys: use them only against chain 31337.

Quoting an offer needs no key at all; only paying does.

3. Configure SubethaSchemeClient and wrap fetch

Section titled “3. Configure SubethaSchemeClient and wrap fetch”

The scheme client structurally implements the official SchemeNetworkClient, so it registers on the official x402Client like any other scheme, and the official wrapFetchWithPayment does the rest (this is the payer wiring the pinned docs/PROTOCOL.md §6 defines):

import { SubethaSchemeClient } from "@subetha/x402-scheme";
import { x402Client } from "@x402/core/client";
import { wrapFetchWithPayment } from "@x402/fetch";
import { privateKeyToAccount } from "viem/accounts";
const payerPk = process.env.SUBETHA_PAYER_PK;
if (!payerPk) {
throw new Error("SUBETHA_PAYER_PK is not set — see step 2 above.");
}
const account = privateKeyToAccount(payerPk as `0x${string}`);
const scheme = new SubethaSchemeClient({
account,
mode: "permit", // gasless default; "self-transfer" broadcasts the burn itself
rpcUrl: process.env.SUBETHA_RPC_URL ?? "http://127.0.0.1:8545",
});
const client = new x402Client().register("eip155:31337", scheme);
const fetchWithPay = wrapFetchWithPayment(fetch, client);
// A locally running provider (the in-repo demo provider listens on :4031).
const res = await fetchWithPay("http://127.0.0.1:4031/api/complete", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ prompt: "hi" }),
});
console.log(res.status, await res.json());

Save this as pay.mts and run it with tsx — the packages are ESM, so this is the shortest path that handles both TypeScript and ESM loading:

Terminal window
npx tsx pay.mts

(Alternatively, remove the one as `0x${string}` cast, save the file as pay.mjs, and run node pay.mjs — Node >= 20 runs the ESM file natively.)

On success the script prints the paid response’s status and body — schematically:

200 { …the provider's resource body… }

The exact body is whatever the provider serves; the payment outcome itself travels in the PAYMENT-RESPONSE header described in section 4.

Two constructor behaviors worth knowing before the first run (client.ts):

  • Construction requires rpcUrl or an injected publicClient.
  • In the default "permit" mode the client fails fast when an offer lacks extra.helper — it never silently degrades to a gas-paying transfer. The gasless path therefore needs the facilitator configured with a deployed PermitBurner (settlement.permit_burner); with a bare facilitator config, use mode: "self-transfer" and the payer broadcasts the burn itself, paying gas.

Offers can carry a facilitator fee. parseFee(requirements) exposes { fee, bps, quoted, listPrice }, and feeAwarePolicy() is a ready-made PaymentPolicy that ranks offers by the quoted total — the payer’s true spend — never by list price. Details in the TypeScript API reference.

4. What to expect: 402 → payment → accepted → finalized

Section titled “4. What to expect: 402 → payment → accepted → finalized”

With a local provider and facilitator running, the pinned sources define this sequence for the call above:

  1. 402 — the first request is unpaid; the provider answers 402 Payment Required with a PAYMENT-REQUIRED header carrying the offer, whose payTo is a fresh one-time burn address.
  2. PaymentwrapFetchWithPayment intercepts the 402, has SubethaSchemeClient build the subetha-zerc20 payload (permit mode: two signatures, no transaction), and retries with PAYMENT-SIGNATURE.
  3. Accepted — the facilitator’s settle succeeds, the response arrives with your resource body and a PAYMENT-RESPONSE header whose SettleResponse has success: true and extra.phase: "accepted". This is the terminal state of the HTTP request.
  4. Finalized — later and asynchronously, the facilitator’s finalize loop drives the proof-gated mint. No HTTP response ever says finalized; accepted vs finalized explains why the distinction is load-bearing.

A client without the SubEtha scheme handler simply skips subetha-zerc20 offers — standard x402 scheme filtering.