DEV Community

Emmanuel (Emmo00)
Emmanuel (Emmo00)

Posted on

Building x402 Clients on Celo: A Getting-Started Guide

How to make your code (or your agent) pay an HTTP endpoint automatically by signing a stablecoin micropayment on Celo and retrieving the resource in a single flow.


What is x402, from the client's side?

x402 allows a program to pay for an HTTP resource the moment it needs it with no accounts, API keys, or credit cards on file. When you request a protected URL, the server responds with 402 Payment Required and explains how to pay. Your client signs a payment and retries the request to receive the data. For an autonomous agent, this means discovering a priced endpoint and paying a fraction of a cent for it without requiring a human to configure billing beforehand.

This guide covers the client side on Celo, which offers cheap gas fees, fast finality, and widely held stablecoins like USDT and USDC. If you are building the endpoint itself, check out my companion guide, Building x402 Servers on Celo.


The Flow

  1. You send a GET request to the protected URL. The server replies with an HTTP 402 status and a base64-encoded PAYMENT-REQUIRED response header (along with an empty {} body). Decoding the header reveals { x402Version, error, resource, accepts: [...] }.
  2. The accepts[] array lists the payment options accepted by the server. Each item specifies a { scheme, network, amount, asset, payTo, maxTimeoutSeconds, extra }.
  3. Your client selects one option, signs an EIP-3009 authorization (a signed off-chain message rather than an on-chain transaction from your wallet), and retries the request including an X-PAYMENT header.
  4. A facilitator submits the transfer on-chain and pays the gas fee. You receive an HTTP 200 status along with a base64-encoded PAYMENT-RESPONSE header containing the settlement transaction hash.

x402 Flow - Sequence Diagram


Prerequisites

Before starting, make sure you have:

  • Node.js 20 or higher.
  • A funded wallet on Celo containing the stablecoin you plan to spend (such as Celo USDT). You do not need native CELO for gas.
  • Your wallet's private key provided via an environment variable (never hardcoded in source code).

Install the required packages:

npm install @x402/core @x402/evm @x402/fetch viem

Enter fullscreen mode Exit fullscreen mode
  • @x402/core: Provides the x402Client.
  • @x402/evm: Provides the exact EVM scheme (registerExactEvmScheme).
  • @x402/fetch: Provides wrapFetchWithPayment, which converts standard fetch into a payment-aware request handler.
  • viem: Utility for generating a signer from a private key.

Inspect Before You Code

Before writing client code, you can inspect an endpoint's exact configuration (including its accepts[] array, supported networks, assets, and prices) using the x402 Inspector:

