# CoRelayer for agents > Everything an autonomous agent needs: discovery, purchase (x402 or on-chain), auth, relaying, errors. This file contains all documentation content in a single document following the llmstxt.org standard. ## Authentication There is no password and no account sign-up. Everything is proved with a signature, and the question on each route is *which* signature. ### The rings | Ring | What you present | Where it comes from | |---|---|---| | **Public** | nothing | Mirrors of chain state and published documents | | **Transaction** | the signed transaction itself | `POST /v1/relay` | | **Presence proof** | a signature over a short, timestamped message | `POST /v1/relay/assign` | | **Native-auth** | a MultiversX native-auth bearer token | Private reads and every write about your account | | **Sponsor key** | `X-Api-Key: crk___` | Paying for other senders, server side only | | **x402** | `PAYMENT-SIGNATURE` header | Purchases | | **Stream ticket** | a single-use query parameter | One server-sent-events connection | ### Transaction as credential `POST /v1/relay` needs no token at all. The credential *is* the signed transaction: bytes carrying your Ed25519 signature, naming one of our relayers, on the right chain, at a nonce the network will accept. So this route needs no token round trip, and the nonce prevents replay without the server having to remember anything. A sponsor key may accompany the transaction, but it never *replaces* it. The key only selects who pays. ([Key handling](/security/keys)) ### The presence proof Assignment reserves nothing, but it still needs a proof. A lease issued to anyone who knows an address would let a third party that holds an old signed payload, never relayed, bring it back to life. ```text message = corelayer/assign/v1||| ``` The sender's own key signs the UTF-8 bytes of that message directly, as a raw Ed25519 signature (sdk-core `UserSigner.sign`). Do not use a wallet's `signMessage` or sdk-core `Account.signMessage`: they add the MultiversX message prefix first, and the API refuses that signature with [`ASSIGN_PROOF_INVALID`](/errors/assign-proof-invalid). A signing service that holds your users' keys must be able to sign raw bytes. ```ts import { assignProofMessage, type CoRelayerClient } from '@corelayer/sdk'; /** * A presence proof for `sender`. `signProofMessage` signs the raw UTF-8 bytes of the message with * the sender's key (no MultiversX message prefix) and returns the signature as hex. */ export async function presenceProof( client: CoRelayerClient, sender: string, signProofMessage: (message: string) => Promise, kind: 'key' | 'sponsor' = 'key', // `sponsor` when a sponsor key pays for the relay ) { const { data: network } = await client.getNetwork(); const message = assignProofMessage(network.chainId, sender, network.serverTimeMs); return { kind, serverTimeMs: network.serverTimeMs, signature: await signProofMessage(message), }; } ``` | | | |---|---| | Signature | Raw Ed25519 by the sender's own key over the message bytes, for `kind: "key"` and `kind: "sponsor"` alike. | | Validity | Within 30,000 ms of the server's clock, and accepted once. Take `serverTimeMs` from `GET /v1/network`, not from your own clock. | | Verified by | The signer, not just the API. | | Chain binding | The chain id is inside the message, so a proof captured on one network cannot mint a lease on another. | | Missing | [`ASSIGN_PROOF_REQUIRED`](/errors/assign-proof-required) | | Wrong | [`ASSIGN_PROOF_INVALID`](/errors/assign-proof-invalid) | A native-auth token for the same sender stands in for the proof when it comes from one of the origins CoRelayer accepts: the dashboard, or the headless-agent origin (see [Native-auth](#native-auth)). #### Key mode and sponsor mode The proof says the sender is present. Who pays is decided at relay, and there are two ways: | | Key mode | Sponsor mode | |---|---|---| | Who pays | The sender's own account, or an account that listed it on chain (a named wallet) | The account that owns the sponsor key | | Proof at assign | `proof.kind: "key"`, made by the program that holds the sender's key, or a native-auth token from an origin CoRelayer accepts | `proof.kind: "sponsor"`, made by your server with the sender's key: the same message and the same raw signature | | Credential at relay | The signed transaction | The signed transaction, plus `X-Api-Key` from your server | | Limit | The plan's named-wallet count | No sender limit; the key's policy applies | | Plans | All | Builder, Growth, Scale, Enterprise, Agent Pro, Agent Fleet | | `billing.authMode` | `own_account` or `authorized_sender` | `api_key` | In both modes the sender's key signs the presence proof; a sponsor key never proves presence, and the assign route does not read it. Sponsor mode marks the proof `kind: "sponsor"` and adds the key to the relay call, the one request that reads it: ```ts title="sponsor-relay.ts" snippet="examples/sponsor-relay.ts#site-sponsor" import { CoRelayerClient, type RelayRequest } from '@corelayer/sdk'; // On your server. The sponsor key never reaches a browser. const apiKey = process.env.CORELAYER_API_KEY; if (!apiKey) throw new Error('Set CORELAYER_API_KEY'); const corelayer = new CoRelayerClient({ baseUrl: 'https://api.co-relayer.com', apiKey, }); // Your user signed the transaction. Your plan pays the network fee. export async function payForUser(req: RelayRequest, actionId: string) { const { data } = await corelayer.relay(req, { intentKey: actionId }); return data.txHash; } ``` The key comes from the environment, and a missing key stops the server at start-up. [Paying for other senders](/plans/sponsoring-senders) compares the two modes in full, and [Pay for your users](/sdk/recipes/sponsor-users) is the recipe in four languages. ### Native-auth Native-auth is the MultiversX standard for proving control of an address to a web service. The token is a bearer token; you present it as `Authorization: Bearer `. ```http GET /v1/usage HTTP/1.1 Host: api.co-relayer.com Authorization: Bearer ``` What CoRelayer requires of a token: | Requirement | Why | |---|---| | Time to live at most one hour | Bounds the value of a leaked token. | | The embedded block must be a **shard-1** block | The validator resolves block hashes from its own shard-1 feed with zero gateway calls, so login never depends on a public rate-limited endpoint. A hash from another shard is refused with `details.expectedShard = 1`. | | Exact origin match | The dashboard origin for browsers; one fixed headless-agent origin for programs. Browser calls must also send a matching `Origin` header. | | No impersonation or multisig extras | Tokens carrying those fields are rejected outright. | | The token address must equal the account you are asking about | A token for one address cannot read another's private data. | A headless agent builds its own token. Take the block and the origin from `GET /v1/network`: ```json "nativeAuth": { "blockHash": "…", "blockTsMs": 1789000000000, "shard": 1, "origin": "…" } ``` Put `origin` into the token exactly as the API sends it. It is the one headless-agent origin that network accepts, and it differs between devnet and mainnet, so never hard-code it. When the answer has no `nativeAuth` member, prove presence on `POST /v1/relay/assign` with a presence proof instead, and read private data with a `read` key. Each SDK has helpers that build a token, take one apart and check it locally. The local check covers the origin, the lifetime, the formats, `extraInfo`, the address and expiry. It cannot verify the signature or see which shard the block came from, so the API can still refuse a token that passes: ```ts import { checkNativeAuthToken, composeNativeAuthToken, decodeNativeAuthToken, MAX_TTL_SECONDS, nativeAuthSignPayload, } from '@corelayer/sdk'; ``` The SDKs write and read the token exactly as the MultiversX native-auth client (`@multiversx/sdk-native-auth-client`, used by sdk-dapp) does: | Part | Content | |---|---| | The message the wallet signs | `
` | | The init | `...` | | The token | `..` | Every SDK is tested against a token that client really made and a key really signed, byte for byte. A token with a plain `erd1…` address part, or with the block hash base64url-encoded inside the init, is refused. Never put a token in a URL. That is what stream tickets exist for. ### Sponsor keys A sponsor key is a server-side secret that lets your backend pay for transactions signed by any sender whose key it holds or reaches. That is what an app that pays for its embedded or custodial wallets needs, and what an operator running many agents needs. ``` crk_live__ X-Api-Key: crk_live_… ``` Your server sends it with each transaction it relays. Over plain HTTP: ```bash curl -sS https://api.co-relayer.com/v1/relay \ -H 'content-type: application/json' \ -H "X-Api-Key: $CORELAYER_API_KEY" \ -H 'Idempotency-Key: order-1042-mint-1' \ -d @signed-relay.json ``` `signed-relay.json` holds `tx`, the transaction the sender signed, and `lease`, the assignment it was signed for. The answer names your account in `account`, with `billing.authMode: "api_key"`. | | | |---|---| | Scopes | `relay` (pay for arbitrary senders) and `read` (private reads of the account). | | Shown | Once, at creation. Stored as a keyed hash; we cannot show it again. | | Required policy | A **non-empty receiver allow-list** for scope `relay`. This is not optional. It is compared with the transaction's receiver field, so NFT, SFT, Meta-ESDT and multi-token transfers, which name the sender as receiver, cannot be sponsored yet. | | Optional policy | Function allow-list, per-sender daily unit limit, account daily unit limit, IP allow-list. | | Limit | Ten keys per account. | | Requires | The tier flag that permits sponsoring arbitrary senders. Otherwise [`API_KEY_SCOPE`](/errors/api-key-scope). | | Cannot do | Create, change or revoke keys. That always needs your wallet. | **There are no browser-visible keys.** Sender addresses are free to create, so a key visible in a page would let anyone burn the sponsor's entire cap within the allow-list. Named wallets suit addresses whose key your own programs hold, such as bots, devices and scripts. A browser dApp whose users sign in their own wallet can't be relayed for yet. The mandatory receiver allow-list is the reason a leaked key is bounded: the thief can spend your Relay Units, but only on transactions to receivers you named. ### Stream tickets A browser's `EventSource` cannot send headers, and a bearer token must never travel in a URL. So: ```http POST /v1/stream/tickets → { ticket, expiresAtMs } GET /v1/stream?ticket= → server-sent events ``` A ticket is single use, valid for 30,000 ms and read-only. You get one with a native-auth token or a `read`-scoped key. ### Which route takes what | Route | Auth | |---|---| | `/healthz`, `/readyz`, `/v1/network`, `/.well-known/x402` | public | | `GET /v1/pricing`, `/v1/pricing/tariff-history` | public | | `GET /v1/relayers`, `/v1/status`, `/v1/incidents` | public | | `GET /v1/relay/{id}`, `/v1/intents/{sender}/{nonce}`, `/v1/tx/{hash}` | public | | `POST /v1/quote`, `POST /v1/validate` | public, tightly rate-limited | | `POST /v1/*/prepare` | public: they only build unsigned transactions | | `GET /v1/account/{erd}`, `/quota`, `/senders`, `/purchases` | public | | `POST /v1/relay/assign` | presence proof or native-auth; the route does not read a sponsor key, which never proves presence | | `POST /v1/relay` | the transaction, optionally plus a sponsor key | | `GET /v1/usage*`, `/notices`, `/outages` | native-auth or `read` key | | `/v1/account/{erd}/keys*` | native-auth only | | `/v1/account/{erd}/webhooks*`, `/notifications` | native-auth only | | `POST /v1/x402/topup`, `/v1/x402/purchase` | x402 | A read that only mirrors chain state is public. A read that comes from CoRelayer's own records or configuration is private. Your quota is derivable from the chain, so it is public. Your per-transaction latency is our measurement of you, so it is not. ### Environments do not mix An API key minted against the devnet API carries `test` and is refused by the mainnet API, and the reverse. Leases, quotes, webhook secrets and every other MAC key differ per environment, even though the wallet addresses are the same. A credential that works in one place will fail cleanly in the other rather than doing something surprising. --- ## Buying a plan Three routes to entitlement. They differ in the protocol, not in the price: all three end at the same contract call, with the same price ceiling, at the same tariff. | | You need | Round trips | Read | |---|---|---|---| | **On chain** | a key that can sign | prepare, sign once, relay | below | | **x402** | an HTTP client that speaks 402 | challenge, sign once, paid retry | [x402](/x402) | | **MCP** | an MCP host | not callable yet — see [the MCP server](/mcp) | [MCP tools](/mcp/tools) | ### The ordering rule :::danger[Pay first, then sign the payload] Your payment and your payload both come from your account, and both use a nonce. A payment executed at nonce `n` **invalidates a payload you signed at nonce `n`**. So: buy, wait for the purchase to be final, read your nonce again, and only then build the transaction you actually wanted to send. ::: This is also why `POST /v1/relay` does not accept payments. Its 402 answer carries a `PAYMENT-REQUIRED` header saying where to pay, and nothing more — a relay body already holds one signed transaction and must not carry a second. ### On chain, in one transaction ```ts title="buy-plan.ts" snippet="examples/buy-plan.ts" /** * Buying a plan on chain, with no EGLD: one signature, and CoRelayer pays the fee of the purchase. * * POST /v1/subscribe/prepare → an unsigned `depositAndSubscribe` transaction with the relayer * already set, the quote it was priced from, and a lease * check it → YOUR contract, YOUR chain, YOUR ceiling, an active relayer * sign once, POST /v1/relay → the same route every relayed transaction uses * * The transaction carries `max_price`. If the price moved between the quote and the block, the * contract reverts instead of charging more — the ceiling you checked is the ceiling you signed. */ import { ApiError, type components, guardSignOnce, type RelayResponse, type TransactionPlain, } from '@corelayer/sdk'; import { connect, presenceProof, type RelayContext } from './send-token.ts'; import type { UnsignedTransaction } from './wallet.ts'; type PreparedPurchase = components['schemas']['PreparedPurchase']; type SubscribeQuote = components['schemas']['SubscribeQuote']; type Prepared = components['schemas']['UnsignedTransaction']; export interface PurchaseContext extends RelayContext { /** The CoRelayer contract of `chainId`, from YOUR configuration. */ readonly contract: string; } export interface BuyPlanRequest { readonly tierId: number; /** Months to prepay, 1…12. The metered agent tier takes 0. */ readonly months: number; /** For the metered tier: USDC to deposit beyond the price, in micro-USDC. */ readonly extraDepositMicroUsdc?: bigint; /** The most you are willing to pay in this transaction, micro-USDC. Checked before signing. */ readonly maxPayMicroUsdc: bigint; readonly intentKey: string; readonly signal?: AbortSignal; } /** Narrows the prepared transaction to what a relayed transaction must be — or refuses. */ function relayable(tx: Prepared, context: PurchaseContext): UnsignedTransaction { const { relayer, version, options, ...rest } = tx; if (tx.sender !== context.wallet.address) throw new Error(`Prepared for ${tx.sender}, not for this wallet.`); if (tx.receiver !== context.contract) { throw new Error(`Prepared transaction pays ${tx.receiver}, not the contract you pinned.`); } if (tx.chainID !== context.chainId) throw new Error(`Prepared for chain ${tx.chainID}.`); if (relayer === undefined) throw new Error('Prepared transaction names no relayer.'); if (version !== 2) throw new Error(`Relayed v3 needs version 2, got ${version}.`); if (options !== undefined && options !== 0 && options !== 1 && options !== 2 && options !== 3) { throw new Error(`Unsupported transaction options ${options}.`); } return { ...rest, relayer, version, ...(options === undefined ? {} : { options }) }; } export async function buyPlan( context: PurchaseContext, request: BuyPlanRequest, ): Promise<{ readonly response: RelayResponse; readonly quote: SubscribeQuote }> { const { client, wallet, relayers } = context; const { signal } = request; const options = signal === undefined ? {} : { signal }; await connect(context, signal); const { data: prepared } = await client.transport.post( '/v1/subscribe/prepare', { address: wallet.address, tierId: request.tierId, months: request.months, ...(request.extraDepositMicroUsdc === undefined ? {} : { extraDepositMicroUsdc: request.extraDepositMicroUsdc.toString() }), }, options, ); const pay = BigInt(prepared.payMicroUsdc); if (pay > request.maxPayMicroUsdc) { throw new Error( `The purchase costs ${pay} micro-USDC; your ceiling is ${request.maxPayMicroUsdc}.`, ); } const assignment = prepared.assignment; if (assignment === undefined) throw new Error('No assignment: this purchase would not be relayed.'); const unsigned = relayable(prepared.transaction, context); if (unsigned.relayer !== assignment.relayer) throw new Error('Transaction and assignment disagree.'); // Same rule as every relayed transaction: never sign for a relayer you have not checked. await relayers.verify(wallet.address, assignment.relayer, assignment.registryVersion, signal); const sign = guardSignOnce(async ({ transaction }) => wallet.signTransaction(transaction)); const signed: TransactionPlain = await sign({ assignment, transaction: unsigned }); if (signed.nonce !== unsigned.nonce) { throw new Error( `The wallet signed nonce ${signed.nonce}, the purchase was prepared for ${unsigned.nonce}.`, ); } const submit = async (lease: string): Promise => (await client.relay({ tx: signed, lease }, { intentKey: request.intentKey, ...options })).data; try { return { response: await submit(assignment.lease), quote: prepared.quote }; } catch (error) { // A signer slower than the 60 s lease (a hardware wallet, a person reading the screen) is the // one case handled here: renew the lease for the same relayer and send the identical bytes. if ( error instanceof ApiError && error.code === 'LEASE_EXPIRED' && error.detail('renewable') ) { const proof = await presenceProof(context, signal); const { data: renewed } = await client.assignRelayer( { sender: wallet.address, renewFor: assignment.relayer, proof }, signal, ); return { response: await submit(renewed.lease), quote: prepared.quote }; } throw error; } } ``` The purchase is on the **free list**: CoRelayer pays the fee of the transaction that buys the plan, which is what makes buying possible with zero EGLD. The checks before the signature are the point of the function: the transaction pays **your** pinned contract, on **your** chain, at no more than **your** ceiling, through a relayer the chain says is active. What the contract does with it: ```text depositAndSubscribe(tier_id, months, max_price, ref) → swap the USDC in the same call → credit the account 1:1 from the USDC paid → price the tier at the block timestamp → revert if price > max_price → write the plan block ``` `ref` is opaque, at most 32 bytes, and echoed in the event; the prepare route puts the `quoteId` in it. #### What can go wrong, and what it means | Error | Meaning | |---|---| | [`PRICE_ABOVE_MAX`](/errors/price-above-max) | The price at execution exceeded the ceiling you signed. Get a fresh quote. | | [`QUOTE_EXPIRED`](/errors/quote-expired) | The quote's 120 seconds passed. Prepare again. | | [`TIER_NOT_PURCHASABLE`](/errors/tier-not-purchasable) | The tier is configured but not being sold. | | [`INSUFFICIENT_CREDITS`](/errors/insufficient-credits) | A purchase from existing credits, and there are not enough. Deposit, or use `depositAndSubscribe`. | | [`DEPOSIT_BELOW_MIN`](/errors/deposit-below-min) | Below one USDC. | | [`SWAP_VENUE_PAUSED`](/errors/swap-venue-paused) | The exchange venue is unavailable. The transaction would revert, so it is refused before it costs anyone anything. | | [`CONTRACT_PAUSED`](/errors/contract-paused) | Our own contract is paused for deposits. The contract starts paused, and deposits open when the owner unpauses them. | | [`FREE_FLOW_BUSY`](/errors/free-flow-busy) | You already have a free-flow transaction in flight. One at a time. | Everything on the purchase path is checked **before** co-signing, by running the same sequence the contract will run against mirrored state. That is not politeness: a purchase that reverts on chain is paid for by our relayer. ### Quotes `POST /v1/subscribe/prepare` returns a `PreparedPurchase`: the unsigned transaction, the assignment whose lease it is submitted with, `payMicroUsdc` (what this transaction pays), `fromCreditsMicroUsdc` (what existing credits cover), and the quote it was priced from: ```json title="SubscribeQuote (illustrative values)" { "quoteId": "q_01JA7M3Z9K", "tierId": 12, "months": 1, "tariff": "10000", "tariffVersion": 1, "tariffEffectiveMs": 1789000000000, "priceMicroUsdc": "85000000", "maxPrice": "85000000", "pendingTariff": null, "issuedAtMs": 1789819200000, "expiresAtMs": 1789819320000, "quoteMac": "…" } ``` | | | |---|---| | Life | 120,000 ms. | | Storage | None. A quote is content plus a MAC over that content, so any host can verify one it did not issue. Asking twice gives you two valid quotes. | | Slack | None: `maxPrice` equals `priceMicroUsdc`. A price decrease in between simply charges less. | | Across a scheduled increase | Priced at the **pending**, higher value, with `pendingTariff` filled. A transaction that lands before the activation pays the lower current price and the surplus stays as credits. | | Amounts | Decimal strings in micro-USDC. Parse them as integers, never as floats. | ### Choosing a tier programmatically ```ts title="pick-tier.ts" snippet="examples/pick-tier.ts" /** * Choosing a tier from the live pricing document. * * `GET /v1/pricing?audience=agent` is public. Every amount in it is a decimal string in micro-USDC * (the payment token has 6 decimals). Compare amounts as BigInt, because floats lose precision. Do * not multiply a `price` by a million: it is already in micro-USDC. */ import type { CoRelayerClient, components } from '@corelayer/sdk'; type PricingTier = components['schemas']['PricingTier']; /** * The largest purchasable plan whose monthly price fits the budget; the metered tier when none * does. `undefined` only when nothing at all is on sale. */ export function pickTier( tiers: readonly PricingTier[], monthlyBudgetMicroUsdc: bigint, ): PricingTier | undefined { const onSale = tiers.filter((tier) => tier.status === 'active' && tier.available !== false); const metered = onSale.find((tier) => tier.periodMs === 0); const plans = onSale .filter((tier) => tier.periodMs > 0 && BigInt(tier.price) <= monthlyBudgetMicroUsdc) .sort((a, b) => BigInt(b.capRu) > BigInt(a.capRu) ? 1 : BigInt(b.capRu) < BigInt(a.capRu) ? -1 : 0, ); return plans[0] ?? metered; } export async function pickAgentTier( client: CoRelayerClient, monthlyBudgetMicroUsdc: bigint, ): Promise { const { data } = await client.getPricing({ audience: 'agent' }); return pickTier(data.tiers, monthlyBudgetMicroUsdc); } ``` If nothing fits, [Agent Metered](/plans/agent-tiers) is the floor: no period, no cap, every unit metered out of an escrow you decide the size of — and `depositAndSubscribe(11, 0, …)` sets it all up in a single transaction. ### Renewing without a human ```text setAutoRenew(enabled, renew_tier_id, max_renew_price) ``` `max_renew_price` must be greater than zero when enabled, and **zero never means unlimited**. An account that renews itself has to state its own ceiling; there is no open-ended standing authorisation in this system, which matters most for exactly the accounts that run unattended. Renewal is permissionless — anyone may trigger it for an account that opted in, because the terms are the account's own and the contract enforces them. Your agent does not need to be awake at the right moment. Watch for the notice rather than polling: a scheduled tariff increase arrives as an account notice, in the notice feed and on the account stream, 48 hours before it takes effect, so a ceiling that is about to become too low is something you can see coming. ([Tariff](/plans/tariff)) ### Topping up without buying a plan ```text POST /v1/x402/topup { payer, beneficiary?, amountMicroUsdc } ``` or, on chain, `deposit()` / `depositFor(beneficiary)`, prepared by `POST /v1/deposit/prepare`. Credits are a USDC-denominated balance inside the contract; they buy plans and fund pay-as-you-go escrow. Minimum one USDC. **Nothing you pay in is refundable to your wallet.** Credits stay credits; escrow that pay-as-you-go did not use goes back to credits when you turn pay-as-you-go off. ([Credits and billing](/plans/credits-and-billing)) --- ## Discovery Everything an agent needs to work out what this service is, what it costs and how to call it is a fetchable file. None of it requires a key, an account or a conversation. This page says where each file comes from: this site's own build, the API's answers, or the deploy pipeline, which generates a file for each network it deploys. ### Documentation, as text Served by this site (`docs.co-relayer.com`), produced by its build: | URL | Content | |---|---| | `/llms.txt` | Index: a summary, then a linked list of every page by section. | | `/llms-full.txt` | Every page in full, as one file, plus the plain-Markdown API reference appended. | | `/llms-agents.txt` | Only the agent, MCP, x402, API and error pages — in full. | | `/.md` | Any page of this site as Markdown. Append `.md` to the URL. | | `/api-reference.md` | The whole API as plain Markdown: one section per operation, with parameters, request schema and responses. | | `/errors.json` | Every error code with its status, group, `retryable`, `resign`, summary and hint. | | `/openapi.yaml`, `/openapi.json` | The API document, OpenAPI 3.1. | | `/abi/corelayer.abi.json` | The contract ABI, straight from its build output. | | `/sitemap.xml`, `/robots.txt` | Standard. Nothing is disallowed; no AI crawler is blocked. | | `/.well-known/security.txt` | Where to report a vulnerability. | The [`llms.txt` convention](https://llmstxt.org/) is what the first three follow. ### The marketing site `co-relayer.com` publishes its own `/llms.txt` and `/llms-full.txt` — the product and pricing pages rather than the reference — plus a Markdown twin of every page at `.md`. These are built output. ### From the API, at runtime These are answers from the API of each network. | URL | Content | |---|---| | `GET /v1/network` | Chain id, CAIP-2 name, contract address, payment token, gas constants, round duration, direct hosts, native-auth block, contract and swap-venue pause state, per-shard latency. | | `GET /v1/pricing` | Tiers, tariff and its version, pay-as-you-go rates, rate classes, document version. Amounts are decimal strings. | | `GET /v1/status` | Service state, components, current incidents. | | `GET /openapi.json` | The document as the running binary serves it, at the root of the API host. | | `GET /.well-known/x402` | The x402 resource descriptor. | | `GET /v1/x402/supported` | Supported schemes, networks, extensions and signer addresses. | ### Generated at deploy These are generated by the deploy pipeline from the same sources, for each network it deploys, and again whenever the tariff, a tier or the policy changes. | URL | What it carries | Source | |---|---|---| | `co-relayer.com/pricing.json`, `/pricing.md` | A static mirror of the pricing document, rebuilt when the tariff, a tier or the policy changes. | Generated at deploy | | `co-relayer.com/schemas/pricing-v1.json` | The schema that mirror validates against. | Generated at deploy | | `co-relayer.com/schemas/ru-schedule-v{N}.json` | The Relay Unit schedule, whose hash is committed on chain. | Generated at deploy | | `/.well-known/corelayer.json` | Direct hosts, contract address, chain id, registry view name. | Generated at deploy | | `/.well-known/agent-card.json` | An A2A agent card: skills derived from the API tags, the MCP endpoint, the auth rings. | Generated at deploy | | `/.well-known/agent-registration.json` | The on-chain identity registration document. | Generated at deploy | | MCP registry entry | The server's published entry, from its own `tools/list`. | Published by the key holder | ### The rules for using any of it 1. **Chain id, not address.** The CoRelayer owner and relayer wallets have the **same addresses on devnet and on mainnet**, and the contract address may coincide too. An address therefore tells you nothing about which network you are on. Compare `chainId` and refuse on a mismatch. 2. **Your pin wins.** Every discovery file is a convenience copy. If one names a contract address that differs from the one pinned in your configuration, the correct behaviour is to refuse, not to adopt it. 3. **Verify the relayer against the chain**, through a node that is not ours, before you sign a transaction naming it: `getRelayerState(address)` must be active. 4. **Check the schedule hash if you recompute prices.** The contract stores the version and hash of the Relay Unit schedule, so you can prove the document you read is the one committed to. ### Verifying the price without trusting us Every number in the pricing document comes from a contract view you can call yourself: | View | Gives | |---|---| | [`getPricingConfig`](/contract/reference/views) | Tariff, Relay Unit schedule and deposit bounds in one call. | | [`getTiers`](/contract/reference/views) | Every tier record as stored. | | [`getPrice`](/contract/reference/views), [`getPaygPrice`](/contract/reference/views) | Prices at the effective tariff. | | [`getRateClasses`](/contract/reference/views) | The throughput table. | | [`getTariffHistory`](/contract/reference/views) | Every tariff ever in force. | Read-only calls cost nothing and need no permission. If the document and the chain disagree, the chain is right and we have a bug — please [tell us](/security/disclosure). ### A note on freshness The static mirrors are rebuilt on the events that change them; the API is live. Where the two can differ — during the 48 hours between a tariff increase being scheduled and taking effect — both carry the pending value and the moment it becomes effective, so a client that reads either one can see the change coming. ([Tariff](/plans/tariff)) --- ## Errors and retries Every failure is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem document with the media type `application/problem+json`. Its `type` is the address of the page describing it, so an error leads straight to its own documentation. ```json { "type": "https://docs.co-relayer.com/errors/rate-limited", "title": "You are sending faster than your rate class allows.", "status": 429, "detail": "20 RU/s sustained exceeded for account erd1….", "instance": "req_01JB…", "code": "RATE_LIMITED", "retryable": true, "resign": "SAME_BYTES", "details": { "retryAfterMs": 1200, "scope": "account" }, "hint": "Wait for details.retryAfterMs and resend the identical bytes." } ``` ### The members to branch on | Member | Always | Use it for | |---|---|---| | `code` | yes | **The** branch. Stable machine name. | | `status` | yes | The fallback when `code` is one you do not know. | | `retryable` | yes | Whether sending the *same* request again can succeed. | | `resign` | relay path | Whether a signature is needed: `NONE`, `SAME_BYTES`, `NEW_SIGNATURE_SAME_NONCE`. | | `hint` | usually | One sentence naming the next action, written for a program. | | `instance` | yes | The request id. Quote it to support. | | `details` | some codes | Machine-readable specifics, listed on each code's page. | | `intent` | relay path | `sender`, `nonce`, `state`, and `txHash` once one exists. | Never branch on `title` or `detail`. Both are prose meant for a human reading a log. ### The enum is open New codes are added without a breaking change. An unknown `code` is handled by its `status` and `retryable`, never by failing: ```ts function classify(problem: Problem): 'retry' | 'buy' | 'stop' { if (problem.code === 'QUOTA_EXHAUSTED' || problem.code === 'NO_ENTITLEMENT') return 'buy'; if (problem.retryable) return 'retry'; if (problem.status >= 500) return 'retry'; // unknown 5xx: assume transient return 'stop'; } ``` Additions are announced in the [changelog](/changelog). ### The decision tree ```mermaid flowchart TD E["Problem document"] --> R{"resign?"} R -->|NEW_SIGNATURE_SAME_NONCE| S["Nothing was sent.
Sign once more on the same nonce."] R -->|SAME_BYTES / NONE / absent| T{"retryable?"} T -->|true| B["Back off,
resend identical bytes"] T -->|false| P{"402 or 429
about quota?"} P -->|yes| BUY["Buy or top up,
then retry"] P -->|no| STOP["Stop.
Fix the request or escalate."] ``` ### Retry, with the right backoff ```ts title="retry.ts" snippet="examples/retry.ts" /** * Retrying a CoRelayer call the way the problem documents say to. * * - retry only what the server marked `retryable`, and only by sending the same request again — * a retry never rebuilds and never re-signs; * - the server's `details.retryAfterMs` (or `Retry-After`) wins over any schedule of yours; * - otherwise back off exponentially, with jitter, so a thousand agents do not retry in step. * * `QUOTA_EXHAUSTED` is `retryable: false` on purpose: waiting does not buy you Relay Units. */ import { isApiError } from '@corelayer/sdk'; export interface RetryOptions { readonly attempts?: number; readonly baseMs?: number; readonly capMs?: number; readonly sleep?: (ms: number) => Promise; readonly random?: () => number; } export async function withRetry(run: () => Promise, options: RetryOptions = {}): Promise { const attempts = options.attempts ?? 4; const base = options.baseMs ?? 250; const cap = options.capMs ?? 8_000; const sleep = options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); const random = options.random ?? Math.random; for (let attempt = 0; ; attempt += 1) { try { return await run(); } catch (error) { if (!isApiError(error) || !error.retryable || attempt + 1 >= attempts) throw error; const serverSays = error.retryAfterMs; const backoff = Math.min(base * 2 ** attempt, cap) * (0.5 + random()); await sleep(serverSays ?? backoff); } } } ``` Rules that matter more than the schedule: - **`details.retryAfterMs` wins.** When it is present it is not advisory. - **Jitter.** Every agent retrying on the same grid is a second outage. - **Retry means the same bytes.** It never means rebuild, and it never means re-sign. ### The codes by what you do about them #### Wait and resend | Code | Status | Note | |---|---|---| | [`RATE_LIMITED`](/errors/rate-limited) | 429 | `details.retryAfterMs`. `details.scope` says whether it was your account or the platform. | | [`GAS_BUDGET_EXCEEDED`](/errors/gas-budget-exceeded) | 429 | The per-shard gas budget of your rate class. | | [`HOURLY_BURN_EXCEEDED`](/errors/hourly-burn-exceeded) | 429 | Units per hour. A sustained pattern means: change tier. | | [`TOO_MANY_IN_FLIGHT`](/errors/too-many-in-flight) | 429 | Too many unsettled intents for this sender. Wait for some to finish. | | [`NONCE_IN_FLIGHT`](/errors/nonce-in-flight) | 409 | Another intent holds this nonce. Do not build a new one for it. | | [`NO_RELAYER_AVAILABLE`](/errors/no-relayer-available) | 503 | No healthy relayer in that shard right now. | | [`SIGNER_UNAVAILABLE`](/errors/signer-unavailable) · [`SIGNER_FENCED`](/errors/signer-fenced) | 503 | Our signer. Nothing was co-signed. | | [`UPSTREAM_UNAVAILABLE`](/errors/upstream-unavailable) | 503 | A gateway or node, before anything was committed. | | [`LEASE_EXPIRED`](/errors/lease-expired) | 409 | Renew the lease and resubmit the **same** bytes. The SDK's relay helper does this for you when the client has a native-auth token for the sender, or when you give it a proof signer so it can sign a fresh presence proof for the renewal. With a proof you signed yourself, it returns this error. | #### Buy something | Code | Status | What to do | |---|---|---| | [`NO_ENTITLEMENT`](/errors/no-entitlement) | 402 | No plan. The response carries a `PAYMENT-REQUIRED` header pointing at the x402 top-up endpoint. | | [`QUOTA_EXHAUSTED`](/errors/quota-exhausted) | 429 | The units are used up: `details.reason` is `CAP_REACHED_PAYG_OFF` or `PAYG_ESCROW_EMPTY`. **No `Retry-After`** — waiting does not help. `details.pricingUrl`, `details.x402Url`, `details.periodEndMs`. | | [`INSUFFICIENT_CREDITS`](/errors/insufficient-credits) | 402 | A purchase from existing credits, and there are not enough. Deposit first, or buy with `depositAndSubscribe` in one call. | #### Stop — this nonce is finished | Code | Status | |---|---| | [`NONCE_TOO_LOW`](/errors/nonce-too-low) | 409 | | [`INTENT_ALREADY_EXECUTED`](/errors/intent-already-executed) | 409 | The slot is gone. Read your account nonce again and start over with a new one. Retrying the same nonce is an infinite loop. ```ts import { endsSlot, isApiError } from '@corelayer/sdk'; if (isApiError(error) && endsSlot(error)) { myNonce = await readAccountNonce(); // not a retry: a restart } ``` #### Sign once more — and only here | Code | Status | `resign` | |---|---|---| | [`RESIGN_REQUIRED`](/errors/resign-required) | 409 | `NEW_SIGNATURE_SAME_NONCE` | | [`RESIGN_SAME_NONCE`](/errors/resign-same-nonce) | 409 | `NEW_SIGNATURE_SAME_NONCE` | Both mean: **nothing was co-signed, so nothing was sent**, and the transaction you already signed is inert forever. A fresh assignment comes with the error, pinned to the same nonce. ```ts const outcome = await sendToken(context, { receiver, amount, intentKey }); if (outcome.kind === 'resign-required') { // outcome.previousRelayer – the one that went away // outcome.nextAssignment – the replacement, its lease pinned to the nonce // outcome.pinnedNonce – the nonce to keep if (await decide(outcome)) await resignWith(context, outcome, intentKey); } ``` Sign with the **pinned** assignment from the answer — never with a fresh, unpinned one, and never with a cancel lease, which the signer fixes to a 0-value transfer to yourself. Never infer this from a status code. Only `resign` says it. ([The re-sign case](/sdk/recipes/handle-resign)) #### Fix the request `MALFORMED_REQUEST`, `GAS_LIMIT_TOO_LOW`, `GAS_LIMIT_TOO_HIGH`, `GAS_PRICE_OUT_OF_RANGE`, `DATA_TOO_LARGE`, `GAS_OVERPROVISIONED`, `SIMULATION_FAILED`, `UNSUPPORTED_TX_FIELD`, `VARIANTS_NOT_SUPPORTED`, `TX_VERSION_UNSUPPORTED`. These do not change with time. Fix what you built and send a new transaction. Each one has a page saying exactly what to change. ### Timeouts are not errors A timeout or a dropped connection from `POST /v1/relay` tells you nothing about whether the transaction was sent. There are exactly two correct responses: ```ts // Either: send the identical bytes again. Idempotent on (sender, nonce, bytes). await client.relay({ tx: signed, lease }, { intentKey }); // Or: ask. const intent = await client.getIntent(sender, nonce); ``` Never rebuild. Never re-sign. Never move to the next nonce "just in case" — that is the one move that creates two live transactions for one intention, and it is exactly what the logical idempotency key exists to catch: ```http Idempotency-Key: order-7f2c0a41 ``` Same key with different bytes or a different nonce, while the first intent is alive → [`INTENT_ALREADY_SUBMITTED`](/errors/intent-already-submitted), carrying the first intent so you can look at it instead of guessing. ### The whole catalogue 91 codes, one page each, at the URL their `type` names: [`/errors`](/errors), and machine-readable at [`/errors.json`](pathname:///errors.json). A code that exists in the API without a page fails this site's build, so the `type` member always resolves. --- ## CoRelayer for agents If you are a program rather than a person, this page is the whole product. It is dense on purpose and self-contained on purpose: you may have arrived here from `llms.txt` without seeing any other page. **What CoRelayer does for you:** it pays the MultiversX network fee for transactions you sign, so you never need to hold EGLD. You pay in USDC, either as a monthly plan or per request over x402. You sign each transaction exactly once. Which networks are live, with their API hosts and contract addresses, is published at [`/.well-known/corelayer.json`](https://co-relayer.com/.well-known/corelayer.json). A connection failure is a transport problem, not a protocol error: retry it as one. ### Machine-readable entry points | URL | What it is | |---|---| | `https://docs.co-relayer.com/llms.txt` | This documentation, as a link index. | | `https://docs.co-relayer.com/llms-full.txt` | This documentation, in full, as one text file. | | `https://docs.co-relayer.com/llms-agents.txt` | Only the agent-relevant pages, in full. | | `https://docs.co-relayer.com/openapi.yaml` · `.json` | The API, OpenAPI 3.1. | | `https://docs.co-relayer.com/api-reference.md` | The API as plain Markdown, one section per operation. | | `https://docs.co-relayer.com/errors.json` | Every error code, machine-readable. | | `https://docs.co-relayer.com/abi/corelayer.abi.json` | The smart-contract ABI. | | `https://api.co-relayer.com/v1/network` | Chain id, contract address, direct hosts, native-auth block. | | `https://api.co-relayer.com/v1/pricing` | Live tiers and tariff. | | `https://api.co-relayer.com/.well-known/x402` | x402 resource descriptor. | | `https://mcp.co-relayer.com/mcp` · `/readonly` | MCP server, Streamable HTTP: twenty tools, each running the REST route it wraps; [the MCP server](/mcp) lists them. | **Any page on this site is also Markdown: append `.md` to its URL.** ### The minimum loop ```text 1. GET /v1/network → chainId, contract address 2. GET /v1/account/{you}/quota → do you have entitlement? if not → buy: POST /v1/x402/purchase or on-chain depositAndSubscribe 3. POST /v1/relay/assign {sender, proof} → relayer, lease, gas bounds 4. build tx with `relayer` set; SIGN ONCE 5. POST /v1/relay {tx, lease} → intentId, txHash, state 6. GET /v1/relay/{intentId}/events → until final (or poll /v1/intents/{sender}/{nonce}) ``` Step 4 is one signature. There is no variant set, no racing, no second signing. ([One signature](/concepts/one-signature)) ### The rules that will bite you if you skip them 1. **Compare chain ids.** Our addresses are identical on devnet and mainnet. An address is not a network identifier. Compare `chainId` from `/v1/network` with the `chainID` of the transaction you are about to sign, and refuse on a mismatch. 2. **Pin the contract address.** Take it from your own configuration, never from an API response. The discovery files are convenience copies; where they disagree with your pin, your pin wins. 3. **Verify the relayer on chain** before signing: `getRelayerState(relayer)` must be *active*. Query a node that is not ours. Cache by `registryVersion`. 4. **An error from `POST /v1/relay` means nothing was sent.** No error is ever returned after the relayer signature exists. 5. **On a timeout, re-send the identical bytes or query the intent.** Never rebuild, never re-sign. 6. **Branch on `code`, not on `title` or `detail`.** The enum is open; handle an unknown code by its HTTP status and its `retryable` member. 7. **Only sign again when `resign` says so.** `NONE`, `SAME_BYTES`, `NEW_SIGNATURE_SAME_NONCE` — never infer it from a status code. 8. **Set an idempotency key**, one per logical action: `Idempotency-Key` header or `intentKey` in the body. 9. **Never log tool arguments containing a signed transaction.** A signed payload in a transcript is a payload someone else can hold. ### Errors Everything is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem document, `application/problem+json`, whose `type` is a page on this site: ```json { "type": "https://docs.co-relayer.com/errors/quota-exhausted", "title": "The plan's included Relay Units are used up.", "status": 429, "detail": "Cap of 1000 RU reached for the period ending 2026-10-19T00:00:00Z.", "instance": "req_01JB…", "code": "QUOTA_EXHAUSTED", "retryable": false, "resign": "NONE", "details": { "reason": "CAP_REACHED_PAYG_OFF" }, "hint": "Turn on pay-as-you-go or upgrade the tier." } ``` `hint` is written for you: one sentence naming the next action. Full catalogue: [`/errors`](/errors) and [`/errors.json`](pathname:///errors.json). Handling patterns: [Errors and retries](/agents/errors-and-retries). ### Paying | Way | Shape | Read | |---|---|---| | On-chain plan | One `depositAndSubscribe` call, relayed free of charge, with a price ceiling you set | [Buying a plan](/agents/buying-a-plan) | | x402 | HTTP 402 challenge → pay → retry; settle-before-serve | [x402](/x402) | | MCP | `prepare_subscribe`, `buy_plan_x402`, `topup_x402`, each running the route it wraps | [MCP tools](/mcp/tools) | An important sequencing rule: **`POST /v1/relay` does not accept payments.** Its 402 tells you where to pay. Pay first, *then* sign your payload transaction — a payment at nonce `n` would kill a payload you signed at nonce `n`. ### Authentication | Ring | How | For | |---|---|---| | Public | nothing | Pricing, network, status, registry, intent status | | Transaction-as-credential | the signed transaction itself | `POST /v1/relay` | | Presence proof | sign `corelayer/assign/v1\|chainId\|sender\|serverTimeMs` | `POST /v1/relay/assign` | | Native-auth | MultiversX native-auth bearer token | Your own private reads | | Sponsor key | `X-Api-Key: crk__…` on `POST /v1/relay` | Paying for other senders, such as every agent an operator runs: server-side only, Agent Pro and Agent Fleet ([sponsor mode](/plans/sponsoring-senders)) | | x402 | `PAYMENT-SIGNATURE` header | Purchases | Details and exact message formats: [Authentication](/agents/auth). ### Next - A runnable end-to-end script: [Quickstart](/agents/quickstart) - Every discovery file and what is in it: [Discovery](/agents/discovery) - The typed client: [SDK](/sdk) --- ## Agent quickstart Discover → check → buy → relay → follow, as one program. It uses `@corelayer/sdk` for the CoRelayer calls and `@multiversx/sdk-core` for the agent's own key. The program is not a sketch: it is type-checked against the API's generated types and **run end to end by this site's test suite** against a stand-in API and gateway, from an account with no plan to an executed transfer. ### What you need | | | |---|---| | A MultiversX key | In a PEM file the program reads. Nothing in CoRelayer ever sees it. | | USDC on the network you use | `USDC-c76f1f` on mainnet, `USDC-350c4e` on devnet. The program takes it from `GET /v1/network`. | | The contract address of that network | **Your pin**, from your own configuration. Copy it from [the contract page](/contract/overview#at-a-glance) or [`/.well-known/corelayer.json`](https://co-relayer.com/.well-known/corelayer.json). | | EGLD | None. | ### The program ```ts title="agent-quickstart.ts" snippet="examples/agent-quickstart.ts" /** * The agent quickstart as one program: discover → check → buy if needed → relay → follow. * * Configuration comes from the environment, so no key, address or host is written into the code: * * CORELAYER_API https://devnet-api.co-relayer.com (or a local backend) * CORELAYER_CHAIN_ID D (1 on mainnet) * CORELAYER_CONTRACT the CoRelayer contract of that chain — YOUR pin, from the docs or the explorer * CORELAYER_GATEWAY https://devnet-gateway.multiversx.com (any gateway that is not CoRelayer's) * CORELAYER_PEM path to the agent's key file * RECEIVER who gets the tokens * AMOUNT smallest units of the network's USDC (6 decimals: 1000000 = 1 USDC) * BUY_TIER optional: tier to buy when the account has no entitlement (11 = Agent Metered) * BUY_MONTHS optional, default 1 (0 for Agent Metered) * MAX_PAY optional: the most the purchase may cost, micro-USDC * * node agent-quickstart.ts */ import { pathToFileURL } from 'node:url'; import { CoRelayerClient, type components } from '@corelayer/sdk'; import { buyPlan } from './buy-plan.ts'; import { Gateway } from './gateway.ts'; import { type RelayContext, sendToken } from './send-token.ts'; import { RelayerCheck } from './verify-relayer.ts'; import { type Wallet, walletFromPemFile } from './wallet.ts'; import { followIntent, type Outcome } from './watch-intent.ts'; type Quota = components['schemas']['Quota']; /** Suffix for idempotency keys: one run of this program is one business action per step. */ const RUN_ID = new Date() .toISOString() .replace(/[^0-9]/g, '') .slice(0, 14); export interface QuickstartConfig { readonly api: string; readonly chainId: string; readonly contract: string; readonly gateway: string; readonly wallet: Wallet; readonly receiver: string; readonly amount: bigint; readonly buy?: { readonly tierId: number; readonly months: number; readonly maxPayMicroUsdc: bigint; }; /** Injected in tests; the global `fetch` otherwise. */ readonly fetch?: typeof globalThis.fetch; readonly log?: (line: string) => void; } /** The account states in which CoRelayer serves a transaction. */ const SERVED: ReadonlySet = new Set([ 'ACTIVE_CAP', 'ACTIVE_PAYG', 'RENEWAL_PENDING', ]); /** `erd1…:41` → 41. The intent id is `:` by definition. */ function nonceOf(intentId: string): number { return Number(intentId.slice(intentId.lastIndexOf(':') + 1)); } export async function runQuickstart(config: QuickstartConfig): Promise { const log = config.log ?? ((line: string) => console.log(line)); const fetchOption = config.fetch === undefined ? {} : { fetch: config.fetch }; const me = config.wallet.address; // ── 1. Discover ──────────────────────────────────────────────────────────────────────────── const bootstrap = new CoRelayerClient({ baseUrl: config.api, ...fetchOption }); const { data: network } = await bootstrap.getNetwork(); if (network.chainId !== config.chainId) { throw new Error( `The API serves chain ${network.chainId}; this agent is configured for ${config.chainId}.`, ); } if (network.contract !== null && network.contract !== config.contract) { // The API repeats the contract address for convenience. Your pin wins; a disagreement is a // reason to stop and find out why, not to switch. throw new Error(`The API names contract ${network.contract}; your pin is ${config.contract}.`); } // From here on, the failover list is the operator's, not a guess frozen into this file. const client = new CoRelayerClient({ baseUrl: config.api, directHosts: network.directHosts ?? [], ...fetchOption, }); const gateway = new Gateway({ url: config.gateway, ...fetchOption }); const context: RelayContext = { client, wallet: config.wallet, gateway, relayers: new RelayerCheck({ chainId: config.chainId, contract: config.contract, gateway }), chainId: config.chainId, }; log( `chain ${network.chainId}, payment token ${network.paymentToken ?? 'not reported'}, sender ${me}`, ); // ── 2. Check entitlement ─────────────────────────────────────────────────────────────────── const readQuota = async (): Promise => (await client.transport.get(`/v1/account/${encodeURIComponent(me)}/quota`)).data; let quota = await readQuota(); log(`service state ${quota.state}`); // ── 3. Buy, if there is nothing to relay on ──────────────────────────────────────────────── if (!SERVED.has(quota.state)) { if (config.buy === undefined) { throw new Error(`The account is ${quota.state} and no purchase is configured (BUY_TIER).`); } const { response, quote } = await buyPlan( { ...context, contract: config.contract }, { ...config.buy, intentKey: `buy-${me}-${config.buy.tierId}-${RUN_ID}` }, ); log( `purchase ${response.intentId}: ${response.state}, price ${quote.priceMicroUsdc} micro-USDC`, ); // Wait for the purchase to be final before signing anything else: it used nonce n. const bought = await followIntent(client, me, nonceOf(response.intentId)); if (bought.kind !== 'executed') throw new Error(`The purchase ended ${bought.kind}.`); quota = await readQuota(); log(`service state ${quota.state}`); } // ── 4. Relay ─────────────────────────────────────────────────────────────────────────────── const sent = await sendToken(context, { receiver: config.receiver, amount: config.amount, intentKey: `send-${me}-${config.receiver}-${config.amount}-${RUN_ID}`, }); if (sent.kind === 'resign-required') { // An agent may decide to re-sign (see resignWith), but it decides — with a bound. throw new Error( `Relayer ${sent.previousRelayer} became unavailable before co-signing. Nothing was sent; ` + `re-signing on nonce ${sent.pinnedNonce} is a separate, deliberate step.`, ); } log(`relayed ${sent.response.intentId} (${sent.response.txHash}): ${sent.response.state}`); // ── 5. Follow it to the outcome ──────────────────────────────────────────────────────────── const outcome = await followIntent(client, me, nonceOf(sent.response.intentId), { onState: (intent) => log(` ${intent.state}${intent.final ? ' (final)' : ''}`), }); log(`outcome: ${outcome.kind}`); return outcome; } function required(name: string): string { const value = process.env[name]; if (value === undefined || value === '') throw new Error(`Set ${name}.`); return value; } async function main(): Promise { const tier = process.env.BUY_TIER; const outcome = await runQuickstart({ api: required('CORELAYER_API'), chainId: required('CORELAYER_CHAIN_ID'), contract: required('CORELAYER_CONTRACT'), gateway: required('CORELAYER_GATEWAY'), wallet: walletFromPemFile(required('CORELAYER_PEM')), receiver: required('RECEIVER'), amount: BigInt(required('AMOUNT')), ...(tier === undefined ? {} : { buy: { tierId: Number(tier), months: Number(process.env.BUY_MONTHS ?? '1'), maxPayMicroUsdc: BigInt(required('MAX_PAY')), }, }), }); process.exitCode = outcome.kind === 'executed' ? 0 : 1; } if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { await main(); } ``` It is built from the pieces documented on their own pages: | Step | Module | Page | |---|---|---| | Discover, refuse the wrong chain | `send-token.ts` → `connect` | [TypeScript](/sdk/javascript) | | Check entitlement | `GET /v1/account/{erd}/quota` | [Check before you send](/sdk/recipes/check-quota) | | Buy, if there is nothing to relay on | `buy-plan.ts` | [Buying a plan](/agents/buying-a-plan) | | Relay, with the relayer verified on chain | `send-token.ts`, `verify-relayer.ts` | [Sign and relay](/sdk/recipes/sign-and-relay) | | Follow to the outcome | `watch-intent.ts` | [Watch an intent](/sdk/recipes/watch-an-intent) | | The key | `wallet.ts` | [Sign and relay](/sdk/recipes/sign-and-relay) | ### The relay step on its own An agent that already has a plan needs only the relay. `relayOnce` asks for a relayer with a presence proof your agent signs, builds the transaction for that relayer, has your agent sign it once and submits it. This is the code co-relayer.com shows for an agent that pays for itself: ```ts title="agent-relay.ts" snippet="examples/agent-relay.ts#site-agent" import { relayOnce } from '@corelayer/sdk'; type Opts = Parameters[0]; // The agent's own key: its address and the two things it signs. type AgentKey = Pick; // The client, the transaction builder and one key per action. type Job = Pick; // Your agent signs once, with its own key. No EGLD needed. export function relayAsAgent(agent: AgentKey, job: Job) { return relayOnce({ ...job, ...agent }); } ``` `agent` is your agent's key as `relayOnce` takes it: `sender` is its address, `signProof` signs the presence proof and `signOnce` signs the transaction, once. To check the relayer on chain before your agent signs, pass `verifyRelayer` a verifier from `createRelayerVerifier`: see [Verify a relayer](/concepts/verify-a-relayer). ### Running it The program runs on Node 24, next to the example files it imports and with `@corelayer/sdk` and `@multiversx/sdk-core` installed: ```bash CORELAYER_API=https://devnet-api.co-relayer.com \ CORELAYER_CHAIN_ID=D \ CORELAYER_CONTRACT= \ CORELAYER_GATEWAY=https://devnet-gateway.multiversx.com \ CORELAYER_PEM=./agent.pem \ RECEIVER=erd1… AMOUNT=1000000 \ BUY_TIER=11 BUY_MONTHS=0 MAX_PAY=1000000 \ node examples/agent-quickstart.ts ``` The SDK is not on npm yet. Every call it makes is a documented route, so until it is, plain `fetch` can take its role: [Pay for your users](/sdk/recipes/sponsor-users#without-an-sdk) shows a relay with no SDK. ### What it does, and why each check is there **It compares chain ids before anything else.** CoRelayer's wallets have the same addresses on devnet and mainnet, and the contract address may coincide too. An address never tells you which network you are on; the chain id does. **It stops if the API names a different contract.** The API repeats the contract address for convenience. Your pin wins, and a disagreement is a reason to find out why — never to switch. **It takes the failover hosts from the server.** `GET /v1/network` lists the regional hosts the transport falls back to, so the list is the operator's, not a guess in your code. **It buys before it signs anything else.** A purchase and a payload both come from the same account and both use a nonce. So it buys, waits for the purchase to be final, and only then builds the transfer — at the next nonce. A payload signed first at nonce `n` would be dead the moment the purchase took `n`. **It verifies the relayer on chain before its one signature.** Through a gateway that is not CoRelayer's, against the pinned contract. ([Verify a relayer](/concepts/verify-a-relayer)) **It does not re-sign on its own.** If the relayer becomes unusable before co-signing, it stops and says so; re-signing is a separate, deliberate step with a bound. ([Handle a re-sign request](/sdk/recipes/handle-resign)) **It follows the transaction to a final state** — and treats a timeout as "unknown", never as a failure. ### Tier 11, Agent Metered The example buys [Agent Metered](/plans/agent-tiers) when the account has no entitlement: no period, no cap, every Relay Unit billed at the pay-as-you-go price from an escrow. `depositAndSubscribe` with tier 11 and `months = 0` turns the whole payment into credits and pay-as-you-go escrow in **one** transaction, and the purchase itself is relayed free of charge — which is what makes it possible with zero EGLD. ### Over x402 instead If your agent speaks HTTP 402, it can buy the same plan without the prepare route: [x402](/x402) has the complete client. The same ordering rule applies — pay first, then sign the payload. ### The checklist Before your agent handles real money, make sure it does each of these: - Compare the chain id before every signature. - Pin the contract address in your own configuration, keyed by chain id. - Verify the relayer is `active` on chain, through a node that is not ours. - Sign exactly once per action. - Use one idempotency key per business action. - Retry by re-sending identical bytes; never re-sign. - Respect `resign`; never infer it from a status code. - Handle unknown error codes by `status` and `retryable`. - Never write a signed transaction to a log or a transcript. --- ## Conventions These hold for every route. Reading them once saves reading them thirteen times in the reference. ### Media types and names | | | |---|---| | Requests and responses | `application/json`, UTF-8 | | Errors | `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)) | | JSON member names | `camelCase`. `snake_case` appears only when a contract name is being quoted. | | Unknown members in a request body | **Rejected** on every write route. | | Maximum request body | 131,072 bytes → [`PAYLOAD_TOO_LARGE`](/errors/payload-too-large) | | `{erd}` in a path | Always a bech32 address. | ### Numbers This is the one convention that surprises people, so it is stated first and precisely. | Where | Type | Why | |---|---|---| | atto-EGLD, anywhere | **decimal string**, member ends in `Atto` | It does not fit a double. | | Operational routes — relay, intents, quota, usage, dashboard — micro-USDC, Relay Units, gas, gas price | **JSON integers** | Bounded far below 2^53. | | The **pricing document family** — `GET /v1/pricing`, the static mirror, `POST /v1/quote`, `POST /v1/subscribe/prepare`, x402 `accepts[]` | **decimal strings** | These documents are hashed and recomputed by agents in arbitrary languages. A float is not a price. | The rule is mechanical rather than a matter of taste: the schema types `MicroUsdc` and `Ru` are integers, `DecimalString` is a string. If you are parsing a price, parse a decimal. ### Time | | | |---|---| | Everything | Unix **milliseconds** as a JSON number, member name ending in `Ms`. | | Seconds | Only where an external standard dictates: `Retry-After`, `RateLimit-Reset`, x402 `maxTimeoutSeconds`, native-auth TTL. Each has a millisecond twin in the body — `details.retryAfterMs`, `extra.quoteExpiresAtMs`, `expiresAtMs`. | | Two clocks | `chainTimeMs` is the shard's block timestamp and decides every entitlement question and every ledger row. `serverTimeMs` is our wall clock and covers request timestamps and lease expiry. Responses that carry both name them. | Correct your own clock against `serverTimeMs`, not the other way round: the presence proof is valid for thirty seconds either side of it. ### Identifiers | | Form | |---|---| | Intent id | `:`, e.g. `erd1…:41` | | Quote id | `q_` + 10 Crockford base32 characters | | Incident | `inc_…` | | Webhook | `wh_…` | | API key id | 12 base32 characters | | API key | `crk___` | | Request id | `req_…`, returned in `CoRelayer-Request-Id` and as a problem document's `instance` | ### Headers On every response: | Header | | |---|---| | `CoRelayer-Request-Id` | The log correlation id, and the `instance` of any problem document. | | `CoRelayer-Version` | The API build. | On rate-limited routes: `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`. From a client: `CoRelayer-Client: /` is appreciated and never used to refuse you. An old client is logged, not rejected — a refused relay is an outage for whoever is running it. ### Idempotency | Route | Key | Behaviour | |---|---|---| | `POST /v1/relay`, `POST /relay` | Natural: `(sender, nonce)` + a hash of the signed bytes | Identical bytes → the stored response, `duplicate: true`. Different bytes on a live slot → [`NONCE_IN_FLIGHT`](/errors/nonce-in-flight). | | `POST /v1/relay` (optional, recommended) | `Idempotency-Key` header, or `intentKey` in the body — 16 to 128 visible ASCII characters | One per logical action. Same key with different bytes or nonce while the first intent lives → [`INTENT_ALREADY_SUBMITTED`](/errors/intent-already-submitted). | | x402 purchases | `extensions["payment-identifier"].id`, **required** | Same id + same payload → the same result. Same id + different payload → `402 PAYMENT_INVALID`. | | `POST` on keys and webhooks | `Idempotency-Key`, **required** | Same key + same body → the stored response. Different body → [`IDEMPOTENCY_KEY_REUSED`](/errors/idempotency-key-reused). Still running → [`IDEMPOTENCY_IN_PROGRESS`](/errors/idempotency-in-progress). | | `POST /v1/relay/assign`, the `prepare` routes, `quote`, `validate` | none | Stateless. Calling twice gives two valid answers. | | `PUT`, `PATCH`, `DELETE`, acknowledging notices | — | Idempotent by construction. | | Webhook deliveries | The event `id` | At-least-once. The consumer de-duplicates. | **The client rule:** on a timeout or a connection error from `POST /v1/relay`, re-send the identical bytes or query `GET /v1/intents/{sender}/{nonce}`. Never rebuild, never re-sign. ### Pagination Cursor only. ```http GET /v1/usage?limit=50&cursor= ``` ```json { "items": [ … ], "nextCursor": "…", "hasMore": true } ``` plus `Link: <…>; rel="next"`. | | | |---|---| | `limit` | 1 to 200, default 50 | | Cursors | Opaque; they encode the last sort key and a hash of the filter set. Using one with different filters → [`CURSOR_INVALID`](/errors/cursor-invalid). They do not expire. | | Sort order | Fixed per route and total. Usage is newest first; purchases by event sequence; notices by sequence; incidents newest first; senders in the order they were authorised. | | **No offset pagination, no total counts** | A `COUNT(*)` per page is the classic way to make a dashboard slow. Totals come from `GET /v1/usage/summary`. | | Incremental polling | `sinceSeq` for notices, `fromMs` for usage. Better than walking pages you have already seen. | | CSV | `GET /v1/usage?format=csv` streams every matching row without a cursor. Cells beginning with `=`, `+`, `-` or `@` are prefixed with `'` so a spreadsheet cannot be made to execute them. | ### Rate limits Relay-path limits come from your tier's rate class: units per second with a burst, a per-shard gas budget, a ceiling on any single transaction's gas limit, and units per hour. ([Limits](/operations/limits)) Everything else is limited per IP or per account by route class — public cached reads generously, `quote` and `validate` tightly (they can trigger a simulation), writes on keys and webhooks strictly. Every 429 carries `Retry-After` in seconds and `details.retryAfterMs` — **except** [`QUOTA_EXHAUSTED`](/errors/quota-exhausted), which deliberately has neither, because waiting does not help. It carries `details.pricingUrl`, `details.x402Url` and `details.periodEndMs` instead. ### CORS | Routes | Policy | |---|---| | Public `GET` | `Access-Control-Allow-Origin: *` | | `POST /v1/relay`, `/v1/relay/assign`, intent reads, `/v1/network`, the `prepare` routes, the compatibility facade | `*`, without credentials — they carry no ambient authority; the signed transaction is the credential | | Native-auth routes | The dashboard origin only | The regional direct hosts send **the same** CORS headers as the main host, so a browser client can fail over too. ### Attacker-controlled bytes Transaction `data`, contract return messages, the `ref` of a purchase and token names are all values somebody else chose. They are returned as base64, as hex, or as plain text fields that a client must render as text. **The API never returns HTML.** ### Errors One shape, everywhere: ```json { "type": "https://docs.co-relayer.com/errors/gas-limit-too-low", "title": "The gas limit is below the cost of moving the transaction.", "status": 400, "detail": "gasLimit 90000 is below moveGas 100000.", "instance": "req_01JB…", "code": "GAS_LIMIT_TOO_LOW", "retryable": false, "resign": "NONE", "details": { "moveGas": 100000, "gasLimit": 90000 }, "hint": "Raise the gas limit to at least moveGas." } ``` Branch on `code`. Use `status` and `retryable` for a code you do not know. Only sign again when `resign` says so. Full catalogue: [`/errors`](/errors). --- ## API overview One HTTP API, described by one OpenAPI 3.1 document, from which this reference, the SDK types and the plain-Markdown summary for agents are all generated. **The OpenAPI document is the authority** for every route, field and error name. If a field is not in it, no client should depend on it. The types of all four SDKs are generated from it. | | | |---|---| | Document | [`/openapi.yaml`](pathname:///openapi.yaml) · [`/openapi.json`](pathname:///openapi.json) | | For agents | [`/api-reference.md`](pathname:///api-reference.md) | | Errors | [`/errors`](/errors) · [`/errors.json`](pathname:///errors.json) | ### Hosts | Surface | URL | |---|---| | REST API | `https://api.co-relayer.com/v1/…` | | Compatibility facade | `https://api.co-relayer.com/health`, `/relayer/address/{erd}`, `/relay` | | x402 | `https://api.co-relayer.com/v1/x402/*`, `/.well-known/x402` | | MCP | `https://mcp.co-relayer.com/mcp`, `/readonly` | | Staging (devnet) | `https://devnet-api.co-relayer.com` | Regional hosts for client-side failover are published in `GET /v1/network` as `directHosts`. Take them from there rather than hard-coding them: the failover list should be the operator's, not a guess frozen into a build. **One process serves every surface.** The compatibility, x402 and MCP surfaces are adapters in front of `POST /v1/relay` — one validation pipeline, one signer boundary, one ledger. There is no second relay path. ### Versioning | | | |---|---| | Major version | In the path: `/v1`. A breaking change creates `/v2`, and `/v1` keeps running alongside it. | | Non-breaking, allowed inside v1 | New routes, new optional request members, new response members, new values in enums documented as **open**. | | Open enums | `ErrorCode`, `NoticeKind`, `DetectorCode`, `ComponentId`, `PurchaseKind`. | | Frozen enums | `IntentState`, `DeadReason`, `ResignAction`, `RelayMode`, `ServiceState`, `RelayerStateName`, `BillingClass`, `ExecStatus`, `AuthMode`. | | Breaking | Removing or renaming anything, changing a type, changing what an error code means, tightening documented validation. | | Deprecation | `Deprecation` and `Sunset` headers plus a `Link` with `rel="deprecation"`, well before removal. | | The compatibility facade | Unversioned and **frozen**. Its shape belongs to third-party clients. | **The client contract:** ignore unknown members, and handle an unknown error `code` by its HTTP status and `retryable`. A client that raises on an unfamiliar code will break for no reason. Several things version independently of the API, and none of them is coupled to it: the Relay Unit schedule, the rate-class policy, the pricing document, the lease layout and the webhook payload version. ### The route groups | Tag | What is in it | Auth | |---|---|---| | **meta** | Liveness, readiness, the OpenAPI document, the x402 descriptor, `GET /v1/network` | public | | **relay** | Assign, relay, intent status, the per-intent stream, quote, validate | the transaction; presence proof for assign | | **pricing** | The pricing document and the tariff history | public | | **purchase** | The four `prepare` routes that build unsigned transactions | public | | **x402** | Top-up and purchase over HTTP 402 | x402 | | **registry** | The relayer registry as the API mirrors it | public | | **status** | Service state and incidents | public | | **account** | Plan, quota, senders, purchases — mirrors of chain state | public | | **dashboard** | Notices, usage, latency, per-account relayers and outages | native-auth or a read key | | **keys** | Sponsor API keys | native-auth only | | **notifications** | Webhooks, notification preferences, the account stream | native-auth only | | **compat** | The frozen third-party facade | the transaction | The dividing line for the public reads is one rule: **a pure mirror of chain state is public; anything derived from our own ledger or configuration is private.** Your quota is derivable from the chain, so it is public. Your per-transaction latency is our measurement of you, so it is not. ### The shape of a call ```bash curl -sS https://api.co-relayer.com/v1/pricing \ -H 'accept: application/json' ``` ```bash curl -sS https://api.co-relayer.com/v1/relay \ -H 'content-type: application/json' \ -H 'Idempotency-Key: order-7f2c0a41' \ -d '{"tx": { … }, "lease": "…"}' ``` Every response carries `CoRelayer-Request-Id` — the same value as a problem document's `instance`, and the one to quote to support — and `CoRelayer-Version`, the API build. The conventions that apply to every route are on their own page: [Conventions](/api/conventions). ### Reading the reference The **Reference** section of this sidebar is generated from the OpenAPI document, one page per operation, grouped by tag. Each page shows the parameters, the request schema, every response and the schemas they reference. There is deliberately **no "try it" console**. A button that cannot reach anything is worse than no button; it will come back when an environment is public. ### The compatibility facade Three unversioned routes at the API root, with shapes fixed by a third-party starter kit: | Route | Behaviour | |---|---| | `GET /health` | `{"status":"ok"}` | | `GET /relayer/address/{userAddress}` | One relayer address, deterministic and long-lived, no lease. | | `POST /relay` | Takes a transaction naming any of our active or draining relayers in the right shard. Transaction-as-credential only. Stricter limits: one unit per second, minimum gas price, simulation always on, no modes. | It exists because those clients cache a relayer address forever with no expiry. That is also why a relayer handed out here drains for a full month before it can retire: there is no way to tell those clients to look again. ([Relayers](/concepts/relayers)) --- ## ACCOUNT_SUSPENDED ## `ACCOUNT_SUSPENDED` The account is suspended. | | | |---|---| | HTTP status | `403` | | Group | Platform | | Retryable | No — sending it again changes nothing | ### What happened An operator flag suspended it; relaying and purchasing are blocked while it is set. ### What to do Contact support@co-relayer.com. Credits are not lost by a suspension. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/account-suspended", "title": "The account is suspended.", "status": 403, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "ACCOUNT_SUSPENDED", "retryable": false, "hint": "Contact support@co-relayer.com. Credits are not lost by a suspension." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## API_KEY_INVALID ## `API_KEY_INVALID` The API key is unknown, revoked, or from another environment. | | | |---|---| | HTTP status | `401` | | Group | Platform | | Retryable | No — sending it again changes nothing | ### What happened Keys are environment-bound: a test key never works on mainnet and the reverse. ### What to do Use a key issued for this host, or create a new one in the dashboard. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/api-key-invalid", "title": "The API key is unknown, revoked, or from another environment.", "status": 401, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "API_KEY_INVALID", "retryable": false, "hint": "Use a key issued for this host, or create a new one in the dashboard." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## API_KEY_SCOPE ## `API_KEY_SCOPE` The API key is valid but not allowed to do this. | | | |---|---| | HTTP status | `403` | | Group | Platform | | Retryable | No — sending it again changes nothing | ### What happened Keys carry scopes. A `relay` key needs a plan that can sponsor any sender (Builder and up, Agent Pro, Agent Fleet), and pays only for the receivers and functions on its allow-lists. ### What to do Use a key with the right scope, move to a plan that can sponsor, or widen the key’s allow-lists in the dashboard. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/api-key-scope", "title": "The API key is valid but not allowed to do this.", "status": 403, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "API_KEY_SCOPE", "retryable": false, "hint": "Use a key with the right scope, move to a plan that can sponsor, or widen the key’s allow-lists in the dashboard." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## ASSIGN_PROOF_INVALID ## `ASSIGN_PROOF_INVALID` The presence proof did not verify. | | | |---|---| | HTTP status | `401` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened Wrong signature, wrong domain string, wrong chain id, or a timestamp too far from server time. A common cause: the message was signed with the MultiversX message prefix (a wallet’s `signMessage`, or sdk-core `Account.signMessage`) instead of raw. The proof is a raw Ed25519 signature by the sender’s own key over the UTF-8 bytes of the message, for `kind` `key` and `sponsor` alike. ### What to do Sign the bytes of the message directly with the sender key (sdk-core `UserSigner.sign`), read `details.serverTimeMs`, rebuild the proof for this host’s chain id, and call assign again. ### Extra members This code carries `serverTimeMs` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/assign-proof-invalid", "title": "The presence proof did not verify.", "status": 401, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "ASSIGN_PROOF_INVALID", "retryable": false, "resign": "NONE", "hint": "Sign the bytes of the message directly with the sender key (sdk-core `UserSigner.sign`), read `details.serverTimeMs`, rebuild the proof for this host’s chain id, and call assign again." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## ASSIGN_PROOF_REQUIRED ## `ASSIGN_PROOF_REQUIRED` The assign call needs a proof that the sender is present. | | | |---|---| | HTTP status | `401` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened A lease is only minted against a presence proof signed with the sender key, or a native-auth token for the sender. A sponsor API key does not prove presence: the assign route does not read `X-Api-Key`. ### What to do Sign the presence message with the sender key and call assign again. ### Extra members This code carries `serverTimeMs` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/assign-proof-required", "title": "The assign call needs a proof that the sender is present.", "status": 401, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "ASSIGN_PROOF_REQUIRED", "retryable": false, "resign": "NONE", "hint": "Sign the presence message with the sender key and call assign again." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## CHAIN_ID_MISMATCH ## `CHAIN_ID_MISMATCH` The transaction was signed for a different network than this host serves. | | | |---|---| | HTTP status | `400` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened The `chainID` inside the signed transaction is not the chain id of this API host. Mainnet is `1`, devnet staging is `D`. ### What to do Rebuild the transaction with the chain id from `GET /v1/network` on the host you are calling, and sign it again. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/chain-id-mismatch", "title": "The transaction was signed for a different network than this host serves.", "status": 400, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "CHAIN_ID_MISMATCH", "retryable": false, "resign": "NONE", "hint": "Rebuild the transaction with the chain id from `GET /v1/network` on the host you are calling, and sign it again." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## CHALLENGE_INVALID ## `CHALLENGE_INVALID` The signed challenge did not verify. | | | |---|---| | HTTP status | `401` | | Group | Platform | | Retryable | No — sending it again changes nothing | ### What happened It was already used, has expired, or was signed by a different key. ### What to do Request a new challenge and sign that one. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/challenge-invalid", "title": "The signed challenge did not verify.", "status": 401, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "CHALLENGE_INVALID", "retryable": false, "hint": "Request a new challenge and sign that one." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## CONTRACT_PAUSED ## `CONTRACT_PAUSED` The CoRelayer contract is paused for this kind of call. | | | |---|---| | HTTP status | `503` | | Group | Relay path | | Retryable | Yes — the same request may be sent again | | Re-sign | `SAME_BYTES` — resend the transaction you already have, byte for byte | ### What happened `details.scope` says how wide the pause is (`all` or `deposits`). The contract is deployed paused and every money path has its own kill switch. ### What to do Retry later with the same bytes. An existing plan keeps working; nothing was charged. ### Extra members This code carries `scope` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/contract-paused", "title": "The CoRelayer contract is paused for this kind of call.", "status": 503, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "CONTRACT_PAUSED", "retryable": true, "resign": "SAME_BYTES", "hint": "Retry later with the same bytes. An existing plan keeps working; nothing was charged." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## CURSOR_INVALID ## `CURSOR_INVALID` The pagination cursor is malformed or no longer valid. | | | |---|---| | HTTP status | `400` | | Group | Platform | | Retryable | No — sending it again changes nothing | ### What happened Cursors are opaque and tied to the query they were issued for. ### What to do Start the listing again without a cursor. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/cursor-invalid", "title": "The pagination cursor is malformed or no longer valid.", "status": 400, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "CURSOR_INVALID", "retryable": false, "hint": "Start the listing again without a cursor." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## DATA_TOO_LARGE ## `DATA_TOO_LARGE` The transaction data field is larger than self-serve accounts may send. | | | |---|---| | HTTP status | `422` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened Data bytes are paid for per byte by the relayer, so the size is capped for every self-serve account. ### What to do Shrink the payload. Larger payloads and contract deployments need an account allow-list entry — contact support. ### Extra members This code carries `limit`, `actual` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/data-too-large", "title": "The transaction data field is larger than self-serve accounts may send.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "DATA_TOO_LARGE", "retryable": false, "resign": "NONE", "hint": "Shrink the payload. Larger payloads and contract deployments need an account allow-list entry — contact support." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## DEPLOY_NOT_ALLOWED ## `DEPLOY_NOT_ALLOWED` Contract deployments and upgrades are not relayed for this account. | | | |---|---| | HTTP status | `403` | | Group | Pricing and purchase | | Retryable | No — sending it again changes nothing | ### What happened Deploy transactions carry large payloads and are enabled per account only. ### What to do Ask support to allow-list the account, or deploy with your own gas. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/deploy-not-allowed", "title": "Contract deployments and upgrades are not relayed for this account.", "status": 403, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "DEPLOY_NOT_ALLOWED", "retryable": false, "hint": "Ask support to allow-list the account, or deploy with your own gas." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## DEPOSIT_ABOVE_MAX ## `DEPOSIT_ABOVE_MAX` The deposit is above the configured maximum. | | | |---|---| | HTTP status | `422` | | Group | Pricing and purchase | | Retryable | No — sending it again changes nothing | ### What happened An owner-settable ceiling exists; at launch it is 0, which means unlimited. ### What to do Read `deposit.maxMicroUsdc` from `GET /v1/pricing` (0 means unlimited) and split the deposit below it. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/deposit-above-max", "title": "The deposit is above the configured maximum.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "DEPOSIT_ABOVE_MAX", "retryable": false, "hint": "Read `deposit.maxMicroUsdc` from `GET /v1/pricing` (0 means unlimited) and split the deposit below it." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## DEPOSIT_BELOW_MIN ## `DEPOSIT_BELOW_MIN` The deposit is below the minimum the contract accepts. | | | |---|---| | HTTP status | `422` | | Group | Pricing and purchase | | Retryable | No — sending it again changes nothing | ### What happened A minimum keeps dust deposits from costing more in gas than they are worth. ### What to do Read `deposit.minMicroUsdc` from `GET /v1/pricing` and deposit at least that. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/deposit-below-min", "title": "The deposit is below the minimum the contract accepts.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "DEPOSIT_BELOW_MIN", "retryable": false, "hint": "Read `deposit.minMicroUsdc` from `GET /v1/pricing` and deposit at least that." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## DOWNGRADE_NOT_IMMEDIATE ## `DOWNGRADE_NOT_IMMEDIATE` A plan without a period cannot start while a period plan is running. | | | |---|---| | HTTP status | `409` | | Group | Pricing and purchase | | Retryable | No — sending it again changes nothing | ### What happened You asked to buy a no-period tier (Agent Metered) while a plan with a period is active. The running block cannot be cut short, so the no-period plan has nowhere to start. ### What to do Let the current plan end — turn auto-renew off, or leave it off — and buy the no-period tier after `endMs`; or keep the period plan and renew it with `setAutoRenew(true, months, maxPrice)`. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/downgrade-not-immediate", "title": "A plan without a period cannot start while a period plan is running.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "DOWNGRADE_NOT_IMMEDIATE", "retryable": false, "hint": "Let the current plan end — turn auto-renew off, or leave it off — and buy the no-period tier after `endMs`; or keep the period plan and renew it with `setAutoRenew(true, months, maxPrice)`." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## FORBIDDEN ## `FORBIDDEN` Authenticated, but not permitted to do this. | | | |---|---| | HTTP status | `403` | | Group | Platform | | Retryable | No — sending it again changes nothing | ### What happened The credential is valid and simply does not carry this permission. ### What to do Use a credential that does, or ask the account owner for access. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/forbidden", "title": "Authenticated, but not permitted to do this.", "status": 403, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "FORBIDDEN", "retryable": false, "hint": "Use a credential that does, or ask the account owner for access." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## FREE_FLOW_BARRED ## `FREE_FLOW_BARRED` This sender is temporarily barred from the free flow. | | | |---|---| | HTTP status | `403` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened Repeated failing free transactions trip the strike rule. ### What to do Wait until `details.untilMs`, or pay the gas yourself for this transaction. ### Extra members This code carries `untilMs` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/free-flow-barred", "title": "This sender is temporarily barred from the free flow.", "status": 403, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "FREE_FLOW_BARRED", "retryable": false, "resign": "NONE", "hint": "Wait until `details.untilMs`, or pay the gas yourself for this transaction." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## FREE_FLOW_BUSY ## `FREE_FLOW_BUSY` This sender already has a free transaction in flight. | | | |---|---| | HTTP status | `409` | | Group | Relay path | | Retryable | Yes — the same request may be sent again | | Re-sign | `SAME_BYTES` — resend the transaction you already have, byte for byte | ### What happened The free flow allows one non-terminal free intent per sender, at the on-chain nonce with an empty pool, so it cannot be used to drain gas. ### What to do Wait for the free intent to settle, then send the next one. ### Extra members This code carries `reason` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/free-flow-busy", "title": "This sender already has a free transaction in flight.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "FREE_FLOW_BUSY", "retryable": true, "resign": "SAME_BYTES", "hint": "Wait for the free intent to settle, then send the next one." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## FREE_FLOW_UNAVAILABLE ## `FREE_FLOW_UNAVAILABLE` The free-transaction budget for this period is spent. | | | |---|---| | HTTP status | `503` | | Group | Relay path | | Retryable | Yes — the same request may be sent again | | Re-sign | `SAME_BYTES` — resend the transaction you already have, byte for byte | ### What happened Free relays come out of a separate house budget so they can never eat into paid capacity. ### What to do Pay the gas yourself for this one, or retry later. Paid relaying is unaffected. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/free-flow-unavailable", "title": "The free-transaction budget for this period is spent.", "status": 503, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "FREE_FLOW_UNAVAILABLE", "retryable": true, "resign": "SAME_BYTES", "hint": "Pay the gas yourself for this one, or retry later. Paid relaying is unaffected." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## GAS_BUDGET_EXCEEDED ## `GAS_BUDGET_EXCEEDED` The gas-per-second budget of your rate class in this shard is spent. | | | |---|---| | HTTP status | `429` | | Group | Relay path | | Retryable | Yes — the same request may be sent again | | Re-sign | `SAME_BYTES` — resend the transaction you already have, byte for byte | ### What happened A burst of heavy transactions can exhaust the shard budget before the Relay-Unit budget. ### What to do Honour `Retry-After` and resend, or spread heavy transactions out. ### Extra members This code carries `retryAfterMs` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/gas-budget-exceeded", "title": "The gas-per-second budget of your rate class in this shard is spent.", "status": 429, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "GAS_BUDGET_EXCEEDED", "retryable": true, "resign": "SAME_BYTES", "hint": "Honour `Retry-After` and resend, or spread heavy transactions out." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## GAS_LIMIT_TOO_HIGH ## `GAS_LIMIT_TOO_HIGH` The gas limit is above the ceiling of your rate class. | | | |---|---| | HTTP status | `422` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened The ceiling bounds what a single transaction can cost the relayer that pays for it. ### What to do Lower `gasLimit` below `details.limit`, or move to a tier whose rate class allows more. ### Extra members This code carries `limit`, `actual` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/gas-limit-too-high", "title": "The gas limit is above the ceiling of your rate class.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "GAS_LIMIT_TOO_HIGH", "retryable": false, "resign": "NONE", "hint": "Lower `gasLimit` below `details.limit`, or move to a tier whose rate class allows more." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## GAS_LIMIT_TOO_LOW ## `GAS_LIMIT_TOO_LOW` The gas limit is below the movement gas of the transaction. | | | |---|---| | HTTP status | `422` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened A relayed v3 transaction must at least pay for moving itself, including the relayed-transaction surcharge. ### What to do Raise `gasLimit` to at least `details.limit` and sign once. ### Extra members This code carries `limit`, `actual` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/gas-limit-too-low", "title": "The gas limit is below the movement gas of the transaction.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "GAS_LIMIT_TOO_LOW", "retryable": false, "resign": "NONE", "hint": "Raise `gasLimit` to at least `details.limit` and sign once." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## GAS_OVERPROVISIONED ## `GAS_OVERPROVISIONED` The gas limit is far above what simulation says the transaction needs. | | | |---|---| | HTTP status | `422` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened Relay Units are charged on the worst case, so a wildly over-declared limit would reserve quota (and relayer exposure) that cannot be used. ### What to do Set `gasLimit` close to the simulated cost — the endpoint accepts up to 1.9× the simulated processing gas above movement gas. ### Extra members This code carries `limit`, `actual` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/gas-overprovisioned", "title": "The gas limit is far above what simulation says the transaction needs.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "GAS_OVERPROVISIONED", "retryable": false, "resign": "NONE", "hint": "Set `gasLimit` close to the simulated cost — the endpoint accepts up to 1.9× the simulated processing gas above movement gas." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## GAS_PRICE_OUT_OF_RANGE ## `GAS_PRICE_OUT_OF_RANGE` The gas price is below the network minimum or above the cap CoRelayer relays. | | | |---|---| | HTTP status | `422` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened Accepted is the network minimum up to twice the network minimum; the free purchase flow requires exactly the minimum. ### What to do Read `details.bound` (`min` or `max`) and `details.limit`, rebuild inside the range, sign once. ### Extra members This code carries `limit`, `actual`, `bound` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/gas-price-out-of-range", "title": "The gas price is below the network minimum or above the cap CoRelayer relays.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "GAS_PRICE_OUT_OF_RANGE", "retryable": false, "resign": "NONE", "hint": "Read `details.bound` (`min` or `max`) and `details.limit`, rebuild inside the range, sign once." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## GUARDIAN_IS_RELAYER ## `GUARDIAN_IS_RELAYER` The guardian and the relayer are the same address. | | | |---|---| | HTTP status | `422` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened The protocol does not allow one address to be both guardian and relayer of a transaction. ### What to do Assign a different relayer and sign once. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/guardian-is-relayer", "title": "The guardian and the relayer are the same address.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "GUARDIAN_IS_RELAYER", "retryable": false, "resign": "NONE", "hint": "Assign a different relayer and sign once." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## GUARDIAN_MISMATCH ## `GUARDIAN_MISMATCH` The guardian in the transaction is not the sender’s active guardian. | | | |---|---| | HTTP status | `422` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened The account’s guardian on chain differs from the one named in the transaction. ### What to do Read the account’s active guardian from the network and rebuild. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/guardian-mismatch", "title": "The guardian in the transaction is not the sender’s active guardian.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "GUARDIAN_MISMATCH", "retryable": false, "resign": "NONE", "hint": "Read the account’s active guardian from the network and rebuild." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## GUARDIAN_REQUIRED ## `GUARDIAN_REQUIRED` The sender is guarded but the transaction is not. | | | |---|---| | HTTP status | `422` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened A guarded account can only move with the guarded option set and a guardian signature present. ### What to do Rebuild as a guarded transaction, collect the guardian signature, then relay. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/guardian-required", "title": "The sender is guarded but the transaction is not.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "GUARDIAN_REQUIRED", "retryable": false, "resign": "NONE", "hint": "Rebuild as a guarded transaction, collect the guardian signature, then relay." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## GUARDIAN_SIGNATURE_INVALID ## `GUARDIAN_SIGNATURE_INVALID` The guardian signature does not verify. | | | |---|---| | HTTP status | `422` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened The guarded transaction carries a guardian signature that does not match its bytes. ### What to do Have the guardian sign the final bytes, then relay. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/guardian-signature-invalid", "title": "The guardian signature does not verify.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "GUARDIAN_SIGNATURE_INVALID", "retryable": false, "resign": "NONE", "hint": "Have the guardian sign the final bytes, then relay." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## HOURLY_BURN_EXCEEDED ## `HOURLY_BURN_EXCEEDED` The account passed the Relay Units per hour of its rate class. | | | |---|---| | HTTP status | `429` | | Group | Relay path | | Retryable | Yes — the same request may be sent again | | Re-sign | `SAME_BYTES` — resend the transaction you already have, byte for byte | ### What happened The hourly window protects the funded EGLD float behind the relayer fleet. ### What to do Honour `Retry-After` and send the same bytes again afterwards. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/hourly-burn-exceeded", "title": "The account passed the Relay Units per hour of its rate class.", "status": 429, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "HOURLY_BURN_EXCEEDED", "retryable": true, "resign": "SAME_BYTES", "hint": "Honour `Retry-After` and send the same bytes again afterwards." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## IDEMPOTENCY_IN_PROGRESS ## `IDEMPOTENCY_IN_PROGRESS` The first request with this key is still running. | | | |---|---| | HTTP status | `409` | | Group | Platform | | Retryable | Yes — the same request may be sent again | ### What happened Two identical requests arrived at once; only one may proceed. ### What to do Wait briefly and retry with the same key — you will get the first request’s result. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/idempotency-in-progress", "title": "The first request with this key is still running.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "IDEMPOTENCY_IN_PROGRESS", "retryable": true, "hint": "Wait briefly and retry with the same key — you will get the first request’s result." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## IDEMPOTENCY_KEY_REUSED ## `IDEMPOTENCY_KEY_REUSED` That idempotency key was used for a different request body. | | | |---|---| | HTTP status | `422` | | Group | Platform | | Retryable | No — sending it again changes nothing | ### What happened A key identifies one request; reusing it for other content would make replay meaningless. ### What to do Use a new key, or resend the original body. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/idempotency-key-reused", "title": "That idempotency key was used for a different request body.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "IDEMPOTENCY_KEY_REUSED", "retryable": false, "hint": "Use a new key, or resend the original body." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## Error catalogue Every error the API returns is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem document with the media type `application/problem+json`. Its `type` member is the address of the page that describes it, so an agent can follow the error straight to its own documentation. ```json { "type": "https://docs.co-relayer.com/errors/quota-exhausted", "title": "The plan’s included Relay Units are used up.", "status": 429, "detail": "Cap of 1000 RU reached for the period ending 2026-10-19T00:00:00Z.", "instance": "req_01JB…", "code": "QUOTA_EXHAUSTED", "retryable": false, "resign": "NONE", "details": { "reason": "CAP_REACHED_PAYG_OFF" }, "hint": "Turn on pay-as-you-go or upgrade the tier." } ``` ### The members you should branch on | Member | Always present | What it is for | |---|---|---| | `code` | yes | The stable machine name. Branch on this, not on `title` or `detail`. | | `status` | yes | The HTTP status. Use it when `code` is one your client does not know. | | `retryable` | yes | Whether sending the *same* request again can succeed. | | `resign` | relay path | `NONE`, `SAME_BYTES` or `NEW_SIGNATURE_SAME_NONCE`. Never ask for a signature unless it says so. | | `hint` | usually | One sentence naming the next action, written for an agent. | | `instance` | yes | The request id. Quote it to support. | | `details` | some codes | Machine-readable specifics, listed on each code’s page. | | `intent` | relay path | `sender`, `nonce`, `state` and, once it exists, `txHash`. | ### The enum is open New codes are added without a breaking change, so treat an unknown `code` by its `status` and `retryable` members rather than failing. Additions are announced in the [changelog](/changelog). :::note[The names some other documents use] A few names that appear in our own specs are **not** codes of this API: `NO_SUBSCRIPTION` is `NO_ENTITLEMENT`, `SERVICE_HALTED` is `QUOTA_EXHAUSTED` (or `ACCOUNT_SUSPENDED`), and `GAS_PRICE_TOO_HIGH` / `GAS_PRICE_TOO_LOW` are both `GAS_PRICE_OUT_OF_RANGE` with `details.bound` set to `max` or `min`. ::: ### Every code The machine-readable version of this table is [`/errors.json`](pathname:///errors.json). #### Relay path | Code | HTTP | Retryable | Meaning | |---|---|---|---| | [`MALFORMED_REQUEST`](/errors/malformed-request) | 400 | no | The request body did not match the schema of the endpoint. | | [`VARIANTS_NOT_SUPPORTED`](/errors/variants-not-supported) | 400 | no | The request carried several signed variants of the same transaction. | | [`RELAYER_SIGNATURE_PRESENT`](/errors/relayer-signature-present) | 400 | no | The transaction already had a `relayerSignature`. | | [`CHAIN_ID_MISMATCH`](/errors/chain-id-mismatch) | 400 | no | The transaction was signed for a different network than this host serves. | | [`TX_VERSION_UNSUPPORTED`](/errors/tx-version-unsupported) | 400 | no | The transaction `version` is not one CoRelayer relays. | | [`TX_OPTIONS_UNSUPPORTED`](/errors/tx-options-unsupported) | 400 | no | The transaction `options` field has a bit CoRelayer does not relay. | | [`LEASE_MISSING`](/errors/lease-missing) | 400 | no | The relay request arrived without the lease returned by the assign call. | | [`LEASE_INVALID`](/errors/lease-invalid) | 403 | no | The lease did not verify. | | [`LEASE_MISMATCH`](/errors/lease-mismatch) | 409 | no | The lease does not belong to this transaction. | | [`LEASE_EXPIRED`](/errors/lease-expired) | 409 | yes | The lease was older than its validity window when the transaction arrived. | | [`NONCE_PIN_MISMATCH`](/errors/nonce-pin-mismatch) | 409 | no | The transaction nonce is not the nonce the assignment pinned. | | [`RESIGN_REQUIRED`](/errors/resign-required) | 409 | no | The assigned relayer can no longer be used, so the transaction must be signed once more. | | [`RESIGN_SAME_NONCE`](/errors/resign-same-nonce) | 409 | no | A replacement for the same nonce is needed, at a higher gas price. | | [`REPLACEMENT_UNDERPRICED`](/errors/replacement-underpriced) | 422 | no | The replacement transaction does not pay enough to displace the one in the pool. | | [`INTENT_ALREADY_EXECUTED`](/errors/intent-already-executed) | 409 | no | That nonce has already executed on chain. | | [`NOTHING_TO_CANCEL`](/errors/nothing-to-cancel) | 409 | no | There is no in-flight intent to cancel. | | [`NONCE_TOO_LOW`](/errors/nonce-too-low) | 409 | no | The transaction nonce is below the account nonce on chain. | | [`NONCE_IN_FLIGHT`](/errors/nonce-in-flight) | 409 | yes | Another intent already holds this nonce. | | [`NONCE_GAP`](/errors/nonce-gap) | 409 | no | The nonce skips ahead of the account nonce and the pool. | | [`TOO_MANY_IN_FLIGHT`](/errors/too-many-in-flight) | 429 | yes | This sender has as many unsettled intents as it may have at once. | | [`RELAYER_UNKNOWN`](/errors/relayer-unknown) | 422 | no | The `relayer` address in the transaction is not one of ours. | | [`RELAYER_SHARD_MISMATCH`](/errors/relayer-shard-mismatch) | 422 | no | The relayer is not in the sender’s shard. | | [`RELAYER_RETIRED`](/errors/relayer-retired) | 410 | no | The relayer in the transaction has been retired. | | [`GAS_PRICE_OUT_OF_RANGE`](/errors/gas-price-out-of-range) | 422 | no | The gas price is below the network minimum or above the cap CoRelayer relays. | | [`GAS_LIMIT_TOO_LOW`](/errors/gas-limit-too-low) | 422 | no | The gas limit is below the movement gas of the transaction. | | [`GAS_LIMIT_TOO_HIGH`](/errors/gas-limit-too-high) | 422 | no | The gas limit is above the ceiling of your rate class. | | [`GAS_OVERPROVISIONED`](/errors/gas-overprovisioned) | 422 | no | The gas limit is far above what simulation says the transaction needs. | | [`DATA_TOO_LARGE`](/errors/data-too-large) | 422 | no | The transaction data field is larger than self-serve accounts may send. | | [`RECEIVER_NOT_ALLOWED`](/errors/receiver-not-allowed) | 403 | no | The receiver of the transaction is not allowed on this path. | | [`SENDER_SIGNATURE_INVALID`](/errors/sender-signature-invalid) | 422 | no | The sender signature does not verify against the transaction bytes. | | [`GUARDIAN_SIGNATURE_INVALID`](/errors/guardian-signature-invalid) | 422 | no | The guardian signature does not verify. | | [`GUARDIAN_REQUIRED`](/errors/guardian-required) | 422 | no | The sender is guarded but the transaction is not. | | [`GUARDIAN_MISMATCH`](/errors/guardian-mismatch) | 422 | no | The guardian in the transaction is not the sender’s active guardian. | | [`GUARDIAN_IS_RELAYER`](/errors/guardian-is-relayer) | 422 | no | The guardian and the relayer are the same address. | | [`INSUFFICIENT_SENDER_BALANCE`](/errors/insufficient-sender-balance) | 422 | no | The sender cannot cover the `value` it is trying to move. | | [`SIMULATION_FAILED`](/errors/simulation-failed) | 422 | no | Pre-flight simulation says the transaction would fail on chain. | | [`SWAP_VENUE_PAUSED`](/errors/swap-venue-paused) | 503 | yes | The exchange venue used to convert USDC is paused. | | [`CONTRACT_PAUSED`](/errors/contract-paused) | 503 | yes | The CoRelayer contract is paused for this kind of call. | | [`ASSIGN_PROOF_REQUIRED`](/errors/assign-proof-required) | 401 | no | The assign call needs a proof that the sender is present. | | [`ASSIGN_PROOF_INVALID`](/errors/assign-proof-invalid) | 401 | no | The presence proof did not verify. | | [`FREE_FLOW_BUSY`](/errors/free-flow-busy) | 409 | yes | This sender already has a free transaction in flight. | | [`FREE_FLOW_BARRED`](/errors/free-flow-barred) | 403 | no | This sender is temporarily barred from the free flow. | | [`FREE_FLOW_UNAVAILABLE`](/errors/free-flow-unavailable) | 503 | yes | The free-transaction budget for this period is spent. | | [`INTENT_ALREADY_SUBMITTED`](/errors/intent-already-submitted) | 409 | no | This idempotency key was already used. | | [`HOURLY_BURN_EXCEEDED`](/errors/hourly-burn-exceeded) | 429 | yes | The account passed the Relay Units per hour of its rate class. | | [`SIGNER_TIMEOUT`](/errors/signer-timeout) | 503 | yes | The signer did not commit before the deadline. | | [`NO_ENTITLEMENT`](/errors/no-entitlement) | 402 | no | The account has no plan that can pay for this transaction. | | [`SENDER_NOT_AUTHORIZED`](/errors/sender-not-authorized) | 403 | no | This sender is not authorised to spend the paying account’s plan. | | [`QUOTA_EXHAUSTED`](/errors/quota-exhausted) | 429 | no | The plan’s included Relay Units are used up. | | [`RATE_LIMITED`](/errors/rate-limited) | 429 | yes | You are sending faster than your rate class allows. | | [`GAS_BUDGET_EXCEEDED`](/errors/gas-budget-exceeded) | 429 | yes | The gas-per-second budget of your rate class in this shard is spent. | | [`NO_RELAYER_AVAILABLE`](/errors/no-relayer-available) | 503 | yes | No healthy relayer can be assigned in that shard right now. | | [`UPSTREAM_UNAVAILABLE`](/errors/upstream-unavailable) | 503 | yes | A dependency (chain gateway, node) was unavailable before anything was committed. | | [`SIGNER_UNAVAILABLE`](/errors/signer-unavailable) | 503 | yes | The signer process could not be reached. | | [`SIGNER_FENCED`](/errors/signer-fenced) | 503 | yes | The signer fenced itself and refuses to sign. | | [`INTERNAL`](/errors/internal) | 500 | yes | An unexpected failure before the commit point. | #### Pricing and purchase | Code | HTTP | Retryable | Meaning | |---|---|---|---| | [`PRICE_ABOVE_MAX`](/errors/price-above-max) | 409 | no | The price at execution time is above the maximum you signed for. | | [`TIER_NOT_PURCHASABLE`](/errors/tier-not-purchasable) | 422 | no | That tier cannot be bought right now. | | [`QUEUED_BLOCK_EXISTS`](/errors/queued-block-exists) | 409 | no | A plan is already queued to start after the current one. | | [`DOWNGRADE_NOT_IMMEDIATE`](/errors/downgrade-not-immediate) | 409 | no | A plan without a period cannot start while a period plan is running. | | [`INSUFFICIENT_CREDITS`](/errors/insufficient-credits) | 402 | no | The account does not hold enough credits for this purchase. | | [`DEPOSIT_BELOW_MIN`](/errors/deposit-below-min) | 422 | no | The deposit is below the minimum the contract accepts. | | [`DEPOSIT_ABOVE_MAX`](/errors/deposit-above-max) | 422 | no | The deposit is above the configured maximum. | | [`SWAP_BUDGET_EXHAUSTED`](/errors/swap-budget-exhausted) | 503 | yes | The rolling swap budget for deposits is used up. | | [`PAYG_PRICE_ABOVE_MAX`](/errors/payg-price-above-max) | 409 | no | The pay-as-you-go price is above the maximum this account accepts. | | [`DEPLOY_NOT_ALLOWED`](/errors/deploy-not-allowed) | 403 | no | Contract deployments and upgrades are not relayed for this account. | | [`UNSUPPORTED_TX_FIELD`](/errors/unsupported-tx-field) | 422 | no | The transaction carries a fee-relevant field this Relay-Unit schedule does not price. | | [`QUOTE_EXPIRED`](/errors/quote-expired) | 409 | no | The quote presented with the payment has expired. | | [`RENEW_NOT_APPLICABLE`](/errors/renew-not-applicable) | 409 | no | “Renew now” would do nothing. | #### Platform | Code | HTTP | Retryable | Meaning | |---|---|---|---| | [`UNAUTHENTICATED`](/errors/unauthenticated) | 401 | no | The endpoint needs credentials and none were presented. | | [`TOKEN_INVALID`](/errors/token-invalid) | 401 | no | The token did not verify. | | [`TOKEN_EXPIRED`](/errors/token-expired) | 401 | no | The token is past its validity. | | [`ORIGIN_NOT_ALLOWED`](/errors/origin-not-allowed) | 403 | no | The browser origin is not allowed for this route. | | [`CHALLENGE_INVALID`](/errors/challenge-invalid) | 401 | no | The signed challenge did not verify. | | [`API_KEY_INVALID`](/errors/api-key-invalid) | 401 | no | The API key is unknown, revoked, or from another environment. | | [`API_KEY_SCOPE`](/errors/api-key-scope) | 403 | no | The API key is valid but not allowed to do this. | | [`STREAM_TICKET_INVALID`](/errors/stream-ticket-invalid) | 401 | no | The ticket presented to the event stream did not verify. | | [`FORBIDDEN`](/errors/forbidden) | 403 | no | Authenticated, but not permitted to do this. | | [`ACCOUNT_SUSPENDED`](/errors/account-suspended) | 403 | no | The account is suspended. | | [`NOT_FOUND`](/errors/not-found) | 404 | no | No such resource. | | [`PAYLOAD_TOO_LARGE`](/errors/payload-too-large) | 413 | no | The request body is larger than the endpoint accepts. | | [`CURSOR_INVALID`](/errors/cursor-invalid) | 400 | no | The pagination cursor is malformed or no longer valid. | | [`IDEMPOTENCY_KEY_REUSED`](/errors/idempotency-key-reused) | 422 | no | That idempotency key was used for a different request body. | | [`IDEMPOTENCY_IN_PROGRESS`](/errors/idempotency-in-progress) | 409 | yes | The first request with this key is still running. | | [`LIMIT_REACHED`](/errors/limit-reached) | 409 | no | A per-account limit is full. | | [`WEBHOOK_URL_NOT_ALLOWED`](/errors/webhook-url-not-allowed) | 422 | no | That webhook URL cannot be registered. | #### x402 | Code | HTTP | Retryable | Meaning | |---|---|---|---| | [`PAYMENT_REQUIRED`](/errors/payment-required) | 402 | no | The resource needs payment; the response describes how. | | [`PAYMENT_INVALID`](/errors/payment-invalid) | 402 | no | The payment presented did not verify. | | [`PAYMENT_FAILED`](/errors/payment-failed) | 402 | no | The payment verified but could not be settled. | --- ## INSUFFICIENT_CREDITS ## `INSUFFICIENT_CREDITS` The account does not hold enough credits for this purchase. | | | |---|---| | HTTP status | `402` | | Group | Pricing and purchase | | Retryable | No — sending it again changes nothing | ### What happened You asked to pay from credits, and the balance is below the price. ### What to do Deposit USDC first, or buy in one call with `depositAndSubscribe`. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/insufficient-credits", "title": "The account does not hold enough credits for this purchase.", "status": 402, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "INSUFFICIENT_CREDITS", "retryable": false, "hint": "Deposit USDC first, or buy in one call with `depositAndSubscribe`." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## INSUFFICIENT_SENDER_BALANCE ## `INSUFFICIENT_SENDER_BALANCE` The sender cannot cover the `value` it is trying to move. | | | |---|---| | HTTP status | `422` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened CoRelayer pays the gas, not the amount. A transfer whose value the sender does not hold would fail on chain and still cost the relayer the full fee. ### What to do Fund the sender, or lower `value`, then relay. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/insufficient-sender-balance", "title": "The sender cannot cover the `value` it is trying to move.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "INSUFFICIENT_SENDER_BALANCE", "retryable": false, "resign": "NONE", "hint": "Fund the sender, or lower `value`, then relay." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## INTENT_ALREADY_EXECUTED ## `INTENT_ALREADY_EXECUTED` That nonce has already executed on chain. | | | |---|---| | HTTP status | `409` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened The transaction you are trying to relay, or another one with the same nonce, is final. ### What to do Read `intent.txHash` and treat the work as done. Do not sign again. ### Extra members This code carries `intent.txHash` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/intent-already-executed", "title": "That nonce has already executed on chain.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "INTENT_ALREADY_EXECUTED", "retryable": false, "resign": "NONE", "hint": "Read `intent.txHash` and treat the work as done. Do not sign again." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## INTENT_ALREADY_SUBMITTED ## `INTENT_ALREADY_SUBMITTED` This idempotency key was already used. | | | |---|---| | HTTP status | `409` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened The key maps to an intent we already accepted; replaying it must not create a second one. ### What to do Read the `intent` member: it is the first submission. Track that one. ### Extra members This code carries `intent` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/intent-already-submitted", "title": "This idempotency key was already used.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "INTENT_ALREADY_SUBMITTED", "retryable": false, "resign": "NONE", "hint": "Read the `intent` member: it is the first submission. Track that one." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## INTERNAL ## `INTERNAL` An unexpected failure before the commit point. | | | |---|---| | HTTP status | `500` | | Group | Relay path | | Retryable | Yes — the same request may be sent again | | Re-sign | `SAME_BYTES` — resend the transaction you already have, byte for byte | ### What happened No error is ever returned after the commit point, so an `INTERNAL` means nothing was signed or broadcast. ### What to do Retry with the same bytes. If it repeats, send `instance` (the request id) to support. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/internal", "title": "An unexpected failure before the commit point.", "status": 500, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "INTERNAL", "retryable": true, "resign": "SAME_BYTES", "hint": "Retry with the same bytes. If it repeats, send `instance` (the request id) to support." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## LEASE_EXPIRED ## `LEASE_EXPIRED` The lease was older than its validity window when the transaction arrived. | | | |---|---| | HTTP status | `409` | | Group | Relay path | | Retryable | Yes — the same request may be sent again | | Re-sign | `SAME_BYTES` — resend the transaction you already have, byte for byte | ### What happened A lease is short-lived on purpose: it is the only freshness bound a MultiversX transaction (which never expires) can be given. ### What to do If `details.renewable` is true, renew the lease and resend the same signed bytes. Otherwise assign again and sign once more. ### Extra members This code carries `renewable` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/lease-expired", "title": "The lease was older than its validity window when the transaction arrived.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "LEASE_EXPIRED", "retryable": true, "resign": "SAME_BYTES", "hint": "If `details.renewable` is true, renew the lease and resend the same signed bytes. Otherwise assign again and sign once more." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## LEASE_INVALID ## `LEASE_INVALID` The lease did not verify. | | | |---|---| | HTTP status | `403` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened Its MAC does not match, or it was minted with a key id this host does not know. ### What to do Request a fresh assignment. Never edit a lease; it is opaque. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/lease-invalid", "title": "The lease did not verify.", "status": 403, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "LEASE_INVALID", "retryable": false, "resign": "NONE", "hint": "Request a fresh assignment. Never edit a lease; it is opaque." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## LEASE_MISMATCH ## `LEASE_MISMATCH` The lease does not belong to this transaction. | | | |---|---| | HTTP status | `409` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened The sender or the relayer inside the signed transaction is not the pair the lease was minted for. ### What to do Assign again for the sender you are actually relaying, then sign that transaction. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/lease-mismatch", "title": "The lease does not belong to this transaction.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "LEASE_MISMATCH", "retryable": false, "resign": "NONE", "hint": "Assign again for the sender you are actually relaying, then sign that transaction." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## LEASE_MISSING ## `LEASE_MISSING` The relay request arrived without the lease returned by the assign call. | | | |---|---| | HTTP status | `400` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened On the native relay path the lease is what proves the relayer in the transaction is the one we handed out, and how recently. ### What to do Call the assign endpoint first and send the lease it returns with the transaction. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/lease-missing", "title": "The relay request arrived without the lease returned by the assign call.", "status": 400, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "LEASE_MISSING", "retryable": false, "resign": "NONE", "hint": "Call the assign endpoint first and send the lease it returns with the transaction." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## LIMIT_REACHED ## `LIMIT_REACHED` A per-account limit is full. | | | |---|---| | HTTP status | `409` | | Group | Platform | | Retryable | No — sending it again changes nothing | ### What happened For example the number of API keys, webhooks or authorised senders the tier allows. ### What to do Remove an existing entry, or move to a tier with a higher limit. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/limit-reached", "title": "A per-account limit is full.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "LIMIT_REACHED", "retryable": false, "hint": "Remove an existing entry, or move to a tier with a higher limit." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## MALFORMED_REQUEST ## `MALFORMED_REQUEST` The request body did not match the schema of the endpoint. | | | |---|---| | HTTP status | `400` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened A required field is missing, a field has the wrong JSON type, or a value is outside the range the schema allows. ### What to do Read `details.field`, fix the request and send it again. Validate against `/openapi.yaml` before retrying in a loop. ### Extra members This code carries `field` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/malformed-request", "title": "The request body did not match the schema of the endpoint.", "status": 400, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "MALFORMED_REQUEST", "retryable": false, "resign": "NONE", "hint": "Read `details.field`, fix the request and send it again. Validate against `/openapi.yaml` before retrying in a loop." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## NO_ENTITLEMENT ## `NO_ENTITLEMENT` The account has no plan that can pay for this transaction. | | | |---|---| | HTTP status | `402` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened `details.reason` is `NO_ACCOUNT` (nothing was ever bought) or `PERIOD_LAPSED` (the plan’s period ended). ### What to do Buy or renew a plan, then relay. The response carries a `PAYMENT-REQUIRED` header for x402 clients that pay for their own address. A server relaying with a sponsor key ignores that header: it is built for the transaction’s sender, not for the key’s account. If that server’s plan is active and the answer says `NO_ACCOUNT`, the key never reached `POST /v1/relay` (a stripped header, a key sent as `Authorization: Bearer`, a client built without it), so the relay billed the sender, who has no plan: send it as `X-Api-Key`. A sponsored answer carries `billing.authMode: "api_key"`. Otherwise renew the plan in the dashboard. ### Extra members This code carries `reason` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/no-entitlement", "title": "The account has no plan that can pay for this transaction.", "status": 402, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "NO_ENTITLEMENT", "retryable": false, "resign": "NONE", "hint": "Buy or renew a plan, then relay. The response carries a `PAYMENT-REQUIRED` header for x402 clients that pay for their own address. A server relaying with a sponsor key ignores that header: it is built for the transaction’s sender, not for the key’s account. If that server’s plan is active and the answer says `NO_ACCOUNT`, the key never reached `POST /v1/relay` (a stripped header, a key sent as `Authorization: Bearer`, a client built without it), so the relay billed the sender, who has no plan: send it as `X-Api-Key`. A sponsored answer carries `billing.authMode: \"api_key\"`. Otherwise renew the plan in the dashboard." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## NO_RELAYER_AVAILABLE ## `NO_RELAYER_AVAILABLE` No healthy relayer can be assigned in that shard right now. | | | |---|---| | HTTP status | `503` | | Group | Relay path | | Retryable | Yes — the same request may be sent again | | Re-sign | `SAME_BYTES` — resend the transaction you already have, byte for byte | ### What happened Every eligible relayer is momentarily out of headroom or window budget. This is never answered with a re-sign: the relayer is not lost, only busy. ### What to do Retry shortly. If you already hold a signed transaction, resend the same bytes. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/no-relayer-available", "title": "No healthy relayer can be assigned in that shard right now.", "status": 503, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "NO_RELAYER_AVAILABLE", "retryable": true, "resign": "SAME_BYTES", "hint": "Retry shortly. If you already hold a signed transaction, resend the same bytes." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## NONCE_GAP ## `NONCE_GAP` The nonce skips ahead of the account nonce and the pool. | | | |---|---| | HTTP status | `409` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened MultiversX executes nonces in order; a gap would leave the transaction stuck. ### What to do Fill the gap first, or rebuild at the next usable nonce and sign once. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/nonce-gap", "title": "The nonce skips ahead of the account nonce and the pool.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "NONCE_GAP", "retryable": false, "resign": "NONE", "hint": "Fill the gap first, or rebuild at the next usable nonce and sign once." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## NONCE_IN_FLIGHT ## `NONCE_IN_FLIGHT` Another intent already holds this nonce. | | | |---|---| | HTTP status | `409` | | Group | Relay path | | Retryable | Yes — the same request may be sent again | | Re-sign | `SAME_BYTES` — resend the transaction you already have, byte for byte | ### What happened One nonce carries one transaction at a time; a second signature for it would be a second send. ### What to do If `resign` is `SAME_BYTES`, resend the identical bytes — that is safe. Otherwise wait for the in-flight intent to settle, or cancel it. ### Extra members This code carries `occupiedBy`, `pinnedNonce` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/nonce-in-flight", "title": "Another intent already holds this nonce.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "NONCE_IN_FLIGHT", "retryable": true, "resign": "SAME_BYTES", "hint": "If `resign` is `SAME_BYTES`, resend the identical bytes — that is safe. Otherwise wait for the in-flight intent to settle, or cancel it." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## NONCE_PIN_MISMATCH ## `NONCE_PIN_MISMATCH` The transaction nonce is not the nonce the assignment pinned. | | | |---|---| | HTTP status | `409` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened An assignment pins the nonce it was issued for, so two signatures cannot compete for one slot. ### What to do Discard this signature. Assign again, use `assignment.pinNonce`, and sign that transaction. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/nonce-pin-mismatch", "title": "The transaction nonce is not the nonce the assignment pinned.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "NONCE_PIN_MISMATCH", "retryable": false, "resign": "NONE", "hint": "Discard this signature. Assign again, use `assignment.pinNonce`, and sign that transaction." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## NONCE_TOO_LOW ## `NONCE_TOO_LOW` The transaction nonce is below the account nonce on chain. | | | |---|---| | HTTP status | `409` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened The sender has moved on: the nonce in the signed transaction can never execute. ### What to do Read `details.expectedNonce`, rebuild with it, sign once. ### Extra members This code carries `onchainNonce`, `poolLastNonce`, `expectedNonce` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/nonce-too-low", "title": "The transaction nonce is below the account nonce on chain.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "NONCE_TOO_LOW", "retryable": false, "resign": "NONE", "hint": "Read `details.expectedNonce`, rebuild with it, sign once." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## NOT_FOUND ## `NOT_FOUND` No such resource. | | | |---|---| | HTTP status | `404` | | Group | Platform | | Retryable | No — sending it again changes nothing | ### What happened The id, hash or address is unknown to this host — or belongs to another network. ### What to do Check the identifier and the host you are calling. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/not-found", "title": "No such resource.", "status": 404, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "NOT_FOUND", "retryable": false, "hint": "Check the identifier and the host you are calling." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## NOTHING_TO_CANCEL ## `NOTHING_TO_CANCEL` There is no in-flight intent to cancel. | | | |---|---| | HTTP status | `409` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened The intent is already terminal, or it never existed on this account. ### What to do Read the current state with the intent status endpoint before cancelling. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/nothing-to-cancel", "title": "There is no in-flight intent to cancel.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "NOTHING_TO_CANCEL", "retryable": false, "resign": "NONE", "hint": "Read the current state with the intent status endpoint before cancelling." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## ORIGIN_NOT_ALLOWED ## `ORIGIN_NOT_ALLOWED` The browser origin is not allowed for this route. | | | |---|---| | HTTP status | `403` | | Group | Platform | | Retryable | No — sending it again changes nothing | ### What happened Cookie-authenticated routes accept a fixed set of origins. ### What to do Call the API from an allowed origin, or use a token instead of a cookie. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/origin-not-allowed", "title": "The browser origin is not allowed for this route.", "status": 403, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "ORIGIN_NOT_ALLOWED", "retryable": false, "hint": "Call the API from an allowed origin, or use a token instead of a cookie." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## PAYG_PRICE_ABOVE_MAX ## `PAYG_PRICE_ABOVE_MAX` The pay-as-you-go price is above the maximum this account accepts. | | | |---|---| | HTTP status | `409` | | Group | Pricing and purchase | | Retryable | No — sending it again changes nothing | ### What happened A metered account can pin a ceiling on the floating per-unit price. ### What to do Raise `max_payg_price` on the account, or wait for the price to come back under it. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/payg-price-above-max", "title": "The pay-as-you-go price is above the maximum this account accepts.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "PAYG_PRICE_ABOVE_MAX", "retryable": false, "hint": "Raise `max_payg_price` on the account, or wait for the price to come back under it." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## PAYLOAD_TOO_LARGE ## `PAYLOAD_TOO_LARGE` The request body is larger than the endpoint accepts. | | | |---|---| | HTTP status | `413` | | Group | Platform | | Retryable | No — sending it again changes nothing | ### What happened Body size is bounded on every route. ### What to do Send less in one request; page or batch instead. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/payload-too-large", "title": "The request body is larger than the endpoint accepts.", "status": 413, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "PAYLOAD_TOO_LARGE", "retryable": false, "hint": "Send less in one request; page or batch instead." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## PAYMENT_FAILED ## `PAYMENT_FAILED` The payment verified but could not be settled. | | | |---|---| | HTTP status | `402` | | Group | x402 | | Retryable | No — sending it again changes nothing | ### What happened Settlement failed on chain — for example the venue was paused or the transfer reverted. ### What to do Request a fresh challenge and pay again. Read the changelog and status page if it repeats. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/payment-failed", "title": "The payment verified but could not be settled.", "status": 402, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "PAYMENT_FAILED", "retryable": false, "hint": "Request a fresh challenge and pay again. Read the changelog and status page if it repeats." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## PAYMENT_INVALID ## `PAYMENT_INVALID` The payment presented did not verify. | | | |---|---| | HTTP status | `402` | | Group | x402 | | Retryable | No — sending it again changes nothing | ### What happened Wrong network, wrong token, wrong amount, wrong recipient, or a malformed payload. ### What to do Compare your payment against the challenge you were served, fix it, and retry. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/payment-invalid", "title": "The payment presented did not verify.", "status": 402, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "PAYMENT_INVALID", "retryable": false, "hint": "Compare your payment against the challenge you were served, fix it, and retry." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## PAYMENT_REQUIRED ## `PAYMENT_REQUIRED` The resource needs payment; the response describes how. | | | |---|---| | HTTP status | `402` | | Group | x402 | | Retryable | No — sending it again changes nothing | ### What happened This is the x402 challenge, not a failure: the body lists what is accepted and at what price. ### What to do Pay against one of the `accepts` entries and repeat the request with the payment header. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/payment-required", "title": "The resource needs payment; the response describes how.", "status": 402, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "PAYMENT_REQUIRED", "retryable": false, "hint": "Pay against one of the `accepts` entries and repeat the request with the payment header." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## PRICE_ABOVE_MAX ## `PRICE_ABOVE_MAX` The price at execution time is above the maximum you signed for. | | | |---|---| | HTTP status | `409` | | Group | Pricing and purchase | | Retryable | No — sending it again changes nothing | ### What happened Every purchase carries a `max_price`; the tariff moved between quote and execution. ### What to do Re-quote and sign a purchase with a current `max_price`. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/price-above-max", "title": "The price at execution time is above the maximum you signed for.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "PRICE_ABOVE_MAX", "retryable": false, "hint": "Re-quote and sign a purchase with a current `max_price`." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## QUEUED_BLOCK_EXISTS ## `QUEUED_BLOCK_EXISTS` A plan is already queued to start after the current one. | | | |---|---| | HTTP status | `409` | | Group | Pricing and purchase | | Retryable | No — sending it again changes nothing | ### What happened An account holds at most one queued plan block, so the next period is never ambiguous. A queued block cannot be cancelled. ### What to do Wait for the queued block to start, or buy a more expensive tier: an upgrade takes effect now and credits the queued block back to your account. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/queued-block-exists", "title": "A plan is already queued to start after the current one.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "QUEUED_BLOCK_EXISTS", "retryable": false, "hint": "Wait for the queued block to start, or buy a more expensive tier: an upgrade takes effect now and credits the queued block back to your account." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## QUOTA_EXHAUSTED ## `QUOTA_EXHAUSTED` The plan’s included Relay Units are used up. | | | |---|---| | HTTP status | `429` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened `details.reason` is `CAP_REACHED_PAYG_OFF` (the cap is reached and pay-as-you-go is off) or `PAYG_ESCROW_EMPTY` (pay-as-you-go is on but its escrow is empty). ### What to do Turn on pay-as-you-go, top up its escrow, upgrade the tier, or wait for the next period. There is no `Retry-After`: time alone does not fix this. ### Extra members This code carries `reason` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/quota-exhausted", "title": "The plan’s included Relay Units are used up.", "status": 429, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "QUOTA_EXHAUSTED", "retryable": false, "resign": "NONE", "hint": "Turn on pay-as-you-go, top up its escrow, upgrade the tier, or wait for the next period. There is no `Retry-After`: time alone does not fix this." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## QUOTE_EXPIRED ## `QUOTE_EXPIRED` The quote presented with the payment has expired. | | | |---|---| | HTTP status | `409` | | Group | Pricing and purchase | | Retryable | No — sending it again changes nothing | ### What happened Quotes are short-lived so a price cannot be held open across a tariff change. ### What to do Request a fresh quote and pay against that one. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/quote-expired", "title": "The quote presented with the payment has expired.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "QUOTE_EXPIRED", "retryable": false, "hint": "Request a fresh quote and pay against that one." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## RATE_LIMITED ## `RATE_LIMITED` You are sending faster than your rate class allows. | | | |---|---| | HTTP status | `429` | | Group | Relay path | | Retryable | Yes — the same request may be sent again | | Re-sign | `SAME_BYTES` — resend the transaction you already have, byte for byte | ### What happened Rate classes bound Relay Units per second, gas per second per shard and Relay Units per hour. ### What to do Honour `Retry-After` / `details.retryAfterMs` and resend the same bytes. ### Extra members This code carries `retryAfterMs`, `scope` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/rate-limited", "title": "You are sending faster than your rate class allows.", "status": 429, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "RATE_LIMITED", "retryable": true, "resign": "SAME_BYTES", "hint": "Honour `Retry-After` / `details.retryAfterMs` and resend the same bytes." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## RECEIVER_NOT_ALLOWED ## `RECEIVER_NOT_ALLOWED` The receiver of the transaction is not allowed on this path. | | | |---|---| | HTTP status | `403` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened It is on a deny-list, or the path you used has an allow-list: the free flow and sponsor API keys both restrict which contracts they may call. A sponsor key checks the transaction's receiver field. Single fungible-token payments (ESDTTransfer) to a listed contract are covered. NFT, SFT, Meta-ESDT and multi-token transfers name the sender as receiver, so a sponsor key cannot pay for them yet. ### What to do Relay it on a paid path, or send it to an allowed receiver. For a sponsor key, add the contract to the key's receiver list; an NFT, SFT, Meta-ESDT or multi-token transfer has to be paid by the sender's own plan or a plan that names the sender. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/receiver-not-allowed", "title": "The receiver of the transaction is not allowed on this path.", "status": 403, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "RECEIVER_NOT_ALLOWED", "retryable": false, "resign": "NONE", "hint": "Relay it on a paid path, or send it to an allowed receiver. For a sponsor key, add the contract to the key's receiver list; an NFT, SFT, Meta-ESDT or multi-token transfer has to be paid by the sender's own plan or a plan that names the sender." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## RELAYER_RETIRED ## `RELAYER_RETIRED` The relayer in the transaction has been retired. | | | |---|---| | HTTP status | `410` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened A retired address is permanently out of service — for example after a key was rotated. ### What to do Assign again and sign the new transaction once. On the native path this normally arrives as `RESIGN_REQUIRED` instead. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/relayer-retired", "title": "The relayer in the transaction has been retired.", "status": 410, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "RELAYER_RETIRED", "retryable": false, "resign": "NONE", "hint": "Assign again and sign the new transaction once. On the native path this normally arrives as `RESIGN_REQUIRED` instead." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## RELAYER_SHARD_MISMATCH ## `RELAYER_SHARD_MISMATCH` The relayer is not in the sender’s shard. | | | |---|---| | HTTP status | `422` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened Relayed v3 requires the relayer and the sender to live in the same shard. ### What to do Assign again for this sender; assignment always picks a relayer in the right shard. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/relayer-shard-mismatch", "title": "The relayer is not in the sender’s shard.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "RELAYER_SHARD_MISMATCH", "retryable": false, "resign": "NONE", "hint": "Assign again for this sender; assignment always picks a relayer in the right shard." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## RELAYER_SIGNATURE_PRESENT ## `RELAYER_SIGNATURE_PRESENT` The transaction already had a `relayerSignature`. | | | |---|---| | HTTP status | `400` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened The relayer signature is ours to add. A transaction that arrives with one cannot be co-signed. ### What to do Remove `relayerSignature` and send the transaction with only the sender (and guardian) signature. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/relayer-signature-present", "title": "The transaction already had a `relayerSignature`.", "status": 400, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "RELAYER_SIGNATURE_PRESENT", "retryable": false, "resign": "NONE", "hint": "Remove `relayerSignature` and send the transaction with only the sender (and guardian) signature." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## RELAYER_UNKNOWN ## `RELAYER_UNKNOWN` The `relayer` address in the transaction is not one of ours. | | | |---|---| | HTTP status | `422` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened Only addresses from the on-chain relayer registry can be co-signed. ### What to do Call the assign endpoint and use the relayer it returns. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/relayer-unknown", "title": "The `relayer` address in the transaction is not one of ours.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "RELAYER_UNKNOWN", "retryable": false, "resign": "NONE", "hint": "Call the assign endpoint and use the relayer it returns." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## RENEW_NOT_APPLICABLE ## `RENEW_NOT_APPLICABLE` “Renew now” would do nothing. | | | |---|---| | HTTP status | `409` | | Group | Pricing and purchase | | Retryable | No — sending it again changes nothing | ### What happened The renewal prediction says the call would be a no-op — the period is not due, or the state makes renewal impossible. The free relay is not spent on it. ### What to do Read `details.predictedOutcome` and act on that instead. ### Extra members This code carries `predictedOutcome` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/renew-not-applicable", "title": "“Renew now” would do nothing.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "RENEW_NOT_APPLICABLE", "retryable": false, "hint": "Read `details.predictedOutcome` and act on that instead." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## REPLACEMENT_UNDERPRICED ## `REPLACEMENT_UNDERPRICED` The replacement transaction does not pay enough to displace the one in the pool. | | | |---|---| | HTTP status | `422` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened Its gas price is at or below the gas price of the transaction that already occupies the nonce. ### What to do Sign a replacement with at least `details.minGasPrice`, or wait for the pending one to execute. ### Extra members This code carries `minGasPrice` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/replacement-underpriced", "title": "The replacement transaction does not pay enough to displace the one in the pool.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "REPLACEMENT_UNDERPRICED", "retryable": false, "resign": "NONE", "hint": "Sign a replacement with at least `details.minGasPrice`, or wait for the pending one to execute." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## RESIGN_REQUIRED ## `RESIGN_REQUIRED` The assigned relayer can no longer be used, so the transaction must be signed once more. | | | |---|---| | HTTP status | `409` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NEW_SIGNATURE_SAME_NONCE` — one new signature, same nonce, new relayer | ### What happened The relayer is no longer renewable, or its exposure headroom or hourly signing budget is spent on every host that holds its key. Nothing was signed or broadcast. ### What to do Take the new relayer from the `assignment` member, rebuild the transaction with the same nonce, and have the user sign it — once. ### Extra members This code carries `assignment` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/resign-required", "title": "The assigned relayer can no longer be used, so the transaction must be signed once more.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "RESIGN_REQUIRED", "retryable": false, "resign": "NEW_SIGNATURE_SAME_NONCE", "hint": "Take the new relayer from the `assignment` member, rebuild the transaction with the same nonce, and have the user sign it — once." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## RESIGN_SAME_NONCE ## `RESIGN_SAME_NONCE` A replacement for the same nonce is needed, at a higher gas price. | | | |---|---| | HTTP status | `409` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NEW_SIGNATURE_SAME_NONCE` — one new signature, same nonce, new relayer | ### What happened An earlier transaction for this nonce is in the pool and can only be displaced by one that pays more. ### What to do Rebuild with the same nonce and at least `details.minGasPrice`, then sign once. ### Extra members This code carries `assignment`, `minGasPrice` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/resign-same-nonce", "title": "A replacement for the same nonce is needed, at a higher gas price.", "status": 409, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "RESIGN_SAME_NONCE", "retryable": false, "resign": "NEW_SIGNATURE_SAME_NONCE", "hint": "Rebuild with the same nonce and at least `details.minGasPrice`, then sign once." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## SENDER_NOT_AUTHORIZED ## `SENDER_NOT_AUTHORIZED` This sender is not authorised to spend the paying account’s plan. | | | |---|---| | HTTP status | `403` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened The request named an account in `account`, and that account has not listed this sender on chain as a named wallet (`details.reason: NOT_AUTHORIZED_FOR_ACCOUNT`, `details.account`). An explicit account is never replaced by another one. A request without `account` and without a sponsor key does not get this answer: it bills the sender, and a sender with no plan gets `NO_ENTITLEMENT`. ### What to do List the sender on that account as a named wallet, or leave `account` out. To pay for users you do not list, relay through your server with a sponsor key (`X-Api-Key`, Builder and up): the key decides who pays, whatever `account` says. ### Extra members This code carries `reason`, `account` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/sender-not-authorized", "title": "This sender is not authorised to spend the paying account’s plan.", "status": 403, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "SENDER_NOT_AUTHORIZED", "retryable": false, "resign": "NONE", "hint": "List the sender on that account as a named wallet, or leave `account` out. To pay for users you do not list, relay through your server with a sponsor key (`X-Api-Key`, Builder and up): the key decides who pays, whatever `account` says." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## SENDER_SIGNATURE_INVALID ## `SENDER_SIGNATURE_INVALID` The sender signature does not verify against the transaction bytes. | | | |---|---| | HTTP status | `422` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened The signed payload and the transaction sent do not match — usually a field was changed after signing. ### What to do Rebuild the transaction, sign it once, and send exactly the bytes that were signed. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/sender-signature-invalid", "title": "The sender signature does not verify against the transaction bytes.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "SENDER_SIGNATURE_INVALID", "retryable": false, "resign": "NONE", "hint": "Rebuild the transaction, sign it once, and send exactly the bytes that were signed." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## SIGNER_FENCED ## `SIGNER_FENCED` The signer fenced itself and refuses to sign. | | | |---|---| | HTTP status | `503` | | Group | Relay path | | Retryable | Yes — the same request may be sent again | | Re-sign | `SAME_BYTES` — resend the transaction you already have, byte for byte | ### What happened A signer that cannot prove it is not partitioned from its peers stops signing. That is the design: at-most-once beats availability. ### What to do Retry with the same bytes; another host takes over. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/signer-fenced", "title": "The signer fenced itself and refuses to sign.", "status": 503, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "SIGNER_FENCED", "retryable": true, "resign": "SAME_BYTES", "hint": "Retry with the same bytes; another host takes over." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## SIGNER_TIMEOUT ## `SIGNER_TIMEOUT` The signer did not commit before the deadline. | | | |---|---| | HTTP status | `503` | | Group | Relay path | | Retryable | Yes — the same request may be sent again | | Re-sign | `SAME_BYTES` — resend the transaction you already have, byte for byte | ### What happened The commit deadline passed, so no signature was released — the transaction does not exist anywhere. ### What to do Retry with the same bytes. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/signer-timeout", "title": "The signer did not commit before the deadline.", "status": 503, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "SIGNER_TIMEOUT", "retryable": true, "resign": "SAME_BYTES", "hint": "Retry with the same bytes." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## SIGNER_UNAVAILABLE ## `SIGNER_UNAVAILABLE` The signer process could not be reached. | | | |---|---| | HTTP status | `503` | | Group | Relay path | | Retryable | Yes — the same request may be sent again | | Re-sign | `SAME_BYTES` — resend the transaction you already have, byte for byte | ### What happened Its host is restarting, draining or otherwise not accepting work. ### What to do Retry with the same bytes. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/signer-unavailable", "title": "The signer process could not be reached.", "status": 503, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "SIGNER_UNAVAILABLE", "retryable": true, "resign": "SAME_BYTES", "hint": "Retry with the same bytes." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## SIMULATION_FAILED ## `SIMULATION_FAILED` Pre-flight simulation says the transaction would fail on chain. | | | |---|---| | HTTP status | `422` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened The node returned an error for the simulated execution; relaying it would burn quota and gas for nothing. ### What to do Read `details.returnMessage`, fix the call, and sign a corrected transaction. ### Extra members This code carries `returnMessage` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/simulation-failed", "title": "Pre-flight simulation says the transaction would fail on chain.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "SIMULATION_FAILED", "retryable": false, "resign": "NONE", "hint": "Read `details.returnMessage`, fix the call, and sign a corrected transaction." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## STREAM_TICKET_INVALID ## `STREAM_TICKET_INVALID` The ticket presented to the event stream did not verify. | | | |---|---| | HTTP status | `401` | | Group | Platform | | Retryable | No — sending it again changes nothing | ### What happened Stream tickets are single-purpose and short-lived. ### What to do Request a new ticket and reconnect. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/stream-ticket-invalid", "title": "The ticket presented to the event stream did not verify.", "status": 401, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "STREAM_TICKET_INVALID", "retryable": false, "hint": "Request a new ticket and reconnect." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## SWAP_BUDGET_EXHAUSTED ## `SWAP_BUDGET_EXHAUSTED` The rolling swap budget for deposits is used up. | | | |---|---| | HTTP status | `503` | | Group | Pricing and purchase | | Retryable | Yes — the same request may be sent again | ### What happened Only applies while a deposit maximum is configured; it bounds how much can be swapped per window. ### What to do Retry after `details.retryAfterMs`. ### Extra members This code carries `retryAfterMs` in the problem document, on top of the common members. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/swap-budget-exhausted", "title": "The rolling swap budget for deposits is used up.", "status": 503, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "SWAP_BUDGET_EXHAUSTED", "retryable": true, "hint": "Retry after `details.retryAfterMs`." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## SWAP_VENUE_PAUSED ## `SWAP_VENUE_PAUSED` The exchange venue used to convert USDC is paused. | | | |---|---| | HTTP status | `503` | | Group | Relay path | | Retryable | Yes — the same request may be sent again | | Re-sign | `SAME_BYTES` — resend the transaction you already have, byte for byte | ### What happened Every deposit swaps USDC in the same call. If the pair, the wrapper or the token is paused, the call reverts cleanly rather than leaving funds at rest. ### What to do Retry later with the same bytes. Nothing was charged. This never affects relaying an existing plan. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/swap-venue-paused", "title": "The exchange venue used to convert USDC is paused.", "status": 503, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "SWAP_VENUE_PAUSED", "retryable": true, "resign": "SAME_BYTES", "hint": "Retry later with the same bytes. Nothing was charged. This never affects relaying an existing plan." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## TIER_NOT_PURCHASABLE ## `TIER_NOT_PURCHASABLE` That tier cannot be bought right now. | | | |---|---| | HTTP status | `422` | | Group | Pricing and purchase | | Retryable | No — sending it again changes nothing | ### What happened It is draft, legacy, retired, or a custom tier you are not on the buyer list for. ### What to do Read `GET /v1/pricing` and pick a tier whose `available` is true. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/tier-not-purchasable", "title": "That tier cannot be bought right now.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "TIER_NOT_PURCHASABLE", "retryable": false, "hint": "Read `GET /v1/pricing` and pick a tier whose `available` is true." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## TOKEN_EXPIRED ## `TOKEN_EXPIRED` The token is past its validity. | | | |---|---| | HTTP status | `401` | | Group | Platform | | Retryable | No — sending it again changes nothing | ### What happened Tokens are short-lived by design. ### What to do Refresh the token and retry. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/token-expired", "title": "The token is past its validity.", "status": 401, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "TOKEN_EXPIRED", "retryable": false, "hint": "Refresh the token and retry." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## TOKEN_INVALID ## `TOKEN_INVALID` The token did not verify. | | | |---|---| | HTTP status | `401` | | Group | Platform | | Retryable | No — sending it again changes nothing | ### What happened Bad signature, wrong audience, or issued for a different network. ### What to do Obtain a fresh token for this host and retry. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/token-invalid", "title": "The token did not verify.", "status": 401, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "TOKEN_INVALID", "retryable": false, "hint": "Obtain a fresh token for this host and retry." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## TOO_MANY_IN_FLIGHT ## `TOO_MANY_IN_FLIGHT` This sender has as many unsettled intents as it may have at once. | | | |---|---| | HTTP status | `429` | | Group | Relay path | | Retryable | Yes — the same request may be sent again | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened The in-flight limit keeps one account from occupying the relayer fleet. ### What to do Wait for an intent to reach a terminal state, then send the next one. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/too-many-in-flight", "title": "This sender has as many unsettled intents as it may have at once.", "status": 429, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "TOO_MANY_IN_FLIGHT", "retryable": true, "resign": "NONE", "hint": "Wait for an intent to reach a terminal state, then send the next one." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## TX_OPTIONS_UNSUPPORTED ## `TX_OPTIONS_UNSUPPORTED` The transaction `options` field has a bit CoRelayer does not relay. | | | |---|---| | HTTP status | `400` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened Only the option bits documented for relayed v3 (including the guarded bit) are accepted. ### What to do Clear the unsupported bits, rebuild and sign again. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/tx-options-unsupported", "title": "The transaction `options` field has a bit CoRelayer does not relay.", "status": 400, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "TX_OPTIONS_UNSUPPORTED", "retryable": false, "resign": "NONE", "hint": "Clear the unsupported bits, rebuild and sign again." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## TX_VERSION_UNSUPPORTED ## `TX_VERSION_UNSUPPORTED` The transaction `version` is not one CoRelayer relays. | | | |---|---| | HTTP status | `400` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened Accepted are version 1 with `options: 0`, and version 2 with the option bits listed in the relay docs. ### What to do Rebuild the transaction with a supported version and sign it again. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/tx-version-unsupported", "title": "The transaction `version` is not one CoRelayer relays.", "status": 400, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "TX_VERSION_UNSUPPORTED", "retryable": false, "resign": "NONE", "hint": "Rebuild the transaction with a supported version and sign it again." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## UNAUTHENTICATED ## `UNAUTHENTICATED` The endpoint needs credentials and none were presented. | | | |---|---| | HTTP status | `401` | | Group | Platform | | Retryable | No — sending it again changes nothing | ### What happened The route requires a wallet-bound token or an API key. ### What to do Authenticate and repeat the request. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/unauthenticated", "title": "The endpoint needs credentials and none were presented.", "status": 401, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "UNAUTHENTICATED", "retryable": false, "hint": "Authenticate and repeat the request." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## UNSUPPORTED_TX_FIELD ## `UNSUPPORTED_TX_FIELD` The transaction carries a fee-relevant field this Relay-Unit schedule does not price. | | | |---|---| | HTTP status | `422` | | Group | Pricing and purchase | | Retryable | No — sending it again changes nothing | ### What happened If a protocol upgrade adds a field that changes what a transaction costs, CoRelayer refuses it until a schedule version that prices it is active — rather than guess and under-charge. ### What to do Remove the field, or wait for the schedule version announced in the changelog. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/unsupported-tx-field", "title": "The transaction carries a fee-relevant field this Relay-Unit schedule does not price.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "UNSUPPORTED_TX_FIELD", "retryable": false, "hint": "Remove the field, or wait for the schedule version announced in the changelog." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## UPSTREAM_UNAVAILABLE ## `UPSTREAM_UNAVAILABLE` A dependency (chain gateway, node) was unavailable before anything was committed. | | | |---|---| | HTTP status | `503` | | Group | Relay path | | Retryable | Yes — the same request may be sent again | | Re-sign | `SAME_BYTES` — resend the transaction you already have, byte for byte | ### What happened The failure happened before the commit point, so no signature was released and nothing was broadcast. ### What to do Retry with the same bytes. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/upstream-unavailable", "title": "A dependency (chain gateway, node) was unavailable before anything was committed.", "status": 503, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "UPSTREAM_UNAVAILABLE", "retryable": true, "resign": "SAME_BYTES", "hint": "Retry with the same bytes." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## VARIANTS_NOT_SUPPORTED ## `VARIANTS_NOT_SUPPORTED` The request carried several signed variants of the same transaction. | | | |---|---| | HTTP status | `400` | | Group | Relay path | | Retryable | No — sending it again changes nothing | | Re-sign | `NONE` — do not ask the user to sign anything | ### What happened A relay request carries one signed transaction. Signing the same nonce several times, once for each relayer, is not supported anywhere in CoRelayer: each user action is signed once, for the relayer the assign endpoint returned. ### What to do Call the assign endpoint, put the relayer it returns into the transaction, sign once, and relay that one transaction. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/variants-not-supported", "title": "The request carried several signed variants of the same transaction.", "status": 400, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "VARIANTS_NOT_SUPPORTED", "retryable": false, "resign": "NONE", "hint": "Call the assign endpoint, put the relayer it returns into the transaction, sign once, and relay that one transaction." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## WEBHOOK_URL_NOT_ALLOWED ## `WEBHOOK_URL_NOT_ALLOWED` That webhook URL cannot be registered. | | | |---|---| | HTTP status | `422` | | Group | Platform | | Retryable | No — sending it again changes nothing | ### What happened Webhook egress is deliberately narrow: private, loopback and link-local destinations are refused so the notifier cannot be pointed at internal hosts. ### What to do Register a public HTTPS endpoint. ### The problem document Every error is `application/problem+json` (RFC 9457) with the same shape: ```json { "type": "https://docs.co-relayer.com/errors/webhook-url-not-allowed", "title": "That webhook URL cannot be registered.", "status": 422, "detail": "A sentence about this specific occurrence.", "instance": "req_01JB…", "code": "WEBHOOK_URL_NOT_ALLOWED", "retryable": false, "hint": "Register a public HTTPS endpoint." } ``` See [the error model](/errors) for what each member means and how to handle unknown codes. --- ## The MCP server CoRelayer exposes its API as a [Model Context Protocol](https://modelcontextprotocol.io) server, so an MCP client can discover pricing, buy a plan and relay a transaction as tool calls rather than as HTTP requests it has to construct. The MCP server is part of the same binary as the API and ships with it. Every tool call runs the REST route it wraps inside the server, with your credentials and your client address, so a tool answers exactly as its route does. ### Endpoints and editions | | | |---|---| | Full | `https://mcp.co-relayer.com/mcp` | | Read-only | `https://mcp.co-relayer.com/readonly` | | Transport | Streamable HTTP, stateless, JSON-RPC 2.0 | | Protocol revision | `2025-06-18` | The read-only edition exposes only the tools that cannot change anything: pricing, network, status, relayers, quota, account, quotes, validation, transaction status, usage history, notices and the documentation search. Point untrusted or exploratory clients at it. ### What it is, precisely **Every tool is a typed wrapper around one API route** (two, where one answer is more useful than two round trips). The MCP server holds no key, adds no capability the REST API lacks, and applies the same authentication rings, the same rate limits and the same errors. A tool error is the problem document, as structured content. This matters because the alternative — an MCP server that is its own service with its own credentials — would be a second path to the signer with its own security properties. There is one validation pipeline and one signer boundary, and MCP is an adapter in front of them. ### The tools Every tool runs in the full edition. The third column says which ones the read-only edition offers as well. | Tool | Route | Read-only edition | |---|---|---| | `get_pricing` | `GET /v1/pricing` | yes | | `get_network_status` | `GET /v1/network` + `GET /v1/status` | yes | | `list_relayers` | `GET /v1/relayers` | yes | | `get_quota` | `GET /v1/account/{erd}/quota` | yes | | `get_relayer_for_sender` | `POST /v1/relay/assign` | yes | | `get_account` | `GET /v1/account/{erd}` + `/purchases` | yes | | `quote_relay` | `POST /v1/quote` | yes | | `validate_transaction` | `POST /v1/validate` | yes | | `prepare_subscribe` | `POST /v1/subscribe/prepare` | no | | `prepare_deposit` | `POST /v1/deposit/prepare` | no | | `prepare_set_flags` | `POST /v1/flags/prepare` | no | | `prepare_senders` | `POST /v1/senders/prepare` | no | | `relay_transaction` | `POST /v1/relay`, then `GET /v1/relay/{id}` until the state you ask for | no | | `relay_batch` | several `POST /v1/relay` on one lease | no | | `get_tx_status` | `GET /v1/relay/{id}` | yes | | `get_usage_history` | `GET /v1/usage` | yes | | `get_notices` | `GET /v1/account/{erd}/notices` | yes | | `buy_plan_x402` | `POST /v1/x402/purchase` ([x402](/x402)) | no | | `topup_x402` | `POST /v1/x402/topup` | no | | `search_docs` | the operations of the [OpenAPI document](pathname:///openapi.yaml) | yes | Arguments, results and examples: [Tools](/mcp/tools). ### Resources and prompts | Resource | Content | |---|---| | `corelayer://pricing` | The live pricing document. | | `corelayer://openapi` | The OpenAPI document. | | `corelayer://status` | Service status. | | `corelayer://docs/quickstart-agent` | The [agent quickstart](/agents/quickstart). | | Prompt | For | |---|---| | `go_gasless` | Walking an account from nothing to a first relayed transaction. | | `buy_plan` | Choosing and buying a tier. | `tools/list` is deterministic and cacheable for five minutes. As built, `resources/list` and `prompts/list` return the entries above, but reading them — `resources/read`, `prompts/get` — is not implemented yet and answers "unknown method". The same content is available over HTTP: the pricing document at `GET /v1/pricing`, the OpenAPI document at [`/openapi.yaml`](pathname:///openapi.yaml), status at `GET /v1/status`, and the quickstart on this site. ### Client configuration The server speaks Streamable HTTP, so any MCP client that supports a remote HTTP server can use it. Two examples of the usual shape: ```json title="A remote MCP server entry" { "mcpServers": { "corelayer": { "type": "http", "url": "https://mcp.co-relayer.com/mcp" } } } ``` ```json title="Read-only, for exploration" { "mcpServers": { "corelayer-readonly": { "type": "http", "url": "https://mcp.co-relayer.com/readonly" } } } ``` The exact file and key names differ per client — consult your client's own documentation for where its server list lives. We will not reproduce a configuration path here that we have not verified against the client in question. ### What the server cannot do - **It cannot sign.** No private key ever reaches it. Every tool that needs a signature returns something for *you* to sign: a presence-proof message, an unsigned transaction, an x402 challenge. - **It cannot create entitlement.** `prepare_*` builds transactions; the chain decides. - **It cannot bypass a limit.** Same rate classes, same quotas, same errors as the REST API. - **There is no `subscribe` tool.** Subscribing requires a signature, and an MCP server that cannot sign cannot offer one honestly. `prepare_subscribe` is the truthful shape. ### Handling signed transactions safely :::danger[Do not log tool arguments for `relay_transaction` or `relay_batch`] These two tools take a **user-signed transaction** in their arguments. MCP hosts and model transcripts routinely log tool arguments, and a signed relayed transaction sitting in a transcript is a payload somebody else can hold. Three mitigations, and one honest residual: - the result and every error of these two tools never echo the signed transaction, the signature, the guardian signature or the lease — only sender, nonce, transaction hash, state and the problem fields; - both tools set an idempotency key automatically, one per call; - hosts are asked, in the tool descriptions themselves, not to log these arguments. The residual — a logged payload that we never co-signed — is inert, because a relayed transaction without a relayer signature cannot execute. It is bounded further by the presence proof and by first-seen expiry. It is not zero. The scrubbing is built and tested: every result and every error of these two tools passes through a filter that drops `signature`, `guardianSignature`, `relayerSignature`, `lease`, `transaction`, `tx` and `secret` members before anything is returned. ::: ### `relay_batch`, and why there is no REST equivalent `relay_batch` sends several transactions from one sender, on strictly consecutive nonces, over one lease, stopping at the first error. It exists because an MCP round trip per transaction is expensive. There is deliberately **no REST batch route**. One request, one intent keeps every error attributable to exactly one transaction. A batch of *different* nonces is not a set of variants of one action, so this does not conflict with the one-signature rule — each transaction in the batch was signed once, for itself. ([One signature](/concepts/one-signature)) --- ## MCP tools The server lists twenty tools, and every one of them runs. Each one wraps one API route (two, where a single answer is more useful than two round trips) and runs it inside the server with your credentials, so the auth ring, the rate limit and the error of a tool are the route's. Arguments marked `?` are optional. Every tool returns the route's response as structured content; a failure returns the [problem document](/agents/errors-and-retries) instead. --- ### Reading #### `get_pricing` ```jsonc { "audience": "agent" } // "human" | "agent", optional ``` Wraps `GET /v1/pricing`. Returns tiers, the tariff and its version, pay-as-you-go rates, rate classes and the document version. Amounts are **decimal strings** — parse them as decimals. Public. Available in the read-only edition. #### `get_network_status` ```jsonc {} ``` Wraps `GET /v1/network` and `GET /v1/status`, and returns both as `network` and `status`: chain id, contract address, payment token, gas constants, round duration, direct hosts, the native-auth block, contract and swap-venue pause state, and the service state with its open incidents. Use `chainId` from here to check every transaction you are about to sign. Public, read-only edition. #### `list_relayers` ```jsonc { "shard": 1 } // optional ``` Wraps `GET /v1/relayers`. The registry as the API mirrors it. **Verify an address against the contract before signing a transaction that names it** — see [`getRelayerState`](/contract/reference/views). Public, read-only edition. #### `get_quota` ```jsonc { "address": "erd1…" } ``` Wraps `GET /v1/account/{erd}/quota`. Tier, cap, units used, period end, pay-as-you-go state. Public — a fleet agent can read the quota of the account sponsoring it without holding its key. Read-only edition. #### `get_account` ```jsonc { "address": "erd1…" } ``` Wraps `GET /v1/account/{erd}` plus the first page of `/purchases`, returned as `account` and `purchases`. Plan, credits, flags, authorised senders, recent purchases. Public, read-only edition. #### `get_tx_status` ```jsonc { "txHash": "…" } // or { "intentId": "erd1…:41" } ``` Wraps `GET /v1/relay/{id}`, which resolves either form. Returns the full intent: state, whether it is final, the timestamps, the latency breakdown, the gateway acknowledgements, the block, the fee paid and the gas used. Public, read-only edition. #### `get_usage_history` ```jsonc { "fromMs": 1789000000000, "toMs": 1789600000000, "limit": 50, "account": "erd1…" } // all optional ``` Wraps `GET /v1/usage`. One row per relayed transaction, newest first. **Private** — needs native-auth or a `read`-scoped key. Read-only edition. #### `get_notices` ```jsonc { "address": "erd1…", "sinceSeq": 17 } // both optional ``` Wraps `GET /v1/account/{erd}/notices`. Without `address`, it reads the account of the credential you present. Scheduled tariff changes, cap thresholds, draining relayers, renewal outcomes. Poll with `sinceSeq` rather than walking pages. **Private.** Read-only edition. #### `search_docs` ```jsonc { "query": "relay unit formula" } ``` Searches the operations of the API: every route of the [OpenAPI document](pathname:///openapi.yaml), ranked by how many of your terms its name and path contain, with links to this site and to [`/llms.txt`](pathname:///llms.txt) to read on. The only tool that wraps no API route. Public, read-only edition. --- ### Estimating before committing #### `quote_relay` ```jsonc { "transaction": { "nonce": 41, "value": "0", "receiver": "erd1…", "sender": "erd1…", "gasPrice": 1000000000, "gasLimit": 150000, "data": "", "chainID": "1", "version": 2 } } ``` Wraps `POST /v1/quote`. Returns `ru`, `maxFeeAtto`, `moveGas`, the Relay Unit schedule version, the tariff, the pay-as-you-go price per unit and how it would be billed. The units follow the [published formula and vectors](/concepts/relay-units). Public, read-only edition. #### `validate_transaction` ```jsonc { "transaction": { /* unsigned or signed */ } } ``` Wraps `POST /v1/validate`. Runs the whole relay validation pipeline **without a lease, without a reservation and without asking the signer for anything**, and returns every problem a real relay would raise, plus `ru`, `simulatedGas`, `expectedNonce` and which account would be billed. This is the tool to call before spending a signature. Public, read-only edition. --- ### Preparing transactions for you to sign These build unsigned transactions. They are public — they grant nothing, because a transaction only matters once you have signed it. #### `prepare_subscribe` ```jsonc { "address": "erd1…", "tierId": 12, "months": 1, "payWith": "credits" } ``` Wraps `POST /v1/subscribe/prepare`. Returns a [quote](/agents/buying-a-plan), the unsigned transaction with the relayer and gas bounds already set, and a lease. The quote lives 120 seconds and its `maxPrice` is part of the signed arguments. #### `prepare_deposit` ```jsonc { "address": "erd1…", "amountUsdc": "25", "beneficiary": "erd1…" } ``` Wraps `POST /v1/deposit/prepare`. Minimum one USDC. #### `prepare_set_flags` ```jsonc { "address": "erd1…", "payg": true, "autoRenew": false, "releaseEscrow": false, "renew": false } ``` Wraps `POST /v1/flags/prepare`. Builds the on-chain call for pay-as-you-go, auto-renew, escrow release or an immediate renewal. Remember that auto-renew requires a non-zero `max_renew_price` — [zero never means unlimited](/plans/credits-and-billing). #### `prepare_senders` ```jsonc { "address": "erd1…", "action": "add", "senders": ["erd1…"] } ``` Wraps `POST /v1/senders/prepare`. The result states the fee as `feeMicroUsdc`, which is the `max_fee` argument you will be signing. **Adding senders costs Relay Units; removing them is free.** --- ### Relaying #### `get_relayer_for_sender` ```jsonc { "sender": "erd1…", "proof": { "kind": "key", "serverTimeMs": 1789000000000, "signature": "…hex…" }, // or "sponsor" "renewFor": "erd1…", // optional: renew the lease for a relayer already in your signed bytes "cancelNonce": 41 // optional: a lease for cancelling that nonce } ``` Wraps `POST /v1/relay/assign`. The agent signs the presence-proof **message** `corelayer/assign/v1|||` with its own key — the MCP server never holds one. The signature is a raw Ed25519 signature over the bytes of the message, without the MultiversX message prefix a wallet's `signMessage` adds, and the sender's key makes it for `kind: "key"` and `kind: "sponsor"` alike ([the presence proof](/agents/auth#the-presence-proof)). Returns the full assignment, lease included, and an `instructions` string: set `relayer` before signing, add the relayed-transaction gas, sign once, and verify the relayer's registry state on chain first. ([Verify a relayer](/concepts/verify-a-relayer)) Read-only edition. #### `relay_transaction` ```jsonc { "transaction": { /* signed, `relayer` set, no relayerSignature */ }, "lease": "…", "mode": "normal", // "normal" | "cancel" | "replace" "account": "erd1…", // optional: bill this account "waitFor": "in_block" // "accepted" | "in_block" | "finalized" } ``` Wraps `POST /v1/relay`, then reads `GET /v1/relay/{id}` until the intent reaches the state you asked for, or for at most thirty seconds — after which it returns the last state it read rather than failing. Sets an idempotency key automatically, one per call. :::danger[Signed transaction in the arguments] Do not log the arguments of this tool. The result and every error omit the signed transaction, the signature, the guardian signature and the lease on purpose; the arguments are your host's responsibility. See [the MCP overview](/mcp). ::: #### `relay_batch` ```jsonc { "transactions": [ /* ≤ 16, same sender, consecutive nonces */ ], "lease": "…" } ``` Several transactions over one multi-use lease, submitted in order, stopping at the first error. The result says how many were relayed and holds each answer in order. There is no REST equivalent, deliberately: one request, one intent keeps every error attributable. The same logging warning applies. --- ### Paying over x402 #### `buy_plan_x402` ```jsonc { "payer": "erd1…", "tierId": 12, "months": 1 } ``` Wraps `POST /v1/x402/purchase`. The first call returns the challenge in the tool result, as `paymentRequired`. Call the tool again with the payment in `_meta["x402/payment"]`; it is sent to the route as the `PAYMENT-SIGNATURE` header, and the tool returns the route's answer. #### `topup_x402` ```jsonc { "payer": "erd1…", "amountUsdc": "25", "beneficiary": "erd1…" } ``` Wraps `POST /v1/x402/topup`. Same exchange. Minimum one USDC. Both are *settle before serve*: the answer arrives when the payment is final and the contract event has been observed — or, if that takes longer than the budget, as a `202` with a payment id to poll. ([x402](/x402)) --- ### Errors A tool failure is the problem document, as structured content: ```json { "type": "https://docs.co-relayer.com/errors/quota-exhausted", "title": "The plan's included Relay Units are used up.", "status": 429, "code": "QUOTA_EXHAUSTED", "retryable": false, "resign": "NONE", "hint": "Turn on pay-as-you-go or upgrade the tier." } ``` Branch on `code`; handle an unknown `code` by `status` and `retryable`; only sign again when `resign` says so. ([Errors and retries](/agents/errors-and-retries)) --- ## Tiers for agents Three tiers carry the agent label. They exist because a program buys differently from a person: it may have no operator to approve a monthly plan, its volume may be unknown until it runs, and it often needs to start immediately with whatever it holds. | Tier | Cap / month | Price | Pay-as-you-go / RU | Status | |---|---|---|---|---| | **Agent Metered** | none | $0 | $0.0150 | Available | | **Agent Pro** | 10,000 RU | $85 | $0.0120 | Available | | **Agent Fleet** | 100,000 RU | $650 | $0.0110 | Not yet sold | | Tier | All your agents (sponsor key) | Named wallets (key mode) | Rate class | Service class | |---|---|---|---|---| | **Agent Metered** | — | 1 | 6 | Shared pool, best effort | | **Agent Pro** | ✓ | 25 | 3 | Shared pool | | **Agent Fleet** | ✓ | 1,000 | 5 | Priority lane | Snapshot of 2026-09-19 at a tariff of 10,000 micro-USDC per Relay Unit. Live values: `GET /v1/pricing?audience=agent`. ### Agent Metered: a tier with no period and no cap Agent Metered has `cap_ru = 0`, `price = 0` and `period_ms = 0`. That is not a placeholder. It is a tier whose entire behaviour is pay-as-you-go: - **Nothing to commit to.** There is no monthly price and no term. - **Everything is metered.** Every Relay Unit is billed at the pay-as-you-go price, out of escrow. - **Deposit what you intend to spend.** The escrow is the ceiling; nothing can spend past it. - **Your own account plus one named wallet.** There is no sponsor key on this tier: paying for other agents takes Agent Pro or Agent Fleet. It costs the most per unit, which is the honest shape of a plan with no commitment. It exists so that an agent holding only USDC can be transacting within one purchase, and can move to a plan later without changing anything about how it relays. ```mermaid flowchart LR A["Agent holds USDC"] --> B["depositAndSubscribe
tier 11, months 0"] B --> C["Escrow funded"] C --> D["Relay, billed per unit"] D -->|volume becomes predictable| E["Buy Agent Pro"] ``` ### Agent Pro and Agent Fleet: one key for every agent you run Agent Pro and Agent Fleet can pay for **any** agent address with a sponsor key, with no list to keep. An operator running many agents holds one sponsor key on its server and relays each agent's transactions with it (`X-Api-Key`). Each agent still signs its own presence proof and its own transaction; the key only decides who pays. The relay answer names the operator's account, with `billing.authMode: "api_key"`. The key's policy bounds what it pays for: the contracts on its receiver allow-list, and optional limits per agent per day and per key per day. The same plans also carry 25 (Pro) and 1,000 (Fleet) named wallets, for addresses you list on chain. ([Paying for other senders](/plans/sponsoring-senders) · [Pay for your users](/sdk/recipes/sponsor-users)) ### What the agent label actually is `AGENT` is a flag on the tier record. It is **an audience label, not an enforcement mechanism**, and that is stated in the contract rather than implied: nothing on chain can tell an agent from a person, and pretending otherwise would be security theatre. What the label does: - it lets `GET /v1/pricing?audience=agent` return the plans meant for machine buyers; - it groups these tiers in the pricing document and the dashboard; - it signals the intent behind the tier's shape — one account, metered, best effort. What it does not do: restrict who may buy. A person may buy Agent Metered; an agent may buy Builder. Nothing checks. ### Differences that are real | | Agent tiers | Human tiers | |---|---|---| | Purchasable in one payment | Yes — including via [x402](/x402) | Yes | | Period | Metered has none | 30 days | | Simulation before relaying | Opt-out, at the account's own Relay Unit risk | On by default | | Service class of the entry tier | Best effort | Shared pool | The simulation difference is the one to think about. Pre-flight simulation catches a transaction that would revert, before it costs you the units — a person usually wants that. An agent running a tight loop may prefer the latency and accept that a reverting call still costs its full worst case. It is a choice, not a default we made for you. ### Buying without a person Three ways, described in full in the agents section — two of them usable today: | Way | When | |---|---| | **On-chain purchase** | The agent holds a key and can sign. One `depositAndSubscribe` call — and the purchase itself is relayed for free, so it needs no EGLD. | | **x402** | The agent speaks HTTP 402. Challenge, pay, retry; the plan exists when the call returns. [x402](/x402) | | **MCP** | The agent is an MCP client. `prepare_subscribe` and `buy_plan_x402` are specified for this but do not execute yet; until they do, an MCP client uses the same REST routes. [MCP tools](/mcp/tools) | All three end at the same contract call with the same price ceiling. There is no separate agent pricing path, no special rate and no discount that is not in the table above. ### Rate classes matter more here An agent is far more likely to hit a rate limit than a cap. Agent Metered is rate class 6: twice the sustained Relay Units per second of Starter's class 1, with the same burst, the same per-shard gas budget, the same 100,000,000 gas ceiling per transaction and the same hourly budget. Agent Pro is class 3, the class Growth uses. If your traffic is bursty rather than voluminous, the rate class is the reason to move up, not the cap. [`RATE_LIMITED`](/errors/rate-limited) carries a `Retry-After` and `details.retryAfterMs`; back off on it rather than retrying immediately, and treat a sustained pattern of it as a signal to change tier. ([Limits](/operations/limits) · [Errors and retries](/agents/errors-and-retries)) ### Agent Fleet Configured, priced, and not purchasable yet: the throughput it commits to needs a larger relayer float than is funded. `available: false`, `unavailableReason: "CAPACITY"`, and [`TIER_NOT_PURCHASABLE`](/errors/tier-not-purchasable) if you try. It will be announced in the [changelog](/changelog) when that changes. --- ## Paying for other senders A plan belongs to an **account**, but the transactions it pays for can be signed by other addresses. An app pays for its users; an operator pays for a fleet of agents; a team pays for its members' wallets. There are two ways to do it, for two shapes of problem. | | Sponsor mode | Key mode (named wallets) | |---|---|---| | For | Any number of senders whose key your server holds or reaches: embedded or custodial wallets, players, the agents you run | A small set you know in advance whose key your own programs hold: bots, devices, scripts, your treasury | | Who pays | The account that owns the sponsor key | The sender's own account, or an account that listed the sender on chain | | Where the permission lives | On **your server**, as a secret | **On chain**, one entry per `(account, sender)` | | Proof at assign | The sender's presence proof (`proof.kind: "sponsor"`), which your server signs with the sender's key; the sponsor key is not read at assign | `proof.kind: "key"`, made by the program that holds the sender's key, or a native-auth token from the CoRelayer dashboard | | Credential at relay | The signed transaction, plus `X-Api-Key` from your server | The signed transaction | | Limit | No sender limit. The key's policy applies: receiver allow-list (required), function allow-list, units per sender per day, units per day, IP ranges | The tier's named-wallet count | | Costs | Nothing beyond the Relay Units used | A fee per wallet, paid once when it is added | | Plans | Builder, Growth, Scale, Enterprise, Agent Pro, Agent Fleet | Every plan, up to its named-wallet count | | `billing.authMode` on the relay answer | `api_key` | `own_account` or `authorized_sender` | Either way, **rate limits and quotas are the account's**, never per sender. Paying for another sender does not add capacity; it shares what the account bought. ### Sponsor keys, on your server A dApp with a hundred thousand users cannot list them on chain. A sponsor key lets your server pay for transactions signed by **any** sender whose key it holds or reaches, within limits you set. Your server submits each signed transaction with the key, and your plan pays the network fee: ```ts title="sponsor-relay.ts" snippet="examples/sponsor-relay.ts#site-sponsor" import { CoRelayerClient, type RelayRequest } from '@corelayer/sdk'; // On your server. The sponsor key never reaches a browser. const apiKey = process.env.CORELAYER_API_KEY; if (!apiKey) throw new Error('Set CORELAYER_API_KEY'); const corelayer = new CoRelayerClient({ baseUrl: 'https://api.co-relayer.com', apiKey, }); // Your user signed the transaction. Your plan pays the network fee. export async function payForUser(req: RelayRequest, actionId: string) { const { data } = await corelayer.relay(req, { intentKey: actionId }); return data.txHash; } ``` The key comes from the environment, and a missing key stops the server at start-up. [Pay for your users](/sdk/recipes/sponsor-users) has the whole file, the same code in Go, Rust and Python, the steps to create a key, and what to do when a key refuses a request. ```text crk___ sent as X-Api-Key: crk_live_… ``` | | | |---|---| | Scope | `relay`: pay for any sender. (`read` exists for private reads of the account.) | | Required | a plan that can sponsor: Builder and up, Agent Pro and Agent Fleet (tier flag `sponsor_any_sender`); otherwise [`API_KEY_SCOPE`](/errors/api-key-scope) | | Required policy | a **non-empty receiver allow-list**: the contracts your users may call at your expense. The key compares it with the transaction's receiver field. Single fungible-token payments (`ESDTTransfer`) to a listed contract are covered. NFT, SFT, Meta-ESDT and multi-token transfers name the sender as receiver, so a sponsor key cannot pay for them yet. | | Optional policy | a function allow-list, Relay Units per sender per day, Relay Units per day, IP ranges | | Shown | once, at creation. It is stored as a keyed hash and cannot be shown again. | | Created, changed, revoked by | your wallet, through native-auth, never by another key | | Per account | at most 10 keys | **There are no browser keys.** Sender addresses cost nothing to create, so a key visible in a web page would let anyone spend your whole cap inside the allow-list. Named wallets suit addresses whose key your own programs hold, such as bots, devices and scripts. A browser dApp whose users sign in their own wallet can't be relayed for yet. **Which senders.** Sponsor mode covers senders whose key your server holds or reaches through a signing service: embedded and custodial wallets, game and app servers, bots and agent fleets. The server proves the sender is present with the sender's key, has that key sign once, and relays with the sponsor key. The sponsor key stays on your server, and the signature comes from the sender's key: ```mermaid sequenceDiagram autonumber participant K as Sender's key (your server or signing service) participant B as Your backend (holds the sponsor key) participant A as CoRelayer API K-->>B: presence proof, kind "sponsor" (signed with the sender's key) B->>A: POST /v1/relay/assign { sender, proof } A-->>B: relayer in the sender's shard, lease B->>K: sign once (a call to an allow-listed contract, relayer set) K-->>B: signed transaction B->>B: your own checks: is this sender allowed, is this call one you pay for B->>A: POST /v1/relay { tx, lease } with X-Api-Key A->>A: key valid, scope relay, receiver and function allowed, daily limits A-->>B: 200 { intentId, txHash, account = yours, authMode api_key } ``` If a key leaks, the damage is bounded by what you configured: the thief can spend your Relay Units, but only on calls to receivers you allow-listed, and only up to the per-sender and per-day limits. Revoke it from the dashboard with your wallet. #### Payments to people A payment of EGLD or a token to a person's address has that address as its receiver field. A sponsor key pays for it only if that address is on the receiver allow-list, which holds at most 100 entries, so a sponsor key can't pay for payments to arbitrary addresses. Send payouts and person-to-person payments from [wallets you name](#named-wallets-on-chain) instead (key mode), or let each sender hold a plan of its own. | The transaction | Its receiver field | A sponsor key pays for it | |---|---|---| | A call to a contract, with or without EGLD or a single fungible token | The contract | When the contract is listed | | EGLD or a token sent to a person's address | That address | Only if that address is on the list (at most 100). Pay those from wallets you name (key mode). | | NFT, SFT, Meta-ESDT and multi-token transfers | The sender | Not yet | ### Named wallets, on chain A named wallet is an address your account lists on chain: an **authorised sender**, in the API's words. It suits a small set you know in advance whose key your own programs hold, such as bots, devices, scripts or your treasury, and it needs no sponsor key: the program that holds each key makes its own presence proof. The account calls the contract: ```text addSenders(max_fee, senders…) up to 50 per call; the fee must not exceed max_fee removeSenders(senders…) free ``` From the moment the `senderAdded` event is final and mirrored, those addresses relay with nothing but their own signature — no key, no token. When a sender is covered by more than one account, it can name the payer with the optional `account` member of `POST /v1/relay`. **The fee.** Adding a sender costs `sender_fee_ru × tariff` per address, taken from the account's credits. At launch `sender_fee_ru` is 5 Relay Units, which at a tariff of 10,000 micro-USDC per unit is **0.05 USDC per sender**. The fee is charged in Relay Units priced at the tariff, so the single price lever rescales it with everything else. An increase of `sender_fee_ru` takes effect only 172,800,000 ms (48 hours) after it is set; a decrease is immediate. **The ceiling.** Like every purchase, `addSenders` carries a ceiling you set. `POST /v1/senders/prepare` quotes the fee as `feeMicroUsdc` and puts exactly that value into the `max_fee` argument you sign; a higher fee at execution reverts with [`PRICE_ABOVE_MAX`](/errors/price-above-max). ```bash curl -sS https://api.co-relayer.com/v1/senders/prepare \ -H 'content-type: application/json' \ -d '{"address":"erd1…account","action":"add","senders":["erd1…a","erd1…b"]}' ``` The answer is a `PreparedTransaction`: the unsigned transaction with the relayer set, the assignment whose lease you submit it with, a one-line `summary`, `free: false`, and the fee. Sign it once and relay it like any other transaction — [Buy a plan](/agents/buying-a-plan) shows the same pattern end to end with its checks. **Why adding costs something and removing does not.** `addSenders` is never on the free list: it is billed as a normal relayed transaction plus the per-sender fee, so a loop that adds and removes addresses to make CoRelayer pay for gas costs the looper on every cycle. `removeSenders` is free because it only lowers exposure, and it is the account's one defence against a compromised sender key once that key has used up the quota. **The limit** is the tier's named-wallet count: the **Named wallets (key mode)** column of [Tiers](/plans/tiers). The contract enforces it in `addSenders`. After a move to a tier with a smaller limit, `addSenders` reverts until the count fits, and the service keeps serving the oldest entries up to the new limit (ordered by when they were added, then by address), which anyone can reproduce from the events. **Many-to-many, on purpose.** One address may be authorised by several accounts. Being listed only ever *gives* a sender paid relays, and the listing account consented on chain, so no consent from the sender is needed — and because there is no uniqueness rule, nobody can block a legitimate sponsor by registering an address first. ### Who pays for a given transaction When a transaction arrives, its payer is decided in a fixed order, and the first rule that applies wins: 1. **A sponsor key is presented** → the key's account. 2. **The request names an `account`** that authorised this sender on chain → that account. 3. **The sender has its own account** with something to serve the transaction → the sender. 4. **Accounts that authorised the sender**, oldest authorisation first → the first one that can serve it. This is the path a client that sends no extra fields takes. 5. **The call is on the free list** — the short, published set of calls to CoRelayer's own contract that the service pays for, so that buying and managing a plan never requires already having one → the service. If none applies you get one of three answers, and they mean different things: | Code | Status | Meaning | |---|---|---| | [`NO_ENTITLEMENT`](/errors/no-entitlement) | 402 | The account to bill has no plan (`details.reason`: `NO_ACCOUNT` or `PERIOD_LAPSED`). With no sponsor key and no account covering it, that account is the sender's own. So when your sponsor key never reached `POST /v1/relay` (a stripped header, a key sent as `Authorization: Bearer`), the relay bills your user, and the answer is `NO_ACCOUNT` even though your plan is active. | | [`SENDER_NOT_AUTHORIZED`](/errors/sender-not-authorized) | 403 | The request named an account in `account`, and that account has not listed this sender (`details.reason`: `NOT_AUTHORIZED_FOR_ACCOUNT`). An explicit account is never swapped for another. | | [`QUOTA_EXHAUSTED`](/errors/quota-exhausted) | 429 | An account covers it, and its units are used up (`CAP_REACHED_PAYG_OFF` or `PAYG_ESCROW_EMPTY`). | The response to a successful relay names the account that was billed (`account`) and how it was resolved (`billing.authMode`). ### Reading it back | | | |---|---| | `GET /v1/account/{erd}/senders` | The named wallets (authorised senders), in the order they were added — public, because it mirrors chain state. | | `GET /v1/account/{erd}/quota` | Includes `senders.count` and `senders.max`. | | `GET /v1/account/{erd}/keys` | Your sponsor keys and their policies — native-auth only. | | `senders.changed` notice | Sent to the account whenever an entry is added or removed. | --- ## Pay for your users With a **sponsor key**, your server pays the network fee on transactions your users sign. One key covers any number of senders, so there is no list of wallets to keep. What limits you is the number of transactions your plan includes, not the number of users. | | | |---|---| | Who signs | Your user's key, once per action | | Who pays | The account that owns the sponsor key. The relay answer says `billing.authMode: "api_key"`. | | Where the key lives | On your server, and nowhere else | | What it pays for | Transactions whose receiver is on its receiver allow-list, within its daily limits ([what that covers](#what-a-sponsor-key-can-pay-for)) | | Plans | Builder, Growth, Scale, Enterprise, Agent Pro and Agent Fleet | ### Which senders this covers This recipe is for senders whose key your server holds, or reaches through a signing service: - embedded and custodial wallets your app creates for its users; - game and app servers that keep a key for each player; - bots and agent fleets you run. Your server proves the sender is present with the sender's own key, has that key sign the transaction once, and relays it with the sponsor key. The sections below follow that order. Users who sign in their own wallet (xPortal, the Web Wallet, the browser extension or a Ledger) can't be relayed for yet. Their wallet can't make the presence proof the API accepts today, and the API accepts sign-in tokens only from CoRelayer's own origins. ### What a sponsor key can pay for The key checks the transaction's receiver field against its receiver allow-list. Single fungible-token payments (`ESDTTransfer`) to a listed contract are covered. NFT, SFT, Meta-ESDT and multi-token transfers name the sender as receiver, so a sponsor key cannot pay for them yet. | The transaction | Its receiver field | Covered | |---|---|---| | A contract call, with or without EGLD | The contract | Yes, when the contract is listed | | `ESDTTransfer`: one fungible token, optionally calling the contract | The contract | Yes, when the contract is listed. The function list checks the function inside the transfer. | | `ESDTNFTTransfer`: an NFT, SFT or Meta-ESDT | The sender | Not yet | | `MultiESDTNFTTransfer`: several tokens at once | The sender | Not yet | | EGLD or a token sent to a person's address | That address | Only if that address is on the list (at most 100). A sponsor key can't pay for payments to arbitrary addresses. Pay those from [wallets you name](/plans/sponsoring-senders#named-wallets-on-chain) (key mode). | A transfer the key cannot cover is refused with [`RECEIVER_NOT_ALLOWED`](/errors/receiver-not-allowed) before anything is sent. ### 1. Create a sponsor key [In the dashboard](https://app.co-relayer.com/api-keys): 1. Open **API keys** and choose **Create a key**. 2. Name it, and turn on **Relay — pay for transactions other addresses sign**. The switch is available from Builder up. 3. Fill in **Receivers this key may pay for**: the contracts your users call at your expense. The list is required. A relay key with no receiver pays for nothing. The key compares it with each transaction's receiver field ([what that covers](#what-a-sponsor-key-can-pay-for)). 4. Narrow it further if you like: the functions it may call, a daily limit for the whole key, a limit per sender per day, and the IP ranges your servers call from. 5. Create the key and copy it. It is shown once. CoRelayer keeps only a keyed hash of it. Store it in your server's secret store as `CORELAYER_API_KEY`. A devnet key starts with `crk_test_` and a mainnet key with `crk_live_`, and neither works on the other network. Creating, changing and revoking a key always takes your wallet. A key can never create or change another key. ### 2. Get a relayer and the one signature Before anything is sent, your server: 1. asks for a relayer for the sender (`POST /v1/relay/assign`) with a presence proof signed by the sender's key, marked `proof.kind: "sponsor"`; 2. builds the transaction for the relayer it was given; 3. has the sender's key sign it, once. The sponsor key plays no part in step 1. The assign route does not read `X-Api-Key`, and the proof is always the sender's: its key signs the UTF-8 bytes of the message directly, as a raw Ed25519 signature (sdk-core `UserSigner.sign`). Do not use a wallet's `signMessage` or sdk-core `Account.signMessage`: they add the MultiversX message prefix, and the API refuses that signature with [`ASSIGN_PROOF_INVALID`](/errors/assign-proof-invalid). If a signing service holds your users' keys, it must be able to sign raw bytes. [Sign and relay](/sdk/recipes/sign-and-relay) shows each step, and `relayOnce` in every SDK does all three for you. If a separate service holds your users' keys, such as an embedded-wallet provider or a key vault, it returns the signed transaction and the lease it was signed for. ### 3. Relay with the sponsor key Your server submits the signed transaction and its lease with the sponsor key. The client sends the key as `X-Api-Key` on this request, the one that reads it, and your plan pays the network fee. #### TypeScript ```ts title="sponsor-relay.ts" snippet="examples/sponsor-relay.ts#site-sponsor" import { CoRelayerClient, type RelayRequest } from '@corelayer/sdk'; // On your server. The sponsor key never reaches a browser. const apiKey = process.env.CORELAYER_API_KEY; if (!apiKey) throw new Error('Set CORELAYER_API_KEY'); const corelayer = new CoRelayerClient({ baseUrl: 'https://api.co-relayer.com', apiKey, }); // Your user signed the transaction. Your plan pays the network fee. export async function payForUser(req: RelayRequest, actionId: string) { const { data } = await corelayer.relay(req, { intentKey: actionId }); return data.txHash; } ``` A missing key stops the server at start-up, instead of sending requests the API would refuse one by one. The whole file is below. #### Go ```go snippet="packages/sdk-go/example_site_test.go#site-sponsor" // On your server. The sponsor key never reaches a browser. client, err := corelayer.NewClient(corelayer.ClientOptions{ BaseURL: "https://api.co-relayer.com", APIKey: mustEnv("CORELAYER_API_KEY"), }) if err != nil { log.Fatal(err) } // Your user signed tx. Your plan pays the network fee. req := corelayer.RelayRequest{Tx: tx, Lease: lease} opts := corelayer.RelayOptions{IntentKey: actionID} res, err := client.Relay(ctx, req, opts) if err != nil { log.Fatal(err) } ``` `mustEnv` comes from the same file: ```go snippet="packages/sdk-go/example_site_test.go#site-sponsor-env" // mustEnv stops the server at start-up when the sponsor key is not set. // Without a key the client sends no X-Api-Key, and a relay from a funded // wallet would be billed to that wallet's own account instead of your plan. func mustEnv(name string) string { value := os.Getenv(name) if value == "" { log.Fatalf("set %s to your sponsor key", name) } return value } ``` #### Rust ```rust snippet="packages/sdk-rust/examples/site_snippets.rs#site-sponsor" // On your server. The sponsor key never reaches a browser. let key = std::env::var("CORELAYER_API_KEY")?; let options = ClientOptions::new("https://api.co-relayer.com"); let client = Client::new(options.api_key(key))?; // Your user signed tx. Your plan pays the network fee. let req = RelayRequest { tx, lease, ..Default::default() }; let res = client.relay(&req, Some(action_id.as_str())).await?; ``` #### Python ```python snippet="packages/sdk-python/examples/site_snippets.py#site-sponsor" # On your server. The sponsor key never reaches a browser. def sponsor_client(): return Client( "https://api.co-relayer.com", api_key=os.environ["CORELAYER_API_KEY"], ) def pay_for_user(client, tx, lease, action_id): # Your user signed tx. Your plan pays the network fee. res = client.relay({"tx": tx, "lease": lease}, intent_key=action_id) return res.data["txHash"] ``` The action id is the idempotency key of one user action: 16 to 128 visible ASCII characters, such as `order-1042-mint-1`. Send the same one when you retry the same action, and the API answers with the first result instead of relaying twice. The Go, Rust and Python code comes from each SDK's own example file, which its test suite compiles. The TypeScript file runs in this site's tests against a stand-in API. ### 4. Put together When the same server holds the sender's key, `relayOnce` runs sections 2 and 3 as one call. Give it the sponsor client of `sponsor-relay.ts` above, the sender's key as `signProof` and `signOnce`, and `proofKind: 'sponsor'`. The sender's key still signs the presence proof; `sponsor` marks the lease of a relay that the sponsor key pays for, and the client sends the key on the relay call only. Here the call goes to a contract on the key's receiver list: ```ts title="sponsor-call.ts" snippet="examples/sponsor-call.ts#sponsor-call" import { type RelayerVerifier, relayOnce } from '@corelayer/sdk'; import { corelayer } from './sponsor-relay.ts'; // the sponsor client import type { Wallet } from './wallet.ts'; // on the Sign and relay page /** A call to a contract on the sponsor key's receiver allow-list. */ export interface ContractCall { /** The contract. It must be on the key's receiver list. */ readonly contract: string; /** The call as `function@hexArg@…`, for example `claim@01`. */ readonly payload: string; /** Gas for the call itself. The relayed-transaction surcharge is added below. */ readonly gasLimit: number; /** The user's account nonce. */ readonly nonce: number; } /** * One user action. The user's key signs the presence proof (`proof.kind: "sponsor"`) and the * transaction, once each; the relay is billed to your plan (`billing.authMode: "api_key"`). */ export async function sponsorCall( user: Wallet, // the user's key, held by your server or its signing service call: ContractCall, actionId: string, // one per user action: 16 to 128 visible ASCII characters verify: RelayerVerifier, // createRelayerVerifier, with the contract you pinned ): Promise { const result = await relayOnce({ client: corelayer, sender: user.address, // The user's key signs the proof, raw. `sponsor` marks the lease of a sponsored relay. signProof: (message) => user.signProofMessage(message), proofKind: 'sponsor', intentKey: actionId, // Nothing is signed for a relayer that is not active in the user's shard. verifyRelayer: (assignment) => verify(assignment, user.address), buildTransaction: (assignment) => ({ nonce: call.nonce, value: '0', receiver: call.contract, sender: user.address, gasPrice: assignment.minGasPrice, gasLimit: call.gasLimit + assignment.extraGasRelayed, data: Buffer.from(call.payload).toString('base64'), chainID: assignment.chainId, version: 2, relayer: assignment.relayer, }), signOnce: ({ transaction }) => user.signTransaction(transaction), }); if (result.kind === 'relayed') return result.response.txHash; // The relayer went away before it co-signed, and nothing was sent. Signing again is the // user's decision: see "Handle a re-sign request". throw new Error(`Nothing was sent; relayer ${result.previousRelayer} is gone.`); } ``` `Wallet` comes from `wallet.ts`, shown in full on [Sign and relay](/sdk/recipes/sign-and-relay#the-signer); copy it next to these two files, or pass any object with an `address`, a `signProofMessage` that signs raw bytes, and a `signTransaction`. `verify` is a relayer check from `createRelayerVerifier`, built once with the CoRelayer contract you pinned and a MultiversX gateway you choose ([Verify a relayer](/concepts/verify-a-relayer)). The test suite runs this function against a stand-in API and checks that the proof is `kind: "sponsor"`, that only the relay carries `X-Api-Key`, and that the user's key signs the transaction exactly once. Each SDK guide shows `relayOnce` in its own language: [TypeScript](/sdk/javascript), [Go](/sdk/go), [Rust](/sdk/rust) and [Python](/sdk/python). ### Without an SDK The same action takes three HTTPS calls, and nothing but `fetch` and `@multiversx/sdk-core`. It is also the way in while the SDKs are not published yet: ```ts title="sponsor-http.ts" snippet="examples/sponsor-http.ts#no-sdk" const computer = new TransactionComputer(); const hex = (bytes: Uint8Array) => Buffer.from(bytes).toString('hex'); /** A call to a contract on the sponsor key's receiver allow-list. */ export interface ContractCall { readonly contract: string; // on the key's receiver list readonly payload: string; // `function@hexArg@…`, e.g. `claim@01` readonly gasLimit: number; // gas for the call itself readonly nonce: number; // the user's account nonce } export async function relayWithoutSdk( api: string, // https://api.co-relayer.com, or the devnet API sponsorKey: string, // from your secret store, never from code user: UserSigner, // the user's key, on your server relayers: RelayerCheck, // verify-relayer.ts, with the contract you pinned call: ContractCall, actionId: string, // one per user action: 16 to 128 visible ASCII characters ): Promise { const sender = user.getAddress().toBech32(); // 1. The user's key proves the user is present, over the server's clock. It signs the raw // bytes of the message: no MultiversX message prefix, so not a wallet's `signMessage`. const network = await request(`${api}/v1/network`, { cache: 'no-store' }); const message = `corelayer/assign/v1|${network.chainId}|${sender}|${network.serverTimeMs}`; const signature = hex(await user.sign(new TextEncoder().encode(message))); const proof = { kind: 'sponsor', serverTimeMs: network.serverTimeMs, signature }; const assignment = await post(`${api}/v1/relay/assign`, { sender, proof }); // 2. Nothing is signed for a relayer that is not active in the user's shard. await relayers.verify(sender, assignment.relayer, assignment.registryVersion); // 3. The user's key signs once. The relayer is inside the signed bytes. const tx = { nonce: call.nonce, value: '0', receiver: call.contract, sender, gasPrice: assignment.minGasPrice, gasLimit: call.gasLimit + assignment.extraGasRelayed, data: Buffer.from(call.payload).toString('base64'), chainID: assignment.chainId, version: 2, relayer: assignment.relayer, }; const bytes = computer.computeBytesForSigning(Transaction.newFromPlainObject(tx)); const signed = { ...tx, signature: hex(await user.sign(bytes)) }; // 4. Relay with the sponsor key. Your plan pays the network fee. const relayed = await post( `${api}/v1/relay`, { tx: signed, lease: assignment.lease }, { 'X-Api-Key': sponsorKey, 'Idempotency-Key': actionId }, ); return relayed.txHash; } ``` `RelayerCheck` is the relayer check of [Verify a relayer](/concepts/verify-a-relayer), which also needs no SDK. The whole file: ```ts title="sponsor-http.ts" snippet="examples/sponsor-http.ts" /** * Pays for a user with a sponsor key, without the CoRelayer SDK: three HTTPS calls and one * signature, with nothing but `fetch` and `@multiversx/sdk-core`. * * network GET /v1/network the chain id and the server's clock, for the proof * assign POST /v1/relay/assign a presence proof signed by the user's key (`kind: "sponsor"`) * check the relayer, on chain, through a gateway CoRelayer does not run (verify-relayer.ts) * relay POST /v1/relay the transaction the user's key signed once, with the * sponsor key in `X-Api-Key`, so your plan pays the fee * * Use it for senders whose key your server holds or reaches: embedded or custodial wallets, game * servers, bots and agent fleets. The sponsor key stays on the server that runs this file. */ import { Transaction, TransactionComputer, type UserSigner } from '@multiversx/sdk-core'; import type { RelayerCheck } from './verify-relayer.ts'; const computer = new TransactionComputer(); const hex = (bytes: Uint8Array) => Buffer.from(bytes).toString('hex'); /** A call to a contract on the sponsor key's receiver allow-list. */ export interface ContractCall { readonly contract: string; // on the key's receiver list readonly payload: string; // `function@hexArg@…`, e.g. `claim@01` readonly gasLimit: number; // gas for the call itself readonly nonce: number; // the user's account nonce } export async function relayWithoutSdk( api: string, // https://api.co-relayer.com, or the devnet API sponsorKey: string, // from your secret store, never from code user: UserSigner, // the user's key, on your server relayers: RelayerCheck, // verify-relayer.ts, with the contract you pinned call: ContractCall, actionId: string, // one per user action: 16 to 128 visible ASCII characters ): Promise { const sender = user.getAddress().toBech32(); // 1. The user's key proves the user is present, over the server's clock. It signs the raw // bytes of the message: no MultiversX message prefix, so not a wallet's `signMessage`. const network = await request(`${api}/v1/network`, { cache: 'no-store' }); const message = `corelayer/assign/v1|${network.chainId}|${sender}|${network.serverTimeMs}`; const signature = hex(await user.sign(new TextEncoder().encode(message))); const proof = { kind: 'sponsor', serverTimeMs: network.serverTimeMs, signature }; const assignment = await post(`${api}/v1/relay/assign`, { sender, proof }); // 2. Nothing is signed for a relayer that is not active in the user's shard. await relayers.verify(sender, assignment.relayer, assignment.registryVersion); // 3. The user's key signs once. The relayer is inside the signed bytes. const tx = { nonce: call.nonce, value: '0', receiver: call.contract, sender, gasPrice: assignment.minGasPrice, gasLimit: call.gasLimit + assignment.extraGasRelayed, data: Buffer.from(call.payload).toString('base64'), chainID: assignment.chainId, version: 2, relayer: assignment.relayer, }; const bytes = computer.computeBytesForSigning(Transaction.newFromPlainObject(tx)); const signed = { ...tx, signature: hex(await user.sign(bytes)) }; // 4. Relay with the sponsor key. Your plan pays the network fee. const relayed = await post( `${api}/v1/relay`, { tx: signed, lease: assignment.lease }, { 'X-Api-Key': sponsorKey, 'Idempotency-Key': actionId }, ); return relayed.txHash; } /** The members this file reads from the API's answers. */ interface Answer { readonly chainId: string; readonly serverTimeMs: number; readonly relayer: string; readonly lease: string; readonly registryVersion: number; readonly minGasPrice: number; readonly extraGasRelayed: number; readonly txHash: string; } function post(url: string, body: unknown, headers: Record = {}) { return request(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), }); } /** * One API call. An error answer is an RFC 9457 problem document: its `code` says what went wrong, * and `retryable` whether sending the same request again can help. An error you do not handle stops * the action here, before anything else is signed or sent. */ async function request(url: string, init: RequestInit): Promise { const response = await fetch(url, init); const body = (await response.json()) as Answer & { code?: string; detail?: string }; if (!response.ok) { throw new Error(`${url}: ${response.status} ${body.code ?? ''} ${body.detail ?? ''}`.trim()); } return body; } ``` ### What the answer tells you | Member | In sponsor mode | |---|---| | `account` | Your account, the one that owns the key | | `billing.authMode` | `api_key` | | `ru` | The Relay Units the transaction counts against your plan | A transaction that runs and fails counts too: the network charges its fee, and the relayer pays it. A transaction that never runs does not count. ### When the key refuses | Answer | Why | What to do | |---|---|---| | [`RECEIVER_NOT_ALLOWED`](/errors/receiver-not-allowed) 403 | The transaction's receiver field is not on the key's receiver list. NFT, SFT, Meta-ESDT and multi-token transfers name the sender as receiver, so a key cannot pay for them yet. | Add the contract to the key, or do not sponsor that call. | | [`API_KEY_SCOPE`](/errors/api-key-scope) 403 | The key has no `relay` scope, the plan cannot sponsor (Starter or Agent Metered), or the function is not on the key's function list (`details.function`). | Move to Builder or up, or widen the key in the dashboard. | | [`QUOTA_EXHAUSTED`](/errors/quota-exhausted) 429 | A daily limit of the key is reached (`details.scope`), or the plan's units are used up. | Wait for the day to roll over, raise the limit, or turn on pay-as-you-go. | | [`FORBIDDEN`](/errors/forbidden) 403 | The request came from outside the key's IP ranges (`details.reason: "ipAllowList"`). | Call from an allowed range, or change the ranges. | | [`API_KEY_INVALID`](/errors/api-key-invalid) 401 | The key is unknown, revoked, or from the other network. | Use a key made for this network. | | [`NO_ENTITLEMENT`](/errors/no-entitlement) 402 with `details.reason: "NO_ACCOUNT"`, **while your plan is active** | The sponsor key never reached `POST /v1/relay`. A proxy stripped the header, the key went out as `Authorization: Bearer`, or the client was built without `apiKey`. Without a key the relay bills the transaction's sender, and a user you sponsor has no plan. | Send the key in the `X-Api-Key` header of `POST /v1/relay`. A sponsored answer carries `billing.authMode: "api_key"`: check it, and alert when it is anything else. Ignore the `PAYMENT-REQUIRED` header: it is built for the sender. | | [`NO_ENTITLEMENT`](/errors/no-entitlement) 402, and your plan has ended | Your account has no plan, or its 30-day period ended (`details.reason` is `NO_ACCOUNT` or `PERIOD_LAPSED`). | Renew in the dashboard. Ignore the `PAYMENT-REQUIRED` header of this answer: it is built for the transaction's sender, not for your account. | A refused request costs the key nothing: the units it would have used go back to its daily limits. To check before you ask a user to sign, read your own account's quota, not the user's: `POST /v1/quote` and `POST /v1/validate` don't see the sponsor key, so their `billedAs` and `wouldBill` describe the user. [Check before you send: in sponsor mode](/sdk/recipes/check-quota#in-sponsor-mode) has the check and the rate it can run at. ### Keep the key on the server - **Never in a web page, an app bundle or a repository.** Sender addresses cost nothing to create, so anyone who can read the key can spend your plan within its receiver list. - **Rotate without downtime.** Create a second key, deploy it, then revoke the first. - **If a key leaks, revoke it** in the dashboard. Until you do, the damage stays within the receiver list and the daily limits you set. ### The complete file ```ts title="sponsor-relay.ts" snippet="examples/sponsor-relay.ts" /** * Pays for your users with a sponsor key: the server side of sponsor mode. * * A sponsor key is an API key with the `relay` scope, created in the dashboard under API keys. It * names the contracts it may pay for (the receiver allow-list is mandatory) and can carry daily * limits per sender and per key. Your server sends each transaction your user signed to * `POST /v1/relay` with the key in `X-Api-Key`, and your plan pays the network fee. On Builder and * up (`sponsor_any_sender`) one key pays for any sender, within those contracts and limits; the * answer's `billing.authMode` is then `api_key`. * * Today that covers senders whose keys your server holds: embedded or custodial wallets, game * servers, bots and agent fleets. `payForUser` submits a transaction your user's key already signed * for its assignment (see `send-token.ts`). `sponsor-call.ts` runs the whole action with the client * this file exports. * * The key is read from the environment, never written into code, and it never reaches a browser: * it lives on the server that runs this file. A missing key stops the server at start-up, rather * than sending relay requests the API would refuse one by one. The check sits inside the region the * site shows, so code copied from co-relayer.com runs as it is. * * CORELAYER_API_KEY the sponsor key: crk_live_… on mainnet, crk_test_… on devnet * * The API origin below is mainnet; use https://devnet-api.co-relayer.com with a devnet key. */ import { CoRelayerClient, type RelayRequest } from '@corelayer/sdk'; // On your server. The sponsor key never reaches a browser. const apiKey = process.env.CORELAYER_API_KEY; if (!apiKey) throw new Error('Set CORELAYER_API_KEY'); const corelayer = new CoRelayerClient({ baseUrl: 'https://api.co-relayer.com', apiKey, }); // Your user signed the transaction. Your plan pays the network fee. export async function payForUser(req: RelayRequest, actionId: string) { const { data } = await corelayer.relay(req, { intentKey: actionId }); return data.txHash; } /** The sponsor client, for `sponsor-call.ts`. */ export { corelayer }; ``` ### Related - [Paying for other senders](/plans/sponsoring-senders): sponsor keys next to named wallets, and how the payer of each transaction is decided. - [Authentication](/agents/auth): every credential and the routes that take it. - [Key handling](/security/keys): how CoRelayer stores and checks keys. - [Check before you send](/sdk/recipes/check-quota#in-sponsor-mode): the pre-send check for a server that relays with a sponsor key. - [Tiers](/plans/tiers): which plans include sponsor keys. --- ## Buying over x402 [x402](https://x402.org) 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. 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 ```mermaid sequenceDiagram autonumber participant A as Agent participant C as CoRelayer participant N as MultiversX A->>C: POST /v1/x402/purchase { payer, tierId, months } C-->>A: 402 + PAYMENT-REQUIRED (base64 PaymentRequired) Note over A: build the payment tx from accepts[0].extra,
sign once A->>C: POST /v1/x402/purchase + PAYMENT-SIGNATURE C->>C: verify quote MAC, re-check the dry run, renew the lease if needed C->>C: co-sign as relayer C->>N: broadcast N-->>C: final, success, contract event observed C-->>A: 200 + PAYMENT-RESPONSE (base64 SettlementResponse) ``` | 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 ```jsonc { "scheme": "exact", "network": "multiversx:1", "amount": "85000000", "asset": "USDC-c76f1f", "payTo": "", "maxTimeoutSeconds": 120, "extra": { "assetTransferMethod": "esdt", "paymentFlow": "upfront", "scFunction": "depositAndSubscribe", "arguments": ["0c", "01", "0510ff40", ""], "gasLimit": 40000000, "gasPrice": 1000000000, "chainId": "1", "relayer": "erd1…", "lease": "", "leaseExpiresAtMs": 1789000060000, "registryVersion": 7, "expectedNonce": 17, "quoteId": "q_01JA7M3Z9K", "quoteMac": "", "quoteExpiresAtMs": 1789000120000, "quote": { /* the full quote */ }, "dataTemplate": "ESDTTransfer@@@@@@[@]", "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](/sdk): type-checked, and run by the test suite against a stand-in that checks the payment transaction byte for byte and verifies its signature. ```ts title="x402-purchase.ts" snippet="examples/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; } 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 { 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 { const client = new CoRelayerClient({ baseUrl: context.api, ...(context.fetch === undefined ? {} : { fetch: context.fetch }), }); const sleep = context.sleep ?? ((ms: number) => new Promise((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>; 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, signal: AbortSignal | undefined, ): Promise { 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`: ```json { "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`](/errors/resign-required) and means the same thing: nothing was co-signed. #### 4. A payment identifier is required ```jsonc "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`: ```json { "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`](/errors/insufficient-sender-balance) | | `invalid_payload` | [`PAYMENT_INVALID`](/errors/payment-invalid) | | `invalid_payment_requirements` | [`QUOTE_EXPIRED`](/errors/quote-expired) | | `invalid_transaction_state` | [`NONCE_TOO_LOW`](/errors/nonce-too-low) | | `unexpected_settle_error` | [`SWAP_VENUE_PAUSED`](/errors/swap-venue-paused), [`PRICE_ABOVE_MAX`](/errors/price-above-max), [`DEPOSIT_BELOW_MIN`](/errors/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](/mcp/tools#paying-over-x402))