Skip to main content

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​

RingWhat you presentWhere it comes from
PublicnothingMirrors of chain state and published documents
Transactionthe signed transaction itselfPOST /v1/relay
Presence proofa signature over a short, timestamped messagePOST /v1/relay/assign
Native-autha MultiversX native-auth bearer tokenPrivate reads and every write about your account
Sponsor keyX-Api-Key: crk_<env>_<keyId>_<secret>Paying for other senders, server side only
x402PAYMENT-SIGNATURE headerPurchases
Stream ticketa single-use query parameterOne 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),
};
}
SignatureRaw Ed25519 by the sender's own key over the message bytes, for kind: "key" and kind: "sponsor" alike.
ValidityWithin 30,000 ms of the server's clock, and accepted once. Take serverTimeMs from GET /v1/network, not from your own clock.
Verified byThe signer, not just the API.
Chain bindingThe chain id is inside the message, so a proof captured on one network cannot mint a lease on another.
MissingASSIGN_PROOF_REQUIRED
WrongASSIGN_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 modeSponsor mode
Who paysThe sender's own account, or an account that listed it on chain (a named wallet)The account that owns the sponsor key
Proof at assignproof.kind: "key", made by the program that holds the sender's key, or a native-auth token from an origin CoRelayer acceptsproof.kind: "sponsor", made by your server with the sender's key: the same message and the same raw signature
Credential at relayThe signed transactionThe signed transaction, plus X-Api-Key from your server
LimitThe plan's named-wallet countNo sender limit; the key's policy applies
PlansAllBuilder, Growth, Scale, Enterprise, Agent Pro, Agent Fleet
billing.authModeown_account or authorized_senderapi_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:

sponsor-relay.ts
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:

RequirementWhy
Time to live at most one hourBounds the value of a leaked token.
The embedded block must be a shard-1 blockThe 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 matchThe dashboard origin for browsers; one fixed headless-agent origin for programs. Browser calls must also send a matching Origin header.
No impersonation or multisig extrasTokens carrying those fields are rejected outright.
The token address must equal the account you are asking aboutA 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:

PartContent
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.

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".

Scopesrelay (pay for arbitrary senders) and read (private reads of the account).
ShownOnce, at creation. Stored as a keyed hash; we cannot show it again.
Required policyA 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 policyFunction allow-list, per-sender daily unit limit, account daily unit limit, IP allow-list.
LimitTen keys per account.
RequiresThe tier flag that permits sponsoring arbitrary senders. Otherwise API_KEY_SCOPE.
Cannot doCreate, 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​

RouteAuth
/healthz, /readyz, /v1/network, /.well-known/x402public
GET /v1/pricing, /v1/pricing/tariff-historypublic
GET /v1/relayers, /v1/status, /v1/incidentspublic
GET /v1/relay/{id}, /v1/intents/{sender}/{nonce}, /v1/tx/{hash}public
POST /v1/quote, POST /v1/validatepublic, tightly rate-limited
POST /v1/*/preparepublic: they only build unsigned transactions
GET /v1/account/{erd}, /quota, /senders, /purchasespublic
POST /v1/relay/assignpresence proof or native-auth; the route does not read a sponsor key, which never proves presence
POST /v1/relaythe transaction, optionally plus a sponsor key
GET /v1/usage*, /notices, /outagesnative-auth or read key
/v1/account/{erd}/keys*native-auth only
/v1/account/{erd}/webhooks*, /notificationsnative-auth only
POST /v1/x402/topup, /v1/x402/purchasex402

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.