Authentication

All partner endpoints require request signing using Ed25519 asymmetric cryptography. This page covers everything you need to understand, implement, and troubleshoot authentication.

The Three Required Headers

Every authenticated request must include:

HeaderValue
X-Partner-IDYour partner identifier
X-TimestampCurrent Unix timestamp in milliseconds
X-SignatureBase64-encoded Ed25519 signature of the canonical string

Authentication Model

CoinMENA uses Ed25519 asymmetric key signing for all partner requests. Understanding this model is important before implementing:

  • Every request must be independently signed — there are no sessions or tokens
  • The server rebuilds the canonical string from your request and verifies the signature using your stored public key
  • Your private key never leaves your system — CoinMENA only stores your public key
  • The timestamp in every request prevents replay attacks — requests outside the 60-second window are rejected
  • There is no login flow — authentication happens at the request level, every time

How It Works

On your side (client)

  1. Generate or load your Ed25519 private key
  2. Build the canonical string using timestamp, method, path, and body hash
  3. Sign the canonical string using your private key
  4. Encode the signature using standard Base64
  5. Send the request with the three required headers

On CoinMENA's side (server)

When CoinMENA receives your request, it does the following in order:

  1. Extracts X-Partner-ID, X-Timestamp, and X-Signature from the headers
  2. Validates the timestamp is within 60 seconds and not in the future
  3. Rebuilds the canonical string using your timestamp, method, path, and body
  4. Computes the SHA256 hash of the received request body
  5. Verifies your signature against the canonical string using your stored public key
  6. Accepts or rejects the request
📘

Why this matters for debugging

If your signature fails, the server's canonical string and yours are different. Work backwards through steps 3 and 4 — compare your canonical string and body hash against what the server would reconstruct from your actual request.


Generating Your Key Pair

If you have not generated your Ed25519 key pair yet:

Using OpenSSL

# Generate private key
openssl genpkey -algorithm Ed25519 -out private_key.pem

# Extract public key
openssl pkey -in private_key.pem -pubout -out public_key.pem

Using Python

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import (
    Encoding, PrivateFormat, PublicFormat, NoEncryption
)

private_key = Ed25519PrivateKey.generate()

with open("private_key.pem", "wb") as f:
    f.write(private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()))

with open("public_key.pem", "wb") as f:
    f.write(private_key.public_key().public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo))
⚠️

Keep your private key secure

Never share your private key, commit it to version control, or expose it in client-side code. CoinMENA stores only your public key — your private key must never leave your system. If your private key is compromised, contact CoinMENA immediately to rotate your keys.

Share the contents of public_key.pem with CoinMENA before making any signed requests.


The Canonical String

The canonical string is the exact value you sign. It is built by concatenating four components with no separators:

{TIMESTAMP}{METHOD}{PATH}{SHA256_BODY_HASH}

GET example (empty body):

1737654321000GET/v1/partner/orders?page=1&status=completede3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

POST example (with body hash):

1737654321000POST/v1/partner/quotes4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945
📘

For POST and PUT, the trailing hash is the SHA256 digest of the exact raw JSON body you send. See SHA256_BODY_HASH below.

TIMESTAMP

  • Unix timestamp in milliseconds
  • Must not be in the future
  • Must be within 60 seconds of server time
🔒

Replay attack prevention

The 60-second timestamp window ensures that intercepted requests cannot be reused. Any request outside this window is rejected with 401 Unauthorized.

METHOD

  • Must be uppercase: GET, POST, PUT, DELETE

