Skip to main content

Python

corelayer-sdk is the Python client for the CoRelayer API. You import it as corelayer. It needs Python 3.11 or later and depends only on httpx. Every API operation is a typed method, on a synchronous Client and an asyncio AsyncClient, and helpers cover relaying with one signature, checking the relayer on chain, native auth, event streams, webhooks and x402.

Install​

Not on PyPI yet

Once the package is published, install it with pip 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.

Quick start​

from corelayer import Client

with Client("https://api.co-relayer.com") as client:
network = client.get_network().data
print("chain:", network["chainId"], "min gas price:", network["minGasPrice"])

For devnet, use https://devnet-api.co-relayer.com. get_network() also lists the regional direct hosts: pass network.get("directHosts", []) as direct_hosts, and the client fails over to them when the main host is slow or down.

Every method returns a Response with data, status, headers, request_id and rate_limit_remaining. The models are TypedDicts keyed by the API's member names, so data is a plain dict and your type checker knows its members. AsyncClient has the same methods as coroutines. With the default HTTP client, one Client can be shared between threads.

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 keyapi_keyX-Api-Key header, on the relay and the account reads only
An app acting for a signed-in walletnative_auth_tokenAuthorization: Bearer <token>

Public routes such as get_network need none of them, and the docstring of each method says which credentials it accepts. Keep an API key on your server, and never ship it in a browser or mobile app. native_auth_token takes a function, so a token you refresh is used on the next call. On AsyncClient the function may be a coroutine function.

An agent can build its own native-auth token. The package gives you the message to sign, and your key or wallet signs it:

from collections.abc import Callable

from corelayer import (
MAX_TTL_SECONDS,
Client,
NativeAuthBody,
compose_native_auth_token,
encode_native_auth_body,
native_auth_sign_payload,
)


def agent_token(client: Client, address: str, sign_message: Callable[[str], str]) -> str:
"""Builds a native-auth token for `address`. `sign_message` signs a message the way a
MultiversX wallet's `signMessage` does, with the signed-message prefix a token needs (unlike a
presence proof), and returns the signature as 128 hex characters."""
block = client.get_network().data.get("nativeAuth") # a recent shard-1 block
origin = block.get("origin") if block is not None else None
if block is None or origin is None:
raise RuntimeError("the API returned no native-auth block")
body = encode_native_auth_body(
NativeAuthBody(origin=origin, block_hash=block["blockHash"], ttl_seconds=MAX_TTL_SECONDS)
)
signature = sign_message(native_auth_sign_payload(address, body))
return compose_native_auth_token(address, body, signature)

decode_native_auth_token takes a token apart, and check_native_auth_token checks the rules that can be checked locally: origin, lifetime, formats, extraInfo, 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. Authentication describes each method in full.

Relaying a transaction​

relay_once runs the whole flow for one user action. It asks the API for a relayer, runs your verify_relayer check, calls your build_transaction, asks your sign_once for one signature, checks that the wallet signed the nonce you built, and submits the transaction.

from corelayer import (
Assignment,
Client,
Intent,
Relayed,
RelayerVerifier,
SignOnce,
UnsignedTransactionPlain,
relay_once,
)

CHAIN_ID = "1" # mainnet; devnet is "D"


def mainnet_verifier(registry: str) -> RelayerVerifier:
"""Create the verifier once and share it: it caches the relayers it has confirmed."""
return RelayerVerifier(
"https://gateway.multiversx.com", # a gateway CoRelayer does not run
registry, # the CoRelayer contract address, from your own configuration
CHAIN_ID,
)


def relay_transfer(
client: Client,
verifier: RelayerVerifier,
sender: str,
receiver: str,
nonce: int,
intent_key: str,
sign: SignOnce,
) -> Intent:
"""Sends 0.001 EGLD. `intent_key` is one key per user action, 16 to 128 characters. Reuse it
when you retry that action."""

def build(assignment: Assignment) -> UnsignedTransactionPlain:
return {
"nonce": nonce,
"value": "1000000000000000", # 0.001 EGLD
"sender": sender,
"receiver": receiver,
"relayer": assignment["relayer"],
"gasPrice": assignment["minGasPrice"],
"gasLimit": 50_000 + assignment["extraGasRelayed"],
"chainID": CHAIN_ID,
"version": 2,
}

