Appearance
Authentication
There is no login call and no token. Every request to /api/v1/tenant/* authenticates itself with four headers. (GET /api/v1/tenant/health is the one open route.)
| Header | Value |
|---|---|
X-Api-Key | Your API key, nwak_… |
X-Key-Id | The kid of the Ed25519 key you signed with |
X-Timestamp | Unix epoch seconds — 1756555200, not milliseconds |
X-Signature | Base64 Ed25519 signature over the canonical string below |
Don't hand-roll it
The kit has this as one function in TypeScript and Python, with test vectors.
The canonical string
METHOD \n PATH+QUERY \n X-Timestamp \n sha256hex(body)| Part | Rule |
|---|---|
METHOD | Upper case: GET, POST, PATCH |
PATH+QUERY | Exactly as sent, with /api/v1 and the query string. No re-ordering, no decoding. |
X-Timestamp | The header value, verbatim |
sha256hex(body) | Lower-case hex SHA-256 of the exact bytes you send. No body → hash of the empty string. |
Join with a single \n, no trailing newline. Sign the UTF-8 bytes. Idempotency-Key and X-Request-Id are not part of the signature.
The empty-body hash, for every GET: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Minimal implementation
ts
import { createHash, createPrivateKey, sign } from "node:crypto";
const privateKey = createPrivateKey(process.env.NEO_SIGNING_KEY!); // PKCS#8 PEM
export function signedHeaders(method: string, pathWithQuery: string, body?: string) {
const timestamp = String(Math.floor(Date.now() / 1000));
const bodyHash = createHash("sha256")
.update(body ?? "", "utf8")
.digest("hex");
const canonical = [method.toUpperCase(), pathWithQuery, timestamp, bodyHash].join("\n");
return {
"X-Api-Key": process.env.NEO_API_KEY!,
"X-Key-Id": process.env.NEO_KEY_ID!,
"X-Timestamp": timestamp,
"X-Signature": sign(null, Buffer.from(canonical, "utf8"), privateKey).toString("base64"),
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
};
}python
import base64, hashlib, os, time
from cryptography.hazmat.primitives.serialization import load_pem_private_key
private_key = load_pem_private_key(os.environ["NEO_SIGNING_KEY"].encode(), password=None)
def signed_headers(method: str, path_with_query: str, body: bytes = b"") -> dict:
timestamp = str(int(time.time()))
canonical = "\n".join([method.upper(), path_with_query, timestamp, hashlib.sha256(body).hexdigest()])
headers = {
"X-Api-Key": os.environ["NEO_API_KEY"],
"X-Key-Id": os.environ["NEO_KEY_ID"],
"X-Timestamp": timestamp,
"X-Signature": base64.b64encode(private_key.sign(canonical.encode())).decode(),
}
if body:
headers["Content-Type"] = "application/json"
return headersWhat gets a 401
Every failure is the same body — the server never says which check failed.
- A header is missing, or the timestamp is more than ±300 s off.
- The API key or signing key is unknown, expired or revoked, or your account is suspended.
- The signature does not verify over what the server received.
- The same signature was seen before. Never resend a signed request as-is; a retry is a fresh timestamp and a fresh signature. (The
Idempotency-Keyis what makes the retry safe.) - Your account has an IP allowlist and the request came from outside it.
Scopes: 403 and 404
Each route needs the scopes shown in the reference; your account must hold all of them. Neo Wallet grants scopes to your account; you can see yours in the client portal.
403— authenticated, but a scope is missing. The body never names it.404— the resource is not yours, or does not exist. The two are indistinguishable by design.
Rotating keys
Register the new public key (new kid), switch your traffic to it, then revoke the old one. Both work during the overlap; the server only ever tries the kid you name.