# Authentication

Source: https://tenergy.me/docs/authentication
Last updated: 2026-09-25

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

```text
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`:

```text
2026-09-11T18:04:05.123ZGET/v1/balance
```

```bash title="cURL"
TS=2026-09-11T18:04:05.123Z
printf '%s' "${TS}GET/v1/balance" | openssl dgst -sha256 -hmac "sk_test_example" -binary | base64
```

```ts title="TypeScript"
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);
```

```python title="Python"
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

```bash title="cURL"
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"
```

```ts title="TypeScript"
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());
```

```python title="Python"
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](https://tenergy.me/docs/quickstart#step-2--sign-requests-with-your-key) 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 |

> [!WARNING]
> 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`](https://tenergy.me/openapi.yaml)); the [API keys reference](https://tenergy.me/docs/api/api-keys) 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`.

## Next steps

- [Quickstart](https://tenergy.me/docs/quickstart) — the signing helper in use, from estimate to a confirmed order.
- [Errors & rate limits](https://tenergy.me/docs/errors) — the `1xxx` codes and what each one asks you to change.
- [Environments](https://tenergy.me/docs/environments) — which host takes which key.