PATH

  • Must include the exact endpoint path and sorted query parameters (if present)
  • Do NOT include the domain (https://...)
  • Path is case-sensitive and must match exactly
  • Trailing slashes produce a different signature: /v1/partner/orders/v1/partner/orders/

Query parameter sorting:

Query parameters must be sorted alphabetically and appended to the path before signing. The final signed path must match the actual request path.

Request:
GET /v1/partner/orders?status=completed&page=1

Step 1 — Sort alphabetically:
page=1&status=completed

Step 2 — Append to path:
/v1/partner/orders?page=1&status=completed

Step 3 — Use this in the canonical string

SHA256_BODY_HASH

  • Must be the SHA256 hex digest of the raw request body
  • All strings must be UTF-8 encoded before hashing
  • For requests with no body (GET), use the SHA256 hash of an empty string:
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
⚠️

POST and PUT requests

The body hash must be computed from the exact raw JSON string you send as the request body. Whitespace, formatting, and key order all affect the hash. Any difference between the string you hash and the string you send will cause a signature mismatch and a 401 response.


Signing the Canonical String

Sign the canonical string using your Ed25519 private key. Encode the result using standard Base64 — not URL-safe Base64.

TypeCharactersExample
✅ Standard Base64+, /, =YWJjZGVm+/==
❌ URL-safe Base64-, _YWJjZGVm-_==

Python — reusable signing helper

import time
import hashlib
import base64
from cryptography.hazmat.primitives.serialization import load_pem_private_key

with open("private_key.pem", "rb") as f:
    private_key = load_pem_private_key(f.read(), password=None)

def build_headers(method, path, body=""):
    timestamp = str(int(time.time() * 1000))
    body_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
    canonical = f"{timestamp}{method}{path}{body_hash}"
    signature_bytes = private_key.sign(canonical.encode("utf-8"))
    signature = base64.b64encode(signature_bytes).decode()
    return {
        "X-Partner-ID": "your_partner_id",
        "X-Timestamp": timestamp,
        "X-Signature": signature,
        "Accept": "application/json"
    }

Node.js — reusable signing helper

import crypto from "crypto";
import fs from "fs";

const privateKey = crypto.createPrivateKey(fs.readFileSync("private_key.pem", "utf8"));

function buildHeaders(method, path, body = "") {
  const timestamp = Date.now().toString();
  const bodyHash = crypto.createHash("sha256").update(Buffer.from(body, "utf8")).digest("hex");
  const canonical = `${timestamp}${method}${path}${bodyHash}`;
  const signatureBuffer = crypto.sign(null, Buffer.from(canonical, "utf8"), privateKey);
  const signature = signatureBuffer.toString("base64");
  return {
    "X-Partner-ID": "your_partner_id",
    "X-Timestamp": timestamp,
    "X-Signature": signature,
    "Accept": "application/json"
  };
}

Signing a POST Request

For POST and PUT requests, serialize your body consistently and use the exact same string for both hashing and sending.

Python

import json
import requests

# Uses the build_headers() helper defined in the previous section
body_dict = {
    "partner_client_id": "user_12345",
    "asset_pair": "BTC-USD",
    "side": "buy",
    "base_amount": "0.001"
}

# Use compact serialization — no extra whitespace
body = json.dumps(body_dict, separators=(",", ":"))

headers = build_headers("POST", "/v1/partner/quotes", body)
response = requests.post(
    "https://external-api.coinmena.com/v1/partner/quotes",
    headers=headers,
    data=body
)
print(response.json())

Node.js (v18+)

// Uses the buildHeaders() helper defined in the previous section
const bodyObj = {
  partner_client_id: "user_12345",
  asset_pair: "BTC-USD",
  side: "buy",
  base_amount: "0.001"
};

// Use compact serialization — no extra whitespace
const body = JSON.stringify(bodyObj);

const headers = buildHeaders("POST", "/v1/partner/quotes", body);

const response = await fetch("https://external-api.coinmena.com/v1/partner/quotes", {
  method: "POST",
  headers: {
    ...headers,
    "Content-Type": "application/json"
  },
  body: body
});

const data = await response.json();
console.log(data);

Common Mistakes

These are the most frequent causes of signature failures during integration:

MistakeFix
Using URL-safe Base64 instead of standard Base64Use base64.b64encode() in Python or .toString("base64") in Node.js
Not sorting query parameters alphabeticallySort all query params before appending to the path
Hashing formatted JSON but sending minified JSONHash and send the exact same string
Hashing a re-serialized bodyHash the exact raw string you send over the wire, not a parsed-then-re-serialized version
Including the full URL instead of path onlyStrip the domain — use /v1/partner/... not https://...
Trailing slash mismatch/v1/partner/orders and /v1/partner/orders/ produce different signatures
Timestamp outside the 60-second windowAlways generate a fresh timestamp per request
Clock drift between client and serverEnsure your server clock is NTP-synced — a 2+ minute drift will fail the 60-second timestamp check
Wrong private keyConfirm the public key you shared with CoinMENA matches your private key
Canonical string in wrong orderMust be exactly: TIMESTAMP + METHOD + PATH + BODY_HASH

Troubleshooting

If you receive a 401 Unauthorized response, work through the following steps.

Step 1 — Log the three critical values

📘

Debug checklist

Before anything else, log and compare these three values:

  • The exact canonical string you built
  • The body hash you computed
  • The final Base64 signature

Most signature failures can be traced back to a mismatch in one of these three values.

Step 2 — Match the symptom to the likely cause

SymptomLikely causeHow to verify
401 on every requestWrong key pair, or public key not sharedRe-share your public_key.pem with CoinMENA
401 only on POST/PUT requestsBody hash mismatchHash the exact raw string you send — not a re-serialized version
401 intermittentlyClock driftCheck your server clock — ensure NTP sync
401 on requests with query parametersParameters not sorted alphabeticallySort params before appending to path
401 after a code changeNew endpoint path or trailing slash issueVerify path matches exactly, including any trailing slash
Same code, different client → 401IP whitelisting blocking one clientSee the IP Whitelisting guide
Signature works locally, fails in prodDifferent clock, different keys, or IPsVerify your prod server shares the same private key and is NTP-synced

What’s Next
Next StepDescription
IP WhitelistingAdd an extra layer of security by whitelisting your server IPs
Error CodesFull reference of all error codes returned by the API