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

X-MP-KEY-ID

Your API key ID (starts with mpay_pk_).

X-MP-PASSPHRASE

The passphrase issued alongside the key.

X-MP-TIMESTAMP

Current Unix time in seconds, as a string. Must be within ±300 seconds of server time.

X-MP-NONCE

A unique value per request (e.g. a random UUID). Each nonce is accepted once — reusing one is rejected as a replay (E2009).

X-MP-SIGNATURE

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

timestamp

Exactly the value sent in X-MP-TIMESTAMP.

nonce

Exactly the value sent in X-MP-NONCE.

METHOD

The HTTP method, uppercase (GET, POST).

path

The full request path including the /external/v1 prefix and any query string — e.g. /external/v1/payments?limit=20&status=successful. No scheme or host.

rawBody

The exact body bytes you transmit. For requests without a body (GET), use the empty string — the canonical string then simply ends with the newline after path.

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

E2001

Send all five X-MP-* headers on every request.

Timestamp not a Unix-seconds integer

E2002

Send seconds (not milliseconds) as a string.

Server clock drifted beyond ±300 s

E2003

Sync your clock (NTP), re-sign, retry. This error is retryable.

Unknown key ID or wrong passphrase

E2004

Verify key ID and passphrase from the dashboard.

Key was revoked

E2005

Create a new key.

Key passed its expiry date

E2006

Rotate or create a new key.

Signature mismatch (wrong path, re-serialized body, wrong secret)

E2007

Check the canonical string rules above, byte-for-byte.

Nonce reused within the window

E2009

Re-sign with a fresh nonce and timestamp — never resend a signed request verbatim.

Key lacks the endpoint's scope

E2101

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.