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.
{timestamp}
{METHOD}
{path-with-query}
{sha256-hex-of-raw-body}
{idempotency-key or empty string}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, andPOST /withdrawals/{id}/cancel) hashes empty bytes. - idempotency key — the
Idempotency-Keyheader 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
| Header | Notes |
|---|---|
x-api-key-id | Your Key ID. |
x-api-timestamp | Unix SECONDS. Must be within ±5 minutes of our clock. |
x-api-signature | Base64 Ed25519 signature of the canonical string. |
idempotency-key | Required 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:
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:
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.
| Response | What to do |
|---|---|
429, 503 | Wait Retry-After seconds, then sign a new attempt. |
500, 503, network failure | Retry; for a payout, only with the same Idempotency-Key and body. |
401 | Check the Key ID, expiry, IP allowlist, your clock, the canonical encoding, and that you did not resend a signature. |
Other 4xx | Correct the request before retrying. |