Skip to content

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.)

HeaderValue
X-Api-KeyYour API key, nwak_…
X-Key-IdThe kid of the Ed25519 key you signed with
X-TimestampUnix epoch seconds — 1756555200, not milliseconds
X-SignatureBase64 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)
PartRule
METHODUpper case: GET, POST, PATCH
PATH+QUERYExactly as sent, with /api/v1 and the query string. No re-ordering, no decoding.
X-TimestampThe 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 headers

What 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-Key is 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.