Skip to main content

Go

github.com/Co-relayer/corelayer/packages/sdk-go (package corelayer) is the Go client for the CoRelayer API. It needs Go 1.24 or later and uses only the standard library. Every API operation is a typed method, and helpers cover relaying with one signature, checking the relayer on chain, native auth, event streams, webhooks and x402.

Install​

No release yet

Once a version is published, install it with go get github.com/Co-relayer/corelayer/packages/sdk-go. 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​

package main

import (
"context"
"fmt"
"log"

corelayer "github.com/Co-relayer/corelayer/packages/sdk-go"
)

func main() {
client, err := corelayer.NewClient(corelayer.ClientOptions{BaseURL: "https://api.co-relayer.com"})
if err != nil {
log.Fatal(err)
}
network, err := client.GetNetwork(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println("chain:", network.Data.ChainID, "min gas price:", network.Data.MinGasPrice)
}

For devnet, use https://devnet-api.co-relayer.com. GetNetwork also returns the regional direct hosts: pass network.Data.DirectHosts as ClientOptions.DirectHosts, and the client fails over to them when the main host is slow or down. A Client is safe for concurrent use, so create one and share it.

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, and the doc comment 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. NativeAuthToken is called before every request, so a token you refresh is used on the next call. Return "" when the user is signed out.

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

// agentToken builds a native-auth token for address. signMessage 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.
func agentToken(ctx context.Context, client *corelayer.Client, address string,
signMessage func(message string) (string, error)) (string, error) {
network, err := client.GetNetwork(ctx)
if err != nil {
return "", err
}
block := network.Data.NativeAuth // a recent shard-1 block
if block == nil || block.Origin == nil {
return "", errors.New("the API returned no native-auth block")
}
body, err := corelayer.EncodeNativeAuthBody(corelayer.NativeAuthBody{
Origin: *block.Origin,
BlockHash: block.BlockHash,
TTLSeconds: corelayer.MaxTTLSeconds,
})
if err != nil {
return "", err
}
signature, err := signMessage(corelayer.NativeAuthSignPayload(address, body))
if err != nil {
return "", err
}
return corelayer.ComposeNativeAuthToken(address, body, signature)
}

DecodeNativeAuthToken takes a token apart, and CheckNativeAuthToken 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​

RelayOnce runs the whole flow for one user action. It asks the API for a relayer, runs your VerifyRelayer check, calls your BuildTransaction, asks your SignOnce for one signature, checks that the wallet signed the nonce you built, and submits the transaction. With SignProof set, it reads GetNetwork before the first assign, for the presence proof's chain ID and time.

// mainnetVerifier checks relayers on mainnet. Create it once and share it: it is safe for
// concurrent use and caches the relayers it has confirmed.
func mainnetVerifier(registry string) *corelayer.RelayerVerifier {
return corelayer.NewRelayerVerifier(corelayer.RelayerVerifierOptions{
Gateway: "https://gateway.multiversx.com", // a gateway CoRelayer does not run
Contract: &registry, // the CoRelayer contract, from your own config
ChainID: "1",
})
}

// relayTransfer sends 0.001 EGLD. intentKey is one key per user action, 16 to 128 characters.
// Reuse it when you retry that action.
func relayTransfer(ctx context.Context, client *corelayer.Client,
verifier *corelayer.RelayerVerifier, sender, receiver string, nonce int64, intentKey string,
sign corelayer.SignOnce) (*corelayer.Intent, error) {
const chainID = "1" // mainnet; devnet is "D"
result, err := corelayer.RelayOnce(ctx, corelayer.RelayOnceOptions{
Client: client,
Sender: sender,
IntentKey: intentKey,
VerifyRelayer: verifier.Hook(sender),
BuildTransaction: func(a corelayer.Assignment) (corelayer.TransactionPlain, error) {
return corelayer.TransactionPlain{
Nonce: nonce,
Value: "1000000000000000", // 0.001 EGLD
Sender: sender,
Receiver: receiver,
Relayer: a.Relayer,
GasPrice: a.MinGasPrice,
GasLimit: 50_000 + a.ExtraGasRelayed,
ChainID: chainID,
Version: 2,
}, nil
},
SignOnce: sign,
})
if err != nil {
return nil, err
}
if resign := result.ResignRequired; resign != nil {
// Nothing was sent. Ask the user before signing again.
return nil, fmt.Errorf("a new signature is needed: %w", resign.Err)
}
return client.WaitForIntent(ctx, sender, result.Relayed.Signed.Nonce, corelayer.WaitOptions{
OnError: func(err error, failures int) (time.Duration, bool) {
return 2 * time.Second, failures < 5 // one failed read is not a failed transaction
},
})
}

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 a RelayerVerifier is safe for concurrent use. See Verify a relayer.

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 is enough. Without one, set SignProof: a function that signs a message with the sender's key and returns the raw Ed25519 signature over the message's bytes as 128 hex characters (ed25519.Sign over []byte(message)). Sign the bytes themselves, not through a wallet's signMessage: that adds 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 GetNetwork and signs AssignProofMessage(chainID, sender, serverTimeMs). That costs one extra request.
  • For the renewal of an expired lease, it takes ChainID from the assignment. It estimates the API's clock as the assignment's ServerTimeMs plus the milliseconds that have passed since the assignment arrived, measured on the monotonic clock.

The proofs carry kind key by default. In sponsor mode, set ProofKind: corelayer.AssignRequestProofKindSponsor: the sender's key still signs the proof, and the sponsor key goes on the relay call only.

// relayAsAgent relays one transaction for a program that holds the sender's key and has no
// native-auth token. The key signs the presence proofs; sign signs the transaction.
func relayAsAgent(ctx context.Context, client *corelayer.Client, key ed25519.PrivateKey,
intentKey string, build func(corelayer.Assignment) (corelayer.TransactionPlain, error),
sign corelayer.SignOnce) (*corelayer.RelayOnceResult, error) {
sender, err := corelayer.PublicKeyToAddress(key.Public().(ed25519.PublicKey))
if err != nil {
return nil, err
}
return corelayer.RelayOnce(ctx, corelayer.RelayOnceOptions{
Client: client,
Sender: sender,
IntentKey: intentKey,
SignProof: func(_ context.Context, message string) (string, error) {
return hex.EncodeToString(ed25519.Sign(key, []byte(message))), nil
},
BuildTransaction: build,
SignOnce: sign,
})
}

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 returns the LEASE_EXPIRED *APIError as the API sent it. Set 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:

// On your server. The sponsor key never reaches a browser.
client, err := corelayer.NewClient(corelayer.ClientOptions{
BaseURL: "https://api.co-relayer.com",
APIKey: mustEnv("CORELAYER_API_KEY"),
})
if err != nil {
log.Fatal(err)
}

// Your user signed tx. Your plan pays the network fee.
req := corelayer.RelayRequest{Tx: tx, Lease: lease}
opts := corelayer.RelayOptions{IntentKey: actionID}
res, err := client.Relay(ctx, req, opts)
if err != nil {
log.Fatal(err)
}

mustEnv comes from the same file:

// mustEnv stops the server at start-up when the sponsor key is not set.
// Without a key the client sends no X-Api-Key, and a relay from a funded
// wallet would be billed to that wallet's own account instead of your plan.
func mustEnv(name string) string {
value := os.Getenv(name)
if value == "" {
log.Fatalf("set %s to your sponsor key", name)
}
return value
}

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, give RelayOnce the sponsor client as Client, with ProofKind: corelayer.AssignRequestProofKindSponsor: 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 marks it as renewable, RelayOnce 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 SignProof, 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 RelayOnce returns ResignRequired instead of an error. Nothing was sent. Ask the user. If they agree, call RelayOnce again with Assignment: resign.NextAssignment, MinGasPrice: resign.MinGasPrice and a transaction built for resign.PinnedNonce. RelayOnce refuses before asking the wallet if the nonce or the gas price would not fit, and it never signs again by itself. Handle a re-sign request explains when this happens.

With SignProof, a renewal of that lease counts time from the moment you call RelayOnce, but the API issued the lease when it sent the RESIGN_REQUIRED or RESIGN_SAME_NONCE answer. Everything in between, including the time the user takes to decide, puts the proof behind the API's clock. If that is more than 30,000 ms, the API refuses the renewal with ASSIGN_PROOF_INVALID.

Waiting for the result​

WaitForIntent reads the intent once a second (Interval) until the transaction reaches EXECUTED_OK, EXECUTED_FAIL, DEAD or REJECTED. After two minutes (Timeout) it stops anyway and returns the last state it read, so check State. If no read has succeeded by then, it returns the last read's error.

Before that time, a failed read ends the wait unless you set OnError. It receives the error and the number of failures in a row, and returns how long to wait before the next read (at least Interval) and true, or false to stop with the error. Timeout also ends a run of failed reads, so an OnError that always returns true cannot keep the wait going forever. Cancelling the context ends the wait at once, also while it waits between reads. StreamRelay gives the same updates as events. See Watch an intent.

Handling errors​

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 MaxResponseBytes, on every host 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.
*DecodeErrorA 2xx whose JSON does not fit the expected type.Do not retry: the API did answer.
*RelayOnceErrorRelayOnce refused its options, for example Proof and SignProof together. Nothing was signed.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 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.
context.Canceled, context.DeadlineExceededYour context ended.A cancelled request is never sent to another host.
func nextStep(err error) string {
var apiErr *corelayer.APIError
if !errors.As(err, &apiErr) {
return "not an API answer: " + err.Error()
}
switch {
case corelayer.NeedsPayment(apiErr): // NO_ENTITLEMENT, QUOTA_EXHAUSTED
return "buy a plan or top up first"
case corelayer.EndsSlot(apiErr): // NONCE_TOO_LOW, INTENT_ALREADY_EXECUTED
return "this nonce already has an outcome: read it with GetIntent"
case apiErr.NeedsNewSignature():
return "ask the user before signing again"
case apiErr.CanResubmitSameBytes():
wait, _ := apiErr.RetryAfter()
return fmt.Sprintf("send the same signed bytes again in %v", wait)
case apiErr.Retryable():
return "try again later"
}
return fmt.Sprintf("%s (HTTP %d): %s. Request ID for support: %s",
apiErr.Code(), apiErr.Status, apiErr.Error(), apiErr.RequestID)
}

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

Other endpoints​

Every API operation is a method on Client, named after the operation: GetPricing, ListUsage, CreateWebhook and so on. Path parameters are arguments, and query and header parameters go in a ...Params struct, where optional ones are pointers (corelayer.Ptr makes one). Each method returns a *Response[T] with Data, Status, Header, RequestID, RateLimitRemaining and the raw Body.

func report(ctx context.Context, client *corelayer.Client, account string, out io.Writer) error {
// A paged list: the iterator fetches the next page when the loop needs it.
for row, err := range client.ListUsageAll(ctx, corelayer.ListUsageParams{Account: account}) {
if err != nil {
return err
}
fmt.Fprintln(out, row.TxHash, row.Ru)
}

// An event stream: only the wait for the headers is timed.
stream, err := client.StreamAccount(ctx, corelayer.StreamAccountParams{Account: &account})
if err != nil {
return err
}
defer stream.Close()
event, err := stream.Next()
if err != nil {
return err
}
fmt.Fprintln(out, event.Type, event.Data, stream.LastEventID())

// A CSV export. The body is streamed, so MaxResponseBytes does not limit it.
export, err := client.ListUsageCSV(ctx, corelayer.ListUsageParams{Account: account})
if err != nil {
return err
}
defer export.Close()
_, err = io.Copy(out, export.Body)
return err
}

An event stream does not reconnect by itself. To resume, open a new one with LastEventID set to stream.LastEventID(), after waiting stream.Retry() milliseconds when the server set it. The account stream replays up to 300,000 ms or 1,000 events. For an older ID, an ID from the other API host or one from before a restart, the server sends a reset event first: reload your data then. An EventStream is not safe for concurrent use.

ListUsageCSV and ListPurchasesCSV return at most 1,000,000 rows, with no cursor. A cell that starts with =, +, - or @ is prefixed with an apostrophe, so spreadsheet apps do not run it as a formula. A request member where "leave it as it is" and "clear it" differ is a Nullable[T].

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.

func webhookHandler(secrets []string, handle func(corelayer.WebhookEvent) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "cannot read the body", http.StatusBadRequest)
return
}
event, err := corelayer.VerifyWebhook(r.Header, body, corelayer.VerifyWebhookOptions{Secrets: secrets})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := handle(*event); err != nil {
http.Error(w, "try again later", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusNoContent)
}
}