[https://x402-inspector.vercel.app/?url=](https://x402-inspector.vercel.app/?url=)<x402endpoint>

You can point the inspector at the live Chess Puzzles API on Celo as a working example (each call costs a fraction of a cent):

[https://x402-inspector.vercel.app/?url=https://api.chesspuzzles.xyz/puzzles?count=1](https://x402-inspector.vercel.app/?url=https://api.chesspuzzles.xyz/puzzles?count=1)

The inspector decodes the 402 challenge and lets you make real payments directly from a browser wallet, making it the fastest way to understand and debug endpoints.


Celo Token Constants

const CELO_NETWORK = "eip155:42220";                                // CAIP-2 chain id
const CELO_USDT    = "0x48065fbbe25f71c9282ddf5e1cd6d6a887483d5e";     // 6 decimals, EIP-3009 (proxy)
// USDT's EIP-712 domain: name = "Tether USD", version = "1"

Enter fullscreen mode Exit fullscreen mode

An endpoint's accepts[] array often lists several options across different blockchains. For example:

  • [0] Base USDC (eip155:8453): The wrong chain for a Celo-only wallet.
  • [1] Celo USDC (eip155:42220)
  • [2] Celo USDT (eip155:42220, asset 0x48065…): Target this option if your wallet holds USDT.

The single most important habit: always select the option matching the network and asset your wallet is actually funded with. Do not assume accepts[0] is the correct choice. Selecting the wrong network option is the primary reason funded wallets fail to settle payments.


The Complete Client (Node + viem)

const { privateKeyToAccount } = require("viem/accounts");
const { x402Client } = require("@x402/core/client");
const { registerExactEvmScheme } = require("@x402/evm/exact/client");
const { wrapFetchWithPayment, decodePaymentResponseHeader } = require("@x402/fetch");

const CELO_NETWORK = "eip155:42220";
const CELO_USDT = "0x48065fbbe25f71c9282ddf5e1cd6d6a887483d5e";
const TARGET_URL = process.argv[2] || "https://api.chesspuzzles.xyz/puzzles?count=1";

// Read the key from the environment: never hardcode a private key.
const privateKey = process.env.AGENT_WALLET;
if (!privateKey) {
  console.error("AGENT_WALLET env is required (0x-prefixed private key).");
  process.exit(1);
}
const account = privateKeyToAccount(privateKey.startsWith("0x") ? privateKey : `0x${privateKey}`);

// Selector signature: (version, requirements) => requirement.
// Prefer Celo USDT; fall back to any Celo option; last resort accepts[0].
function selectCeloUsdt(_version, requirements) {
  const usdt = requirements.find(
    (r) => r.network === CELO_NETWORK && String(r.asset).toLowerCase() === CELO_USDT,
  );
  return usdt || requirements.find((r) => r.network === CELO_NETWORK) || requirements[0];
}

// Decode the real error from the base64 PAYMENT-REQUIRED response header.
function decodePaymentRequiredError(response) {
  const header = response.headers.get("payment-required");
  if (!header) return null;
  try {
    const decoded = JSON.parse(Buffer.from(header, "base64").toString("utf8"));
    return decoded && decoded.error ? String(decoded.error) : null; // e.g. "insufficient_funds"
  } catch { return null; }
}

// CRITICAL: the selector goes in the x402Client CONSTRUCTOR (see Pitfall #1).
const client = new x402Client(selectCeloUsdt);
registerExactEvmScheme(client, { signer: account, networks: [CELO_NETWORK] });
const fetchWithPayment = wrapFetchWithPayment(globalThis.fetch, client);

(async () => {
  console.log(`Paying ${TARGET_URL} from ${account.address} (Celo USDT)…`);
  // Wrap in new URL(): a bare string can throw "Failed to parse URL" on Node 20+.
  const res = await fetchWithPayment(new URL(TARGET_URL), { method: "GET" });

  if (!res.ok) {
    const serverError = decodePaymentRequiredError(res);
    console.error(`FAILED status=${res.status} serverError=${serverError || "(none)"}`);
    process.exit(1);
  }

  const settle = decodePaymentResponseHeader(res.headers.get("payment-response") || "");
  const data = await res.json().catch(() => null);
  console.log(`SETTLED tx=${(settle && settle.transaction) || "(none)"}`);
  console.log("response:", JSON.stringify(data));
})().catch((err) => console.error("THREW:", String(err && (err.message || err))));

Enter fullscreen mode Exit fullscreen mode

Run the client:

AGENT_WALLET=0x... node pay-x402-celo.js https://api.chesspuzzles.xyz/puzzles?count=1

Enter fullscreen mode Exit fullscreen mode

On success, the script logs the settlement transaction hash. On failure, it outputs the decoded server error. This gives you a complete, functional x402 client on Celo.


Two Pitfalls That Cost Me Hours

When I first implemented x402 on Celo, I encountered two subtle issues. Both presented as a properly funded wallet failing to pay, and both had non-obvious causes. Understanding these pitfalls will save you time.

Pitfall #1: The selector MUST be passed to the x402Client constructor

This bug causes a funded wallet to fail with an insufficient_funds error:

// ❌ BROKEN: The option is IGNORED, and the client defaults to accepts[0] (Base USDC).
const client = new x402Client();
wrapFetchWithPayment(fetch, client, { paymentRequirementsSelector: selectCeloUsdt });

// ✅ CORRECT: The selector explicitly controls which asset and network are paid.
const client = new x402Client(selectCeloUsdt);
wrapFetchWithPayment(fetch, client);

Enter fullscreen mode Exit fullscreen mode

When the selector is ignored, the client attempts to pay accepts[0], which defaults to Base USDC on `eip155:8453. A Celo-only wallet holds no balance on Base, causing the facilitator to return insufficient_funds` even if your wallet holds Celo USDT. Adding more funds to Celo will not fix this; the solution is passing your selector directly to the constructor.

Note the function signature difference:

  • Constructor / x402HTTPClient signature: (version, requirements) => requirement
  • The wrapFetchWithPayment options argument expects (requirements), but using this path can cause selection issues.

Pitfall #2: Read the actual error from the PAYMENT-REQUIRED header

When a payment retry fails, the HTTP response body is usually an empty object {}. The actual error details are stored inside the base64-encoded PAYMENT-REQUIRED response header. Always decode this header when debugging failures (the decodePaymentRequiredError helper function above handles this).

If you receive an unresolved 402 with error: "insufficient_funds", treat it as a temporary failure rather than a fatal error. It indicates the payment did not settle (due to network mismatches, empty balances, or temporary facilitator issues), and retrying later may succeed. If you are unsure, paste your endpoint into the inspector to compare decoded requirements with what your client selected:

[https://x402-inspector.vercel.app/?url=](https://x402-inspector.vercel.app/?url=)<x402endpoint>


Other Gotchas Worth Knowing

  • Node 20+ fetch handling: Passing a raw string URL to wrapped fetch can throw a Failed to parse URL error. Wrap string URLs using new URL(TARGET_URL).
  • Atomic token units: Celo USDT uses 6 decimal places, so amount: "10000" represents 0.01 USDT.
  • No CELO required for gas: The facilitator covers transaction gas fees. Your wallet only needs sufficient stablecoin balances. You can verify token balances with viem by querying balanceOf and decimals on the token contract at [https://forno.celo.org](https://forno.celo.org).
  • Object-style asset declarations: Some servers define asset as an object { address, decimals, eip712: { name, version } } with empty extra fields. The client library expects asset to be a string address with EIP-712 parameters inside extra. If signature generation fails to locate the domain, normalize each accepts[] entry so asset contains the address string and extra contains { name, version }.

Paying from a Browser Wallet (ethers)

The same client libraries function in browser environments; you simply adapt the signer to use an injected EIP-1193 wallet provider. The selection function must still be passed to the x402Client constructor.

const signer = {
  address,
  async signTypedData({ domain, types, message }) {
    const t = { ...types }; delete t.EIP712Domain; // ethers rejects EIP712Domain
    return ethersSigner.signTypedData(domain, t, message);
  },
  async readContract({ address: to, abi, functionName, args = [] }) { /* provider.call */ },
};
const client = new x402Client(selectRequirements); // (version, options) => option
registerExactEvmScheme(client, { signer });
const http = new x402HTTPClient(client);
const paymentRequired = http.getPaymentRequiredResponse(getHeader, parsedBody);
const payload = await http.createPaymentPayload(paymentRequired);
const headers = http.encodePaymentSignatureHeader(payload);
// retry the original request with `headers` merged in

Enter fullscreen mode Exit fullscreen mode

Skip the Trial and Error: Grab the Skills

To simplify client integration, I packaged my Celo x402 learnings into reusable agent skills. If you build with an AI coding assistant (such as Claude Code), you can install them directly:

GitHub Repository: https://github.com/Emmo00/agent-skills

npx skills add emmo00/agent-skills

Enter fullscreen mode Exit fullscreen mode

This library provides pre-configured skills for building x402 clients and servers on Celo, ensuring your development workflow automatically handles constructor selector placement, EIP-712 domains, and header error decoding.


Where to Go Next

  • Build the server side: Read my companion guide, Building x402 Servers on Celo.
  • Inspect endpoints: Test endpoints using the x402 Inspector.
  • Reference implementation: Test your client against the live Chess Puzzles API on Celo.

x402 streamlines API access by replacing user registration, API keys, and credit cards with a single signed HTTP request. On Celo, low gas fees, widespread stablecoin adoption, and gasless user transactions provide an ideal environment for building autonomous client applications.

Top comments (0)