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:
| Header | Value |
|---|---|
X-Partner-ID | Your partner identifier |
X-Timestamp | Current Unix timestamp in milliseconds |
X-Signature | Base64-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)
- Generate or load your Ed25519 private key
- Build the canonical string using timestamp, method, path, and body hash
- Sign the canonical string using your private key
- Encode the signature using standard Base64
- Send the request with the three required headers
On CoinMENA's side (server)
When CoinMENA receives your request, it does the following in order:
- Extracts
X-Partner-ID,X-Timestamp, andX-Signaturefrom the headers - Validates the timestamp is within 60 seconds and not in the future
- Rebuilds the canonical string using your timestamp, method, path, and body
- Computes the SHA256 hash of the received request body
- Verifies your signature against the canonical string using your stored public key
- Accepts or rejects the request
Why this matters for debuggingIf 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.pemUsing 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 secureNever 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 preventionThe 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 requestsThe 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
401response.
Signing the Canonical String
Sign the canonical string using your Ed25519 private key. Encode the result using standard Base64 — not URL-safe Base64.
| Type | Characters | Example |
|---|---|---|
| ✅ 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:
| Mistake | Fix |
|---|---|
| Using URL-safe Base64 instead of standard Base64 | Use base64.b64encode() in Python or .toString("base64") in Node.js |
| Not sorting query parameters alphabetically | Sort all query params before appending to the path |
| Hashing formatted JSON but sending minified JSON | Hash and send the exact same string |
| Hashing a re-serialized body | Hash the exact raw string you send over the wire, not a parsed-then-re-serialized version |
| Including the full URL instead of path only | Strip 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 window | Always generate a fresh timestamp per request |
| Clock drift between client and server | Ensure your server clock is NTP-synced — a 2+ minute drift will fail the 60-second timestamp check |
| Wrong private key | Confirm the public key you shared with CoinMENA matches your private key |
| Canonical string in wrong order | Must 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 checklistBefore 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
| Symptom | Likely cause | How to verify |
|---|---|---|
401 on every request | Wrong key pair, or public key not shared | Re-share your public_key.pem with CoinMENA |
401 only on POST/PUT requests | Body hash mismatch | Hash the exact raw string you send — not a re-serialized version |
401 intermittently | Clock drift | Check your server clock — ensure NTP sync |
401 on requests with query parameters | Parameters not sorted alphabetically | Sort params before appending to path |
401 after a code change | New endpoint path or trailing slash issue | Verify path matches exactly, including any trailing slash |
Same code, different client → 401 | IP whitelisting blocking one client | See the IP Whitelisting guide |
| Signature works locally, fails in prod | Different clock, different keys, or IPs | Verify your prod server shares the same private key and is NTP-synced |
Updated 2 days ago
| Next Step | Description |
|---|---|
| IP Whitelisting | Add an extra layer of security by whitelisting your server IPs |
| Error Codes | Full reference of all error codes returned by the API |