VerifyWebhook 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 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, X402Topup and X402Purchase first answer 402. Its body is the payment challenge rather than a problem document, so the APIError has the code UPSTREAM_UNAVAILABLE. Recognise it by Status 402 and a result from PaymentRequiredOf, which reads what to pay from the error. EncodePaymentSignature builds the PAYMENT-SIGNATURE header for the paid request (the PAYMENTSIGNATURE parameter), and DecodePaymentResponse reads the settlement receipt. Check the requirement's PayTo, Asset and Amount against your own configuration before you sign.

Configuration​

All options are fields of ClientOptions.

OptionDefaultWhat it does
BaseURLrequiredThe API origin: https://api.co-relayer.com, or https://devnet-api.co-relayer.com for devnet.
DirectHostsnoneRegional hosts to fail over to, in order. Take them from GetNetwork.
FailoverAfter1,500 msHow long the main host may take before the next host is tried. Used only with DirectHosts.
Timeout15 sThe time limit of one attempt on a direct host, or on the main host when there are none.
HTTPClientDefaultHTTPClient()Anything with Do(*http.Request). The default does not follow redirects.
HeadersnoneHeaders added to every request.
UserAgentcorelayer-sdk-go/<version> (<go version>)The User-Agent header.
MaxResponseBytes32 MiBThe largest answer body read into memory. A negative value removes the limit.
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.
NativeAuthTokennoneReturns 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 FailoverAfter (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 comes back at once and is never tried on another host.
  • A cancelled request is never retried, and apart from failover nothing is retried for you.
  • Each attempt on a direct host, or on the main host when there are none, has Timeout, 15 s by default. For event streams and CSV exports only the wait for the headers is timed.
  • Redirects are not followed: a 3xx comes back as an *APIError that is not retryable, so a signed body and your API key never go to a host you did not configure.
  • A buffered answer body is limited to MaxResponseBytes, 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-go/<version> (<go version>).
  • Times are Unix milliseconds and durations are milliseconds (the ...Ms fields).
  • The client talks only to the CoRelayer API. The relayer verifier is the exception: it calls the gateway you give it, with a 5 s limit unless you set Timeout.