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_<env>_<keyId>_<secret> | 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)
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.
message = corelayer/assign/v1|<chainId>|<sender>|<serverTimeMs>
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. A signing service that holds
your users' keys must be able to sign raw bytes.
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<string>,
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 |
| Wrong | 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).
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:
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 compares the two modes in full, and Pay for your 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 <token>.
GET /v1/usage HTTP/1.1
Host: api.co-relayer.com
Authorization: Bearer <native-auth token>
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:
"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:
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 | <address><init> |
| The init | <base64url origin>.<block hash as hex>.<ttl seconds>.<base64url extraInfo JSON> |
| The token | <base64url address>.<base64url init>.<signature hex> |
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_<keyId>_<secret> X-Api-Key: crk_live_…
Your server sends it with each transaction it relays. Over plain HTTP:
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. |
| 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:
POST /v1/stream/tickets → { ticket, expiresAtMs }
GET /v1/stream?ticket=<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.