Conventions
These hold for every route. Reading them once saves reading them thirteen times in the reference.
Media types and names
| Requests and responses | application/json, UTF-8 |
| Errors | application/problem+json (RFC 9457) |
| JSON member names | camelCase. snake_case appears only when a contract name is being quoted. |
| Unknown members in a request body | Rejected on every write route. |
| Maximum request body | 131,072 bytes → PAYLOAD_TOO_LARGE |
{erd} in a path | Always a bech32 address. |
Numbers
This is the one convention that surprises people, so it is stated first and precisely.
| Where | Type | Why |
|---|---|---|
| atto-EGLD, anywhere | decimal string, member ends in Atto | It does not fit a double. |
| Operational routes — relay, intents, quota, usage, dashboard — micro-USDC, Relay Units, gas, gas price | JSON integers | Bounded far below 2^53. |
The pricing document family — GET /v1/pricing, the static mirror, POST /v1/quote, POST /v1/subscribe/prepare, x402 accepts[] | decimal strings | These 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
| Everything | Unix milliseconds as a JSON number, member name ending in Ms. |
| Seconds | Only 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 clocks | chainTimeMs 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 id | q_ + 10 Crockford base32 characters |
| Incident | inc_… |
| Webhook | wh_… |
| API key id | 12 base32 characters |
| API key | crk_<env>_<keyId>_<secret> |
| Request id | req_…, returned in CoRelayer-Request-Id and as a problem document's instance |
Headers
On every response:
| Header | |
|---|---|
CoRelayer-Request-Id | The log correlation id, and the instance of any problem document. |
CoRelayer-Version | The 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
| Route | Key | Behaviour |
|---|---|---|
POST /v1/relay, POST /relay | Natural: (sender, nonce) + a hash of the signed bytes | Identical 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 characters | One per logical action. Same key with different bytes or nonce while the first intent lives → INTENT_ALREADY_SUBMITTED. |
| x402 purchases | extensions["payment-identifier"].id, required | Same id + same payload → the same result. Same id + different payload → 402 PAYMENT_INVALID. |
POST on keys and webhooks | Idempotency-Key, required | Same key + same body → the stored response. Different body → IDEMPOTENCY_KEY_REUSED. Still running → IDEMPOTENCY_IN_PROGRESS. |
POST /v1/relay/assign, the prepare routes, quote, validate | none | Stateless. Calling twice gives two valid answers. |
PUT, PATCH, DELETE, acknowledging notices | — | Idempotent by construction. |
| Webhook deliveries | The event id | At-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".
limit | 1 to 200, default 50 |
| Cursors | Opaque; 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 order | Fixed 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 counts | A COUNT(*) per page is the classic way to make a dashboard slow. Totals come from GET /v1/usage/summary. |
| Incremental polling | sinceSeq for notices, fromMs for usage. Better than walking pages you have already seen. |
| CSV | GET /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
| Routes | Policy |
|---|---|
Public GET | Access-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 routes | The 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.