Skip to main content

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. Helpers cover relaying with one signature, checking the relayer on chain, native auth, paged lists, event streams, CSV exports, webhooks and x402 payments.

Install​

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, and Pay for your users 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​

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.

Before you start: try it on devnet

Devnet runs the same code as mainnet, with test tokens, so you can try everything before you pay with real money.

  1. Open the devnet dashboard and connect a devnet wallet.
  2. Get test EGLD from the faucet of the devnet web wallet, and swap some of it for USDC-350c4e on the devnet xExchange.
  3. Buy a plan on the Plan screen, paid in USDC-350c4e. To pay for your users, pick Builder or above.
  4. 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.

Authentication​

You pass a key, or a function the client calls before each request, and the client adds the right header.

Who is callingOptionSent as
Your server paying for your users (a sponsor key), or an agent with an API keyapiKeyX-Api-Key header, on the relay and the account reads only
An app acting for a signed-in walletnativeAuthTokenAuthorization: Bearer <token>

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.

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.

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<string>,
): Promise<string> {
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 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.

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<TransactionPlain>,
): Promise<void> {
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.

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<string>;
signTransaction(transaction: SignOnceInput['transaction']): Promise<TransactionPlain>;
}

/** 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<RelayOnceResult> {
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:

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. The whole file is on Pay for your 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 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.

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.

Handling errors​

ErrorWhenWhat to do
ApiErrorThe 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.
TransportErrorNo 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.
SignedNonceMismatchErrorThe 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.
RelayerVerificationErrorThe 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.
CursorLoopErrorAn ...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.
EventTooLargeErrorAn 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.
WebhookErrorverifyWebhook 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.

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, and 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.
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<void> {
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.

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<void> {
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 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.

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<Uint8Array>;
await pipeline(Readable.fromWeb(body), createWriteStream(path));
}

In a browser, await new Response(exported.body).blob() gives you the file to download.

Webhooks​

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

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<void>,
) {
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 <id>.<timestamp>.<raw body>, 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 has a complete client.

Configuration​

OptionDefaultWhat it does
baseUrlrequiredThe 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.
failoverAfterMs1,500 msHow long the main host may take before the next host is tried. Used only with directHosts.
timeoutMs15,000 msThe time limit of one attempt on a direct host, or on the main host when there are none.
apiKeynoneSent 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.
nativeAuthTokennoneA function returning the token, sent as Authorization: Bearer.
headersnoneHeaders added to every request.
maxResponseBytes33,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.
maxEventBytes8,388,608 bytes (8 MiB)The largest event an event stream accepts.
fetchglobalThis.fetchThe 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:

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.