Authentication
Every request to the External Payments API must be signed with HMAC-SHA512 using your API key's secret. There is no Authorization header — authentication is carried entirely by five X-MP-* headers.
See Prerequisites for how to create an API key and obtain your key ID, secret, and passphrase.
Request Headers
Header |
Value |
|---|---|
|
Your API key ID (starts with |
|
The passphrase issued alongside the key. |
|
Current Unix time in seconds, as a string. Must be within ±300 seconds of server time. |
|
A unique value per request (e.g. a random UUID). Each nonce is accepted once — reusing one is rejected as a replay (E2009). |
|
Lowercase hex HMAC-SHA512 signature computed as described below. |
The Canonical String
The signature is computed over five fields joined by newline characters (\n):
<timestamp>\n<nonce>\n<METHOD>\n<path>\n<rawBody>
signature = hex( HMAC-SHA512( secret, canonicalString ) )
The rules for each field — every one of these must match byte-for-byte what you actually send:
Field |
Rule |
|---|---|
|
Exactly the value sent in |
|
Exactly the value sent in |
|
The HTTP method, uppercase ( |
|
The full request path including the |
|
The exact body bytes you transmit. For requests without a body ( |
Warning
The two most common causes of signature_invalid (E2007):
Re-serializing the body after signing. Sign the exact string you send — serialize once, sign that string, send that string. A different key order or whitespace produces a different signature.
A proxy that rewrites the path. The server verifies against the path it receives; if a reverse proxy strips or adds a path prefix between you and the API, every signature fails. Point your base URL directly at the API host with no path suffix.
Worked Example
Use these fixed inputs to unit-test your signer. For a GET request to the active blockchains endpoint:
timestamp: 1750000000
nonce: 3f2c1a9e-8d4b-4c6a-9e2f-1b7d5a3c8e4f
method: GET
path: /external/v1/blockchains/active
rawBody: (empty string)
The canonical string is (shown with visible \n):
1750000000\n3f2c1a9e-8d4b-4c6a-9e2f-1b7d5a3c8e4f\nGET\n/external/v1/blockchains/active\n
Signing this with the secret mpay_sk_test:
printf '%s\n%s\n%s\n%s\n%s' \
"1750000000" \
"3f2c1a9e-8d4b-4c6a-9e2f-1b7d5a3c8e4f" \
"GET" \
"/external/v1/blockchains/active" \
"" \
| openssl dgst -sha512 -hmac "mpay_sk_test"
must produce this signature — if your signer outputs anything else, one of the five fields differs:
dded28718cdc654e0680c76809c174eb5b40741282ebd0b58f01c00130031b63464ce4dba8120258cf33d4b7265406568184b229ccb0a524e190cf77967d1659
Code Samples
Node.js / TypeScript
A complete, reusable signed-fetch helper. Keep it server-side only — never ship your secret to a browser.
import { createHmac, randomUUID } from "node:crypto";
const BASE_URL = "https://api.miraclecash.info";
const KEY_ID = process.env.MPAY_KEY_ID!;
const SECRET = process.env.MPAY_SECRET!;
const PASSPHRASE = process.env.MPAY_PASSPHRASE!;
export async function miraclePayFetch(
method: "GET" | "POST",
path: string, // must include /external/v1 and any query string
body?: unknown,
extraHeaders?: Record<string, string>,
): Promise<Response> {
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = randomUUID();
// Send the exact string that was signed — re-serializing after signing
// (different key order / whitespace) would invalidate the signature.
const rawBody = body === undefined ? "" : JSON.stringify(body);
const signature = createHmac("sha512", SECRET)
.update([timestamp, nonce, method, path, rawBody].join("\n"))
.digest("hex");
return fetch(`${BASE_URL}${path}`, {
method,
headers: {
...(rawBody ? { "Content-Type": "application/json" } : {}),
"X-MP-KEY-ID": KEY_ID,
"X-MP-PASSPHRASE": PASSPHRASE,
"X-MP-TIMESTAMP": timestamp,
"X-MP-NONCE": nonce,
"X-MP-SIGNATURE": signature,
...extraHeaders,
},
body: rawBody || undefined,
});
}
curl / bash
KEY_ID="mpay_pk_..."
SECRET="mpay_sk_..."
PASSPHRASE="..."
TIMESTAMP=$(date +%s)
NONCE=$(uuidgen)
METHOD="POST"
PATH_="/external/v1/payments/prompt"
BODY='{"amount":1099,"blockchainIds":["eth-usdc"]}'
SIGNATURE=$(printf '%s\n%s\n%s\n%s\n%s' \
"$TIMESTAMP" "$NONCE" "$METHOD" "$PATH_" "$BODY" \
| openssl dgst -sha512 -hmac "$SECRET" | sed 's/^.* //')
curl -X "$METHOD" "https://api.miraclecash.info$PATH_" \
-H "Content-Type: application/json" \
-H "X-MP-KEY-ID: $KEY_ID" \
-H "X-MP-PASSPHRASE: $PASSPHRASE" \
-H "X-MP-TIMESTAMP: $TIMESTAMP" \
-H "X-MP-NONCE: $NONCE" \
-H "X-MP-SIGNATURE: $SIGNATURE" \
-d "$BODY"
Note
The body string passed to curl -d must be the exact string that was signed. Do not let a JSON formatter re-serialize it in between.
Common Failure Modes
Symptom |
Error code |
Fix |
|---|---|---|
Missing one of the five headers |
|
Send all five |
Timestamp not a Unix-seconds integer |
|
Send seconds (not milliseconds) as a string. |
Server clock drifted beyond ±300 s |
|
Sync your clock (NTP), re-sign, retry. This error is retryable. |
Unknown key ID or wrong passphrase |
|
Verify key ID and passphrase from the dashboard. |
Key was revoked |
|
Create a new key. |
Key passed its expiry date |
|
Rotate or create a new key. |
Signature mismatch (wrong path, re-serialized body, wrong secret) |
|
Check the canonical string rules above, byte-for-byte. |
Nonce reused within the window |
|
Re-sign with a fresh nonce and timestamp — never resend a signed request verbatim. |
Key lacks the endpoint's scope |
|
Grant the required scope to the key in the dashboard. |
Full details for every code are in the Error Reference.
Key Rotation
Rotating a key in the dashboard issues a new secret and passphrase under the same key ID. The previous secret/passphrase pair keeps verifying for a 24-hour grace period (unless you choose immediate rotation), so you can deploy the new credentials without downtime.