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) |
| 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 (key mode). |
A transfer the key cannot cover is refused with
RECEIVER_NOT_ALLOWED before anything is sent.
Devnet runs the same code as mainnet, with test tokens, so you can try everything before you pay with real money.
- Open the devnet dashboard and connect a devnet wallet.
- Get test EGLD from the faucet of the devnet web wallet, and swap some of it for
USDC-350c4eon the devnet xExchange. - Buy a plan on the Plan screen, paid in
USDC-350c4e. To pay for your users, pick Builder or above. - To pay for your users, create a sponsor key on the API keys screen. A devnet key starts with
crk_test_.
In your code, use https://devnet-api.co-relayer.com as the API origin.
1. Create a sponsor key
- Open API keys and choose Create a key.
- Name it, and turn on Relay — pay for transactions other addresses sign. The switch is available from Builder up.
- 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).
- 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.
- 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:
- asks for a relayer for the sender (
POST /v1/relay/assign) with a presence proof signed by the sender's key, markedproof.kind: "sponsor"; - builds the transaction for the relayer it was given;
- 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. If a signing service holds your users'
keys, it must be able to sign raw bytes.
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
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
// 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:
// 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
// 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
# 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:
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<string> {
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; 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). 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,
Go, Rust and 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:
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<string> {
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, which also
needs no SDK. The whole file:
/**
* 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<string> {
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<string, string> = {}) {
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<Answer> {
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 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 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 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 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 401 | The key is unknown, revoked, or from the other network. | Use a key made for this network. |
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 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 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
/**
* 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: sponsor keys next to named wallets, and how the payer of each transaction is decided.
- Authentication: every credential and the routes that take it.
- Key handling: how CoRelayer stores and checks keys.
- Check before you send: the pre-send check for a server that relays with a sponsor key.
- Tiers: which plans include sponsor keys.