Skip to content

Authentication

Every call to the partner API carries an API key in the Authorization header, except GET /v1/health and GET /v1/system/time, which take no key. The key decides which environment you are talking to, which operations you may perform, and which addresses you may call from.

bash
curl https://sandbox-api.myrt.money/v1/config \
  -H "Authorization: Bearer $MYRT_API_KEY"

API keys

A key has four segments separated by underscores:

text
myrt_sandbox_xxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
SegmentValueNotes
PrefixmyrtAlways present
Environmentsandbox or liveThe only environment the key works in
Key id16 hexadecimal charactersIdentifies the key. Safe to quote to support
Secret43 charactersNever share it, including with support

One-time display. The full key is shown once, when it is created. MYRT stores only a hash of it, so it cannot be shown again. A lost key is rotated, not recovered.

Rotation. Rotation supports overlapping validity. Issue the new key, move your traffic to it, then revoke the old one. Both keys work during the overlap, so there is no downtime. Rotate on a schedule and whenever staff with access to a key change.

Revocation. A revoked key returns 401 UNAUTHORIZED on every call, the same as a key that never existed. If a key may have been exposed, revoke it first and issue a replacement afterwards.

Environment binding. A key works only in the environment it was issued for. The prefix tells you which one.

EnvironmentKey prefixNotes
Sandboxmyrt_sandbox_Test fiat rails and a test network. No real money moves
Livemyrt_live_Real value

Using a sandbox key on the live host, or a live key on the sandbox host, returns 401 UNAUTHORIZED. The hosts are listed under base URLs.

Rate limits are counted per key. See rate limits and best practices.

DANGER

Never put a key in browser code, a mobile binary, or a public repository. The API is server-to-server: no CORS headers are sent and browser calls are not supported. Keep the key in your server's secret store and load it at runtime, as $MYRT_API_KEY in the samples on this site.

IP allowlist

A key can be restricted to a list of source IP addresses or CIDR ranges. A request from any other address returns 403 IP_NOT_ALLOWED.

Restrict every live key by IP. Do this in particular for any key that holds transfer:execute: keep that scope on a dedicated key, restrict it by IP allowlist, and alert on every use. If your egress addresses change, update the allowlist before you move traffic; a request from an address that is not listed fails with 403 IP_NOT_ALLOWED until it is added.

Scopes

Each key carries a set of scopes. A request that needs a scope the key does not hold returns 403 SCOPE_DENIED. There are 13 scopes:

ScopeGrants
mint:createCreate a mint order (fiatConfirmed: false)
mint:executeSubmit a fiat-confirmed mint for settlement (fiatConfirmed: true)
mint:readRead mint orders
redeem:createCreate a redeem order
redeem:executeSubmit a redeem burn for settlement
redeem:readRead redeem orders
transfer:createCreate a transfer
transfer:executeSubmit a transfer for settlement
transfer:readRead transfers
balance:readRead on-chain MYRT balances
transaction:readRead and list every order you created, any direction
account:readRead customer and corporate verification status. Granted only under a signed data processing agreement
config:readRead network and token configuration

Every value-moving operation has a create scope and an execute scope. create records the intent. execute submits it for settlement. The split lets you keep the scope that moves value on a separate, tightly restricted key. How the two combine differs by operation:

  • Mint. POST /v1/mints needs mint:create. Sending fiatConfirmed: true additionally needs mint:execute. Without it the call returns 403 SCOPE_DENIED and nothing is recorded. A key with only mint:create can record orders with fiatConfirmed: false but can never submit one for settlement. See mint.
  • Redeem. Creating a redeem order submits it for settlement, so POST /v1/redeems needs both redeem:create and redeem:execute. A key with only redeem:create receives 403 SCOPE_DENIED. See redeem.
  • Transfer. The same as redeem: POST /v1/transfers needs both transfer:create and transfer:execute. Transfer is the highest-risk operation. Hold transfer:execute on a dedicated key, restrict it by IP allowlist and alert on every use. See transfers.

The read scopes are per direction: mint:read reads only mint orders, redeem:read only redeems, transfer:read only transfers. transaction:read reads any order you created, in any direction, through /v1/transactions.

What each operation needs

OperationScopes
Create a mint order with fiatConfirmed: falsemint:create
Create a mint order with fiatConfirmed: truemint:create and mint:execute
Create a redeem orderredeem:create and redeem:execute
Create a transfertransfer:create and transfer:execute
Read a mint, redeem or transfer order on its own routemint:read, redeem:read or transfer:read
Read a balancebalance:read
Read or list any order on /v1/transactionstransaction:read
Read a customer or corporateaccount:read, under a signed data processing agreement
Read configuration and tokensconfig:read
Health and server timeNo key

