Docs menu

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

CredentialLooks likeUsed forLifetime
API key + secretak_live_… / sk_live_… (ak_test_… / sk_test_… on Nile)Every call from your backendUntil revoked or expires_at
Bootstrap tokenAuthorization: Bearer abt_…Account creation, the signup deposit address, GET /v1/account and the first key — nothing else15 minutes
No credential—GET /v1/prices, GET /v1/estimate, GET /v1/orderbook, GET /v1/resources/{address} and the signup challengeLimited 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

HeaderValue
X-API-KEYThe key id, as shown in the dashboard. Not secret.
X-API-TIMESTAMPCurrent UTC time, ISO 8601 with milliseconds, e.g. 2026-09-11T18:04:05.123Z.
X-API-SIGNbase64(HMAC_SHA256(api_secret, canonical_string))

The canonical string

text
canonical_string = timestamp + METHOD + path + query + body
PartExactly
timestampThe value of X-API-TIMESTAMP, byte for byte.
METHODUpper case: GET, POST, PATCH, DELETE.
pathIncluding 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.
bodyThe 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:

text
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

RuleValueError when broken
Clock skew±5 seconds, early or late1003 signature_timestamp_skew — details.server_time carries our clock
ReplayA signature is single-use inside that window1009 replayed_signature — make a fresh timestamp and signature for every attempt, retries included
IP allowlistOptional per key; empty means any address1004 ip_not_allowed — details.source_ip echoes what we saw
Environmentak_live_ keys on the mainnet host, ak_test_ on Nile1012 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

CallKeyOn a repeat
POST /v1/ordersclient_order_id in the bodyThe original order, HTTP 200, no second charge
Other mutating callsIdempotency-Key header, 8–128 charactersThe original result

Records are kept for 24 hours. The same key with a different body is refused with 3010 idempotency_conflict.

Next steps

    ↑ ↓ to move · Enter to open · Esc to close