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.
curl https://sandbox-api.myrt.money/v1/config \
-H "Authorization: Bearer $MYRT_API_KEY"API keys
A key has four segments separated by underscores:
myrt_sandbox_xxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx| Segment | Value | Notes |
|---|---|---|
| Prefix | myrt | Always present |
| Environment | sandbox or live | The only environment the key works in |
| Key id | 16 hexadecimal characters | Identifies the key. Safe to quote to support |
| Secret | 43 characters | Never 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.
| Environment | Key prefix | Notes |
|---|---|---|
| Sandbox | myrt_sandbox_ | Test fiat rails and a test network. No real money moves |
| Live | myrt_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:
| Scope | Grants |
|---|---|
mint:create | Create a mint order (fiatConfirmed: false) |
mint:execute | Submit a fiat-confirmed mint for settlement (fiatConfirmed: true) |
mint:read | Read mint orders |
redeem:create | Create a redeem order |
redeem:execute | Submit a redeem burn for settlement |
redeem:read | Read redeem orders |
transfer:create | Create a transfer |
transfer:execute | Submit a transfer for settlement |
transfer:read | Read transfers |
balance:read | Read on-chain MYRT balances |
transaction:read | Read and list every order you created, any direction |
account:read | Read customer and corporate verification status. Granted only under a signed data processing agreement |
config:read | Read 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/mintsneedsmint:create. SendingfiatConfirmed: trueadditionally needsmint:execute. Without it the call returns403 SCOPE_DENIEDand nothing is recorded. A key with onlymint:createcan record orders withfiatConfirmed: falsebut can never submit one for settlement. See mint. - Redeem. Creating a redeem order submits it for settlement, so
POST /v1/redeemsneeds bothredeem:createandredeem:execute. A key with onlyredeem:createreceives403 SCOPE_DENIED. See redeem. - Transfer. The same as redeem:
POST /v1/transfersneeds bothtransfer:createandtransfer:execute. Transfer is the highest-risk operation. Holdtransfer:executeon 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
| Operation | Scopes |
|---|---|
Create a mint order with fiatConfirmed: false | mint:create |
Create a mint order with fiatConfirmed: true | mint:create and mint:execute |
| Create a redeem order | redeem:create and redeem:execute |
| Create a transfer | transfer:create and transfer:execute |
| Read a mint, redeem or transfer order on its own route | mint:read, redeem:read or transfer:read |
| Read a balance | balance:read |
Read or list any order on /v1/transactions | transaction:read |
| Read a customer or corporate | account:read, under a signed data processing agreement |
| Read configuration and tokens | config:read |
| Health and server time | No 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
| Code | HTTP | When |
|---|---|---|
UNAUTHORIZED | 401 | The key is missing, malformed, unknown, revoked, expired, or issued for the other environment |
IP_NOT_ALLOWED | 403 | The request came from an address outside the key's allowlist |
SCOPE_DENIED | 403 | The 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:
{
"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.
| Request | Result |
|---|---|
No Idempotency-Key, or longer than 255 characters | 400 IDEMPOTENCY_KEY_REQUIRED |
| Same key, same body | The original status and body again, with X-MYRT-Idempotent-Replay: true. Nothing is created twice |
| Same key, different body | 409 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.
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:
acme:mint:2026-09-10:00417Uniqueness. A referenceId is unique within your integration. What happens when you post one that already exists depends on the order type:
| Order type | Re-posting an existing referenceId |
|---|---|
| Mint, transfer | Returns the existing order |
| Redeem | 409 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}.
