Skip to main content

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.

On devnet only

CoRelayer runs on devnet only; nothing is deployed on mainnet yet.

The shapes below are what the implementation builds.

Scope​

ImplementedNot implemented
x402 v2 upfront purchases that call our contract: POST /v1/x402/topup, POST /v1/x402/purchaseA 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​

DirectionHeaderCarries
Server → clientPAYMENT-REQUIREDbase64 of the PaymentRequired object. The same JSON is also the body.
Client → serverPAYMENT-SIGNATUREbase64 of the PaymentPayload.
Server → clientPAYMENT-RESPONSEbase64 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.

x402-purchase.ts
/**
* 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 sendYou get
Same id, same payloadThe same result: 200, 202, or the stored failure.
Same id, different payload402 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 errorReasonTypical CoRelayer code
insufficient_fundsINSUFFICIENT_SENDER_BALANCE
invalid_payloadPAYMENT_INVALID
invalid_payment_requirementsQUOTE_EXPIRED
invalid_transaction_stateNONCE_TOO_LOW
unexpected_settle_errorSWAP_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)