Skip to main content

Conventions

These hold for every route. Reading them once saves reading them thirteen times in the reference.

Media types and names​

Requests and responsesapplication/json, UTF-8
Errorsapplication/problem+json (RFC 9457)
JSON member namescamelCase. snake_case appears only when a contract name is being quoted.
Unknown members in a request bodyRejected on every write route.
Maximum request body131,072 bytes → PAYLOAD_TOO_LARGE
{erd} in a pathAlways a bech32 address.

Numbers​

This is the one convention that surprises people, so it is stated first and precisely.

WhereTypeWhy
atto-EGLD, anywheredecimal string, member ends in AttoIt does not fit a double.
Operational routes — relay, intents, quota, usage, dashboard — micro-USDC, Relay Units, gas, gas priceJSON integersBounded far below 2^53.
The pricing document family — GET /v1/pricing, the static mirror, POST /v1/quote, POST /v1/subscribe/prepare, x402 accepts[]decimal stringsThese documents are hashed and recomputed by agents in arbitrary languages. A float is not a price.

The rule is mechanical rather than a matter of taste: the schema types MicroUsdc and Ru are integers, DecimalString is a string. If you are parsing a price, parse a decimal.

Time​

EverythingUnix milliseconds as a JSON number, member name ending in Ms.
SecondsOnly where an external standard dictates: Retry-After, RateLimit-Reset, x402 maxTimeoutSeconds, native-auth TTL. Each has a millisecond twin in the body — details.retryAfterMs, extra.quoteExpiresAtMs, expiresAtMs.
Two clockschainTimeMs is the shard's block timestamp and decides every entitlement question and every ledger row. serverTimeMs is our wall clock and covers request timestamps and lease expiry. Responses that carry both name them.

Correct your own clock against serverTimeMs, not the other way round: the presence proof is valid for thirty seconds either side of it.

Identifiers​

Form
Intent id<sender>:<nonce>, e.g. erd1…:41
Quote idq_ + 10 Crockford base32 characters
Incidentinc_…
Webhookwh_…
API key id12 base32 characters
API keycrk_<env>_<keyId>_<secret>
Request idreq_…, returned in CoRelayer-Request-Id and as a problem document's instance

Headers​

On every response:

Header
CoRelayer-Request-IdThe log correlation id, and the instance of any problem document.
CoRelayer-VersionThe API build.

On rate-limited routes: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset.

From a client: CoRelayer-Client: <name>/<version> is appreciated and never used to refuse you. An old client is logged, not rejected — a refused relay is an outage for whoever is running it.

Idempotency​

RouteKeyBehaviour
POST /v1/relay, POST /relayNatural: (sender, nonce) + a hash of the signed bytesIdentical bytes → the stored response, duplicate: true. Different bytes on a live slot → NONCE_IN_FLIGHT.
POST /v1/relay (optional, recommended)Idempotency-Key header, or intentKey in the body — 16 to 128 visible ASCII charactersOne per logical action. Same key with different bytes or nonce while the first intent lives → INTENT_ALREADY_SUBMITTED.
x402 purchasesextensions["payment-identifier"].id, requiredSame id + same payload → the same result. Same id + different payload → 402 PAYMENT_INVALID.
POST on keys and webhooksIdempotency-Key, requiredSame key + same body → the stored response. Different body → IDEMPOTENCY_KEY_REUSED. Still running → IDEMPOTENCY_IN_PROGRESS.
POST /v1/relay/assign, the prepare routes, quote, validatenoneStateless. Calling twice gives two valid answers.
PUT, PATCH, DELETE, acknowledging notices—Idempotent by construction.
Webhook deliveriesThe event idAt-least-once. The consumer de-duplicates.

The client rule: on a timeout or a connection error from POST /v1/relay, re-send the identical bytes or query GET /v1/intents/{sender}/{nonce}. Never rebuild, never re-sign.

Pagination​

Cursor only.

GET /v1/usage?limit=50&cursor=<opaque base64url>
{ "items": [ … ], "nextCursor": "…", "hasMore": true }

plus Link: <…>; rel="next".

limit1 to 200, default 50
CursorsOpaque; they encode the last sort key and a hash of the filter set. Using one with different filters → CURSOR_INVALID. They do not expire.
Sort orderFixed per route and total. Usage is newest first; purchases by event sequence; notices by sequence; incidents newest first; senders in the order they were authorised.
No offset pagination, no total countsA COUNT(*) per page is the classic way to make a dashboard slow. Totals come from GET /v1/usage/summary.
Incremental pollingsinceSeq for notices, fromMs for usage. Better than walking pages you have already seen.
CSVGET /v1/usage?format=csv streams every matching row without a cursor. Cells beginning with =, +, - or @ are prefixed with ' so a spreadsheet cannot be made to execute them.

Rate limits​

Relay-path limits come from your tier's rate class: units per second with a burst, a per-shard gas budget, a ceiling on any single transaction's gas limit, and units per hour. (Limits)

Everything else is limited per IP or per account by route class — public cached reads generously, quote and validate tightly (they can trigger a simulation), writes on keys and webhooks strictly.

Every 429 carries Retry-After in seconds and details.retryAfterMs — except QUOTA_EXHAUSTED, which deliberately has neither, because waiting does not help. It carries details.pricingUrl, details.x402Url and details.periodEndMs instead.

CORS​

RoutesPolicy
Public GETAccess-Control-Allow-Origin: *
POST /v1/relay, /v1/relay/assign, intent reads, /v1/network, the prepare routes, the compatibility facade*, without credentials — they carry no ambient authority; the signed transaction is the credential
Native-auth routesThe dashboard origin only

The regional direct hosts send the same CORS headers as the main host, so a browser client can fail over too.

Attacker-controlled bytes​

Transaction data, contract return messages, the ref of a purchase and token names are all values somebody else chose. They are returned as base64, as hex, or as plain text fields that a client must render as text. The API never returns HTML.

Errors​

One shape, everywhere:

{
"type": "https://docs.co-relayer.com/errors/gas-limit-too-low",
"title": "The gas limit is below the cost of moving the transaction.",
"status": 400,
"detail": "gasLimit 90000 is below moveGas 100000.",
"instance": "req_01JB…",
"code": "GAS_LIMIT_TOO_LOW",
"retryable": false,
"resign": "NONE",
"details": { "moveGas": 100000, "gasLimit": 90000 },
"hint": "Raise the gas limit to at least moveGas."
}

Branch on code. Use status and retryable for a code you do not know. Only sign again when resign says so. Full catalogue: /errors.