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
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.
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.
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 <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
chainIdandserverTimeMsfromGET /v1/networkand signsassignProofMessage(chainId, sender, serverTimeMs). That costs one extra request, made withcache: '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
chainIdfrom the assignment. It estimates the server's time as the assignment'sserverTimeMsplus 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:
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
| 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.
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:
- The path parameters, such as
erdingetQuota(erd). - The request body, for an operation that takes one.
- An object with the query and header parameters, such as
ListUsageParamsforlistUsage. A parameter leftundefinedis not sent, and a list is sent as one comma-separated value. Header parameters have camelCase names:Idempotency-KeyisidempotencyKeyandLast-Event-IDislastEventId. - 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
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:
paymentRequiredOf(error)reads what to pay from the error'sPAYMENT-REQUIREDheader. Check the requirement'spayTo,assetandamountagainst your own configuration before you sign.encodePaymentSignature(payload)writes the signed payment as a header value. Send the same request again with it aspaymentSignature.- The answer is 200 with the result, or 202 with a payment to poll with
getX402Payment.decodePaymentResponsereads the settlement receipt from itsPAYMENT-RESPONSEheader.
decodePaymentRequired reads a PAYMENT-REQUIRED value you hold yourself. Buying over 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:
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
transportrequest,noFailover: truekeeps it on the main host. It does not setcredentials, 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.
waitForIntentandrelayOncealso check the signal between their steps. - Apart from failover, nothing is retried for you.
retryAfterMscomes from the problem'sdetails.retryAfterMs, or else from theRetry-Afterheader, 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
ApiErrorwith its status and theLocationit named. In a browser,fetchhides the redirect's details, and theApiErrorhas 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, whateverContent-Lengthsays. A larger answer fails the attempt on that host: the request moves to the next host, and after the last one it rejects withTransportError. - 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 withheaders. - 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.