.. _authentication: 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 :ref:`prerequisites` for how to create an API key and obtain your **key ID**, **secret**, and **passphrase**. Request Headers --------------- .. list-table:: :widths: 24 76 :header-rows: 1 * - 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 (:ref:`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``): .. code-block:: text \n\n\n\n .. code-block:: text signature = hex( HMAC-SHA512( secret, canonicalString ) ) The rules for each field — every one of these must match **byte-for-byte** what you actually send: .. list-table:: :widths: 18 82 :header-rows: 1 * - 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 its prefix and any query string** — e.g. ``/external/v1/payments?limit=20&status=successful`` or ``/external/partner/payments?page=2``. No scheme or host. Preserve query order and encoding exactly. * - ``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`` (:ref:`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: .. code-block:: text 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``): .. code-block:: text 1750000000\n3f2c1a9e-8d4b-4c6a-9e2f-1b7d5a3c8e4f\nGET\n/external/v1/blockchains/active\n Signing this with the secret ``mpay_sk_test``: .. code-block:: bash 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: .. code-block:: text dded28718cdc654e0680c76809c174eb5b40741282ebd0b58f01c00130031b63464ce4dba8120258cf33d4b7265406568184b229ccb0a524e190cf77967d1659 Code Samples ------------ Node.js / TypeScript ~~~~~~~~~~~~~~~~~~~~ A complete, reusable signed-fetch helper. Keep it **server-side only** — never ship your secret to a browser. .. code-block:: typescript 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, // full route prefix and exact query string body?: unknown, extraHeaders?: Record, ): Promise { 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 ~~~~~~~~~~~ .. code-block:: 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 -------------------- .. list-table:: :widths: 42 14 44 :header-rows: 1 * - 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`` - Create a new key; rotating the secret does not extend expiry. * - 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 :doc:`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. Rotation changes credentials, not the key's expiry date or scopes. Treat the signing secret as the exact supplied string, including its prefix; do not hex- or Base64-decode it before computing the HMAC.