Buying over x402
x402 turns HTTP's unused 402 Payment Required into a real exchange: the
server answers with what it wants, the client pays, the client retries. CoRelayer uses it for two
purchases, topping up credits and buying a plan, so an agent that holds USDC can get an entitlement
without a person, an account or an API key.
CoRelayer runs on devnet only; nothing is deployed on mainnet yet.
The shapes below are what the implementation builds.
Scope
| Implemented | Not implemented |
|---|---|
x402 v2 upfront purchases that call our contract: POST /v1/x402/topup, POST /v1/x402/purchase | A hosted facilitator for third-party merchants. The /v1/x402/facilitator/* prefix is reserved and answers 404. |
Discovery: GET /.well-known/x402 for the resource descriptor, GET /v1/x402/supported for the
schemes, networks, extensions and signer addresses.
The exchange
| Direction | Header | Carries |
|---|---|---|
| Server → client | PAYMENT-REQUIRED | base64 of the PaymentRequired object. The same JSON is also the body. |
| Client → server | PAYMENT-SIGNATURE | base64 of the PaymentPayload. |
| Server → client | PAYMENT-RESPONSE | base64 of the SettlementResponse. |
What the challenge contains
{
"scheme": "exact",
"network": "multiversx:1",
"amount": "85000000",
"asset": "USDC-c76f1f",
"payTo": "<contract address>",
"maxTimeoutSeconds": 120,
"extra": {
"assetTransferMethod": "esdt",
"paymentFlow": "upfront",
"scFunction": "depositAndSubscribe",
"arguments": ["0c", "01", "0510ff40", "<hex(quoteId)>"],
"gasLimit": 40000000,
"gasPrice": 1000000000,
"chainId": "1",
"relayer": "erd1…",
"lease": "<base64url>",
"leaseExpiresAtMs": 1789000060000,
"registryVersion": 7,
"expectedNonce": 17,
"quoteId": "q_01JA7M3Z9K",
"quoteMac": "<base64url, 43 chars>",
"quoteExpiresAtMs": 1789000120000,
"quote": { /* the full quote */ },
"dataTemplate": "ESDTTransfer@<hex(token)>@<hex(amount)>@<hex(function)>@<tierId>@<months>@<maxPrice>[@<ref>]",
"name": "USDC",
"decimals": 6
}
}
accepts has a second entry, identical except for network: "mvx:1", because both CAIP-2
spellings are in use. On devnet the document carries multiversx:D and mvx:D, the devnet token
and the devnet contract. Every value comes from the server's configuration and the contract, so
check payTo and asset against the values you pinned.
extra is unusually rich because a stock x402 client cannot construct a MultiversX contract call
on its own. It contains everything needed: the exact data grammar, the relayer, a lease, the gas
bounds and the quote with its MAC.
A complete client
No stock x402 client can pay a MultiversX contract call today, so this client is built on
@corelayer/sdk. x402Purchase sends the request and throws the 402 as an ApiError.
paymentRequiredOf reads the challenge from that error, encodePaymentSignature writes the
PAYMENT-SIGNATURE header, and decodePaymentResponse reads the receipt. It is one of the site's
runnable examples: type-checked, and run by the test suite against a stand-in that checks the
payment transaction byte for byte and verifies its signature.
/**
* Buying a plan over x402 v2: the HTTP 402 exchange, with the SDK's x402 helpers.
*
* 1. POST /v1/x402/purchase → 402, `PAYMENT-REQUIRED`: what to pay, to whom, and how
* 2. check the requirements → your chain, your contract, your ceiling, an active relayer
* 3. build and sign one transaction → an ESDT transfer that calls the contract, exactly as asked
* 4. POST again + `PAYMENT-SIGNATURE` → 200 when settled on chain, or 202 and a payment to poll
*
* `x402Purchase` throws the 402 as an `ApiError`. `paymentRequiredOf` reads the challenge from it,
* `encodePaymentSignature` writes the payment header, and `decodePaymentResponse` reads the receipt.
*/
import { randomUUID } from 'node:crypto';
import {
ApiError,
CoRelayerClient,
type components,
decodePaymentResponse,
encodePaymentSignature,
PAYMENT_RESPONSE_HEADER,
paymentRequiredOf,
type X402PaymentPayload,
type X402PaymentRequired,
type X402SettlementResponse,
} from '@corelayer/sdk';
import type { Gateway } from './gateway.ts';
import type { RelayerCheck } from './verify-relayer.ts';
import type { UnsignedTransaction, Wallet } from './wallet.ts';
type Requirements = components['schemas']['X402Requirements'];
type PurchaseResult = components['schemas']['X402PurchaseResult'];
type Pending = components['schemas']['X402Pending'];
export interface X402Context {
/** The API origin, such as `https://api.co-relayer.com`, a local backend or a test server. */
readonly api: string;
readonly wallet: Wallet;
readonly gateway: Gateway;
readonly relayers: RelayerCheck;
/** The chain id you pay on (`1` or `D`) and the contract you pinned for it. */
readonly chainId: string;
readonly contract: string;
readonly fetch?: typeof globalThis.fetch;
readonly sleep?: (ms: number) => Promise<void>;
}
export interface X402PurchaseRequest {
readonly tierId: number;
readonly months: number;
/** The most this payment may be, in micro-USDC. Checked before signing. */
readonly maxAmountMicroUsdc: bigint;
readonly signal?: AbortSignal;
}
export type X402Outcome =
| {
readonly kind: 'settled';
readonly result: PurchaseResult;
readonly settlement: X402SettlementResponse | undefined;
}
/**
* The relayer named in the challenge became unusable before anything was co-signed: the x402
* form of a re-sign request. Nothing was paid. Starting over means a new challenge and a new
* signature, and that decision is yours.
*/
| { readonly kind: 'challenge-changed'; readonly challenge: X402PaymentRequired };
const hexOf = (text: string): string => Buffer.from(text, 'utf8').toString('hex');
const hexOfAmount = (amount: bigint): string => {
const hex = amount.toString(16);
return hex.length % 2 === 0 ? hex : `0${hex}`;
};
/** Picks the requirement this client can pay, or explains why none fits. */
export function chooseRequirement(
challenge: X402PaymentRequired,
context: X402Context,
): Requirements {
const requirement = challenge.accepts.find(
(r) =>
r.scheme === 'exact' &&
r.network === `multiversx:${context.chainId}` &&
r.extra.chainId === context.chainId &&
r.extra.paymentFlow === 'upfront',
);
if (requirement === undefined) {
throw new Error(`No payable requirement for chain ${context.chainId} in the challenge.`);
}
if (requirement.payTo !== context.contract) {
throw new Error(`The challenge pays ${requirement.payTo}, not the contract you pinned.`);
}
return requirement;
}
/**
* The payment transaction, exactly as the requirement describes it: an ESDT transfer of `amount`
* of `asset` to the contract, calling `scFunction` with `arguments`, at the stated gas limit and
* price, co-signed by `extra.relayer`.
*/
export function paymentTransaction(
requirement: Requirements,
payer: string,
nonce: number,
): UnsignedTransaction {
const { extra } = requirement;
const data = [
'ESDTTransfer',
hexOf(requirement.asset),
hexOfAmount(BigInt(requirement.amount)),
hexOf(extra.scFunction),
...extra.arguments,
].join('@');
return {
nonce,
value: '0',
receiver: requirement.payTo,
sender: payer,
gasPrice: extra.gasPrice,
gasLimit: extra.gasLimit,
data: Buffer.from(data, 'utf8').toString('base64'),
chainID: extra.chainId,
version: 2,
relayer: extra.relayer,
};
}
/** The unpaid request: the API answers 402, and the challenge travels in `PAYMENT-REQUIRED`. */
async function challengeFor(
client: CoRelayerClient,
body: { readonly payer: string; readonly tierId: number; readonly months: number },
signal: AbortSignal | undefined,
): Promise<X402PaymentRequired> {
try {
await client.x402Purchase(body, {}, signal);
} catch (error) {
const challenge =
error instanceof ApiError && error.status === 402 ? paymentRequiredOf(error) : undefined;
if (challenge !== undefined) return challenge;
throw error;
}
throw new Error('The API answered the unpaid purchase without asking for a payment.');
}
export async function buyPlanOverX402(
context: X402Context,
request: X402PurchaseRequest,
): Promise<X402Outcome> {
const client = new CoRelayerClient({
baseUrl: context.api,
...(context.fetch === undefined ? {} : { fetch: context.fetch }),
});
const sleep = context.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
const { signal } = request;
const body = { payer: context.wallet.address, tierId: request.tierId, months: request.months };
// ── 1. The challenge ───────────────────────────────────────────────────────────────────────
const challenge = await challengeFor(client, body, signal);
// ── 2. Check it ────────────────────────────────────────────────────────────────────────────
const requirement = chooseRequirement(challenge, context);
const amount = BigInt(requirement.amount);
if (amount > request.maxAmountMicroUsdc) {
throw new Error(
`The plan costs ${amount} micro-USDC; your ceiling is ${request.maxAmountMicroUsdc}.`,
);
}
await context.relayers.verify(
context.wallet.address,
requirement.extra.relayer,
requirement.extra.registryVersion ?? 0,
signal,
);
// ── 3. One transaction, one signature ──────────────────────────────────────────────────────
// Pay before signing anything else from this account: the payment uses nonce n, so a payload
// signed earlier at nonce n could never execute.
const nonce =
requirement.extra.expectedNonce ??
(await context.gateway.accountNonce(context.wallet.address, signal));
const signed = await context.wallet.signTransaction(
paymentTransaction(requirement, context.wallet.address, nonce),
);
// The identifier makes the paid request idempotent: a retry with the same id returns the stored
// result instead of paying twice. Keep it for as long as you might retry this purchase.
const payment: X402PaymentPayload = {
x402Version: 2,
accepted: requirement,
payload: { ...signed },
extensions: { 'payment-identifier': { id: randomUUID() } },
};
// ── 4. The paid request ────────────────────────────────────────────────────────────────────
let paid: Awaited<ReturnType<CoRelayerClient['x402Purchase']>>;
try {
paid = await client.x402Purchase(
body,
{ paymentSignature: encodePaymentSignature(payment) },
signal,
);
} catch (error) {
// A new 402 with `relayer_changed`: nothing was co-signed, and the challenge is new.
const next = error instanceof ApiError ? paymentRequiredOf(error) : undefined;
if (next?.error === 'relayer_changed') return { kind: 'challenge-changed', challenge: next };
throw error;
}
const receipt = paid.headers.get(PAYMENT_RESPONSE_HEADER);
const settlement = receipt === null ? undefined : decodePaymentResponse(receipt);
if (paid.status === 200) return { kind: 'settled', result: paid.data, settlement };
// Settle before serve: the plan exists only once the payment is final on chain.
return { kind: 'settled', result: await poll(client, paid.data, sleep, signal), settlement };
}
async function poll(
client: CoRelayerClient,
pending: Pending,
sleep: (ms: number) => Promise<void>,
signal: AbortSignal | undefined,
): Promise<PurchaseResult> {
for (let attempt = 0; attempt < 60; attempt += 1) {
await sleep(pending.retryAfterMs ?? 2_000);
const answer = await client.getX402Payment(pending.paymentId, signal);
if (answer.status === 202) continue;
const result = answer.data;
if (!result.success)
throw new Error(
`Payment ${result.paymentId} failed: ${result.errorReason ?? 'no reason given'}`,
);
return result;
}
// Not a failure: the payment transaction may still settle. Keep the id and ask again later.
throw new Error(
`Payment ${pending.paymentId} is still pending; read it again later with getX402Payment.`,
);
}
The payment transaction is fully determined by the requirement: an ESDTTransfer of exactly
amount of asset to payTo, calling extra.scFunction with extra.arguments, at exactly
extra.gasLimit and extra.gasPrice, naming extra.relayer, on extra.chainId. Anything else is
refused before co-signing. The relayer check and the ceiling are the client's own: the challenge
tells you what to pay, and your code decides whether to.
The five rules
1. Settle before serve
With upfront, the server sends 200 only after the payment transaction is final with status
success and the server has seen the expected contract event. A broadcast or an inclusion in a block
is not enough.
If settling takes longer than 8,000 ms, you get a 202:
{ "status": "pending", "paymentId": "…", "transaction": "…", "poll": "/v1/x402/payments/…" }
Poll GET /v1/x402/payments/{paymentId} until it resolves. The 202 is not part of the x402
specification. CoRelayer sends it so that your connection does not have to stay open until the
chain reaches finality.
2. The signed data binds the purchase
The tier, the number of months, your max_price and the reference are arguments of the contract
call you sign, so the server cannot swap in a different purchase.
The server checks a quote by recomputing its MAC. A quote is its content plus an HMAC over that content, so any CoRelayer host can check a quote another host issued, and your paid retry can land on a different host from the one that sent the challenge.
Before co-signing, the server runs the contract's own sequence off chain against mirrored state. A purchase that would revert is refused here rather than broadcast, because on the free flow a reverting purchase is paid for by our relayer.
3. The lease is inside the challenge
Our signer co-signs nothing without a lease, and a stock x402 exchange has no assignment step. So the challenge carries a lease valid for 60,000 ms, next to a quote valid for 120,000 ms.
If the lease has expired by the time you pay, the server renews it for the same relayer, because
your paid retry is itself a fresh request. If that relayer can no longer be used, you get a new
402 with error: "relayer_changed". It is the x402 form of
RESIGN_REQUIRED and means the same thing: nothing was co-signed.
4. A payment identifier is required
"extensions": { "payment-identifier": { "id": "your-16-to-128-char-handle" } }
The server records the identifier, and the (payer, nonce) pair, before it co-signs anything, and
neither can be recorded twice. If the record cannot be written, the answer is
503 UPSTREAM_UNAVAILABLE and nothing is co-signed.
| You send | You get |
|---|---|
| Same id, same payload | The same result: 200, 202, or the stored failure. |
| Same id, different payload | 402 PAYMENT_INVALID. |
5. Time bounds are server-side only
MultiversX does not sign validAfter / validBefore, so they are enforced by the server and
nowhere else. The real expiry is the quote. An unused signed payment is inert without our
co-signature, and is never stored.
Failures
A failure is a 402 with PAYMENT-RESPONSE:
{ "success": false, "errorReason": "insufficient_funds", "transaction": "" }
and a problem body whose code is the native CoRelayer one, so you get both vocabularies:
x402 errorReason | Typical CoRelayer code |
|---|---|
insufficient_funds | INSUFFICIENT_SENDER_BALANCE |
invalid_payload | PAYMENT_INVALID |
invalid_payment_requirements | QUOTE_EXPIRED |
invalid_transaction_state | NONCE_TOO_LOW |
unexpected_settle_error | SWAP_VENUE_PAUSED, PRICE_ABOVE_MAX, DEPOSIT_BELOW_MIN |
Relaying does not accept payments
POST /v1/relay will never take a payment. Its 402 carries a PAYMENT-REQUIRED header whose
resource is the top-up endpoint, so an x402-aware agent learns where to pay.
A relay body already holds one signed transaction and cannot carry a second. A payment at nonce n
also makes a payload signed at nonce n unusable, so pay first, wait until the payment is final,
and only then sign the transaction you wanted to send.
Over MCP
The MCP tools buy_plan_x402 and topup_x402 run the routes above. The first call returns the
challenge in the tool result, as paymentRequired. Call the tool again with the payment in
_meta["x402/payment"]: it goes to the route as the PAYMENT-SIGNATURE header, and the tool
returns the route's answer. (MCP tools)