result = relay_once(
client,
sender=sender,
intent_key=intent_key,
verify_relayer=verifier.hook(sender),
build_transaction=build,
sign_once=sign,
)
if not isinstance(result, Relayed):
# Nothing was sent. Ask the user before signing again.
raise RuntimeError(f"a new signature is needed: {result.error.code}")
# One failed read does not mean the transaction failed: wait 2 s and ask again, up to 5 times.
return client.wait_for_intent(
sender,
result.signed["nonce"],
on_error=lambda error, failures: 2000 if failures < 5 else None,
)

The verifier asks a MultiversX gateway for the relayer's state in the CoRelayer contract, refuses unless it is Active, and checks that the relayer is in the sender's shard. Use a gateway CoRelayer does not run, and pin the contract address and the chain ID in your own configuration. Answers are cached per chain ID, registry version and relayer, and one verifier can be shared between threads. See Verify a relayer.

The signer is called at most once. A second call raises RelayOnceError with the code signer-called-twice, even when two threads call it at the same time. If the wallet signed a different nonce, you get a SignedNonceMismatchError and nothing is sent. relay_once_async does the same with an AsyncClient and an AsyncRelayerVerifier, and its builder, signers and relayer check may be coroutine functions. Cancelling the task cancels the call, and a cancelled request is never sent to another host.

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, pass sign_proof: a function that receives the message to sign and returns the sender key's raw Ed25519 signature over the message's UTF-8 bytes, as 128 hex characters. Sign the bytes themselves, not through a wallet's signMessage: that adds the MultiversX message prefix, and the API answers ASSIGN_PROOF_INVALID. relay_once then signs a fresh proof for every assign call it makes:

  • For the first assign, it reads chainId and serverTimeMs from get_network() and signs assign_proof_message(chainId, sender, serverTimeMs). That costs one extra request, which is not made when you pass assignment.
  • For the renewal of an expired lease, it takes chainId from the assignment. It estimates the API's time as the assignment's serverTimeMs plus the milliseconds that have passed since the assignment arrived, measured with time.monotonic.

The proofs carry the kind "key" by default. In sponsor mode, pass proof_kind="sponsor": the sender's key still signs the proof, and the sponsor key goes on the relay call only. With relay_once_async, sign_proof may be a coroutine function.

from collections.abc import Callable

from corelayer import (
Assignment,
Client,
RelayOnceResult,
TransactionPlain,
UnsignedTransactionPlain,
relay_once,
)


