Webhooks: order events
MYRT sends an HTTP POST with a JSON body to your endpoint when a mint, redeem or transfer changes state.
Planned
This is part of the v1 contract and is documented ahead of release. It is not yet served in production. Build against it only once this notice is gone.
Until webhooks ship, poll the read endpoints, for example GET /v1/transactions/{referenceId}, to follow an order.
Registering an endpoint
Register an HTTPS endpoint with your account manager. You receive a webhook secret in return. The secret is shown once, so store it in your secrets store at registration time. It is the key you use to verify every delivery.
DANGER
Treat the webhook secret like an API key. Never put it in browser code, a mobile binary, or a public repository.
Events
| Event | data.status | Meaning |
|---|---|---|
mint.pending | pending | The mint was accepted and is awaiting settlement |
mint.completed | completed | The mint is confirmed on chain. Final |
mint.failed | failed | Terminal. No value moved |
redeem.pending | pending | The redeem was accepted and is awaiting settlement |
redeem.completed | completed | The burn is confirmed and the MYR payout has been initiated |
redeem.failed | failed | Terminal. No value moved |
transfer.completed | completed | The transfer is confirmed on chain. Final |
transfer.failed | failed | Terminal. No value moved |
redeem.completed does not mean the customer has received MYR. Bank settlement time is outside the API. The status values are described in order lifecycle.
Payload
{
"id": "evt_01J8ZQK4M7XN2R",
"type": "mint.completed",
"createdAt": "2026-09-10T04:19:02.441Z",
"data": {
"referenceId": "acme:mint:2026-09-10:00417",
"status": "completed",
"direction": "MINT",
"amounts": { "myr": "1000.00", "myrt": "1000.00", "decimals": 6 },
"blockchain": { "txHash": "0x...", "blockNumber": "20481922" }
}
}| Field | Type | Description |
|---|---|---|
id | string | Event id, unique per event. Deduplicate on it |
type | string | One of the events above |
createdAt | string | ISO 8601 UTC time the event was created |
data.referenceId | string | The referenceId you supplied on the order |
data.status | string | The order status the event reports |
data.direction | string | MINT, REDEEM or TRANSFER |
data.amounts | object | myr, myrt and decimals, with the same values as amounts on the order. See amounts |
data.blockchain | object | txHash and blockNumber, with the same values as on the order |
data is a subset of the order object. Read the order back for the full object, including phase, walletAddress and amountRaw.
Verifying the signature
Every delivery carries one signature header:
X-MYRT-Signature: t=<unix seconds>,v1=<hex>t is a Unix timestamp in seconds. v1 is HMAC-SHA256 over the string {t}.{raw request body}, keyed with your webhook secret and hex encoded, so it is always 64 characters.
Verify every delivery in this order, and do nothing with the body until it passes:
- Take the request body as raw bytes. Do not parse, re-serialise or re-encode it first. A single changed byte changes the signature.
- Split the header on
,and readtandv1. - Reject the delivery if
|now - t|is more than 300 seconds. If you see rejections here, check your clock againstGET /v1/system/time. - Compute HMAC-SHA256 over
t, a.and the raw body, keyed with the secret, and hex encode it. - Compare the result with
v1using a constant-time comparison. Reject on any mismatch. - Only then parse the JSON.
Both verifiers below take the secret as an argument and return true only when every step passes. The optional now argument exists so you can run the test vector below.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
/**
* rawBody the request body exactly as received, before JSON parsing
* header the value of the X-MYRT-Signature header
* secret your webhook secret
* now current Unix time in seconds; override it only to run the test vector
*/
export function verifyMyrtWebhook(
rawBody: Buffer,
header: string | undefined,
secret: string,
now: number = Math.floor(Date.now() / 1000),
): boolean {
if (!header) return false;
const parts = new Map<string, string>();
for (const pair of header.split(",")) {
const i = pair.indexOf("=");
if (i > 0) parts.set(pair.slice(0, i).trim(), pair.slice(i + 1).trim());
}
const t = parts.get("t");
const v1 = parts.get("v1");
if (!t || !v1) return false;
const timestamp = Number(t);
if (!Number.isInteger(timestamp)) return false;
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody)
.digest("hex");
const expectedBytes = Buffer.from(expected, "utf8");
const receivedBytes = Buffer.from(v1, "utf8");
if (expectedBytes.length !== receivedBytes.length) return false;
return timingSafeEqual(expectedBytes, receivedBytes);
}import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_myrt_webhook(raw_body: bytes, header: str, secret: str, now=None) -> bool:
"""
raw_body the request body exactly as received, before JSON parsing
header the value of the X-MYRT-Signature header
secret your webhook secret
now current Unix time in seconds; set it only to run the test vector
"""
if not header:
return False
parts = {}
for pair in header.split(","):
key, sep, value = pair.partition("=")
if sep:
parts[key.strip()] = value.strip()
t = parts.get("t")
v1 = parts.get("v1")
if not t or not v1 or not t.isdigit():
return False
if now is None:
now = int(time.time())
if abs(now - int(t)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode("utf-8"),
t.encode("utf-8") + b"." + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected.encode("utf-8"), v1.encode("utf-8"))Test vector
Use this vector to check your verifier before you register an endpoint.
| Input | Value |
|---|---|
| Secret | whsec_0123456789abcdef0123456789abcdef |
t | 1789013942 |
Raw body, one line, exactly these bytes:
{"id":"evt_01J8ZQK4M7XN2R","type":"mint.completed","createdAt":"2026-09-10T04:19:02.441Z","data":{"referenceId":"acme:mint:2026-09-10:00417","status":"completed","direction":"MINT","amounts":{"myr":"1000.00","myrt":"1000.00","decimals":6},"blockchain":{"txHash":"0x0000000000000000000000000000000000000000000000000000000000000001","blockNumber":"20481922"}}}Expected header:
X-MYRT-Signature: t=1789013942,v1=3d8aab65751aa498030f2f0f701b2d0dd00cd5ca264f3c44504fa73891c878ddA verifier that accepts this vector and rejects the same body with any byte changed is correct. Pass 1789013942 as now when you run it: the t in the vector is fixed, so the real clock would fail the 300 second tolerance check. The txHash here is written out in full rather than as 0x... because the signature covers the exact bytes.
Delivery rules
| Rule | What it means for you |
|---|---|
| Acknowledge within 5 seconds | Respond 2xx as soon as you have stored the event. Queue the work and process it asynchronously |
| Retries | A delivery that is not acknowledged with a 2xx is retried with exponential backoff for up to 24 hours |
| At-least-once | The same event can arrive more than once. Deduplicate on id |
| No ordering guarantee | Events can arrive out of order. Do not infer state from the sequence; the order's status is the current truth |
| Read back before crediting | Treat a webhook as a hint. On any event, read the order with the API before you credit anyone |
GET /v1/transactions/{referenceId} returns any order you created, whatever its direction, and needs transaction:read. See the transactions reference.
curl https://sandbox-api.myrt.money/v1/transactions/acme:mint:2026-09-10:00417 \
-H "Authorization: Bearer $MYRT_API_KEY"Act on the status in that response, not on the event.
Failure handling
If your endpoint is down, every delivery in that window fails and MYRT retries each one with exponential backoff for up to 24 hours. After 24 hours an event is no longer retried. Plan for both cases.
- Endpoint back within 24 hours. Expect a burst of retried events, out of order and possibly duplicated. Deduplicate on
id, read each order back, and act on itsstatus. - Longer outage, or any doubt. Reconcile against the API. List your orders with
GET /v1/transactions(planned), or walk thereferenceIdvalues you stored before sending and read each one withGET /v1/transactions/{referenceId}.
Store every referenceId before you send the order; it is your handle for every read. Reconciliation should be routine rather than an incident response, so run it daily. See best practices.
