Skip to main content

Signing Requests

Private (user and admin) endpoints require three headers on every request:

HeaderValue
api-keyYour API key
api-expiresUnix timestamp in seconds after which the request is rejected, e.g. now + 60
api-signatureHex-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, POST or DELETE.
  • 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 /api prefix.
  • 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.
RequestString to sign
GET /v2/user/balanceGET/v2/user/balance1735689600
GET /v2/orders?symbol=btc-usdtGET/v2/orders?symbol=btc-usdt1735689600
POST /v2/orderPOST/v2/order1735689600{"symbol":"btc-usdt","side":"buy","size":0.001,"type":"market"}
DELETE /v2/order?order_id=abcDELETE/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​

SymptomLikely cause
Fails only on POST / PUTThe body you signed differs from the body you sent (spacing, key order, number formatting).
Fails only with query parametersThe query string wasn't included in PATH, or it was encoded differently from what was sent.
Fails on some requestsServer clock drift. api-expires is already in the past. Sync your clock with NTP or use a larger window.
Always failsWrong secret, the key was revoked, the /v2 prefix is missing from PATH, or the request is coming from an IP that isn't whitelisted.