def relay_as_agent(
client: Client,
sender: str,
sign_proof_message: Callable[[str], str],
sign_transaction: Callable[[UnsignedTransactionPlain], TransactionPlain],
build: Callable[[Assignment], UnsignedTransactionPlain],
intent_key: str,
) -> RelayOnceResult:
"""Relays one transaction for a program that holds the sender's key and has no native-auth
token. `sign_proof_message` signs the raw UTF-8 bytes of a message with that key, with no
MultiversX message prefix, and returns the signature as hex."""
return relay_once(
client,
sender=sender,
intent_key=intent_key,
sign_proof=sign_proof_message,
build_transaction=build,
sign_once=lambda signer_input: sign_transaction(signer_input.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. relay_once never sends it a second time, so when the lease expires before the submit it raises the LEASE_EXPIRED ApiError as the API sent it. Pass sign_proof to have the lease renewed for you. relay_once refuses proof and sign_proof together with a RelayOnceError (code proof-with-signer) 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 api_key, 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:

# 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"]

res.data["account"] is your account, and res.data["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 relay_once, with proof_kind="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 API says it can be renewed, relay_once renews it for the same relayer and submits the same signed bytes again. It never asks for a second signature by itself. The renewal carries a fresh proof from sign_proof, or no proof when the client has a native-auth token for the sender.

When the API asks for a new signature​

If the relayer becomes unavailable before it co-signs, the API answers RESIGN_REQUIRED or RESIGN_SAME_NONCE, and relay_once returns a ResignRequired instead of raising. Nothing was sent. Ask the user. If they agree, call relay_once again with assignment=resign.next_assignment, a min_gas_price of resign.min_gas_price and a transaction built for resign.pinned_nonce, keeping the same intent_key. relay_once raises RelayOnceError before asking the wallet if the nonce or the gas price would not fit. Handle a re-sign request explains when this happens. relay_once never signs again by itself.

With sign_proof, a renewal of that lease dates its proof from the lease's serverTimeMs plus the time since you called relay_once. The time before the call is not counted, and that includes the time the user takes to decide. If more than 30,000 ms pass between the API's answer that carried the lease and your call, the API refuses the renewal proof and relay_once raises the ASSIGN_PROOF_INVALID ApiError.

Waiting for the result​

wait_for_intent reads the intent every 1,000 ms (interval_ms) and stops at EXECUTED_OK, EXECUTED_FAIL, DEAD or REJECTED. After 120,000 ms (timeout_ms) it stops anyway and returns the last intent it read, so check state. If no read has succeeded by then, it raises the last read's error.

Before that time, a failed read is raised unless you pass on_error. It receives the error and the number of failed reads in a row, and returns the milliseconds to wait before the next read (at least interval_ms), or None to raise the error. The time limit also ends a run of failed reads, so an on_error that always returns a number cannot keep the wait going forever. On AsyncClient, cancelling the task ends the wait at once, also while it waits between reads. stream_relay gives the same updates as events. See Watch an intent.

Handling errors​

Every error this package raises derives from CoRelayerError. Errors raised by your own functions (the signer, the builder, a token source) reach you unchanged.

ErrorWhenWhat to do
ApiErrorThe API answered with a status that is not 2xx, a 3xx included.Branch on code. For a code you do not know, go by status and retryable.
TransportErrorNo host answered: a network failure, a timeout, a 2xx that was not JSON, or an answer larger than max_response_bytes, on every host tried.The request may or may not have arrived. After a relay, read the intent with get_intent, or send the same signed bytes again. Never sign again because of it.
DecodeErrorA 2xx without the documented body.Do not retry: the API did answer.
InvalidInputErrorAn argument cannot be sent, such as an empty required query or header parameter. Path parameters are not checked.Fix the call. Nothing was sent.
RelayOnceErrorrelay_once refused its arguments.Fix the call. code names the check.
SignedNonceMismatchErrorThe wallet signed a different nonce than the one you built. Nothing was sent.Read the intent for the nonce you built with get_intent 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.
from corelayer import ApiError, ends_slot, needs_payment


def next_step(error: Exception) -> str:
if not isinstance(error, ApiError):
return f"not an API answer: {error}"
if needs_payment(error): # NO_ENTITLEMENT, QUOTA_EXHAUSTED
return "buy a plan or top up first"
if ends_slot(error): # NONCE_TOO_LOW, INTENT_ALREADY_EXECUTED
return "this nonce already has an outcome: read it with get_intent"
if error.needs_new_signature:
return "ask the user before signing again"
if error.can_resubmit_same_bytes:
wait = error.retry_after or 1.0 # seconds
return f"send the same signed bytes again in {wait} s"
if error.retryable:
return "try again later"
return f"{error.code} (HTTP {error.status}): {error}. Request ID for support: {error.request_id}"

The API can add error codes at any time, so always keep a fallback. Errors and retries covers what each case means.

An error answer without a problem document, such as an HTML page from a proxy, becomes an ApiError with the code UPSTREAM_UNAVAILABLE and the answer's status. Its retryable is true only for 408, 425, 429 and 5xx.

Other endpoints​

Every API operation is a method, named after the operation in snake case: get_account, list_usage, create_webhook and so on. Path parameters come first. Everything else is keyword-only and snake-cased (from_ms, api_key_id). A paged list also has an ..._all method that iterates over every item, and an exportable list a ..._csv method that streams the CSV.

from typing import BinaryIO

from corelayer import Client


def report(client: Client, account: str, out: BinaryIO) -> None:
# A paged list: the iterator fetches the next page when the loop needs it.
for row in client.list_usage_all(account=account):
print(row["txHash"], row["ru"])

# An event stream: iterate it for events, and close it when you are done.
with client.stream_account(account=account) as stream:
for event in stream:
print(event.event, event.data, stream.last_event_id)
break

# A CSV export: the body arrives in chunks and is never held in memory whole.
with client.list_usage_csv(account=account) as export:
for chunk in export.iter_bytes():
out.write(chunk)

An event stream does not reconnect by itself. To resume, wait stream.retry milliseconds when the server set it, then open a new stream with last_event_id=stream.last_event_id. The account stream replays up to 300,000 ms or 1,000 events. When your ID is older than that, comes from the other API host or is from before a server restart, the stream starts with a reset event: reload your data then. An event larger than 8 MiB raises EventTooLargeError.

A CSV export has 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. Where the API tells an absent member apart from null, the type says NotRequired[T | None]: leave the key out to keep the current value, or set it to None to clear it.

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.

from collections.abc import Mapping

from corelayer import WebhookError, verify_webhook


def handle_delivery(headers: Mapping[str, str], body: bytes, secrets: list[str], seen: set[str]) -> int:
"""Returns the HTTP status to answer the delivery with."""
try:
event = verify_webhook(headers, body, secrets=secrets)
except WebhookError:
return 400 # do not act on it
if event["id"] in seen:
return 204 # a retry of a delivery you already handled
seen.add(event["id"])
print(event["type"], event["account"], event["data"])
return 204

verify_webhook checks the CoRelayer-Webhook-Signature header, the hex HMAC-SHA256 of <id>.<timestamp>.<raw body>, and refuses a timestamp more than 300,000 ms from now. Pass the body bytes as received, before any parsing. After a secret rotation, pass both secrets for 24 hours. 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.

x402 payments​

For x402 purchases, x402_topup and x402_purchase first answer 402, which the client raises 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 payment_required_of(error), which reads what to pay from the error and returns None when the error carries no challenge. encode_payment_signature builds the PAYMENT-SIGNATURE header for the paid request (the payment_signature argument), and decode_payment_response reads the settlement receipt. Check the requirement's payTo, asset and amount against your own configuration before you sign.

Configuration​

Client and AsyncClient take the same options. Only base_url is positional.

OptionDefaultWhat it does
base_urlrequiredThe API origin: https://api.co-relayer.com, or https://devnet-api.co-relayer.com for devnet.
direct_hostsnoneRegional hosts to fail over to, in order. Take them from get_network().
failover_after_ms1,500 msHow long the main host may take before the next host is tried. Used only with direct_hosts.
timeout_ms15,000 msThe time limit of one attempt on a direct host, or on the main host when there are none.
http_clienthttpxA SyncHttpClient (or AsyncHttpClient) for another HTTP stack or a test double. The default does not follow redirects.
headersnoneHeaders added to every request.
user_agentcorelayer-sdk-python/<version>The User-Agent header.
max_response_bytes32 MiBThe largest answer body read into memory.
api_keynoneSent 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.
native_auth_tokennoneReturns the user's native-auth token before each request.

How requests behave​

  • The request body is serialised once. If the main host does not answer within failover_after_ms (1,500 ms by default), fails at the network level, answers 5xx, or answers 2xx with a body that is not JSON, the same bytes go to the next direct host. A 4xx is an answer, so it is raised at once and never tried on another host.
  • A cancelled call is never retried, and apart from failover nothing is retried for you.
  • For a normal call the time limit covers the whole answer, body included. For event streams and CSV exports only the wait for the headers is timed.
  • Redirects are not followed: a 3xx is raised as an ApiError, so a signed body and your API key never go to a host you did not configure.
  • A buffered answer body is limited to max_response_bytes, 32 MiB by default. A larger one counts as a failed attempt, like a network failure, and is never truncated. Streams and exports are not buffered.
  • Every request carries a User-Agent of corelayer-sdk-python/<version>.
  • Times are Unix milliseconds and durations are milliseconds (...Ms members, ..._ms arguments).
  • 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 unless you set timeout_ms.