MegTrustDevelopers

Sign a request

Every call carries its own signature. Nothing is reusable.

The canonical string

Each request is signed over the timestamp, method, path, body and idempotency key. A captured request can't be replayed elsewhere, after five minutes, or even a second time — each signature is accepted exactly once.

5 lines, exact order
{timestamp}
{METHOD}
{path-with-query}
{sha256-hex-of-raw-body}
{idempotency-key or empty string}
signature
x-api-signature = base64( Ed25519-sign(private key, canonical) )

The rules, byte for byte:

  • Join the five lines with a single LF (\n) and sign the UTF-8 bytes.
  • timestamp — Unix seconds, within ±300 seconds of the server clock.
  • METHOD — uppercase, e.g. GET, POST.
  • path-with-query — the path and query string exactly as sent, e.g. /api/v1/transactions?limit=50&_request_id=….
  • body hash — lowercase hex SHA-256 of the raw body bytes you send. A request with no body (every GET, and POST /withdrawals/{id}/cancel) hashes empty bytes.
  • idempotency key — the Idempotency-Key header value, or an empty string. When it is empty the canonical string ends with one LF; otherwise there is no trailing LF.
  • Send the signature as canonical padded standard base64 (not URL-safe base64).

Headers

HeaderNotes
x-api-key-idYour Key ID.
x-api-timestampUnix SECONDS. Must be within ±5 minutes of our clock.
x-api-signatureBase64 Ed25519 signature of the canonical string.
idempotency-keyRequired on POST /withdrawals only. Include it in the canonical string too.

Every response carries Cache-Control: no-store and an X-Request-Id; quote the request ID when you contact support.

Node.js

This is the complete signing function used in Your first call:

call.mjs
import crypto from "node:crypto";
import fs from "node:fs";

// Your private key — generated by you, never sent to MegTrust.
const PRIVATE_KEY = crypto.createPrivateKey(fs.readFileSync("megtrust-private.pem"));
const KEY_ID = process.env.MEGTRUST_KEY_ID;
const BASE = "https://stagingvault.megtrust.com";

export async function call(method, path, body, idempotencyKey) {
  // A fresh signed query nonce distinguishes identical calls in the same second.
  const url = new URL(path, BASE);
  url.searchParams.set("_request_id", crypto.randomUUID());
  path = url.pathname + url.search;
  const payload = body ? JSON.stringify(body) : "";
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const bodyHash = crypto.createHash("sha256").update(payload).digest("hex");
  const idem = idempotencyKey ?? "";

  // 5 lines, in this exact order.
  const canonical = [timestamp, method.toUpperCase(), path, bodyHash, idem].join("\n");
  const signature = crypto.sign(null, Buffer.from(canonical), PRIVATE_KEY).toString("base64");

  const res = await fetch(BASE + path, {
    method,
    headers: {
      "content-type": "application/json",
      "x-api-key-id": KEY_ID,
      "x-api-timestamp": timestamp,
      "x-api-signature": signature,
      ...(idem ? { "idempotency-key": idem } : {}),
    },
    ...(payload ? { body: payload } : {}),
    redirect: "error",
    signal: AbortSignal.timeout(30_000),
  });
  return { status: res.status, body: await res.json() };
}

For production, set BASE to https://vault.megtrust.com.

Other languages

Any language with Ed25519 and HTTPS works. Send the four headers above and build the canonical bytes exactly as listed. The same function in Python, with the cryptography and requests packages:

megtrust.py
import base64, hashlib, json, os, time, uuid

import requests  # pip install requests cryptography
from cryptography.hazmat.primitives.serialization import load_pem_private_key

BASE = os.environ.get("MEGTRUST_BASE_URL", "https://stagingvault.megtrust.com")
KEY_ID = os.environ["MEGTRUST_KEY_ID"]
with open(os.environ.get("MEGTRUST_PRIVATE_KEY_PATH", "megtrust-private.pem"), "rb") as f:
    PRIVATE_KEY = load_pem_private_key(f.read(), password=None)


def call(method, path, body=None, idempotency_key=None):
    # A fresh signed query nonce on every attempt, retries included.
    path += ("&" if "?" in path else "?") + f"_request_id={uuid.uuid4()}"
    payload = b"" if body is None else json.dumps(body).encode()
    timestamp = str(int(time.time()))

    # 5 lines, in this exact order.
    canonical = "\n".join([
        timestamp,
        method.upper(),
        path,
        hashlib.sha256(payload).hexdigest(),
        idempotency_key or "",
    ])
    signature = base64.b64encode(PRIVATE_KEY.sign(canonical.encode())).decode()

    headers = {
        "content-type": "application/json",
        "x-api-key-id": KEY_ID,
        "x-api-timestamp": timestamp,
        "x-api-signature": signature,
    }
    if idempotency_key:
        headers["idempotency-key"] = idempotency_key
    res = requests.request(method, BASE + path, data=payload or None, headers=headers,
                           timeout=30, allow_redirects=False)
    return res.status_code, res.json()

Two things break signatures in every language:

  • Hash the bytes you send. Serialize the body once, hash that string, and send that same string. Letting an HTTP library re-serialize an object after you hashed it changes the bytes.
  • Sign the path you send. If your HTTP library re-encodes the query string, sign the encoded form.

Timeouts

Allow at least 10 seconds. A typical call answers in well under a second, but the first request after a quiet period also has to start the service, which can take a few seconds. A short client timeout will fail on that first call and succeed on the next — set it generously and you will never see it.

Retries

Re-sign every attempt. A retry needs a fresh signed query nonce and signature — the supplied client adds a random _request_id on every attempt, including within the same second. Resending identical bytes is indistinguishable from a replay and is refused with unauthorized. No additional query parameter changes a payment's identity.

Retrying a payout is safe. Reuse the SAME Idempotency-Key and you get the original withdrawal back, flagged idempotent_replay — never a second one. So if a request times out and you do not know whether it landed, retry it: that is exactly what the key is for. A NEW key on a retry is a NEW withdrawal, so never generate one per attempt — generate it per intended payment. See Send a payout.

ResponseWhat to do
429, 503Wait Retry-After seconds, then sign a new attempt.
500, 503, network failureRetry; for a payout, only with the same Idempotency-Key and body.
401Check the Key ID, expiry, IP allowlist, your clock, the canonical encoding, and that you did not resend a signature.
Other 4xxCorrect the request before retrying.

On this page