Authentication
Every key works in exactly one mode, chosen when you create it. A request made in the other
mode is refused with 45210. Webhooks are always HMAC-signed with the key's secret, whichever
mode the key uses.
Bearer
Send the access id and the secret, joined by a dot:
Authorization: Bearer <access_id>.<secret>curl https://energy.payany.com/v1/account \
-H "Authorization: Bearer ek_0123456789abcdef0123.Zq3…"Simple, but the secret travels with every request. Fine for a quick integration; prefer HMAC in production.
HMAC
The secret never leaves your server. Send four headers:
| Header | Value |
|---|---|
X-Access-Id | your access_id |
X-Timestamp | current Unix time in seconds (must be within ±300 s of ours) |
X-Nonce | a fresh random string, 8–64 chars of A-Z a-z 0-9 _ -, never reused |
X-Signature | hex(HMAC_SHA256(secret, string_to_sign)) |
The string to sign is five parts joined by a newline (\n):
string_to_sign = X-Timestamp + "\n" + X-Nonce + "\n" + METHOD + "\n" + path_with_query + "\n" + raw_bodyMETHODupper-case (GET,POST,PATCH,DELETE).path_with_queryexactly as you send it, without the host:/v1/price?energy=65000&term=1h. When there is no query string, it is just the path (/v1/orders).raw_bodythe exact bytes of the body — an empty string forGET. Serialise your JSON once, sign those bytes, send those bytes. Any JSON serialiser works because the signature is over bytes.- The signature is hex and compared case-insensitively.
- A nonce can be used once per key within 10 minutes (
45205otherwise). It is only consumed after the signature matched.
Test vectors
Secret test-secret, timestamp 1760000000. Use these two vectors to unit-test your signer.
GET with a query string — nonce n0nce-123:
GET /v1/price?energy=65000&term=1h
string_to_sign = "1760000000\nn0nce-123\nGET\n/v1/price?energy=65000&term=1h\n"
X-Signature = afd6a1001db2471c8fabd9e5b86be6154ffc2c5843b9918e6378a030887e20d9POST with a body — nonce n0nce-124:
POST /v1/orders
body = {"external_id":"inv-1001","energy_address":"TLsV52sRDL79HXGGm9yzwKibb6BeruhUzy","energy":65000,"term":"1h"}
string_to_sign = "1760000000\nn0nce-124\nPOST\n/v1/orders\n" + body
X-Signature = 7244351749440bda013f07fb44fd9e04109fb1ffac1288a75bae5c65eb3566cdNote the trailing \n after the path even when the body is empty.
Signing in a few lines
import hashlib, hmac, secrets, time
ts, nonce = str(int(time.time())), secrets.token_hex(16)
msg = f"{ts}\n{nonce}\nGET\n/v1/account\n".encode() + b""
sig = hmac.new(SECRET.encode(), msg, hashlib.sha256).hexdigest()TS=$(date +%s); NONCE=$(openssl rand -hex 16)
SIG=$(printf '%s\n%s\nGET\n/v1/account\n' "$TS" "$NONCE" \
| openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl https://energy.payany.com/v1/account \
-H "X-Access-Id: $ACCESS_ID" -H "X-Timestamp: $TS" \
-H "X-Nonce: $NONCE" -H "X-Signature: $SIG"Complete clients: Examples.
Debugging a signature
POST /v1/auth/echo with your usual headers (any body) never fails on a bad signature — it
always answers HTTP 200 and tells you what it saw:
| Field | Meaning |
|---|---|
mode | Hmac, Bearer or null — what the request looked like |
access_id, key_found, key_auth_mode, key_active | the key the request named |
method, path_with_query, body_length, body_sha256 | what we received |
timestamp, server_time, timestamp_ok | the clock check |
nonce, nonce_format_ok | the nonce check |
string_to_sign | the exact string we signed |
signature_matched | HMAC: the signature matched · Bearer: the secret matched |
client_ip, ip_allowed | your IP as we see it, and whether the key's allow-list admits it |
Compare its string_to_sign with yours byte by byte. The expected signature is never returned.
Echo does not consume the nonce and does not count against the key's rate limit (it is limited to
60 calls a minute per IP).
Checks, in order
- Credentials present (
45201) → the key exists and the secret / signature is right (45202,45203,45204,45205, all HTTP 401) → the key is active and not expired (45208, 403) → your account is not frozen (46003, 403). - IP allow-list — when the key has one, calls from any other IP get
45206(403). Entries are exact IPv4/IPv6 addresses or CIDR ranges. An empty list allows every IP. - Rate limit — 600 calls per minute per key by default (
45209, HTTP 429). Every response carriesX-RateLimit-LimitandX-RateLimit-Remaining. Need more? Ask support. - Permissions — Read for every
GET, Order for everything that creates or changes something (45207, 403).