Authentication
Every request is signed with your key secret. The secret itself never travels. Only a signature computed from the request does, which is why a captured request cannot be replayed or altered.
The four headers
All four are required on every request, including GETs. Omitting any one returns 401 Missing credentials.
| Header | Example | Meaning |
|---|---|---|
| X-Vanta-Key-Id | vk_… | The key id shown when the key was created. It identifies which secret the server verifies against, and is not itself a secret. |
| X-Vanta-Timestamp | 1717718400000 | Whole milliseconds since the Unix epoch, as a decimal string. Must be within 5 minutes of server time, in either direction. |
| X-Vanta-Nonce | 9f8c1a2b… | A unique random value per request. 16 random bytes as hex is ample. Single-use for the life of the key. |
| X-Vanta-Signature | v1=<base64> | The literal prefix v1= followed by the base64 HMAC-SHA256 of the canonical string. |
The canonical string
Build this string, HMAC it, base64 the result. Six fields joined by newlines gives five separators, and no trailing newline.
v1
<HTTP METHOD, uppercase>
<request path, including the query string if any>
<timestamp in milliseconds>
<nonce>
<lowercase hex SHA-256 of the request body>Field by field
v1is the scheme version, a literal. It is not your key id and it does not change per request.- Method is uppercase, for example
GETorPOST. - Path is the request target exactly as sent: the pathname, plus
?and the query string when there is one. It is not the full URL, and not the path with the query stripped off. - Timestamp is the same value you put in
X-Vanta-Timestamp. - Nonce is the same value you put in
X-Vanta-Nonce. - Body hash is the lowercase hex SHA-256 of the raw request body. For a GET the body is the empty string, whose hash is the constant
e3b0c442…52b855.
Sign the bytes you send
data=body, never json=.A working signer
Drop-in helpers with no dependencies beyond the standard library and, for Python, requests.
# pip install requests
import base64
import hashlib
import hmac
import os
import secrets
import time
import requests
# The secret is shown once, when the key is created. Keep it out of source
# control. Read it from the environment or a secret manager.
KEY_ID = os.environ["VANTA_KEY_ID"]
SECRET = os.environ["VANTA_SECRET"]
BASE_URL = "https://app.vantatrading.io"
def signed_headers(method: str, path: str, body: str = "") -> dict:
"""Build the four auth headers for one request.
`path` must be the request target exactly as it is sent: the pathname plus
the query string, if any. Signing a different string than you request is
the most common cause of a 401.
"""
timestamp = str(int(time.time() * 1000))
nonce = secrets.token_hex(16)
canonical = "\n".join(
[
"v1",
method.upper(),
path,
timestamp,
nonce,
hashlib.sha256(body.encode("utf-8")).hexdigest(),
]
)
signature = base64.b64encode(
hmac.new(
SECRET.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256
).digest()
).decode("ascii")
return {
"X-Vanta-Key-Id": KEY_ID,
"X-Vanta-Timestamp": timestamp,
"X-Vanta-Nonce": nonce,
"X-Vanta-Signature": f"v1={signature}",
}// Node 18 or newer. No dependencies: `fetch` and `node:crypto` are built in.
import { createHash, createHmac, randomBytes } from "node:crypto";
// The secret is shown once, when the key is created. Keep it out of source
// control. Read it from the environment or a secret manager.
const KEY_ID = process.env.VANTA_KEY_ID;
const SECRET = process.env.VANTA_SECRET;
const BASE_URL = "https://app.vantatrading.io";
/**
* Build the four auth headers for one request. `path` must be the request
* target exactly as it is sent: pathname plus query string, if any.
*/
function signedHeaders(method, path, body = "") {
const timestamp = String(Date.now());
const nonce = randomBytes(16).toString("hex");
const canonical = [
"v1",
method.toUpperCase(),
path,
timestamp,
nonce,
createHash("sha256").update(body, "utf8").digest("hex"),
].join("\n");
const signature = createHmac("sha256", SECRET)
.update(canonical, "utf8")
.digest("base64");
return {
"X-Vanta-Key-Id": KEY_ID,
"X-Vanta-Timestamp": timestamp,
"X-Vanta-Nonce": nonce,
"X-Vanta-Signature": `v1=${signature}`,
};
}Test vectors
Fixed inputs with known-good outputs. Feed these into your signer offline: if your signature matches, your implementation is correct and any 401 you then see is about credentials or timing, not maths.
These use the sample secret vks_EXAMPLE_SECRET_DO_NOT_USE_IN_PRODUCTION and key id vk_EXAMPLEKEYID000000. Neither is a real credential and no request signed with them will authenticate. They exist purely so the numbers are reproducible.
GET request (empty body)
Every GET signs the SHA-256 of an empty string. That hash is a constant. If your signer produces a different value for a GET, it is hashing something it should not, such as a `null`, the string "undefined", or the query string a second time.
secret vks_EXAMPLE_SECRET_DO_NOT_USE_IN_PRODUCTION
method GET
path /api/v1/trading/account
timestamp 1717718400000
nonce 9f8c1a2b3d4e5f60718293a4b5c6d7e8
body (empty)v1\nGET\n/api/v1/trading/account\n1717718400000\n9f8c1a2b3d4e5f60718293a4b5c6d7e8\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855sha256(body) e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
signature ARXQ6aQCU4GUl47DNcQOJVW0miSfhgWdlfufJHWHRdE=
header X-Vanta-Signature: v1=ARXQ6aQCU4GUl47DNcQOJVW0miSfhgWdlfufJHWHRdE=POST request (JSON body)
The body is hashed as the exact bytes you transmit. Serialise once, sign that string, and send that same string. Re-serialising between signing and sending changes the hash, and the signature no longer matches. A pretty printer, a different key order, or a library that re-encodes will all do it.
secret vks_EXAMPLE_SECRET_DO_NOT_USE_IN_PRODUCTION
method POST
path /api/v1/trading/orders
timestamp 1717718400000
nonce 3c1d5e7f90a2b4c6d8e0f2a4b6c8d0e2
body {"accountId":"6f1c2e34-9a4b-4c1d-8e2f-1a2b3c4d5e6f","trade":{"execution_type":"MARKET","trade_pair":"BTCUSDC","order_type":"LONG","value":1000}}v1\nPOST\n/api/v1/trading/orders\n1717718400000\n3c1d5e7f90a2b4c6d8e0f2a4b6c8d0e2\n8aa6e38b3b0a50f1ad3588f4697040a1b0954ed896e88dd2f50074502b75b848sha256(body) 8aa6e38b3b0a50f1ad3588f4697040a1b0954ed896e88dd2f50074502b75b848
signature 1/Oq1TQAeJehKvyAoHCq8DIE+8dAqkXYHIyvO3/17Q4=
header X-Vanta-Signature: v1=1/Oq1TQAeJehKvyAoHCq8DIE+8dAqkXYHIyvO3/17Q4=Timestamp, nonce and key rules
Four constraints that shape how a long-running client should be written.
| Rule | Consequence |
|---|---|
| 5-minute clock window | A request whose timestamp is more than 5 minutes from server time is rejected with 401 Timestamp invalid. Keep the host clock synced, and sign immediately before sending rather than pre-signing a batch. |
| Nonces are single-use, permanently | Used nonces are recorded per key and never expire. A repeat returns 409. Generate a fresh random value per request. Do not derive it from a counter you reset on restart, and never retry a failed request with the same one. |
| One key, one account | The binding is set at creation. Reads need no account id; supplying one that does not match returns 403. Trading several accounts means one key each. |
| Scopes are fixed at creation | A key is issued with trade:read, trade:place and trade:close. Scopes cannot be added to an existing key, so issue a new one. |
Revoking a key
Revocation takes effect immediately.
Revoke from Settings → Key Management. The next request signed with that key returns 401 Invalid key. Revoking frees a slot against the per account key limit. If a secret is exposed, revoke first and investigate afterwards. See Errors & rate limits for how a compromised key surfaces in the responses your client sees.