Appearance
Authentication
The Neo Wallet API authenticates with asymmetric request signing. You generate an Ed25519 key pair and register the public key in the client portal; the private key stays with you. Every request to /api/v1/tenant/* is signed with that private key and carries 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 |
Use the kit
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
A 401 has the same body whichever of these caused it:
- 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 signature was already used. A signature is single-use; 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 your account lacks a scope the route requires. Compare the route's scopes in the reference with yours in the client portal.404— there is no such resource in your account.
Rotating keys
Register the new public key (new kid), switch your traffic to it, then revoke the old one. Both work during the overlap; X-Key-Id selects the key each request is verified with.