Buyer guide: pay for an API
:::tip Any wallet with USDC on XDC can pay You are the buyer: you hold USDC and sign a payment. There is no account to create, no API key, and no XDC AI wallet required. Bring MetaMask, Rabby, a Ledger, or a bare private key. Seller API keys and credits belong to the seller side. :::
Gas is sponsored, so you never need XDC to pay for a call. You sign a message, and the facilitator submits the transaction and pays the gas.
Which wallets can pay
Anything that can sign EIP-712 typed data works, and nothing has to be registered anywhere first:
| Wallet | Pays? |
|---|---|
| MetaMask, Rabby, Trust, any browser or mobile wallet | Yes |
| A hardware wallet (Ledger, Trezor) | Yes |
| A raw private key in a script or a backend | Yes |
An XDC AI hosted wallet from xdcai login | Yes |
| An MPC wallet that returns a standard ECDSA signature | Yes |
| A deployed smart contract wallet (ERC-1271) | Yes, see the adapter |
| A contract wallet that is not deployed yet (ERC-6492) | No |
USDC on XDC checks signatures with OpenZeppelin's SignatureChecker, so it accepts an
ordinary ECDSA signature and a contract account's ERC-1271 signature through the same
call. A deployed contract wallet signs its own EIP-3009 authorization and settles
through the public facilitator; it does not have to move funds to an ordinary address
first. The wallet contract is the payer, and its signature must be passed through
unchanged. See Smart contract wallets.
Pick your path
Both end in the same place: a signed authorization on the seller's second request. They differ only in how much you write. If you would rather not write code at all, the CLI and the MCP connector make paid calls for you.
- Buyer SDK
- Your own code
@xdcai/x402-buyer is a payment-aware fetch for your own Node agent, with a spending policy you set and an idempotent payment-intent store. It is a thin, XDC-pinned wrapper over the official @x402/core, @x402/evm and @x402/fetch packages, published by the facilitator team (Apache-2.0, Node 20+). You pass a signer, never a raw key.
npm install @xdcai/x402-buyer@1.0.0-rc.2
:::warning Pin the exact version
This is a release candidate, and npm latest still points at 1.0.0-rc.1 while next
points at 1.0.0-rc.2. Pin the exact version rather than a floating range, or you get
whichever one a tag happens to mean that week. rc.2 changed documentation only: its
compiled output is identical to rc.1, so upgrading fixes nothing and breaks nothing.
:::
import { createXdcPaymentFetch, XDC_USDC } from "@xdcai/x402-buyer";
// signer is a ClientEvmSigner: an object with an address and signTypedData,
// for example a viem LocalAccount. Do not paste a private key into source or chat.
const paidFetch = createXdcPaymentFetch({
signer,
maxAmount: 10_000n, // atomic USDC units (6 decimals), so this is 0.01 USDC
allowedAssets: [XDC_USDC],
allowedPayTo: ["0xYourTrustedSeller"],
});
const response = await paidFetch("https://seller.example/api/data", {
paymentIntentId: "order-123", // required, one logical purchase
});
If the response is a 402 within policy, the SDK selects the requirement, asks your signer to sign, retries with the proof, and validates the receipt before returning. If anything is out of policy it throws before the signer is ever called.
The policy is the point. Every guard is on by default or required, and each refuses before signing rather than after paying:
| Control | Behavior |
|---|---|
maxAmount | Required. A bigint in atomic units, not fiat. There is no price oracle. |
| Network | Defaults to and requires eip155:50. allowedNetworks to change. |
| Asset | Defaults to XDC USDC. allowedAssets to change. |
allowedPayTo | Optional payee allowlist. Set it for production spend. |
| Caller-supplied proof | Rejected. The SDK attaches the payment header, you do not. |
| Redirects | Never followed, so a proof cannot leak to another origin. |
Idempotency. Every call needs a paymentIntentId, your key for one logical purchase, and it is what stops a retry from paying twice. The SDK tracks each intent as in_flight, then ambiguous or completed, in a PaymentIntentStore.
:::danger Inject a durable store in production
The default createMemoryPaymentIntentStore() is single-process and forgets on restart, so two workers could both authorize the same intent. For any real deployment pass an intentStore backed by a database with an insert-if-absent or CAS reserve. The store interface is exported for you to implement.
:::
When settlement is uncertain. If a request is interrupted after the authorization went out but before a confirmed receipt, the intent lands in ambiguous. The SDK will not guess, so you inspect and reconcile:
await paidFetch.listAmbiguousAttempts();
await paidFetch.reconcileAmbiguousAttempt(attemptId, { outcome: "not_settled" });
// or, if it did settle:
await paidFetch.reconcileAmbiguousAttempt(attemptId, { outcome: "settled", transaction: "0x..." });
A stranded in_flight reservation is fail-closed: the SDK will not release it (that would reopen a double-payment race). You can mark it settled if you confirm it settled; recovering one that did not settle is an operator action on the durable store, after confirming the owning process is dead.
Common BuyerSdkError codes: amount_exceeds_limit, unsupported_network / unsupported_asset, payee_not_allowed, signer_refused, caller_supplied_signature, ambiguous_payment_state.
Real-money calls should require an explicit human approval before the first live payment, and should show the network, asset, payee and maximum amount first.
:::warning The standard x402 client packages do not cover XDC yet
x402-fetch, x402-axios, and the Python and Go clients built on the x402 package validate the network against a fixed list. XDC is not on it, so an XDC challenge is rejected before any signing happens. Paste the wrapper below instead, or use the Buyer SDK which handles this for you. This wrapper takes the same shape as those clients, so moving to one later is a one line change.
:::
A paid call is one request that comes back 402 and the same request repeated with a signed authorization attached. Wrap that loop once and you never think about it again.
// x402-xdc.ts
const USDC = "0xfA2958CB79b0491CC627c1557F441eF849Ca8eb1";
const DOMAIN = { name: "USDC", version: "2", chainId: 50, verifyingContract: USDC } as const;
const TYPES = {
TransferWithAuthorization: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "validAfter", type: "uint256" },
{ name: "validBefore", type: "uint256" },
{ name: "nonce", type: "bytes32" },
],
} as const;
/** Prefer the seller's v2 terms, fall back to v1. */
function pickTerms(body: any, url: string) {
const entries = Array.isArray(body?.accepts) ? body.accepts : [body];
const v2 = entries.find((e: any) => e?.network === "eip155:50" && e?.amount);
if (v2) return { entry: v2, version: 2 as const, amount: v2.amount, resource: body?.resource?.url ?? url };
const v1 = entries.find((e: any) => e?.network === "xdc" && e?.maxAmountRequired);
if (v1) return { entry: v1, version: 1 as const, amount: v1.maxAmountRequired, resource: v1.resource ?? url };
throw new Error("that 402 carries no XDC payment terms");
}
/** `signer` is any viem account: privateKeyToAccount, or a connected browser wallet. */
export function wrapFetchWithPayment(fetchImpl: typeof fetch, signer: any) {
return async function fetchWithPayment(url: string, init?: RequestInit): Promise<Response> {
const first = await fetchImpl(url, init);
if (first.status !== 402) return first;
const { entry, version, amount, resource } = pickTerms(await first.clone().json(), url);
const nonce = "0x" + [...crypto.getRandomValues(new Uint8Array(32))]
.map((b) => b.toString(16).padStart(2, "0")).join("");
const message = {
from: signer.address,
to: entry.payTo,
value: BigInt(amount),
validAfter: 0n,
validBefore: BigInt(Math.floor(Date.now() / 1000) + (entry.maxTimeoutSeconds ?? 600)),
nonce,
};
const signature = await signer.signTypedData({
domain: DOMAIN, types: TYPES, primaryType: "TransferWithAuthorization", message,
});
// The wire form carries every number as a string.
const authorization = {
from: message.from,
to: message.to,
value: message.value.toString(),
validAfter: message.validAfter.toString(),
validBefore: message.validBefore.toString(),
nonce: message.nonce,
};
// v2 echoes back the entry it is paying against, v1 does not.
const envelope = version === 2
? { x402Version: 2, scheme: "exact", network: "eip155:50",
resource: { url: resource }, accepted: entry, payload: { signature, authorization } }
: { x402Version: 1, scheme: "exact", network: "xdc", payload: { signature, authorization } };
return fetchImpl(url, {
...init,
headers: {
...(init?.headers ?? {}),
[version === 2 ? "PAYMENT-SIGNATURE" : "X-PAYMENT"]: btoa(JSON.stringify(envelope)),
},
});
};
}
Then call it like fetch:
import { privateKeyToAccount } from "viem/accounts";
import { wrapFetchWithPayment } from "./x402-xdc";
const signer = privateKeyToAccount(process.env.BUYER_KEY as `0x${string}`);
const fetchWithPayment = wrapFetchWithPayment(fetch, signer);
const res = await fetchWithPayment("https://provider.example/x402/endpoint");
const data = await res.json();
Free endpoints pass straight through, so you can point the wrapped fetch at everything and let the 402 decide.
In a browser, the signer is the wallet the visitor already has:
import { createWalletClient, custom } from "viem";
const [address] = await window.ethereum.request({ method: "eth_requestAccounts" });
const wallet = createWalletClient({ account: address, transport: custom(window.ethereum) });
const fetchWithPayment = wrapFetchWithPayment(fetch, {
address,
signTypedData: (args: any) => wallet.signTypedData({ account: address, ...args }),
});
Porting it to another language. The whole protocol is four steps, and nothing in it is XDC specific except the constants:
- Request the resource. Anything other than 402 is your answer, return it.
- Read the price from the 402. Prefer an
accepts[]entry withnetwork: "eip155:50"andamount; fall back to one withnetwork: "xdc"andmaxAmountRequired. - Sign an EIP-3009
TransferWithAuthorizationover the domain above, with a random 32 byte nonce. - Repeat the request with the base64 envelope in
PAYMENT-SIGNATURE(v2) orX-PAYMENT(v1).
Three things to get right:
- Pay the exact amount. The
exactscheme rejects an authorization signed for a different value, so do not round or add a tip. - Use the right header. v2 sellers read
PAYMENT-SIGNATURE, v1 sellers readX-PAYMENT. Theaccepts[]entry tells you which: a v2 entry hasamountand a CAIP-2networklikeeip155:50, a v1 entry hasmaxAmountRequiredandnetwork: "xdc". - The EIP-712 name is
USDC. NotUSD Coin. The wrong name changes the domain separator and the signature will not recover.
Getting your receipt
A successful paid call returns a receipt header, PAYMENT-RESPONSE on v2 or X-PAYMENT-RESPONSE on v1, holding the settlement result including the transaction hash. The Buyer SDK reads and validates it for you before returning. If the seller omits it, the payment can still be found on-chain by the nonce you signed: the USDC contract emits AuthorizationUsed(authorizer, nonce).