Signing Requests
Private (user and admin) endpoints require three headers on every request:
| Header | Value |
|---|---|
api-key | Your API key |
api-expires | Unix timestamp in seconds after which the request is rejected, e.g. now + 60 |
api-signature | Hex-encoded HMAC-SHA256(api-secret, METHOD + PATH + api-expires + BODY) |
Building the string to sign
METHOD + PATH + EXPIRES + BODY
- METHOD: uppercase HTTP verb, e.g.
GET,POSTorDELETE. - PATH: path starting at
/v2, including the query string, e.g./v2/orders?symbol=btc-usdt&open=true. Don't include the host or the/apiprefix. - EXPIRES: the same value you send in
api-expires. - BODY: the exact JSON string you send as the request body. Leave it out (empty string) when there is no body.
| Request | String to sign |
|---|---|
GET /v2/user/balance | GET/v2/user/balance1735689600 |
GET /v2/orders?symbol=btc-usdt | GET/v2/orders?symbol=btc-usdt1735689600 |
POST /v2/order | POST/v2/order1735689600{"symbol":"btc-usdt","side":"buy","size":0.001,"type":"market"} |
DELETE /v2/order?order_id=abc | DELETE/v2/order?order_id=abc1735689600 |
The body must match byte for byte
Serialize the body once, sign that string, and send that same string. If you let your HTTP client re-serialize the object, it may change spacing or key order and the signature check will fail.
Node.js
sign.js
const crypto = require('crypto');
const API_URL = 'https://<your-api-domain>';
const API_KEY = process.env.HOLLAEX_API_KEY;
const API_SECRET = process.env.HOLLAEX_API_SECRET;
async function hollaex(method, path, body) {
const expires = Math.floor(Date.now() / 1000) + 60;
const payload = body ? JSON.stringify(body) : '';
const signature = crypto
.createHmac('sha256', API_SECRET)
.update(method + path + expires + payload)
.digest('hex');
const res = await fetch(API_URL + path, {
method,
headers: {
'content-type': 'application/json',
'api-key': API_KEY,
'api-expires': String(expires),
'api-signature': signature,
},
body: payload || undefined,
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.message}`);
return data;
}
(async () => {
console.log(await hollaex('GET', '/v2/user/balance'));
console.log(await hollaex('POST', '/v2/order', {
symbol: 'btc-usdt', side: 'buy', size: 0.001, type: 'limit', price: 30000,
}));
})();
Python
sign.py
import hashlib, hmac, json, os, time
import requests
API_URL = "https://<your-api-domain>"
API_KEY = os.environ["HOLLAEX_API_KEY"]
API_SECRET = os.environ["HOLLAEX_API_SECRET"]
def hollaex(method, path, body=None):
expires = str(int(time.time()) + 60)
payload = json.dumps(body, separators=(",", ":")) if body is not None else ""
signature = hmac.new(
API_SECRET.encode(), (method + path + expires + payload).encode(), hashlib.sha256
).hexdigest()
res = requests.request(
method,
API_URL + path,
headers={
"content-type": "application/json",
"api-key": API_KEY,
"api-expires": expires,
"api-signature": signature,
},
data=payload or None, # send the exact string that was signed
)
res.raise_for_status()
return res.json()
print(hollaex("GET", "/v2/user/balance"))
Bash
sign.sh
API_URL="https://<your-api-domain>"
METHOD="GET"
REQ_PATH="/v2/user/balance"
EXPIRES=$(( $(date +%s) + 60 ))
SIGNATURE=$(printf '%s' "${METHOD}${REQ_PATH}${EXPIRES}" \
| openssl dgst -sha256 -hmac "$HOLLAEX_API_SECRET" | sed 's/^.* //')
curl -s "${API_URL}${REQ_PATH}" \
-H "api-key: ${HOLLAEX_API_KEY}" \
-H "api-expires: ${EXPIRES}" \
-H "api-signature: ${SIGNATURE}"
WebSocket authentication
Private WebSocket channels use the same scheme with method CONNECT and path /stream. There is no body. Pass the values as query parameters:
const expires = Math.floor(Date.now() / 1000) + 60;
const signature = crypto.createHmac('sha256', API_SECRET)
.update('CONNECT' + '/stream' + expires)
.digest('hex');
const url = `wss://<your-api-domain>/stream?api-key=${API_KEY}&api-signature=${signature}&api-expires=${expires}`;
Troubleshooting signature errors
| Symptom | Likely cause |
|---|---|
Fails only on POST / PUT | The body you signed differs from the body you sent (spacing, key order, number formatting). |
| Fails only with query parameters | The query string wasn't included in PATH, or it was encoded differently from what was sent. |
| Fails on some requests | Server clock drift. api-expires is already in the past. Sync your clock with NTP or use a larger window. |
| Always fails | Wrong secret, the key was revoked, the /v2 prefix is missing from PATH, or the request is coming from an IP that isn't whitelisted. |