# CoRelayer > Gasless MultiversX transaction relaying (Relayed v3) sold as a USDC subscription. The user signs each transaction once; CoRelayer pays the EGLD network fee. This file contains all documentation content in a single document following the llmstxt.org standard. ## 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) --- ## 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. | --- ## CoRelayer documentation **Cooperative Relayer Infrastructure for Autonomous Transactions.** CoRelayer pays the MultiversX network fee for your transaction. You keep your keys, you sign your transaction exactly once, and you never have to hold EGLD to move a token, call a contract or let a program act on your behalf. You pay for the service in USDC, monthly, at a price you can read off the chain before you buy. Every page here describes what is **built and tested** — the contract, the API document, the SDK, the two web front ends and these docs. Where a thing is not running, the page says so instead of describing it as if it were. Nothing on this site reports a measured latency, an uptime figure or a transaction count before there is something to measure. ### Four ways in #### For people You have a wallet with USDC in it and no EGLD. Start at **[What CoRelayer does](/start/overview)**, then follow **[the quickstart](/start/humans-quickstart)** to buy a plan and send your first relayed transaction from a key your own program holds. Buying works from any MultiversX wallet. Sending from xPortal, the Web Wallet or a Ledger on other apps isn't supported yet: CoRelayer can't relay for you yet. ([FAQ](/start/faq#my-dapp-has-10000-users-which-plan)) #### For agents You are a program. Read **[the agents page](/agents)** — it is written for you, in one screen, with every discovery URL. Or take the whole site as text: [`/llms.txt`](pathname:///llms.txt) and [`/llms-full.txt`](pathname:///llms-full.txt), or append `.md` to any page URL on this site. #### For apps: pay for your users Your app pays the network fee, so your users never need EGLD. One sponsor key pays for every user your server signs for: **[Pay for your users](/sdk/recipes/sponsor-users)**. For wallets you name on chain, such as your bots or your treasury, see **[Sign and relay](/sdk/recipes/sign-and-relay)**. #### For integrators Start with **[the SDKs](/sdk)**, in TypeScript, Go, Rust and Python. The HTTP surface underneath is documented in **[the API reference](/api/overview)**, generated from the same OpenAPI document as the SDK types: [`/openapi.yaml`](pathname:///openapi.yaml) · [`/openapi.json`](pathname:///openapi.json). ### What it actually does, in five lines 1. You ask CoRelayer which relayer serves your address. You get one address and a short-lived lease. ([Assignment](/concepts/relayers)) 2. You build your transaction with that relayer in it and **sign it once**. ([One signature](/concepts/one-signature)) 3. CoRelayer checks the transaction, co-signs it as the relayer, and broadcasts it. ([Relayed v3](/concepts/relayed-v3)) 4. The relayer's EGLD pays the fee. Your account nonce moves; your balances do not, beyond what your own transaction does. ([What we can and cannot do](/security/overview)) 5. The cost is counted in [Relay Units](/concepts/relay-units) against the plan you bought with USDC. ```mermaid sequenceDiagram autonumber participant You participant CoRelayer participant Chain as MultiversX You->>CoRelayer: Which relayer serves me? CoRelayer-->>You: relayer address + 60 s lease Note over You: sign once You->>CoRelayer: signed transaction + lease CoRelayer->>CoRelayer: validate, reserve Relay Units, co-sign CoRelayer->>Chain: broadcast Chain-->>CoRelayer: included, executed CoRelayer-->>You: intent id, tx hash, state ``` ### The parts of this documentation | Section | What is in it | |---|---| | [Guides](/start/overview) | The product explained for a reader: what gasless means, what you buy, what a Relay Unit is, how the price is set. | | [Agents](/agents) | The same product for a machine: discovery files, auth, purchase, relay, error handling. | | [API](/api/overview) | Every route, generated from the OpenAPI document, plus the conventions that apply to all of them. | | [Contract](/contract/overview) | The on-chain half: what it stores, what it can be asked to do, who may ask. Generated from the contract's own build output. | | [Errors](/errors) | One page per error code, at exactly the URL the API puts in the `type` member of its problem documents. | | [Changelog](/changelog) | Dated entries, with RSS, Atom and JSON feeds. | ### Where the numbers on this site come from Every figure on these pages is either generated from a source in this repository or labelled with its date and its source: - prices and tiers come from the same snapshot module the pricing page renders, and every page that shows them names the live endpoint (`GET /v1/pricing`) and the date the snapshot was taken; - request and response shapes come from [`/openapi.yaml`](pathname:///openapi.yaml); - contract endpoints, views, events and types come from [`/abi/corelayer.abi.json`](pathname:///abi/corelayer.abi.json), which is the contract's own build artefact; - error codes come from the `ErrorCode` enum of the API document, and the build fails if a code exists without a page. Nothing here is a projection, a benchmark or a marketing number. --- ## 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)) --- ## SDKs The CoRelayer API is plain HTTPS and JSON, so you can call it with `curl` or any HTTP client. A client library takes care of the parts that are easy to get wrong: failover between hosts, error handling and relaying a transaction with a single signature. ### Pick a package | Language | Package | What it needs | |---|---|---| | [TypeScript and JavaScript](/sdk/javascript) | `@corelayer/sdk` | TypeScript 5.9 or later, and a global `fetch` (browsers, Node). No runtime dependencies. | | [Go](/sdk/go) | `github.com/Co-relayer/corelayer/packages/sdk-go` | Go 1.24 or later. Standard library only. | | [Rust](/sdk/rust) | `corelayer-sdk` | Rust 1.87 or later, on tokio. | | [Python](/sdk/python) | `corelayer-sdk` | Python 3.11 or later, and httpx. Synchronous and asyncio clients. | Each package stands on its own. You install the one for your language and nothing else. :::note[Not published yet] None of the four has a release yet: they are not on npm, crates.io or PyPI, and the Go module has no tagged version. Each page gives the install command for when it is. Until then, the API is plain HTTPS: [Pay for your users](/sdk/recipes/sponsor-users#without-an-sdk) shows a whole relay with no SDK, and [`/openapi.yaml`](pathname:///openapi.yaml) describes every route. ::: The whole API is described in [`/openapi.yaml`](pathname:///openapi.yaml). For another language, you can generate a client from it. ### What every SDK does for you - Fails over to the regional direct hosts when you give it their list. If the main host does not answer in time (1,500 ms by default), cannot be reached, answers with a 5xx, sends a 2xx that is not JSON, or sends an answer larger than the size limit, the same request bytes go to the next host. A 4xx is an answer, so it is never retried on another host. - Raises a typed error for every error answer, with the whole problem document. An error code the SDK does not know yet still arrives with its status and its `retryable` flag. - Relays a transaction with one signature. It gets a relayer assigned, runs your relayer check, builds the transaction, asks your wallet to sign it once and submits it. It never asks for a second transaction signature on its own. When the client has no native-auth token for the sender, you give it a proof signer, and it asks that signer to sign a short presence-proof message for each assign call, including the renewal of an expired lease. - Pays for your users from your server: a client created with a sponsor key relays each transaction a user signed, and your plan pays. ([Pay for your users](/sdk/recipes/sponsor-users)) - Checks the relayer before you sign: it reads the relayer's state from the CoRelayer contract through a MultiversX gateway you choose, and checks that the relayer is in the sender's shard. - Gives you a method for every operation of the API, with types for every request and response. - Walks paged lists page by page, reads the event streams, and streams CSV exports without holding them in memory. - Checks the signature and the timestamp of a webhook delivery, and reads and writes the x402 payment headers. That relayer check is the only call an SDK makes to anything other than the CoRelayer API. No SDK computes Relay Units for you: ask `POST /v1/quote`, or use the [formula](/concepts/relay-units). To tell us which app is calling, add a `CoRelayer-Client: /` header through the client's headers option. The API logs it and never refuses a request because of it. ### Runnable examples The TypeScript programs in the [recipes](/sdk/recipes/sign-and-relay) and guides come from the `examples/` folder of the docs site. A build step copies each file into the pages that show it, so every page shows the tested code. The test suite type-checks every file against the API types and runs it against an in-memory stand-in for the API and for a MultiversX gateway, which checks the Ed25519 signatures the examples produce. It uses no network and no real key. | File | What it does | |---|---| | `wallet.ts` | A signer for a program that holds its own key, built on `@multiversx/sdk-core`: the presence-proof message, and one signature per transaction. | | `gateway.ts` | Two reads from a MultiversX gateway that CoRelayer does not run: an account nonce and a contract view. | | `verify-relayer.ts` | The relayer check: same shard, then `getRelayerState` on the contract you pinned, cached per chain ID, registry version and relayer. | | `send-token.ts` | Sends a token without holding EGLD: build, prove presence, get a relayer, check it, sign once, relay. It also shows how to sign again when the API asks. | | `sponsor-relay.ts` | Pays for your users: your server relays a transaction a user signed, with a sponsor key read from the environment. | | `sponsor-call.ts` | One sponsored action end to end: the user's presence proof, the relayer check, one signature and the relay, for a call to an allow-listed contract. | | `sponsor-http.ts` | The same sponsored action with no SDK: three HTTPS calls, the relayer check and one signature. | | `preflight.ts` | Quota and quote before you sign: will the transaction be served, who pays, and how many units it costs. | | `pick-tier.ts` | Picks a tier from the live pricing document. | | `buy-plan.ts` | Buys a plan on chain without holding EGLD, after checking the contract, the price ceiling and the relayer. | | `x402-purchase.ts` | Buys a plan over HTTP 402. | | `watch-intent.ts` | Follows an intent to its outcome, by polling or over the event stream. | | `retry.ts` | Retries what the server marked retryable, after the delay the server asked for. | | `relay-units.ts` | The Relay Unit formula. | | `agent-quickstart.ts` | All of the above as one program: discover → check → buy → relay → follow. | Each page shows the whole file it uses, so you can copy it next to your own code. The files need `@corelayer/sdk` and `@multiversx/sdk-core`, and run on Node 24. The Go, Rust and Python code on [Pay for your users](/sdk/recipes/sponsor-users) and in the language guides comes from each SDK's own example file (`example_site_test.go`, `examples/site_snippets.rs`, `examples/site_snippets.py`), which that SDK's test suite compiles or runs. --- ## 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)) --- ## 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. --- ## 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. --- ## Dashboard tour `app.co-relayer.com` is the dashboard. It is a single-page application that talks only to the CoRelayer API — it never contacts a chain node directly, and it holds no key. Your wallet signs; the dashboard builds the transactions to be signed and shows you what happened afterwards. This page is a written tour rather than a gallery of screenshots. Screenshots of an application that has nothing to connect to would show empty states, which teaches nothing. ### Getting in | Screen | What happens | |---|---| | **Connect** | Choose a wallet. The dashboard learns your address and nothing else. Public screens work from here. | | **Welcome** | Shown once, for an address with no plan: what the service does, what a Relay Unit is, and the way to the plan picker. | Signing in properly — a native-auth login — happens the first time you open a screen that shows data only you should see. That is a signature over a short-lived message, not a transaction: it costs nothing and moves nothing. ### The screens #### Overview The answer to "am I able to send transactions right now". It shows the active plan, Relay Units used against the cap, the period end, whether pay-as-you-go is on, and any notice the account has not acknowledged — a scheduled tariff change, a relayer draining, a lapsed renewal. #### Plan The tier ladder with your current plan marked, and what changing to another one would do. Prices come from the contract through `GET /v1/pricing`; the page states the tariff they were computed from. Checkout is a separate screen because it builds a transaction and asks for a signature. The plan's own controls live here too: **auto-renew** (with the price ceiling you sign), **pay-as-you-go** and its escrow budget, and **releasing the escrow** back to credits. Each one that changes on-chain state builds a transaction, and the screen says so before you press it. #### Billing and Deposit Credits, purchases and the escrow. **Deposit** builds the USDC transfer that turns money into credits. Every purchase you have ever made is listed here, taken from the contract's own events, so the list cannot disagree with the chain. #### Usage and Transactions **Transactions** is the per-transaction view: one row per relayed transaction, with the intent state, the Relay Units it cost, the relayer that carried it and the time each stage took. A row opens into the full timeline of that one transaction. **Usage** is the aggregate: units per day, split between plan cap and pay-as-you-go, and a CSV export for accounting. Totals come from a counter, not from counting pages, so the numbers do not drift as the history grows. #### Latency What the service did for *your* transactions: the time from your submission to co-signature, to first gateway acknowledgement, and to inclusion. It is a record of what happened, not a promise of what will happen — see [Status and SLOs](/operations/status-and-slos) for why this site publishes no latency target. #### Relayers The relayers that served your account, and their current registry state. You can verify any of them against the chain yourself; the page tells you which view to call. #### Senders Named wallets: the addresses you know in advance that your plan pays for besides your own. Adding one is an on-chain change — the contract enforces the named-wallet limit of the plan you bought — so this screen builds a transaction for you to sign. Removing one is free. To pay for your users without listing them, use a sponsor key instead. #### API keys Sponsor keys: from Builder up, a key on your server pays for your users' transactions, only for the contracts you list and within the daily limits you set. A key with only the `read` scope serves headless reads. A key is shown **once**, at creation. Keys cannot create, change or revoke other keys: that always needs your wallet. See [Pay for your users](/sdk/recipes/sponsor-users) and [Key handling](/security/keys). #### Notifications The notice inbox: every notice the account received, such as a scheduled price change, a skipped renewal or a cap threshold, newest first, marked read up to where you choose. New notices arrive while the screen is open. Your server can read the same notices from the notice feed or follow them on the account stream ([Do not poll the cap](/sdk/recipes/check-quota#do-not-poll-the-cap)). The screen also keeps e-mail and webhook preferences. This deployment does not deliver webhooks or e-mail: registering a webhook endpoint answers `503` with `reason: WEBHOOK_DELIVERY_NOT_AVAILABLE`, and no e-mail is sent. The inbox, the notice feed and the stream carry every notice. #### Incidents Anything the service has declared, with its own history. The public version of the same data is on the status site, which is deployed separately so that it survives an outage of the API. #### Settings Appearance (theme), the session — who is signed in and until when — the data export, and the facts of the build you are looking at: its environment, chain id and the API host in use. Nothing on Settings changes on-chain state. ### What the dashboard never does - It never signs for you, and it never asks for a second signature for one action. ([One signature](/concepts/one-signature)) - It never invents a number. Every figure is a response from the API or a value read from the chain; where a value is a snapshot, the screen carries the date it was taken. - It never silently retries a transaction with a new signature. If the service needs one, it asks, and it tells you what the previous attempt did — which, in that specific case, is nothing. --- ## Frequently asked questions ### Do you hold my tokens? No. CoRelayer never takes custody of anything you own. You sign a transaction; we add a second signature that makes us the fee payer; the network does the rest. There is no approval step, no allowance and no deposit of your assets. The only money you send us is the USDC that buys a plan, and that goes to the contract, not to a wallet of ours. ### Can you change my transaction? No. Your signature covers every field of the transaction, including the receiver, the amount, the data, the gas limit and the relayer address. Change one byte and the signature is worthless. Our signature is a **second, separate** signature over the very same bytes. What a relayer can do is delay a transaction or decline to broadcast it. That is why the relayer that will carry it is named to you before you sign, and why you can check its state in the registry on chain, through a gateway that is not ours. ([What we can and cannot do](/security/overview) · [Verify a relayer](/concepts/verify-a-relayer)) ### Do I need EGLD at all? No. You need USDC to buy a plan, and nothing else. The network fee is paid by the relayer in EGLD, and the screen where you sign shows it as paid by CoRelayer. Buying the plan is itself one of the calls CoRelayer pays for. ### What are the limits on a relayed transaction? A gas price at most twice the network minimum, at most 4,096 bytes of data, and at most 25 Relay Units for one transaction (15 on Starter and Agent Metered). A transaction outside those bounds is refused before anything is signed, and the refusal names the limit. ([Limits](/operations/limits)) ### Can you send my transaction twice? The protocol prevents it. A transaction occupies one `(sender, nonce)` slot, and once that nonce has executed, another transaction carrying it is rejected by the network itself. Re-broadcasting identical bytes is harmless: it is the same transaction, with the same hash. ([Delivery guarantees](/concepts/delivery-guarantees)) ### What if my transaction fails on chain? You are charged. A transaction that reverts still costs the relayer the full fee — the network does not give it back — so a failed transaction consumes its Relay Units exactly like a successful one. What is *not* charged is a transaction that was never executable at all: a nonce that was already used, a fee the network refused. Those cost nothing because they cost the relayer nothing. ### Are plans refundable? No, and the contract has no path that would make them refundable. USDC that enters becomes credits; credits buy plans. This is stated plainly rather than buried: buy the size you will use, and use [pay-as-you-go](/plans/payg) rather than a bigger tier if your volume is spiky. When you turn pay-as-you-go off, the unspent escrow goes back to your credits once the last usage is settled. That release is an endpoint the contract can never switch off. Credits themselves stay non-refundable: nothing goes back to your wallet. ### What happens when my plan runs out? If pay-as-you-go is off, relays stop with [`QUOTA_EXHAUSTED`](/errors/quota-exhausted) — a 429 with no `Retry-After`, because waiting does not help. If pay-as-you-go is on, relays continue and are billed per unit at your plan's pay-as-you-go price until the escrow behind it is empty. ### Why is there a relayer *per shard*? MultiversX is sharded, and the fee payer of a relayed transaction has to be in the same shard as the sender. So there is not one relayer, there is a set, and the right one for your address is picked deterministically. ([Shards and routing](/concepts/shards-and-routing)) ### What if the relayer you gave me disappears before it signs? Then nothing was sent, and you are asked to sign once more for a different relayer, on the **same nonce**. That is the only situation in which you are ever asked for a second signature, and you are always told that the first attempt produced nothing. ([The re-sign case](/sdk/recipes/handle-resign)) ### Can I pay for all my users? Yes, from Builder up, and on Agent Pro and Agent Fleet. A sponsor key on your server pays for any sender, with no list to keep and no fee per user: your plan limits transactions, not users. The key pays only for the contracts you list, with optional daily limits per user. It covers senders whose key your server holds or reaches, such as embedded or custodial wallets, game servers, bots and agent fleets. ([Pay for your users](/sdk/recipes/sponsor-users)) ### My dApp has 10,000 users. Which plan? Size it by transactions, not users. A sponsor key covers any number of wallets your server runs, from Builder up, such as embedded or custodial wallets; the plan limits transactions, not users. Estimate the transactions per user in 30 days, multiply by your users, and pick the plan whose included units cover it. Pay-as-you-go absorbs the peaks. Named wallets are for the few addresses you know in advance, such as your team's. If your users sign in their own wallet, such as xPortal, the Web Wallet, the browser extension or a Ledger, CoRelayer can't relay for them yet. ([Which senders a sponsor key covers](/sdk/recipes/sponsor-users#which-senders-this-covers) · [Tiers](/plans/tiers)) ### Can someone else use my plan? Yes, in two ways. A sponsor key on your server pays for any sender whose key your server holds or reaches, within the contracts and daily limits you set, from Builder up. Its receiver allow-list is mandatory, so a leaked key cannot be pointed at a contract of the thief's choosing. Or you list a few addresses on chain as named wallets, up to the count your plan includes. ([Paying for other senders](/plans/sponsoring-senders) · [Key handling](/security/keys)) ### What do you learn about me? Everything we relay is public: it goes on a public chain, signed by you. On top of that we keep what a billing system has to keep — which account paid for which transaction, when, and how many Relay Units it cost. We do not ask for a name, and the login is a wallet signature, not an account with a password. ### Do I have to use the SDK? No. The SDK is a typed convenience over an ordinary HTTPS API; every route is documented and callable with `curl`. What the SDK adds is the transport failover, the typed errors and a relay helper that structurally cannot ask you to sign twice. ([SDK](/sdk)) --- ## Quickstart for people This is the path from "I have USDC and no EGLD" to "my transaction is on chain". You buy the plan from any MultiversX wallet. You send from a key your own program holds, or with the dashboard's test transfer to yourself. It takes four steps: one signed transaction to buy the plan, then one signature for each transaction you send. :::note[Which wallets can send today] Buying works from xPortal, the Web Wallet, the browser extension and a Ledger. Sending through CoRelayer works from a key your own program holds, and from the dashboard's test transfer. If you sign in xPortal, the Web Wallet, the browser extension or a Ledger on another app, CoRelayer can't relay for you yet, so don't buy a plan for that. ([FAQ](/start/faq#my-dapp-has-10000-users-which-plan)) ::: ### What you need | | | |---|---| | A MultiversX wallet, to buy the plan | xPortal, the Web Wallet, the browser extension or a Ledger. | | A key your own program holds, to send | The program signs each transaction with it (step 4). The dashboard's test transfer needs none. | | USDC on MultiversX | The token identifier of the network you are on. On mainnet that is `USDC-c76f1f`; on devnet, `USDC-350c4e`. The [pricing endpoint](/api/overview) states which one applies. | | EGLD | **None.** That is the point. | ### 1. Connect Open `app.co-relayer.com` and connect your wallet. Connecting asks your wallet to sign one login message; it costs nothing and is not a transaction. That signature is what lets the dashboard read what is yours alone — your usage history, your API keys, your notification settings. Connecting tells the dashboard your address. From it, the dashboard shows which shard you are in, whether you have a plan (from the chain), and how many transactions are left in the current period (from CoRelayer's usage records). ### 2. Pick a plan The **Plan** screen lists every tier that is currently purchasable, with its monthly cap in Relay Units, its price in USDC and its rate class. The numbers come from the contract, not from a price list we keep in a file — see [Tiers](/plans/tiers) for the ladder and [Tariff](/plans/tariff) for the single value everything is derived from. Before you buy, know how the price works and that a purchase cannot be refunded. The price is `price_units × tariff`, and the tariff is one number on chain. If it ever goes up, the contract gives 48 hours of notice, and what you have already paid for does not change. USDC that enters the contract becomes credits, and credits are spent on plans. Credits cannot be withdrawn, so buy the size you will use. ### 3. Pay Checkout builds one transaction: a USDC transfer to the CoRelayer contract that carries the tier id, the number of months and a maximum price you are willing to pay. You sign it once. You can pay without holding any EGLD. Buying a plan is on the free list, so CoRelayer pays the fee for that transaction. The transaction also says the most you will pay: if the price moves between the quote and the block, the contract reverts instead of charging you more, and you see [`PRICE_ABOVE_MAX`](/errors/price-above-max) with a fresh quote. When the transaction executes, the contract converts your USDC, credits your account and writes the plan block that fixes your cap, your period length, your pay-as-you-go price and the number of wallets you can name, for as long as that block lasts. ### 4. Send your first relayed transaction **From the dashboard**, send a test transfer to yourself: the **Overview** screen offers it while the account has sent nothing yet. It needs no EGLD, and it shows on the **Transactions** screen once the chain has executed it. **From your own code (a program key)**, it is one call. This is the relay step of the site's tested example, for a program that holds its own key: ```ts title="send-token.ts" snippet="examples/send-token.ts#relay" const result = await relayOnce({ client, sender: wallet.address, // Every assign call gets a fresh presence proof: the first one, and the renewal when the lease // expires while the wallet is signing. A proof is accepted only once. signProof: (message) => wallet.signProofMessage(message), intentKey: request.intentKey, ...(signal === undefined ? {} : { signal }), buildTransaction: (assignment) => forAssignment(call, nonce, assignment, chainId), // The single signature. The relayer is checked on chain first; if it is not an active // CoRelayer relayer in your shard, nothing is signed. signOnce: async ({ assignment, transaction }) => { await relayers.verify(wallet.address, assignment.relayer, assignment.registryVersion, signal); return wallet.signTransaction(transaction); }, }); ``` `relayOnce` has your wallet sign a presence proof (a short message, not a transaction), asks CoRelayer for a relayer in your shard, builds your transaction with that relayer in it, checks the relayer against the contract, asks your wallet to sign the transaction once, and submits it. If the lease expires while you sign, it has your wallet sign one more presence proof to renew the lease, never a second transaction. The whole file, including the transfer it sends and the check that you are on the network you meant, is in [Sign and relay](/sdk/recipes/sign-and-relay). Building an app that pays for its users? Your server relays each transaction with a sponsor key: [Pay for your users](/sdk/recipes/sponsor-users). ### What you should see afterwards | Where | What | |---|---| | The **Overview** screen | Relay Units used against your cap, and the day the period rolls over. | | The **Transactions** screen | One row per relayed transaction: hash, state, how many Relay Units it cost, and how long each stage took — with a CSV export. | | The **Usage** screen | The same rows aggregated over the period. | | The explorer | Your transaction, with your signature and the relayer's, and a fee paid by the relayer. | ### When something goes wrong Every failure is an [RFC 9457 problem document](/errors) whose `type` is the address of a page on this site. The three you are most likely to meet first: | Code | What it means | What to do | |---|---|---| | [`NO_ENTITLEMENT`](/errors/no-entitlement) | No plan, or the plan has lapsed. | Buy or renew one. | | [`QUOTA_EXHAUSTED`](/errors/quota-exhausted) | The cap is used up and pay-as-you-go is off. | Turn on [pay-as-you-go](/plans/payg) or move up a tier. | | [`RESIGN_REQUIRED`](/errors/resign-required) | The relayer became unavailable **before** anything was co-signed. Nothing was sent. | Sign once more, on the same nonce, for the new relayer. The dashboard asks you; it never does this silently. | ### Next - What the dashboard shows you, screen by screen: [Dashboard tour](/start/dashboard-tour) - Questions people ask first: [FAQ](/start/faq) - Why a heavy transaction costs more than one unit: [Relay Units](/concepts/relay-units) --- ## What CoRelayer does On MultiversX, every transaction costs a fee, and that fee is paid in EGLD. This is a small thing until it is the only thing standing between you and a transaction: you hold USDC, you want to send USDC, and you cannot, because you do not have the network's own token. CoRelayer removes that requirement. A **relayer** — a wallet we operate — pays the fee for your transaction. You still sign it, it is still your transaction, it still moves only what you told it to move. What changes is who is charged for the network fee. ### Who pays what | | Pays | In | |---|---|---| | You, or the app you use if it pays for its users | the plan | USDC, per 30 days, on chain | | CoRelayer | the network fee of every transaction we relay | EGLD, from the relayer wallet | | The network | nothing — it collects the fee from the relayer | | There is no allowance to grant, no token to approve and no custody step. The only thing you hand over is a transaction you signed yourself. If you build an app, your plan can pay for your users as well. From Builder up, one sponsor key on your server pays for the transactions of every user whose key your server holds or reaches (embedded or custodial wallets, game servers, agent fleets), with no list of wallets to keep; the plan limits transactions, not users. It pays for calls to the contracts you list, not for payments to arbitrary addresses. ([Pay for your users](/sdk/recipes/sponsor-users)) ### The sixty-second mental model 1. **You buy a plan.** A plan is a number of [Relay Units](/concepts/relay-units) per month, paid for in USDC by a transaction to the CoRelayer contract. The contract turns your USDC into credits and into a plan block that records exactly what you bought. 2. **You ask for a relayer.** Because MultiversX is sharded, the relayer has to be in the same shard as you. CoRelayer picks one and hands you its address plus a short-lived **lease** — a token that says "this relayer will co-sign for you for the next sixty seconds". 3. **You sign once.** The relayer address is one of the fields your signature covers, so it cannot be swapped afterwards. There is no second variant and no second signature. ([Why one signature](/concepts/one-signature)) 4. **We co-sign and broadcast.** The relayer adds its own signature over the *same bytes* and the transaction goes to the network. 5. **We count it.** The transaction's worst-case fee is converted into Relay Units and taken off your plan. ```mermaid flowchart LR A["You hold USDC
and no EGLD"] --> B["Buy a plan
(USDC on chain)"] B --> C["Ask for a relayer
address + lease"] C --> D["Sign one
transaction"] D --> E["CoRelayer co-signs
and broadcasts"] E --> F["Relayer's EGLD
pays the fee"] F --> G["Relay Units
come off your plan"] ``` ### What this is good for - **Wallets and apps** that do not want a new user's first experience to be "go and buy EGLD somewhere else first". One plan and one sponsor key pay for every user whose key your server holds or reaches (embedded or custodial wallets, game servers, agent fleets), for calls to the contracts you list; the plan limits transactions, not users. A wallet whose users pay each other sends to people's addresses, which a sponsor key can't cover: send those payments from wallets you name, or let each user hold a plan of their own. ([Paying for other senders](/plans/sponsoring-senders#payments-to-people)) - **Businesses** that pay in a stable unit. A plan costs a known number of USDC per month; the EGLD price moves against us, not against you, for the period you paid for. - **Programs and agents** that need to transact without a human topping up a gas balance. An agent can discover the price, buy a plan or pay per request, and relay — without a person in the loop. ([For agents](/agents)) ### What this is not - It is **not custody**. We never hold your tokens and we cannot move them. - It is **not a wallet**. You bring your own keys; we never see a private key of yours. - It is **not a way to send a transaction you could not otherwise send**. Every rule of the protocol still applies: your nonce, your balance, the contract you call. - It is **not free**. The relayer spends real EGLD on your behalf, which is what the subscription pays for. ### The honest limits - A relayer can be **slow** or **decline**. That is the real risk of this design, and it is why the relayer is named to you before you sign and why you can check its state on chain yourself. ([Security](/security/overview)) - A transaction whose worst case is large costs more than one Relay Unit. The unit is defined so that an ordinary transfer or contract call is exactly one. ([Relay Units](/concepts/relay-units)) - Service measurements appear on this site only once there are measurements to show. ([Status and SLOs](/operations/status-and-slos)) ### Next - Buy a plan and send something: [Quickstart for people](/start/humans-quickstart) - Understand the protocol underneath: [Relayed v3](/concepts/relayed-v3) - See what a plan costs: [Tiers](/plans/tiers) --- ## Delivery guarantees The unit of work is an **intent**: one user-signed transaction we were asked to relay. Its key is `(sender, nonce)` — never the transaction hash, because the hash is an attribute of one attempt and the slot is the thing that can only be used once. ### The states ```mermaid stateDiagram-v2 [*] --> ASSIGNED: lease issued ASSIGNED --> RECEIVED: POST /v1/relay RECEIVED --> REJECTED: any validation failure RECEIVED --> ACCEPTED: checks pass, units reserved ACCEPTED --> REJECTED: signer refuses ACCEPTED --> COSIGNED: relayerSignature released COSIGNED --> BROADCAST: a gateway acknowledges BROADCAST --> EXECUTED_OK: included, succeeded BROADCAST --> EXECUTED_FAIL: included, reverted COSIGNED --> EXPIRED_LOCALLY_STILL_VALID: our window closed EXPIRED_LOCALLY_STILL_VALID --> COSIGNED: re-broadcast EXECUTED_OK --> [*] EXECUTED_FAIL --> [*] BROADCAST --> DEAD: the slot was taken by another hash REJECTED --> [*] DEAD --> [*] ``` | State | What it tells you | |---|---| | `ASSIGNED` | You hold a lease. Nothing exists on our side. | | `RECEIVED` | We have your bytes and are checking them. | | `REJECTED` | Terminal. **No signature was produced and nothing was sent.** | | `ACCEPTED` | Checks passed, Relay Units reserved, about to be co-signed. | | `COSIGNED` | Past the commit point. The transaction exists. | | `BROADCAST` | A gateway took it. | | `EXECUTED_OK` | On chain, succeeded. | | `EXECUTED_FAIL` | On chain, reverted. **Charged** — see below. | | `EXPIRED_LOCALLY_STILL_VALID` | We stopped re-broadcasting. The transaction is *still valid* and may yet execute. Non-terminal on purpose. | | `DEAD` | The nonce was consumed by a different transaction. Ours can never execute; the reservation is released. | ### The commit point The commit point is the moment a relayer signature leaves the signer process. It divides the whole system: **Before it** nothing exists that can execute. Every validation failure, every entitlement problem, every rate limit, every "no relayer available" lives here. A client that receives an error has a guarantee: nothing was sent. **After it** the transaction exists in the world and, by the protocol, has no expiry. So after the commit point the API stops returning errors and returns *states*: `200` when a gateway has taken it, `202` when it is co-signed but not yet acknowledged. "Failed" is not an available answer, because it would not be true. This is the property to design your client around. An error from `POST /v1/relay` always means the transaction was not sent. ### Never sent twice Three mechanisms, in layers, each closing what the previous one cannot see. #### The protocol At most one transaction per `(sender, nonce)` executes, and identical bytes re-broadcast any number of times are *the same transaction*. Re-broadcasting is therefore not a risk; it is the correct response to uncertainty. #### The natural key `POST /v1/relay` is idempotent on `(sender, nonce)` plus a hash of the signed bytes: | You send | You get | |---|---| | Identical bytes again | The stored response, marked `duplicate: true`. No second reservation, no second broadcast decision. | | Different bytes, same nonce, first still alive | [`NONCE_IN_FLIGHT`](/errors/nonce-in-flight) — unless you explicitly asked to cancel or replace. | This works across hosts because Ed25519 signing is deterministic and the ledger has a uniqueness constraint on the slot. #### The logical key The natural key cannot see one specific mistake: a client that times out, assumes failure, and signs a *fresh* transaction at nonce `n+1` for the same business action while `T(n)` is still alive. Different nonce, different bytes — both valid, both will execute. So there is an optional logical key, one per user action: the `Idempotency-Key` header, or `intentKey` in the body. ```http POST /v1/relay Idempotency-Key: checkout-7f2c0a41 ``` Same key, different bytes or nonce, while the first intent is not `DEAD` → [`INTENT_ALREADY_SUBMITTED`](/errors/intent-already-submitted), carrying the first intent so you can look at it instead of guessing. Once the first intent is `DEAD`, the key is free again. The key can only ever **reject**; it can never disagree with the natural key. The SDK, the MCP server and the dashboard set one automatically, one per user action. ### When something fails Three cases, three different answers. None of them asks the user to sign the same thing twice. | What fails | What happens | |---|---| | A gateway is unreachable | The identical signed bytes go to another gateway. Nothing is rebuilt, so there is nothing new to sign and nothing new that could execute. | | The relayer runs low on EGLD | A balance watcher keeps every assignable relayer above a floor. A relayer below it stops being assignable before it stops being able to pay, and topping it up lets the transaction already signed go through. | | The relayer is lost before it co-signs | Only then does the API answer that a new signature is needed: one signature over the same account nonce, so the old transaction and the new one can never both execute. ([Handle a re-sign](/sdk/recipes/handle-resign)) | ### What to do on a timeout ``` On a timeout or a dropped connection from POST /v1/relay: → re-send the identical bytes, or → GET /v1/intents/{sender}/{nonce} Never rebuild. Never re-sign. ``` `GET /v1/intents/{sender}/{nonce}` is the authoritative read. It is answered correctly by any host, including one that did not handle your submission. ### What gets charged | Outcome | Relay Units | |---|---| | Executed, succeeded | Charged, at the weight of the executed transaction. | | Executed, reverted | **Charged.** The network charges the relayer the full gas limit for a failed execution and refunds nothing. | | Rejected before co-signing | Nothing. There was no cost. | | Not executable at all — nonce already used, fee refused | Nothing. The network charges nothing for these. | | `DEAD` — the slot was taken by another transaction | Nothing. The reservation is released. | | Co-signed, still waiting | Reserved, not yet charged. | Units are reserved at the **worst case** before co-signing, and settled at the real weight after execution. That is why a transaction with a needlessly high gas limit costs you more than it should — the reservation has to assume the network will take all of it. ([Relay Units](/concepts/relay-units)) ### The state that surprises people `EXPIRED_LOCALLY_STILL_VALID` means: we have stopped actively re-broadcasting, but your transaction is a perfectly valid transaction that any node may still include. It is **not** a failure and it is **not** terminal. The honest answer at that point is "unknown", and the wrong answer would be to tell you it failed and let you sign a replacement — which is precisely how two transactions for one intention get created. From that state, the safe moves are: wait, re-send the identical bytes, or explicitly cancel or replace the slot with a pinned nonce. ### Watching an intent | Way | Good for | |---|---| | `GET /v1/relay/{id}/events` | Server-sent events, one per state change. Closes when the intent is final. | | `GET /v1/intents/{sender}/{nonce}` | A definitive answer at any moment, from any host. | | `GET /v1/relay/{id}` | A resolver that takes either an intent id or a transaction hash. | | The account stream | Everything for one account on one connection. | `POST /v1/relay` itself never waits. It answers at the commit point or at its acknowledgement deadline, because a held-open request would turn every slow block into an ambiguous client timeout — which is the exact condition that makes clients re-sign. ([Watch an intent](/sdk/recipes/watch-an-intent)) --- ## The intent lifecycle An **intent** is one user-signed transaction CoRelayer was asked to relay, keyed by `(sender, nonce)`. This page follows it from the moment a lease is issued to the moment the chain decides — and through the recovery steps in between. [Delivery guarantees](/concepts/delivery-guarantees) covers what those states promise; this page covers how an intent moves between them. ### The state machine ```mermaid stateDiagram-v2 [*] --> ASSIGNED: POST /v1/relay/assign ASSIGNED --> RECEIVED: POST /v1/relay RECEIVED --> REJECTED: validation fails RECEIVED --> ACCEPTED: checks pass, units reserved ACCEPTED --> REJECTED: signer refuses (reservation released) ACCEPTED --> COSIGNED: commit point COSIGNED --> BROADCAST: first gateway acknowledgement BROADCAST --> EXECUTED_OK: in a block, succeeded BROADCAST --> EXECUTED_FAIL: in a block, reverted COSIGNED --> EXPIRED_LOCALLY_STILL_VALID: broadcast window closed BROADCAST --> EXPIRED_LOCALLY_STILL_VALID: broadcast window closed EXPIRED_LOCALLY_STILL_VALID --> COSIGNED: same bytes again (new window) EXPIRED_LOCALLY_STILL_VALID --> EXECUTED_OK: executed after all COSIGNED --> DEAD: slot consumed by another hash BROADCAST --> DEAD: slot consumed by another hash EXPIRED_LOCALLY_STILL_VALID --> DEAD: slot consumed by another hash REJECTED --> [*] EXECUTED_OK --> [*] EXECUTED_FAIL --> [*] DEAD --> [*] ``` | State | Held where | Moves on | |---|---|---| | `ASSIGNED` | Only by you: a lease in hand. The server keeps nothing and reserves nothing. | Your `POST /v1/relay`. | | `RECEIVED` | The API, while it checks your bytes. | Any failed check → `REJECTED`. | | `ACCEPTED` | Units and relayer exposure reserved, about to be co-signed. | The signer's answer. | | `COSIGNED` | The **commit point** has passed: the relayer signature exists. | A gateway acknowledgement, a lost slot, or the end of the broadcast window. | | `BROADCAST` | At least one gateway returned our hash. `includedInBlock` fills in once a block carries it. | Chain facts. | | `EXECUTED_OK` / `EXECUTED_FAIL` | On chain. `final` turns `true` when the block is final — that is the billing trigger. | Nothing: terminal. A revert before finality moves it back to `BROADCAST`. | | `EXPIRED_LOCALLY_STILL_VALID` | Not terminal. We stopped re-broadcasting; the transaction is still valid. | Same bytes, Cancel, Replace, or chain facts. | | `DEAD` | The sender's nonce moved past `n` at a final block and our hash is not the one that executed. | Nothing: terminal. Reservation released. | | `REJECTED` | Nothing was co-signed. The problem document says why. | Nothing: terminal. | Two rules decide terminal states, and both are about honesty rather than convenience: - **Terminal states come from chain facts keyed by `(sender, nonce)`,** never from a hash lookup alone and never from a timeout. A timeout is "unknown", always. - **`DEAD` needs two independent sources and a second look** 6,000 ms later (ten rounds). A reconciliation that later finds our hash did execute turns `DEAD` into `EXECUTED_*` and marks the intent `corrected: true`; the ledger still bills exactly one row for the slot. ### The broadcast window After the commit point the service re-broadcasts the **identical bytes** only on evidence of loss — the transaction is missing from the sender's pool view while the on-chain nonce is still `n` — checking at 1,800, 3,600, 7,200, 15,000, 30,000, 60,000 and 120,000 ms after the commit. The window is 120,000 ms. It pauses while the shard is stalled (no new block for 3,000 ms) and has an absolute cap of 1,800,000 ms. When it closes without an outcome, the intent becomes `EXPIRED_LOCALLY_STILL_VALID` and the problem detail says, verbatim: > "We stopped broadcasting this transaction. It has not executed, but it is still valid and will > execute if anyone re-broadcasts it while nonce \{n\} is unused. To make it impossible, consume nonce > \{n\}: use Cancel, or send any other transaction with nonce \{n\}." While an intent is parked there: - its Relay Units and the relayer's exposure **stay reserved** — the risk is real, because anyone holding the bytes can broadcast them; - the sender is **pinned** to nonce `n`: any submission for another nonce is refused with [`NONCE_IN_FLIGHT`](/errors/nonce-in-flight) until `n` is consumed; - the only submissions accepted are the same bytes again (a new window), `mode: "cancel"` and `mode: "replace"`; - the account is sent an `intent.expired_locally` notice carrying the same sentence. The wrong response is the one that feels natural: treating it as a failure and signing a fresh transaction at `n + 1` for the same business action. That creates a second live transaction for one intention. The [logical idempotency key](/concepts/delivery-guarantees) exists to catch exactly that. ### The re-sign ladder When something fails between assignment and execution, recovery climbs a ladder. A higher rung is used only when every lower one cannot work, and only two rungs ever ask for a signature. | Rung | Situation | What happens | New signature | |---|---|---|---| | **0** | A signer host, an API host, an egress address or a gateway fails. | The same co-signed bytes go out through another host or another gateway. Ed25519 is deterministic, so the hash is the same. | none | | **1** | The lease expired while you were signing, and the relayer is still usable. | [`LEASE_EXPIRED`](/errors/lease-expired) with `renewable: true` → `POST /v1/relay/assign { renewFor }` → the identical bytes again. | none | | **2** | **Nothing was co-signed** and the named relayer is truly unusable: not renewable, fenced, retired, its hourly budget exhausted on both hosts, or the payload first seen too long ago. | [`RESIGN_REQUIRED`](/errors/resign-required), carrying a fresh assignment whose lease is **pinned to nonce `n`**. | one, same nonce | | **3** | Co-signed, but the relayer is under-funded. | The service funds that relayer address from the fleet; no bytes change. You see `BROADCAST` with `stuckReason: RELAYER_UNDERFUNDED`. | none | | **4** | Rung 3 is impossible — the relayer is flagged compromised, or the top-up did not execute within 30,000 ms. | [`RESIGN_SAME_NONCE`](/errors/resign-same-nonce): same nonce, a gas price at least one higher, a pinned lease. | one, same nonce, higher gas price | | **C** | You want the intent gone. | Cancel — below. | one, for a *different* transaction | A host-local shortage of relayer headroom is **never** a reason for rung 2; it is handled on rung 0 by forwarding to the peer host. "Re-sign" means the relayer is truly lost, not that one machine was busy. #### Why no rung can send twice 1. At most one transaction per `(sender, nonce)` executes — a protocol fact. 2. Every transaction CoRelayer ever co-signs for one intent carries the **same nonce**. Re-sign and cancel leases are nonce-pinned, and the signer refuses any other nonce. A wallet that silently produces `n + 1` yields a transaction that is never co-signed and is inert forever. 3. So "nothing was co-signed" does not even have to be globally true. If one host answers `RESIGN_REQUIRED` just as the other co-signed `T(n)`, the replacement is `T′(n)`: one of the two executes, the other dies, and the executed hash is billed once. The cost of that race is an unnecessary second signature — a UX defect, never a double send. 4. No state ever suggests or accepts a nonce other than `n` while a co-signed `T(n)` is alive. #### What a client must do on rungs 2 and 4 - Rebuild the **same** call with `relayer = assignment.relayer`, `nonce = assignment.pinNonce`, and a gas price of at least `assignment.minGasPrice`. - Sign once and submit it with the **new** lease — the pinned one from the problem document. - **Read the nonce back** from the signed transaction. A wallet that re-nonces to `n + 1` because the old transaction executed in the meantime has produced something for the wrong slot: discard it and ask `GET /v1/intents/{sender}/{n}`. - A guarded account needs a new guardian co-signature for the re-signed transaction. - If the pinned re-sign is answered [`NONCE_TOO_LOW`](/errors/nonce-too-low) or [`INTENT_ALREADY_EXECUTED`](/errors/intent-already-executed), the flow is **over**: slot `n` was consumed. Do not start a fresh, unpinned flow for the same action; read the slot's outcome. The worked code is [Handle a re-sign request](/sdk/recipes/handle-resign). ### Cancel and Replace Cancel is a **second signature for a second intent** — "make slot `n` harmless". It is not a failover re-sign, so the one-signature rule is untouched, and a user interface must say so. ```http POST /v1/relay/assign { "sender": "erd1…", "cancel": { "nonce": 41 }, "proof": { … } } ``` returns a lease pinned to nonce 41 with a minimum gas price one above the pending transaction's, on a healthy relayer (the same one when it is healthy). The cancel transaction is **fixed by the signer**: a 0-value transfer to yourself, empty data, gas limit equal to `moveGas`. It is an ordinary relayed transaction — one Relay Unit, reserved in the same slot — and whichever of the two executes is billed. The API reports the race truthfully: *cancel requested — outcome decided on chain*. With nothing to cancel you get [`NOTHING_TO_CANCEL`](/errors/nothing-to-cancel). `mode: "replace"` is the same mechanism for a **foreign** pending transaction at `n` — your own non-relayed transaction stuck in the pool. Its gas price must exceed the pooled one, and it is billed normally. Replacements of any kind must satisfy `gasPrice ≥ previous + 1` and at most `2 × minimum + 16`. An equal or lower price is refused with [`REPLACEMENT_UNDERPRICED`](/errors/replacement-underpriced), because it would never win. ### A signed payload that was never co-signed Before the commit point, your signed transaction exists **only in the memory of the request that carries it**. It is not queued, not retried from a buffer, not written to a database row or a log line. A rejection is recorded as `{sender, nonce, hash of the signature bytes, code}` — never the signature. A crash between `ACCEPTED` and `COSIGNED` loses the request, and the client resubmits the same bytes. Two limits stop a payload someone else holds from being revived later: - the signer co-signs nothing without a **lease**, and a lease needs a fresh presence proof; - a payload first seen more than 600,000 ms ago is never co-signed — the answer is `RESIGN_REQUIRED` with a nonce-pinned lease. ### Next - What each state guarantees, and what is charged: [Delivery guarantees](/concepts/delivery-guarantees) - Following an intent in code: [Watch an intent](/sdk/recipes/watch-an-intent) - Why the second signature is the exception: [One signature](/concepts/one-signature) --- ## Latency, and how it is measured This page describes **how** latency is measured and reported. It does not state a target, a percentile or a promise, because CoRelayer has never run in production: there is nothing measured to publish, and a number that is not measured is not a number. ### What sets the floor Two facts of the network bound anything a relayer can do: - **Blocks arrive on a round.** A transaction that misses the current round waits for the next one. Every millisecond of processing on our side is a fraction of a round's worth of risk that a transaction slips into the following one. - **There is a propagation grace.** A transaction that has just arrived at a node is not selected immediately; it has to be seen for a short moment first. Best case is therefore the next round or the one after — not "instant". Cross-shard execution adds its own step: the transaction executes in the sender's shard and its effect is carried to the receiver's shard. A MultiversX round lasts about 600 ms. That is the network's own figure, not one CoRelayer measured. ### Millisecond timestamps everywhere Every time value in the API is Unix milliseconds as a JSON number, and the member name ends in `Ms`. Seconds appear only where an external standard requires them — `Retry-After`, `RateLimit-Reset`, a token TTL — and each of those has a millisecond twin in the body. Two clocks are reported, and they are named so you never mix them: | | Source | Used for | |---|---|---| | `chainTimeMs` | The shard's block timestamp | Every entitlement decision and every ledger timestamp. | | `serverTimeMs` | Our wall clock | Request timestamps, lease expiry, clock-skew correction in clients. | Entitlement is decided on chain time on purpose. Wall clocks drift and can be wrong; a block timestamp is a fact the whole network agreed on. ### What is reported per transaction Each relayed transaction carries a breakdown, all in milliseconds, all `null` when the underlying timestamp is genuinely unknown rather than guessed: | Field | Interval | |---|---| | `acceptToCosignMs` | Received → relayer signature released | | `cosignToBroadcastMs` | Signature released → a gateway acknowledged | | `addedMs` | Received → acknowledged (the two above, together) | | `inclusionMs` | Received → the block that executed it | | `finalityMs` | Executed → final | | `totalMs` | Received → final | | `roundsToInclusion` | `inclusionMs` expressed in rounds | | `crossShard` | Whether the transaction crossed a shard boundary | The first two are the part CoRelayer controls. Inclusion and finality are the network's. Presenting them separately is the point: a service that reported only `totalMs` could hide its own queueing inside the chain's variance. ### Where you see it | | | |---|---| | Per transaction | `GET /v1/relay/{id}`, and the **Transactions** screen of the dashboard. | | Aggregated for your account | `GET /v1/usage/latency`, which reports p50, p95 and p99 — of *your* traffic. | | Service-wide | `GET /v1/status`, and the status site. | A percentile computed over your own transactions is a fact about what happened to you. It is not a forecast, and it is not an SLA. ([Status and SLOs](/operations/status-and-slos)) ### Why the relay call does not wait `POST /v1/relay` answers as soon as the transaction is co-signed, or as soon as a gateway acknowledges it — whichever the acknowledgement deadline allows. It never holds the connection open until the transaction is in a block. The reason is a safety property, not a performance one. A request that waits for inclusion turns every slow block into a client timeout, and a client that has timed out is a client that is about to build a *second* transaction for the same intention. Answering early and exposing the state through a stream removes that pressure entirely. ([Delivery guarantees](/concepts/delivery-guarantees) · [Watch an intent](/sdk/recipes/watch-an-intent)) ### What we will publish, when there is something to publish Once an environment has run long enough to have a distribution rather than an anecdote, the measured numbers will appear on the status site and in the changelog, with the window they were measured over and the method. Every timing is measured on chain, never estimated: per shard, as percentiles over a stated window, with CoRelayer's own synthetic probes left out of the counts. Until then, this documentation says "designed for" where it has to describe an intention, and says nothing at all where it would otherwise be guessing. --- ## One signature **One user action is one signature.** There is no flow in CoRelayer — not in the dashboard, not in the SDK, not in the MCP server — that asks a user to sign two variants of the same transaction. This page explains what that rules out and why the rule is worth its cost. ### The four steps 1. **A relayer is assigned.** It is in the sender's shard, healthy and funded, and chosen the moment before the user signs. A relayer that is not fit to deliver is never handed out. 2. **The user signs once.** One signature, over one transaction, with that relayer named inside it. 3. **CoRelayer co-signs and broadcasts.** The relayer signature is added and the relayer pays the network fee in EGLD. The same signed bytes can be re-broadcast through several gateways. 4. **The chain executes it.** The sender's account nonce orders the transaction, exactly as if the sender had sent it directly. Custody never changes hands. There are no pre-signed variants and no step at which CoRelayer could alter what was signed. One more signature is asked for only if the assigned relayer is lost before it co-signs, and then over the same nonce, so the two can never both execute. ### The temptation A relayer can become unavailable between the moment it is assigned and the moment it co-signs. The obvious fix is to hedge: ask the user to sign the same transaction three times, once per candidate relayer, and submit whichever one still works. It is a small ask in the wallet and it makes a failure mode disappear. It also creates three live, independently executable payloads for one intention. Each of them is a valid transaction the moment a relayer signs it, each has no expiry, and each is enough on its own. A user who signs three variants has authorised three things while believing they authorised one. ### What the rule is | | | |---|---| | Per user action | Exactly one signature is requested. | | Per relay attempt | The signer function is invoked at most once, and calling it again is an error, not a retry. | | On retry | The **identical bytes** are re-sent. A retry never re-signs. | | On re-sign | Only when the service says nothing was co-signed, and only after the user is told and agrees. | The SDK enforces this structurally rather than by convention. `relayOnce` wraps the caller's signer in `guardSignOnce`, so that a second invocation throws instead of signing: ```ts import { guardSignOnce } from '@corelayer/sdk'; const sign = guardSignOnce(async ({ transaction }) => wallet.signTransaction(transaction)); await sign({ assignment, transaction }); // signs await sign({ assignment, transaction }); // throws: "relayOnce: the signer was called twice. …" ``` A rule that lives only inside a private function is a rule nothing can test. This one is exported, so the property has a test of its own — in the SDK's suite, and again in this site's [example suite](/sdk), which counts the signatures every example produces. ### Why the relayer is assigned just before you sign Assigning late is what makes one signature enough. When you start a transaction, the API returns one relayer that is healthy, funded and in your shard, with a short lease. The lease reserves nothing: it is a freshness bound, because a signed MultiversX transaction never expires on its own. ([Intent lifecycle](/concepts/intent-lifecycle)) Because the choice is made at the last moment, a relayer that is failing is never handed out. And because the user signs after the choice, the relayer named inside the transaction is one the service already knows it can serve. The alternative, signing several variants so that another relayer can be put in later, is the one this rule rules out. ### Why a fleet, and not one sponsoring wallet Two reasons, both structural rather than a matter of scale. **Shards.** The fee payer of a relayed transaction has to be in the sender's shard. One wallet cannot serve every sender; a set with coverage in each shard can. ([Shards and routing](/concepts/shards-and-routing)) **Exposure.** Every transaction in flight reserves its worst-case fee against the relayer that will pay it. One wallet is one balance and one point of failure. Several per shard means a relayer can be drained without its shard stopping, and a compromise costs the float of a few wallets rather than everything. ([Relayers](/concepts/relayers)) ### The one case where you are asked again If the assigned relayer becomes unavailable **before the commit point**, the service answers [`RESIGN_REQUIRED`](/errors/resign-required). What that answer means, precisely: - nothing was co-signed, so nothing can execute; - the transaction you signed is inert and always will be; - a fresh assignment for a different relayer is attached, pinned to the **same nonce**; - the decision to sign again belongs to you. The SDK does not act on this. `relayOnce` returns it: ```ts const result = await relayOnce({ /* … */ }); if (result.kind === 'resign-required') { // Nothing was sent. Show the user what happened; only then sign once more, with the // pinned assignment in result.nextAssignment and the nonce in result.pinnedNonce. } ``` The pinned assignment is what makes the second signature safe — its lease is valid for that nonce only. ([Handle a re-sign request](/sdk/recipes/handle-resign)) The dashboard shows a dialog naming the old relayer, the new one and the nonce. It never re-signs silently. ([The re-sign case](/sdk/recipes/handle-resign)) ### What is safe to retry automatically Retrying is fine as long as nothing is re-signed. Two cases are handled for you: | Situation | Handled how | |---|---| | The lease expired but is renewable | A new lease is fetched for the **same** relayer and the **identical** signed bytes are submitted again. No new signature, no new nonce. The renewal needs a fresh presence proof, so `relayOnce` does it when the client has a native-auth token for the sender or you pass `signProof`. With a proof you signed yourself, you get `LEASE_EXPIRED` back. | | The request timed out or the connection dropped | Re-send the same bytes, or ask `GET /v1/intents/{sender}/{nonce}`. Never rebuild, never re-sign. | Both are safe for the same reason: identical bytes are the same transaction, with the same hash, occupying the same slot. The network treats a duplicate as what it is. ### What this costs, honestly The hedge we refuse would genuinely remove one failure mode. Instead, when a relayer vanishes at the wrong moment, a person has to approve one more transaction. We think that is the right trade: a user who signed once should not have to reason about how many live payloads that produced. If your integration cannot show a prompt — a fully headless agent, for example — the same rule applies and the answer is the same: re-sign with the new assignment, in code, having first checked that the service told you nothing was sent. The `resign` member of every relay-path problem document exists to make that check mechanical: | `resign` | Meaning | |---|---| | `NONE` | Do not ask for a signature. Nothing about signing will help. | | `SAME_BYTES` | Re-send exactly what you have. | | `NEW_SIGNATURE_SAME_NONCE` | One new signature, same nonce, new relayer. | Never sign because of a status code alone. Sign because `resign` said so. --- ## Relay Units A **Relay Unit** (RU) is the unit of everything CoRelayer sells. A plan is a number of Relay Units per month. Pay-as-you-go is a price per Relay Unit. The single price lever on chain, the [tariff](/plans/tariff), is expressed per Relay Unit. One Relay Unit is **0.001 EGLD of worst-case network fee**. It is defined in terms of the fee because the fee is the real cost of relaying; a unit defined any other way would drift away from what the service actually spends. ### The formula Schedule version 1: ```text RU_SIZE_ATTO = 1_000_000_000_000_000 // 0.001 EGLD moveGas = erd_min_gas_limit + erd_gas_per_data_byte × len(data) // data = raw bytes of the data field + erd_extra_gas_limit_relayed_tx // always: everything we relay is relayed v3 + (guarded ? erd_extra_gas_limit_guarded_tx : 0) procPrice = ceil(gasPrice / 100) // integer form: (gasPrice + 99) / 100 maxFee = moveGas × gasPrice + (gasLimit − moveGas) × procPrice ru = max(1, ceil(maxFee / RU_SIZE_ATTO)) ``` Three things are worth reading twice: - **It is integer arithmetic on the fields of the signed transaction.** No floats, no oracle, no server-side discretion. You can compute the cost of your own transaction before you sign it and get the same answer the service does. - **It prices the worst case, not the gas used.** A relayed transaction that reverts still costs the relayer its full gas limit, and the network refunds nothing above a small ceiling. Charging on gas used would mean charging less than was paid. - **The minimum is one.** Nothing costs zero units. The same thing as code, runnable, with the constants taken from `GET /v1/network`: ```ts title="relay-units.ts" snippet="examples/relay-units.ts" /** * The Relay Unit formula, schedule version 1 — computed on your side, before you sign. * * Integer arithmetic on fields of the transaction and on constants the network publishes; nothing * else. `POST /v1/quote` returns the same number, and `GET /v1/network` returns the constants * (`minGasLimit`, `gasPerDataByte`, `extraGasRelayed`, `extraGasGuarded`), so a client can check * the service's arithmetic rather than trust it. */ /** 0.001 EGLD, in atto-EGLD. A contract constant: it has no setter. */ export const RU_SIZE_ATTO = 1_000_000_000_000_000n; export interface NetworkConstants { readonly minGasLimit: bigint; // erd_min_gas_limit readonly gasPerDataByte: bigint; // erd_gas_per_data_byte readonly extraGasRelayed: bigint; // erd_extra_gas_limit_relayed_tx readonly extraGasGuarded: bigint; // erd_extra_gas_limit_guarded_tx } export interface Priced { /** Gas that moves the transaction; the rest of the gas limit is processing gas. */ readonly moveGas: bigint; /** The worst-case fee, in atto-EGLD: what the relayer is exposed to. */ readonly maxFee: bigint; readonly ru: bigint; } export function relayUnits( tx: { readonly gasLimit: bigint; readonly gasPrice: bigint; readonly dataBytes: number; readonly guarded: boolean; }, net: NetworkConstants, ): Priced { const moveGas = net.minGasLimit + net.gasPerDataByte * BigInt(tx.dataBytes) + net.extraGasRelayed + // always: everything CoRelayer relays is relayed v3 (tx.guarded ? net.extraGasGuarded : 0n); if (tx.gasLimit < moveGas) throw new RangeError(`gasLimit ${tx.gasLimit} is below moveGas ${moveGas}`); // The node applies a 0.01 price modifier with truncation; the schedule rounds UP, so it can // never under-count what the relayer is exposed to — and needs no floating point. const procPrice = (tx.gasPrice + 99n) / 100n; const maxFee = moveGas * tx.gasPrice + (tx.gasLimit - moveGas) * procPrice; const ru = maxFee <= RU_SIZE_ATTO ? 1n : (maxFee + RU_SIZE_ATTO - 1n) / RU_SIZE_ATTO; return { moveGas, maxFee, ru }; } /** The constants as `GET /v1/network` reports them. */ export function constantsOf(network: { readonly minGasLimit: number; readonly gasPerDataByte: number; readonly extraGasRelayed: number; readonly extraGasGuarded: number; }): NetworkConstants { return { minGasLimit: BigInt(network.minGasLimit), gasPerDataByte: BigInt(network.gasPerDataByte), extraGasRelayed: BigInt(network.extraGasRelayed), extraGasGuarded: BigInt(network.extraGasGuarded), }; } ``` #### The named constants `moveGas` is built from protocol constants referenced by name, not copied as numbers. The backend re-reads them from the network on every epoch change, so a protocol change that alters the real cost of a relayed transaction alters the Relay Unit count automatically — because the unit tracks cost, that is the correct behaviour. `GET /v1/network` reports the values currently in use. Their values on mainnet when the schedule was specified (2026-09-19): | Constant | Value | |---|---| | `erd_min_gas_limit` | 50,000 | | `erd_gas_per_data_byte` | 1,500 | | `erd_extra_gas_limit_relayed_tx` | 50,000 | | `erd_extra_gas_limit_guarded_tx` | 50,000 | | `erd_gas_price_modifier` | 0.01 | | `erd_min_gas_price` | 1,000,000,000 | | `erd_max_gas_per_transaction` | 600,000,000 | One deliberate difference from the node: where the node truncates when it applies the price modifier, the schedule **rounds up**. For a gas price that is not a multiple of 100 the two differ by at most one atto per gas unit. Rounding up can never under-count what the relayer is exposed to, and it removes floating point from every client that wants to check the arithmetic. ### Work it out Pick the shape of a transaction and the card below runs the same formula, with every intermediate value shown. The shapes are rows of the golden-vector table that follows. ### Golden vectors The single reference set for every implementation of the formula. The backend's implementation is tested against it, and this site's own test suite recomputes every row below from the formula above and fails the build if a single figure disagrees. | Case | `gasLimit` | `gasPrice` | data bytes | guarded | `moveGas` | `maxFee` (atto) | RU | |---|---|---|---|---|---|---|---| | Plain EGLD transfer | 100,000 | 1,000,000,000 | 0 | no | 100,000 | 100,000,000,000,000 | **1** | | `ESDTTransfer` | 500,000 | 1,000,000,000 | 50 | no | 175,000 | 178,250,000,000,000 | **1** | | Typical contract call | 5,000,000 | 1,000,000,000 | 180 | no | 370,000 | 416,300,000,000,000 | **1** | | Exactly at the 1-RU boundary | 75,250,000 | 1,000,000,000 | 100 | no | 250,000 | 1,000,000,000,000,000 | **1** | | One gas unit past it | 75,250,001 | 1,000,000,000 | 100 | no | 250,000 | 1,000,000,010,000,000 | **2** | | 1 KB of data, 10 M gas | 10,000,000 | 1,000,000,000 | 1,024 | no | 1,636,000 | 1,719,640,000,000,000 | **2** | | Maximum gas, small data | 600,000,000 | 1,000,000,000 | 100 | no | 250,000 | 6,247,500,000,000,000 | **7** | | Guarded, cross-shard (a mainnet transaction) | 9,900,000 | 1,000,000,000 | 87 | yes | 280,500 | 376,695,000,000,000 | **1** | | Gas price not a multiple of 100 | 5,000,000 | 1,234,567,891 | 180 | no | 370,000 | 513,950,613,440,000 | **1** | | Self-serve worst case | 600,000,000 | 2,000,000,000 | 4,096 | yes | 6,294,000 | 24,462,120,000,000,000 | **25** | | A purchase transaction (40 M gas) | 40,000,000 | 1,000,000,000 | 120 | no | 280,000 | 677,200,000,000,000 | **1** | | Smallest round-up (one atto) | 5,000,000 | 1,000,000,001 | 180 | no | 370,000 | 416,300,005,000,000 | **1** | The guarded row reproduces the fee of a real mainnet relayed transaction. The last row is the round-up rule at its smallest: `procPrice = (1_000_000_001 + 99) / 100 = 10_000_001`, one more than the node's truncated value. ### Why "transactions" is a fair shorthand In a sample of real MultiversX mainnet relayed v3 transactions taken while the schedule was being specified, **88.4 % were exactly one Relay Unit** and the mean was 1.187. That is why plans are described in transactions, with the footnote that heavy transactions count as several — and why the footnote is always there rather than in small print. Those two figures describe the network's traffic, not CoRelayer's; CoRelayer has relayed nothing yet. The thing that makes a transaction expensive is almost never what people expect. It is not the value being moved and it is not the contract being called. It is: - **an unnecessarily high gas limit** — you are charged on what the network *could* take; - **a large data field** — each byte adds to the movement cost at the full gas price; - **a raised gas price** — it multiplies the movement part of the fee directly. ### Check before you sign `POST /v1/quote` computes the units for a transaction you have not signed yet. Its body carries the transaction as `tx` (signed or not — only `sender`, `gasLimit`, `gasPrice`, `data`, `options` and `guardian` are read) and, optionally, the `account` that would be billed: ```bash curl -sS https://api.co-relayer.com/v1/quote \ -H 'content-type: application/json' \ -d '{"tx":{"sender":"erd1…","gasPrice":1000000000,"gasLimit":150000,"data":""}}' ``` The answer — a `RelayQuote` — carries `ru`, `maxFeeAtto`, `moveGas`, the schedule version, the tariff, the pay-as-you-go price per unit and `billedAs`: how this transaction would be billed for that account (`grant`, `bonus`, `cap`, `payg`, `free`, or `none` when it would not be served). Amounts in it are decimal strings. `POST /v1/validate` goes further and returns every problem a real relay would raise — without a lease, without a reservation and without asking the signer for anything. ([Check before you send](/sdk/recipes/check-quota)) ### Admission limits Some transactions are refused before units are even counted, because they would expose a relayer to more than the service will risk on one transaction: | Limit | Rule | Error | |---|---|---| | Gas price | between the network minimum and twice it; **exactly** the minimum on the free purchase flow | [`GAS_PRICE_OUT_OF_RANGE`](/errors/gas-price-out-of-range) | | Gas limit, lower bound | at least `moveGas` | [`GAS_LIMIT_TOO_LOW`](/errors/gas-limit-too-low) | | Gas limit, upper bound | 100,000,000 on rate classes 1 and 6; 600,000,000 on classes 2 to 5 | [`GAS_LIMIT_TOO_HIGH`](/errors/gas-limit-too-high) | | Data size | at most 4,096 bytes on a self-serve account | [`DATA_TOO_LARGE`](/errors/data-too-large) | | Over-provisioned gas | with simulation on, `gasLimit − moveGas` at most 1.9 × the simulated processing gas | [`GAS_OVERPROVISIONED`](/errors/gas-overprovisioned) | | Contract deployment or upgrade | allow-listed per account, then at most 65,536 bytes | [`DEPLOY_NOT_ALLOWED`](/errors/deploy-not-allowed) | | The sender can pay `value` | checked before co-signing — a transaction that fails for lack of funds still costs the relayer its whole fee | [`INSUFFICIENT_SENDER_BALANCE`](/errors/insufficient-sender-balance) | Together these fix the heaviest transaction a self-serve account can send: **15 Relay Units** on classes 1 and 6, **25** on classes 2 to 5 (the "self-serve worst case" row above). The burst of every rate class is at least that large, so a transaction the class permits can always be admitted. ([Limits](/operations/limits)) ### Versioning The formula is a **published, versioned document**, and the contract stores its version and a SHA-256 hash of it (`getRuSchedule`). Two properties follow: - a change to the schedule carries the same 48 hours of notice as a price increase; - anyone can check that the schedule they read is the one the owner committed to on chain, by comparing the hash. Caps are always denominated in units of the schedule in force at relay time. The size of a Relay Unit — 0.001 EGLD — is a contract constant with no setter at all: changing it would silently reprice every cap ever sold, so it is an upgrade with a migration, never a switch. If a protocol upgrade ever adds a fee-relevant transaction field that the schedule does not price, the service refuses transactions carrying it with [`UNSUPPORTED_TX_FIELD`](/errors/unsupported-tx-field) until a schedule version that prices it is in force. --- ## Relayed v3 Relayed v3 is the MultiversX protocol feature CoRelayer is built on. It is not a wrapper, a meta-transaction format or a contract trick: it is two extra fields on an ordinary transaction, and the node treats the result as one transaction with two signatures. It is also what makes a transaction **gasless** for its sender. The relayer, not the sender, pays the network fee in EGLD, so the sender needs no EGLD at all. CoRelayer runs those relayers, and you pay for them with a plan in USDC. ### The two fields An ordinary transaction becomes a relayed one by setting: | Field | What it is | |---|---| | `relayer` | The bech32 address of the account that will pay the fee. | | `relayerSignature` | That account's Ed25519 signature over the same bytes the sender signed. | Both are part of the transaction. Neither is a header, a side channel or an off-chain agreement. ### What this means, exactly Four consequences follow, and everything else about the service follows from them. #### The sender signs the relayer `relayer` is inside the bytes the sender signs. So the relayer **cannot be changed after you signed**: swapping it invalidates your signature, and the network will not accept the result. You know, before you approve anything, which account is going to pay for your transaction. #### Only the sender's nonce is consumed The relayer's own nonce is untouched. That is what lets one relayer serve many senders at once, and it is what lets the same relayer key co-sign on more than one host without the two hosts having to agree on a counter. #### A co-signed transaction has no expiry Once a relayer signature exists, the transaction is executable until the sender's nonce moves past it. There is no time-to-live. Anyone holding the bytes can broadcast them. This is why the moment the relayer signature leaves our signer is a hard boundary in our design: before it, an error is still possible; after it, the transaction exists in the world and the only honest answer is a state, never a failure. ([Delivery guarantees](/concepts/delivery-guarantees)) #### Without the relayer signature it is inert A transaction that names a relayer but carries no relayer signature is invalid. It cannot execute, cannot be repaired by anyone else, and costs nobody anything. A signed payload that we never co-signed is a dead letter. ### The shape of one relayed transaction ```json title="A relayed v3 transaction, as the API takes it" { "nonce": 41, "value": "0", "receiver": "erd1…", "sender": "erd1…", "gasPrice": 1000000000, "gasLimit": 150000, "data": "…base64…", "chainID": "1", "version": 2, "relayer": "erd1…", "signature": "…hex…" } ``` You send that to `POST /v1/relay` **without** `relayerSignature`. The service adds it. A body that tries to supply one is rejected with [`RELAYER_SIGNATURE_PRESENT`](/errors/relayer-signature-present): a co-signature is not something a client can bring. ### The extra gas Relaying is not free for the network either. Every relayed transaction pays a fixed surcharge on top of the usual cost of moving a transaction, and a guarded transaction pays a second one. The assignment response gives you both numbers so you do not have to guess: | From the assignment | Meaning | |---|---| | `extraGasRelayed` | Gas the protocol adds because the transaction is relayed. Always applies. | | `extraGasGuarded` | Gas the protocol adds when the sender uses a guardian. Applies only then. | | `minGasPrice`, `maxGasPrice` | The band your `gasPrice` must fall in to be accepted. | Getting the gas limit right matters for you, because the Relay Unit count is computed from the transaction's **worst case**, not from the gas it ends up using. ([Relay Units](/concepts/relay-units)) ### The full exchange ```mermaid sequenceDiagram autonumber participant C as Your client participant A as CoRelayer API participant S as CoRelayer signer participant G as Public gateway participant N as MultiversX C->>A: POST /v1/relay/assign { sender, proof } A-->>C: relayer, lease (60 s), gas bounds, chain id Note over C: build tx with `relayer` set,
sign once C->>A: POST /v1/relay { tx, lease } A->>A: validate: nonce, gas, balance,
entitlement, rate limits A->>A: reserve Relay Units A->>S: co-sign (lease verified again here) Note over S: commit point — after this the
transaction exists and can execute S-->>A: relayerSignature A->>G: broadcast G-->>A: accepted (tx hash) A-->>C: 200 { intentId, txHash, state, ru } G->>N: included in a block N-->>A: executed (success or failure) ``` Everything that can go wrong is on the left of the commit point. That is a deliberate property, not an accident of implementation: it is what lets a client treat any error it receives as "nothing was sent". ([Delivery guarantees](/concepts/delivery-guarantees)) ### What we verify before co-signing The list is long on purpose, because every item is a way for a relayed transaction to cost us a fee and give the sender nothing: - the signature is valid and the bytes name one of our relayers, in the right shard; - the lease is ours, unexpired and for this sender; - the nonce is in the window the network will accept, and no other transaction of ours occupies the same slot; - `gasPrice` is inside the admitted band and `gasLimit` is above the true movement cost and below the class ceiling; - the sender can actually pay any `value` the transaction moves — a transaction that fails for lack of funds still costs the relayer the whole fee; - the account has entitlement, and the reservation fits inside the relayer's funded exposure; - optionally, a simulation says the call would not revert for a reason we could have seen. Each of these has its own error code, and each error page says whether re-sending the same bytes can ever help. Start at [the error catalogue](/errors). ### The facts this rests on These are properties of the MultiversX protocol, not of CoRelayer. They are what make the design above sound, and they are worth checking against the protocol documentation rather than taking from us: 1. Sender, guardian and relayer sign the **same bytes**, and those bytes contain `relayer`. 2. Relayed v3 consumes only the **sender's** nonce. 3. A co-signed transaction has **no TTL**; without a relayer signature it is invalid. 4. At most one transaction per `(sender, nonce)` executes; identical bytes re-broadcast are the same transaction. 5. Among transactions with the same nonce, the mempool prefers the higher gas price, then the lower hash. There is no minimum bump. 6. A not-executable outcome charges no fee and does not move the nonce. A failed **execution** charges the relayer the full gas limit. ### Next - Why there is never a second signature: [One signature](/concepts/one-signature) - How a relayer is chosen for you: [Shards and routing](/concepts/shards-and-routing) - What happens after the commit point: [Delivery guarantees](/concepts/delivery-guarantees) --- ## Relayers A relayer is a MultiversX wallet that holds EGLD and does exactly one thing: it co-signs other people's transactions so that its EGLD pays their fees. It has no authority over anything else. ### The fleet The registry is committed to this repository as public data — addresses, shards and states, no keys — in `wallets/registry.public.json`: | | | |---|---| | Relayer wallets | 30 | | Per shard | 10 (shards 0, 1, 2) | | Marked active at launch | 9 — three per shard | | Held as cold spares | 21 | The spares are not warm standbys. Their keys are on no server at all; they exist on chain with zero weight, and activating one is a deliberate, owner-signed act. That is the difference between "we have capacity" and "a machine that is compromised gives up 30 keys". ### Why several per shard Three active relayers per shard, rather than one, buys three things: - **Exposure headroom.** Every transaction in flight reserves its worst-case fee against the relayer that will pay it. One relayer has one balance; three have three. - **A failure that is not an outage.** If a relayer has to be drained, the shard keeps serving. - **Room for weights.** The registry stores a weight per relayer, so traffic can be shifted without removing anybody. ### How a relayer is funded Relayers hold a small working float — enough to pay fees, never enough to be worth attacking. The float is topped up from revenue and swept back down when it grows past its ceiling; the sweep destination is a cold reserve wallet whose key is on no server. The float target is deliberately low. The money a relayer holds is CoRelayer's own working capital, not customer funds: a customer's USDC goes to the contract, and the contract never sends anything to a relayer wallet as custody. The worst case of a compromised signing host is the loss of the active relayer floats and the work of replacing those wallets from spares — not the loss of anybody's plan, credits or tokens. ### States, and how a relayer leaves ```mermaid stateDiagram-v2 [*] --> registered: addRelayers (owner) registered --> active: activateRelayers active --> draining: drainRelayers draining --> retired: retireRelayers retired --> [*] note right of draining Finishes what it promised. Takes no new assignments. end note ``` | State | Assigned to new senders | Co-signs transactions already promised | |---|---|---| | `registered` | no | no | | `active` | yes | yes | | `draining` | no | yes | | `retired` | no | no | Two rules make this safe: 1. **A relayer is never retired while a transaction it co-signed is still in flight.** Retiring one early would strand a transaction that is perfectly valid and waiting for a block. 2. **A relayer that was ever handed out by the compatibility endpoint drains for a full month.** That endpoint exists for clients which cache one relayer address and never look again; there is no way to tell them to refresh, so the only honest option is to keep the address working long enough for a human to notice. ### The registry is on chain Everything above is contract state, not a configuration file of ours: | View | Answers | |---|---| | [`getActiveRelayers`](/contract/reference/views) | Which addresses are serving right now. | | [`getRelayerState`](/contract/reference/views) | The state of one address. | | [`getRegistryVersion`](/contract/reference/views) | A number that changes whenever the set changes — a cache key. | | [`getShardWeights`](/contract/reference/views) | How traffic is distributed inside a shard. | This is the point. A client should never have to trust an API response about which address is a legitimate relayer, because that is exactly the claim an attacker would want to make. Verify the address you were given against the contract, through a node that is not ours, and refuse if it is not `active`. Each CoRelayer SDK does this for you when you give its relay flow a relayer verifier. The verifier asks a gateway you choose, before anything is built or signed. [Verify a relayer](/concepts/verify-a-relayer) has the procedure and the code. ### Who may change what | Action | Who | Note | |---|---|---| | `addRelayers` | owner only | New addresses can only ever be introduced by the cold key. | | `activateRelayers`, `drainRelayers`, `retireRelayers` | operator or owner | Incident response has to be fast. | | `setRelayerWeights` | operator or owner | Shifts traffic without changing membership. | A stolen operator key can take relayers out of service — which is a denial of service, and visible — but it cannot introduce an address of its own, because every address it can activate was added by the owner first. The contract enforces that ordering. ### Revenue Relayers are not a cost centre paid out of a treasury by hand. Of every payment that enters the contract, a fixed share is swapped into EGLD and accrues to the relayer pool, and the rest goes to the treasury. The split is on chain and so is the accounting. ([Where the money goes](/concepts/revenue-flow)) ### What relayers deliberately do not have - **No guardian.** The protocol does not allow a guarded relayer, and the contract refuses to register one. - **No role overlap.** A relayer address may not also be the owner, operator, reporter or treasury. The contract checks this, and the signer refuses to start with a key bundle that violates it. - **No authority over accounts.** A relayer cannot grant entitlement, change a plan, move credits or settle usage. Its only verb is "co-sign". --- ## Where the money goes You pay in USDC. Fees are paid in EGLD. Something has to convert one into the other, and the honest thing is to say exactly where that happens and what it does to your money. It happens **in the contract, in the same transaction as your payment**. There is no treasury wallet that receives your USDC and converts it later at a time of our choosing. ### The path of one payment ```mermaid flowchart TB U["Your USDC payment
(ESDT transfer to the contract)"] --> C["CoRelayer contract"] C --> S["Swap USDC → WEGLD
on the xExchange pair"] S --> W["Unwrap WEGLD → EGLD"] W --> T["30% → treasury"] W --> P["70% → relayer pool"] C --> CR["Credits for your account
1:1 from the USDC you paid"] P --> R["Relayer wallets
(the EGLD that pays your fees)"] ``` Step by step: 1. **You transfer USDC to the contract.** This is an ordinary ESDT transfer that also calls a contract function — `deposit`, `depositFor` or `depositAndSubscribe`. 2. **The contract swaps it in the same call.** USDC becomes WEGLD on the xExchange pair, and WEGLD is unwrapped to EGLD. If the venue is paused or the swap fails, the whole transaction reverts: your USDC never leaves your wallet and you get [`SWAP_VENUE_PAUSED`](/errors/swap-venue-paused) rather than a half-finished state. 3. **The EGLD is split.** 30% to the treasury, 70% to the relayer pool. The pool is what funds the relayer wallets that pay your fees. 4. **You are credited.** Your account is credited **1:1 from the USDC you paid**, not from what the swap returned. The exchange rate is our problem, not yours: a bad swap costs CoRelayer, not the customer. ### Credits, plans and escrow | | What it is | Refundable | |---|---|---| | **Credits** | USDC-denominated balance inside the contract, created by a deposit. | No | | **A plan block** | What a purchase writes: cap, period length, pay-as-you-go price and sender limit, frozen for the term. | No | | **Escrow** | Credits set aside for pay-as-you-go usage that has not happened yet. | No — unused escrow returns to your **credits** when you turn pay-as-you-go off | Nothing that enters the contract goes back to a wallet. Credits and plans are non-refundable, and the contract has no endpoint that would make them refundable; there is no discretionary "we can return it if we want to" path either, which is the same statement seen from the other side. What the contract does guarantee is that escrow cannot be trapped. Unused escrow returns to the account's credits once pay-as-you-go is off — normally through a closing settlement line, and, if the settlement key is dead or hostile, through `releaseEscrow`, which anyone may call seven days later and which no pause can block. ([Credits and billing](/plans/credits-and-billing)) ### What the split pays for **The relayer pool (70%)** buys the EGLD that pays your fees. This is the part that has to cover: - the fee of every transaction we relay, at its worst case, because a failed execution costs the relayer the full gas limit and the network refunds nothing; - the volatility between the moment you pay a fixed USDC price and the moment the EGLD is spent; - the float each relayer holds so that it can accept the next transaction. **The treasury (30%)** is everything else: infrastructure, development and margin. Both shares are on chain, and the distribution to relayer wallets is itself a contract endpoint with its own events — `poolDistributed`, `distributionDeferred` — so the accounting is auditable by anyone, not just by us. ### Why the swap is inside the payment It would be simpler to receive USDC, keep it, and buy EGLD periodically. We do not, for two reasons: - **It would make us a custodian of your payment for a while.** Swapping in the same call means there is never a moment where the contract holds a pile of customer USDC waiting for a decision. - **It would move the exchange-rate risk to the wrong side.** Converting at payment time fixes the coverage of what you just bought. Converting later means the coverage of your prepaid plan depends on when we felt like trading. The consequence — that a paused or broken venue makes purchases fail outright — is a real cost of this choice, and it is why `SWAP_VENUE_PAUSED` is a documented, expected error rather than an internal one. ### When a price change touches your money It does not, for anything you already paid for. A purchase snapshots its terms into a plan block. Changing the [tariff](/plans/tariff), editing a tier or retiring a tier never reaches into a block that was already paid for. A tariff **increase** additionally cannot take effect for 48 hours, and that notice period is a compiled constant of the contract — not a setting that the same key which sets the tariff could also set to zero. ### The accounting you can check | Contract view | Tells you | |---|---| | [`getTotalOutstandingCredits`](/contract/reference/views) | Credits and pay-as-you-go escrow still owed to accounts. | | [`getDistributionState`](/contract/reference/views) | The relayer pool and how it is being distributed. | | [`getSwapBudget`](/contract/reference/views) | The bounds the contract applies to its own swapping. | | [`getTariffHistory`](/contract/reference/views) | Every tariff that has ever been in force, with the moment it became effective. | Every one of these is a read-only call that costs nothing and needs no permission. ([Contract reference](/contract/overview)) --- ## Shards and routing MultiversX splits its state across shards. Which shard an account belongs to is determined by the last byte of its address — it is a property of the address, fixed forever, and anyone can compute it without asking a node. This matters here for one reason: **the fee payer of a relayed transaction has to be in the same shard as the sender.** So "the CoRelayer relayer" is not one address. It is a set, with coverage in every shard, and part of the service is telling you which one is yours. ### The assignment Before you sign anything, you ask: ```http POST /v1/relay/assign { "sender": "erd1…", "proof": { "kind": "key", "serverTimeMs": 1789000000000, "signature": "…" } } ``` The request is the same whoever pays: the sender's key signs the proof. When your server pays for the sender with a sponsor key, the key travels on the relay call that follows, not here: ```http POST /v1/relay X-Api-Key: crk_live_… { "tx": { "sender": "erd1…", "relayer": "erd1…", "signature": "…" }, "lease": "…" } ``` From the assignment you get back everything the transaction needs: | Field | What you do with it | |---|---| | `relayer` | Put it in the `relayer` field before signing. | | `shard` | The shard both of you are in. Informational. | | `lease` | An opaque token you send back with the signed transaction. | | `leaseExpiresAtMs` | Sixty seconds after issue. | | `serverTimeMs` | Correct your clock against this rather than trusting the local one. | | `chainId` | Compare it with the `chainID` you are about to sign. They must match. | | `minGasPrice`, `maxGasPrice` | The band your gas price must fall in. | | `extraGasRelayed`, `extraGasGuarded` | The protocol surcharges to add to the gas limit. | | `expectedNonce` | The nonce the service believes is next for this sender. | | `pinNonce` | Present when the flow must use a specific nonce — a cancel, or a re-sign. | | `registryVersion` | Changes when the relayer set changes; use it as a cache key. | ### Why the assignment needs a proof Assignment reserves nothing and costs nothing, so it is tempting to make it public. It is not, and the reason is specific: a lease issued to anyone who merely knows an address would let a third party who is holding an old, signed-but-never-relayed payload bring it back to life. The proof is a signature by the sender's own key over a short message: ``` corelayer/assign/v1||| ``` valid within thirty seconds of `serverTimeMs`. The chain id is inside the message, so a proof captured on one network cannot mint a lease on another. If you already hold a native-auth token for the sender, made for one of CoRelayer's own origins, that stands in for the proof. If you are a service paying for other people's transactions, the proof is the same, signed with the sender's key, and your sponsor key goes on the relay call. ([Authentication](/agents/auth), [Paying for other senders](/plans/sponsoring-senders)) ### What the lease is, and is not A lease is a short-lived, authenticated token minted by the signer. It says: *this relayer, for this sender, until this moment*. | It is | It is not | |---|---| | A freshness proof — evidence the client is here now | A reservation. Nothing is held for you | | A routing decision — which relayer will co-sign | A promise. Entitlement is checked at relay time | | Verified again by the signer before co-signing | Something a client can construct | Because it reserves nothing, asking twice is harmless: you get two valid leases for the same relayer. Because it is verified again inside the signer, a leaked lease alone gets nobody anything — it still needs a validly signed transaction naming that relayer. ### How the relayer is chosen Within your shard, the choice is deterministic: a rendezvous hash over the shard's currently **active** relayers. Two properties follow, and both are useful: - **It is stable.** The same sender gets the same relayer as long as the active set does not change, which keeps nonce handling and exposure accounting simple on our side. - **It is not a secret.** The compatibility endpoint `GET /relayer/address/{userAddress}` returns the same answer without a lease, for clients that cache one relayer address forever. When the active set changes — a relayer is drained, a spare is activated — `registryVersion` moves and the mapping re-settles. That is also the moment an old cached relayer address may stop working. ### Verifying the relayer yourself You do not have to take our word for which address is a real CoRelayer relayer. The registry lives in the contract: - `getRelayerState(address)` returns the registry state; only an **active** relayer should ever be co-signing for you; - `getRegistryVersion()` tells you whether your cached answer is still current; - `getActiveRelayers()` lists them. Do this before signing, through a gateway that is not ours, and cache the result by `registryVersion`. **The contract address you query is pinned in your own configuration** — never taken from an API response, because an API that could name its own contract could name any contract. Each CoRelayer SDK runs this check for you when you give its relay flow a relayer verifier. [Verify a relayer](/concepts/verify-a-relayer) shows how to turn it on, and lists the steps for a program that calls the API directly. ### Relayer states | State | Meaning for you | |---|---| | `registered` | Known to the contract, not serving. Never assigned. | | `active` | Serving. This is the only state an assignment ever names. | | `draining` | Still co-signs transactions that were already promised, takes no new ones. | | `retired` | Finished. A relayer is never retired while a transaction it co-signed is still in flight. | A relayer that is handed out by the compatibility endpoint — which clients cache without any expiry — drains for a full month before it can retire, because there is no way to tell those clients to look again. ### Next - What the relayer set is for and how it is funded: [Relayers](/concepts/relayers) - What happens once a transaction is co-signed: [Delivery guarantees](/concepts/delivery-guarantees) - The rest of the assignment fields, precisely: [API overview](/api/overview) --- ## Verify a relayer Before you sign a transaction, check that the `relayer` it names is an active CoRelayer relayer, in your shard, on the network you are signing for. Every CoRelayer SDK runs this check for you when you give its relay flow a relayer verifier. This page explains the check, shows how to turn it on in each SDK, and lists the steps for a program that calls the API directly. ### Why it matters Under Relayed v3 the relayer address is part of the bytes you sign, so nobody can change it afterwards. An impostor relayer could still hold on to a transaction you signed for it. A signed MultiversX transaction does not expire, so the impostor could co-sign and broadcast it at any later time, or never send it at all. So do not sign for a `relayer` you cannot tie to CoRelayer. A name, a tag or a branding file cannot do that, because anyone can publish one. Use them to find things, and use the registry in the CoRelayer contract to decide. You read the registry through a node you chose. ### Pin the contract address The whole check depends on the contract address. Pin it in your own configuration, keyed by chain id (`1` for mainnet, `D` for devnet), and never take it from the same API response that gives you a relayer. The address is also published on [the contract page](/contract/overview#at-a-glance), in the pricing document and in the discovery files. Those are copies for convenience. If one of them disagrees with your pin, keep your pin. An SDK verifier with no contract address fails every check with the reason `no-contract`, and the relay flow stops before your wallet is asked to sign. That is on purpose: a relayer that cannot be checked is never signed for. The owner and relayer wallets are the same on devnet and mainnet, and the contract address may be the same too. An address that is `Active` in the devnet registry tells you nothing about mainnet. Always query the contract of the chain id you are about to sign for, and key every cache by that chain id. ### What the check does 1. It computes the shard of the relayer and of the sender from the two addresses. This needs no network call. A relayer in another shard cannot pay for your transaction, so the check refuses it without asking the chain. 2. It calls the view `getRelayerState` on your pinned contract, through a MultiversX gateway that CoRelayer does not run, and accepts only `2` (Active). 3. It caches a positive answer, keyed by chain id, the `registryVersion` of the assignment and the relayer. The registry version changes whenever the registry does, and a new version needs a new check. | `getRelayerState` | As `returnData[0]` | State | What to do | |---|---|---|---| | 2 | `"Ag=="` | Active | Sign. | | 3 | `"Aw=="` | Draining | Do not sign a new transaction. Ask for a new assignment. | | 0, 1, 4 | empty, `"AQ=="`, `"BA=="` | Not registered, registered but not active, retired | Refuse, and treat the API that offered it as suspect. | Use the public gateway of your chain id unless you run your own: `https://gateway.multiversx.com` for chain `1` and `https://devnet-gateway.multiversx.com` for chain `D`. Any observer node you trust works the same way. ### Let the SDK run it Each SDK has a relayer verifier. You create it with a gateway, your pinned contract address and the chain id, and pass it to the relay flow. The flow runs it after the API assigns a relayer and before your transaction is built or signed. If the check fails, the flow stops with the verifier's `RelayerVerificationError`, and your wallet is never asked to sign. The verifier behaves the same way in every SDK: - It compares the shards first, and asks the gateway only when they match. - It accepts only state 2 (Active). - It keeps each accepted relayer in its cache, keyed by chain id, registry version and relayer, for as long as the verifier exists. The registry version in that key comes from the assignment, so the cache relies on the API to report a new version when the registry changes. Create one verifier per network and reuse it. You can share it between threads or tasks. - It gives the gateway 5,000 ms to answer, unless you set another time limit. When the gateway does not answer, or answers with an error, the relayer is not verified and nothing is signed. The error carries the relayer address, the raw state when the gateway answered, and a reason: | Reason | When | What to do | |---|---|---| | `shard-mismatch` | The relayer is in a different shard from the sender. | Do not sign. Ask for a new assignment. | | `not-active` | The contract says the relayer is not Active. | Do not sign for this relayer. If the state is 3 (Draining), ask for a new assignment. Otherwise treat the API that offered it as suspect. | | `unreachable` | The gateway did not answer within the time limit, or answered with an error. | Try again in a moment, or ask another gateway you trust. | | `no-contract` | The verifier has no contract address. | Set the contract address of this chain id in your configuration. | | `malformed` | The relayer or the sender address could not be read. | Do not sign. | In Go the reason is a `RelayerVerificationFailure` string with these values. In Rust it is an enum, and `as_str()` returns these names. #### TypeScript ```ts import { createRelayerVerifier, type RelayOnceOptions, relayOnce } from '@corelayer/sdk'; /** `contract` is the CoRelayer contract of chain `1`, from your own configuration. */ export function createMainnetRelay(contract: string) { // Create the verifier once and reuse it: it remembers the relayers it has confirmed. const verifier = createRelayerVerifier({ gateway: 'https://gateway.multiversx.com', // a gateway CoRelayer does not run contract, chainId: '1', }); // Each call checks the assigned relayer before your wallet is asked to sign. return (options: RelayOnceOptions) => relayOnce({ ...options, verifyRelayer: (assignment) => verifier(assignment, options.sender) }); } ``` `relayOnce` throws the verifier's error unchanged. Set `timeoutMs` to change the time limit. The [TypeScript SDK page](/sdk/javascript) shows a whole transfer. #### Go ```go import ( "context" corelayer "github.com/Co-relayer/corelayer/packages/sdk-go" ) // NewMainnetVerifier checks relayers on mainnet. contract is the CoRelayer contract of chain "1", // from your own configuration. Create one and share it: it is safe for concurrent use and // remembers the relayers it has confirmed. func NewMainnetVerifier(contract string) *corelayer.RelayerVerifier { return corelayer.NewRelayerVerifier(corelayer.RelayerVerifierOptions{ Gateway: "https://gateway.multiversx.com", // a gateway CoRelayer does not run Contract: &contract, ChainID: "1", }) } // RelayChecked runs RelayOnce with the relayer check turned on. func RelayChecked(ctx context.Context, verifier *corelayer.RelayerVerifier, options corelayer.RelayOnceOptions) (*corelayer.RelayOnceResult, error) { options.VerifyRelayer = verifier.Hook(options.Sender) return corelayer.RelayOnce(ctx, options) } ``` `RelayOnce` returns the verifier's `*RelayerVerificationError` unchanged, so `errors.As` finds it. Set `Timeout` in the options to change the time limit. The [Go SDK page](/sdk/go) shows a whole transfer. #### Rust ```rust use std::future::Future; use corelayer::{ Assignment, BoxError, Client, Error, RelayOnceOptions, RelayOnceResult, RelayerVerifier, RelayerVerifierOptions, SignOnceInput, TransactionPlain, relay_once, }; /// Checks relayers on mainnet. `contract` is the CoRelayer contract of chain `1`, from your own /// configuration. Create one and share it between tasks: it remembers the relayers it has confirmed. pub fn mainnet_verifier(contract: &str) -> Result { RelayerVerifier::new(RelayerVerifierOptions { gateway: "https://gateway.multiversx.com".into(), // a gateway CoRelayer does not run contract: Some(contract.into()), chain_id: "1".into(), ..RelayerVerifierOptions::default() }) } /// Runs `relay_once` with the relayer check turned on. pub async fn relay_checked( client: &Client, verifier: &RelayerVerifier, sender: &str, build: B, sign: S, ) -> Result where B: FnOnce(&Assignment) -> Result, S: FnOnce(SignOnceInput) -> F, F: Future>, { let hook = verifier.hook(sender); let options = RelayOnceOptions { sender: sender.to_owned(), verify_relayer: Some(&hook), ..RelayOnceOptions::default() }; relay_once(client, options, build, sign).await } ``` A failed check arrives as `Error::RelayerVerification`. Set `timeout` in the options to change the time limit. The [Rust SDK page](/sdk/rust) shows a whole transfer. #### Python ```python from collections.abc import Callable from corelayer import ( Assignment, Client, RelayerVerifier, RelayOnceResult, SignOnce, UnsignedTransactionPlain, relay_once, ) def mainnet_verifier(contract: str) -> RelayerVerifier: """`contract` is the CoRelayer contract of chain "1", from your own configuration. Create one verifier and share it between threads: it remembers the relayers it has confirmed.""" return RelayerVerifier( "https://gateway.multiversx.com", # a gateway CoRelayer does not run contract, "1", ) def relay_checked( client: Client, verifier: RelayerVerifier, sender: str, build: Callable[[Assignment], UnsignedTransactionPlain], sign: SignOnce, ) -> RelayOnceResult: """Runs `relay_once` with the relayer check turned on.""" return relay_once( client, sender=sender, verify_relayer=verifier.hook(sender), build_transaction=build, sign_once=sign, ) ``` `relay_once` raises the verifier's `RelayerVerificationError`. Pass `timeout_ms` to change the time limit. With asyncio, use `AsyncRelayerVerifier` and `relay_once_async`; they take the same arguments. The [Python SDK page](/sdk/python) shows a whole transfer. ### Check it yourself If you call the API without an SDK, run these steps before you sign. Step 4 also limits how long you trust a cached answer: 1. Get an assignment. `POST /v1/relay/assign` returns the relayer, a lease and the registry version the service built its view from. 2. Compute the shard of the relayer and of the sender from the addresses. If they differ, refuse the relayer. 3. Through a gateway that CoRelayer does not run, call the view `getRelayerState` on your pinned contract: ```http POST https://gateway.multiversx.com/vm-values/query content-type: application/json { "scAddress": "", "funcName": "getRelayerState", "args": [""] } ``` The first element of `data.data.returnData` is the state as a base64 top-encoded integer. Look it up in the table under [What the check does](#what-the-check-does). 4. Cache a positive answer under `(chainId, registryVersion, relayer)` for at most 60,000 ms, the lifetime of a lease. A different `registryVersion` or a different relayer needs a new check. 5. Sign once, and submit with the lease. The module below does these steps. It is one of this site's runnable examples: the test suite type-checks it against the API types and runs it against a fixture gateway. ```ts title="verify-relayer.ts" snippet="examples/verify-relayer.ts" /** * Checking a relayer before signing anything that names it. * * Under Relayed v3 the relayer address is part of the bytes you sign, so nobody can change it * afterwards. An impostor could still hold on to a transaction you signed for it, and a signed * MultiversX transaction does not expire. So before you sign, check that the address you were given * is an active relayer in the CoRelayer registry, on the chain you are about to sign for: * * 1. it is in the sender's shard, computed from the two addresses with no network call; * 2. `getRelayerState(relayer)` is 2 (Active) on the contract pinned in your own configuration, * read through a gateway that CoRelayer does not run; * 3. a positive answer is cached under `(chainId, registryVersion, relayer)` for at most * 60,000 ms. * * `@corelayer/sdk` runs this check for you when you pass a verifier from `createRelayerVerifier` to * `relayOnce` as `verifyRelayer`. This module writes the steps out, for a program that calls the * API directly or wants to see how the check works. Its cache also lets a positive answer expire * after 60,000 ms. */ import { Address, AddressComputer } from '@multiversx/sdk-core'; import { type Gateway, topDecodeUint } from './gateway.ts'; /** Registry states, as `getRelayerState` returns them. The discriminants are frozen. */ export const RelayerState = { None: 0, Registered: 1, Active: 2, Draining: 3, Retired: 4, } as const; export type RelayerStateName = keyof typeof RelayerState; export function stateName(value: number): RelayerStateName | `unknown (${number})` { const entry = Object.entries(RelayerState).find(([, v]) => v === value); return entry === undefined ? `unknown (${value})` : (entry[0] as RelayerStateName); } const shards = new AddressComputer(3); /** The shard of an address, computed from the address alone. */ export function shardOf(address: string): number { return shards.getShardOfAddress(Address.newFromBech32(address)); } export class RelayerRejected extends Error { readonly relayer: string; readonly reason: 'shard' | 'state'; readonly state: number | undefined; constructor(relayer: string, reason: 'shard' | 'state', detail: string, state?: number) { super(`Refusing to sign for relayer ${relayer}: ${detail}`); this.name = 'RelayerRejected'; this.relayer = relayer; this.reason = reason; this.state = state; } } export interface RelayerCheckOptions { /** The chain id you sign for: `1` (mainnet) or `D` (devnet). */ readonly chainId: string; /** * The CoRelayer contract of that chain, from your own configuration and never from an API * response. */ readonly contract: string; /** A gateway for that chain that is not operated by CoRelayer. */ readonly gateway: Gateway; /** How long a positive answer is reused. Never more than the 60,000 ms lease lifetime. */ readonly cacheMs?: number; readonly now?: () => number; } export class RelayerCheck { readonly #options: RelayerCheckOptions; readonly #cacheMs: number; readonly #verified = new Map(); constructor(options: RelayerCheckOptions) { this.#options = options; this.#cacheMs = Math.min(options.cacheMs ?? 60_000, 60_000); } /** The on-chain registry state of one address, read through the gateway. */ async state(relayer: string, signal?: AbortSignal): Promise { const { gateway, contract } = this.#options; const [first] = await gateway.query( contract, 'getRelayerState', [Address.newFromBech32(relayer).toHex()], signal, ); return Number(topDecodeUint(first ?? new Uint8Array())); } /** * Throws `RelayerRejected` unless `relayer` may be signed for by `sender`. `registryVersion` is * the value the assignment carried; a different version is a different cache entry. */ async verify( sender: string, relayer: string, registryVersion: number, signal?: AbortSignal, ): Promise { if (shardOf(relayer) !== shardOf(sender)) { throw new RelayerRejected( relayer, 'shard', `it is in shard ${shardOf(relayer)}, the sender is in shard ${shardOf(sender)}`, ); } const now = this.#options.now ?? Date.now; const key = `${this.#options.chainId}:${registryVersion}:${relayer}`; const until = this.#verified.get(key); if (until !== undefined && until > now()) return; const state = await this.state(relayer, signal); if (state !== RelayerState.Active) { // Draining: it finishes what it already accepted and takes nothing new, so ask for a new // assignment. Any other state: not a CoRelayer relayer, or not any more. Treat the API as // suspect. throw new RelayerRejected(relayer, 'state', `registry state is ${stateName(state)}`, state); } this.#verified.set(key, now() + this.#cacheMs); } } ``` It reads the chain through a small gateway client: ```ts title="gateway.ts" snippet="examples/gateway.ts" /** * Two reads from a MultiversX gateway that CoRelayer does not run. * * Before you sign, you need to know whether a relayer is really a CoRelayer relayer and which nonce * your account is at. Both are chain state, so read them from the chain, through a node you chose. * Asking the API those questions would mean trusting the party whose answer is being checked. * * The default public gateway of each network is `https://gateway.multiversx.com` (chain id `1`) and * `https://devnet-gateway.multiversx.com` (chain id `D`). Any observer you trust works the same way. */ export interface GatewayOptions { /** Base URL of the gateway, without a trailing slash. */ readonly url: string; /** Injected for tests; defaults to the global `fetch`. */ readonly fetch?: typeof globalThis.fetch; } /** The gateway wraps every answer as `{ data: …, error: string, code: string }`. */ interface Envelope { readonly data?: T; readonly error?: string; readonly code?: string; } export class Gateway { readonly #url: string; readonly #fetch: typeof globalThis.fetch; constructor(options: GatewayOptions) { this.#url = options.url.replace(/\/$/, ''); this.#fetch = options.fetch ?? globalThis.fetch; } async #call(path: string, init: RequestInit = {}): Promise { const response = await this.#fetch(`${this.#url}${path}`, init); const body = (await response.json()) as Envelope; if (!response.ok || body.code !== 'successful' || body.data === undefined) { throw new Error( `Gateway ${path} failed: ${response.status} ${body.error ?? body.code ?? ''}`, ); } return body.data; } /** The account's current on-chain nonce: the nonce its next transaction must carry. */ async accountNonce(address: string, signal?: AbortSignal): Promise { const data = await this.#call<{ account: { nonce?: number } }>( `/address/${encodeURIComponent(address)}`, signal === undefined ? {} : { signal }, ); return data.account.nonce ?? 0; } /** * Runs a read-only contract view. Arguments are hex; each returned part is the raw bytes of one * top-encoded value. */ async query( contract: string, funcName: string, args: readonly string[], signal?: AbortSignal, ): Promise { const data = await this.#call<{ data: { returnData?: (string | null)[] | null; returnCode?: string; returnMessage?: string }; }>('/vm-values/query', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ scAddress: contract, funcName, args }), ...(signal === undefined ? {} : { signal }), }); const { returnData, returnCode, returnMessage } = data.data; if (returnCode !== 'ok') { throw new Error( `${funcName} on ${contract}: ${returnCode ?? 'no return code'} ${returnMessage ?? ''}`, ); } return (returnData ?? []).map((part) => Uint8Array.from(Buffer.from(part ?? '', 'base64'))); } } /** A top-encoded unsigned integer: big-endian, no padding, zero is the empty byte string. */ export function topDecodeUint(bytes: Uint8Array): bigint { let value = 0n; for (const byte of bytes) value = (value << 8n) | BigInt(byte); return value; } ``` It runs once per signature, inside the signer callback, so nothing is signed for a relayer that failed it: ```ts snippet="examples/send-token.ts#verify-then-sign" // The single signature. The relayer is checked on chain first; if it is not an active // CoRelayer relayer in your shard, nothing is signed. signOnce: async ({ assignment, transaction }) => { await relayers.verify(wallet.address, assignment.relayer, assignment.registryVersion, signal); return wallet.signTransaction(transaction); }, ``` ### After the fact Anyone can audit a relayed transaction that has already executed. Its `relayer` must have been `Active` or `Draining` at the block's timestamp, according to the history of `relayerStateChanged` events. CoRelayer's settlement reconciliation requires the same of every unit it bills: a billed unit must map to a transaction hash whose relayer is in the registry. ### The API's copy, and when to use it `GET /v1/relayers` returns the registry as the service mirrors it: `{ contract, registryVersion, relayers: [{ address, shard, state, weight, operatorId, slaClass }] }`. It is useful for display, and the dashboard's relayer screen uses it. It does not replace the check above. A client should never trust an API response about which address is a real relayer, because that is exactly the claim an attacker would want to make. The compatibility route `GET /relayer/address/{userAddress}` does no verification, because the clients it exists for do not expect any. For a new integration, use the native API and the check on this page. ### Next - How the relayer set is run, funded and rotated: [Relayers](/concepts/relayers) - Why the relayer must share your shard: [Shards and routing](/concepts/shards-and-routing) - The views, generated from the ABI: [`getRelayerState`](/contract/reference/views), [`getActiveRelayers`](/contract/reference/views), [`getRegistryVersion`](/contract/reference/views) --- ## Why gasless A blockchain charges for the work it does, and it charges in its own token. On MultiversX that token is EGLD. This is a reasonable design and it has one consequence that is felt by almost every new user and almost every program: **you cannot do anything at all until you hold EGLD**, even if what you wanted to do has nothing to do with EGLD. ### The three cases where it hurts #### The first transaction Someone receives USDC. They open a wallet. They want to send some of it on. They cannot, because the wallet needs a fee and the fee is a token they have never heard of. The fix is a trip to an exchange to acquire a few cents' worth of a second asset — which, for most people, is where the story ends. #### The balance that has to be kept A business that settles in a stablecoin has to keep a float of a *different*, volatile asset purely to be allowed to move the first one. That float has to be monitored, topped up, accounted for and written down when the price moves. It is an operational tax on doing anything on chain. #### The program with no human An agent, a scheduler, a backend job — anything that acts without someone watching — cannot go and buy gas. Give it a gas balance and you have given it an expiry date: it runs until the balance is empty, then it stops, and someone has to notice. Most autonomous systems fail this way long before they fail for an interesting reason. ### What a relayer changes MultiversX has a protocol feature for exactly this. In a **relayed transaction**, the fee is paid by an account other than the sender: the *relayer*. The sender signs, the relayer signs, and the network charges the relayer. Nothing about the sender's authority changes — it is still the sender's signature that authorises the transfer or the contract call. So the requirement moves. Instead of *everyone* needing EGLD, *one* party needs EGLD, and everyone else can pay that party in whatever they actually hold. CoRelayer is that party, and it takes payment in USDC. ```mermaid flowchart TB subgraph without["Without a relayer"] direction LR U1["Every user"] --> E1["must hold EGLD"] --> T1["to transact"] end subgraph with["With a relayer"] direction LR U2["Every user"] --> P2["pays USDC per 30 days,
or their app pays for them"] --> R2["CoRelayer holds EGLD"] --> T2["everyone transacts"] end ``` ### What it costs Nothing is free, and it is worth being precise about where the cost lands. - **CoRelayer carries the EGLD risk.** We buy EGLD, we spend it on fees, and the price of EGLD moves. Your price is fixed in USDC for the period you paid for; the exposure between those two facts is ours. That is the product. - **You pay a margin.** The subscription is priced above the expected fee cost, because the service has to cover volatility, the infrastructure that makes delivery reliable, and failed transactions. A transaction that runs and fails still costs the relayer its full fee, so it counts against your plan; one that never runs costs nothing and does not count. ([Delivery guarantees](/concepts/delivery-guarantees)) - **You give up nothing else.** Not custody, not control over the transaction, not the ability to verify. Those are the properties a relayer design must not cost you, and the rest of this section explains how each one is preserved. ### What it does not change | Still true | Why | |---|---| | Only you can authorise your transaction | Your Ed25519 signature is what moves your funds. A relayer signature authorises nothing but the fee. | | Your nonce still orders your transactions | Relayed v3 consumes the sender's nonce, not the relayer's. | | The transaction is public and verifiable | It goes to the same chain, into the same blocks, with both signatures visible. | | The network's rules still apply | Balance, gas, the contract's own checks. A relayer cannot make an invalid transaction valid. | ### Next - The protocol feature itself: [Relayed v3](/concepts/relayed-v3) - Why you are never asked to sign twice: [One signature](/concepts/one-signature) - What the service can and cannot do to you: [Security](/security/overview) --- ## 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. --- ## Credits and billing There are four money-shaped things in CoRelayer, and they behave differently. Keeping them apart is most of understanding the billing. | | What it is | Created by | Reversible | |---|---|---|---| | **A deposit** | USDC arriving at the contract | You, signing a transfer | No — it becomes credits in the same call | | **Credits** | A USDC-denominated balance inside the contract | A deposit | No | | **A plan block** | The record of what you bought, with its terms frozen | Spending credits on a tier | No | | **Escrow** | Credits set aside for pay-as-you-go usage | Opting into pay-as-you-go | **Back to credits** — never to your wallet — once you turn pay-as-you-go off | ### Depositing A deposit is an ESDT transfer of USDC to the contract that also calls one of three functions: | Function | Effect | |---|---| | `deposit()` | Credits your own account. | | `depositFor(beneficiary)` | Credits someone else's account. | | `depositAndSubscribe(tier, months, max_price, ref)` | Deposits **and** buys a plan, in one transaction. | The contract swaps the USDC in the same call and credits you **1:1 from the USDC you paid** — not from what the swap returned. ([Where the money goes](/concepts/revenue-flow)) | Bound | Launch value | |---|---| | Minimum deposit | 1 USDC. It exists because a deposit costs a relayer gas, so a dust deposit would cost more to process than it is worth. It is deliberately not scaled by the tariff. | | Maximum deposit | Unlimited at launch. If the owner ever sets a ceiling, it bounds both a single call and the total swapped per minute, and the API refuses over-large deposits *before* co-signing rather than letting the transaction revert. | If the swap venue is paused, or the pair or token is unavailable, the whole deposit reverts and you keep your USDC. The API sees this coming and answers [`SWAP_VENUE_PAUSED`](/errors/swap-venue-paused) instead of relaying a transaction that would certainly fail — which matters because on the free purchase flow, our relayer pays for that failure. ### Buying with a price ceiling **No purchase path exists without a ceiling you set yourself.** Every purchasing call takes a `max_price`, and the contract reverts if the price at execution time is above it. This includes adding named wallets, which also costs Relay Units. A sponsor key costs nothing beyond the Relay Units it relays. The flow the dashboard and the SDK use: 1. `POST /v1/subscribe/prepare` returns a **quote** and the unsigned transaction. 2. You sign the transaction. Its arguments — tier, months, `max_price`, and a reference — are part of the signed bytes, so what you approved is what executes. 3. The contract prices the purchase at the block timestamp and reverts if that exceeds your ceiling: [`PRICE_ABOVE_MAX`](/errors/price-above-max). ```json title="A quote" { "quoteId": "q_01JA7M3Z9K", "tierId": 12, "months": 1, "tariff": "10000", "tariffVersion": 1, "priceMicroUsdc": "85000000", "maxPrice": "85000000", "pendingTariff": null, "issuedAtMs": 1789819200000, "expiresAtMs": 1789819320000 } ``` Three properties of quotes worth knowing: - **They live for 120 seconds**, and they are stateless. A quote is not a row in a table we could lose or mismatch across hosts; it is content plus a signature over that content, so any host can verify one it did not issue. Asking twice simply gives you two valid quotes. - **`maxPrice` equals the price.** There is no built-in slack. If the price goes *down* between quote and block, you are charged less. - **A quote never straddles a price increase silently.** If an increase becomes effective before the quote expires, the quote is priced at the pending, higher value and says so. A transaction that lands before the activation pays the lower current price and the surplus stays as credits. You are never charged more than you accepted, and the purchase never fails for this reason. ### What a purchase freezes Paying writes a **plan block**, and a plan block is immutable for its term. It records: - the cap in Relay Units and the length of the period; - the pay-as-you-go price per unit; - the number of named wallets (listed senders) allowed; - the rate class and service class; - the tariff version it was priced at. Nothing later can change it. Raising the tariff does not; editing the tier does not; retiring the tier does not. An account holds at most one current block and one queued block, so buying ahead does not disturb what is running. ### Prepaying, upgrading and downgrading | | | |---|---| | **Prepaying** | Buy 1 to 12 months in one purchase. Every month is priced at the tariff in force when the purchase executes, and that price holds for the whole block. There is no discount for prepaying: the benefit is the locked price. | | **Unused transactions** | They belong to the period they were bought for and expire with it. Nothing rolls over into the next period. | | **Upgrading mid-period** | A tier with a higher price starts **now**. The months of the old block that had not started are credited back to your credits, and the unused part of the current period's cap carries over **once**, into the first period of the new block. The started period itself is not refunded. | | **Downgrading** | There is no immediate downgrade. A lower tier starts when your paid block ends: buy it as the queued block, or set it as your auto-renew tier. A prepaid block cannot be shortened or swapped for a cheaper one. | A credit-back is not a refund: it returns to your credits, which buy plans and pay-as-you-go usage and are not converted back to USDC. ### Renewal | | | |---|---| | **Manual** | Buy again. The new block queues behind the current one. | | **Auto-renew** | `setAutoRenew(enabled, tier, max_renew_price)` — and `max_renew_price` must be greater than zero when enabled. | Zero never means "unlimited". An account that renews itself has to state its ceiling explicitly; that is a deliberate refusal to offer an open-ended standing authorisation, and it matters most for autonomous buyers, which are exactly the accounts most likely to renew unattended. Renewal is permissionless: anyone may trigger the renewal of an account that has opted in, because the terms are fixed by the account itself and the contract enforces them. This keeps renewals working without a scheduler that has to be running at midnight. ### When there is no entitlement | Situation | What you get | |---|---| | No plan at all | [`NO_ENTITLEMENT`](/errors/no-entitlement) — 402, with a pointer to the pricing document and the x402 endpoint. | | Cap used up, pay-as-you-go off | [`QUOTA_EXHAUSTED`](/errors/quota-exhausted) — 429 with **no** `Retry-After`, because waiting does not help. | | Cap used up, pay-as-you-go on, escrow empty | [`QUOTA_EXHAUSTED`](/errors/quota-exhausted) with `details.reason = PAYG_ESCROW_EMPTY` — again without `Retry-After`. | | Pay-as-you-go price above your `max_payg_price` | [`PAYG_PRICE_ABOVE_MAX`](/errors/payg-price-above-max). | | A purchase from credits, and there are not enough | [`INSUFFICIENT_CREDITS`](/errors/insufficient-credits) — deposit first, or buy with `depositAndSubscribe`. | | Suspended | [`ACCOUNT_SUSPENDED`](/errors/account-suspended). | ### Escrow, and how it comes back Opting into pay-as-you-go moves credits into escrow: an amount set aside for usage beyond the cap. Only settled pay-as-you-go usage can spend it, and nothing else can spend it — that separation is what stops the same credits being spent twice, once on a plan and once on usage. What pay-as-you-go does not use comes back **to your credits** — not to your wallet; credits are never refundable — once you turn pay-as-you-go off: 1. **Turn it off.** `setPayg(false, …)` stops new pay-as-you-go usage and starts the close. The escrow stays locked for now, because usage from before the switch may still be settling. 2. **The normal release.** Once every pay-as-you-go transaction from before the switch is settled, and at least an hour after it, the settlement key sends a closing line that returns the rest to your credits. 3. **The fallback.** If that never happens — the settlement key dead, or hostile — anyone may call `releaseEscrow(account)` seven days after the switch, and the escrow returns to that account's credits. When the account itself sends it, CoRelayer relays it free of charge. No pause scope blocks this endpoint: a deliberate constraint on ourselves, so that no switch of ours can lock it. Lowering the pay-as-you-go budget never releases escrow; only turning pay-as-you-go off does. ### Who pays for a given transaction When a transaction arrives, the payer is resolved in a fixed order: 1. a sponsor API key, if one was presented; 2. the account explicitly named in the request, if that account authorised this sender on chain; 3. the sender's own account; 4. the oldest account that authorised this sender; 5. the free list — a short, published set of calls to our own contract that CoRelayer pays for, so that buying and managing a plan never requires already having one. If none of these applies you get `NO_ENTITLEMENT`, `SENDER_NOT_AUTHORIZED` or `QUOTA_EXHAUSTED`, depending on which step failed. ### Reading your own numbers | | | |---|---| | `GET /v1/account/{erd}` | Plan, credits, flags — a mirror of chain state, public. | | `GET /v1/account/{erd}/quota` | Units used, cap, period end. | | `GET /v1/account/{erd}/purchases` | Every purchase, from the contract's own events. | | `GET /v1/usage`, `GET /v1/usage/summary` | Per-transaction and aggregated usage. Private. | --- ## Pay-as-you-go A plan has a cap. Pay-as-you-go decides what happens when you reach it: stop, or keep going and pay per unit. **It is off by default.** A service that silently kept spending your money past the limit you chose would be making a decision that is yours to make. ### Turning it on ``` setPayg(enabled, budget, auto_topup, max_payg_price) ``` | Argument | What it does | |---|---| | `enabled` | Whether relays continue past the cap. | | `budget` | How much, in micro-USDC, to hold in escrow for this. | | `auto_topup` | Whether to refill the escrow back to `budget` at the start of each period. | | `max_payg_price` | A ceiling on the price per Relay Unit. `0` means no ceiling. | All four are set in one on-chain call, and the dashboard builds it for you under **Settings**. `POST /v1/flags/prepare` builds the same transaction for a programmatic client. ### What a unit costs The pay-as-you-go price is derived from the same single lever as everything else: ``` payg_price(tier) = tariff × payg_bps / 10,000 ``` Each tier carries its own `payg_bps`, and every one of them is above 10,000 — so a unit past the cap always costs more than a unit inside it. That is the intended shape: buying the right size is cheaper than overflowing a small plan. At the launch configuration (tariff 10,000 micro-USDC per unit, snapshot of 2026-09-19): | Tier | Inside the cap | Past the cap | Difference | |---|---|---|---| | Starter | $0.0100 | $0.0130 | +30% | | Builder | $0.0090 | $0.0125 | +39% | | Growth | $0.0080 | $0.0120 | +50% | | Agent Pro | $0.0085 | $0.0120 | +41% | | Agent Metered | — (no cap) | $0.0150 | metered only | Live values: `GET /v1/pricing`, or `getPaygPrice(tier)` on the contract. ### The escrow Pay-as-you-go spends from an escrow, not from your credits directly. The escrow is a bounded amount you put aside for this purpose, and it has one useful property: **it is the maximum**. Whatever happens — a runaway loop in your own code, a busier month than expected — pay-as-you-go cannot spend past it. ```mermaid flowchart LR C["Credits"] -->|"setPayg: budget moved in"| E["Escrow"] E -->|"settled usage past the cap"| U["Paid for"] E -->|"pay-as-you-go turned off:
closing line, or releaseEscrow after 7 days"| C C -.->|"auto_topup: refilled at period start"| E ``` When the escrow is empty, relays stop with [`QUOTA_EXHAUSTED`](/errors/quota-exhausted) and `details.reason = PAYG_ESCROW_EMPTY`. What the escrow did not spend returns to your **credits** — not to your wallet — once you turn pay-as-you-go off; lowering the budget never releases any of it. ([Credits and billing](/plans/credits-and-billing)) With `auto_topup` on, the escrow is refilled to the budget from your credits **only at the start of a period**, after the plan itself has been paid. So for a plan, the budget is a true per-period ceiling on pay-as-you-go spend. ### The price ceiling `max_payg_price` matters for accounts whose pay-as-you-go price moves: on [Agent Metered](/plans/agent-tiers), the price per unit follows the current tariff. (On a plan tier it cannot move — the pay-as-you-go price is frozen in the plan block you bought.) A metered account with no ceiling would simply follow the tariff; `0` means exactly that. With a ceiling set, the service stops serving pay-as-you-go for the account as soon as the effective price is above it, and answers [`PAYG_PRICE_ABOVE_MAX`](/errors/payg-price-above-max); the contract independently refuses to settle a line priced above it. A tariff increase carries 48 hours of notice, and the notice is delivered — banner, `GET /v1/account/{erd}/notices`, `GET /v1/stream` — so the ceiling is a backstop rather than a surprise. ([Tariff](/plans/tariff)) ### Behaviour at the cap | Pay-as-you-go | At the cap | |---|---| | Off | Relays stop. [`QUOTA_EXHAUSTED`](/errors/quota-exhausted) with `details.reason = CAP_REACHED_PAYG_OFF`, a 429 with **no** `Retry-After` — because waiting genuinely does not help. The error carries the pricing URL and the x402 URL, so a client knows where to go. | | On, escrow funded | Relays continue, billed per unit at the pay-as-you-go price. | | On, escrow empty | Relays stop. [`QUOTA_EXHAUSTED`](/errors/quota-exhausted) with `details.reason = PAYG_ESCROW_EMPTY`. | | On, a floating price above your ceiling | Relays stop. [`PAYG_PRICE_ABOVE_MAX`](/errors/payg-price-above-max), until the price is back under the ceiling or you raise it. | Rate limits are separate and always apply: a plan with pay-as-you-go on is not a plan without a rate class. [`RATE_LIMITED`](/errors/rate-limited) is a different answer from `QUOTA_EXHAUSTED`, and it *does* carry a `Retry-After`, because in that case waiting is exactly the right thing to do. ([Limits](/operations/limits)) ### When it is the right choice - **Spiky traffic.** Buy the tier that matches your normal month and let the peaks be metered. Cheaper than permanently paying for headroom you use twice a year. - **An unknown starting volume.** Start small, watch `GET /v1/usage/summary` for a month, then buy the tier the data points at. - **Never running out.** If a stopped relay is worse for you than an unexpected invoice, turn it on and set a budget you can live with. ### When it is not - **A tight, known budget.** Leave it off. The cap becomes a hard limit you cannot exceed. - **Sustained volume above the cap.** Moving up a tier is cheaper per unit than metering; the table above is the whole argument. --- ## 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. | --- ## The tariff The tariff is **one unsigned integer stored in the contract**: micro-USDC per Relay Unit. At launch it is `10_000`, which is $0.010 per Relay Unit. Everything with a price is derived from it: ```text price(tier) = price_units(tier) × tariff payg_price(tier) = tariff × payg_bps(tier) / 10,000 sender_fee = sender_fee_ru × tariff ``` Nothing else in the system carries a USDC price. Add-ons that cost something — adding a named wallet, for example — are priced in Relay Units, so the one value keeps rescaling everything at once. That is the design: one lever, publicly readable, with hard limits on how it may move. Because there is exactly one lever, no plan's price can move on its own, and there is no negotiated rate that others cannot see: a custom plan is created on chain like any other tier. Paying up to 12 months ahead locks today's tariff for the whole prepaid block. ([Credits and billing](/plans/credits-and-billing)) ### Who may change it Only the **owner** — the deploying key, held offline. Not the operator, whose key is warm and whose job is incident response. The operator can pause things; it cannot reprice them. ### What the contract will not allow These are compiled constants of the contract, not settings. Changing one requires an upgrade, which is public and deliberate. A settable notice period could be set to zero by the very key it is meant to restrain. | Rule | Value | |---|---| | Absolute floor | 1,000 micro-USDC per Relay Unit | | Absolute ceiling | 1,000,000 micro-USDC per Relay Unit | | Step per call | At most a doubling, at least a halving — the new value must lie in `[current/2, current × 2]` | | Notice for an **increase** | 48 hours before it takes effect | | Notice for a **decrease** | None; it is immediate | | Granularity | A multiple of 100 | The step limit caps the rate of increase at a doubling per 48 hours. The granularity rule exists so that every pay-as-you-go price is an exact integer and no rounding rule has to exist anywhere in the billing. ### The 48 hours are real ```mermaid sequenceDiagram participant O as Owner participant C as Contract participant B as Backend participant Y as You O->>C: setTariff(new) (an increase) C-->>C: pending = new, effective in 48 h C-->>B: tariffScheduled(old, new, effectiveMs) B->>Y: account notice · account stream · pricing document Note over Y: 48 hours C-->>C: lazily activated by the next pricing call C-->>B: tariffActivated(version, tariff, effectiveMs) ``` A scheduled increase is announced the moment it is scheduled, through every channel: the `pending` member of the pricing document, an account notice (in the notice feed and on the account stream), a banner in the dashboard. A notice period that is not delivered is not a notice period. Only one change can be pending at a time, and scheduling a new one cancels the old one. That keeps the invariant honest: a decrease that left an older, larger increase queued behind it could produce a jump bigger than the step rule allows. ### What a change does to your money **Nothing, for anything already paid for.** A purchase snapshots its terms into a plan block. Tariff changes do not reach into a block that has been paid for — in either direction. If the tariff halves tomorrow, what you bought today is still what you bought. What a change touches: | | Effect of an increase | |---|---| | Plans you already paid for | None. | | Your next renewal | Priced at the new tariff — which is why auto-renew requires an explicit `max_renew_price`. | | Pay-as-you-go usage after activation | Priced at the new tariff — which is why `setPayg` takes a `max_payg_price`. | | A quote already issued | A quote whose 120-second life crosses an activation is priced at the **pending**, higher value and says so. It can only ever charge you less than you accepted, never more. | ### Why not an oracle Fees are paid in EGLD and EGLD has a market price, so an automatic peg is the obvious idea. It is rejected, for reasons worth stating: - **The contract would have to trust a price feed.** That is a new dependency with its own failure modes and its own attack surface, inside the one component that must be simplest. - **Prices would move without notice.** The 48-hour rule would be meaningless if a feed could move the number. - **It would be unreadable.** "Micro-USDC per Relay Unit, currently 10,000" can be checked by anyone in one call. A formula over an oracle cannot. The contract never reads a price. Adjusting the tariff is a human decision, made publicly, with notice — and the pricing policy behind it is documented rather than secret: it targets covering the deepest tier discount, the relayer's share of revenue and the swap cost at the prevailing EGLD price, with a small headroom. There is also a deliberate asymmetry: **the tariff is sticky downward.** Credits deposited while EGLD was expensive and spent after a cut are under-hedged, which is CoRelayer's problem and nobody else's — so cuts happen for competitive reasons, not automatically. ### Reading it, and its history | Call | Gives | |---|---| | [`getTariff`](/contract/reference/views) | Current value, pending value, effective time, version. | | [`getEffectiveTariff`](/contract/reference/views) | The value in force right now, applying any due activation. | | [`getTariffByVersion`](/contract/reference/views) | Any historical value by version. | | [`getTariffHistory`](/contract/reference/views) | The full append-only history. | | `GET /v1/pricing` | The same, plus prices, as a document. | | `GET /v1/pricing/tariff-history` | The same history over HTTP. | The history is append-only in the contract, and the version number is simply its length. There is no separate counter that could disagree with the list. ### The Relay Unit schedule changes the same way The [formula](/concepts/relay-units) that converts a transaction into Relay Units is itself versioned, hashed into the contract, and changed under the same 48-hour notice. So neither half of "what you pay" — the price per unit, or what counts as a unit — can move without warning. --- ## Tiers A tier is what you buy. It fixes, for the period you paid for: how many [Relay Units](/concepts/relay-units) you may use, what that costs in USDC, what a unit beyond the cap costs, whether a sponsor key can pay for your users, how many wallets you can name on chain, and which rate class you are in. :::info[These are snapshot values] The tables below are the launch configuration as of **2026-09-19**, at a tariff of **10,000 micro-USDC per Relay Unit** ($0.010/RU). The authoritative live values are `GET /v1/pricing`, which reads them from the contract. Every number in the tables below is also held as data in this site's source (`data/tiers.json`, itself generated), and a test fails the build if the two ever disagree. ::: ### For people What each plan costs, and how much it covers: | Tier | Relay Units / month | Price | Effective / RU | Pay-as-you-go / RU | Status | |---|---|---|---|---|---| | **Starter** | 1,000 | $10 | $0.0100 | $0.0130 | Available | | **Builder** | 5,000 | $45 | $0.0090 | $0.0125 | Available | | **Growth** | 25,000 | $200 | $0.0080 | $0.0120 | Available | | **Scale** | 100,000 | $700 | $0.0070 | $0.0115 | Not yet sold | | **Enterprise** | 500,000 | $3,000 | $0.0060 | $0.0110 | Not yet sold | Who it can pay for, and how it is served: | Tier | All your users (sponsor key) | Named wallets (key mode) | Rate class | Service class | |---|---|---|---|---| | **Starter** | — | 3 | 1 | Shared pool | | **Builder** | ✓ | 10 | 2 | Shared pool | | **Growth** | ✓ | 50 | 3 | Priority lane | | **Scale** | ✓ | 250 | 4 | Priority lane | | **Enterprise** | ✓ | 1,000 | 5 | Dedicated relayers | ### For agents | Tier | Relay Units / month | Price | Effective / RU | Pay-as-you-go / RU | Status | |---|---|---|---|---|---| | **Agent Metered** | none (metered) | $0 | — | $0.0150 | Available | | **Agent Pro** | 10,000 | $85 | $0.0085 | $0.0120 | Available | | **Agent Fleet** | 100,000 | $650 | $0.0065 | $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 | The agent tiers are explained separately, including why "Agent Metered" has no cap at all: [Tiers for agents](/plans/agent-tiers). ### Reading the tables **Relay Units per month** is the cap. Every relayed transaction costs at least one unit; the [formula](/concepts/relay-units) says how many. **Price** is `price_units × tariff`. The tier stores `price_units`; the tariff is one number on chain. That is the whole pricing model — one lever, and nothing else in the system carries a USDC price. ([Tariff](/plans/tariff)) **Effective per Relay Unit** is simply price ÷ cap. It is derived here, never stored, so it cannot disagree with the two numbers it comes from. It is what makes the ladder visible: a larger plan is cheaper per unit. **Pay-as-you-go per Relay Unit** is what a unit costs *beyond* the cap, if you have opted in. It is always above the effective price of the plan — buying the right size is cheaper than overflowing a small one. ([Pay-as-you-go](/plans/payg)) **All your users (sponsor key)** (for agents, **all your agents**) marks the plans where a sponsor key on your server pays for any sender: no list to keep and no limit on the number of senders, only the key's own allow-lists and daily limits. ([Paying for other senders](/plans/sponsoring-senders)) **Named wallets (key mode)** is how many addresses besides your own your plan may pay for by listing them on chain. The contract enforces it when you add one. **Rate class** decides throughput: units per second, a burst allowance, a per-shard gas budget, a ceiling on any single transaction's gas limit, and units per hour. ([Limits](/operations/limits)) **Service class** describes which relayers serve you — a shared pool, a priority lane, or relayers dedicated to your account. It is a label stored on the tier, so it is part of what you bought, visible on chain. ### "Not yet sold" Scale, Enterprise and Agent Fleet are configured but marked as not purchasable. The reason is capacity, and it is worth being blunt about it: those tiers commit to throughput that requires a larger relayer float than is funded, and selling a plan we cannot serve would be worse than not listing it. They become purchasable when the float behind them exists — announced in the [changelog](/changelog). The API reports this honestly rather than hiding the tier: `available: false` with `unavailableReason: "CAPACITY"`. Attempting to buy one returns [`TIER_NOT_PURCHASABLE`](/errors/tier-not-purchasable). ### The live values ```bash curl -sS https://api.co-relayer.com/v1/pricing ``` The response carries the tier list, the tariff and its version, the pay-as-you-go rates, the rate classes and the document's own version. Amounts in the pricing document are **decimal strings**, not JSON numbers, because agents recompute them in arbitrary languages and a float is not a price. Everything in it is derived from contract views you can call yourself: | Contract view | What it gives | |---|---| | [`getTiers`](/contract/reference/views) | Every tier record, as the contract stores it. | | [`getPrice`](/contract/reference/views) | The price of a tier at the effective tariff. | | [`getPaygPrice`](/contract/reference/views) | The pay-as-you-go price of a tier. | | [`getPricingConfig`](/contract/reference/views) | Tariff, Relay Unit schedule and deposit bounds in one call. | | [`getRateClasses`](/contract/reference/views) | The rate-class table. | ### What a purchase freezes When you buy, the contract writes a **plan block**. It records the cap, the period length, the pay-as-you-go price and the named-wallet limit — and nothing afterwards can reach into it. Editing a tier, raising the tariff, even retiring the tier entirely leaves a block that was already paid for exactly as it was sold. ([Credits and billing](/plans/credits-and-billing)) ### Choosing - Pick the tier whose cap you expect to *use*, not the one that covers your worst month. Pay-as-you-go handles the peaks and costs less than permanently buying headroom. - Paying for the users of an app? Pick Builder or above and use a sponsor key. Then the cap, not the number of users, is what you size. The named-wallet count matters only for addresses you list on chain. - If throughput matters more than volume — bursts rather than totals — read [Limits](/operations/limits) first. The rate class is often the real reason to move up. --- ## 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)) --- ## Go `github.com/Co-relayer/corelayer/packages/sdk-go` (package `corelayer`) is the Go client for the CoRelayer API. It needs Go 1.24 or later and uses only the standard library. Every API operation is a typed method, and helpers cover relaying with one signature, checking the relayer on chain, native auth, event streams, webhooks and x402. ### Install :::note[No release yet] Once a version is published, install it with `go get github.com/Co-relayer/corelayer/packages/sdk-go`. Until then, every call it makes is an ordinary HTTPS request you can send yourself: the API is described in [`/openapi.yaml`](pathname:///openapi.yaml), and [Pay for your users](/sdk/recipes/sponsor-users#without-an-sdk) shows a whole relay with no SDK. ::: ### Quick start ```go package main import ( "context" "fmt" "log" corelayer "github.com/Co-relayer/corelayer/packages/sdk-go" ) func main() { client, err := corelayer.NewClient(corelayer.ClientOptions{BaseURL: "https://api.co-relayer.com"}) if err != nil { log.Fatal(err) } network, err := client.GetNetwork(context.Background()) if err != nil { log.Fatal(err) } fmt.Println("chain:", network.Data.ChainID, "min gas price:", network.Data.MinGasPrice) } ``` For devnet, use `https://devnet-api.co-relayer.com`. `GetNetwork` also returns the regional direct hosts: pass `network.Data.DirectHosts` as `ClientOptions.DirectHosts`, and the client fails over to them when the main host is slow or down. A `Client` is safe for concurrent use, so create one and share it. ### Authentication You pass a key, or a function the client calls before each request, and the client adds the right header. | Who is calling | Option | Sent as | |---|---|---| | Your server paying for your users (a sponsor key), or an agent with an API key | `APIKey` | `X-Api-Key` header, on the relay and the account reads only | | An app acting for a signed-in wallet | `NativeAuthToken` | `Authorization: Bearer ` | Public routes such as `GetNetwork` need none of them, and the doc comment of each method says which credentials it accepts. Keep an API key on your server, and never ship it in a browser or mobile app. `NativeAuthToken` is called before every request, so a token you refresh is used on the next call. Return `""` when the user is signed out. An agent can build its own native-auth token. The package gives you the message to sign, and your key or wallet signs it: ```go // agentToken builds a native-auth token for address. signMessage signs a message the way a // MultiversX wallet's signMessage does, with the signed-message prefix a token needs (unlike a // presence proof), and returns the signature as 128 hex characters. func agentToken(ctx context.Context, client *corelayer.Client, address string, signMessage func(message string) (string, error)) (string, error) { network, err := client.GetNetwork(ctx) if err != nil { return "", err } block := network.Data.NativeAuth // a recent shard-1 block if block == nil || block.Origin == nil { return "", errors.New("the API returned no native-auth block") } body, err := corelayer.EncodeNativeAuthBody(corelayer.NativeAuthBody{ Origin: *block.Origin, BlockHash: block.BlockHash, TTLSeconds: corelayer.MaxTTLSeconds, }) if err != nil { return "", err } signature, err := signMessage(corelayer.NativeAuthSignPayload(address, body)) if err != nil { return "", err } return corelayer.ComposeNativeAuthToken(address, body, signature) } ``` `DecodeNativeAuthToken` takes a token apart, and `CheckNativeAuthToken` checks the rules that can be checked locally: origin, lifetime, formats, `ExtraInfo`, 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. [Authentication](/agents/auth) describes each method in full. ### Relaying a transaction `RelayOnce` runs the whole flow for one user action. It asks the API for a relayer, runs your `VerifyRelayer` check, calls your `BuildTransaction`, asks your `SignOnce` for one signature, checks that the wallet signed the nonce you built, and submits the transaction. With `SignProof` set, it reads `GetNetwork` before the first assign, for the presence proof's chain ID and time. ```go // mainnetVerifier checks relayers on mainnet. Create it once and share it: it is safe for // concurrent use and caches the relayers it has confirmed. func mainnetVerifier(registry string) *corelayer.RelayerVerifier { return corelayer.NewRelayerVerifier(corelayer.RelayerVerifierOptions{ Gateway: "https://gateway.multiversx.com", // a gateway CoRelayer does not run Contract: ®istry, // the CoRelayer contract, from your own config ChainID: "1", }) } // relayTransfer sends 0.001 EGLD. intentKey is one key per user action, 16 to 128 characters. // Reuse it when you retry that action. func relayTransfer(ctx context.Context, client *corelayer.Client, verifier *corelayer.RelayerVerifier, sender, receiver string, nonce int64, intentKey string, sign corelayer.SignOnce) (*corelayer.Intent, error) { const chainID = "1" // mainnet; devnet is "D" result, err := corelayer.RelayOnce(ctx, corelayer.RelayOnceOptions{ Client: client, Sender: sender, IntentKey: intentKey, VerifyRelayer: verifier.Hook(sender), BuildTransaction: func(a corelayer.Assignment) (corelayer.TransactionPlain, error) { return corelayer.TransactionPlain{ Nonce: nonce, Value: "1000000000000000", // 0.001 EGLD Sender: sender, Receiver: receiver, Relayer: a.Relayer, GasPrice: a.MinGasPrice, GasLimit: 50_000 + a.ExtraGasRelayed, ChainID: chainID, Version: 2, }, nil }, SignOnce: sign, }) if err != nil { return nil, err } if resign := result.ResignRequired; resign != nil { // Nothing was sent. Ask the user before signing again. return nil, fmt.Errorf("a new signature is needed: %w", resign.Err) } return client.WaitForIntent(ctx, sender, result.Relayed.Signed.Nonce, corelayer.WaitOptions{ OnError: func(err error, failures int) (time.Duration, bool) { return 2 * time.Second, failures < 5 // one failed read is not a failed transaction }, }) } ``` The verifier asks a MultiversX gateway for the relayer's state in the CoRelayer contract, refuses unless it is Active, and checks that the relayer is in the sender's shard. Use a gateway CoRelayer does not run, and pin the contract address and the chain ID in your own configuration. Answers are cached per chain ID, registry version and relayer, and a `RelayerVerifier` is safe for concurrent use. See [Verify a relayer](/concepts/verify-a-relayer). #### Proving you control the sender Before it assigns a relayer, the API needs proof that you control the sender. A native-auth token for the sender is enough. Without one, set `SignProof`: a function that signs a message with the sender's key and returns the raw Ed25519 signature over the message's bytes as 128 hex characters (`ed25519.Sign` over `[]byte(message)`). Sign the bytes themselves, not through a wallet's `signMessage`: that adds the MultiversX message prefix, and the API answers `ASSIGN_PROOF_INVALID`. `RelayOnce` then signs a fresh proof for every assign call it makes: - For the first assign, it reads `ChainID` and `ServerTimeMs` from `GetNetwork` and signs `AssignProofMessage(chainID, sender, serverTimeMs)`. That costs one extra request. - For the renewal of an expired lease, it takes `ChainID` from the assignment. It estimates the API's clock as the assignment's `ServerTimeMs` plus the milliseconds that have passed since the assignment arrived, measured on the monotonic clock. The proofs carry kind `key` by default. In sponsor mode, set `ProofKind: corelayer.AssignRequestProofKindSponsor`: the sender's key still signs the proof, and the sponsor key goes on the relay call only. ```go // relayAsAgent relays one transaction for a program that holds the sender's key and has no // native-auth token. The key signs the presence proofs; sign signs the transaction. func relayAsAgent(ctx context.Context, client *corelayer.Client, key ed25519.PrivateKey, intentKey string, build func(corelayer.Assignment) (corelayer.TransactionPlain, error), sign corelayer.SignOnce) (*corelayer.RelayOnceResult, error) { sender, err := corelayer.PublicKeyToAddress(key.Public().(ed25519.PublicKey)) if err != nil { return nil, err } return corelayer.RelayOnce(ctx, corelayer.RelayOnceOptions{ Client: client, Sender: sender, IntentKey: intentKey, SignProof: func(_ context.Context, message string) (string, error) { return hex.EncodeToString(ed25519.Sign(key, []byte(message))), nil }, BuildTransaction: build, SignOnce: sign, }) } ``` The API accepts each proof once, and only within 30,000 ms of its own clock. A lease lasts 60,000 ms. You can also pass a proof you signed yourself as `Proof`, but it covers the first assign only. `RelayOnce` never sends it a second time, so when the lease expires before the submit it returns the `LEASE_EXPIRED` `*APIError` as the API sent it. Set `SignProof` to have the lease renewed for you. `RelayOnce` refuses `Proof` and `SignProof` together, before it sends anything. #### Paying for your users (sponsor mode) From Builder up, a sponsor key on your server pays for any sender, within the contracts and daily limits the key allows. Create the client with the key as `APIKey`, and submit each transaction your user signed with `Relay`. The client sends the key as `X-Api-Key` on the relay and on the account reads that accept it, never on the assign call or the network read, and your plan pays the network fee: ```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 } ``` `res.Data.Account` is your account, and `res.Data.Billing.AuthMode` is `api_key`. This covers senders whose key your server holds or reaches, such as embedded or custodial wallets, game servers, bots and agent fleets. When the same server holds the sender's key, give `RelayOnce` the sponsor client as `Client`, with `ProofKind: corelayer.AssignRequestProofKindSponsor`: every step runs as above, and the relay is billed to your plan. The presence proofs are marked kind `sponsor` and are still signed with the sender's key. [Pay for your users](/sdk/recipes/sponsor-users) covers creating the key and the answers a key can refuse with. #### When the lease expires When the lease has expired and the API marks it as renewable, `RelayOnce` renews it for the same relayer and submits the same signed bytes again. It never asks for a second signature by itself. The renewal carries a fresh proof from `SignProof`, or no proof when the client has a native-auth token for the sender. #### When the API asks for a new signature If the relayer becomes unavailable before it co-signs, the API answers `RESIGN_REQUIRED` or `RESIGN_SAME_NONCE`, and `RelayOnce` returns `ResignRequired` instead of an error. Nothing was sent. Ask the user. If they agree, call `RelayOnce` again with `Assignment: resign.NextAssignment`, `MinGasPrice: resign.MinGasPrice` and a transaction built for `resign.PinnedNonce`. `RelayOnce` refuses before asking the wallet if the nonce or the gas price would not fit, and it never signs again by itself. [Handle a re-sign request](/sdk/recipes/handle-resign) explains when this happens. With `SignProof`, a renewal of that lease counts time from the moment you call `RelayOnce`, but the API issued the lease when it sent the `RESIGN_REQUIRED` or `RESIGN_SAME_NONCE` answer. Everything in between, including the time the user takes to decide, puts the proof behind the API's clock. If that is more than 30,000 ms, the API refuses the renewal with `ASSIGN_PROOF_INVALID`. #### Waiting for the result `WaitForIntent` reads the intent once a second (`Interval`) until the transaction reaches `EXECUTED_OK`, `EXECUTED_FAIL`, `DEAD` or `REJECTED`. After two minutes (`Timeout`) it stops anyway and returns the last state it read, so check `State`. If no read has succeeded by then, it returns the last read's error. Before that time, a failed read ends the wait unless you set `OnError`. It receives the error and the number of failures in a row, and returns how long to wait before the next read (at least `Interval`) and true, or false to stop with the error. `Timeout` also ends a run of failed reads, so an `OnError` that always returns true cannot keep the wait going forever. Cancelling the context ends the wait at once, also while it waits between reads. `StreamRelay` gives the same updates as events. See [Watch an intent](/sdk/recipes/watch-an-intent). ### Handling errors | Error | When | What to do | |---|---|---| | `*APIError` | The API answered with a status that is not 2xx, a 3xx included. | Branch on `Code()`. For a code you do not know, go by `Status` and `Retryable()`. | | `*TransportError` | No host answered: a network failure, a timeout, a 2xx that was not JSON, or an answer larger than `MaxResponseBytes`, on every host tried. | The request may or may not have arrived. After a relay, read the intent with `GetIntent`, or send the same signed bytes again. Never sign again because of it. | | `*DecodeError` | A 2xx whose JSON does not fit the expected type. | Do not retry: the API did answer. | | `*RelayOnceError` | `RelayOnce` refused its options, for example `Proof` and `SignProof` together. Nothing was signed. | Fix the call. `Code` names the check. | | `*SignedNonceMismatchError` | The wallet signed a different nonce than the one you built. Nothing was sent. | Read the intent for the nonce you built with `GetIntent` before you try again. | | `*RelayerVerificationError` | The relayer is not Active, is in another shard, or could not be checked. Nothing was signed. | `Reason` says which. If the gateway could not be reached, try again later. Otherwise do not sign for this relayer. | | `context.Canceled`, `context.DeadlineExceeded` | Your context ended. | A cancelled request is never sent to another host. | ```go func nextStep(err error) string { var apiErr *corelayer.APIError if !errors.As(err, &apiErr) { return "not an API answer: " + err.Error() } switch { case corelayer.NeedsPayment(apiErr): // NO_ENTITLEMENT, QUOTA_EXHAUSTED return "buy a plan or top up first" case corelayer.EndsSlot(apiErr): // NONCE_TOO_LOW, INTENT_ALREADY_EXECUTED return "this nonce already has an outcome: read it with GetIntent" case apiErr.NeedsNewSignature(): return "ask the user before signing again" case apiErr.CanResubmitSameBytes(): wait, _ := apiErr.RetryAfter() return fmt.Sprintf("send the same signed bytes again in %v", wait) case apiErr.Retryable(): return "try again later" } return fmt.Sprintf("%s (HTTP %d): %s. Request ID for support: %s", apiErr.Code(), apiErr.Status, apiErr.Error(), apiErr.RequestID) } ``` The API can add error codes at any time, so always keep a fallback. [Errors and retries](/agents/errors-and-retries) covers what each case means. An error answer without a problem document, for example a proxy's HTML page, becomes an `*APIError` with code `UPSTREAM_UNAVAILABLE` and the answer's status. Its `Retryable()` is true only for 408, 425, 429 and 5xx. ### Other endpoints Every API operation is a method on `Client`, named after the operation: `GetPricing`, `ListUsage`, `CreateWebhook` and so on. Path parameters are arguments, and query and header parameters go in a `...Params` struct, where optional ones are pointers (`corelayer.Ptr` makes one). Each method returns a `*Response[T]` with `Data`, `Status`, `Header`, `RequestID`, `RateLimitRemaining` and the raw `Body`. ```go func report(ctx context.Context, client *corelayer.Client, account string, out io.Writer) error { // A paged list: the iterator fetches the next page when the loop needs it. for row, err := range client.ListUsageAll(ctx, corelayer.ListUsageParams{Account: account}) { if err != nil { return err } fmt.Fprintln(out, row.TxHash, row.Ru) } // An event stream: only the wait for the headers is timed. stream, err := client.StreamAccount(ctx, corelayer.StreamAccountParams{Account: &account}) if err != nil { return err } defer stream.Close() event, err := stream.Next() if err != nil { return err } fmt.Fprintln(out, event.Type, event.Data, stream.LastEventID()) // A CSV export. The body is streamed, so MaxResponseBytes does not limit it. export, err := client.ListUsageCSV(ctx, corelayer.ListUsageParams{Account: account}) if err != nil { return err } defer export.Close() _, err = io.Copy(out, export.Body) return err } ``` An event stream does not reconnect by itself. To resume, open a new one with `LastEventID` set to `stream.LastEventID()`, after waiting `stream.Retry()` milliseconds when the server set it. The account stream replays up to 300,000 ms or 1,000 events. For an older ID, an ID from the other API host or one from before a restart, the server sends a `reset` event first: reload your data then. An `EventStream` is not safe for concurrent use. `ListUsageCSV` and `ListPurchasesCSV` return at most 1,000,000 rows, with no cursor. A cell that starts with `=`, `+`, `-` or `@` is prefixed with an apostrophe, so spreadsheet apps do not run it as a formula. A request member where "leave it as it is" and "clear it" differ is a `Nullable[T]`. ### Webhooks :::note[Not delivered yet] The CoRelayer service does not send webhooks yet: registering an endpoint answers `503` with `reason: WEBHOOK_DELIVERY_NOT_AVAILABLE`. Until it does, follow your account with the notice feed or the account stream ([Do not poll the cap](/sdk/recipes/check-quota#do-not-poll-the-cap)). The verifier below is for the deliveries that feature will send. ::: ```go func webhookHandler(secrets []string, handle func(corelayer.WebhookEvent) error) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "cannot read the body", http.StatusBadRequest) return } event, err := corelayer.VerifyWebhook(r.Header, body, corelayer.VerifyWebhookOptions{Secrets: secrets}) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := handle(*event); err != nil { http.Error(w, "try again later", http.StatusServiceUnavailable) return } w.WriteHeader(http.StatusNoContent) } } ``` `VerifyWebhook` checks the `CoRelayer-Webhook-Signature` header, the hex HMAC-SHA256 of `..`, and refuses a timestamp more than 300,000 ms from now. Pass the body as received, before any parsing. After a secret rotation, pass both secrets for 24 hours. A failed delivery is retried with growing delays, 10 attempts in all over about 23 hours, and an endpoint that keeps failing for 604,800,000 ms (7 days) is disabled. A delivery can arrive more than once, so ignore an event `ID` you have already handled. ### x402 payments For [x402](/x402) purchases, `X402Topup` and `X402Purchase` first answer 402. Its body is the payment challenge rather than a problem document, so the `APIError` has the code `UPSTREAM_UNAVAILABLE`. Recognise it by `Status` 402 and a result from `PaymentRequiredOf`, which reads what to pay from the error. `EncodePaymentSignature` builds the `PAYMENT-SIGNATURE` header for the paid request (the `PAYMENTSIGNATURE` parameter), and `DecodePaymentResponse` reads the settlement receipt. Check the requirement's `PayTo`, `Asset` and `Amount` against your own configuration before you sign. ### Configuration All options are fields of `ClientOptions`. | Option | Default | What it does | |---|---|---| | `BaseURL` | required | The API origin: `https://api.co-relayer.com`, or `https://devnet-api.co-relayer.com` for devnet. | | `DirectHosts` | none | Regional hosts to fail over to, in order. Take them from `GetNetwork`. | | `FailoverAfter` | 1,500 ms | How long the main host may take before the next host is tried. Used only with `DirectHosts`. | | `Timeout` | 15 s | The time limit of one attempt on a direct host, or on the main host when there are none. | | `HTTPClient` | `DefaultHTTPClient()` | Anything with `Do(*http.Request)`. The default does not follow redirects. | | `Headers` | none | Headers added to every request. | | `UserAgent` | `corelayer-sdk-go/ ()` | The `User-Agent` header. | | `MaxResponseBytes` | 32 MiB | The largest answer body read into memory. A negative value removes the limit. | | `APIKey` | none | Sent as `X-Api-Key` on the operations that accept it: the relay and the account reads, never the assign call or `GET /v1/network`. A sponsor key pays for your users' transactions. | | `NativeAuthToken` | none | Returns the user's native-auth token before each request. | ### How requests behave - The request body is serialised once. If the main host does not answer within `FailoverAfter` (1,500 ms by default), fails at the network level, answers 5xx, or answers 2xx with a body that is not JSON, the same bytes go to the next direct host. A 4xx is an answer, so it comes back at once and is never tried on another host. - A cancelled request is never retried, and apart from failover nothing is retried for you. - Each attempt on a direct host, or on the main host when there are none, has `Timeout`, 15 s by default. For event streams and CSV exports only the wait for the headers is timed. - Redirects are not followed: a 3xx comes back as an `*APIError` that is not retryable, so a signed body and your API key never go to a host you did not configure. - A buffered answer body is limited to `MaxResponseBytes`, 32 MiB by default. A larger one counts as a failed attempt, like a network failure, and is never truncated. Streams and exports are not buffered. - Every request carries a `User-Agent` of `corelayer-sdk-go/ ()`. - Times are Unix milliseconds and durations are milliseconds (the `...Ms` fields). - The client talks only to the CoRelayer API. The relayer verifier is the exception: it calls the gateway you give it, with a 5 s limit unless you set `Timeout`. --- ## TypeScript and JavaScript `@corelayer/sdk` is the TypeScript client for the CoRelayer API. It is an ES module written for TypeScript 5.9 or later. It runs anywhere with a global `fetch`, in browsers and in Node, and has no runtime dependencies. Every operation of the API is a method, with request and response types generated from [`/openapi.yaml`](pathname:///openapi.yaml). Helpers cover relaying with one signature, checking the relayer on chain, native auth, paged lists, event streams, CSV exports, webhooks and x402 payments. ### Install :::note[Not on npm yet] Once the package is published, install it with `npm install @corelayer/sdk`. Until then, every call it makes is an ordinary HTTPS request you can send yourself: the API is described in [`/openapi.yaml`](pathname:///openapi.yaml), and [Pay for your users](/sdk/recipes/sponsor-users#without-an-sdk) shows a whole relay with no SDK. ::: The package ships its TypeScript source. A bundler such as Vite or esbuild compiles it along with your code, and Node 24 runs it directly from the package folder. If you type-check with `tsc`, turn on `allowImportingTsExtensions`, because the source imports its own files with `.ts` extensions. That option needs `noEmit` or `emitDeclarationOnly`, which suits a bundler setup. ### Quick start ```ts import { CoRelayerClient } from '@corelayer/sdk'; const client = new CoRelayerClient({ baseUrl: 'https://api.co-relayer.com' }); const { data: network } = await client.getNetwork(); console.log(`chain ${network.chainId}, ${network.roundDurationMs} ms rounds`); ``` Every API method resolves to an `ApiResponse`: the parsed body in `data`, plus `status`, `headers`, `requestId` and `rateLimitRemaining`. `waitForIntent` resolves to the `Intent` itself. Every method also accepts an `AbortSignal`, as its last argument or as `signal` in its options. For devnet, use `https://devnet-api.co-relayer.com`. ### Authentication You pass a key, or a function the client calls before each request, and the client adds the right header. | Who is calling | Option | Sent as | |---|---|---| | Your server paying for your users (a sponsor key), or an agent with an API key | `apiKey` | `X-Api-Key` header, on the relay and the account reads only | | An app acting for a signed-in wallet | `nativeAuthToken` | `Authorization: Bearer ` | Public routes such as `getNetwork` need none of them. Keep an API key on your server, and never ship it in a browser or mobile app. `nativeAuthToken` is called before every request, so a token you refresh is used on the next call. Return `undefined` when the user is signed out. ```ts import { CoRelayerClient } from '@corelayer/sdk'; // On a server or in an agent: an API key. const apiKey = process.env.CORELAYER_API_KEY; if (apiKey === undefined) throw new Error('Set CORELAYER_API_KEY.'); export const server = new CoRelayerClient({ baseUrl: 'https://api.co-relayer.com', apiKey }); // In a browser: the signed-in user's native-auth token, read before every request. let token: string | undefined; export const browser = new CoRelayerClient({ baseUrl: 'https://api.co-relayer.com', nativeAuthToken: () => token, }); export function signIn(newToken: string): void { token = newToken; } ``` A program can build its own native-auth token. The package never signs anything: `encodeNativeAuthBody` builds the token body, `nativeAuthSignPayload` gives the message your wallet signs, and `composeNativeAuthToken` puts the token together. ```ts import { type CoRelayerClient, composeNativeAuthToken, encodeNativeAuthBody, MAX_TTL_SECONDS, nativeAuthSignPayload, } from '@corelayer/sdk'; /** * Builds a one-hour token for a program. `signMessage` is a wallet's `signMessage` (sdk-core * `Account.signMessage`): a token is signed with the MultiversX message prefix, unlike a presence * proof. It returns the signature as hex. */ export async function agentToken( client: CoRelayerClient, address: string, signMessage: (message: string) => Promise, ): Promise { const { data: network } = await client.getNetwork(); const block = network.nativeAuth; // a recent shard-1 block, and the origin to name if (block?.origin === undefined) { throw new Error('The API sent no native-auth block for programs.'); } const body = encodeNativeAuthBody({ origin: block.origin, blockHash: block.blockHash, ttlSeconds: MAX_TTL_SECONDS, extraInfo: {}, }); const signature = await signMessage(nativeAuthSignPayload(address, body)); return composeNativeAuthToken(address, body, signature); } ``` A token lives at most 3,600 seconds (`MAX_TTL_SECONDS`), and its block hash must come from shard 1, which is why the example takes the block from `getNetwork`. The origin comes from there too: put it into the token exactly as the API sends it, because the API accepts only its own origin for programs, and that origin differs per network. `decodeNativeAuthToken` takes a token apart, and `checkNativeAuthToken` checks it against the rules that can be checked locally: the origin, the lifetime, the formats, `extraInfo`, the address and expiry. Use it to replace a token before the API refuses it. It cannot verify the signature or see which shard the block came from, so the API can still refuse a token that passes. [Authentication](/agents/auth) describes each method in full. ### Relaying a transaction `relayOnce` runs the whole flow for one user action. It gets a relayer assigned, runs your relayer check, calls your `buildTransaction`, calls your signer once, checks that the wallet signed the nonce you built, and submits the signed transaction. If your signer is called a second time, it throws instead of asking the wallet again. `createRelayerVerifier` gives you the relayer check. It asks a MultiversX gateway that CoRelayer does not run whether the relayer is Active in the CoRelayer contract, and checks that the relayer is in the sender's shard. When either check fails, it throws `RelayerVerificationError` and nothing is signed. Answers are cached per chain ID, registry version and relayer. The verifier does not follow redirects: a gateway that redirects counts as unreachable. See [Verify a relayer](/concepts/verify-a-relayer). ```ts import { type CoRelayerClient, createRelayerVerifier, type RelayerVerifier, relayOnce, type SignOnceInput, type TransactionPlain, } from '@corelayer/sdk'; /** Checks relayers on mainnet. Pin the CoRelayer contract address in your own configuration. */ export function mainnetVerifier(contract: string): RelayerVerifier { return createRelayerVerifier({ gateway: 'https://gateway.multiversx.com', contract, chainId: '1' }); } export interface Transfer { readonly sender: string; readonly receiver: string; /** In the smallest EGLD unit. */ readonly value: string; /** The sender's next account nonce. */ readonly nonce: number; /** One key per user action, 16 to 128 characters. Reuse it if you retry the action. */ readonly intentKey: string; } /** Sends EGLD with CoRelayer paying the gas. `sign` asks the user's wallet for the signature. */ export async function sendEgld( client: CoRelayerClient, verifier: RelayerVerifier, transfer: Transfer, sign: (transaction: SignOnceInput['transaction']) => Promise, ): Promise { const { sender, receiver, value, nonce, intentKey } = transfer; const result = await relayOnce({ client, sender, intentKey, verifyRelayer: (assignment) => verifier(assignment, sender), buildTransaction: (assignment) => ({ nonce, value, sender, receiver, gasPrice: assignment.minGasPrice, gasLimit: 50_000 + assignment.extraGasRelayed, chainID: assignment.chainId, version: 2, relayer: assignment.relayer, }), signOnce: ({ transaction }) => sign(transaction), }); if (result.kind === 'resign-required') { console.log(`The relayer changed. Ask the user to sign nonce ${result.pinnedNonce} again.`); return; } // The API has the transaction now, so one failed read does not mean it failed. const intent = await client.waitForIntent(sender, result.signed.nonce, { onError: (_error, failures) => (failures < 5 ? 2_000 : undefined), }); console.log(`${intent.intentId} is ${intent.state}`); } ``` #### Proving you control the sender Before it assigns a relayer, the API needs proof that you control the sender. A native-auth token for the sender on the client is enough. Without one, give `relayOnce` a `signProof` function. It receives the message to sign and returns the sender key's raw Ed25519 signature over the message's UTF-8 bytes, as hex, directly or as a promise. Sign the bytes themselves (sdk-core `UserSigner.sign`), not through a wallet's `signMessage` or sdk-core `Account.signMessage`: those add the MultiversX message prefix, and the API answers `ASSIGN_PROOF_INVALID`. `relayOnce` then signs a fresh proof for every assign call it makes: - For the first assign, it reads `chainId` and `serverTimeMs` from `GET /v1/network` and signs `assignProofMessage(chainId, sender, serverTimeMs)`. That costs one extra request, made with `cache: 'no-store'` so that no cache answers it: a cached answer would repeat the time of an earlier proof, and the API accepts each proof once. - For the renewal of an expired lease, it takes `chainId` from the assignment. It estimates the server's time as the assignment's `serverTimeMs` plus the milliseconds that have passed since the assignment arrived, measured on a monotonic clock. The proofs carry `kind: 'key'` by default. In sponsor mode, pass `proofKind: 'sponsor'`: the sender's key still signs the proof, and the sponsor key goes on the relay call only. ```ts import { type Assignment, type CoRelayerClient, type RelayOnceResult, relayOnce, type SignOnceInput, type TransactionPlain, } from '@corelayer/sdk'; /** A program's own key. Both methods resolve to hex signatures made with it. */ export interface AgentKey { readonly address: string; /** Raw Ed25519 over the UTF-8 bytes of `message`, with no MultiversX message prefix. */ signProofMessage(message: string): Promise; signTransaction(transaction: SignOnceInput['transaction']): Promise; } /** Relays one transaction for a program that holds the sender key and has no native-auth token. */ export function relayAsAgent( client: CoRelayerClient, key: AgentKey, build: (assignment: Assignment) => SignOnceInput['transaction'], intentKey: string, ): Promise { return relayOnce({ client, sender: key.address, intentKey, signProof: (message) => key.signProofMessage(message), buildTransaction: build, signOnce: ({ transaction }) => key.signTransaction(transaction), }); } ``` The API accepts each proof once, and only within 30,000 ms of its own clock. A lease lasts 60,000 ms. You can also pass a proof you signed yourself as `proof`, but it covers the first assign only. `relayOnce` never sends it a second time, so when the lease expires before the submit it throws the `LEASE_EXPIRED` error as the API sent it. Pass `signProof` to have the lease renewed for you. `relayOnce` refuses `proof` and `signProof` together, before it sends anything. #### Paying for your users (sponsor mode) From Builder up, a sponsor key on your server pays for any sender, within the contracts and daily limits the key allows. Create the client with the key as `apiKey`, and submit each transaction your user signed with `relay`. The client sends the key as `X-Api-Key` on the relay and on the account reads that accept it, never on the assign call or the network read, 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. The whole file is on [Pay for your users](/sdk/recipes/sponsor-users). The answer's `account` is your account, and `billing.authMode` is `'api_key'`. This covers senders whose key your server holds or reaches, such as embedded or custodial wallets, game servers, bots and agent fleets. When the same server holds the sender's key, pass the sponsor client to `relayOnce` as `client`, with `proofKind: 'sponsor'`: every step runs as above, and the relay is billed to your plan. The presence proofs are marked `kind: 'sponsor'` and are still signed with the sender's key. [Pay for your users](/sdk/recipes/sponsor-users) covers creating the key and the answers a key can refuse with. #### When the lease expires When the lease has expired and the server says it can be renewed, `relayOnce` renews it for the same relayer and sends the same signed bytes again. It never asks for a second signature by itself. The renewal carries a fresh proof from `signProof`, or no proof when the client has a native-auth token for the sender. #### Cancelling Pass `signal` to cancel. `relayOnce` then rejects with the signal's reason. It does not call your signer once the signal has aborted, and it submits nothing after the abort, even a transaction the wallet has already signed. #### When the API asks for a new signature `kind: 'resign-required'` means the relayer became unavailable before it co-signed. Nothing was sent, and signing again is the user's decision. If they agree, call `relayOnce` again with `assignment: result.nextAssignment`, `minGasPrice: result.minGasPrice` when it is set, and a transaction built for `result.pinnedNonce`. If the wallet signs a different nonce than the one you built, `relayOnce` throws `SignedNonceMismatchError` and sends nothing. See [Handle a re-sign request](/sdk/recipes/handle-resign). With `signProof`, a renewal of that lease estimates the server's time from the moment the `resign-required` result arrived, so the time the user took to decide is counted. `relayOnce` knows that moment only for the `nextAssignment` object it returned, so pass that object itself, not a copy. A copy, or a lease from anywhere else, is timed from the moment you call `relayOnce`, and its renewal proof falls behind by the time the lease waited before that. The API refuses a proof more than 30,000 ms away from its clock. #### Waiting for the result `waitForIntent` reads the intent every 1,000 ms (`intervalMs`) and stops at `EXECUTED_OK`, `EXECUTED_FAIL`, `DEAD` or `REJECTED`. After 120,000 ms (`timeoutMs`) it stops anyway and returns the last intent it read, so check `state`. If no read has succeeded by then, it throws the last read's error. Before that time, a failed read is thrown unless you pass `onError`. It receives the error and the number of failures in a row, and returns the milliseconds to wait before the next read (at least `intervalMs`), or `undefined` to throw the error. The time limit also ends a run of failed reads, so an `onError` that always returns a number cannot keep the wait going forever. No pause runs past the time limit, the one `onError` asks for included: a longer one is cut short, and one last read is made when the time is up. Aborting `signal` ends the wait at once, also while it waits between reads, and rejects with the signal's reason. See [Watch an intent](/sdk/recipes/watch-an-intent). ### Handling errors | Error | When | What to do | |---|---|---| | `ApiError` | The API answered with a status that is not 2xx, a redirect included. | Branch on `code`. For a code you do not know, go by `status` and `retryable`. | | `TransportError` | No host gave an answer: a network failure, a timeout, a 2xx body that is not JSON, or an answer larger than `maxResponseBytes`. `attempts` lists every URL tried. | The request may or may not have arrived. After a relay, read the intent with `getIntent`, or send the same signed bytes again. Never sign again because of it. | | `SignedNonceMismatchError` | The wallet signed a different nonce than the one you built in `relayOnce`. Nothing was sent. | Read the intent for the nonce you built with `getIntent` before you try again. | | `RelayerVerificationError` | The relayer is not Active, is in another shard, or could not be checked. Nothing was signed. | `reason` says which. If the gateway could not be reached, try again later. Otherwise do not sign for this relayer. | | `CursorLoopError` | An `...All` loop got back the cursor it had sent. The items before it were returned. | Stop and report it: following that cursor would fetch the same page forever. | | `EventTooLargeError` | An event on a stream was larger than `maxEventBytes`. The events before it are read first, then the stream is closed. | Raise `maxEventBytes`, or open the stream again. | | `WebhookError` | `verifyWebhook` refused a delivery. | Answer the delivery with a 4xx and do not act on it. | The API can add error codes at any time. A code this version does not know still arrives as an `ApiError`, so handle it by `status` and `retryable`. `canResubmitSameBytes` says when sending the same signed bytes again is safe, and `needsNewSignature` when the user must sign again. `needsPayment(error)` is true when the account has to buy a plan or credits, and `endsSlot(error)` when the nonce is already used. Quote `requestId` when you contact support. An error answer without a problem document, for example a proxy's HTML page, becomes an `ApiError` with code `UPSTREAM_UNAVAILABLE` and the answer's status. Its `retryable` is true only for 408, 425, 429 and 5xx. A generated API method rejects with a plain `Error`, before anything is sent, when a required parameter is an empty string. `getIntent`, `getRelay` and `waitForIntent` do not check this, so pass them a non-empty sender or ID. ```ts import { type CoRelayerClient, endsSlot, isApiError, isTransportError, needsPayment, } from '@corelayer/sdk'; export async function showIntent(client: CoRelayerClient, sender: string, nonce: number) { try { const { data } = await client.getIntent(sender, nonce); console.log(data.state); } catch (error) { if (isApiError(error) && needsPayment(error)) { console.log('The account needs a plan or credits.'); } else if (isApiError(error) && endsSlot(error)) { console.log('This nonce is already used.'); } else if (isApiError(error) && error.retryable) { console.log(`Try again in ${error.retryAfterMs ?? 1_000} ms (request ${error.requestId}).`); } else if (isTransportError(error)) { console.log(`No answer from ${error.attempts.join(', ')}.`); } else { throw error; } } } ``` Every code has a page in the [error catalogue](/errors), and [Errors and retries](/agents/errors-and-retries) covers when to retry. ### Calling the API Every operation of the API is a method of `CoRelayerClient`, named after the operation: `getPricing`, `listUsage`, `createWebhook` and so on. Its arguments come in this order: 1. The path parameters, such as `erd` in `getQuota(erd)`. 2. The request body, for an operation that takes one. 3. An object with the query and header parameters, such as `ListUsageParams` for `listUsage`. A parameter left `undefined` is not sent, and a list is sent as one comma-separated value. Header parameters have camelCase names: `Idempotency-Key` is `idempotencyKey` and `Last-Event-ID` is `lastEventId`. 4. An `AbortSignal`, optional. ```ts import type { CoRelayerClient } from '@corelayer/sdk'; /** Prints what an account has left and its failed transactions of the last day. */ export async function dailyReport(client: CoRelayerClient, account: string): Promise { const { data: quota } = await client.getQuota(account); console.log(`${quota.cap?.left ?? 0} RU left in the plan, halted: ${quota.halted}`); const since = Date.now() - 86_400_000; for await (const row of client.listUsageAll({ account, status: ['executed_fail'], fromMs: since })) { console.log(row.txHash, row.status); } } ``` The body and answer types are the generated ones: `BodyOf<'createWebhook'>` is the body of `createWebhook`, and `AnswerOf<'getQuota', 200>` is the `data` of `getQuota`. When the body of an answer depends on its status, compare `status` to narrow `data`: `x402Purchase` answers 200 with the result, or 202 with a payment to poll. `getNetwork`, `assignRelayer`, `getIntent` and `getRelay` are the steps of the relay flow. `relay(request, { intentKey })` calls `relayTransaction` with the key as `Idempotency-Key`, and `waitForIntent` reads `getIntent` until the intent reaches a terminal state (`EXECUTED_OK`, `EXECUTED_FAIL`, `DEAD` or `REJECTED`). `OPERATIONS` lists every operation with its method, path, accepted credentials and whether it may fail over. For a request that no method covers, `client.transport` sends the same credentials and applies the same failover and error handling. #### Paged lists A paged list answers with `items`, `hasMore` and `nextCursor`, and its method returns one page. The `...All` method takes the same arguments and returns every item, as in `listUsageAll` above: - It starts at `params.cursor`, or at the first page when you leave it out. - It fetches a page only when the loop needs it, and stops fetching when you leave the loop. - An error from a page is thrown from the loop. - If the server hands back the cursor it was given, the loop ends with `CursorLoopError`, after the items of that page. `paginate` does the same for a page function of your own. #### Event streams `streamRelay` follows one transaction and `streamAccount` one account. Each resolves to an `EventStream` once the server has answered. Only that wait is timed, so the stream stays open as long as the server keeps it open. Read the events with `for await`: each has an `event` type, its `data` (JSON on these streams) and an `id`. Call `close()` when you are done; leaving a `for await` loop early closes the stream too. A stream does not reconnect by itself. To resume, open it again with `lastEventId` set to the stream's `lastEventId`, after waiting `retryMs` when the server set it. The stream's `lastEventId` counts only the events you have read, so an event that arrived but was never read comes again. On the account stream, the server sends a `reset` event when it cannot resume from your ID; reload your data then. ```ts import { type CoRelayerClient, type components, isTransportError } from '@corelayer/sdk'; type StreamEvent = components['schemas']['StreamEvent']; /** Follows an account's events until `signal` aborts, resuming after a dropped connection. */ export async function followAccount( client: CoRelayerClient, account: string, onEvent: (event: StreamEvent) => void, signal: AbortSignal, ): Promise { let lastEventId: string | undefined; for (;;) { const stream = await client.streamAccount({ account, lastEventId }, signal); try { for await (const message of stream) onEvent(JSON.parse(message.data) as StreamEvent); } catch (error) { // A dropped connection is resumed below. Anything else, an abort included, ends the loop. if (!isTransportError(error)) throw error; } lastEventId = stream.lastEventId || lastEventId; await new Promise((resolve) => setTimeout(resolve, stream.retryMs ?? 1_000)); } } ``` A read rejects with `TransportError` when the connection breaks, and with `EventTooLargeError` when an event is larger than `maxEventBytes` (8,388,608 bytes by default). The client sends its credentials as headers, so it needs no stream ticket. A ticket from `createStreamTicket` (the `ticket` parameter) is for a browser's `EventSource`, which cannot send headers. [Watch an intent](/sdk/recipes/watch-an-intent) follows one transaction to its outcome. #### CSV exports `listUsageCsv` and `listPurchasesCsv` return the rows of `listUsage` and `listPurchases` as a CSV file, with no cursor and at most 1,000,000 rows. A cell that starts with `=`, `+`, `-` or `@` is prefixed with an apostrophe, so spreadsheet apps do not run it as a formula. Each resolves to a `StreamResponse` once the server has answered, and the file arrives in `body`, a `ReadableStream` of bytes. The body is not buffered, so `maxResponseBytes` does not apply to it, and only the wait for the answer's headers is timed. Read it to the end, or cancel it to close the connection. ```ts import { createWriteStream } from 'node:fs'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import type { ReadableStream as NodeReadableStream } from 'node:stream/web'; import type { CoRelayerClient } from '@corelayer/sdk'; /** Saves an account's whole usage history to a CSV file, without holding it in memory. */ export async function saveUsage(client: CoRelayerClient, account: string, path: string) { const exported = await client.listUsageCsv({ account }); // Node types its web streams apart from the DOM's, but the object is the same. const body = exported.body as NodeReadableStream; await pipeline(Readable.fromWeb(body), createWriteStream(path)); } ``` In a browser, `await new Response(exported.body).blob()` gives you the file to download. ### Webhooks :::note[Not delivered yet] The CoRelayer service does not send webhooks yet: registering an endpoint answers `503` with `reason: WEBHOOK_DELIVERY_NOT_AVAILABLE`. Until it does, follow your account with the notice feed or the account stream ([Do not poll the cap](/sdk/recipes/check-quota#do-not-poll-the-cap)). The verifier below is for the deliveries that feature will send. ::: `verifyWebhook` checks a delivery and returns its event. Pass the headers and the raw body as your server received them, before any JSON parsing. ```ts import { createServer } from 'node:http'; import { verifyWebhook, WebhookError, type WebhookEvent } from '@corelayer/sdk'; /** A webhook endpoint. `secrets` holds your `whsec_…` secrets. */ export function webhookServer( secrets: readonly string[], handle: (event: WebhookEvent) => Promise, ) { return createServer(async (request, response) => { const chunks: Buffer[] = []; for await (const chunk of request) chunks.push(chunk as Buffer); try { const event = await verifyWebhook(request.headers, Buffer.concat(chunks), { secrets }); await handle(event); response.writeHead(204).end(); } catch (error) { // A delivery you must not trust gets a 4xx. Any other failure gets a 5xx, and the delivery // comes again. response.writeHead(error instanceof WebhookError ? 400 : 503).end(); } }); } ``` In a handler that receives a `Request`, pass `request.headers` and `await request.arrayBuffer()`. It accepts the delivery when the `CoRelayer-Webhook-Id`, `CoRelayer-Webhook-Timestamp` and `CoRelayer-Webhook-Signature` headers are present, the timestamp is within 300,000 ms of your clock (`toleranceMs`), one of the `v1=` signatures matches one of your secrets, and the body is an event whose `id` is the ID header. Otherwise it rejects with `WebhookError`. The signature is the hex HMAC-SHA256 of `..`, keyed with the UTF-8 bytes of the whole secret. After a secret rotation, pass both secrets for 24 hours: the header then carries two signatures. A delivery succeeds when your endpoint answers 2xx within 5,000 ms. A failed delivery is retried with growing delays, 10 attempts in all over about 23 hours, and an endpoint that keeps failing for 604,800,000 ms (7 days) is disabled. A delivery can arrive more than once, so ignore an event `id` you have already handled. `signWebhook` makes the signature header, so you can test your endpoint. Both use Web Crypto, which a browser offers on HTTPS pages only. ### x402 payments `x402Topup` and `x402Purchase` answer the unpaid request with a 402, which the client throws as an `ApiError`. Its body is the payment challenge rather than a problem document, so its `code` is `UPSTREAM_UNAVAILABLE`. Recognise it by `status` 402 and a result from `paymentRequiredOf(error)`. From there: 1. `paymentRequiredOf(error)` reads what to pay from the error's `PAYMENT-REQUIRED` header. Check the requirement's `payTo`, `asset` and `amount` against your own configuration before you sign. 2. `encodePaymentSignature(payload)` writes the signed payment as a header value. Send the same request again with it as `paymentSignature`. 3. The answer is 200 with the result, or 202 with a payment to poll with `getX402Payment`. `decodePaymentResponse` reads the settlement receipt from its `PAYMENT-RESPONSE` header. `decodePaymentRequired` reads a `PAYMENT-REQUIRED` value you hold yourself. [Buying over x402](/x402) has a complete client. ### Configuration | Option | Default | What it does | |---|---|---| | `baseUrl` | required | The API origin: `https://api.co-relayer.com`, or `https://devnet-api.co-relayer.com` for devnet. | | `directHosts` | `[]` | Regional hosts to fail over to, in order. Take them from `getNetwork`. | | `failoverAfterMs` | 1,500 ms | How long the main host may take before the next host is tried. Used only with `directHosts`. | | `timeoutMs` | 15,000 ms | The time limit of one attempt on a direct host, or on the main host when there are none. | | `apiKey` | none | Sent as `X-Api-Key` on the operations that accept it: the relay and the account reads, never the assign call or `GET /v1/network`. A sponsor key pays for your users' transactions. | | `nativeAuthToken` | none | A function returning the token, sent as `Authorization: Bearer`. | | `headers` | none | Headers added to every request. | | `maxResponseBytes` | 33,554,432 bytes (32 MiB) | The largest answer the client reads into memory. A larger one fails the attempt on that host. Event streams and CSV exports have no limit. | | `maxEventBytes` | 8,388,608 bytes (8 MiB) | The largest event an event stream accepts. | | `fetch` | `globalThis.fetch` | The `fetch` to send requests with. Pass your own to test without a network. | All times are in milliseconds. To turn on failover, read the host list once and create the client with it: ```ts import { CoRelayerClient } from '@corelayer/sdk'; const bootstrap = new CoRelayerClient({ baseUrl: 'https://api.co-relayer.com' }); const { data: network } = await bootstrap.getNetwork(); export const client = new CoRelayerClient({ baseUrl: 'https://api.co-relayer.com', directHosts: network.directHosts ?? [], }); ``` ### How requests behave - A request body is serialised once, and every attempt sends the same string. - With direct hosts, a request moves to the next host when the current one does not answer in time, fails to connect, answers with a 5xx, answers 2xx with a body that is not JSON, or sends more than `maxResponseBytes`. A 4xx is an answer, so it is thrown at once and never tried on another host. - On a `transport` request, `noFailover: true` keeps it on the main host. It does not set `credentials`, so set that yourself when the request needs cookies. - Aborting a request in flight rejects it with your signal's reason, and it is not tried on another host. If the signal is already aborted when a request starts, it rejects at once with the reason and nothing is sent. `waitForIntent` and `relayOnce` also check the signal between their steps. - Apart from failover, nothing is retried for you. `retryAfterMs` comes from the problem's `details.retryAfterMs`, or else from the `Retry-After` header, read as seconds. - Redirects are not followed, so a signed body and your credentials never reach a host you did not configure. A 3xx answer is thrown as an `ApiError` with its status and the `Location` it named. In a browser, `fetch` hides the redirect's details, and the `ApiError` has status 0. - An answer is read into memory up to `maxResponseBytes`, 33,554,432 bytes (32 MiB) by default. The bytes are counted as they arrive, whatever `Content-Length` says. A larger answer fails the attempt on that host: the request moves to the next host, and after the last one it rejects with `TransportError`. - For an event stream or a CSV export, the time limit covers only the wait for the answer's headers. The body is handed to you as it arrives, with no time limit and no size limit. - The package sets no `User-Agent`. Browsers send their own; in Node you can set one with `headers`. - The client talks only to the CoRelayer API. The relayer verifier is the exception: it calls the gateway you give it, with a 5,000 ms limit. --- ## Python `corelayer-sdk` is the Python client for the CoRelayer API. You import it as `corelayer`. It needs Python 3.11 or later and depends only on httpx. Every API operation is a typed method, on a synchronous `Client` and an asyncio `AsyncClient`, and helpers cover relaying with one signature, checking the relayer on chain, native auth, event streams, webhooks and x402. ### Install :::note[Not on PyPI yet] Once the package is published, install it with `pip install corelayer-sdk`. Until then, every call it makes is an ordinary HTTPS request you can send yourself: the API is described in [`/openapi.yaml`](pathname:///openapi.yaml), and [Pay for your users](/sdk/recipes/sponsor-users#without-an-sdk) shows a whole relay with no SDK. ::: ### Quick start ```python from corelayer import Client with Client("https://api.co-relayer.com") as client: network = client.get_network().data print("chain:", network["chainId"], "min gas price:", network["minGasPrice"]) ``` For devnet, use `https://devnet-api.co-relayer.com`. `get_network()` also lists the regional direct hosts: pass `network.get("directHosts", [])` as `direct_hosts`, and the client fails over to them when the main host is slow or down. Every method returns a `Response` with `data`, `status`, `headers`, `request_id` and `rate_limit_remaining`. The models are `TypedDict`s keyed by the API's member names, so `data` is a plain dict and your type checker knows its members. `AsyncClient` has the same methods as coroutines. With the default HTTP client, one `Client` can be shared between threads. ### Authentication You pass a key, or a function the client calls before each request, and the client adds the right header. | Who is calling | Option | Sent as | |---|---|---| | Your server paying for your users (a sponsor key), or an agent with an API key | `api_key` | `X-Api-Key` header, on the relay and the account reads only | | An app acting for a signed-in wallet | `native_auth_token` | `Authorization: Bearer ` | Public routes such as `get_network` need none of them, and the docstring of each method says which credentials it accepts. Keep an API key on your server, and never ship it in a browser or mobile app. `native_auth_token` takes a function, so a token you refresh is used on the next call. On `AsyncClient` the function may be a coroutine function. An agent can build its own native-auth token. The package gives you the message to sign, and your key or wallet signs it: ```python from collections.abc import Callable from corelayer import ( MAX_TTL_SECONDS, Client, NativeAuthBody, compose_native_auth_token, encode_native_auth_body, native_auth_sign_payload, ) def agent_token(client: Client, address: str, sign_message: Callable[[str], str]) -> str: """Builds a native-auth token for `address`. `sign_message` signs a message the way a MultiversX wallet's `signMessage` does, with the signed-message prefix a token needs (unlike a presence proof), and returns the signature as 128 hex characters.""" block = client.get_network().data.get("nativeAuth") # a recent shard-1 block origin = block.get("origin") if block is not None else None if block is None or origin is None: raise RuntimeError("the API returned no native-auth block") body = encode_native_auth_body( NativeAuthBody(origin=origin, block_hash=block["blockHash"], ttl_seconds=MAX_TTL_SECONDS) ) signature = sign_message(native_auth_sign_payload(address, body)) return compose_native_auth_token(address, body, signature) ``` `decode_native_auth_token` takes a token apart, and `check_native_auth_token` checks the rules that can be checked locally: origin, lifetime, formats, `extraInfo`, 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. [Authentication](/agents/auth) describes each method in full. ### Relaying a transaction `relay_once` runs the whole flow for one user action. It asks the API for a relayer, runs your `verify_relayer` check, calls your `build_transaction`, asks your `sign_once` for one signature, checks that the wallet signed the nonce you built, and submits the transaction. ```python from corelayer import ( Assignment, Client, Intent, Relayed, RelayerVerifier, SignOnce, UnsignedTransactionPlain, relay_once, ) CHAIN_ID = "1" # mainnet; devnet is "D" def mainnet_verifier(registry: str) -> RelayerVerifier: """Create the verifier once and share it: it caches the relayers it has confirmed.""" return RelayerVerifier( "https://gateway.multiversx.com", # a gateway CoRelayer does not run registry, # the CoRelayer contract address, from your own configuration CHAIN_ID, ) def relay_transfer( client: Client, verifier: RelayerVerifier, sender: str, receiver: str, nonce: int, intent_key: str, sign: SignOnce, ) -> Intent: """Sends 0.001 EGLD. `intent_key` is one key per user action, 16 to 128 characters. Reuse it when you retry that action.""" def build(assignment: Assignment) -> UnsignedTransactionPlain: return { "nonce": nonce, "value": "1000000000000000", # 0.001 EGLD "sender": sender, "receiver": receiver, "relayer": assignment["relayer"], "gasPrice": assignment["minGasPrice"], "gasLimit": 50_000 + assignment["extraGasRelayed"], "chainID": CHAIN_ID, "version": 2, } result = relay_once( client, sender=sender, intent_key=intent_key, verify_relayer=verifier.hook(sender), build_transaction=build, sign_once=sign, ) if not isinstance(result, Relayed): # Nothing was sent. Ask the user before signing again. raise RuntimeError(f"a new signature is needed: {result.error.code}") # One failed read does not mean the transaction failed: wait 2 s and ask again, up to 5 times. return client.wait_for_intent( sender, result.signed["nonce"], on_error=lambda error, failures: 2000 if failures < 5 else None, ) ``` The verifier asks a MultiversX gateway for the relayer's state in the CoRelayer contract, refuses unless it is Active, and checks that the relayer is in the sender's shard. Use a gateway CoRelayer does not run, and pin the contract address and the chain ID in your own configuration. Answers are cached per chain ID, registry version and relayer, and one verifier can be shared between threads. See [Verify a relayer](/concepts/verify-a-relayer). The signer is called at most once. A second call raises `RelayOnceError` with the code `signer-called-twice`, even when two threads call it at the same time. If the wallet signed a different nonce, you get a `SignedNonceMismatchError` and nothing is sent. `relay_once_async` does the same with an `AsyncClient` and an `AsyncRelayerVerifier`, and its builder, signers and relayer check may be coroutine functions. Cancelling the task cancels the call, and a cancelled request is never sent to another host. #### Proving you control the sender Before it assigns a relayer, the API needs proof that you control the sender. A native-auth token for the sender on the client is enough. Without one, pass `sign_proof`: a function that receives the message to sign and returns the sender key's raw Ed25519 signature over the message's UTF-8 bytes, as 128 hex characters. Sign the bytes themselves, not through a wallet's `signMessage`: that adds the MultiversX message prefix, and the API answers `ASSIGN_PROOF_INVALID`. `relay_once` then signs a fresh proof for every assign call it makes: - For the first assign, it reads `chainId` and `serverTimeMs` from `get_network()` and signs `assign_proof_message(chainId, sender, serverTimeMs)`. That costs one extra request, which is not made when you pass `assignment`. - For the renewal of an expired lease, it takes `chainId` from the assignment. It estimates the API's time as the assignment's `serverTimeMs` plus the milliseconds that have passed since the assignment arrived, measured with `time.monotonic`. The proofs carry the kind `"key"` by default. In sponsor mode, pass `proof_kind="sponsor"`: the sender's key still signs the proof, and the sponsor key goes on the relay call only. With `relay_once_async`, `sign_proof` may be a coroutine function. ```python from collections.abc import Callable from corelayer import ( Assignment, Client, RelayOnceResult, TransactionPlain, UnsignedTransactionPlain, relay_once, ) def relay_as_agent( client: Client, sender: str, sign_proof_message: Callable[[str], str], sign_transaction: Callable[[UnsignedTransactionPlain], TransactionPlain], build: Callable[[Assignment], UnsignedTransactionPlain], intent_key: str, ) -> RelayOnceResult: """Relays one transaction for a program that holds the sender's key and has no native-auth token. `sign_proof_message` signs the raw UTF-8 bytes of a message with that key, with no MultiversX message prefix, and returns the signature as hex.""" return relay_once( client, sender=sender, intent_key=intent_key, sign_proof=sign_proof_message, build_transaction=build, sign_once=lambda signer_input: sign_transaction(signer_input.transaction), ) ``` The API accepts each proof once, and only within 30,000 ms of its own clock. A lease lasts 60,000 ms. You can also pass a proof you signed yourself as `proof`, but it covers the first assign only. `relay_once` never sends it a second time, so when the lease expires before the submit it raises the `LEASE_EXPIRED` `ApiError` as the API sent it. Pass `sign_proof` to have the lease renewed for you. `relay_once` refuses `proof` and `sign_proof` together with a `RelayOnceError` (code `proof-with-signer`) before it sends anything. #### Paying for your users (sponsor mode) From Builder up, a sponsor key on your server pays for any sender, within the contracts and daily limits the key allows. Create the client with the key as `api_key`, and submit each transaction your user signed with `relay`. The client sends the key as `X-Api-Key` on the relay and on the account reads that accept it, never on the assign call or the network read, and your plan pays the network fee: ```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"] ``` `res.data["account"]` is your account, and `res.data["billing"]["authMode"]` is `"api_key"`. This covers senders whose key your server holds or reaches, such as embedded or custodial wallets, game servers, bots and agent fleets. When the same server holds the sender's key, pass the sponsor client to `relay_once`, with `proof_kind="sponsor"`: every step runs as above, and the relay is billed to your plan. The presence proofs are marked kind `"sponsor"` and are still signed with the sender's key. [Pay for your users](/sdk/recipes/sponsor-users) covers creating the key and the answers a key can refuse with. #### When the lease expires When the lease has expired and the API says it can be renewed, `relay_once` renews it for the same relayer and submits the same signed bytes again. It never asks for a second signature by itself. The renewal carries a fresh proof from `sign_proof`, or no proof when the client has a native-auth token for the sender. #### When the API asks for a new signature If the relayer becomes unavailable before it co-signs, the API answers `RESIGN_REQUIRED` or `RESIGN_SAME_NONCE`, and `relay_once` returns a `ResignRequired` instead of raising. Nothing was sent. Ask the user. If they agree, call `relay_once` again with `assignment=resign.next_assignment`, a `min_gas_price` of `resign.min_gas_price` and a transaction built for `resign.pinned_nonce`, keeping the same `intent_key`. `relay_once` raises `RelayOnceError` before asking the wallet if the nonce or the gas price would not fit. [Handle a re-sign request](/sdk/recipes/handle-resign) explains when this happens. `relay_once` never signs again by itself. With `sign_proof`, a renewal of that lease dates its proof from the lease's `serverTimeMs` plus the time since you called `relay_once`. The time before the call is not counted, and that includes the time the user takes to decide. If more than 30,000 ms pass between the API's answer that carried the lease and your call, the API refuses the renewal proof and `relay_once` raises the `ASSIGN_PROOF_INVALID` `ApiError`. #### Waiting for the result `wait_for_intent` reads the intent every 1,000 ms (`interval_ms`) and stops at `EXECUTED_OK`, `EXECUTED_FAIL`, `DEAD` or `REJECTED`. After 120,000 ms (`timeout_ms`) it stops anyway and returns the last intent it read, so check `state`. If no read has succeeded by then, it raises the last read's error. Before that time, a failed read is raised unless you pass `on_error`. It receives the error and the number of failed reads in a row, and returns the milliseconds to wait before the next read (at least `interval_ms`), or `None` to raise the error. The time limit also ends a run of failed reads, so an `on_error` that always returns a number cannot keep the wait going forever. On `AsyncClient`, cancelling the task ends the wait at once, also while it waits between reads. `stream_relay` gives the same updates as events. See [Watch an intent](/sdk/recipes/watch-an-intent). ### Handling errors Every error this package raises derives from `CoRelayerError`. Errors raised by your own functions (the signer, the builder, a token source) reach you unchanged. | Error | When | What to do | |---|---|---| | `ApiError` | The API answered with a status that is not 2xx, a 3xx included. | Branch on `code`. For a code you do not know, go by `status` and `retryable`. | | `TransportError` | No host answered: a network failure, a timeout, a 2xx that was not JSON, or an answer larger than `max_response_bytes`, on every host tried. | The request may or may not have arrived. After a relay, read the intent with `get_intent`, or send the same signed bytes again. Never sign again because of it. | | `DecodeError` | A 2xx without the documented body. | Do not retry: the API did answer. | | `InvalidInputError` | An argument cannot be sent, such as an empty required query or header parameter. Path parameters are not checked. | Fix the call. Nothing was sent. | | `RelayOnceError` | `relay_once` refused its arguments. | Fix the call. `code` names the check. | | `SignedNonceMismatchError` | The wallet signed a different nonce than the one you built. Nothing was sent. | Read the intent for the nonce you built with `get_intent` before you try again. | | `RelayerVerificationError` | The relayer is not Active, is in another shard, or could not be checked. Nothing was signed. | `reason` says which. If the gateway could not be reached, try again later. Otherwise do not sign for this relayer. | ```python from corelayer import ApiError, ends_slot, needs_payment def next_step(error: Exception) -> str: if not isinstance(error, ApiError): return f"not an API answer: {error}" if needs_payment(error): # NO_ENTITLEMENT, QUOTA_EXHAUSTED return "buy a plan or top up first" if ends_slot(error): # NONCE_TOO_LOW, INTENT_ALREADY_EXECUTED return "this nonce already has an outcome: read it with get_intent" if error.needs_new_signature: return "ask the user before signing again" if error.can_resubmit_same_bytes: wait = error.retry_after or 1.0 # seconds return f"send the same signed bytes again in {wait} s" if error.retryable: return "try again later" return f"{error.code} (HTTP {error.status}): {error}. Request ID for support: {error.request_id}" ``` The API can add error codes at any time, so always keep a fallback. [Errors and retries](/agents/errors-and-retries) covers what each case means. An error answer without a problem document, such as an HTML page from a proxy, becomes an `ApiError` with the code `UPSTREAM_UNAVAILABLE` and the answer's status. Its `retryable` is true only for 408, 425, 429 and 5xx. ### Other endpoints Every API operation is a method, named after the operation in snake case: `get_account`, `list_usage`, `create_webhook` and so on. Path parameters come first. Everything else is keyword-only and snake-cased (`from_ms`, `api_key_id`). A paged list also has an `..._all` method that iterates over every item, and an exportable list a `..._csv` method that streams the CSV. ```python from typing import BinaryIO from corelayer import Client def report(client: Client, account: str, out: BinaryIO) -> None: # A paged list: the iterator fetches the next page when the loop needs it. for row in client.list_usage_all(account=account): print(row["txHash"], row["ru"]) # An event stream: iterate it for events, and close it when you are done. with client.stream_account(account=account) as stream: for event in stream: print(event.event, event.data, stream.last_event_id) break # A CSV export: the body arrives in chunks and is never held in memory whole. with client.list_usage_csv(account=account) as export: for chunk in export.iter_bytes(): out.write(chunk) ``` An event stream does not reconnect by itself. To resume, wait `stream.retry` milliseconds when the server set it, then open a new stream with `last_event_id=stream.last_event_id`. The account stream replays up to 300,000 ms or 1,000 events. When your ID is older than that, comes from the other API host or is from before a server restart, the stream starts with a `reset` event: reload your data then. An event larger than 8 MiB raises `EventTooLargeError`. A CSV export has no cursor and at most 1,000,000 rows. A cell that starts with `=`, `+`, `-` or `@` is prefixed with an apostrophe, so spreadsheet apps do not run it as a formula. Where the API tells an absent member apart from `null`, the type says `NotRequired[T | None]`: leave the key out to keep the current value, or set it to `None` to clear it. ### Webhooks :::note[Not delivered yet] The CoRelayer service does not send webhooks yet: registering an endpoint answers `503` with `reason: WEBHOOK_DELIVERY_NOT_AVAILABLE`. Until it does, follow your account with the notice feed or the account stream ([Do not poll the cap](/sdk/recipes/check-quota#do-not-poll-the-cap)). The verifier below is for the deliveries that feature will send. ::: ```python from collections.abc import Mapping from corelayer import WebhookError, verify_webhook def handle_delivery(headers: Mapping[str, str], body: bytes, secrets: list[str], seen: set[str]) -> int: """Returns the HTTP status to answer the delivery with.""" try: event = verify_webhook(headers, body, secrets=secrets) except WebhookError: return 400 # do not act on it if event["id"] in seen: return 204 # a retry of a delivery you already handled seen.add(event["id"]) print(event["type"], event["account"], event["data"]) return 204 ``` `verify_webhook` checks the `CoRelayer-Webhook-Signature` header, the hex HMAC-SHA256 of `..`, and refuses a timestamp more than 300,000 ms from now. Pass the body bytes as received, before any parsing. After a secret rotation, pass both secrets for 24 hours. A failed delivery is retried with growing delays, 10 attempts in all over about 23 hours, and an endpoint that keeps failing for 604,800,000 ms (7 days) is disabled. A delivery can arrive more than once, so ignore an event `id` you have already handled. ### x402 payments For [x402](/x402) purchases, `x402_topup` and `x402_purchase` first answer 402, which the client raises as an `ApiError`. Its body is the payment challenge rather than a problem document, so its `code` is `UPSTREAM_UNAVAILABLE`. Recognise it by `status` 402 and a result from `payment_required_of(error)`, which reads what to pay from the error and returns `None` when the error carries no challenge. `encode_payment_signature` builds the `PAYMENT-SIGNATURE` header for the paid request (the `payment_signature` argument), and `decode_payment_response` reads the settlement receipt. Check the requirement's `payTo`, `asset` and `amount` against your own configuration before you sign. ### Configuration `Client` and `AsyncClient` take the same options. Only `base_url` is positional. | Option | Default | What it does | |---|---|---| | `base_url` | required | The API origin: `https://api.co-relayer.com`, or `https://devnet-api.co-relayer.com` for devnet. | | `direct_hosts` | none | Regional hosts to fail over to, in order. Take them from `get_network()`. | | `failover_after_ms` | 1,500 ms | How long the main host may take before the next host is tried. Used only with `direct_hosts`. | | `timeout_ms` | 15,000 ms | The time limit of one attempt on a direct host, or on the main host when there are none. | | `http_client` | httpx | A `SyncHttpClient` (or `AsyncHttpClient`) for another HTTP stack or a test double. The default does not follow redirects. | | `headers` | none | Headers added to every request. | | `user_agent` | `corelayer-sdk-python/` | The `User-Agent` header. | | `max_response_bytes` | 32 MiB | The largest answer body read into memory. | | `api_key` | none | Sent as `X-Api-Key` on the operations that accept it: the relay and the account reads, never the assign call or `GET /v1/network`. A sponsor key pays for your users' transactions. | | `native_auth_token` | none | Returns the user's native-auth token before each request. | ### How requests behave - The request body is serialised once. If the main host does not answer within `failover_after_ms` (1,500 ms by default), fails at the network level, answers 5xx, or answers 2xx with a body that is not JSON, the same bytes go to the next direct host. A 4xx is an answer, so it is raised at once and never tried on another host. - A cancelled call is never retried, and apart from failover nothing is retried for you. - For a normal call the time limit covers the whole answer, body included. For event streams and CSV exports only the wait for the headers is timed. - Redirects are not followed: a 3xx is raised as an `ApiError`, so a signed body and your API key never go to a host you did not configure. - A buffered answer body is limited to `max_response_bytes`, 32 MiB by default. A larger one counts as a failed attempt, like a network failure, and is never truncated. Streams and exports are not buffered. - Every request carries a `User-Agent` of `corelayer-sdk-python/`. - Times are Unix milliseconds and durations are milliseconds (`...Ms` members, `..._ms` arguments). - The client talks only to the CoRelayer API. The relayer verifier is the exception: it calls the gateway you give it, with a 5,000 ms limit unless you set `timeout_ms`. --- ## Check before you send A relay refused for lack of entitlement costs only a round trip: no signature is spent and no Relay Units are reserved. Checking first lets you decide what to do before you ask for a signature. ### Read the quota and a quote ```ts title="preflight.ts" snippet="examples/preflight.ts#preflight" /** * Before spending a signature: will this transaction be served, and what will it cost? * * Read the quota and ask for a quote. Both routes are public, so you need no key: * * GET /v1/account/{erd}/quota the account's service state, cap, escrow and period * POST /v1/quote this transaction's Relay Units, and what they would be billed to * * `billedAs` says how the server would bill this transaction: `grant`, `bonus`, `cap`, `payg` or * `free` mean it would be served, and `none` means it would not. The quota's `state` says why. The * types come from `@corelayer/sdk`. */ import type { CoRelayerClient, components } from '@corelayer/sdk'; import type { UnsignedTransaction } from './wallet.ts'; type Quota = components['schemas']['Quota']; type RelayQuote = components['schemas']['RelayQuote']; export type Verdict = | { readonly ok: true; readonly ru: bigint; readonly billedAs: NonNullable; /** Non-zero only when the units would be billed as pay-as-you-go. */ readonly priceMicroUsdc: bigint; } | { readonly ok: false; readonly ru: bigint; readonly reason: | 'buy-a-plan' // NO_ACCOUNT, LAPSED | 'cap-reached' // HALTED_CAP: turn on pay-as-you-go, or move up a tier | 'top-up-escrow' // HALTED_PAYG_EMPTY | 'suspended' // SUSPENDED: talk to support, buying does not help | 'not-enough-left'; // served, but not for a transaction this heavy readonly state: Quota['state']; }; /** Pure: the decision, from the two documents. */ export function decide(quota: Quota, quote: RelayQuote): Verdict { const ru = BigInt(quote.ru); if (quote.billedAs !== undefined && quote.billedAs !== 'none') { return { ok: true, ru, billedAs: quote.billedAs, priceMicroUsdc: BigInt(quote.priceMicroUsdc), }; } return { ok: false, ru, reason: reasonOf(quota.state), state: quota.state }; } /** Why an account is not served, from its quota's `state`. */ export function reasonOf(state: Quota['state']): Extract['reason'] { switch (state) { case 'NO_ACCOUNT': case 'LAPSED': return 'buy-a-plan'; case 'HALTED_CAP': return 'cap-reached'; case 'HALTED_PAYG_EMPTY': return 'top-up-escrow'; case 'SUSPENDED': return 'suspended'; case 'ACTIVE_CAP': case 'ACTIVE_PAYG': case 'RENEWAL_PENDING': return 'not-enough-left'; } } /** Reads both documents and decides. `account` defaults to the sender. */ export async function preflight( client: CoRelayerClient, tx: UnsignedTransaction, account: string = tx.sender, signal?: AbortSignal, ): Promise { const [{ data: quota }, { data: quote }] = await Promise.all([ client.getQuota(account, signal), client.quoteRelay({ tx, account }, signal), ]); return decide(quota, quote); } ``` An agent can check the account that pays for it without holding that account's key. The quota comes from public chain data, and the quote reads only the fields of the transaction that affect its price. | From | Member | Meaning | |---|---|---| | Quota | `state` | `ACTIVE_CAP`, `ACTIVE_PAYG`, `RENEWAL_PENDING` are served; `HALTED_CAP`, `HALTED_PAYG_EMPTY`, `LAPSED`, `NO_ACCOUNT`, `SUSPENDED` are not. | | Quota | `cap.left`, `payg.leftRu`, `period.endMs` | What remains in this period, and when the period ends, in chain time. | | Quota | `chainTimeMs` | The block timestamp every one of those is measured against. | | Quote | `ru` | Relay Units this transaction costs, from the [formula](/concepts/relay-units). | | Quote | `billedAs` | `grant`, `bonus`, `cap`, `payg` or `free` when it would be served, `none` when it would not. | | Quote | `priceMicroUsdc` | Non-zero only when it would be billed as pay-as-you-go. | A period ends by chain time. Compare `period.endMs` with the quota's `chainTimeMs`, and ignore your own clock. ### In sponsor mode If your server pays for its users with a sponsor key, the check above answers the wrong question. `POST /v1/quote` and `POST /v1/validate` don't read `X-Api-Key`, so they price the transaction for its sender: - A user you sponsor has no plan, so `billedAs` comes back `none` and `preflight` says `buy-a-plan`, although your plan would pay. - Naming your own account in `account` doesn't help. Your account hasn't listed that user, so the quote can't bill it, and the verdict says `not-enough-left`. The relay itself, sent with your key, bills your account: its answer carries `billing.authMode: "api_key"`. So in sponsor mode: - **Read your own quota**, the account that owns the key, not the sender's. - **Take only `ru` from the quote.** It doesn't depend on who pays. Ignore `billedAs` and `priceMicroUsdc`, and `wouldBill` from `/v1/validate`. - **Don't run the check on every action.** `/v1/quote` and `/v1/validate` allow 2 requests per second per IP ([Limits](/operations/limits)), so a busy server that checks each user's action hits that limit long before its plan runs out. Read your quota once in a while, or keep it current from the [notice feed](#the-notice-feed), and pass it in. The Relay Unit cost can be worked out on your side with [the formula](/concepts/relay-units), without a request. Add this to the same file: ```ts title="preflight.ts" snippet="examples/preflight.ts#sponsored" /** * In sponsor mode, `POST /v1/quote` cannot see your sponsor key. It prices the transaction for the * sender, and a sponsored user has no plan, so `billedAs` is `none` and says nothing about your * account. Naming your own account does not help either: it has not listed the user, so the quote * cannot bill it. Take only `ru` from the quote and judge your own account's quota, with the rule * the relay applies to the account a sponsor key bills: grant first, then the cap, then * pay-as-you-go while it has escrow. * * Pure, so a quota you read a minute ago serves every action until then. */ export function decideSponsored(yourQuota: Quota, ru: bigint): Verdict { const amount = (value: number | string | undefined): bigint => BigInt(value ?? 0); const { state, grant, cap, payg } = yourQuota; if (state === 'ACTIVE_CAP' || state === 'ACTIVE_PAYG' || state === 'RENEWAL_PENDING') { if (amount(grant?.left) >= ru) return { ok: true, ru, billedAs: 'grant', priceMicroUsdc: 0n }; if (state !== 'ACTIVE_PAYG' && amount(cap?.left) >= ru) { return { ok: true, ru, billedAs: 'cap', priceMicroUsdc: 0n }; } if (payg?.enabled === true && amount(payg.escrowMicro) > 0n) { return { ok: true, ru, billedAs: 'payg', priceMicroUsdc: amount(payg.pricePerRuMicro) * ru }; } } return { ok: false, ru, reason: reasonOf(state), state }; } /** * The same check for a server that relays with a sponsor key: your quota, and the quote's `ru`. * `yourAccount` is the account that owns the key. Pass `yourQuota` when you already hold a recent * one, so the check costs one request instead of two. */ export async function preflightSponsored( client: CoRelayerClient, tx: UnsignedTransaction, yourAccount: string, options: { readonly yourQuota?: Quota; readonly signal?: AbortSignal } = {}, ): Promise { const { yourQuota, signal } = options; const [quota, { data: quote }] = await Promise.all([ yourQuota ?? client.getQuota(yourAccount, signal).then((r) => r.data), // No `account`: the quote is asked only for `ru`, which does not depend on who pays. client.quoteRelay({ tx }, signal), ]); return decideSponsored(quota, BigInt(quote.ru)); } ``` A sponsor key also has daily limits of its own (per key, and per user if you set one). The quota doesn't show them. When one is reached, the relay answers [`QUOTA_EXHAUSTED`](/errors/quota-exhausted) with `details.scope`. The whole sponsor flow is in [Pay for your users](/sdk/recipes/sponsor-users). ### Choosing a tier ```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); } ``` Every amount in the pricing document is a decimal string in micro-USDC. A tier that is listed but not on sale carries `available: false` and `unavailableReason: "CAPACITY"`. ([Tiers](/plans/tiers) · [Tiers for agents](/plans/agent-tiers)) ### Do not poll the cap Instead of polling the quota, read the notice feed or follow the account stream. Both carry the same notices. #### The notice feed `GET /v1/account/{erd}/notices?sinceSeq=` returns the account's notices in order. It needs native auth or a sponsor key with the `read` scope. `seq` is per account and strictly increasing, so the last `seq` you processed is a reliable checkpoint. The response is a page: `{ items, nextCursor, hasMore }`, each item a `Notice` with `kind`, `severity`, `title`, `message`, `data` and `seq`. The kinds that concern the cap and the price: | `kind` | When | |---|---| | `cap.threshold` | Usage crossed 80 % of the cap, and each threshold you configured. | | `cap.reached` | 100 %. | | `payg.escrow_low`, `payg.escrow_empty` | Pay-as-you-go escrow below its low mark; exhausted. | | `service.halted`, `service.resumed` | The account stopped being served, or started again. | | `tariff.scheduled` | A price increase was scheduled, 48 hours ahead. Cannot be switched off. | | `renewal.upcoming`, `renewal.succeeded`, `renewal.skipped` | Auto-renew, 72 hours before and after. | #### The account stream For a connection held open, `GET /v1/stream` streams notices, intent changes and quota movements for one account. Send a native-auth header, or from a browser use a single-use ticket from `POST /v1/stream/tickets`. A browser's `EventSource` cannot send headers, and a token must never travel in a URL. This deployment does not deliver webhooks or e-mail: registering a webhook endpoint answers `503` with `reason: WEBHOOK_DELIVERY_NOT_AVAILABLE`, and no e-mail is sent. The notice feed and the stream carry every notice. ### When the cap is reached `QUOTA_EXHAUSTED` is a 429 without a `Retry-After` header, because waiting does not help until the period ends or the account adds funds. Do not retry it in a loop. Its `details` carry `reason` (`CAP_REACHED_PAYG_OFF` or `PAYG_ESCROW_EMPTY`), `periodEndMs`, `pricingUrl` and `x402Url`. ### Which errors to wait on | Code | Status | Waiting helps? | Do | |---|---|---|---| | [`RATE_LIMITED`](/errors/rate-limited) | 429 | Yes, after `Retry-After` (also in `details.retryAfterMs`) | Wait, then send the same bytes again. | | [`QUOTA_EXHAUSTED`](/errors/quota-exhausted) | 429 | No | Turn on pay-as-you-go, top up its escrow, or move up a tier. | | [`NO_ENTITLEMENT`](/errors/no-entitlement) | 402 | No | Buy or renew a plan. | | [`PAYG_PRICE_ABOVE_MAX`](/errors/payg-price-above-max) | 409 | Only if the price comes back down | Raise your `max_payg_price`, or wait. | `RATE_LIMITED` and `QUOTA_EXHAUSTED` are both 429s but need different actions, so branch on `code`. --- ## Handle a re-sign request CoRelayer asks for a second signature in one case only: the assigned relayer became unusable before it co-signed. ### What it means | | | |---|---| | Was anything sent? | No. | | Can the transaction already signed ever execute? | No. It has no relayer signature, so it can never become valid. | | Which nonce does the new one use? | The same one, pinned. | | Whose decision is it? | Yours. No library makes it for you. | `RESIGN_REQUIRED` and `RESIGN_SAME_NONCE` both carry `resign: "NEW_SIGNATURE_SAME_NONCE"`: | Code | Cause | |---|---| | [`RESIGN_REQUIRED`](/errors/resign-required) | The assigned relayer can no longer be used, and nothing was co-signed. | | [`RESIGN_SAME_NONCE`](/errors/resign-same-nonce) | A replacement for the same nonce is needed, with a gas price at least one higher. This happens when the relayer's key is flagged as compromised, or the relayer could not be funded in time. | Both problem documents carry an `assignment` whose lease is pinned to that nonce, and the signer refuses to co-sign any other nonce with it. Sign against that assignment. A new assignment from the assign endpoint is not pinned, so it is not safe here. ([The intent lifecycle](/concepts/intent-lifecycle)) ### How it reaches you `relayOnce` returns this case instead of throwing. The [`sendToken` example](/sdk/recipes/sign-and-relay) passes it on with the call it was relaying: ```ts import { type RelayContext, type SendOutcome, sendToken } from './send-token.ts'; export async function send( context: RelayContext, receiver: string, amount: bigint, intentKey: string, ): Promise { const outcome = await sendToken(context, { receiver, amount, intentKey }); if (outcome.kind === 'resign-required') { console.log(outcome.previousRelayer); // the relayer that went away console.log(outcome.nextAssignment); // the replacement, pinned to the nonce console.log(outcome.pinnedNonce); // the nonce to keep console.log(outcome.call); // the unchanged call } return outcome; } ``` ### Sign once more You can pass `assignment: outcome.nextAssignment`, `minGasPrice` and a transaction built for the pinned nonce to `relayOnce`. With `signProof`, it times a renewal proof for that lease from the moment the re-sign answer arrived, so the time the person took to decide is counted. That works for the `nextAssignment` object `relayOnce` returned, passed on as it is. A copy of it is timed from the moment you call `relayOnce`. The example below does the same steps by hand: it checks the new relayer, builds the same call at the pinned nonce, signs once through `guardSignOnce`, checks the nonce and submits with the pinned lease: ```ts title="send-token.ts" snippet="examples/send-token.ts#resign" /** * Signs the same call once more, for the replacement relayer, on the pinned nonce. This is the one * case in which a second signature is correct. Call it only after a person confirmed, or from an * agent policy that stops after a fixed number of re-signs. * * It uses the assignment that came back with `RESIGN_REQUIRED`. That lease is pinned to the nonce, * so the second transaction can only replace the first. A new assignment from the assign endpoint * is not pinned, so it is not safe here. */ export async function resignWith( context: RelayContext, outcome: Extract, intentKey: string, signal?: AbortSignal, ): Promise { const { client, wallet, relayers, chainId } = context; const assignment = outcome.nextAssignment; if (assignment === undefined) { throw new Error( 'The server returned no replacement assignment; ask for one before re-signing.', ); } const nonce = assignment.pinNonce ?? outcome.pinnedNonce; if (nonce !== outcome.pinnedNonce) { throw new Error( `Replacement lease is pinned to nonce ${nonce}, expected ${outcome.pinnedNonce}.`, ); } await relayers.verify(wallet.address, assignment.relayer, assignment.registryVersion, signal); const unsigned = forAssignment(outcome.call, nonce, assignment, chainId); const sign = guardSignOnce(async ({ transaction }) => wallet.signTransaction(transaction)); const signed = await sign({ assignment, transaction: unsigned }); if (signed.nonce !== nonce) { throw new Error( `The wallet signed nonce ${signed.nonce}, not the pinned ${nonce}. Nothing sent.`, ); } const { data } = await client.relay( { tx: signed, lease: assignment.lease }, { intentKey, ...(signal === undefined ? {} : { signal }) }, ); return data; } ``` Keep the idempotency key of the original action, because it is the same action. ### For a person Ask the person first, in a dialog from your own UI. The function below signs only when the dialog answers yes: ```ts title="confirm-resign.ts" import type { RelayResponse } from '@corelayer/sdk'; import { type RelayContext, resignWith, type SendOutcome } from './send-token.ts'; /** What the dialog shows before the person decides. */ export interface ResignQuestion { /** The nonce the new transaction keeps. */ readonly nonce: number; readonly previousRelayer: string; readonly nextRelayer: string; /** 1 for the first re-sign of this nonce, 2 for the second. */ readonly attempt: number; } /** * Signs once more if the person agrees. `ask` opens your dialog and resolves to `true` when the * person chooses to sign again. Resolves to `undefined` when nothing was signed. */ export async function confirmResign( context: RelayContext, outcome: Extract, intentKey: string, attempt: number, ask: (question: ResignQuestion) => Promise, ): Promise { const next = outcome.nextAssignment; // Without a replacement assignment there is nothing safe to sign. if (next === undefined || attempt > 2) return undefined; const agreed = await ask({ nonce: outcome.pinnedNonce, previousRelayer: outcome.previousRelayer, nextRelayer: next.relayer, attempt, }); return agreed ? resignWith(context, outcome, intentKey) : undefined; } ``` Open the dialog with focus on Discard, so a stray Enter cannot sign. Say that the new transaction uses the same nonce, so the two cannot both execute. After two re-signs for one nonce, stop offering a third. Do not re-sign silently or call this a retry. Tell the user the first transaction was never sent and can never execute. ### For a headless agent A program with no person to ask can decide in code, with a fixed limit: ```ts title="resign-policy.ts" snippet="examples/resign-policy.ts" /** * Re-signs at most `maxResigns` times, for a program with no person to ask. Each re-sign uses the * pinned assignment from the answer and keeps the nonce and the idempotency key. */ import { isApiError, type RelayResponse } from '@corelayer/sdk'; import { type RelayContext, resignWith, type SendTokenRequest, sendToken } from './send-token.ts'; export async function sendWithBoundedResign( context: RelayContext, request: SendTokenRequest, maxResigns = 2, ): Promise { const first = await sendToken(context, request); if (first.kind === 'relayed') return first.response; let pending = first; for (let resigns = 1; ; resigns += 1) { try { return await resignWith(context, pending, request.intentKey, request.signal); } catch (error) { // Retry only on another re-sign answer, and only within the bound. Repeated re-signs point // to a wider problem, so report it. if (!isApiError(error) || !error.needsNewSignature || resigns >= maxResigns) throw error; pending = { ...pending, previousRelayer: pending.nextAssignment?.relayer ?? pending.previousRelayer, nextAssignment: error.problem.assignment, }; } } } ``` The loop stops after `maxResigns`, so one action never collects an unlimited number of signatures. It keeps the idempotency key, because it is the same action, and it keeps the pinned nonce, because moving to the next nonce could leave two live transactions for one action. ### Decide from `resign` A 409 has several meanings, so do not re-sign on the status alone. Read `resign`: ```ts import { isApiError } from '@corelayer/sdk'; export async function onRelayError( error: unknown, signAgain: () => Promise, ): Promise { // `needsNewSignature` is true when `resign` is 'NEW_SIGNATURE_SAME_NONCE'. if (isApiError(error) && error.needsNewSignature) { await signAgain(); return; } throw error; } ``` | `resign` | Do | |---|---| | `NONE` | Do not sign. A new signature does not help. | | `SAME_BYTES` | Send the same signed bytes again. Do not sign. | | `NEW_SIGNATURE_SAME_NONCE` | Sign once more, on the same nonce, with the pinned assignment. | ### What is not a re-sign | Situation | Correct response | |---|---| | A timeout on `POST /v1/relay` | Send the same signed bytes again, or read the intent. Do not sign again: the transaction may already be on its way. | | [`LEASE_EXPIRED`](/errors/lease-expired) with `renewable: true` | Renew the lease for the same relayer and send the same signed bytes again. `relayOnce` does this for you when the client has a native-auth token for the sender or you pass `signProof`, because the renewal needs a fresh presence proof. | | [`NONCE_TOO_LOW`](/errors/nonce-too-low) or [`INTENT_ALREADY_EXECUTED`](/errors/intent-already-executed) after a re-sign | The flow is over: the slot was consumed, very likely by your first transaction. Read its outcome with `GET /v1/intents/{sender}/{nonce}`; do not start a fresh flow for the same action. | | [`RATE_LIMITED`](/errors/rate-limited) | Wait, then send the same signed bytes again. | ### Why CoRelayer cannot pick another relayer for you CoRelayer cannot swap the relayer, because its address is part of the bytes you signed ([Relayed v3](/concepts/relayed-v3)). Signing one variant per candidate relayer would avoid this prompt, but each variant could execute, so CoRelayer asks for one signature and asks again only in this case. ([One signature](/concepts/one-signature)) --- ## Sign and relay `send-token.ts` below is the whole relay flow in one file. The docs test suite type-checks it against the API's generated types and runs it against a stand-in API that verifies its signatures. ### The complete version ```ts title="send-token.ts" snippet="examples/send-token.ts" /** * Sends a token without holding EGLD. This is the whole relay flow. * * connect read /v1/network and refuse to go on if it is not the chain you meant * build an ordinary ESDT transfer, built by sdk-core, no relayer yet * relay relayOnce: sign a presence proof (a message, not a transaction), assign, verify the * relayer on chain, sign once, submit * * `relayOnce` calls the transaction signer at most once. The only thing it retries by itself is a * renewable expired lease: it signs a fresh presence proof, renews the lease and resubmits the * identical signed bytes. A request for a new signature comes back to you as an outcome; * `sendToken` never acts on it. `resignWith` is the one function that does, and only when you call * it. */ import { type Assignment, assignProofMessage, type CoRelayerClient, guardSignOnce, type Network, type RelayResponse, relayOnce, } from '@corelayer/sdk'; import { Address, Token, TokenTransfer, TransactionsFactoryConfig, TransferTransactionsFactory, } from '@multiversx/sdk-core'; import type { Gateway } from './gateway.ts'; import type { RelayerCheck } from './verify-relayer.ts'; import type { UnsignedTransaction, Wallet } from './wallet.ts'; export interface RelayContext { readonly client: CoRelayerClient; readonly wallet: Wallet; /** A MultiversX gateway you choose, used to read the account nonce. */ readonly gateway: Gateway; /** Checks the assigned relayer on chain against the contract address you pinned. */ readonly relayers: RelayerCheck; /** The chain ID you mean to sign for. The API's chain ID is checked against it. */ readonly chainId: string; } /** Reads the network facts and refuses to continue on any other chain. */ export async function connect(context: RelayContext, signal?: AbortSignal): Promise { const { data: network } = await context.client.getNetwork(signal); if (network.chainId !== context.chainId) { // An address looks the same on devnet and mainnet, so it never tells you which network you // are on. Only the chain ID does. throw new Error( `Refusing to continue: the API serves chain ${network.chainId}, not ${context.chainId}.`, ); } return network; } /** * The presence proof `POST /v1/relay/assign` needs when you hold the sender key. It is a signature * over a short message, valid once and for 30,000 ms around the server's clock. The time in the * message comes from the server, so the clock of this machine does not matter. `relayOnce` makes * these itself when you give it `signProof`; this function is for calling the assign route * yourself. */ export async function presenceProof(context: RelayContext, signal?: AbortSignal) { const network = await connect(context, signal); const message = assignProofMessage(network.chainId, context.wallet.address, network.serverTimeMs); return { kind: 'key' as const, serverTimeMs: network.serverTimeMs, signature: await context.wallet.signProofMessage(message), }; } /** What to send, before any relayer is involved. */ export interface Call { readonly sender: string; readonly receiver: string; readonly value: string; readonly data: string | undefined; /** Gas for the call itself; the relayed-transaction surcharge is added per assignment. */ readonly gasLimit: number; } /** An ESDT transfer, built by sdk-core with this network's gas constants. */ export async function tokenTransfer( network: Network, sender: string, receiver: string, token: string, amount: bigint, ): Promise { const config = new TransactionsFactoryConfig({ chainID: network.chainId }); config.minGasLimit = BigInt(network.minGasLimit); config.gasLimitPerByte = BigInt(network.gasPerDataByte); const factory = new TransferTransactionsFactory({ config }); const tx = await factory.createTransactionForESDTTokenTransfer(Address.newFromBech32(sender), { receiver: Address.newFromBech32(receiver), tokenTransfers: [new TokenTransfer({ token: new Token({ identifier: token }), amount })], }); const plain = tx.toPlainObject(); return { sender, receiver: plain.receiver, value: plain.value, data: plain.data, gasLimit: plain.gasLimit, }; } /** * The transaction for one assignment. Only the relayer-specific fields depend on it; the call is * fixed. Refuses an assignment for another chain. */ export function forAssignment( call: Call, nonce: number, assignment: Assignment, chainId: string, ): UnsignedTransaction { if (assignment.chainId !== chainId) { throw new Error(`Assignment is for chain ${assignment.chainId}; refusing to build for it.`); } return { nonce, value: call.value, receiver: call.receiver, sender: call.sender, gasPrice: assignment.minGasPrice, // The extra gas for a relayed transaction comes from the assignment. Do not add more on top: // Relay Units are counted on the worst case the gas limit allows. gasLimit: call.gasLimit + assignment.extraGasRelayed, ...(call.data === undefined ? {} : { data: call.data }), chainID: assignment.chainId, version: 2, relayer: assignment.relayer, // inside the signed bytes from here on }; } export interface SendTokenRequest { readonly receiver: string; /** Token identifier. Defaults to the network's payment token (the USDC of that network). */ readonly token?: string; /** Amount in the token's smallest unit. USDC has 6 decimals, so 5 USDC is 5_000_000n. */ readonly amount: bigint; /** One key per business action. Retries and re-signs of the same action reuse it. */ readonly intentKey: string; /** * The nonce to sign. Defaults to the account's on-chain nonce, which is right for one * transaction at a time. To send several in a row, count the nonces yourself: n, n + 1, … */ readonly nonce?: number; readonly signal?: AbortSignal; } export type SendOutcome = | { readonly kind: 'relayed'; readonly response: RelayResponse } | { /** Nothing was sent, and the transaction already signed can never execute. */ readonly kind: 'resign-required'; readonly call: Call; readonly previousRelayer: string; /** The replacement the server already assigned, with a lease pinned to `pinnedNonce`. */ readonly nextAssignment: Assignment | undefined; readonly pinnedNonce: number; }; export async function sendToken( context: RelayContext, request: SendTokenRequest, ): Promise { const { client, wallet, gateway, relayers, chainId } = context; const { signal } = request; const network = await connect(context, signal); const token = request.token ?? network.paymentToken; if (token === undefined) { throw new Error('No token given and the network reports no payment token.'); } const call = await tokenTransfer( network, wallet.address, request.receiver, token, request.amount, ); const nonce = request.nonce ?? (await gateway.accountNonce(wallet.address, signal)); const result = await relayOnce({ client, sender: wallet.address, // Every assign call gets a fresh presence proof: the first one, and the renewal when the lease // expires while the wallet is signing. A proof is accepted only once. signProof: (message) => wallet.signProofMessage(message), intentKey: request.intentKey, ...(signal === undefined ? {} : { signal }), buildTransaction: (assignment) => forAssignment(call, nonce, assignment, chainId), // The single signature. The relayer is checked on chain first; if it is not an active // CoRelayer relayer in your shard, nothing is signed. signOnce: async ({ assignment, transaction }) => { await relayers.verify(wallet.address, assignment.relayer, assignment.registryVersion, signal); return wallet.signTransaction(transaction); }, }); if (result.kind === 'relayed') return { kind: 'relayed', response: result.response }; return { kind: 'resign-required', call, previousRelayer: result.previousRelayer, nextAssignment: result.nextAssignment, pinnedNonce: result.pinnedNonce, }; } /** * Signs the same call once more, for the replacement relayer, on the pinned nonce. This is the one * case in which a second signature is correct. Call it only after a person confirmed, or from an * agent policy that stops after a fixed number of re-signs. * * It uses the assignment that came back with `RESIGN_REQUIRED`. That lease is pinned to the nonce, * so the second transaction can only replace the first. A new assignment from the assign endpoint * is not pinned, so it is not safe here. */ export async function resignWith( context: RelayContext, outcome: Extract, intentKey: string, signal?: AbortSignal, ): Promise { const { client, wallet, relayers, chainId } = context; const assignment = outcome.nextAssignment; if (assignment === undefined) { throw new Error( 'The server returned no replacement assignment; ask for one before re-signing.', ); } const nonce = assignment.pinNonce ?? outcome.pinnedNonce; if (nonce !== outcome.pinnedNonce) { throw new Error( `Replacement lease is pinned to nonce ${nonce}, expected ${outcome.pinnedNonce}.`, ); } await relayers.verify(wallet.address, assignment.relayer, assignment.registryVersion, signal); const unsigned = forAssignment(outcome.call, nonce, assignment, chainId); const sign = guardSignOnce(async ({ transaction }) => wallet.signTransaction(transaction)); const signed = await sign({ assignment, transaction: unsigned }); if (signed.nonce !== nonce) { throw new Error( `The wallet signed nonce ${signed.nonce}, not the pinned ${nonce}. Nothing sent.`, ); } const { data } = await client.relay( { tx: signed, lease: assignment.lease }, { intentKey, ...(signal === undefined ? {} : { signal }) }, ); return data; } ``` #### The signer It needs a signer. For a program that holds its own key, this is the whole of it: ```ts title="wallet.ts" snippet="examples/wallet.ts" /** * A signer for a program that holds its own MultiversX key. * * CoRelayer never sees a private key. A program that relays through CoRelayer signs the * presence-proof message that `POST /v1/relay/assign` asks for (an Ed25519 signature over UTF-8 * bytes, with no nonce), and then each transaction once, over bytes that already name the relayer. * * The proof is signed **raw**: `UserSigner.sign` over the bytes of the message. A wallet's * `signMessage` (sdk-core `Account.signMessage`, the sdk-dapp providers) first wraps the message in * the MultiversX signed-message prefix, and that signature does not verify as a presence proof, so * a signing service that holds your users' keys must be able to sign raw bytes. The method below * is named `signProofMessage` so that it is never mistaken for `signMessage`. A native-auth token is * the other way round: it is signed with the prefix. * * In the CoRelayer dashboard, the wallet signs the transaction through its dApp connector, and the * native-auth login takes the place of the presence proof. This file is for agents, scripts and * servers, including a server that pays for its users with a sponsor key (`sponsor-relay.ts`). */ import { readFileSync } from 'node:fs'; import type { TransactionPlain } from '@corelayer/sdk'; import { Transaction, TransactionComputer, UserSigner } from '@multiversx/sdk-core'; /** A transaction ready to be signed: every field of `TransactionPlain` except the signature. */ export type UnsignedTransaction = Omit; export interface Wallet { /** The bech32 address (`erd1…`) of the key. */ readonly address: string; /** * The presence proof: a raw Ed25519 signature over the UTF-8 bytes of `message`, with no * MultiversX signed-message prefix, as 128 hex characters. */ signProofMessage(message: string): Promise; /** Signs the transaction as given, without changing it, and returns it with `signature` set. */ signTransaction(transaction: UnsignedTransaction): Promise; } const computer = new TransactionComputer(); function hex(bytes: Uint8Array): string { return Buffer.from(bytes).toString('hex'); } /** Wraps an sdk-core `UserSigner`. */ export function walletFromSigner(signer: UserSigner): Wallet { const address = signer.getAddress().toBech32(); return { address, async signProofMessage(message) { // Raw: the bytes of the message themselves, with no MultiversX signed-message prefix. return hex(await signer.sign(new TextEncoder().encode(message))); }, async signTransaction(transaction) { if (transaction.sender !== address) { throw new Error(`This wallet is ${address}; refusing to sign for ${transaction.sender}.`); } // sdk-core serialises the fields in the order the protocol signs them. `relayer` is one of // them, so the relayer cannot be changed once this signature exists. const tx = Transaction.newFromPlainObject({ ...transaction }); const signature = await signer.sign(computer.computeBytesForSigning(tx)); return { ...transaction, signature: hex(signature) }; }, }; } /** * Loads a PEM key file. The path is the only thing this function is given; the key never leaves * this process. */ export function walletFromPemFile(path: string): Wallet { return walletFromSigner(UserSigner.fromPem(readFileSync(path, 'utf8'))); } ``` `signProofMessage` signs the presence proof raw: the UTF-8 bytes of the message, with the sender's key, through sdk-core `UserSigner.sign`. Do not wire `signProof` to a wallet's `signMessage` or to sdk-core `Account.signMessage`. They add the MultiversX message prefix, which a native-auth token needs and a presence proof must not have, and the API answers [`ASSIGN_PROOF_INVALID`](/errors/assign-proof-invalid). A signing service that holds your users' keys must be able to sign raw bytes. The example reads the account nonce and the relayer's registry state from a MultiversX gateway you choose. See [Verify a relayer](/concepts/verify-a-relayer). ### Using it ```ts import { CoRelayerClient } from '@corelayer/sdk'; import { Gateway } from './gateway.ts'; import { sendToken } from './send-token.ts'; import { RelayerCheck } from './verify-relayer.ts'; import { walletFromPemFile } from './wallet.ts'; // The devnet CoRelayer contract, from your configuration (see below). const PINNED_DEVNET_CONTRACT = process.env.CORELAYER_CONTRACT ?? ''; const gateway = new Gateway({ url: 'https://devnet-gateway.multiversx.com' }); const context = { client: new CoRelayerClient({ baseUrl: 'https://devnet-api.co-relayer.com' }), wallet: walletFromPemFile(process.env.CORELAYER_PEM ?? ''), gateway, relayers: new RelayerCheck({ chainId: 'D', contract: PINNED_DEVNET_CONTRACT, gateway }), chainId: 'D', }; const outcome = await sendToken(context, { receiver: 'erd1…', amount: 5_000_000n, // 5 USDC: the payment token has 6 decimals intentKey: 'invoice-2026-0917', // one per business action }); ``` Set `PINNED_DEVNET_CONTRACT` to the devnet address in [the contract page's "At a glance"](/contract/overview#at-a-glance), or in [`/.well-known/corelayer.json`](https://co-relayer.com/.well-known/corelayer.json). Keep it in your own configuration, so an API answer can never change which contract you check against. With no address, the relayer check refuses every relayer and nothing is signed. ### The three outcomes | Outcome | What it means | What to do | |---|---|---| | `relayed` | Past the commit point. `response.state` is `COSIGNED` or `BROADCAST`, with the intent id and the hash. | Follow it: [Watch an intent](/sdk/recipes/watch-an-intent). | | `resign-required` | The relayer became unusable before anything was co-signed. Nothing was sent, and the signed transaction can never execute. | Ask the user. If they agree, call `resignWith` once. [Handle a re-sign request](/sdk/recipes/handle-resign). | | A thrown `ApiError` | Refused before the commit point. Nothing was sent. | Branch on `code`. [Errors and retries](/agents/errors-and-retries). | A `TransportError` or a timeout is not an outcome: you do not know whether the transaction arrived. Send the same signed bytes again, or read `GET /v1/intents/{sender}/{nonce}`. Do not build or sign a new transaction. ### What `relayOnce` handles for you | Handled for you | Why it is safe | |---|---| | A renewable expired lease | The lease is renewed for the same relayer and the same signed bytes are sent again. No new signature, no new nonce. The renewal needs a fresh presence proof, which is why the example passes `signProof`; with a native-auth token on the client it needs none. | | Nonce check after signing | If the wallet changed the nonce, the helper refuses to submit, because that transaction would be a second one the flow does not track. | | Returned to you | Why | |---|---| | `resign-required` | Only the user can decide to sign again. | | `LEASE_EXPIRED` after a ready-made `proof` | The API accepts a proof once, so `relayOnce` does not send it again. Pass `signProof` to have the lease renewed. | | Everything else | Thrown as `ApiError`, with the server's own `retryable` and `resign`. | Within one `relayOnce` call your signer runs at most once. A second call throws: ```ts import { type Assignment, guardSignOnce } from '@corelayer/sdk'; import type { UnsignedTransaction, Wallet } from './wallet.ts'; export async function signTwice( wallet: Wallet, assignment: Assignment, transaction: UnsignedTransaction, ): Promise { const sign = guardSignOnce(async (input) => wallet.signTransaction(input.transaction)); await sign({ assignment, transaction }); // signs await sign({ assignment, transaction }); // throws: "relayOnce: the signer was called twice. …" } ``` ### Without the helper Without `relayOnce`, the flow is an assign call, the relayer check and a relay call. Keep the nonce check: ```ts import type { RelayResponse } from '@corelayer/sdk'; import { type Call, forAssignment, presenceProof, type RelayContext } from './send-token.ts'; export async function relayByHand( context: RelayContext, call: Call, nonce: number, intentKey: string, ): Promise { const { client, wallet, relayers, chainId } = context; const sender = wallet.address; const proof = await presenceProof(context); const { data: assignment } = await client.assignRelayer({ sender, proof }); await relayers.verify(sender, assignment.relayer, assignment.registryVersion); const unsigned = forAssignment(call, nonce, assignment, chainId); const signed = await wallet.signTransaction(unsigned); // once if (signed.nonce !== unsigned.nonce) throw new Error('The wallet changed the nonce. Nothing sent.'); const { data } = await client.relay({ tx: signed, lease: assignment.lease }, { intentKey }); return data; } ``` ### Gas The assignment carries the gas numbers: | From the assignment | Use | |---|---| | `minGasPrice`, `maxGasPrice` | Your `gasPrice` must lie between them; the minimum is the right choice. | | `extraGasRelayed` | Add it to the gas your call needs, on every relayed transaction. | | `extraGasGuarded` | Add it as well when your account uses a guardian. | Do not pad "for safety". Relay Units are counted on the worst case your gas limit allows, so unused headroom is something you pay for. `POST /v1/quote` tells you the cost before you sign. ([Relay Units](/concepts/relay-units)) --- ## 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. --- ## Watch an intent `POST /v1/relay` answers at the commit point, once the relayer has co-signed. The transaction is not in a block yet. After it returns you hold an intent ID and a state, and you follow it from there. | Way | Shape | Best for | |---|---|---| | `GET /v1/relay/{id}/events` | Server-sent events, one per state change | One transaction, live | | `client.waitForIntent()` | Polling with a terminal-state rule | One transaction, without streaming | | `GET /v1/intents/{sender}/{nonce}` | One authoritative read | "What happened to that one?" | | `GET /v1/stream` | Everything for one account | A server following many | All of the code below is in the site's runnable example `watch-intent.ts`, tested against a stand-in API that streams in uneven chunks the way a real network does. ### One decision, from the state ```ts title="watch-intent.ts" snippet="examples/watch-intent.ts#outcome" export type Outcome = /** Executed and final. The Relay Units are charged. */ | { readonly kind: 'executed'; readonly intent: Intent } /** Executed and reverted on chain. Charged too: the network charged the relayer the full gas. */ | { readonly kind: 'failed'; readonly intent: Intent } /** Another transaction took the nonce. Nothing charged; read your nonce and decide afresh. */ | { readonly kind: 'dead'; readonly intent: Intent } /** Never got past validation; nothing was co-signed. The problem document says what to fix. */ | { readonly kind: 'rejected'; readonly intent: Intent } /** Not decided yet. This includes EXPIRED_LOCALLY_STILL_VALID, which can still execute. */ | { readonly kind: 'pending'; readonly intent: Intent }; export function outcomeOf(intent: Intent): Outcome { switch (intent.state) { case 'EXECUTED_OK': return intent.final ? { kind: 'executed', intent } : { kind: 'pending', intent }; case 'EXECUTED_FAIL': return intent.final ? { kind: 'failed', intent } : { kind: 'pending', intent }; case 'DEAD': return { kind: 'dead', intent }; case 'REJECTED': return { kind: 'rejected', intent }; default: return { kind: 'pending', intent }; } } ``` | State | Charged | What to do | |---|---|---| | `EXECUTED_OK`, final | yes | Done. | | `EXECUTED_FAIL`, final | yes | It reverted on chain. The network charges the relayer the full gas limit for a failed execution, so the units are spent. Read `gasUsed` and the contract's own error. | | `DEAD` | no | Another transaction took the nonce, and the reservation is released. Read your account nonce and decide afresh. | | `REJECTED` | no | It never got past validation; nothing was co-signed. The problem document says what to fix. | | `EXPIRED_LOCALLY_STILL_VALID` | reserved | Not final. CoRelayer stopped re-broadcasting, but the transaction is still valid and may execute. Wait, send the same bytes again, or cancel or replace it with a pinned nonce. Do not sign a replacement as if it had failed. ([The intent lifecycle](/concepts/intent-lifecycle)) | An executed state becomes the outcome only once `final` is `true`. A block can still be reverted before finality, and finality is what triggers billing. ### Polling, with the rule built in ```ts title="watch-intent.ts" snippet="examples/watch-intent.ts#follow" /** Polls the authoritative read until the intent is decided or `timeoutMs` passes. */ export async function followIntent( client: CoRelayerClient, sender: string, nonce: number, options: { readonly timeoutMs?: number; readonly onState?: (intent: Intent) => void } = {}, ): Promise { const intent = await client.waitForIntent(sender, nonce, { intervalMs: 1_000, timeoutMs: options.timeoutMs ?? 120_000, ...(options.onState === undefined ? {} : { onUpdate: options.onState }), }); return outcomeOf(intent); } ``` `waitForIntent` stops at a terminal state: `EXECUTED_OK`, `EXECUTED_FAIL`, `DEAD` or `REJECTED`. An executed intent can still have `final: false`, which is why `outcomeOf` reports it as pending. Without `onError`, a failed read is thrown, so a failure never looks like a pending intent. At the time limit it returns the intent as it stands. With `onError`, a failed read is tried again after the milliseconds `onError` returns, cut short at the time limit. The time limit ends a run of failed reads too: the wait then returns the last intent it read, or throws the last error if no read succeeded. Pass a `signal` to stop the wait early, including while it waits between reads. Every API host gives the same answer to `GET /v1/intents/{sender}/{nonce}`, including a host that did not take your submission. It is the right call after a timeout, a crash or a restart. `client.getRelay(id)` resolves either an intent id (`erd1…:41`) or a 64-character transaction hash, if all you kept was the hash. ### The per-intent stream `client.streamRelay(intentId)` opens this stream as an `EventStream`, which handles every line ending the standard allows. It does not reconnect by itself: to resume after a dropped connection, open it again with `lastEventId` set to the stream's `lastEventId` (see [Event streams](/sdk/javascript#event-streams)). Without the client, a reader for this one stream is a few lines: ```ts title="watch-intent.ts" snippet="examples/watch-intent.ts#stream" /** * Reads the per-intent server-sent-events stream: one `intent` event per state change, each * carrying the full `Intent`. The server closes the stream after the first final event, after * `DEAD` or `REJECTED`, or after 120,000 ms, and the loop then ends. */ export async function* streamIntent( apiBase: string, intentId: string, options: { readonly fetch?: typeof globalThis.fetch; readonly signal?: AbortSignal } = {}, ): AsyncGenerator { const fetchImpl = options.fetch ?? globalThis.fetch; const response = await fetchImpl( `${apiBase.replace(/\/$/, '')}/v1/relay/${encodeURIComponent(intentId)}/events`, { headers: { accept: 'text/event-stream' }, ...(options.signal === undefined ? {} : { signal: options.signal }), }, ); if (!response.ok || response.body === null) { throw new Error(`Event stream for ${intentId} answered ${response.status}.`); } // Server-sent events: frames separated by a blank line, `field: value` lines inside a frame, // `:` lines are keep-alive comments. It uses a reader loop, because not every browser can iterate // a ReadableStream with `for await`. const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; for (;;) { const { done, value } = await reader.read(); if (done) return; buffer += decoder.decode(value, { stream: true }).replaceAll('\r\n', '\n'); let end = buffer.indexOf('\n\n'); while (end !== -1) { const frame = buffer.slice(0, end); buffer = buffer.slice(end + 2); end = buffer.indexOf('\n\n'); let event = 'message'; const data: string[] = []; for (const line of frame.split('\n')) { if (line.startsWith(':')) continue; const colon = line.indexOf(':'); const field = colon === -1 ? line : line.slice(0, colon); const content = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, ''); if (field === 'event') event = content; if (field === 'data') data.push(content); } if (event === 'intent' && data.length > 0) yield JSON.parse(data.join('\n')) as Intent; } } } ``` ```ts import { streamIntent } from './watch-intent.ts'; export async function show(intentId: string, render: (state: string) => void): Promise { for await (const intent of streamIntent('https://api.co-relayer.com', intentId)) { render(intent.state); } } ``` You can read the stream without credentials. When it closes, fall back to `followIntent`. It never sends `RECEIVED`, because it can only be opened once an intent ID exists. In a browser, `EventSource` reads the same stream: `new EventSource(url)` and `addEventListener('intent', …)`. ### What an intent looks like An intent looks like this (example values): ```json title="Intent" { "intentId": "erd1…:41", "sender": "erd1…", "nonce": 41, "txHash": "…", "relayer": "erd1…", "state": "EXECUTED_OK", "final": true, "ru": 1, "receivedAtMs": 1789819200000, "cosignedAtMs": 1789819200031, "broadcastAtMs": 1789819200062, "executedBlockTsMs": 1789819200600, "includedInBlock": { "shard": 1, "nonce": 12345678 }, "acks": [{ "gateway": "G1", "ms": 31 }], "latency": { "acceptToCosignMs": 31, "addedMs": 62, "inclusionMs": 600, "roundsToInclusion": 1 }, "feePaidAtto": "100000000000000", "gasUsed": 100000 } ``` Some members appear only where they apply: `deadReason`, `stuckReason`, `corrected` (reconciliation turned a `DEAD` into an executed state) and `explorerUrl`. `advisory` appears on `EXPIRED_LOCALLY_STILL_VALID` and explains that state. What every latency member measures is on [Telemetry](/operations/telemetry). ### For many transactions at once One stream per transaction does not scale. Use the account stream, `GET /v1/stream`: notices, intent changes and quota movements for one account, on one connection. From a server, authenticate with a native-auth header or a `read`-scoped key. From a browser, first get a single-use ticket from `POST /v1/stream/tickets` with the account and topics. `EventSource` cannot send a header, and a token must never travel in a URL. The account stream is the way to follow many transactions from a server. This deployment does not deliver webhooks or e-mail: registering a webhook endpoint answers `503` with `reason: WEBHOOK_DELIVERY_NOT_AVAILABLE`, and no e-mail is sent. --- ## Rust `corelayer-sdk` is the Rust client for the CoRelayer API. You import it as `corelayer`. It is async on tokio, needs Rust 1.87 or later, and sends requests with reqwest over rustls by default. Every API operation is a typed method, and helpers cover relaying with one signature, checking the relayer on chain, native auth, event streams, webhooks and x402. ### Install :::note[Not on crates.io yet] Once the crate is published, add it with `cargo add corelayer-sdk`, next to `tokio = { version = "1", features = ["macros", "rt-multi-thread"] }`. Until then, every call it makes is an ordinary HTTPS request you can send yourself: the API is described in [`/openapi.yaml`](pathname:///openapi.yaml), and [Pay for your users](/sdk/recipes/sponsor-users#without-an-sdk) shows a whole relay with no SDK. ::: ### Quick start ```rust use corelayer::{Client, ClientOptions, Error}; #[tokio::main] async fn main() -> Result<(), Error> { let client = Client::new(ClientOptions::new("https://api.co-relayer.com"))?; let network = client.get_network().await?.data; println!("chain {}, {} ms rounds", network.chain_id, network.round_duration_ms); Ok(()) } ``` Every method returns a `Response`: the decoded answer in `data`, plus `status`, `headers`, `request_id` and `rate_limit_remaining`. For devnet, use `https://devnet-api.co-relayer.com`. A `Client` is `Send + Sync` and cheap to clone, and clones share one connection pool. ### Authentication You pass a key, or an async function the client calls before each request, and the client adds the right header. Both are set on `ClientOptions`. | Who is calling | Option | Sent as | |---|---|---| | Your server paying for your users (a sponsor key), or an agent with an API key | `api_key` | `X-Api-Key` header, on the relay and the account reads only | | An app acting for a signed-in wallet | `native_auth_token` | `Authorization: Bearer ` | Public routes such as `get_network` need none of them, and the docs of each method say which credentials it accepts. Keep an API key on your server, and never ship it in a browser or mobile app. `native_auth_token` is called before every request, so a token you refresh is used on the next call. ```rust use std::sync::{Arc, RwLock}; use corelayer::{BoxError, Client, ClientOptions, Error}; pub fn session_client(current: Arc>>) -> Result { Client::new(ClientOptions::new("https://api.co-relayer.com").native_auth_token(move || { let token = current.read().map(|token| token.clone()).map_err(|_| BoxError::from("token lock poisoned")); async move { token } })) } ``` An agent can build its own native-auth token. The crate never signs anything: `encode_native_auth_body` gives you the body, `native_auth_sign_payload` the message your key or wallet signs, and `compose_native_auth_token` the token. `decode_native_auth_token` takes a token apart, and `check_native_auth_token` checks the rules that can be checked locally: origin, lifetime, formats, `extraInfo`, 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. [Authentication](/agents/auth) describes each method in full. ### Relaying a transaction `relay_once` runs the whole flow for one user action. It asks the API for a relayer, checks it with your `RelayerVerifier`, calls your builder and then your signer, checks that the wallet signed the nonce you built, and submits the transaction. The signer is an `FnOnce`, so the compiler lets it run at most once. For a reusable signer kept in a struct, `guard_sign_once` refuses a second call at run time. ```rust use std::future::Future; use std::time::Duration; use corelayer::{ BoxError, Client, Error, RelayOnceOptions, RelayOnceResult, RelayerVerifier, RelayerVerifierOptions, TransactionPlain, WaitOptions, relay_once, }; /// Checks relayers on a MultiversX gateway that CoRelayer does not run. Take the registry contract /// address from your own configuration, never from an API answer. pub fn mainnet_verifier(registry_contract: &str) -> Result { RelayerVerifier::new(RelayerVerifierOptions { gateway: "https://gateway.multiversx.com".into(), contract: Some(registry_contract.into()), chain_id: "1".into(), ..RelayerVerifierOptions::default() }) } /// Sends `value` (in the smallest EGLD unit) from `sender` to `receiver`. CoRelayer pays the gas. /// `nonce` is the sender's next account nonce; `sign` asks the user's wallet for a signature. pub async fn send_egld( client: &Client, verifier: &RelayerVerifier, sender: &str, receiver: &str, value: &str, nonce: i64, sign: S, ) -> Result<(), Error> where S: FnOnce(TransactionPlain) -> F, F: Future>, { let hook = verifier.hook(sender); let options = RelayOnceOptions { sender: sender.to_owned(), intent_key: Some(format!("send-{sender}-{nonce}")), // one key per user action verify_relayer: Some(&hook), ..RelayOnceOptions::default() }; let result = relay_once( client, options, |assignment| { Ok(TransactionPlain { nonce, value: value.to_owned(), sender: sender.to_owned(), receiver: receiver.to_owned(), gas_price: assignment.min_gas_price, gas_limit: 50_000 + assignment.extra_gas_relayed, chain_id: assignment.chain_id.clone(), version: 2, relayer: assignment.relayer.clone(), ..TransactionPlain::default() }) }, |input| sign(input.transaction), ) .await?; match result { RelayOnceResult::Relayed(relayed) => { let options = WaitOptions { // The API has the transaction now: a failed read does not mean it failed. on_error: Some(Box::new(|_error: &Error, failures: u32| { (failures < 5).then_some(Duration::from_secs(2)) })), ..WaitOptions::default() }; let intent = client.wait_for_intent(sender, relayed.signed.nonce, options).await?; println!("{} is {}", intent.intent_id, intent.state); } RelayOnceResult::ResignRequired(resign) => { println!("The relayer changed. Ask the user to sign nonce {} again.", resign.pinned_nonce); } } Ok(()) } ``` The verifier asks a MultiversX gateway for the relayer's state in the CoRelayer contract, refuses unless it is Active, and checks that the relayer is in the sender's shard. Use a gateway CoRelayer does not run, and pin the contract address and the chain ID in your own configuration. Answers are cached per chain ID, registry version and relayer, and one `RelayerVerifier` can be shared between tasks. See [Verify a relayer](/concepts/verify-a-relayer). #### Proving you control the sender Before it assigns a relayer, the API needs proof that you control the sender. A native-auth token for the sender on the client is enough. Without one, give `relay_once` a `sign_proof` function. It receives the message to sign and returns the sender key's raw Ed25519 signature over the message's UTF-8 bytes, as hex. Sign the bytes themselves, not through a wallet's `signMessage`: that adds the MultiversX message prefix, and the API answers `ASSIGN_PROOF_INVALID`. `relay_once` then signs a fresh proof for every assign call it makes: - For the first assign, it reads `chain_id` and `server_time_ms` from `get_network` and signs `assign_proof_message(chain_id, sender, server_time_ms)`. That costs one extra request. - For the renewal of an expired lease, it takes `chain_id` from the assignment. It estimates the server's time as the assignment's `server_time_ms` plus the milliseconds that have passed since the assignment arrived, measured on a monotonic clock. The proofs carry the kind `key` by default. In sponsor mode, set `proof_kind: Some(AssignRequestProofKind::Sponsor)`: the sender's key still signs the proof, and the sponsor key goes on the relay call only. `sign_proof` takes any `Fn(String)` closure that returns a `Send` future of `Result`. The signer can be sync or async, and its future may borrow anything that outlives the options, such as your key. A signer that has the signature at once returns `std::future::ready(...)`. When the closure owns the key, for example in an `Arc`, clone it into each future. To keep the key in a type of your own, implement `SignProof<'a>` for that type, where `'a` is the lifetime of the options. Its future may then borrow `self`. ```rust use corelayer::{ Assignment, BoxError, BoxFuture, Client, Error, RelayOnceOptions, RelayOnceResult, TransactionPlain, relay_once, }; /// A key your program holds, for example in a key service. Both methods return hex signatures and /// may wait for the service to answer. pub trait AgentKey: Send + Sync { fn address(&self) -> &str; /// Raw Ed25519 over the UTF-8 bytes of `message`, with no MultiversX message prefix. fn sign_proof_message(&self, message: String) -> BoxFuture<'_, Result>; fn sign_transaction(&self, transaction: TransactionPlain) -> BoxFuture<'_, Result>; } /// Relays one transaction for a program that holds the sender key and has no native-auth token. pub async fn relay_as_agent( client: &Client, key: &dyn AgentKey, build: impl FnOnce(&Assignment) -> TransactionPlain, intent_key: &str, ) -> Result { // The proof future borrows `key`, which outlives `options`. let sign_proof = |message: String| key.sign_proof_message(message); let options = RelayOnceOptions { sender: key.address().to_owned(), intent_key: Some(intent_key.to_owned()), sign_proof: Some(&sign_proof), ..RelayOnceOptions::default() }; relay_once(client, options, |assignment| Ok(build(assignment)), |input| key.sign_transaction(input.transaction)) .await } ``` The API accepts each proof once, and only within 30,000 ms of its own clock. A lease lasts 60,000 ms. You can also pass a proof you signed yourself as `proof`, but it covers the first assign only. `relay_once` never sends it a second time, so when the lease expires before the submit it returns the `LEASE_EXPIRED` error as the API sent it. Pass `sign_proof` to have the lease renewed for you. `relay_once` refuses `proof` and `sign_proof` together, before it sends anything, with `Error::RelayOnce` and the code `RelayOnceErrorCode::ProofWithSigner`. An error from `sign_proof` ends the flow as `Error::Callback`. #### Paying for your users (sponsor mode) From Builder up, a sponsor key on your server pays for any sender, within the contracts and daily limits the key allows. Create the client with the key through `ClientOptions::api_key`, and submit each transaction your user signed with `relay`. The client sends the key as `X-Api-Key` on the relay and on the account reads that accept it, never on the assign call or the network read, and your plan pays the network fee: ```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?; ``` `res.data.account` is your account, and `res.data.billing` carries the auth mode `api_key`. This covers senders whose key your server holds or reaches, such as embedded or custodial wallets, game servers, bots and agent fleets. When the same server holds the sender's key, pass the sponsor client to `relay_once`, with `proof_kind: Some(AssignRequestProofKind::Sponsor)`: every step runs as above, and the relay is billed to your plan. The presence proofs are marked kind `sponsor` and are still signed with the sender's key. [Pay for your users](/sdk/recipes/sponsor-users) covers creating the key and the answers a key can refuse with. #### When the lease expires When the lease has expired and the API says it can be renewed, `relay_once` renews it for the same relayer and submits the same signed bytes again. It never asks for a second signature by itself. The renewal carries a fresh proof from `sign_proof`, or no proof when the client has a native-auth token for the sender. #### When the API asks for a new signature `RelayOnceResult::ResignRequired` means the relayer became unavailable before it co-signed. Nothing was sent. Ask the user. If they agree, call `relay_once` again with `assignment: resign.next_assignment`, `min_gas_price: resign.min_gas_price`, and a transaction built for `resign.pinned_nonce`. `relay_once` refuses before asking the wallet if the nonce or the gas price would not fit. [Handle a re-sign request](/sdk/recipes/handle-resign) explains when this happens. `relay_once` never signs again by itself. With `sign_proof`, a renewal of that lease estimates the server's time from the moment you call `relay_once`, because that is when it received the lease. Call it soon after the user agrees: the API refuses a proof more than 30,000 ms away from its clock with `401 ASSIGN_PROOF_INVALID`. When the wallet signs a different nonce than the one you built, the result is `Error::SignedNonceMismatch` and nothing is sent. #### Waiting for the result `wait_for_intent` reads the intent once a second (`interval`) and stops at `EXECUTED_OK`, `EXECUTED_FAIL`, `DEAD` or `REJECTED`. After two minutes (`timeout`) it stops anyway and returns the last state it read, so check `state`. If no read has succeeded by then, it returns the last read's error. Before that time, a failed read ends the wait with its error unless you set `on_error`. It receives the error and the number of failures in a row, and returns how long to wait before the next read (at least `interval`), or `None` to return the error. The time limit also ends a run of failed reads, so an `on_error` that always returns a duration cannot keep the wait going forever. Dropping the future stops the wait at once, also while it waits between reads. `stream_relay` gives the same updates as events. See [Watch an intent](/sdk/recipes/watch-an-intent). ### Handling errors Every call returns `Result<_, corelayer::Error>`. | Error | When | What to do | |---|---|---| | `Error::Api` | The API answered with a status that is not 2xx, a 3xx included. | Branch on `code()`. For a code you do not know, go by `status()` and `retryable()`. | | `Error::Transport` | No host answered: a network failure, a timeout, a 2xx that was not JSON, or an answer larger than `max_response_bytes`, on every host tried. | The request may or may not have arrived. After a relay, read the intent with `get_intent`, or send the same signed bytes again. Never sign again because of it. | | `Error::Decode` | A 2xx whose JSON does not fit the expected type. | Do not retry: the API did answer. Update the crate if the API has changed. | | `Error::RelayOnce` | `relay_once` refused its options. | Fix the call. Nothing was signed. | | `Error::SignedNonceMismatch` | The wallet signed a different nonce than the one you built. Nothing was sent. | Read the intent for the nonce you built with `get_intent` before you try again. | | `Error::RelayerVerification` | The relayer is not Active, is in another shard, or could not be checked. Nothing was signed. | `reason` says which. If the gateway could not be reached, try again later. Otherwise do not sign for this relayer. | | `Error::InvalidInput` | An argument cannot be sent, such as an empty required query or header parameter. Path parameters are not checked. | Fix the call. Nothing was sent. | | `Error::Callback` | A function you passed in failed. | Its error is inside, unchanged. | ```rust use corelayer::{Client, Error, ends_slot, needs_payment}; pub async fn show_intent(client: &Client, sender: &str, nonce: i64) -> Result<(), Error> { match client.get_intent(sender, nonce).await { Ok(answer) => println!("{}", answer.data.state), Err(Error::Api(error)) if needs_payment(&error) => println!("The account needs a plan or credits."), Err(Error::Api(error)) if ends_slot(&error) => println!("This nonce is already used."), Err(Error::Api(error)) if error.retryable() => { println!("Try again in {:?} (request {:?}).", error.retry_after(), error.request_id()); } Err(error) => return Err(error), } Ok(()) } ``` The API can add error codes at any time, so always keep a fallback. A code this version does not know is `ErrorCode::Other`. `can_resubmit_same_bytes()` says when sending the same signed bytes again is safe, and `needs_new_signature()` when the user must sign again. An error answer without a problem document, for example a proxy's HTML page, becomes an `Error::Api` with code `UPSTREAM_UNAVAILABLE` and the answer's status, and its `retryable()` is true only for 408, 425, 429 and 5xx. Every code has a page in the [error catalogue](/errors), and [Errors and retries](/agents/errors-and-retries) covers when to retry. ### Other endpoints Every API operation is a method on `Client`, named after the operation in snake case: `get_account`, `list_usage`, `create_webhook` and so on. Path parameters and the request body are arguments, and query and header parameters go in a `…Params` struct. A paged list also has an `…_all` method that returns a `Paginator`, and an exportable list a `…_csv` method that streams the CSV. An export has no cursor and at most 1,000,000 rows. A cell that starts with `=`, `+`, `-` or `@` is prefixed with an apostrophe, so spreadsheet apps do not run it as a formula. Event streams return an `EventStream`. ```rust use corelayer::{Client, Error, ListUsageParams, StreamRelayParams}; pub async fn usage(client: &Client, account: &str) -> Result, Error> { let params = ListUsageParams { account: account.to_owned(), ..ListUsageParams::default() }; // Every row, one page at a time. let mut rows = client.list_usage_all(¶ms); while let Some(row) = rows.next().await? { println!("{} {} RU", row.tx_hash, row.ru); } // The same rows as CSV. The body is streamed, so read it chunk by chunk. let mut export = client.list_usage_csv(¶ms).await?; let mut csv = Vec::new(); while let Some(chunk) = export.next_chunk().await? { csv.extend_from_slice(&chunk); } Ok(csv) } pub async fn follow(client: &Client, intent_id: &str) -> Result<(), Error> { let mut stream = client.stream_relay(intent_id, &StreamRelayParams::default()).await?; while let Some(event) = stream.next().await? { println!("{}: {}", event.event_type, event.data); } // To resume after a disconnect, pass stream.last_event_id() as `last_event_id`. Ok(()) } ``` An event stream does not reconnect by itself. To resume, open a new one with `last_event_id` set to `stream.last_event_id()`, after waiting `stream.retry()` milliseconds when the server set it. The account stream replays up to 300,000 ms or 1,000 events. For an older ID, an ID from the other API host or one from before a restart, the server sends a `reset` event first: reload your data then. Decoding is strict: a missing required member is an `Error::Decode`, never a default value. String enums are open, so a value this version does not list decodes as `Other(String)`. A request member where "leave it as it is" and "clear it" differ is a `Nullable`. ### Webhooks :::note[Not delivered yet] The CoRelayer service does not send webhooks yet: registering an endpoint answers `503` with `reason: WEBHOOK_DELIVERY_NOT_AVAILABLE`. Until it does, follow your account with the notice feed or the account stream ([Do not poll the cap](/sdk/recipes/check-quota#do-not-poll-the-cap)). The verifier below is for the deliveries that feature will send. ::: ```rust use corelayer::http::HeaderMap; use corelayer::{VerifyWebhookOptions, WebhookError, WebhookEvent, verify_webhook}; /// Answer 2xx when this returns `Ok`, and 400 when it returns `Err`. pub fn read_delivery(headers: &HeaderMap, body: &[u8], secrets: &[String]) -> Result { let options = VerifyWebhookOptions { secrets: secrets.to_vec(), ..VerifyWebhookOptions::default() }; verify_webhook(headers, body, &options) } ``` `verify_webhook` checks the `CoRelayer-Webhook-Signature` header, the hex HMAC-SHA256 of `..`, and refuses a timestamp more than 300,000 ms from now. Pass the body bytes as received, before any parsing. After a secret rotation, pass both secrets for 24 hours. A failed delivery is retried with growing delays, 10 attempts in all over about 23 hours, and an endpoint that keeps failing for 604,800,000 ms (7 days) is disabled. A delivery can arrive more than once, so ignore an event `id` you have already handled. ### x402 payments For [x402](/x402) purchases, `x402_topup` and `x402_purchase` first answer 402. Its body is the payment challenge rather than a problem document, so the `ApiError` has the code `UPSTREAM_UNAVAILABLE`. Recognise it by `status()` 402 and a result from `payment_required_of`, which reads what to pay from the error. `encode_payment_signature` builds the `PAYMENT-SIGNATURE` header for the paid request (the `payment_signature` parameter), and `decode_payment_response` reads the settlement receipt. Check the requirement's `pay_to`, `asset` and `amount` against your own configuration before you sign. ### Configuration | Option | Default | What it does | |---|---|---| | `base_url` | required | The API origin: `https://api.co-relayer.com`, or `https://devnet-api.co-relayer.com` for devnet. | | `direct_hosts` | none | Regional hosts to fail over to, in order. Take them from `get_network`. | | `failover_after` | 1,500 ms | How long the main host may take before the next host is tried. Used only with `direct_hosts`. | | `timeout` | 15 s | The time limit of one attempt on a direct host, or on the main host when there are none. | | `max_response_bytes` | 32 MiB | The largest answer body read into memory. | | `api_key` | none | Sent as `X-Api-Key` on the operations that accept it: the relay and the account reads, never the assign call or `GET /v1/network`. A sponsor key pays for your users' transactions. | | `native_auth_token` | none | Async function returning the token, sent as `Authorization: Bearer`. | | `headers` | none | Headers added to every request. | | `user_agent` | `corelayer-sdk-rust/` | The `User-Agent` header. | | `http_client` | reqwest over rustls | Your own `HttpClient`. Build without default features to drop reqwest. | `ClientOptions::new(base_url)` sets these defaults, and builder methods change them. To turn on failover, read the host list once and create the client with it: ```rust use corelayer::{Client, ClientOptions, Error}; pub async fn client_with_failover() -> Result { let bootstrap = Client::new(ClientOptions::new("https://api.co-relayer.com"))?; let hosts = bootstrap.get_network().await?.data.direct_hosts.unwrap_or_default(); Client::new(ClientOptions::new("https://api.co-relayer.com").direct_hosts(hosts)) } ``` ### How requests behave - A request body is serialised once, and every attempt sends the same bytes. - When direct hosts are set and the main host does not answer within `failover_after` (1,500 ms by default), fails to connect, answers with a 5xx, or answers 2xx with a body that is not JSON, the same bytes go to the next host. A 4xx is an answer, so it is returned at once and never tried on another host. - Apart from failover, nothing is retried for you. - For a normal call the time limit covers the whole answer, body included. For event streams and CSV exports only the wait for the headers is timed. - Redirects are not followed. A 3xx comes back as `Error::Api`, so a signed body and your API key never go to a location you did not configure. - A buffered answer body is limited to `max_response_bytes`, 32 MiB by default. A larger one counts as a failed attempt, like a network failure, and is never truncated. Streams and CSV exports are read chunk by chunk, so that limit does not apply to them, and one server-sent event may be up to 8 MiB. - Every request carries `User-Agent: corelayer-sdk-rust/` unless you set your own. - Dropping a future cancels the call, and it is never continued on another host. Dropping the future of `wait_for_intent` or `relay_once` stops it between its steps too. - Times are Unix milliseconds and durations are milliseconds (the `…_ms` fields). - The crate talks only to the CoRelayer API. `RelayerVerifier` is the exception: it calls the gateway you give it, with a 5 s limit unless you set its `timeout`. --- ## 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)) --- ## The ABI and verification ### Download it | | | |---|---| | ABI | [`/abi/corelayer.abi.json`](pathname:///abi/corelayer.abi.json) | | Crate | `corelayer` 1.0.0 | | Framework | `multiversx-sc` 0.66.2 | | Contents | 86 endpoints (55 state-changing, 31 views), 54 events, 73 types | This file is the contract crate's own build artefact, copied into this site on every build. It is also what the reference pages in this section are generated from, which is why they cannot describe a contract other than the one that was built. ```bash curl -sSO https://docs.co-relayer.com/abi/corelayer.abi.json ``` ### Using it Any MultiversX SDK that consumes an ABI will build calls and decode results for you: ```ts // The shape is the same in every MultiversX SDK: load the ABI, point it at an address, call a view. const abi = await fetch('https://docs.co-relayer.com/abi/corelayer.abi.json').then((r) => r.json()); ``` The ABI carries the Rust doc comments, so a generated client's own documentation says what the code says. Three things to keep in mind when you talk to the contract directly: 1. **Pin the address in your own configuration.** Take it from your deployment, not from an API response and not from a discovery file. Key the pin by chain id. 2. **Views cost nothing.** Read-only calls through a node's query endpoint need no gas and no permission. Use them to verify anything we tell you. 3. **Arguments are encoded, and wrong encoding costs somebody gas.** The number of arguments and their top-encoding matter — for example, the pay-as-you-go flag call takes **four** arguments. A call that fails to decode still costs a fee, and on our free purchase flow that fee is paid by our relayer. Encode against the ABI, not against an example. ### Checking that the deployed code is the built code The procedure, in the order that makes each step meaningful: | Step | What it proves | |---|---| | **Reproducible build** | Building the published source with the published toolchain yields the same wasm, byte for byte. The build metadata in the ABI names the compiler version and the framework version it was built with. | | **Code hash on chain** | The account's code hash is the hash of the deployed bytes. Compare it with the hash of your own build. | | **Published source** | The source is submitted for verification on the explorer, so the explorer shows the code and the hash together. | | **Deploy-time gate** | The hash deployed to mainnet must equal the hash that passed the devnet exit criteria. This is enforced by the deploy procedure, not by a reviewer remembering to check. | **Where this stands:** the contract builds, and its test suite passes. The reproducible build and the explorer verification are open work. This page will not pretend otherwise; when each step exists it is published in the [changelog](/changelog), with the explorer link. ### The upgrade rule An upgrade **reverts** unless all four money scopes are paused first: everything, settlement, renewals and distribution. Without that rule, unverified code would begin running on live money paths from the first block after the upgrade — including the paths that keep running while the contract is otherwise paused. ([Overview](/contract/overview)) `upgraded` is an event, so an upgrade is not something that can happen quietly. ### Events, for mirroring If you mirror contract state, match on `topics[0]` — the identifier in [the events reference](/contract/reference/events). Every event carries a `meta` member with an `event_seq` and a millisecond `timestamp_ms`. The sequence is what lets a mirror prove it has missed nothing: **a gap in `event_seq` stops the mirror rather than being silently skipped.** A mirror that quietly skips an event is worse than one that stops, because the state it reports afterwards is wrong in a way nobody can see. The events worth watching, by what they tell you: | Event | Meaning | |---|---| | `tariffScheduled`, `tariffActivated`, `tariffPendingCancelled` | The price lever. The scheduled one is the 48-hour notice. | | `ruScheduleScheduled`, `ruScheduleActivated` | The Relay Unit formula, under the same notice. | | `tierSaved`, `tierActivated`, `tierStatusChanged` | The ladder. | | `deposit`, `paymentSwapped`, `subscribed`, `renewed` | Money in, and what it bought. | | `usageSettled`, `paygSettled`, `settleLineRejected` | Consumption being reported and accepted — or rejected. | | `relayerAdded`, `relayerStateChanged`, `relayerWeightsChanged` | The registry. | | `pauseChanged`, `settlementPaused`, `distributionPauseChanged` | Operational state. | | `poolDistributed`, `distributionDeferred` | The relayer pool. | | `upgraded` | The code changed. | ### The Relay Unit schedule hash The contract stores the version and a hash of the [Relay Unit schedule](/concepts/relay-units) — the document defining how a transaction becomes a number of units. Read them with [`getRuSchedule`](/contract/reference/views) and compare the hash against the document you read. That is what makes the formula verifiable rather than merely published: the owner has committed to a specific document on chain, and changing it carries the same 48 hours of notice as a price increase. --- ## The contract One MultiversX smart contract on shard 1 holds everything that has to be true whether or not CoRelayer's servers are running: the price, the tier ladder, who bought what, the relayer registry and the revenue split. The reference pages in this section are generated from the contract's build output. A contract starts **paused**, and each money path opens only when the owner unpauses it. ### At a glance | | Mainnet | Devnet | |---|---|---| | Chain id | `1` | `D` | | Contract shard | 1 | 1 | | Paid in | USDC (`USDC-c76f1f`) | USDC (`USDC-350c4e`) | Each network's API names its contract address in `GET /v1/network`, and co-relayer.com publishes it in [`/.well-known/corelayer.json`](https://co-relayer.com/.well-known/corelayer.json). Pin the address in your own configuration: those are copies for convenience, and your pin wins. A MultiversX round lasts about 600 ms, and the contract counts time in milliseconds throughout. ([Timestamps](/contract/timestamps)) ### What it is for The division of labour is deliberate: | On chain | Off chain | |---|---| | What an account **bought** — tier, cap, period, named-wallet limit, pay-as-you-go price | What an account **used** — counted by the backend | | The price, and every price that was ever in force | Rate limits and admission policy | | The relayer registry and its states | Which relayer serves which sender right now | | The revenue split and the relayer pool | Latency, incidents, notices | The contract knows entitlement; the backend counts consumption and reports it back through a settlement call. "Halted" is therefore a backend state, derived from the two. The contract **never sees a relayed transaction**. It only ever sees Relay Unit counts. ### The build it is generated from The reference pages in this section come from `corelayer.abi.json`, the contract crate's own build artefact, so they say exactly what the code says. Nothing is paraphrased; an endpoint without a doc comment gets its signature and no prose. | | | |---|---| | Crate | `corelayer` 1.0.0 | | Framework | `multiversx-sc` 0.66.2 | | Endpoints | 55 state-changing, 31 read-only views | | Events | 54 | | Types | 73 structs and enums | | ABI | [`/abi/corelayer.abi.json`](pathname:///abi/corelayer.abi.json) | [Endpoints](/contract/reference/endpoints) · [Views](/contract/reference/views) · [Events](/contract/reference/events) · [Types](/contract/reference/types) ### The four roles | Role | Held by | Temperature | Can | |---|---|---|---| | **owner** | The deploying wallet. No multisig. | Cold — an offline keystore, never on a server | Everything: tariff, tiers, Relay Unit schedule, rate classes, deposit bounds, swap venue, treasury, operator, every unpause, grants, registering relayers, upgrades | | **operator** | A shard-1 wallet | Warm, human-held, no server | Every **pause**; activate, drain, retire and reweight relayers; lower settlement caps; schedule a reporter change | | **reporter** | A shard-1 wallet | Hot, inside the backend's signer | `settleUsage`, and nothing else | | **treasury** | The deployer by default | Cold | Receives its share | The asymmetry is the point. A stolen **operator** key can take relayers out of service — a denial of service, and a visible one — but it cannot move the tariff, edit a tier, change the treasury or the swap venue, cannot unpause anything, and **cannot register a relayer of its own**: every address it can activate was added by the owner first. A stolen **reporter** key can only report usage, bounded by an escrow and a per-window cap. Changing the reporter has a delay when the operator does it, and is immediate only for the owner. The operator's instant incident tool is `pauseSettlement()`, not a reporter swap. A role address may not also be a relayer, and the contract enforces that: the sets are disjoint, and the backend's signer refuses to start with a key bundle that violates it. ### Pausing Five independent scopes. Any of them can be paused by the operator or the owner; **only the owner can unpause**. | Scope | Blocks | Never blocks | |---|---|---| | **All** | Deposits, subscribing, flags, adding and removing senders | Views, settlement, renewals, **releasing escrow**, the registry, distribution | | **Deposits** | The three deposit endpoints | Purchases from existing credits, flags, settlement, renewals | | **Settlement** | `settleUsage` | Renewals | | **Distribution** | Distributing the relayer pool | Deposits, which keep accruing | | **Renewals** | The charging part of an automatic renewal — it charges nothing, writes nothing and does not revert | Manual purchases, where the buyer states a ceiling | Two entries in that table are constraints on **us**, not on you: - **`releaseEscrow` is never blocked.** Seven days after an account turns pay-as-you-go off, anyone may call it to return the unused escrow to that account's credits — so a dead or hostile settlement key can never lock it. We do not have a switch that would stop it. - **An upgrade requires all four money scopes paused.** Otherwise unverified code would start running on live money paths from the first block after the upgrade. ### Deployment and upgrade | | | |---|---| | Deployed | Paused, with the venue not yet validated, so no deposit can be taken before a deliberate step. | | Configuration | Chain id, payment token, wrapped-EGLD token, the exchange pair and wrapper addresses, the initial tariff and the Relay Unit schedule hash — all arguments, never compiled constants. One binary serves both networks. | | Upgrade | Requires every money scope paused. Reverts otherwise. | | Verification | Planned: a reproducible build, source verified on the explorer, and a deploy gate that ships to mainnet only the hash that passed the devnet exit criteria. **Not done yet** — see [The ABI and verification](/contract/abi). | Because the configuration is arguments rather than constants, the **same** wasm runs on devnet and mainnet. And because the deploying wallet is the same on both, the contract address may even coincide — which is exactly why an address is never a network identifier and every client must compare chain ids. ([Discovery](/agents/discovery)) ### Reading it without asking us Every view is a free, permissionless call. The ones worth knowing: | View | Answers | |---|---| | [`getConfig`](/contract/reference/views) | Chain id, tokens, venue addresses, roles. | | [`getPauseState`](/contract/reference/views) | Which scopes are paused. | | [`getEffectiveTariff`](/contract/reference/views) | The price lever in force right now. | | [`getTariffHistory`](/contract/reference/views) | Every tariff that has ever been in force. | | [`getTiers`](/contract/reference/views) | The full tier ladder as stored. | | [`getAccount`](/contract/reference/views) | An account's plan, credits and flags. | | [`getActiveRelayers`](/contract/reference/views), [`getRelayerState`](/contract/reference/views) | Who is allowed to co-sign. | | [`getRegistryVersion`](/contract/reference/views) | A cache key for the two above. | | [`getTotalOutstandingCredits`](/contract/reference/views) | What is still owed to accounts. | If a view and our API ever disagree, **the chain is right and we have a bug**. Please [tell us](/security/disclosure). ### Next - Verifying the deployed bytes: [The ABI and verification](/contract/abi) - Why everything is in milliseconds: [Timestamps](/contract/timestamps) - The generated reference: [Endpoints](/contract/reference/endpoints) --- ## Endpoints {/* Generated from contract/corelayer/output/corelayer.abi.json by scripts/generate.ts — do not edit. */} The contract exposes 55 state-changing endpoints — 30 restricted to the owner, 25 callable by anyone with the right to do so. Read-only calls are on [Views](/contract/reference/views). :::info[Generated from the build output] Contract crate `corelayer` 1.0.0, built with `multiversx-sc` 0.66.2 on rustc 1.94.1 (e408947bf 2026-03-25). The ABI itself is served at [`/abi/corelayer.abi.json`](pathname:///abi/corelayer.abi.json). ::: ### Deploy Deployed PAUSED. No sync call happens here: the deep venue validation is `setSwapVenue`, which the launch script must run before the first deposit (`venue_validated` gates deposits). Arguments come from `deploy/networks.json` and from nowhere else (D-018). | Argument | Type | | |---|---|---| | `chain_id` | `bytes` | | | `usdc_token_id` | `TokenIdentifier` | | | `wegld_token_id` | `TokenIdentifier` | | | `pair_address` | `Address` | | | `wrapper_address` | `Address` | | | `initial_tariff` | `u64` | | | `ru_schedule_hash` | `array32<u8>` | | ### Upgrade No arguments, ever. Requires ALL FOUR money scopes paused (K-19): `paused` alone does not stop `renew*`, `settleUsage` or `try_distribute`, which would otherwise run on unverified code from the first block after the upgrade. The contract STAYS paused in all four scopes; the owner unpauses scope by scope after the post-upgrade verification. The owner check is written out rather than declared with `#[only_owner]`: the derive macro applies that attribute to `#[endpoint]` methods only and drops it silently on `#[upgrade]`. It is defence in depth (SPEC-DEVIATIONS DEV-03) — the protocol already refuses an `upgradeContract` transaction from anyone but the contract owner — but INV-K7 names `upgrade` as owner-only, and a guard that lives outside the code cannot be asserted by a test. _No arguments._ ### Open endpoints Anyone may call these; the contract checks the caller’s right inside. `payable` endpoints take the payment token in the call itself. #### `setReporter` Owner: immediate. Operator: effective after `REPORTER_CHANGE_DELAY_MS` (K-20); the operator's instant incident tool is `pauseSettlement()`. | Argument | Type | | |---|---|---| | `reporter` | `Address` | | #### `pause` _No arguments._ #### `pauseDeposits` _No arguments._ #### `pauseSettlement` _No arguments._ #### `pauseDistribution` _No arguments._ #### `pauseRenewals` _No arguments._ #### `subscribe` | Argument | Type | | |---|---|---| | `tier_id` | `u32` | | | `months` | `u8` | | | `max_price` | `u64` | | | `reference` | `optional<bytes>` | variadic | #### `renew` Permissionless, idempotent, never paused, never reverts for a business reason. An unknown account answers `NoAccount` instead of reverting. | Argument | Type | | |---|---|---| | `account` | `Address` | | | Returns | Type | | |---|---|---| | `—` | `RenewOutcome` | | #### `renewMany` The same, per address, independently. An EMPTY batch is a no-op rather than a revert: this endpoint is relayed at our own cost. `Totals` is read ONCE before the loop and written at most ONCE after it, the §4.1 cache pattern `settleUsage` already follows. Reading and writing the one global key per account cost a 50-account batch up to 50 loads and 50 stores (~6.6 M gas at the `02 §6` schedule) that the §13.2 budget does not carry, and `renewMany` is an INTERNAL free-relay call whose declared `gasLimit` C-13 caps at 1.5 × the measured figure. | Argument | Type | | |---|---|---| | `accounts` | `variadic<Address>` | variadic | | Returns | Type | | |---|---|---| | `—` | `variadic<RenewOutcome>` | variadic | #### `setAutoRenew` Sets the flag and the two guards; it does NOT renew (S §4). Zero never means "unlimited": an autonomous buyer must state its ceiling. | Argument | Type | | |---|---|---| | `enabled` | `bool` | | | `renew_tier_id` | `u32` | | | `max_renew_price` | `u64` | | #### `setPayg` Four arguments, exactly (K-04): a client that encodes three gets an argument-decode revert that OUR relayer pays for on the free flow. `enabled = false` never releases escrow — it only starts the close. Disabling twice keeps the FIRST `payg_disabled_at_ms`, so the 1 h and 7 d clocks cannot be restarted by spamming the free call. **Split gate** (SPEC-DEVIATIONS DEV-17): turning PAYG ON is subject to `paused` (`Gate::All`), turning it OFF is not (`Gate::Open`). `setPayg` is the ONLY way to raise `ACC_FLAG_PAYG_CLOSING` and start the 1 h / 7 d clocks, and `releaseEscrow` is deliberately never paused so that "a dead or hostile reporter can never lock user funds". With one `Gate::All` for both directions the operator's warm `pause()` key froze every `payg_escrow` in the system indefinitely while `settleUsage` (`Gate::Settlement`) kept debiting it. Closing takes no fee, needs no venue and only ever moves the account towards release, so it cannot be used to bypass a pause. | Argument | Type | | |---|---|---| | `enabled` | `bool` | | | `budget` | `u64` | | | `auto_topup` | `bool` | | | `max_payg_price` | `u64` | | #### `releaseEscrow` The fallback release (S §5.3 case 2): permissionless, never paused, and it can only move the account's OWN escrow into its OWN credits, once per PAYG close. The normal path is the reporter's closing line. An address with no record answers `ERR_RELEASE_NOT_DUE`, not `ERR_NO_ACCOUNT`: §12.3 gives this endpoint exactly two preconditions and §12.1's "the record must exist" rule is for endpoints whose CALLER is the account. A permissionless sweeper walking a list of addresses must read "nothing to release" for an unknown one, not a billing failure. The lazy block roll runs first, exactly as `setPayg` does, so the `period_id` of the emitted `escrowMoved` is the period `now` really falls in and not the last id of a block that already ended. | Argument | Type | | |---|---|---| | `account` | `Address` | | #### `settleUsage` | Argument | Type | | |---|---|---| | `batch_id` | `u64` | | | `window_end_ms` | `u64` | | | `usage_root` | `array32<u8>` | | | `totals` | `BatchTotals` | | | `lines` | `bytes` | | #### `setSettleCaps` Operator or owner. K-20: the operator may only LOWER the window cap — a stolen warm key cannot turn the 50 USDC/h reporter bound into 1,000. | Argument | Type | | |---|---|---| | `max_settle_per_window` | `u64` | | | `sender_fee_ru` | `u32` | | #### `addSenders` | Argument | Type | | |---|---|---| | `max_fee` | `u64` | | | `senders` | `variadic<Address>` | variadic | #### `removeSenders` Fee-less, and it never refunds: the slot was paid for when it was taken. **Never blocked by `paused`** (`Gate::Open`, SPEC-DEVIATIONS DEV-17). This is "the payer's only defence against a compromised sender key", and `isAuthorizedSender` — which the relay backend reads to decide whether to keep serving that key — is a view and is never paused either. Leaving it under `Gate::All` meant the operator's warm `pause()` key kept a stolen sender key authorised and burning the customer's quota until the cold owner key could unpause. It takes no fee and only ever REMOVES state, so it cannot be used to bypass a pause. | Argument | Type | | |---|---|---| | `senders` | `variadic<Address>` | variadic | #### `activateRelayers` `Registered -> Active` and `Draining -> Active` (false alarm or planned maintenance). Pushes start with the very next distribution. | Argument | Type | | |---|---|---| | `entries` | `variadic<multi<Address,u32>>` | variadic | #### `drainRelayers` `Active -> Draining`: weight 0 and out of `activeSet` in the SAME transaction, so the very next payment no longer pays it. Deliberately unguarded — no share bound, no last-in-shard check — because an incident must never be blocked by a bound. | Argument | Type | | |---|---|---| | `addresses` | `variadic<Address>` | variadic | #### `retireRelayers` `Draining -> Retired` after `MIN_DRAIN_MS`, or `Registered -> Retired` immediately (a spare whose cold key is lost or suspect). Terminal, and the tombstone is permanent. | Argument | Type | | |---|---|---| | `addresses` | `variadic<Address>` | variadic | #### `setRelayerWeights` Active rows only. One `relayerWeightsChanged` event per CALL, never one per relayer, and one `activeSet` store per call. | Argument | Type | | |---|---|---| | `entries` | `variadic<multi<Address,u32>>` | variadic | #### `distributeRelayerPool` Permissionless ("no keeper" purity): anyone may flush once the gates pass. A call by the owner or the operator is FORCED and bypasses the gates — used after a weight change, after draining a compromised relayer, or to empty the pool before an upgrade. Never reverts when the gates are closed; it returns `false`. _No arguments._ | Returns | Type | | |---|---|---| | `—` | `bool` | | #### `setDistributionParams` Operator or owner. The bounds exist so that a stolen operator key can delay pushes by at most 7 days / 100 EGLD and never stop them: the owner, or any caller once the gates pass, can still flush. | Argument | Type | | |---|---|---| | `min_interval_ms` | `u64` | | | `min_amount` | `BigUint` | | #### `deposit` **payable** (any token) Credits the caller's own account. Callable by anyone, smart contracts included (C-11). _No arguments._ #### `depositFor` **payable** (any token) Credits somebody else's account. The payer gets NO rights on the beneficiary's account: it is a payment, not a delegation. The beneficiary guard is evaluated INSIDE `run_deposit`, as §8.2 step 7, so the step-1 re-entrancy check and the step-2 pause / venue checks are answered first: a re-entrant or paused call must say `ERR_REENTRANCY` / `ERR_PAUSED`, never an argument error that looks like client noise. | Argument | Type | | |---|---|---| | `beneficiary` | `Address` | | #### `depositAndSubscribe` **payable** (any token) Deposit, then EXACTLY the `subscribe` logic of §12.2 on the caller's own account — never `try_renew` (K-17). A failing price, credit, tier or queue check reverts the whole call BEFORE the swap, so a doomed purchase burns little gas of our relayer and the payer keeps the USDC. | Argument | Type | | |---|---|---| | `tier_id` | `u32` | | | `months` | `u8` | | | `max_price` | `u64` | | | `reference` | `optional<bytes>` | variadic | ### Owner-only endpoints These revert for every caller but the owner (the deployer wallet — there is no multisig). #### `setOperator` **owner only** Owner, immediate. Clears ANY pending reporter, due or not: a rotated-out operator leaves nothing behind, and a reporter scheduled by a stolen operator key is dropped even if the owner reacts after the delay. | Argument | Type | | |---|---|---| | `operator` | `Address` | | #### `setTreasury` **owner only** Owner. EVERY change, including the first, waits `ROLE_CHANGE_NOTICE_MS`; one pending slot, last call wins and restarts the notice. | Argument | Type | | |---|---|---| | `treasury` | `Address` | | #### `cancelPendingTreasury` **owner only** Owner. A DUE pending treasury is already in force, so it is materialised first and can no longer be cancelled; cancelling it would be an instant treasury change that bypasses the notice. _No arguments._ #### `setRelayerReserve` **owner only** Owner. Published pointer only — the contract never transfers to it; the signer takes its only non-relayer destination from here. | Argument | Type | | |---|---|---| | `reserve` | `Address` | | #### `unpause` **owner only** _No arguments._ #### `unpauseDeposits` **owner only** _No arguments._ #### `unpauseSettlement` **owner only** _No arguments._ #### `unpauseDistribution` **owner only** _No arguments._ #### `unpauseRenewals` **owner only** _No arguments._ #### `setTariff` **owner only** | Argument | Type | | |---|---|---| | `new_tariff` | `u64` | | #### `cancelPendingTariff` **owner only** A DUE pending value is materialised first and can no longer be cancelled. _No arguments._ #### `setSwapVenue` **owner only** Deep validation by read-only sync calls, reduced to what a plain swap needs: the pair trades exactly {usdc, wegld} and the wrapper unwraps exactly `wegld`. The pair need NOT be Active (it may be set during a pause). Requires `paused || depositsPaused`, so a venue switch is always followed by a canary. Token decimals cannot be read on-chain; the deploy script asserts `decimals == 6`. | Argument | Type | | |---|---|---| | `pair` | `Address` | | | `wrapper` | `Address` | | | `usdc` | `TokenIdentifier` | | | `wegld` | `TokenIdentifier` | | #### `setMinDeposit` **owner only** | Argument | Type | | |---|---|---| | `value` | `u64` | | #### `setMaxDeposit` **owner only** 0 = unlimited (D-105). Resets the swap window cells, so a new limit starts with a fresh window. | Argument | Type | | |---|---|---| | `value` | `u64` | | #### `setRateClass` **owner only** Affects only plan blocks written afterwards: blocks snapshot the numbers. | Argument | Type | | |---|---|---| | `class` | `u8` | | | `max_ru_per_s` | `u32` | | | `burst_ru` | `u32` | | #### `setRuSchedule` **owner only** ALWAYS effective after `RU_SCHEDULE_NOTICE_MS`; one pending slot, last call wins and restarts the notice. | Argument | Type | | |---|---|---| | `version` | `u32` | | | `hash` | `array32<u8>` | | #### `cancelPendingRuSchedule` **owner only** A DUE pending schedule is materialised first and can no longer be cancelled. _No arguments._ #### `setTier` **owner only** Creates or overwrites a DRAFT. `version`, `status` and `created_ms` supplied by the caller are overwritten. | Argument | Type | | |---|---|---| | `tier` | `TierV1` | | #### `activateTier` **owner only** `Draft -> Active`. Invariants are re-checked; the rate class must exist because every plan block snapshots its numbers (K-09). | Argument | Type | | |---|---|---| | `tier_id` | `u32` | | #### `setTierStatus` **owner only** | Argument | Type | | |---|---|---| | `tier_id` | `u32` | | | `status` | `TierStatus` | | | `successor_tier_id` | `u32` | | #### `addCustomBuyer` **owner only** | Argument | Type | | |---|---|---| | `tier_id` | `u32` | | | `address` | `Address` | | #### `removeCustomBuyer` **owner only** | Argument | Type | | |---|---|---| | `tier_id` | `u32` | | | `address` | `Address` | | #### `setSettleCeiling` **owner only** Owner only: the hard ceiling the operator can never reach past. | Argument | Type | | |---|---|---| | `ceiling` | `u64` | | #### `grantCredits` **owner only** | Argument | Type | | |---|---|---| | `account` | `Address` | | | `amount` | `u64` | | | `reason` | `GrantReason` | | #### `grantRu` **owner only** Creates the account when it is missing, so a trial can be granted to an address that has never deposited. Grants are independent of plan blocks and of credits: they are RU, consumed by the backend before cap and PAYG, and they vanish at expiry. | Argument | Type | | |---|---|---| | `account` | `Address` | | | `ru` | `u64` | | | `duration_ms` | `u64` | | | `reason` | `GrantReason` | | #### `addRelayers` **owner only** Registers cold rows with weight 0. `operator_id` and `sla_class` are the forward hooks of `08 §6.2`; v1 accepts only `OPERATOR_ID_CORELAYER`. The shard is COMPUTED here, never supplied, and smart-contract addresses are refused — a wallet can always receive EGLD, which removes the only path on which a push could bounce. | Argument | Type | | |---|---|---| | `operator_id` | `u32` | | | `sla_class` | `u8` | | | `addresses` | `variadic<Address>` | variadic | #### `removeRelayers` **owner only** Frees registry capacity 30 days after retirement. The tombstone and the events remain, so the address stays permanently unregisterable. | Argument | Type | | |---|---|---| | `addresses` | `variadic<Address>` | variadic | #### `setRelayerMeta` **owner only** Forward hook only (`08 §6.2`): neither field is read by v1 logic beyond the `operator_id == 1` rule. | Argument | Type | | |---|---|---| | `address` | `Address` | | | `operator_id` | `u32` | | | `sla_class` | `u8` | | #### `setMaxRelayerShareBps` **owner only** Owner only. Documented footgun (distribution.md §6.2): a value below `10_000 / n_active` makes every activate and re-weight call revert with `ERR_SHARE_BOUND` until it is raised again. Draining always keeps working, so the fleet can still be stopped during an incident. The change is announced with `maxRelayerShareBpsChanged` and the value is also readable through `getDistributionState` (SPEC-DEVIATIONS DEV-21): the owner's off-chain transaction builder recomputes the bound off-chain before the owner signs an activate or a re-weight, and a value that only lives in a raw storage key is invisible to an event-driven mirror. | Argument | Type | | |---|---|---| | `bps` | `u32` | | #### `emergencyWithdrawPool` **owner only** Owner only, only while the WHOLE contract is paused AND distribution is paused, destination is the treasury and nothing else. In launch mode the pool holds only atto-dust, so this surface is ≈ 0; it becomes relevant only if batching is ever switched on. Disclosed in the docs. **Why `distributionPaused` is required too** (SPEC-DEVIATIONS DEV-16): `distributeRelayerPool` is permissionless and runs under `Gate::Open`, so `paused` alone does not stop it. With the launch parameters (`min_interval_ms = 0`, `min_amount = 0`) a watcher of the mempool could front-run the rescue with a full permissionless push and make it revert on `pool > 0`. Requiring `distributionPaused` means the rescue is only ever reachable from a state in which `try_distribute` is already a no-op, so the two can never race. _No arguments._ --- ## Events {/* Generated from contract/corelayer/output/corelayer.abi.json by scripts/generate.ts — do not edit. */} 54 events. Each one is matched on `topics[0]` — the identifier below — and every event carries a `meta` member with an `event_seq` and a millisecond `timestamp_ms`, so a mirror can prove it has missed nothing: a gap in `event_seq` stops the mirror instead of silently skipping. :::info[Generated from the build output] Contract crate `corelayer` 1.0.0, built with `multiversx-sc` 0.66.2 on rustc 1.94.1 (e408947bf 2026-03-25). The ABI itself is served at [`/abi/corelayer.abi.json`](pathname:///abi/corelayer.abi.json). ::: #### `initialized` | Field | Type | | |---|---|---| | `data` | `InitializedData` | | #### `upgraded` | Field | Type | | |---|---|---| | `data` | `UpgradedData` | | #### `pauseChanged` `scope` is `PauseScope` as `u8`: only 0 All, 1 Deposits, 4 Renewals appear here. | Field | Type | | |---|---|---| | `scope` | `u8` | indexed topic | | `data` | `PauseChangedData` | | #### `settlementPaused` | Field | Type | | |---|---|---| | `data` | `ByData` | | #### `settlementUnpaused` | Field | Type | | |---|---|---| | `data` | `ByData` | | #### `distributionPauseChanged` | Field | Type | | |---|---|---| | `data` | `PauseChangedData` | | #### `operatorChanged` | Field | Type | | |---|---|---| | `data` | `RoleChangedData` | | #### `reporterChanged` | Field | Type | | |---|---|---| | `data` | `RoleChangedData` | | #### `reporterScheduled` | Field | Type | | |---|---|---| | `data` | `ReporterScheduledData` | | #### `treasuryScheduled` | Field | Type | | |---|---|---| | `address` | `Address` | indexed topic | | `data` | `EffectiveMsData` | | #### `treasuryChanged` | Field | Type | | |---|---|---| | `address` | `Address` | indexed topic | | `data` | `OldAddressData` | | #### `treasuryPendingCancelled` | Field | Type | | |---|---|---| | `address` | `Address` | indexed topic | | `data` | `MetaOnlyData` | | #### `relayerReserveScheduled` | Field | Type | | |---|---|---| | `address` | `Address` | indexed topic | | `data` | `EffectiveMsData` | | #### `relayerReserveChanged` | Field | Type | | |---|---|---| | `address` | `Address` | indexed topic | | `data` | `MetaOnlyData` | | #### `swapVenueChanged` | Field | Type | | |---|---|---| | `data` | `SwapVenueChangedData` | | #### `minDepositChanged` | Field | Type | | |---|---|---| | `data` | `U64ChangedData` | | #### `maxDepositChanged` | Field | Type | | |---|---|---| | `data` | `U64ChangedData` | | #### `rateClassSet` | Field | Type | | |---|---|---| | `rate_class` | `u8` | indexed topic | | `data` | `RateClassSetData` | | #### `customBuyerChanged` | Field | Type | | |---|---|---| | `tier_id` | `u32` | indexed topic | | `address` | `Address` | indexed topic | | `data` | `CustomBuyerChangedData` | | #### `tariffScheduled` | Field | Type | | |---|---|---| | `data` | `TariffScheduledData` | | #### `tariffActivated` | Field | Type | | |---|---|---| | `version` | `u32` | indexed topic | | `data` | `TariffActivatedData` | | #### `tariffPendingCancelled` | Field | Type | | |---|---|---| | `data` | `TariffPendingCancelledData` | | #### `ruScheduleScheduled` | Field | Type | | |---|---|---| | `version` | `u32` | indexed topic | | `data` | `RuScheduleScheduledData` | | #### `ruScheduleActivated` | Field | Type | | |---|---|---| | `version` | `u32` | indexed topic | | `data` | `MetaOnlyData` | | #### `ruSchedulePendingCancelled` | Field | Type | | |---|---|---| | `version` | `u32` | indexed topic | | `data` | `MetaOnlyData` | | #### `tierSaved` | Field | Type | | |---|---|---| | `tier_id` | `u32` | indexed topic | | `data` | `TierSavedData` | | #### `tierActivated` | Field | Type | | |---|---|---| | `tier_id` | `u32` | indexed topic | | `data` | `MetaOnlyData` | | #### `tierStatusChanged` | Field | Type | | |---|---|---| | `tier_id` | `u32` | indexed topic | | `data` | `TierStatusChangedData` | | #### `deposit` | Field | Type | | |---|---|---| | `account` | `Address` | indexed topic | | `payer` | `Address` | indexed topic | | `data` | `DepositData` | | #### `paymentSwapped` | Field | Type | | |---|---|---| | `payer` | `Address` | indexed topic | | `data` | `PaymentSwappedData` | | #### `subscribed` `period_id` is the `first_period_id` of the block. | Field | Type | | |---|---|---| | `account` | `Address` | indexed topic | | `tier_id` | `u32` | indexed topic | | `period_id` | `u32` | indexed topic | | `data` | `SubscribedData` | | #### `renewed` | Field | Type | | |---|---|---| | `account` | `Address` | indexed topic | | `tier_id` | `u32` | indexed topic | | `period_id` | `u32` | indexed topic | | `data` | `RenewedData` | | #### `autoRenewSkipped` `reason` is `SkipReason` as `u8`. | Field | Type | | |---|---|---| | `account` | `Address` | indexed topic | | `reason` | `u8` | indexed topic | | `data` | `AutoRenewSkippedData` | | #### `autoRenewSet` | Field | Type | | |---|---|---| | `account` | `Address` | indexed topic | | `data` | `AutoRenewSetData` | | #### `paygSet` | Field | Type | | |---|---|---| | `account` | `Address` | indexed topic | | `data` | `PaygSetData` | | #### `escrowMoved` `cause` is `EscrowCause` as `u8`. | Field | Type | | |---|---|---| | `account` | `Address` | indexed topic | | `cause` | `u8` | indexed topic | | `data` | `EscrowMovedData` | | #### `senderAdded` | Field | Type | | |---|---|---| | `account` | `Address` | indexed topic | | `sender` | `Address` | indexed topic | | `data` | `SenderChangedData` | | #### `senderRemoved` | Field | Type | | |---|---|---| | `account` | `Address` | indexed topic | | `sender` | `Address` | indexed topic | | `data` | `SenderChangedData` | | #### `creditsGranted` | Field | Type | | |---|---|---| | `account` | `Address` | indexed topic | | `data` | `CreditsGrantedData` | | #### `ruGranted` | Field | Type | | |---|---|---| | `account` | `Address` | indexed topic | | `data` | `RuGrantedData` | | #### `usageSettled` | Field | Type | | |---|---|---| | `batch_id` | `u64` | indexed topic | | `data` | `UsageSettledData` | | #### `paygSettled` | Field | Type | | |---|---|---| | `account` | `Address` | indexed topic | | `period_id` | `u32` | indexed topic | | `data` | `PaygSettledData` | | #### `settleLineRejected` `reason` is `RejectReason` as `u8`. | Field | Type | | |---|---|---| | `account_id` | `u32` | indexed topic | | `reason` | `u8` | indexed topic | | `data` | `SettleLineRejectedData` | | #### `settleConfigChanged` | Field | Type | | |---|---|---| | `data` | `SettleConfigChangedData` | | #### `relayerAdded` | Field | Type | | |---|---|---| | `address` | `Address` | indexed topic | | `shard` | `u8` | indexed topic | | `data` | `RelayerAddedData` | | #### `relayerStateChanged` | Field | Type | | |---|---|---| | `address` | `Address` | indexed topic | | `shard` | `u8` | indexed topic | | `data` | `RelayerStateChangedData` | | #### `relayerWeightsChanged` | Field | Type | | |---|---|---| | `registry_version` | `u64` | indexed topic | | `data` | `RelayerWeightsChangedData` | | #### `relayerMetaChanged` | Field | Type | | |---|---|---| | `address` | `Address` | indexed topic | | `data` | `RelayerMetaChangedData` | | #### `relayerRemoved` | Field | Type | | |---|---|---| | `address` | `Address` | indexed topic | | `data` | `RelayerRemovedData` | | #### `poolDistributed` | Field | Type | | |---|---|---| | `dist_seq` | `u64` | indexed topic | | `data` | `PoolDistributedData` | | #### `distributionDeferred` | Field | Type | | |---|---|---| | `data` | `DistributionDeferredData` | | #### `distributionParamsChanged` | Field | Type | | |---|---|---| | `data` | `DistributionParamsChangedData` | | #### `maxRelayerShareBpsChanged` Not in contract.md §9 / distribution.md §2.4 (SPEC-DEVIATIONS DEV-21): without it `setMaxRelayerShareBps` left no log at all, so the event-driven backend mirror and the `tx/prepare` pre-validation of distribution.md §342 had no source for the value they must recompute the share bound against. Name is 25 characters, the §9 maximum. | Field | Type | | |---|---|---| | `data` | `MaxRelayerShareBpsChangedData` | | #### `poolEmergencyWithdrawn` | Field | Type | | |---|---|---| | `data` | `PoolEmergencyWithdrawnData` | | --- ## Types {/* Generated from contract/corelayer/output/corelayer.abi.json by scripts/generate.ts — do not edit. */} 73 structs and enums appear in endpoint arguments, view results and event payloads. They are encoded with the MultiversX top-encoding rules; every SDK that reads the ABI decodes them for you. :::info[Generated from the build output] Contract crate `corelayer` 1.0.0, built with `multiversx-sc` 0.66.2 on rustc 1.94.1 (e408947bf 2026-03-25). The ABI itself is served at [`/abi/corelayer.abi.json`](pathname:///abi/corelayer.abi.json). ::: #### `Account` _struct_ 131 B funded with no block, 297 B with current + previous, 406 B with all three blocks. Every endpoint loads the record once, mutates it in memory and stores it once. | Field | Type | | |---|---|---| | `version` | `u8` | | | `flags` | `u16` | bit0 AUTO_RENEW, bit1 PAYG_ENABLED, bit2 PAYG_AUTO_TOPUP, bit3 PAYG_CLOSING. | | `credits` | `u64` | micro-USDC. | | `payg_escrow` | `u64` | micro-USDC. | | `payg_budget` | `u64` | micro-USDC; `u64::MAX` = "all credits". | | `max_payg_price` | `u64` | micro-USDC per RU, 0 = no ceiling. | | `max_renew_price` | `u64` | > 0 whenever AUTO_RENEW. | | `renew_tier_id` | `u32` | 0 = same tier. | | `payg_disabled_at_ms` | `u64` | 0 unless PAYG_CLOSING. | | `last_seq` | `u32` | | | `last_line_ms` | `u64` | R5 anchor (K-18): moves forward only on an accepted settlement line. | | `last_topup_period_id` | `u32` | K-16. | | `sender_count` | `u32` | | | `grant_ru` | `u64` | | | `grant_expiry_ms` | `u64` | | | `created_ms` | `u64` | | | `current` | `Option<PlanBlockV1>` | | | `queued` | `Option<PlanBlockV1>` | | | `previous` | `Option<PrevBlockV1>` | | | `used_ru_reported` | `u64` | | | `policy_id` | `u32` | | | `latency_class` | `u8` | | | `agent_id` | `u64` | | #### `AccountView` _struct_ `getAccount`, `getAccountById`: the derived fields after a virtual `advance`. | Field | Type | | |---|---|---| | `account_id` | `u32` | | | `state` | `u8` | | | `at_ms` | `u64` | | | `period_id` | `u32` | | | `period_start_ms` | `u64` | | | `period_end_ms` | `u64` | | | `tier_id` | `u32` | | | `cap_ru` | `u64` | | | `tariff_at_purchase` | `u64` | | | `payg_bps` | `u32` | | | `payg_price` | `u64` | | | `period_flags` | `u8` | | | `record` | `Account` | | #### `AutoRenewSetData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `enabled` | `bool` | | | `renew_tier_id` | `u32` | | | `max_renew_price` | `u64` | | #### `AutoRenewSkippedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `period_id` | `u32` | | | `tier_id` | `u32` | | | `price` | `u64` | | | `max_renew_price` | `u64` | | | `credits` | `u64` | | #### `BatchTotals` _struct_ 28 bytes, informational: committed into `usage_chain_head`, never interpreted. | Field | Type | | |---|---|---| | `rows` | `u32` | | | `ru_cap` | `u64` | | | `ru_payg` | `u64` | | | `ru_other` | `u64` | | #### `BlockKind` _enum_ | Variant | Discriminant | | |---|---|---| | `New` | 0 | | | `Queued` | 1 | | | `Upgrade` | 2 | | | `AutoRenew` | 3 | | #### `ByData` _struct_ `settlementPaused`, `settlementUnpaused`. | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `by` | `Address` | | #### `ConfigView` _struct_ `getConfig`. `reporter` and `treasury` are the EFFECTIVE addresses; the pending pair is reported raw so a watcher sees a scheduled change. | Field | Type | | |---|---|---| | `chain_id` | `bytes` | | | `code_version` | `bytes` | | | `storage_version` | `u32` | | | `owner` | `Address` | | | `operator` | `Address` | | | `reporter` | `Address` | | | `pending_reporter` | `Address` | | | `pending_reporter_effective_ms` | `u64` | | | `treasury` | `Address` | | | `pending_treasury` | `Address` | | | `pending_treasury_effective_ms` | `u64` | | | `usdc_token_id` | `TokenIdentifier` | | | `wegld_token_id` | `TokenIdentifier` | | | `pair_address` | `Address` | | | `wrapper_address` | `Address` | | | `venue_validated` | `bool` | | #### `CreditsGrantedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `amount` | `u64` | | | `reason_code` | `u8` | | | `credits_after` | `u64` | | | `escrow_after` | `u64` | | #### `CustomBuyerChangedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `added` | `bool` | | #### `DepositData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `amount` | `u64` | | | `credits_after` | `u64` | | | `escrow_after` | `u64` | | | `account_id` | `u32` | | | `is_new` | `bool` | | #### `DistLedger` _struct_ Storage key `distLedger`. Read once and written once per deposit; every amount comes from internal accounting, never from the contract balance. | Field | Type | | |---|---|---| | `version` | `u8` | | | `pool` | `BigUint` | Relayer share not yet pushed (atto-EGLD). | | `total_egld_in` | `BigUint` | | | `total_treasury_paid` | `BigUint` | | | `total_distributed` | `BigUint` | | | `total_emergency_withdrawn` | `BigUint` | | | `last_distribution_ms` | `u64` | | | `dist_seq` | `u64` | | #### `DistParams` _struct_ Storage key `distParams`. A non-forced distribution runs only when BOTH gates pass (distribution.md §3.3). It carries the leading `version: u8` of contract.md §3 and the hand-written version-byte decoder that goes with it: §3's rule ("every top-level stored record starts with `version: u8`") and §7.3's "Scalar defaults" row both name `DistParams` as a versioned struct, and `distParams` is read on every deposit inside `try_distribute`, so a later release that adds a third parameter needs the discriminator to take the lazy per-record migration path (see SPEC-DEVIATIONS DEV-20). | Field | Type | | |---|---|---| | `version` | `u8` | | | `min_interval_ms` | `u64` | | | `min_amount` | `BigUint` | | #### `DistributionDeferredData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `pool` | `BigUint` | | | `reason` | `u8` | | #### `DistributionParamsChangedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `min_interval_ms` | `u64` | | | `min_amount` | `BigUint` | | #### `DistributionStateView` _struct_ `getDistributionState`. | Field | Type | | |---|---|---| | `ledger` | `DistLedger` | | | `params` | `DistParams` | | | `paused` | `bool` | | | `max_relayer_share_bps` | `u32` | SPEC-DEVIATIONS DEV-21: the share bound the owner's off-chain transaction builder must recompute against, which distribution.md §2.4 left without any read path. | #### `EffectiveMsData` _struct_ `treasuryScheduled`, `relayerReserveScheduled`: the scheduled address is the topic. | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `effective_ms` | `u64` | | #### `EscrowMovedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `amount` | `u64` | | | `to_escrow` | `bool` | | | `period_id` | `u32` | | | `credits_after` | `u64` | | | `escrow_after` | `u64` | | #### `EventMeta` _struct_ Common header. `event_seq` is contract-wide, +1 per event, gap-free, starts at 1. | Field | Type | | |---|---|---| | `event_seq` | `u64` | | | `timestamp_ms` | `u64` | | #### `GrantReason` _enum_ | Variant | Discriminant | | |---|---|---| | `Trial` | 1 | | | `SlaRemedy` | 2 | | | `Goodwill` | 3 | | | `ReporterIncident` | 4 | | #### `InitializedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `chain_id` | `bytes` | | | `code_version` | `bytes` | | | `storage_version` | `u32` | | #### `MaxRelayerShareBpsChangedData` _struct_ `maxRelayerShareBpsChanged` (SPEC-DEVIATIONS DEV-21). `old_bps` is what the mirror had, `new_bps` what every later `activateRelayers` / `setRelayerWeights` is measured against. | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `old_bps` | `u32` | | | `new_bps` | `u32` | | | `by` | `Address` | | #### `MetaOnlyData` _struct_ Events whose whole payload is in the topics: `treasuryPendingCancelled`, `ruScheduleActivated`, `ruSchedulePendingCancelled`, `tierActivated`, `relayerReserveChanged`. | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | #### `OldAddressData` _struct_ `treasuryChanged`: the new address is the topic. | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `old` | `Address` | | #### `PauseChangedData` _struct_ `pauseChanged`, `distributionPauseChanged`. | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `paused` | `bool` | | | `by` | `Address` | | #### `PauseStateView` _struct_ `getPauseState`. | Field | Type | | |---|---|---| | `paused` | `bool` | | | `deposits_paused` | `bool` | | | `settlement_paused` | `bool` | | | `distribution_paused` | `bool` | | | `renewals_paused` | `bool` | | #### `PaygSetData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `enabled` | `bool` | | | `budget` | `u64` | | | `auto_topup` | `bool` | | | `max_payg_price` | `u64` | | | `disabled_at_ms` | `u64` | | | `credits_after` | `u64` | | | `escrow_after` | `u64` | | #### `PaygSettledData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `batch_id` | `u64` | | | `seq` | `u32` | | | `ru` | `u32` | | | `tariff_idx` | `u16` | | | `price_per_ru` | `u64` | | | `debit` | `u64` | | | `closing` | `bool` | | | `credits_after` | `u64` | | | `escrow_after` | `u64` | | #### `PaymentSwappedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `account` | `Address` | | | `usdc_in` | `u64` | | | `wegld_out` | `BigUint` | | | `egld_out` | `BigUint` | | | `treasury_part` | `BigUint` | | | `relayer_part` | `BigUint` | | | `treasury` | `Address` | | #### `PlanBlockV1` _struct_ 109 bytes nested, no managed types. | Field | Type | | |---|---|---| | `block_id` | `u64` | Global counter, never a timestamp. | | `tier_id` | `u32` | | | `kind` | `BlockKind` | | | `start_ms` | `u64` | | | `end_ms` | `u64` | `start_ms + months * period_ms` (checked); `u64::MAX` for a no-period block (K-11). | | `period_ms` | `u64` | Snapshot; 0 = no period. | | `months` | `u8` | 0 for a no-period block. | | `first_period_id` | `u32` | Period `k` of this block has `period_id = first_period_id + k` (K-01). | | `cap_ru` | `u64` | | | `bonus_ru` | `u64` | RESERVED, always 0 in v1 (K-08). | | `tariff_at_purchase` | `u64` | | | `tariff_version` | `u32` | | | `payg_bps` | `u32` | | | `payg_price` | `u64` | `tariff_at_purchase * payg_bps / 10_000`, exact; 0 when FLOATING. | | `max_senders` | `u32` | | | `max_ru_per_s` | `u32` | | | `burst_ru` | `u32` | | | `rate_class` | `u8` | | | `sla_class` | `u8` | | | `flags` | `u32` | | | `period_flags` | `u8` | bit0 FLOATING. | | `price_paid` | `u64` | | #### `PoolDistributedData` _struct_ One aggregated event per round: `amount_i = unit * weight_i` with the weights of `registry_version` reproduces every transfer exactly. | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `total_sent` | `BigUint` | | | `unit` | `BigUint` | | | `total_weight` | `u64` | | | `n_transfers` | `u32` | | | `remainder` | `BigUint` | | | `registry_version` | `u64` | | | `trigger` | `u8` | `DistTrigger`: 0 deposit, 1 permissionless, 2 forced. | #### `PoolEmergencyWithdrawnData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `amount` | `BigUint` | | | `to` | `Address` | | #### `PrevBlockV1` _struct_ 57 bytes: what late settlement lines and the renew path need from the block that ended. | Field | Type | | |---|---|---| | `block_id` | `u64` | | | `tier_id` | `u32` | | | `first_period_id` | `u32` | | | `last_period_id` | `u32` | | | `end_ms` | `u64` | | | `tariff_at_purchase` | `u64` | | | `payg_bps` | `u32` | | | `payg_price` | `u64` | | | `period_flags` | `u8` | | | `max_ru_per_s` | `u32` | | | `burst_ru` | `u32` | | #### `PricingConfigView` _struct_ `getPricingConfig`: everything `/v1/pricing` needs in one query. | Field | Type | | |---|---|---| | `tariff` | `u64` | Raw storage, as `getTariff`. | | `pending_tariff` | `u64` | | | `pending_tariff_effective_ms` | `u64` | | | `tariff_version` | `u32` | | | `effective_tariff` | `u64` | Lazy rule applied, as `getEffectiveTariff`. | | `effective_tariff_version` | `u32` | | | `ru_schedule` | `RuScheduleView` | | | `min_deposit` | `u64` | | | `max_deposit` | `u64` | 0 = unlimited. | | `tariff_min` | `u64` | | | `tariff_max` | `u64` | | | `tariff_granularity` | `u64` | | | `tariff_increase_notice_ms` | `u64` | | #### `RateClassEntry` _struct_ View output of `getRateClasses`. | Field | Type | | |---|---|---| | `class` | `u8` | | | `max_ru_per_s` | `u32` | | | `burst_ru` | `u32` | | #### `RateClassSetData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `max_ru_per_s` | `u32` | | | `burst_ru` | `u32` | | #### `RateClassV1` _struct_ Storage key `rate_class(class)`: the on-chain upper bound the settlement reject rule R5 uses, snapshotted into every plan block (K-09). | Field | Type | | |---|---|---| | `version` | `u8` | | | `max_ru_per_s` | `u32` | | | `burst_ru` | `u32` | | #### `RelayerAddedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `info` | `RelayerInfo` | | | `registry_version` | `u64` | | #### `RelayerInfo` _struct_ Storage key `relayerInfo(addr)`. Cold path: owner calls and views. | Field | Type | | |---|---|---| | `version` | `u8` | | | `operator_id` | `u32` | Forward hook; v1 requires `OPERATOR_ID_CORELAYER`. | | `sla_class` | `u8` | Forward hook; 0 shared pool, 1 Standard, 2 Priority, 3 Dedicated. | | `shard` | `u8` | Computed on-chain with `get_shard_of_address`; always below 3. | | `state` | `RelayerState` | | | `weight` | `u32` | Non-zero exactly when `state == Active` (invariant I5). | | `stake` | `BigUint` | Forward hook; always 0 in v1. | | `registered_at_ms` | `u64` | | | `activatable_at_ms` | `u64` | RESERVED, always equal to `registered_at_ms` in v1 (K-20). | | `state_changed_at_ms` | `u64` | | #### `RelayerMetaChangedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `operator_id` | `u32` | | | `sla_class` | `u8` | | | `registry_version` | `u64` | | #### `RelayerRemovedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `registry_version` | `u64` | | #### `RelayerRow` _struct_ View output of `getRelayers`. | Field | Type | | |---|---|---| | `address` | `Address` | | | `info` | `RelayerInfo` | | #### `RelayerState` _enum_ `None = 0` is the answer of `getRelayerState` for an address that is not ours. | Variant | Discriminant | | |---|---|---| | `None` | 0 | | | `Registered` | 1 | | | `Active` | 2 | | | `Draining` | 3 | | | `Retired` | 4 | | #### `RelayerStateChangedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `old_state` | `RelayerState` | | | `new_state` | `RelayerState` | | | `weight` | `u32` | | | `registry_version` | `u64` | | #### `RelayerWeightsChangedData` _struct_ One event per call, not per relayer. | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `entries` | `List<WeightEntry>` | | | `total_weight` | `u64` | | #### `RenewedData` _struct_ `renewed`: every `subscribed` field (`kind = AutoRenew`) plus three. | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `block_id` | `u64` | | | `kind` | `BlockKind` | | | `months` | `u8` | | | `start_ms` | `u64` | | | `end_ms` | `u64` | | | `period_ms` | `u64` | | | `cap_ru` | `u64` | | | `bonus_ru` | `u64` | | | `tariff_at_purchase` | `u64` | | | `tariff_version` | `u32` | | | `payg_bps` | `u32` | | | `payg_price` | `u64` | | | `floating` | `bool` | | | `max_senders` | `u32` | | | `rate_class` | `u8` | | | `sla_class` | `u8` | | | `flags` | `u32` | | | `price_paid` | `u64` | | | `credited_back` | `u64` | | | `reference` | `bytes` | | | `credits_after` | `u64` | | | `escrow_after` | `u64` | | | `gap_ms` | `u64` | | | `grid_aligned` | `bool` | | | `caller` | `Address` | | #### `RenewOutcome` _enum_ | Variant | Discriminant | | |---|---|---| | `Ok` | 0 | | | `NotDue` | 1 | | | `NotEnabled` | 2 | | | `Price` | 3 | | | `Credits` | 4 | | | `TierInactive` | 5 | | | `QueuedStarted` | 6 | | | `NoAccount` | 7 | | | `Paused` | 8 | `renewals_paused`: nothing charged, no block written, no revert (K-19). | #### `RenewQuote` _struct_ `quoteRenew`. | Field | Type | | |---|---|---| | `outcome` | `RenewOutcome` | | | `due` | `bool` | | | `tier_id` | `u32` | | | `price` | `u64` | | | `new_start_ms` | `u64` | | | `new_end_ms` | `u64` | | #### `ReporterScheduledData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `current` | `Address` | | | `pending` | `Address` | | | `effective_ms` | `u64` | | | `by` | `Address` | | #### `RoleChangedData` _struct_ `operatorChanged`, `reporterChanged`. | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `old` | `Address` | | | `new` | `Address` | | | `by` | `Address` | | #### `RuGrantedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `ru` | `u64` | | | `grant_ru_after` | `u64` | | | `expiry_ms` | `u64` | | | `reason` | `GrantReason` | | #### `RuScheduleScheduledData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `hash` | `array32<u8>` | | | `effective_ms` | `u64` | | #### `RuScheduleView` _struct_ `getRuSchedule`, with the lazy rule applied at the evaluation time. | Field | Type | | |---|---|---| | `ru_size_atto` | `u64` | | | `version` | `u32` | | | `hash` | `array32<u8>` | | | `pending_version` | `u32` | | | `pending_hash` | `array32<u8>` | | | `pending_effective_ms` | `u64` | | #### `SenderChangedData` _struct_ `senderAdded`, `senderRemoved`. | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `count_after` | `u32` | | | `fee` | `u64` | | | `credits_after` | `u64` | | | `escrow_after` | `u64` | | #### `SettleConfigChangedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `max_settle_per_window` | `u64` | | | `ceiling` | `u64` | | | `sender_fee_ru` | `u32` | | | `pending_sender_fee_ru` | `u32` | | | `pending_sender_fee_effective_ms` | `u64` | | | `by` | `Address` | | #### `SettleLineRejectedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `batch_id` | `u64` | | | `seq` | `u32` | | | `period_id` | `u32` | | | `ru` | `u32` | | #### `SettlementStateView` _struct_ `getSettlementState`. `reporter` is the effective address. | Field | Type | | |---|---|---| | `reporter` | `Address` | | | `settlement_paused` | `bool` | | | `settle_state` | `SettleState` | | | `totals` | `Totals` | | #### `SettleState` _struct_ Storage key `settle_state`: ONE struct, one read and one write per endpoint. | Field | Type | | |---|---|---| | `version` | `u8` | | | `last_batch_id` | `u64` | | | `last_window_end_ms` | `u64` | | | `usage_chain_head` | `array32<u8>` | | | `settle_window_start_ms` | `u64` | | | `settle_window_debited` | `u64` | | | `max_settle_per_window` | `u64` | | | `max_settle_per_window_ceiling` | `u64` | | | `sender_fee_ru` | `u32` | | | `pending_sender_fee_ru` | `u32` | Lazy 48 h activation of a fee INCREASE; an effective time of 0 means none pending. | | `pending_sender_fee_effective_ms` | `u64` | | #### `SubscribedData` _struct_ `subscribed`. The spec names the opaque purchase reference `ref`; that is a Rust keyword, so the ABI publishes it as `reference` (SPEC-DEVIATIONS.md DEV-23 — clients, and the `account_blocks.ref` column of §16.4, map `ref` to this name). | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `block_id` | `u64` | | | `kind` | `BlockKind` | | | `months` | `u8` | | | `start_ms` | `u64` | | | `end_ms` | `u64` | | | `period_ms` | `u64` | | | `cap_ru` | `u64` | | | `bonus_ru` | `u64` | Always 0 in v1 (K-08). | | `tariff_at_purchase` | `u64` | | | `tariff_version` | `u32` | | | `payg_bps` | `u32` | | | `payg_price` | `u64` | | | `floating` | `bool` | | | `max_senders` | `u32` | | | `rate_class` | `u8` | | | `sla_class` | `u8` | | | `flags` | `u32` | | | `price_paid` | `u64` | | | `credited_back` | `u64` | | | `reference` | `bytes` | | | `credits_after` | `u64` | | | `escrow_after` | `u64` | | #### `SwapVenueChangedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `pair` | `Address` | | | `wrapper` | `Address` | | | `usdc_token_id` | `TokenIdentifier` | | | `wegld_token_id` | `TokenIdentifier` | | #### `TariffActivatedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `tariff` | `u64` | | | `effective_ms` | `u64` | The SCHEDULED time, which is what `tariff_hist` stores. | | `materialised_ms` | `u64` | The block time of the mutation that materialised it. | #### `TariffEntry` _struct_ One element of `tariff_hist` (1-based, append-only). `effective_ms` is the SCHEDULED activation time, not the time of materialisation (K-10). | Field | Type | | |---|---|---| | `tariff` | `u64` | | | `effective_ms` | `u64` | | #### `TariffPendingCancelledData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `cancelled_value` | `u64` | | | `would_have_been_effective_ms` | `u64` | | #### `TariffScheduledData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `old` | `u64` | | | `new` | `u64` | | | `effective_ms` | `u64` | | #### `TierSavedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `tier` | `TierV1` | | #### `TierStatus` _enum_ | Variant | Discriminant | | |---|---|---| | `Draft` | 0 | | | `Active` | 1 | | | `Legacy` | 2 | | | `Retired` | 3 | | #### `TierStatusChangedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `old` | `TierStatus` | | | `new` | `TierStatus` | | | `successor_tier_id` | `u32` | | #### `TierV1` _struct_ Storage key `tiers(tier_id)`. Editable only while `Draft`; after `activateTier` only `status` and `successor_tier_id` change (pricing.md §4.2). | Field | Type | | |---|---|---| | `version` | `u8` | | | `tier_id` | `u32` | | | `slug` | `bytes` | | | `status` | `TierStatus` | | | `cap_ru` | `u64` | | | `price_units` | `u64` | | | `payg_bps` | `u32` | | | `period_ms` | `u64` | | | `max_senders` | `u32` | | | `rate_class` | `u8` | | | `sla_class` | `u8` | | | `flags` | `u32` | | | `successor_tier_id` | `u32` | | | `created_ms` | `u64` | | #### `Totals` _struct_ Storage key `totals`: the conservation counters, all micro-USDC. INV-C1: `total_deposited + total_granted == total_credits + total_escrow + total_spent_plans + total_settled_payg + total_fees`. | Field | Type | | |---|---|---| | `version` | `u8` | | | `total_deposited` | `u64` | | | `total_granted` | `u64` | | | `total_credits` | `u64` | | | `total_escrow` | `u64` | | | `total_spent_plans` | `u64` | | | `total_settled_payg` | `u64` | | | `total_fees` | `u64` | | #### `U64ChangedData` _struct_ `minDepositChanged`, `maxDepositChanged`. | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `old` | `u64` | | | `new` | `u64` | | #### `UpgradedData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `old_storage_version` | `u32` | | | `new_storage_version` | `u32` | | | `code_version` | `bytes` | | #### `UsageSettledData` _struct_ | Field | Type | | |---|---|---| | `meta` | `EventMeta` | | | `operator_id` | `u32` | Always `OPERATOR_ID_CORELAYER` in v1. | | `window_end_ms` | `u64` | | | `usage_root` | `array32<u8>` | | | `usage_chain_head` | `array32<u8>` | | | `rows` | `u32` | | | `ru_cap` | `u64` | | | `ru_payg` | `u64` | | | `ru_other` | `u64` | | | `lines_accepted` | `u32` | | | `lines_rejected` | `u32` | | | `debit_total` | `u64` | | | `window_debited_after` | `u64` | | #### `WeightEntry` _struct_ One element of `relayerWeightsChanged.entries`. | Field | Type | | |---|---|---| | `address` | `Address` | | | `weight` | `u32` | | --- ## Views {/* Generated from contract/corelayer/output/corelayer.abi.json by scripts/generate.ts — do not edit. */} 31 read-only calls. They cost no gas when queried through a node’s `vm-values` endpoint and are the authority for anything the API reports about entitlement, pricing and the relayer registry. :::info[Generated from the build output] Contract crate `corelayer` 1.0.0, built with `multiversx-sc` 0.66.2 on rustc 1.94.1 (e408947bf 2026-03-25). The ABI itself is served at [`/abi/corelayer.abi.json`](pathname:///abi/corelayer.abi.json). ::: #### `getConfig` **view** _No arguments._ | Returns | Type | | |---|---|---| | `—` | `ConfigView` | | #### `getPauseState` **view** _No arguments._ | Returns | Type | | |---|---|---| | `—` | `PauseStateView` | | #### `getRelayerReserve` **view** Effective reserve address (lazy activation); zero while never set. | Argument | Type | | |---|---|---| | `at_ms` | `optional<u64>` | variadic | | Returns | Type | | |---|---|---| | `—` | `Address` | | #### `getSwapBudget` **view** `(max_deposit, used_in_window, window_end_ms)`; `(0, 0, 0)` when unlimited. The bucket SLIDES (SPEC-DEVIATIONS DEV-18), so `used_in_window` is the level after draining it to `at_ms` — exactly what a deposit at `at_ms` would measure its amount against — and `window_end_ms` is when the level reaches 0. A fully drained bucket reads as the fresh window a deposit at `at_ms` would open, which is what the previous tumbling window reported. | Argument | Type | | |---|---|---| | `at_ms` | `optional<u64>` | variadic | | Returns | Type | | |---|---|---| | `—` | `u64` | | | `—` | `u64` | | | `—` | `u64` | | #### `getTariff` **view** Raw storage: `(current, pending, pending_effective_ms, version)`. _No arguments._ | Returns | Type | | |---|---|---| | `—` | `u64` | | | `—` | `u64` | | | `—` | `u64` | | | `—` | `u32` | | #### `getEffectiveTariff` **view** `(tariff, version)` with the lazy rule applied. | Argument | Type | | |---|---|---| | `at_ms` | `optional<u64>` | variadic | | Returns | Type | | |---|---|---| | `—` | `u64` | | | `—` | `u32` | | #### `getTariffByVersion` **view** | Argument | Type | | |---|---|---| | `version` | `u32` | | | Returns | Type | | |---|---|---| | `—` | `TariffEntry` | | #### `getTariffHistory` **view** 1-based, at most `TARIFF_HISTORY_PAGE_MAX` entries per call; a range past the end is clamped. | Argument | Type | | |---|---|---| | `from_idx` | `u32` | | | `count` | `u32` | | | Returns | Type | | |---|---|---| | `—` | `variadic<TariffEntry>` | variadic | #### `getRuSchedule` **view** | Argument | Type | | |---|---|---| | `at_ms` | `optional<u64>` | variadic | | Returns | Type | | |---|---|---| | `—` | `RuScheduleView` | | #### `getPricingConfig` **view** One call for `/v1/pricing`. | Argument | Type | | |---|---|---| | `at_ms` | `optional<u64>` | variadic | | Returns | Type | | |---|---|---| | `—` | `PricingConfigView` | | #### `getTier` **view** | Argument | Type | | |---|---|---| | `tier_id` | `u32` | | | Returns | Type | | |---|---|---| | `—` | `TierV1` | | #### `getTiers` **view** Bounded by `MAX_TIERS`. _No arguments._ | Returns | Type | | |---|---|---| | `—` | `variadic<TierV1>` | variadic | #### `getRateClass` **view** | Argument | Type | | |---|---|---| | `class` | `u8` | | | Returns | Type | | |---|---|---| | `—` | `RateClassV1` | | #### `getRateClasses` **view** Probes the whole `u8` class space (1..=255): bounded by the key type, no index key needed. _No arguments._ | Returns | Type | | |---|---|---| | `—` | `List<RateClassEntry>` | | #### `isCustomBuyer` **view** | Argument | Type | | |---|---|---| | `tier_id` | `u32` | | | `address` | `Address` | | | Returns | Type | | |---|---|---| | `—` | `bool` | | #### `getPrice` **view** micro-USDC for `months` of `tier_id` at the effective tariff. Same `months` rule as `subscribe`: 1..=12 for a period tier, 0 for a no-period tier. | Argument | Type | | |---|---|---| | `tier_id` | `u32` | | | `months` | `u8` | | | `at_ms` | `optional<u64>` | variadic | | Returns | Type | | |---|---|---| | `—` | `u64` | | #### `getPaygPrice` **view** micro-USDC per RU at the effective tariff. | Argument | Type | | |---|---|---| | `tier_id` | `u32` | | | `at_ms` | `optional<u64>` | variadic | | Returns | Type | | |---|---|---| | `—` | `u64` | | #### `getAccount` **view** The record after a VIRTUAL `advance` at `t = max(at_ms, created_ms)`, plus the settlement field names of S §3.1 that are derived rather than stored. Nothing is written: a view always shows what the next transaction would see. | Argument | Type | | |---|---|---| | `address` | `Address` | | | `at_ms` | `optional<u64>` | variadic | | Returns | Type | | |---|---|---| | `—` | `optional<AccountView>` | variadic | #### `getAccountById` **view** The reconciliation direction: id -> address plus the same view. | Argument | Type | | |---|---|---| | `account_id` | `u32` | | | `at_ms` | `optional<u64>` | variadic | | Returns | Type | | |---|---|---| | `—` | `optional<multi<Address,AccountView>>` | variadic | #### `isAuthorizedSender` **view** `added_at_ms`, or 0 when the sender is not authorised by this account. | Argument | Type | | |---|---|---| | `account` | `Address` | | | `sender` | `Address` | | | Returns | Type | | |---|---|---| | `—` | `u64` | | #### `quoteRenew` **view** The settler's prediction (S §8): exactly the decision half `renew` runs, so a quote and the transaction cannot drift apart. | Argument | Type | | |---|---|---| | `account` | `Address` | | | `at_ms` | `optional<u64>` | variadic | | Returns | Type | | |---|---|---| | `—` | `RenewQuote` | | #### `quotePaygPrice` **view** The settler's pre-flight: micro-USDC per RU the contract would apply to this line, 0 when the line would be rejected. Like `settleUsage` itself it does NOT advance the plan (INV-C3). | Argument | Type | | |---|---|---| | `account` | `Address` | | | `period_id` | `u32` | | | `tariff_idx` | `u16` | | | `at_ms` | `optional<u64>` | variadic | | Returns | Type | | |---|---|---| | `—` | `u64` | | #### `getSettlementState` **view** `reporter` is the EFFECTIVE address (a due pending reporter is already in force even though it has not been materialised yet), so the settler sees the same address `require_reporter` would accept. _No arguments._ | Returns | Type | | |---|---|---| | `—` | `SettlementStateView` | | #### `getTotalOutstandingCredits` **view** `(free_credits, payg_escrow)` — the tariff-down exposure check of pricing.md §3.6, read before a tariff change. _No arguments._ | Returns | Type | | |---|---|---| | `—` | `u64` | | | `—` | `u64` | | #### `getRelayerState` **view** A bare `u8` with no optional argument on purpose: this is the call agents make through third-party gateways, so it must stay trivially encodable. `0` = not ours, `2` = Active = safe to sign for. | Argument | Type | | |---|---|---| | `address` | `Address` | | | Returns | Type | | |---|---|---| | `—` | `u8` | | #### `getRelayer` **view** | Argument | Type | | |---|---|---| | `address` | `Address` | | | Returns | Type | | |---|---|---| | `—` | `optional<RelayerInfo>` | variadic | #### `getActiveRelayers` **view** `(address, shard, weight)` per active relayer, from ONE storage read. _No arguments._ | Returns | Type | | |---|---|---| | `—` | `variadic<multi<Address,u8,u32>>` | variadic | #### `getRelayers` **view** Bounded by `MAX_RELAYERS`. The two trailing optionals are positional and `255` means "any", so a caller can filter by state without naming a shard. | Argument | Type | | |---|---|---| | `shard` | `optional<u8>` | variadic | | `state` | `optional<u8>` | variadic | | Returns | Type | | |---|---|---| | `—` | `variadic<RelayerRow>` | variadic | #### `getRegistryVersion` **view** _No arguments._ | Returns | Type | | |---|---|---| | `—` | `u64` | | #### `getShardWeights` **view** `(w0, w1, w2, total)` — "where does the 70 % go", derived from the same single blob the push uses. _No arguments._ | Returns | Type | | |---|---|---| | `—` | `u64` | | | `—` | `u64` | | | `—` | `u64` | | | `—` | `u64` | | #### `getDistributionState` **view** _No arguments._ | Returns | Type | | |---|---|---| | `—` | `DistributionStateView` | | --- ## Timestamps Two rules cover every time-dependent thing in the contract, and both have consequences a client has to know about. ### Everything is milliseconds of block time | | | |---|---| | Unit | Milliseconds. Every stored time, every argument, every event field. | | Source | The block timestamp, in milliseconds, of the block executing the call. | | Never | A server's wall clock. Wall clocks drift and can be wrong; a block timestamp is a fact the whole network agreed on. | In the API the same rule holds, with names that keep the two apart: `chainTimeMs` for chain time, `serverTimeMs` for ours. Member names end in `Ms`, and the only places seconds appear are where an external standard requires them — each with a millisecond twin in the body. ([Conventions](/api/conventions)) Some useful constants, as milliseconds: | | | |---|---| | A standard plan period | `2_592_000_000` (30 days) | | Notice before a tariff increase | `172_800_000` (48 hours) | | Notice before a Relay Unit schedule change | `172_800_000` (48 hours) | | A lease | `60_000` | | A quote | `120_000` | ### Nothing is scheduled; everything activates lazily **There is no keeper.** No cron job, no bot, no transaction that has to fire at midnight for the system to be correct. Every time rule is a pure function of the block timestamp, evaluated by whoever touches the state next: - a scheduled tariff becomes effective; - a Relay Unit schedule version becomes effective; - a period rolls over; - a queued plan block starts; - a grant expires; - a delayed role change takes effect. ```rust // The shape of every one of them. fn effective_tariff(&self, now: u64) -> u64 { let pending = self.pending_tariff().get(); if pending != 0 && now >= self.pending_tariff_effective_ms().get() { pending } else { self.tariff().get() } } ``` Note that it **compares** rather than subtracting. Timestamp arithmetic that could wrap is avoided on purpose, as defence in depth. #### What that means for you **A value can be effective without an event having been emitted.** An activation emits nothing at the moment it becomes effective. The event arrives with the next state-changing call, which may be hours later. So: | Do | Do not | |---|---| | Derive activation from the **scheduled** event plus its `effective_ms` | Wait for an "activated" event before believing a value has changed | | Call a view: the views apply the lazy rule without writing | Assume the last event you saw reflects the current effective value | | Compare against **chain** time | Compare against your own wall clock | The views are the honest reading. `getEffectiveTariff()` applies the rule and tells you what is in force right now; `getTariff()` gives you the current value, the pending one and when it becomes effective. The reported version accounts for a due-but-not-yet-materialised change, so readers off chain and the chain itself always agree. ### Asking "what will it be then?" Several views take an optional trailing `at_ms` and evaluate the lazy rule at that moment instead of now: ``` getEffectiveTariff(at_ms?) getPrice(tier, at_ms?) getPaygPrice(tier, at_ms?) getRuSchedule(at_ms?) getPricingConfig(at_ms?) getSwapBudget(at_ms?) ``` This is how a quote that straddles an activation is priced correctly, and how the backend computes the "from date X the price will be" line in the pricing document. The horizon is bounded — `at_ms` more than 24 hours ahead is rejected. For the full 48-hour notice window, multiply the tier's price units by the pending tariff yourself; both numbers are readable. ### After a chain stall Block timestamps jump when a chain resumes after a stall. The comparison rule is unaffected: a pending value whose effective time has passed simply becomes effective at the first call that looks. Nothing has to catch up, and nothing is skipped. This is the practical argument for the lazy design. A keeper-based schedule has to be running at the right moment; a lazily evaluated one only has to be *looked at* eventually, and the answer is the same whenever that happens. --- ## Usage proofs CoRelayer counts consumption off chain — the contract never sees a relayed transaction. So the obvious question is how anyone outside can check what was counted. The answer is a commitment: every settlement batch writes a **Merkle root over every ledger row it covers** into the contract, and a single row can be proven against it. ### What is committed, and where The reporter settles usage in batches with one contract call: ```text settleUsage(batch_id, window_end_ms, usage_root, totals, lines) ``` | Argument | What it is | |---|---| | `batch_id` | Strictly the previous batch id plus one. Exactly one batch per id can ever execute. | | `window_end_ms` | The watermark: every relayed transaction executed at a block timestamp at or before it is in the ledger. | | `usage_root` | The Merkle root over **all** ledger rows of the window — cap, pay-as-you-go, free and internal alike. | | `totals` | Row and Relay Unit counts, informational. The contract trusts only the debits it computes itself. | | `lines` | 19-byte lines that debit pay-as-you-go escrow: an account id, a period, a sequence number and a unit count. **No price and no amount** — the contract prices each line from its own tariff history. | Each accepted batch emits [`usageSettled`](/contract/reference/events) carrying the root, the window, the counts, and the **usage chain head**: ```text usage_chain_head = sha256(usage_chain_head ‖ batch_id ‖ window_end_ms ‖ usage_root ‖ totals) ``` a rolling on-chain commitment to the whole audit history. A backend that wanted to rewrite an old batch after the fact would have to produce a different chain head, and the chain already holds the real one. ### How a row becomes a leaf The tree is built RFC 6962-style, with domain separation between leaves (`0x00`) and interior nodes (`0x01`), over the rows sorted by `(executed_block_ts_ms, tx_hash)`. Rows discovered late for an already committed window — orphan recovery, an anticipation row whose billing was decided later — follow, flagged `late`. ```text leaf = sha256( 0x00 ‖ tx_hash 32 bytes ‖ account 32 bytes — the public key the row was billed to ‖ sender 32 bytes ‖ relayer 32 bytes ‖ executed_block_ts_ms u64, big-endian ‖ ru u32 ‖ ru_cap u32 ‖ ru_payg u32 ‖ period_id u32 ‖ tariff_idx u16 ‖ billing_class u8 ‖ ru_schedule_version u16 ‖ late u8 ) node = sha256( 0x01 ‖ left ‖ right ) ``` Everything in a leaf is either a chain fact — the hash, the addresses, the block timestamp — or a number anyone can recompute from chain facts: `ru` follows from the transaction's own fields by the [Relay Unit formula](/concepts/relay-units) of the recorded schedule version. A Merkle root rather than a plain hash of the batch costs the same 32 bytes on chain and is what makes a **per-row** proof possible. SHA-256 was chosen because every client language has it. ### What a proof would show For one billed transaction, a proof ties three things together: 1. the leaf of that transaction, recomputed by you from chain facts; 2. an audit path of sibling hashes from that leaf to a root; 3. the root in the `usageSettled` event of a specific `settleUsage` transaction on chain. If the recomputed root equals the committed one, the row was counted exactly as the leaf says — including which account paid and how many units it cost. ### The route, and what it returns today ```http GET /v1/usage/{txHash}/proof Authorization: Bearer ``` Private to the billed account: native-auth, or a sponsor key with the `read` scope. The response is a `UsageProof`: `txHash`, `batchId`, `windowEndMs`, `usageRoot`, `usageChainHead`, `settleTxHash`, `leafHash`, `leaf` (the leaf's members) and `path` (the audit path as `{ hash, side }` steps). A row that has not been settled yet answers [`NOT_FOUND`](/errors/not-found): until a batch commits a root, there is nothing to prove against. :::warning[Not verifiable end to end yet] **As built, the route returns the committed root and the leaf hash, but an empty audit path**, and its `leaf` object carries only part of the preimage (the unit counts, the period and the block timestamp — not the addresses, the billing class or the schedule version). The interior nodes of a batch's tree are recomputed only by the settler, and the route does not serve them yet. Until it does, a proof can be checked only for a batch whose tree has a single leaf. The [changelog](/changelog) says when the route serves complete paths. ::: What you can already rely on: - the construction above, which is what the backend's ledger implements and tests — leaf hashing, root and path computation, and the chain head; - the on-chain side, which is fixed by the contract: `usage_root` and `usage_chain_head` in every `usageSettled` event, and `batch_id` strictly sequential; - the rule that a row is only ever billed once, keyed by `(sender, nonce)`, with the executed hash winning. ([Delivery guarantees](/concepts/delivery-guarantees)) ### Reading the settlement state | | | |---|---| | [`getSettlementState`](/contract/reference/views) | The reporter, whether settlement is paused, and the settlement state and totals. | | [`usageSettled`](/contract/reference/events) | One per accepted batch: root, chain head, window, counts, debits. | | [`paygSettled`](/contract/reference/events) | One per accepted pay-as-you-go line. | | [`settleLineRejected`](/contract/reference/events) | A line the contract refused, with its reason. A refused line does not advance the account's sequence. | The reporter key can settle usage and nothing else, bounded by each account's escrow and by a per-window cap, and the contract rejects a line whose units exceed what the account's rate class could have sent in the elapsed time. ([The contract](/contract/overview)) --- ## Limits Three different kinds of limit, and they fail differently. Knowing which one you hit tells you what to do about it. | Kind | Bounded by | Waiting helps? | |---|---|---| | **Throughput** — how fast | Your tier's rate class | Yes | | **Per transaction** — how big | Admission policy | No: change the transaction | | **Volume** — how much per month | Your plan's cap | No: buy more | ### Rate classes Your tier carries a rate class, and the class is part of what you bought — it is stored on chain, not in a configuration file we can quietly change. | Class | Used by | Relay Units/s, sustained / burst | Gas/s per shard · burst | Max gas limit per transaction | Relay Units/h | |---|---|---|---|---|---| | 1 | Starter | 1 / 15 | 20 M · 200 M | 100 M | 300 | | 2 | Builder | 5 / 25 | 50 M · 600 M | 600 M | 600 | | 3 | Growth, Agent Pro | 20 / 60 | 100 M · 1,000 M | 600 M | 1,500 | | 4 | Scale | 50 / 150 | 150 M · 1,500 M | 600 M | 3,000 | | 5 | Enterprise, Agent Fleet | 100 / 300 | 200 M · 2,000 M | 600 M | 6,000 | | 6 | Agent Metered | 2 / 15 | 20 M · 200 M | 100 M | 300 | These are launch values; they are published in the pricing document with a policy version and read from the chain as `getRateClasses()`. The backend's own configuration must be **at or below** the on-chain table — it can tighten during an incident without an owner transaction, and can never loosen beyond what the chain says. | Dimension | Error | |---|---| | Relay Units per second | [`RATE_LIMITED`](/errors/rate-limited) | | Gas per second, per shard | [`GAS_BUDGET_EXCEEDED`](/errors/gas-budget-exceeded) | | Relay Units per hour | [`HOURLY_BURN_EXCEEDED`](/errors/hourly-burn-exceeded) | | Above the class gas limit | [`GAS_LIMIT_TOO_HIGH`](/errors/gas-limit-too-high) — permanent, not a rate limit | All limits are **per account**, never per sender. A sponsor key may impose tighter limits of its own on top. Note that the burst capacity is never below the largest single transaction the class allows — 15 units for classes 1 and 6, 25 for classes 2 to 5. A bucket that could not admit a transaction the class permits would be a permanent, invisible refusal. There is also a platform-wide admission budget. When it binds, you get `RATE_LIMITED` with `details.scope = "platform"` and there will be an incident: that one is ours, not yours. ### Per-transaction ceilings | Limit | Rule | Error | |---|---|---| | Gas price | Between the network minimum and twice it | [`GAS_PRICE_OUT_OF_RANGE`](/errors/gas-price-out-of-range), with `details.bound` = `min` or `max` | | Gas limit, lower | At least the cost of moving the transaction | [`GAS_LIMIT_TOO_LOW`](/errors/gas-limit-too-low) | | Gas limit, upper | The class ceiling above | [`GAS_LIMIT_TOO_HIGH`](/errors/gas-limit-too-high) | | Data | 4,096 bytes on a self-serve account | [`DATA_TOO_LARGE`](/errors/data-too-large) | | Over-provisioned gas | With simulation on, headroom above the simulated cost is bounded | [`GAS_OVERPROVISIONED`](/errors/gas-overprovisioned) | | Deploys and upgrades | Allow-listed per account | [`DEPLOY_NOT_ALLOWED`](/errors/deploy-not-allowed) | | Sender's balance | Must cover any `value` the transaction moves | [`INSUFFICIENT_SENDER_BALANCE`](/errors/insufficient-sender-balance) | | Version and options | Accepted forms only; the guarded bit is fine | [`TX_VERSION_UNSUPPORTED`](/errors/tx-version-unsupported), [`TX_OPTIONS_UNSUPPORTED`](/errors/tx-options-unsupported) | | Unknown transaction members | Refused | [`UNSUPPORTED_TX_FIELD`](/errors/unsupported-tx-field) | | Variant sets | Refused outright | [`VARIANTS_NOT_SUPPORTED`](/errors/variants-not-supported) | The balance check deserves a word: a relayed transaction that fails because the *sender* cannot pay its own `value` still costs the **relayer** the whole fee. Checking it before co-signing is not paternalism; it is the difference between your mistake costing you one Relay Unit and costing us a fee for nothing. ### In flight | Limit | Value | Error | |---|---|---| | Non-terminal intents per sender | 64 | [`TOO_MANY_IN_FLIGHT`](/errors/too-many-in-flight) | | Assignments | 10/s per IP; 2/s with a burst of 10 per sender | [`RATE_LIMITED`](/errors/rate-limited) | | Free-flow transactions in flight per sender | 1 | [`FREE_FLOW_BUSY`](/errors/free-flow-busy) | | Free flag relays | 10 per 24 h, 30 per 30 days, per account | [`FREE_FLOW_BARRED`](/errors/free-flow-barred) | The free-flow limits cover the calls CoRelayer pays for on your behalf — buying a plan, setting flags, removing senders. They are generous enough to manage an account and tight enough that the free list is not a free relay service. ### Request limits outside the relay path | Route class | Key | Limit | |---|---|---| | Public cached reads — pricing, relayers, status, incidents, network, account mirrors, intent status | IP | 20 req/s, burst 60 | | `GET /v1/relay/{id}/events` | IP | 20 concurrent streams | | `POST /v1/quote`, `POST /v1/validate` | IP / account | 2 req/s per IP, 5 req/s per authenticated account | | The `prepare` routes | IP | 2 req/s, burst 10 | | x402 purchase routes | IP / payer | 1 req/s per IP, 6 per minute per payer | | Private reads | account, by class | 5 / 10 / 20 / 40 / 80 / 10 req/s, burst ×4 | | `GET /v1/stream` | account, by class | 2 / 3 / 5 / 10 / 20 / 2 concurrent streams; 5 per IP | | Writes on keys, webhooks, notifications | account | 1 req/s, burst 5 | `quote` and `validate` are limited tightly because `validate` can trigger a chain simulation — it is the most expensive public call on the API and the most useful, so it is protected rather than removed. ### Sizes | | | |---|---| | Request body | 131,072 bytes → [`PAYLOAD_TOO_LARGE`](/errors/payload-too-large) | | Transaction data | 4,096 bytes self-serve; more only by allow-list | | Idempotency key | 16 to 128 visible ASCII characters | | Purchase reference (`ref`) | 32 bytes | | API keys per account | 10 | | `relay_batch` over MCP | 16 transactions | | Page size | 1 to 200, default 50 | ### Reading your own limits ```bash curl -sS https://api.co-relayer.com/v1/pricing curl -sS https://api.co-relayer.com/v1/account/$ME/quota ``` Rate-limited responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`; a 429 carries `Retry-After` and `details.retryAfterMs`. With one exception, which is the most important line on this page: [`QUOTA_EXHAUSTED`](/errors/quota-exhausted) is a 429 with **no** `Retry-After`, because waiting does not help. Buying does. ### When a limit is the wrong limit If you are hitting a rate class repeatedly while your monthly cap is barely touched, the cap is not your constraint — the class is. Moving up a tier buys throughput as well as volume, and for bursty workloads that is usually the real reason to do it. ([Tiers](/plans/tiers) · [Tiers for agents](/plans/agent-tiers)) --- ## Status and SLOs ### The current state The state of each network is published where it is kept current, not written into this page: [`/.well-known/corelayer.json`](https://co-relayer.com/.well-known/corelayer.json) names the networks, their contract addresses and their API hosts; `GET /v1/status` carries the service state and any open incident; and the [changelog](/changelog) records every change. ### What is built, and how it is tested Each part is listed with what its tests cover, so that no page on this site has to be read as more than it is. | Part | State | |---|---| | **Smart contract** | Built and tested. Reproducible build and explorer verification are not published yet. | | **Backend** | The API, the signer, settlement and the other services are built and tested. They have been run end to end on a workstation against a mock chain — assign, relay, tracking to an executed state, billing, and a hard kill in mid-flight without losing or double-charging an intent. That is a test of the wiring, not a measurement: nothing about it is a latency or availability figure. | | **SDKs** (TypeScript, Go, Rust, Python) | Built and tested. Not published to a registry yet. ([SDK](/sdk)) | | **Dashboard, website, status site** | Built, with unit, end-to-end and accessibility tests. | | **These docs** | Built, with runnable examples tested against a stand-in API. | Known gaps: - **The MCP server** executes four of its twenty tools; the rest answer with a pointer to their REST route. ([The MCP server](/mcp)) - **Usage proofs** are served without their Merkle proof path yet. ([Usage proofs](/contract/usage-proofs)) - **Plan-cap billing.** Each API host serves an account from its own share of the account's cap. The allocator that grants those shares is now written and passes its tests against stand-ins for the database and the cache. Its tests against a real PostgreSQL and Valkey have not yet been seen passing, and no end-to-end run has yet billed a relay to a plan cap. - **Inclusion tracking** has all three of its sources wired: the block feed, a per-intent tracker for anything the feed misses, and a sweep over our relayers' recent transactions. A test proves that an intent still reaches "executed" and "finalized" when the feed never delivers its block. None of it has run against a chain. - **Settlement** of pay-as-you-go usage runs end to end in a test — the batch is built, signed and relayed by our own relayer — against stand-ins for the database and the chain. No settlement batch has yet been built against a real database or a chain. ### Where status will live | | | |---|---| | Status site | `status.co-relayer.com` — its own deployment, on infrastructure separate from the API, so that it survives an outage of the thing it reports on. | | Feeds | `/feed/status.json`, `/feed/incidents.json`, `/feed/telemetry.json` — three files, because incidents and telemetry have different cache lifetimes. | | From the API | `GET /v1/status`, `GET /v1/incidents`, `GET /v1/incidents/{id}` — the same data, public. | | For your account | `GET /v1/account/{erd}/outages` — the outages that affected **you**, which is a different question from "was there an incident". | A status page hosted on the same machines as the service it reports on is a status page that goes down when you need it. Hence the separation. Each deployment of co-relayer.com also serves [`/build-info.json`](https://co-relayer.com/build-info.json): the environment, the chain and the commit it was built from. ### Relayer funding, without balances Whether a shard can pay for your transaction is a status, not an amount. A watcher keeps every active relayer above a funding floor, and a relayer leaves the assignable set before it falls below what a transaction costs. The public view is one label per shard: `floatPerShard` in `GET /v1/status`, with the status `ok`, `low` or `critical`. A shard with no relayer left to assign answers [`NO_RELAYER_AVAILABLE`](/errors/no-relayer-available), with `Retry-After`. Exact hot-wallet balances are not published. They would be a shopping list for an attacker, and the label answers the only question a customer has. ### What we publish, and what we refuse to publish :::warning[No uptime percentage. No latency promise.] Availability and latency are published only once they have been measured over enough time to mean something. Until then there is no measured availability, no measured latency distribution and no basis for a service-level objective. Publishing one anyway would mean inventing it. Wherever this documentation has to describe an intention it says **"designed for"**; where it would otherwise be guessing, it says nothing. ::: This is a deliberate policy, not an oversight, and it applies to the service level agreement too: **there are no SLA percentages and no service credits at launch.** They arrive when there is data to base them on — and when they do, the remedy is denominated in Relay Units and granted through a contract endpoint, not in a discretionary credit note. ### What is measured The instrumentation exists; figures are published once there is enough data behind them. #### Per transaction | Field | Interval | |---|---| | `acceptToCosignMs` | Received → the relayer signature was released | | `cosignToBroadcastMs` | Signature → a gateway acknowledged it | | `addedMs` | Received → acknowledged | | `inclusionMs` | Received → the block that executed it | | `finalityMs` | Executed → final | | `totalMs` | Received → final | | `roundsToInclusion` | Inclusion expressed in chain rounds | The first two are ours. Inclusion and finality are the network's. They are reported separately so that our own queueing cannot hide inside the chain's variance. ([Latency](/concepts/latency)) #### Per account `GET /v1/usage/latency` reports p50, p95 and p99 over **your** transactions. That is a fact about what happened to you. It is not a forecast and it is not a commitment. #### Service-wide `GET /v1/status` carries the service state, a per-component breakdown and any open incident. A witness host separate from the API publishes the status feeds, so the report does not depend on the thing being reported on. ### Incidents | | | |---|---| | Declared | By the operators, with an id (`inc_…`) and a first update. | | Published | Immediately, to the status feeds and to `GET /v1/incidents`. | | Delivered | As an account notice and a webhook to affected accounts, not only as a page somebody has to remember to load. | | Per account | `GET /v1/account/{erd}/outages` — the periods where *your* traffic was affected. | Incidents have an id (`inc_…`) and a history, so an update is appended rather than replacing what was said earlier. A status history that can be quietly rewritten is not a status history. ### What "degraded" will mean These are the conditions that will be reported, so they are worth knowing in advance: | Condition | What you would see | |---|---| | A shard has no healthy relayer | [`NO_RELAYER_AVAILABLE`](/errors/no-relayer-available) on assignment, with `Retry-After` | | The signer is unreachable or has fenced itself | [`SIGNER_UNAVAILABLE`](/errors/signer-unavailable), [`SIGNER_FENCED`](/errors/signer-fenced) — nothing was co-signed | | Gateways are unreachable | [`UPSTREAM_UNAVAILABLE`](/errors/upstream-unavailable) before the commit point | | The platform admission budget binds | [`RATE_LIMITED`](/errors/rate-limited) with `details.scope = "platform"`, and an incident | | The exchange venue is paused | [`SWAP_VENUE_PAUSED`](/errors/swap-venue-paused) on purchases; relaying is unaffected | | Our contract is paused | [`CONTRACT_PAUSED`](/errors/contract-paused) on purchases | Note the last two: a purchase problem is not a relay problem. Relaying an already-entitled account's transactions continues while the venue or the contract is unavailable. ### The guarantee that does not depend on measurement One property holds regardless of load, latency or incident: **An error from `POST /v1/relay` means nothing was sent.** No error is returned after the relayer signature exists. Whatever else is degraded, that boundary holds — and it is the property your retry logic should be built on, because it is the one that is true even during an outage. ([Delivery guarantees](/concepts/delivery-guarantees)) --- ## What the latency numbers measure CoRelayer will publish latency figures in four places: on every intent, in each account's latency report, per shard in the network document, and on the status site. None of them exists yet — nothing has run in production, so there is nothing measured to show. This page fixes what each figure **means**, so that when numbers appear they can be read precisely and checked. :::note[No targets on this page] It describes measurements, not objectives. An objective is published only once there is enough measured data behind it, and it is then shown next to its measured value. ([Status and SLOs](/operations/status-and-slos)) ::: ### Five timestamps Every relayed transaction carries up to five timestamps, each in Unix milliseconds: | Timestamp | The moment | Whose clock | |---|---|---| | `receivedAtMs` | The API received your `POST /v1/relay`. | CoRelayer's | | `cosignedAtMs` | The signer released the relayer signature — the commit point. | CoRelayer's | | `broadcastAtMs` | The first gateway acknowledged our hash. | CoRelayer's | | `executedBlockTsMs` | The timestamp of the block that executed the transaction. | The chain's | | `finalizedAtMs` | That block became final. | CoRelayer's observation of the chain | A timestamp that is genuinely unknown is `null`, never estimated. That happens for rows restored by orphan recovery after a crash, where the original receive time was never journaled. CoRelayer's hosts are designed to keep their clocks synchronised against several time sources, to report themselves not ready when the offset grows too large, and to stop minting leases on an unsynchronised clock. That is what bounds the error in the figures below that subtract a chain timestamp from one of ours. ### Seven derived figures Every figure is a difference of two of those timestamps, or a count derived from one: | Figure | Formula | What it isolates | |---|---|---| | `acceptToCosignMs` | `cosignedAtMs − receivedAtMs` | Our validation, reservation and signing. | | `cosignToBroadcastMs` | `broadcastAtMs − cosignedAtMs` | Our path to the network. | | `addedMs` | `broadcastAtMs − receivedAtMs` | **Everything CoRelayer adds** before the network has the transaction. | | `inclusionMs` | `executedBlockTsMs − receivedAtMs` | From your request to execution — ours plus the network's round. | | `finalityMs` | `finalizedAtMs − executedBlockTsMs` | The network's finality. | | `totalMs` | `finalizedAtMs − receivedAtMs` | End to end. | | `roundsToInclusion` | `ceil(inclusionMs / roundDurationMs)` | Inclusion counted in chain rounds — `roundDurationMs` is in `GET /v1/network`. | Plus `crossShard`: whether the transaction left the sender's shard, because a cross-shard effect takes a further step and belongs in a different distribution. The split is the point. The first three figures are the part CoRelayer controls; inclusion and finality are the network's. A service that published only `totalMs` could hide its own queueing inside the chain's variance, and a round of 600 ms makes that variance large next to anything a relayer adds. ### Where each figure appears #### On one intent `GET /v1/relay/{id}` and `GET /v1/intents/{sender}/{nonce}` return the timestamps and a `latency` object with the derived figures. So do the per-intent event stream and the **Transactions** screen of the dashboard. This is a record of what happened to one transaction. #### For your account `GET /v1/usage/latency` reports percentiles over **your** transactions: | Parameter or member | | |---|---| | `window` | `1h`, `24h`, `7d` or `30d`. | | `groupBy` | `shard`, `relayer`, `sender`, or `gateway` — the gateway that acknowledged first. | | `groups[].added`, `groups[].inclusion` | `p50Ms`, `p95Ms`, `p99Ms` of `addedMs` and `inclusionMs`. | | `groups[].bins` | A server-side histogram of `inclusionMs`, each bin a count up to `leMs`; the last bin is the overflow. | | `groups[].samples` | At most 48 sampled transactions per group, for a strip plot, each with its hash. | | `sloTargetInclusionP99Ms` | `null` until an objective is published. | It is private — native-auth or a `read` key — because it is our measurement of you. The percentiles are computed on the server from rollups, not from the samples. #### Per shard, in the network document `GET /v1/network` carries `shards[]`: for each shard, `addedP50Ms`, `addedP99Ms`, `inclusionP50Ms`, `inclusionP95Ms`, `inclusionP99Ms`, the number of `samples` and the `windowMs` they cover, the time of the last block, whether the shard is `stalled`, and `relays24h`. #### On the status site `GET /v1/status`, and the status site's own `/feed/telemetry.json`, carry: | Member | Meaning | |---|---| | `latency30d[]` | Per shard, the same percentiles over the last 30 days — or since `measuredFromMs`, while the service is younger than that. | | `availabilityDaily90[]` | One entry per UTC day: the measured `availabilityPct` and the number of published incidents that touched the day. `null` for a day without data. | | `availability30d` | The measured share of valid relay requests that got a `BROADCAST` or a correct 4xx. **`null` until 30 days of data exist.** | | `relays24h` | Customer relays in the last 24 hours. | The status site is a separate deployment that reads three feed files — status, incidents, telemetry — so it keeps working when the API does not. ### What is excluded - **Synthetic probes.** A monitoring host sends a small number of real relayed transactions of its own, from dedicated low-value sender wallets, to check the whole path end to end. Those rows are marked internal and are **excluded from every public figure** — `relays24h`, `latency30d`, the shard percentiles and the marketing site. - **Rejected requests.** A request that never reached the commit point has no `cosignedAtMs` and contributes nothing to latency. It counts in availability only as what it was: a correct 4xx or not. - **Unknown timestamps.** A `null` stays out of the percentile it would have fed; it is never replaced by an estimate. ### Reading a percentile correctly A percentile over your own transactions is a fact about what happened to you, in a window, with a sample size next to it. It is not a forecast and it is not a promise. When the sample is small the number is noisy, which is why every figure is published with the count or window it came from. --- ## Responsible disclosure If you have found something, please tell us before you tell anyone else. | | | |---|---| | Contact | [security@co-relayer.com](mailto:security@co-relayer.com) | | Machine-readable | [`/.well-known/security.txt`](pathname:///.well-known/security.txt) | | Languages | English, German | ### What to send Enough for us to reproduce it: - what you did, step by step; - what happened; - what you expected instead; - where — mainnet, devnet, which host, which route or which contract call; - when, in UTC, and any `CoRelayer-Request-Id` you saw. Every response carries one, and it is the fastest way for us to find your request in the logs. A proof of concept is welcome. A video without the steps written down is not, because we cannot run a video. ### What we will do 1. **Acknowledge** that we received it, to a human, not an autoresponder. 2. **Tell you whether we could reproduce it**, and what we think the impact is — including if we disagree with your assessment, and why. 3. **Keep you updated** while we fix it, rather than going quiet. 4. **Credit you** when it is fixed, if you want to be credited. Some people do not; that is fine. If a finding is serious, we will pause the affected scope rather than leave it running while we think. The contract has five independent pause scopes precisely so that this does not have to be all-or-nothing. ([The contract](/contract/overview)) ### What we ask - **Do not test against accounts that are not yours.** Use devnet: it runs the same code as mainnet, with the same rules. - **Do not use a finding to move funds**, yours or anyone else's. - **Do not run load or denial-of-service tests** against a live environment. If you want to test capacity behaviour, ask us and we will arrange it. - **Do not publish before we have fixed it**, or before we have told you we cannot. If we go quiet on you, that is our failure and you owe us nothing further. ### What is in scope | | | |---|---| | The CoRelayer smart contract | Anything that lets a caller do something the [role matrix](/contract/overview) says they cannot | | The API | Authentication and authorisation flaws, anything that relays a transaction that should have been refused, anything that reveals another account's private data | | The relay guarantees | A way to make one user action produce two executable transactions; an error returned after the commit point; a way to alter a transaction after signing | | The SDK | A way to make the client sign twice, or to make it re-sign on a retry | | The sites | Injection, anything that renders attacker-controlled bytes as markup | ### What is out of scope | | Why | |---|---| | Missing headers or a low grade from a scanner, with no exploit | We would rather fix a real bug | | Reports from an automated scan with no reproduction | Same | | Social engineering of us or our users | | | Physical attacks | | | Anything requiring a compromised user device | The threat model assumes your device is yours | | Denial of service by volume | Already bounded by rate classes; tell us if you found a way *around* them, which is very much in scope | ### The bits we already know about Listing known residuals is not a way of pre-emptively rejecting reports — a *concrete exploit* of any of them is a valid finding. These are documented so you do not spend time telling us something we have written down ourselves: - the owner key is a single key with no multisig; - a compromised signing host exposes the active relayer floats; - a user-signed transaction we never co-signed may appear in an MCP host's logs; it is inert, but it is there. All three, with their bounds: [The trust model](/security/overview) · [Key handling](/security/keys). ### No bounty yet There is no bug bounty programme, and we will not imply one. If you report something serious we will discuss a reward with you directly; we would rather make a specific offer to a specific person than advertise a table we have not funded. ### Code and specification findings Findings in the **code, the contract or the specification** are entirely welcome, and are often the most useful kind: a bug found before it runs costs nobody anything. --- ## Key handling Every key in this system has one job and a temperature. The design goal is simple to state: **a key that leaks should cost as little as possible**, and the keys that could cost the most should be the hardest to reach. ### Your keys CoRelayer never sees a private key of yours. There is no key import, no custodial wallet and no "connect by pasting your seed phrase" — those things do not exist in the product, so they cannot be phished from it. | You hold | Used for | |---|---| | Your wallet key | Signing transactions and native-auth logins | | Optionally, a guardian | Your own protection; a guarded transaction is relayed like any other, with its extra gas accounted for | ### Our keys | Key | Where it lives | What it can do | |---|---|---| | **Owner** (the deployer) | An offline keystore. Never on a server. | Everything on chain: tariff, tiers, Relay Unit schedule, rate classes, treasury, operator, unpausing, registering relayers, upgrades. | | **Operator** | A human-held warm wallet. Not on a server. | Pause anything; activate, drain, retire and reweight relayers; lower caps. **Cannot** unpause, reprice, or register a relayer of its own. | | **Reporter** | Inside the backend's signer, on the core hosts. Hot. | `settleUsage`, and nothing else. Bounded by escrow and a per-window cap. | | **Relayer keys** (active) | Encrypted keystores inside the signer on the hosts that use them, never as plain files. Each active key is on more than one host, so delivery survives a lost machine. | Co-sign relayed transactions. Nothing else. | | **Relayer keys** (spares) | Offline, in two places. On no server. | Nothing, until activated by the owner. | | **Reserve** | Offline. On no server. | Receives swept relayer float; funds top-ups. | The registry of relayer **addresses** is public data in this repository — 30 wallets, 10 per shard, 9 marked active, 21 cold spares. The addresses are public because the registry is on chain anyway; the keys are what is protected. Relayer wallets **never** enable a guardian: the protocol does not permit a guarded relayer, and the contract refuses to register one. A role address may not also be a relayer. The contract enforces the disjointness, and the signer refuses to start with a key bundle that violates it. ### The bound on a compromised signing host If a host holding active relayer keys were compromised, what is reachable is the **relayer float**: the small working balance each relayer holds to pay fees. That is CoRelayer's own working capital. What is **not** reachable, because it is not there: - customer tokens — we never hold any; - credits and plans — they live in the contract, and no relayer key can touch them; - the tariff, the tier table or the registry — owner-only; - settlement beyond the reporter's bounded cap. The response is to drain the affected relayers, activate spares from cold storage, and replace the wallets. That is why there are 21 spares and why their keys are on no server. ### Environments are separated even though addresses are not The owner, operator, reporter and relayer wallets have the **same addresses** on devnet and on mainnet. Transaction replay between networks is prevented by the chain id inside the signed bytes. Everything else that is signed or authenticated is **per environment**: | Per environment | Consequence | |---|---| | The lease key and the quote key | A devnet lease or quote never verifies on mainnet. | | The sponsor-key hashing key | A `crk_test_…` key is refused by the mainnet API, and the reverse. | | Webhook secrets, status-feed secrets, operational-flag keys | No cross-environment replay. | | Payment identifiers | Separate databases. | And a hard stop in the signer itself: it **pins its chain id** and refuses any transaction whose `chainID` differs. It cross-checks that pin against the network at start-up and refuses to serve on a mismatch. Since the chain id is inside the signed bytes, a signature made for one network is worthless on the other. The devnet hosts get full production key handling from the first deploy — encrypted keystores, signer isolation, no plain key file on any server. Not because devnet matters, but because those hosts hold the real keys. ### Your API keys ``` crk___ ``` | | | |---|---| | Shown | **Once**, at creation. We store a keyed hash and compare in constant time. We cannot show it again. | | Scopes | `relay` — pay for arbitrary senders. `read` — private reads of the account. | | Required policy for `relay` | A **non-empty receiver allow-list**. Not optional. It is compared with the transaction's receiver field ([what that covers](/sdk/recipes/sponsor-users#what-a-sponsor-key-can-pay-for)). | | Optional policy | Function allow-list, per-sender daily unit limit, account daily unit limit, IP allow-list. | | Limit | 10 keys per account. | | Managed by | Your wallet only. A key can never create, change or revoke a key. | | Requires | A plan that can sponsor any sender (Builder and up, Agent Pro, Agent Fleet), else [`API_KEY_SCOPE`](/errors/api-key-scope). | **There are no browser-visible keys, and there will not be.** Sender addresses are free to create, so a key visible in a page would let anyone burn the sponsor's whole cap within the allow-list. Named wallets (on-chain authorised senders) 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. How a server uses a key: [Pay for your users](/sdk/recipes/sponsor-users). The mandatory receiver allow-list is what makes a leaked key survivable: the thief can spend your Relay Units, but only on transactions to receivers **you** named. If a key leaks: revoke it (`DELETE /v1/account/{erd}/keys/{keyId}`), mint a new one, and check `GET /v1/usage` for what was spent under the old one. ### Signed transactions in logs A user-signed relayed transaction that we never co-signed is **inert** — it cannot execute, by the protocol. But it is still a payload somebody else can hold, so: - the MCP tools that take one never echo the transaction, the signature, the guardian signature or the lease in their results or their errors; - their descriptions tell hosts not to log their arguments; - the presence proof and first-seen expiry bound what a captured payload is worth. The residual is not zero, and it is listed as a residual rather than explained away. ([MCP](/mcp)) --- ## The trust model A relaying service sits between you and the chain. The useful question is not whether we are trustworthy — it is **what we would be able to do if we were not**. Four answers, each of which follows from the protocol rather than from our promises. ### We cannot alter your transaction Your signature covers every field: the receiver, the value, the data, the gas limit, the chain id and the relayer address. Change one byte and the signature is worthless and the network rejects it. Our signature is a **second, separate** signature over exactly the same bytes. It authorises the fee payment and nothing else. **Check it yourself:** the transaction on the explorer carries both signatures over the bytes you signed. Compare the fields with what your wallet showed you. ### We cannot make it execute twice The transaction occupies one `(sender, nonce)` slot. Once that nonce has executed, another transaction carrying it is rejected by the network. Re-broadcasting identical bytes is the same transaction, with the same hash. **Check it yourself:** the nonce is in the signed bytes and visible on chain. ([Delivery guarantees](/concepts/delivery-guarantees)) ### We cannot spend your funds A relayer pays the network fee in EGLD. It has no authority over your balances. There is no approval step, no allowance and no custody — because relayed v3 has no mechanism that would create one. The USDC you pay for a plan goes to the **contract**, not to a wallet of ours, and it is swapped and split in the same call. ([Where the money goes](/concepts/revenue-flow)) ### We can delay, or decline This is the real risk in the design, and pretending otherwise would be dishonest. A relayer can be slow to co-sign, or refuse. What bounds it: - the relayer is **named to you before you sign**, and you can verify its registry state on chain through a node that is not ours; - a refusal happens **before** anything is co-signed, so nothing was sent and your signed payload is inert; - the service tells you which case you are in through the `resign` member, rather than leaving you to guess. ([One signature](/concepts/one-signature)) ### Pinning: the mistake worth avoiding :::danger[An address is not a network identifier] The CoRelayer owner and relayer wallets have **the same addresses on devnet and on mainnet** — one key per role, and a signed transaction's chain id is what keeps the networks apart. The contract address may coincide too. So recognising an address tells you **nothing** about which network you are on. ::: The rule for every client: 1. Pin the contract address **and the chain id** in your own configuration. 2. Compare that chain id with the `chainID` of every transaction you are about to sign. 3. Where a discovery file disagrees with your pin, **refuse** — do not adopt the file's value. The discovery files this project publishes are convenience copies. They are not authority. ([Discovery](/agents/discovery)) ### What the service is built not to be able to do | | | |---|---| | **Return an error after sending** | No error is returned after the relayer signature exists. Before it, nothing can execute; after it, the honest answer is a state, never a failure. | | **Ask you to sign twice for one action** | Structurally enforced in the SDK; the only second signature is an explicit, explained re-sign after a failure that sent nothing. | | **Register a relayer with a warm key** | Only the cold owner key can add a relayer address. The operator can only activate ones the owner already added. | | **Keep unused escrow locked** | Turning pay-as-you-go off always returns unused escrow to your credits: through the closing settlement line, or — if the settlement key is dead or hostile — through `releaseEscrow`, which anyone may call seven days later and which no pause scope blocks. | | **Upgrade the contract while money is moving** | An upgrade reverts unless all four money scopes are paused first. | | **Raise the price without notice** | 48 hours for an increase, enforced by a compiled constant, not a setting. | | **Reach into what you already bought** | A purchase freezes its terms. Nothing later touches them. | ### Reproducible build and verified source The plan, in the order that makes each step meaningful: build the contract reproducibly, so that anyone building the published source with the published toolchain gets the same bytes; publish the source for verification on the explorer; and deploy to mainnet only the hash that passed the devnet exit criteria — a check in the deploy procedure rather than something a reviewer has to remember. The reproducible build and the explorer verification are open work. Each step is announced in the [changelog](/changelog) when it is done. ([The ABI and verification](/contract/abi)) ### How the code is checked | | | |---|---| | **A written threat model** | This page: the trust model, with its residual risks named rather than left out. | | **Invariants with tests** | The delivery guarantees are numbered invariants — no signature before its record is durable, no co-signature without a verified lease, one transaction hash per slot, no error after the commit point — each with a test. ([Delivery guarantees](/concepts/delivery-guarantees)) | | **Structural refusals** | The one-signature rule is enforced by the shape of the SDK, not by a convention. The signer pins its chain id. The contract refuses an upgrade unless every money scope is paused. | | **Golden vectors** | The [Relay Unit formula](/concepts/relay-units) has a published set of twelve vectors that an independent implementation can reproduce; this site's own test suite recomputes them on every build. The contract and the backend each reproduce the settlement specification's worked example in their own tests. | | **Runnable, tested examples** | Every code sample on this site is compiled against the API's generated types and run against a stand-in API that verifies its signatures. | | **Generated, not hand-written** | API types, the API reference, the contract reference and the error catalogue are generated from their sources. A drift check fails the build. | ### What you can verify without us | | | |---|---| | The contract's behaviour | Read the [ABI](/contract/abi) and call the [views](/contract/reference/views). They cost nothing. | | The price | [`getPricingConfig`](/contract/reference/views), [`getTiers`](/contract/reference/views), [`getTariffHistory`](/contract/reference/views): the whole pricing model is three views. | | That a relayer is legitimate | [`getRelayerState`](/contract/reference/views), through a node that is not ours. ([Verify a relayer](/concepts/verify-a-relayer)) | | That the Relay Unit schedule is the committed one | Compare the document's hash against [`getRuSchedule`](/contract/reference/views). | | That your transaction was not altered | Both signatures are over the same bytes, and the bytes are on chain. | ### The residual risks, stated A trust model that lists only the reassuring parts is not a trust model. | Risk | Why it exists | What bounds it | |---|---|---| | A compromised signing host | Relayer keys have to be on a machine that can sign | Only the **active relayer floats** — our working capital, not customer funds. No plan, credit or user token is reachable. Every affected wallet is replaced from cold spares. | | The owner key is a single key | No multisig, by decision | It is a cold, offline keystore, never on a server. Increases carry 48 hours of notice. Upgrades need everything paused, and emit an event. | | A leaked sponsor key | Server-side keys exist so that services can pay for their users | A **mandatory, non-empty receiver allow-list** for the relay scope, plus per-day unit limits, an optional IP allow-list, and no key may mint or revoke keys. | | A signed payload in a log | MCP hosts and model transcripts log tool arguments | A payload we never co-signed is inert. The tools never echo it back. The risk is not zero. | ### Reporting something [security@co-relayer.com](mailto:security@co-relayer.com), or the policy at [Responsible disclosure](/security/disclosure). The same contact is published at [`/.well-known/security.txt`](pathname:///.well-known/security.txt). --- ## The Finance Computer Financial software has learned to decide, to price and to authorise on its own. **Delivery has not.** Between "this payment should happen" and "this payment settled" there is still a step that quietly assumes somebody's wallet holds enough of the chain's native token. CoRelayer is that step, made explicit and made buyable. ### Where this sits Above the ledger, below everything that uses it. The chain settles; wallets and applications decide; in between, something has to get a signed transaction into a block and pay for the privilege. Today that job is done implicitly, by whoever happened to fund a wallet, and it fails in the way implicit jobs fail: silently, at the worst moment, with nobody owning it. ```mermaid flowchart TB APP["Applications, agents, wallets
decide · price · authorise"] DEL["Transaction delivery
assignment · co-signature · broadcast · accounting"] CHAIN["MultiversX
orders · executes · settles"] APP --> DEL --> CHAIN ``` Making it a layer rather than an assumption means it can be **priced** (Relay Units), **bought** (a plan in USDC), **measured** (per-transaction latency) and **verified** (the relayer registry and the purchase rules are on chain). ### Four directions, one problem | | | |---|---| | **Person to person** | A payment app where the recipient has never held the chain's native token, and the sender should not have to explain why they must. | | **Person to machine** | Paying a metered service — an API, a model, a device — where the charge is small and a separate gas balance would cost more than the charge. | | **Machine to person** | Payouts, refunds and settlements issued by software on a schedule, which must not stop because an operational wallet ran dry overnight. | | **Machine to machine** | Two agents settling continuously. Neither has a human to top up a balance, and neither should hold the other's token. | The last one is where the gap is widest, and it is the one that grows fastest. ### Why autonomous software cannot operate a gas balance Not because it is technically impossible. Because it is operationally unreasonable: - A gas balance is an operational account. It needs monitoring, alerting and someone on call. - Autonomous software cannot open an exchange account to buy the native token. - The amount needed moves with the token price, so a fixed float is either wasteful or fragile. - A fleet of agents multiplies the problem by the number of keys. - A balance held for gas is a balance exposed to key compromise for no revenue. - Accounting cannot allocate a shared gas wallet to individual actions. - Treasury policy rarely allows a volatile asset to sit in a hot operational key. - **A stopped agent is discovered late**, because "out of gas" looks exactly like "nothing to do". A monthly USDC plan replaces every one of those with a line item. ### Why a fleet, and not one sponsoring wallet Two reasons, and both are structural rather than a matter of scale. **Shards.** The fee payer of a relayed transaction has to be in the sender's shard. One wallet cannot serve every sender; a set with coverage in each shard can. ([Shards and routing](/concepts/shards-and-routing)) **Exposure.** Every transaction in flight reserves its worst-case fee against the relayer that will pay it. One wallet is one balance and one point of failure. Several per shard means a relayer can be drained without the shard stopping — and means a compromise costs the float of a few wallets rather than everything. ([Relayers](/concepts/relayers)) ### An operated fleet today, an open cooperative tomorrow Five phases, numbered 0 to 4, in order and **without dates** — a date we cannot keep is a claim we cannot support. They are the same five on [co-relayer.com/finance-computer](https://co-relayer.com/finance-computer). | Phase | What it means | It ends when | |---|---|---| | **0. Foundation** | An operated fleet in every shard, prices set in the contract, and plans an agent can buy on its own. | Latency is measured and published for every shard. | | **1. Policy and fleets** | Sub-accounts per sender, value limits and usage Merkle proofs. | Fleets can split one plan across teams with their own limits. | | **2. Latency classes and SLAs** | Published objectives become contractual SLAs with credits, with more dedicated relayers in more regions. | 90 days of measured p50, p95 and p99 per class and shard exist. | | **3. Open cooperative** | Third-party operators run relayers under the same on-chain rules and the same measured standards. | At least three independent operators serve production traffic, and a governance document exists. | | **4. Beyond mainnet** | One fleet per sovereign chain, gas stations that take payment on one chain and sponsor on MultiversX, and CoRelayer as the default relayer behind x402 facilitators and MCP servers. | The sovereign-chain documentation is complete and the first sovereign customer runs on it. | The word *cooperative* in "Cooperative Relayer Infrastructure for Autonomous Transactions" is phase 3. What makes phase 3 credible rather than aspirational is that the contract was built for it: relayers are registry entries with states, weights and an operator identity, and the rules they work under are in the contract, not in a spreadsheet. Nothing has to be redesigned to let somebody else run a relayer — only permitted. ### What we do not do The boundary matters as much as the offer: | Not this | | |---|---| | **Custody** | We never hold your tokens and cannot move them. | | **A wallet** | Bring your own keys. We never see a private key of yours. | | **A bridge or an exchange** | The only swap in the system converts *our* revenue into the EGLD that pays fees. | | **A chain** | We deliver transactions to MultiversX. We are not building a ledger. | | **Making an invalid transaction valid** | Every protocol rule still applies. | | **A settlement guarantee** | We deliver; the network decides. What we guarantee is that a failure to deliver is visible and never ambiguous. | ### Where to read the concrete version This page is the argument. The mechanism is documented, not sketched: - [Relayed v3](/concepts/relayed-v3) — the protocol feature it all rests on - [Delivery guarantees](/concepts/delivery-guarantees) — what "delivery" means precisely - [Relay Units](/concepts/relay-units) — how the cost is measured - [The contract](/contract/overview) — what is on chain and who may change it - [CoRelayer for agents](/agents) — the whole product for a machine reader --- ## 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. --- ## 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. # CoRelayer API — plain-Markdown reference Version `1.0.0-draft.1`. Generated from the OpenAPI 3.1 document at https://docs.co-relayer.com/openapi.yaml — that document is authoritative; this file is a reading aid for agents, with schemas flattened one level. 61 operations. The browsable reference starts at https://docs.co-relayer.com/api/reference/corelayer-api. ## Servers - `https://api.co-relayer.com` — MultiversX mainnet (chainID "1") - `https://devnet-api.co-relayer.com` — Devnet staging (chainID "D"). It runs the same service and wallets as mainnet and swaps through the ## Errors Every error is an RFC 9457 problem document whose `type` is `https://docs.co-relayer.com/errors/`. The machine-readable catalogue is at https://docs.co-relayer.com/errors.json. ## meta Health, discovery documents, network parameters. ### GET /healthz operationId: `getHealthz` Liveness of the API process. Auth: `public` Responses: - `200` Process is alive. → Health ### GET /readyz operationId: `getReadyz` Whether this API host is ready to take traffic. It reports not ready while the host restores its state after a restart. Auth: `public` Responses: - `200` Ready to take traffic. → Health - `503` ### GET /openapi.json operationId: `getOpenApi` The API's OpenAPI description, as JSON. Auth: `public` Responses: - `200` OpenAPI 3.1 document. ### GET /.well-known/x402 operationId: `getX402Descriptor` x402 resource descriptor at the conventional well-known path. Auth: `public` Responses: - `200` Descriptor. → X402Descriptor ### GET /v1/network operationId: `getNetwork` Chain parameters the client needs to build a transaction, swap-venue state, measured latency per shard. Chain constants are re-read from the gateway's `/network/config` every epoch. Latency figures are measured values only; nothing is estimated. `nativeAuth` carries a recent **shard-1** block hash for headless agents. A native-auth token must embed a shard-1 block hash, because the API checks it against its own record of recent shard-1 blocks without calling a gateway. `contractPause` reflects the contract's `getPauseState()` and `swapVenue` reflects the exchange venue. They are separate conditions with separate error codes: `CONTRACT_PAUSED` and `SWAP_VENUE_PAUSED`. Auth: `public` Responses: - `200` Network view. → Network - `429` ## relay Native relay API (assign, relay, intent status, per-intent SSE). ### POST /v1/relay/assign operationId: `assignRelayer` Assign one relayer in the sender's shard and issue a lease. Nothing is reserved yet. Call it just before the user signs: the relayer address goes into the transaction, so the user signs once. `renewFor` renews the lease for a transaction that is already signed, so a slow signer does not have to sign again. `cancel` returns a lease for a cancel transaction, pinned to that nonce. Rate limits: 10/s per IP, and 2/s per sender with a burst of 10. The caller must prove it controls the sender, in one of two ways: - a presence proof: the sender's own key signs the message `corelayer/assign/v1|||`, where `serverTimeMs` is within +/- 30 000 ms of the server clock. The signature is a raw Ed25519 signature over the UTF-8 bytes of the message, without the MultiversX signed-message prefix that a wallet's `signMessage` adds. Set `proof.kind` to `sponsor` when a sponsor API key will pay for the relay, and to `key` otherwise. The sender's key signs both kinds; - a native-auth bearer token whose address equals `sender`. This route does not read `X-Api-Key`, and a sponsor API key never proves presence: it goes on `POST /v1/relay`, where it selects the account that pays. A missing proof gives `401 ASSIGN_PROOF_REQUIRED`; a bad or stale one gives `401 ASSIGN_PROOF_INVALID`. The prepare routes of the free purchase flow issue their own `FREE` lease and need no proof. Auth: `presence-proof`, `native-auth` Request body (required): AssignRequest (see /openapi.yaml) Responses: - `200` Assignment. → Assignment - `400` - `401` `ASSIGN_PROOF_REQUIRED` or `ASSIGN_PROOF_INVALID` (`details.serverTimeMs` = signer clock), or a token error of the `Unauthorized` class. → Problem - `403` - `409` `RESIGN_REQUIRED` (renewFor relayer no longer renewable) or `NOTHING_TO_CANCEL`. → Problem - `429` - `503` `NO_RELAYER_AVAILABLE`, `SIGNER_UNAVAILABLE` or `SIGNER_FENCED`; carries `Retry-After`. → Problem ### POST /v1/relay operationId: `relayTransaction` Submit one user-signed Relayed-V3 transaction for co-signing and broadcast. The idempotency key is `(tx.sender, tx.nonce)` plus the SHA-256 of the canonical signing bytes. Posting the same bytes again returns the stored response with `duplicate: true` and never creates a second reservation or a second broadcast. Unknown body members are rejected. `variants`, `transactions` and `alternates` give `VARIANTS_NOT_SUPPORTED`: one request carries one transaction, signed once. Once the transaction is co-signed (state `COSIGNED`), the answer is always 200 or 202. Authentication: the signed transaction is the credential when the sender has an account or is an authorised sender on chain. Relaying for arbitrary senders needs a sponsor API key. Optional **logical idempotency key** (header `Idempotency-Key` or body member `intentKey`; if you send both, the header is used). It is stored per `(sender, key)` for 86 400 000 ms. The same key with different bytes or a different nonce, while the first intent is not `DEAD`, gives `409 INTENT_ALREADY_SUBMITTED` carrying the first intent. After `DEAD` the key is free again. The same key with the same bytes is an ordinary replay. The key can only reject a request, so it never contradicts the idempotency key above. Send one key per user action, so that a retry after a timeout cannot relay the same action twice. Free relays: a short list of calls to the CoRelayer contract is relayed free of charge. `GET /v1/pricing` publishes it as `howToBuy.freeOperations`. A free call to the contract while it is paused (`paused` or `deposits_paused`, sender is not the owner) gives `503 CONTRACT_PAUSED` before anything is co-signed. Auth: `tx`, `sponsor-key` Parameters: - `undefined` (undefined, unspecified, optional) Request body (required): RelayRequest (see /openapi.yaml) Responses: - `200` Co-signed and acknowledged by at least one gateway (state BROADCAST or later). → RelayResponse - `202` Co-signed, no gateway acknowledgement within 800 ms (state COSIGNED). Re-broadcast continues server-side. → RelayResponse - `400` - `401` - `402` `NO_ENTITLEMENT`. Carries a `PAYMENT-REQUIRED` header that points at `POST /v1/x402/topup`. → Problem - `403` - `409` - `410` `RELAYER_RETIRED` (only when no lease flow exists; otherwise `RESIGN_REQUIRED`). → Problem - `413` - `422` - `429` - `500` - `503` ### GET /v1/relay/{id} operationId: `getRelay` Intent status by intent id (`:`) or by transaction hash. Resolver in front of `GET /v1/intents/{sender}/{nonce}` (authoritative) and `GET /v1/tx/{hash}`. Same response schema. Auth: `public` Parameters: - `id` (path, IntentId | TxHash, required) — Intent id `erd1...:41` or a 64-hex transaction hash. Responses: - `200` Intent. → Intent - `404` - `429` ### GET /v1/relay/{id}/events operationId: `streamRelay` Server-sent events for one intent until it is terminal and final, or 120 000 ms elapsed. Event name `intent`, data = `Intent`. `id:` is the per-intent sequence. The server closes the stream after the first event with `final: true`, after a terminal `DEAD`/`REJECTED`, or after 120 000 ms (the client then falls back to polling `GET /v1/relay/{id}`). Auth: `public` Parameters: - `id` (path, IntentId | TxHash, required) - `undefined` (undefined, unspecified, optional) Responses: - `200` Event stream. - `404` - `429` ### GET /v1/intents/{sender}/{nonce} operationId: `getIntent` Authoritative, cross-host intent status keyed by (sender, nonce). Auth: `public` Parameters: - `sender` (path, Address, required) - `nonce` (path, integer (int64), required) Responses: - `200` Intent. → Intent - `404` - `429` ### GET /v1/tx/{hash} operationId: `getTx` Convenience lookup by transaction hash. Terminal state is still decided by (sender, nonce) and chain facts. Auth: `public` Parameters: - `hash` (path, TxHash, required) Responses: - `200` Intent. → Intent - `404` - `429` ### POST /v1/quote operationId: `quoteRelay` RU weight and price of a transaction (signed or unsigned). No reservation, no signer call. Auth: `public` Request body (required): QuoteRelayRequest (see /openapi.yaml) Responses: - `200` Relay quote (pricing-family types, decimal strings). → RelayQuote - `400` - `422` - `429` ### POST /v1/validate operationId: `validateTransaction` Run the relay checks (static checks, signatures, account and nonce, simulation) without a lease, a reservation or a co-signature. Backs the MCP tool `validate_transaction`. Rate limit 2/s per IP because it may trigger a gateway simulation. Auth: `public` Request body (required): ValidateRequest (see /openapi.yaml) Responses: - `200` Validation report. `ok=false` lists the problems that `POST /v1/relay` would return. → ValidateResponse - `400` - `429` ## pricing Machine-readable pricing and quotes. ### GET /v1/pricing operationId: `getPricing` Live machine-readable pricing document (contract views, cache <= 6 000 ms). The same document as the static file `https://co-relayer.com/pricing.json` and the MCP resource `corelayer://pricing`. Its complete JSON Schema is `https://co-relayer.com/schemas/pricing-v1.json`. Money, RU, gas and atto values are decimal strings here. Auth: `public` Parameters: - `audience` (query, "human" | "agent", optional) — Filter `tiers[]` by audience. Responses: - `200` Pricing document. → PricingDocument - `429` ### GET /v1/pricing/tariff-history operationId: `getTariffHistory` Append-only tariff history mirrored from the contract view `getTariffHistory`. Auth: `public` Parameters: - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Responses: - `200` Page of tariff entries, newest first. - `400` ## purchase Builders of unsigned contract calls (deposit, subscribe, flags, senders). ### POST /v1/subscribe/prepare operationId: `prepareSubscribe` Quote a plan purchase and build the unsigned contract call with a relayer and lease pre-filled. Returns a price quote (valid for 120 000 ms, `maxPrice == priceMicroUsdc`), one unsigned transaction (`depositAndSubscribe` when USDC must be paid, `subscribe` when credits cover the price) and the assignment to submit it with. The purchase transaction is relayed free of charge. The quote is **stateless**: `quoteMac` is an HMAC over its canonical JSON, and `quoteId` is derived from the MAC. The same inputs inside one TTL bucket give the same id on either API host, and nothing is stored. `503 CONTRACT_PAUSED` while the CoRelayer contract is paused (it is deployed paused), and `503 SWAP_VENUE_PAUSED` while the exchange venue is paused. Nothing is built in either case. Auth: `public` Request body (required): PrepareSubscribeRequest (see /openapi.yaml) Responses: - `200` Quote + unsigned transaction + assignment. → PreparedPurchase - `400` - `409` - `422` - `429` - `503` ### POST /v1/deposit/prepare operationId: `prepareDeposit` Build the unsigned `deposit` / `depositFor` call (USDC -> credits). Auth: `public` Request body (required): PrepareDepositRequest (see /openapi.yaml) Responses: - `200` Unsigned transaction + assignment. → PreparedTransaction - `400` - `422` - `429` - `503` ### POST /v1/flags/prepare operationId: `prepareSetFlags` Build unsigned `setAutoRenew`, `setPayg` or `releaseEscrow` calls for an existing account (relayed free within the flag-call limits). Flag calls are relayed free of charge, up to 10 per 24 h and 30 per 30 days per account. `setPayg` is encoded with four arguments: `enabled, budget, auto_topup, max_payg_price`. `renew: true` builds `renew(address)`, the dashboard's "Renew now". It is free only when the argument is the sender's own account, so `address` is both the sender and the argument. When the free allowance is used up, the response carries `free: false` and `ru`, and the call is billed as a normal relay. An address that cannot be billed gets `429 RATE_LIMITED` with `details.scope = free_flow`. `503 CONTRACT_PAUSED` while the contract's global pause is set. Auth: `public` Request body (required): PrepareFlagsRequest (see /openapi.yaml) Responses: - `200` One unsigned transaction per requested change, consecutive nonces, one assignment. → PreparedTransactions - `400` - `402` `NO_ENTITLEMENT` (details.reason NO_ACCOUNT) - flag calls are co-signed only for addresses that already have an on-chain account. → Problem - `422` - `429` - `503` ### POST /v1/senders/prepare operationId: `prepareSenders` Build unsigned `addSenders` / `removeSenders` calls. `addSenders` is **never** relayed free. It is billed to the account as a normal relay and carries the on-chain per-sender fee, which makes mass-creating senders expensive. `removeSenders` has no fee and is relayed free: it is how a payer cuts off a compromised sender key. The response members `free`, `ru` and `feeMicroUsdc` say which case applies; show them instead of working the rule out yourself. `addSenders(max_fee, senders...)`: the route fills the first argument `max_fee` with the quoted fee, so `PreparedTransaction.feeMicroUsdc` **equals** `contractCall.args[0]`. It is the ceiling the user signs. A higher on-chain fee at execution reverts with `ERR_PRICE_ABOVE_MAX`, reported as `409 PRICE_ABOVE_MAX`. An increase of `sender_fee_ru` takes effect only 172 800 000 ms after it is set (`SENDER_FEE_NOTICE_MS`). `GET /v1/pricing` does not publish a pending `sender_fee_ru`: the fee this route quotes in `feeMicroUsdc` is the one in force when it answers, and it is the ceiling the user signs. Auth: `public` Request body (required): PrepareSendersRequest (see /openapi.yaml) Responses: - `200` Unsigned transaction + assignment + fee preview. → PreparedTransaction - `400` - `422` - `429` ## x402 x402 v2 purchase flow, paymentFlow "upfront". ### GET /v1/x402/supported operationId: `getX402Supported` x402 v2 `supported` document - kinds, extensions and `signers` (our active relayers per CAIP-2 pattern). Auth: `public` Responses: - `200` Supported kinds. → X402Supported ### POST /v1/x402/topup operationId: `x402Topup` x402 v2 purchase of credits (`deposit` / `depositFor`). Unpaid request -> 402 challenge, paid retry -> settle before serve. These rules hold on both API hosts, and the same rules apply to `/v1/x402/purchase`: - `accepted` is verified by **recomputing `extra.quoteMac`**, never by a lookup, because the challenge may have been issued by the other API host. - The payment record keyed by `extensions["payment-identifier"].id` is written to the database **before** anything is co-signed. If the database is unreachable the answer is `503 UPSTREAM_UNAVAILABLE` and nothing is co-signed. The same id with the same payload returns the stored result; the same id with another payload gives `402 PAYMENT_INVALID`. - `503 CONTRACT_PAUSED` / `503 SWAP_VENUE_PAUSED` are answered before any co-signature. - `asset`, `network` and `payTo` are the values of the serving network (`USDC-c76f1f` / `multiversx:1` on mainnet, `USDC-350c4e` / `multiversx:D` on devnet staging). Auth: `x402` Parameters: - `undefined` (undefined, unspecified, optional) Request body (required): X402TopupRequest (see /openapi.yaml) Responses: - `200` Payment executed successfully on chain and the `deposit` event was mirrored. → X402PurchaseResult - `202` Co-signed and broadcast, completion exceeds the HTTP budget (8 000 ms). Poll `GET /v1/x402/payments/{paymentId}`. → X402Pending - `400` - `402` - `429` - `503` ### POST /v1/x402/purchase operationId: `x402Purchase` x402 v2 purchase of a plan in one payment (`depositAndSubscribe(tier_id, months, max_price, ref)`). The beneficiary is always the payer (contract rule). `ref` = the quote id, bound under the payer's signature. Quote verification, payment-identifier persistence and the pause answers follow the cross-host rules listed under `POST /v1/x402/topup`. Auth: `x402` Parameters: - `undefined` (undefined, unspecified, optional) Request body (required): X402PurchaseRequest (see /openapi.yaml) Responses: - `200` Payment executed and `subscribed` event mirrored. → X402PurchaseResult - `202` Pending, poll. → X402Pending - `400` - `402` - `429` - `503` ### GET /v1/x402/payments/{paymentId} operationId: `getX402Payment` Poll a pending x402 payment by its `payment-identifier`. Auth: `public` Parameters: - `paymentId` (path, string, required) Responses: - `200` Final result (success or failure); `PAYMENT-RESPONSE` header is set. → X402PurchaseResult - `202` Still pending. → X402Pending - `404` ## registry Off-chain mirror of the on-chain relayer registry (convenience, not authority). ### GET /v1/relayers operationId: `listRelayers` Off-chain mirror of the relayer registry. Balances are never exposed here. The contract view `getRelayerState` is the authority. Agents should verify a relayer through a gateway that CoRelayer does not run (https://docs.co-relayer.com/concepts/verify-a-relayer). Auth: `public` Parameters: - `shard` (query, Shard, optional) - `state` (query, RelayerStateName, optional) Responses: - `200` Registry mirror. → RelayerRegistry ### GET /v1/relayers/{address} operationId: `getRelayerPublic` One relayer with its state history and public service statistics. Auth: `public` Parameters: - `undefined` (undefined, unspecified, optional) Responses: - `200` Relayer. → RelayerPublicDetail - `404` ## status Component status and incident feed. ### GET /v1/status operationId: `getStatus` Component status, measured latency per shard, 90-day daily availability, float status label per shard, open incidents. The same document is published every 60 000 ms as `https://status.co-relayer.com/feed/status.json`. Next to it are `/feed/incidents.json` (the first page of `GET /v1/incidents`) and `/feed/telemetry.json` (`latency30d` + `availabilityDaily90`). They are three files because they have different cache lifetimes, and the status page renders each one on its own. Synthetic probe traffic is excluded from every public counter. Percentages are measured over past traffic and carry no service-level guarantee. `floatPerShard` is a status label, never a balance. Auth: `public` Responses: - `200` Status. → Status ### GET /v1/incidents operationId: `listIncidents` Public incident feed, newest first. Auth: `public` Parameters: - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) - `shard` (query, Shard, optional) - `status` (query, IncidentStatus, optional) - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Responses: - `200` Page of incidents. - `400` ### GET /v1/incidents/{incidentId} operationId: `getIncident` One public incident with all updates. Auth: `public` Parameters: - `undefined` (undefined, unspecified, optional) Responses: - `200` Incident. → Incident - `404` ## account Public mirror of on-chain account state plus the derived quota. ### GET /v1/me operationId: `getMe` The accounts the token's address can open in the dashboard. Lists the accounts the token's address owns or is an authorised sender of. Each account's `role` picks the full dashboard (`role: owner`) or the read-only authorised-sender mode (`role: sender`). Auth: `native-auth` Responses: - `200` Identity view of the token address. → Me - `401` - `403` - `429` ### GET /v1/account/{erd} operationId: `getAccount` Mirror of the on-chain account record (credits, escrow, plan blocks, flags). Pure chain mirror, therefore public. Auth: `public` Parameters: - `undefined` (undefined, unspecified, optional) Responses: - `200` Account mirror. → Account - `404` ### GET /v1/account/{erd}/quota operationId: `getQuota` Derived service state of an account ("halted" lives here, never on-chain). Evaluated in shard-1 chain time. `{erd}` may also be an authorised sender. The response then describes the account that would be billed for that sender, and `resolvedFrom` says how that account was found. Auth: `public` Parameters: - `undefined` (undefined, unspecified, optional) Responses: - `200` Quota. An address without an account answers 200 with `state = NO_ACCOUNT`. → Quota - `429` ### GET /v1/account/{erd}/senders operationId: `listSenders` On-chain authorised senders of the account (mirror of `senderAdded` / `senderRemoved`). Auth: `public` Parameters: - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Responses: - `200` Page of senders, oldest authorisation first (the serving order). - `404` ### GET /v1/account/{erd}/purchases operationId: `listPurchases` Money timeline of the account - deposits, plan purchases, renewals, skipped renewals, escrow moves, PAYG settlements, sender fees, grants. Built only from finalised contract events, so it is public. Newest first. Auth: `public` Parameters: - `undefined` (undefined, unspecified, optional) - `kind` (query, PurchaseKind[], optional) — Comma-separated filter. - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Responses: - `200` Page of purchase events (`format=csv` streams all matching rows without a cursor). - `404` ## dashboard Private read models of an account (usage, latency, notices, served-by relayers, outages). ### GET /v1/account/{erd}/notices operationId: `listNotices` Pollable notices - account notices plus global ones (tariff, RU schedule, incidents). Auth: `native-auth`, `sponsor-key` Parameters: - `undefined` (undefined, unspecified, optional) - `sinceSeq` (query, integer (int64), optional) — Return notices with `seq` greater than this value (agents poll with the last seen seq). - `unacknowledged` (query, boolean, optional) - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Responses: - `200` Page of notices, newest first. - `401` - `403` ### POST /v1/account/{erd}/notices/ack operationId: `ackNotices` Acknowledge notices up to a sequence number (idempotent). Auth: `native-auth`, `sponsor-key` Parameters: - `undefined` (undefined, unspecified, optional) Request body (required): - `upToSeq` (integer (int64), required) Responses: - `204` Acknowledged. - `401` - `403` ### GET /v1/account/{erd}/relayers operationId: `listServingRelayers` "Their relayers" under just-in-time assignment = the relayers that served this account, with counts and on-chain state. Auth: `native-auth`, `sponsor-key` Parameters: - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Responses: - `200` Relayers that served the account in the window (default last 30 days). - `401` - `403` ### GET /v1/account/{erd}/outages operationId: `listAccountOutages` Public incidents that overlapped the account's traffic, with the account's own impact numbers. Auth: `native-auth`, `sponsor-key` Parameters: - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Responses: - `200` Page of outages. - `401` - `403` ### GET /v1/usage operationId: `listUsage` Full transaction history of an account - every terminal intent incl. `DEAD` - with per-transaction latency breakdown in ms. The account's transaction history, one row per transaction hash. It holds **every terminal intent, including `DEAD` ones** (with `deadReason`). `billed` says whether the transaction was billed; the billing members are null when it was not. Rows are keyed by transaction hash. A `DEAD` intent that was replaced by a cancel or a re-sign shares its `(sender, senderNonce)` slot with the replacement that executed, and only the executed one is billed. The replaced transaction stays in the history with `status: dead` and `billed: false`. `includeInFlight=true` puts the account's non-terminal intents first (flagged `inFlight`), plus the `REJECTED` submissions of the last 30 days (`state: REJECTED`, `rejectCode`, never a signature). Order: `executedBlockTsMs` descending (the terminal timestamp for rows without a block), then `txHash`. Auth: `native-auth`, `sponsor-key` Parameters: - `account` (query, Address, required) - `sender` (query, Address, optional) - `relayer` (query, Address, optional) - `status` (query, UsageStatus[], optional) — Comma-separated terminal statuses (`dead` = terminal without execution, never billed). - `state` (query, IntentState[], optional) — Comma-separated intent states; only meaningful with `includeInFlight=true` (in-flight and `REJECTED` rows). - `shard` (query, Shard[], optional) — Comma-separated sender shards. - `minInclusionMs` (query, integer, optional) — Only rows whose `latency.inclusionMs` is at least this value (slow-transaction filter). - `q` (query, string, optional) — Prefix match on `txHash`, `sender`, `receiver` or `function` (3 to 64 characters, treated as text). - `billingClass` (query, BillingClass, optional) - `periodId` (query, integer, optional) - `apiKeyId` (query, string, optional) - `includeInFlight` (query, boolean, optional) - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Responses: - `200` Page of usage rows. - `400` - `401` - `403` ### GET /v1/usage/summary operationId: `getUsageSummary` Usage versus cap - period totals plus a bucketed time series (RU, tx count, fail count, latency percentiles), top senders and receivers. RU and money totals count **billed rows only** (`billed = true`). `DEAD` and `REJECTED` rows appear in `totals.txCountByStatus` and nowhere else, so the totals always equal the sum of the `GET /v1/usage` rows with `billed = true`. Grouped latency analytics are served by `GET /v1/usage/latency`. Auth: `native-auth`, `sponsor-key` Parameters: - `account` (query, Address, required) - `periodId` (query, integer, optional) — Default = current period. - `bucket` (query, "hour" | "day", optional) - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Responses: - `200` Summary. → UsageSummary - `401` - `403` ### GET /v1/usage/latency operationId: `getUsageLatency` Latency analytics of an account - percentiles, server-side histogram bins and sampled points per group (shard, relayer, sender or gateway). Served from pre-aggregated per-minute and per-hour latency histograms, grouped by `relayer`, `sender` or `first_ack_gateway`. `samples[]` holds at most 48 sampled points per group, for a strip plot. Only measured values are returned: a group without data is absent, never estimated. Synthetic probes are excluded. Auth: `native-auth`, `sponsor-key` Parameters: - `account` (query, Address, required) - `window` (query, "1h" | "24h" | "7d" | "30d", optional) — Look-back window ending now. `1h` and `24h` read the 1-minute rollup, `7d` and `30d` the 1-hour rollup. - `groupBy` (query, "shard" | "relayer" | "sender" | "gateway", optional) Responses: - `200` Grouped latency. → UsageLatency - `400` - `401` - `403` - `429` ### GET /v1/usage/{txHash} operationId: `getUsageRow` One row of the transaction history: the billing details when it was billed, the latency breakdown, the settlement batch and the gateway acknowledgements. Auth: `native-auth`, `sponsor-key` Parameters: - `undefined` (undefined, unspecified, optional) Responses: - `200` Usage row. → UsageRow - `401` - `403` - `404` ### GET /v1/usage/{txHash}/proof operationId: `getUsageProof` Merkle inclusion proof of a ledger row against the on-chain `usage_root` of its settlement batch. Auth: `native-auth`, `sponsor-key` Parameters: - `undefined` (undefined, unspecified, optional) Responses: - `200` Proof. Verifiable with SHA-256 only, against the `usageSettled` event of `batchId`. → UsageProof - `401` - `403` - `404` - `409` Row exists but is not yet committed in a root (next root-only batch is at most 3 600 000 ms away). → Problem ## keys Sponsor API keys. ### GET /v1/account/{erd}/keys operationId: `listApiKeys` Sponsor API keys of the account (secrets are never returned again). Auth: `native-auth` Parameters: - `undefined` (undefined, unspecified, optional) Responses: - `200` Keys. - `401` - `403` ### POST /v1/account/{erd}/keys operationId: `createApiKey` Mint a sponsor API key. The secret is shown exactly once. Auth: `native-auth` Parameters: - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Request body (required): ApiKeyCreate (see /openapi.yaml) Responses: - `201` Created. → ApiKeyCreated - `401` - `403` - `409` - `422` ### PATCH /v1/account/{erd}/keys/{keyId} operationId: `updateApiKey` Change label or policy of a key. Auth: `native-auth` Parameters: - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Request body (required): ApiKeyUpdate (see /openapi.yaml) Responses: - `200` Updated key. → ApiKey - `401` - `403` - `404` - `422` ### DELETE /v1/account/{erd}/keys/{keyId} operationId: `revokeApiKey` Revoke a key. Effective on every host within 2 000 ms; in-flight reservations complete and bill the account. Auth: `native-auth` Parameters: - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Responses: - `204` Revoked. - `401` - `403` - `404` ## notifications Webhooks, e-mail preferences, SSE stream. ### GET /v1/account/{erd}/webhooks operationId: `listWebhooks` Webhook endpoints of the account. Auth: `native-auth` Parameters: - `undefined` (undefined, unspecified, optional) Responses: - `200` Webhooks. - `401` - `403` ### POST /v1/account/{erd}/webhooks operationId: `createWebhook` Register an HTTPS webhook endpoint. The signing secret is shown exactly once. The URL must use https on port 443 and resolve to a public unicast address, which is checked again on every connection. Redirects are not followed. Any other URL gives `WEBHOOK_URL_NOT_ALLOWED`. Auth: `native-auth` Parameters: - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Request body (required): WebhookCreate (see /openapi.yaml) Responses: - `201` Created, includes `secret`. → WebhookCreated - `401` - `403` - `409` - `422` ### GET /v1/account/{erd}/webhooks/{webhookId} operationId: `getWebhook` One webhook endpoint. Auth: `native-auth` Parameters: - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Responses: - `200` Webhook. → Webhook - `401` - `403` - `404` ### PATCH /v1/account/{erd}/webhooks/{webhookId} operationId: `updateWebhook` Change URL, event types or enabled flag. Auth: `native-auth` Parameters: - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Request body (required): WebhookUpdate (see /openapi.yaml) Responses: - `200` Updated webhook. → Webhook - `401` - `403` - `404` - `422` ### DELETE /v1/account/{erd}/webhooks/{webhookId} operationId: `deleteWebhook` Delete a webhook endpoint. Auth: `native-auth` Parameters: - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Responses: - `204` Deleted. - `401` - `403` - `404` ### POST /v1/account/{erd}/webhooks/{webhookId}/test operationId: `testWebhook` Send a signed `webhook.test` event now and return the delivery result. Auth: `native-auth` Parameters: - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Responses: - `200` Delivery attempt. → WebhookDelivery - `401` - `403` - `404` - `429` ### POST /v1/account/{erd}/webhooks/{webhookId}/rotate-secret operationId: `rotateWebhookSecret` Issue a new signing secret. The old secret stays valid for 86 400 000 ms; deliveries carry both signatures meanwhile. Auth: `native-auth` Parameters: - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Responses: - `200` New secret, shown once. → WebhookCreated - `401` - `403` - `404` ### GET /v1/account/{erd}/webhooks/{webhookId}/deliveries operationId: `listWebhookDeliveries` Delivery log of the last 30 days, newest first. Auth: `native-auth` Parameters: - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) - `undefined` (undefined, unspecified, optional) Responses: - `200` Page of deliveries. - `401` - `403` - `404` ### GET /v1/account/{erd}/notifications operationId: `getNotificationPrefs` Notification preferences (thresholds, e-mail channel, per-kind switches). Auth: `native-auth` Parameters: - `undefined` (undefined, unspecified, optional) Responses: - `200` Preferences. → NotificationPrefs - `401` - `403` ### PUT /v1/account/{erd}/notifications operationId: `putNotificationPrefs` Replace notification preferences. A new e-mail address stays `pending` until its verification link is opened. Auth: `native-auth` Parameters: - `undefined` (undefined, unspecified, optional) Request body (required): NotificationPrefsUpdate (see /openapi.yaml) Responses: - `200` Stored preferences. → NotificationPrefs - `401` - `403` - `422` ### POST /v1/stream/tickets operationId: `createStreamTicket` Single-use ticket for clients that cannot send an Authorization header (native EventSource). TTL 30 000 ms. Auth: `native-auth`, `sponsor-key` Request body (required): - `account` (Address, required) - `topics` (StreamTopic[], optional) Responses: - `201` Ticket. - `401` - `403` - `429` ### GET /v1/stream operationId: `streamAccount` Server-sent events for one account (intents, quota, account mirror, notices, incidents, pricing). Auth: send the `Authorization` header from a streaming HTTP client. From a browser `EventSource`, use a single-use `ticket` query parameter instead. A native-auth token is never accepted in a URL. Event names are the `StreamTopic` values plus `reset`, and every client must handle `reset`. `data:` is one `StreamEvent` JSON object. `id:` is `..`: `hostId` is `a` or `b` (the core host), `bootEpoch` is the start time of that API process in Unix ms, and `seq` counts this account's events on that host. `Last-Event-ID` replays up to 300 000 ms or 1 000 events. The server sends `event: reset` first when the `Last-Event-ID` is older than that window, comes from the other API host, or comes from before a server restart. On `reset`, reload your data. Each host numbers its own events, so an ID from one host cannot be resumed on the other. The server coalesces at 4 to 10 Hz and sends a comment line every 15 000 ms as keep-alive. Auth: `native-auth`, `sponsor-key`, `ticket` Parameters: - `account` (query, Address, optional) — Required with header auth; implied by the ticket otherwise. - `topics` (query, StreamTopic[], optional) — Comma-separated; default all. - `undefined` (undefined, unspecified, optional) Responses: - `200` Event stream of `StreamEvent` objects. - `401` - `403` - `429` ## compat Drop-in facade for OpenClaw / Moltbot starter-kit agents. Unversioned and frozen. ### GET /health operationId: `compatHealth` Compat health probe of the OpenClaw relayer interface. Auth: `public` Responses: - `200` OK. ### GET /relayer/address/{userAddress} operationId: `compatRelayerAddress` Deterministic, long-lived relayer address for a sender (rendezvous hash over the shard's active relayers). No lease. Auth: `public` Parameters: - `userAddress` (path, Address, required) Responses: - `200` Relayer address. - `404` Invalid address. Body keeps the kit's `error` string and is also a valid problem document. → CompatError ### POST /relay operationId: `compatRelay` Compat relay. Accepts a transaction naming any of our active or draining same-shard relayers. First receipt only. The signed transaction is the only credential. Limits are stricter than on `POST /v1/relay`: 1 RU/s per sender with a burst of 5, `gasPrice == min`, simulation always on, `gasLimit <= 60 000 000`, and no replacement modes. The handler takes an internal lease of 5 000 ms inside the request. Only the statuses 200/400/403/404/429/500 are emitted. `challengeNonce` is accepted and ignored. Auth: `tx` Request body (required): - `transaction` (TransactionPlain, required) - `challengeNonce` (string, optional) — Ignored. Kept so unmodified kit clients validate. Responses: - `200` Co-signed. `txHash` is returned as soon as the commit point is passed. - `400` Any validation failure, and `RELAYER_RETIRED` (body explains clearing `.relayer_cache.json`). → CompatError - `403` `NO_ENTITLEMENT` or `SENDER_NOT_AUTHORIZED`. → CompatError - `429` `QUOTA_EXHAUSTED`, `RATE_LIMITED`, `GAS_BUDGET_EXCEEDED`, `TOO_MANY_IN_FLIGHT`. `error` starts with "Quota exceeded" for quota cases. → CompatError - `500` Everything else (including 503-class conditions of the native path). → CompatError