account:read is the one scope with a condition attached. It is granted only under a signed data processing agreement. Until the agreement is on file, every call to /v1/customers/{customerId} and /v1/corporates/{corporateId} returns 403 SCOPE_DENIED, even if the key was issued with the scope. See accounts and corporates.

Authentication errors

CodeHTTPWhen
UNAUTHORIZED401The key is missing, malformed, unknown, revoked, expired, or issued for the other environment
IP_NOT_ALLOWED403The request came from an address outside the key's allowlist
SCOPE_DENIED403The key lacks a scope the operation needs, or account:read is used without a data processing agreement

Every 401 carries the same message, whatever the cause:

json
{
  "ok": false,
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Invalid or expired API key."
  }
}

The response never says which condition failed. Check that the key's environment prefix matches the host you are calling, then confirm the key has not been revoked or expired.

Do not retry a 401 or 403. The same request fails again until the key, the allowlist or the scopes change. Branch on error.code; error.message may be reworded at any time. Every code is listed on the errors page.

Idempotency

Every POST must carry an Idempotency-Key header of 1 to 255 characters. Use a UUID. The header is what makes a retry safe.

RequestResult
No Idempotency-Key, or longer than 255 characters400 IDEMPOTENCY_KEY_REQUIRED
Same key, same bodyThe original status and body again, with X-MYRT-Idempotent-Replay: true. Nothing is created twice
Same key, different body409 IDEMPOTENCY_CONFLICT

Keys are scoped to your integration. Two partners can use the same UUID without colliding.

Generate one UUID per logical operation, not per HTTP attempt, and keep it with the operation record you are creating. On a timeout, or on 429, 502, 503 or 504, retry with the identical key and body. If the first attempt was recorded, you get its response back marked X-MYRT-Idempotent-Replay: true, and nothing is created twice. Never retry any other 4xx.

ts
import { randomUUID } from "node:crypto";

// One key per logical operation. Generate it once and store it with the order
// you are about to create, so a retry after a crash reuses it too.
const idempotencyKey = randomUUID();
const body = JSON.stringify({
  referenceId: "acme:mint:2026-09-10:00417",
  amountMyr: "1000.00",
  walletAddress: "0xAbC0000000000000000000000000000000000001",
  customerId: "cus_9f2a3b4c",
  fiatConfirmed: false,
});

async function createMint(attempt = 1): Promise<Response> {
  let res: Response;
  try {
    res = await fetch("https://sandbox-api.myrt.money/v1/mints", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.MYRT_API_KEY}`,
        "Idempotency-Key": idempotencyKey, // identical on every attempt
        "Content-Type": "application/json",
      },
      body, // identical on every attempt
      signal: AbortSignal.timeout(30_000),
    });
  } catch (err) {
    // Timed out or the connection dropped. Replaying the same key and body is safe.
    if (attempt >= 5) throw err;
    return createMint(attempt + 1);
  }
  if ([429, 502, 503, 504].includes(res.status) && attempt < 5) {
    const seconds = Number(res.headers.get("Retry-After") ?? 2 ** attempt);
    await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
    return createMint(attempt + 1);
  }
  return res;
}

const res = await createMint();
const order = await res.json();
if (res.headers.get("X-MYRT-Idempotent-Replay") === "true") {
  // An earlier attempt was recorded. This is its stored response; nothing was created twice.
}

A request with a different body needs its own key. Confirming a mint with fiatConfirmed: true is a different body from the call that created it with fiatConfirmed: false, so it needs a new Idempotency-Key. Reusing the first key returns 409 IDEMPOTENCY_CONFLICT.

Reference IDs

Every order carries a referenceId that you choose. It is your handle for the order: you read the order back by it, and it appears in reconciliation.

Format. 8 to 100 characters. Letters, digits, ., _, - and :. A referenceId that fails the format returns 400 INVALID_REFERENCE_ID, on reads as well as on creation. A stable pattern keeps IDs readable in reconciliation, for example:

text
acme:mint:2026-09-10:00417

Uniqueness. A referenceId is unique within your integration. What happens when you post one that already exists depends on the order type:

Order typeRe-posting an existing referenceId
Mint, transferReturns the existing order
Redeem409 IDEMPOTENCY_CONFLICT when the details differ

The referenceId and the Idempotency-Key do different jobs. The Idempotency-Key protects one HTTP request from being applied twice. The referenceId identifies the order for its whole life, on every read and in reconciliation. Store the referenceId before you send the request, so that after a crash you can read the order back through /v1/transactions/{referenceId}.

MYRT is a 1:1 Ringgit-backed stablecoin on Ethereum.