Getting started

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.

HeaderExampleMeaning
X-Vanta-Key-Idvk_…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-Timestamp1717718400000Whole milliseconds since the Unix epoch, as a decimal string. Must be within 5 minutes of server time, in either direction.
X-Vanta-Nonce9f8c1a2b…A unique random value per request. 16 random bytes as hex is ample. Single-use for the life of the key.
X-Vanta-Signaturev1=<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.

canonical string
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

  • v1 is the scheme version, a literal. It is not your key id and it does not change per request.
  • Method is uppercase, for example GET or POST.
  • 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

Serialise your JSON once, hash that exact string, and transmit that same string. Anything that re-encodes the payload after signing will change the hash and break the signature. A pretty printer, a different key order, or an HTTP client that serialises the object for you will all do it. In Python that means 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}",
    }

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.

inputs
secret     vks_EXAMPLE_SECRET_DO_NOT_USE_IN_PRODUCTION
method     GET
path       /api/v1/trading/account
timestamp  1717718400000
nonce      9f8c1a2b3d4e5f60718293a4b5c6d7e8
body       (empty)
canonical string (newlines shown escaped)
v1\nGET\n/api/v1/trading/account\n1717718400000\n9f8c1a2b3d4e5f60718293a4b5c6d7e8\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
expected output
sha256(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.

inputs
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}}
canonical string (newlines shown escaped)
v1\nPOST\n/api/v1/trading/orders\n1717718400000\n3c1d5e7f90a2b4c6d8e0f2a4b6c8d0e2\n8aa6e38b3b0a50f1ad3588f4697040a1b0954ed896e88dd2f50074502b75b848
expected output
sha256(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.

RuleConsequence
5-minute clock windowA 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, permanentlyUsed 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 accountThe 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 creationA 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.