Authentication
Every authenticated request is signed with your API key’s secret. The secret never travels: the server recomputes the signature from the same bytes and compares.
Before you begin
| Credential | Looks like | Used for | Lifetime |
|---|---|---|---|
| API key + secret | ak_live_… / sk_live_… (ak_test_… / sk_test_… on Nile) | Every call from your backend | Until revoked or expires_at |
| Bootstrap token | Authorization: Bearer abt_… | Account creation, the signup deposit address, GET /v1/account and the first key — nothing else | 15 minutes |
| No credential | — | GET /v1/prices, GET /v1/estimate, GET /v1/orderbook, GET /v1/resources/{address} and the signup challenge | Limited per source IP |
An API key is a machine credential: never use it from a web page. A key can be created before the first deposit, so your code can read its deposit address (GET /v1/deposit-addresses) and top up on its own; orders are refused with 4001 insufficient_funds until the balance covers them. The secret is returned once, at creation.
The three headers
| Header | Value |
|---|---|
X-API-KEY | The key id, as shown in the dashboard. Not secret. |
X-API-TIMESTAMP | Current UTC time, ISO 8601 with milliseconds, e.g. 2026-09-11T18:04:05.123Z. |
X-API-SIGN | base64(HMAC_SHA256(api_secret, canonical_string)) |
The canonical string
canonical_string = timestamp + METHOD + path + query + body
| Part | Exactly |
|---|---|
timestamp | The value of X-API-TIMESTAMP, byte for byte. |
METHOD | Upper case: GET, POST, PATCH, DELETE. |
path | Including the /v1 prefix, percent-encoded as on the wire: /v1/orders. |
query | "" without a query string, otherwise ? plus the raw query exactly as sent — not re-ordered, not re-encoded. |
body | The raw request body as UTF-8, "" when there is none. |
No separators. Serialise the body once and send those same bytes: signing a pretty-printed body and sending a compact one is the most common cause of 1002 invalid_signature.
Step 1 — Build and sign the string
Signing GET /v1/balance with the secret sk_test_example at 2026-09-11T18:04:05.123Z:
2026-09-11T18:04:05.123ZGET/v1/balance
TS=2026-09-11T18:04:05.123Z
printf '%s' "${TS}GET/v1/balance" | openssl dgst -sha256 -hmac "sk_test_example" -binary | base64
import { createHmac } from "node:crypto";
const ts = "2026-09-11T18:04:05.123Z";
const sign = createHmac("sha256", "sk_test_example").update(`${ts}GET/v1/balance`).digest("base64");
console.log(sign);
import base64, hashlib, hmac
ts = "2026-09-11T18:04:05.123Z"
digest = hmac.new(b"sk_test_example", f"{ts}GET/v1/balance".encode(), hashlib.sha256).digest()
print(base64.b64encode(digest).decode())
All three print zqttXJ133TRJ7aj14dJ3jamfeCG2mDj+TbNPrrBJl2g=. Compare with yours before you debug anything else.
Step 2 — Send it
TS=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
SIGN=$(printf '%s' "${TS}GET/v1/balance" | openssl dgst -sha256 -hmac "$TENERGY_SECRET" -binary | base64)
curl -s https://api-nile.tenergy.me/v1/balance \
-H "X-API-KEY: $TENERGY_KEY" -H "X-API-TIMESTAMP: $TS" -H "X-API-SIGN: $SIGN"
import { createHmac } from "node:crypto";
const ts = new Date().toISOString();
const sign = createHmac("sha256", process.env.TENERGY_SECRET!)
.update(`${ts}GET/v1/balance`)
.digest("base64");
const res = await fetch("https://api-nile.tenergy.me/v1/balance", {
headers: { "X-API-KEY": process.env.TENERGY_KEY!, "X-API-TIMESTAMP": ts, "X-API-SIGN": sign },
});
console.log(res.status, await res.json());
import base64, hashlib, hmac, json, os, urllib.error, urllib.request
from datetime import datetime, timezone
ts = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
sign = base64.b64encode(hmac.new(os.environ["TENERGY_SECRET"].encode(),
f"{ts}GET/v1/balance".encode(), hashlib.sha256).digest()).decode()
req = urllib.request.Request("https://api-nile.tenergy.me/v1/balance", headers={
"X-API-KEY": os.environ["TENERGY_KEY"], "X-API-TIMESTAMP": ts, "X-API-SIGN": sign})
try:
with urllib.request.urlopen(req) as res:
print(res.status, json.load(res))
except urllib.error.HTTPError as err:
print(err.code, json.load(err))
The quickstart wraps the same scheme in a tenergy(method, path, body) helper for any call.
Clock, replay and IP rules
| Rule | Value | Error when broken |
|---|---|---|
| Clock skew | ±5 seconds, early or late | 1003 signature_timestamp_skew — details.server_time carries our clock |
| Replay | A signature is single-use inside that window | 1009 replayed_signature — make a fresh timestamp and signature for every attempt, retries included |
| IP allowlist | Optional per key; empty means any address | 1004 ip_not_allowed — details.source_ip echoes what we saw |
| Environment | ak_live_ keys on the mainnet host, ak_test_ on Nile | 1012 key_environment_mismatch, before the key is looked up |
Run NTP on every host that signs. A drifting clock fails every request with 1003; widening your retry loop will not fix it.
Scopes
A key carries exactly the scopes chosen when it was created — scopes is required, and there is no default set and no preselected option anywhere. The names come from one area.action vocabulary (ApiKeyScope in openapi.yaml); the API keys reference says what each opens. A call outside the key’s scopes answers 403 with 1005 insufficient_scope and names the scope in details.required_scope. Ask for the names your integration needs, and nothing else.
Idempotency
| Call | Key | On a repeat |
|---|---|---|
POST /v1/orders | client_order_id in the body | The original order, HTTP 200, no second charge |
| Other mutating calls | Idempotency-Key header, 8–128 characters | The original result |
Records are kept for 24 hours. The same key with a different body is refused with 3010 idempotency_conflict.