Skip to main content
Every authenticated request to the VOOI Perps API carries a bearer token (vooi_…). Tokens are issued by POST /user/tokens and revoked by DELETE /user/tokens. Both endpoints require an Ed25519 signature over a deterministic message — this page documents that message byte-for-byte and provides round-trippable examples.
This page is for manual token generation — signing POST /user/tokens requests yourself with an Ed25519 key. If you signed up on Ultra or another broker’s app, your token is usually issued through that app’s UI or SDK — check there first.

Prerequisites

  • A userId (UUID) — assigned when you sign up on Ultra or when the broker whose app you’re using registered you.
  • The Ed25519 private key whose public key was registered for that user.
Building your own broker app? See the Broker API for how to register users and set their public keys.

Signing algorithm

Two operations are signed with your Ed25519 key: POST /user/tokens (createToken) and DELETE /user/tokens (deleteToken). Nothing else on the API uses this scheme — every other authenticated call carries the issued vooi_… bearer token.
  1. Build the message string for the operation (templates below).
  2. Encode it as UTF-8 bytes.
  3. Sign the bytes with your Ed25519 private key.
  4. Encode the signature as lowercase hex, 128 characters, no 0x prefix.
Any conformant Ed25519 library works. Common choices:

Message format

Every signed request uses the same shape:
  • action — the operation name (createToken or deleteToken).
  • timestamp — Unix time in milliseconds. Must be within ±2 minutes of server time or the server rejects the request with 401 Signature expired. If a client’s clock may drift, read the server’s clock from GET /time — it needs no authentication and returns { timestamp } in the same units — and sign against that rather than the local clock.
  • Remaining data fields are their values joined with :, with keys sorted alphabetically (String.prototype.localeCompare). Any optional field you omit from the request body becomes an empty slot in the signed message — the slot is not dropped.

createToken template

Data keys sorted alphabetically: expiresIn, name, userId.

deleteToken template

Data keys sorted alphabetically: tokenId, userId.

Field reference

The POST /user/tokens and DELETE /user/tokens reference pages list every field and whether it’s required. A few values behave in non-obvious ways:
  • timestamp — Unix milliseconds; the server enforces a ±2-minute skew window (see Message format).
  • signature — 128-char lowercase hex, no 0x prefix.
  • userId — send the UUID verbatim; re-casing it changes the signed bytes and the signature won’t verify.
  • expiresIn (create only) — token lifetime in seconds, > 0, default 604800 (7 days).
  • name (create only) — unique per user, and required when expiresIn > 604800. A token created without a name is a session token.

Worked examples

The code snippets below are written in Node.js / TypeScript using @noble/curves/ed25519. The algorithm is identical in any language — build the same UTF-8 message and sign it with any conformant Ed25519 library (see the list above for equivalents in Python, Go, and Rust).

Session token

A session token is any token created without a name. The signed message therefore has empty slots for both expiresIn and name.
Response:
expiresAt is returned as a calendar date (YYYY-MM-DD), not a full timestamp — don’t expect a time component.

Long-lived API token

For tokens that should outlive a 7-day session, pass both expiresIn and name. The full four-slot template applies.
name must be unique per user. Reusing a name returns 400 Name must be unique — delete the existing token first or pick a new name.

List your named tokens

GET /user/tokens returns your named (long-lived) tokens as [{ id, name, expiresAt }]. Requires a Bearer token. (Session tokens are not listable — see Session vs long-lived tokens.)

Delete a token

Using the token

Include the returned token as a bearer credential on every authenticated request:
GET /user returns { id, broker } and is a convenient sanity check.

Session vs long-lived tokens

GET /user/tokens returns only named tokens. Session tokens are tracked server-side but are not enumerable via the API — treat them as ephemeral.

Token lifecycle

  • Default expiresIn is 604800 seconds (7 days). expiresIn must be greater than zero. expiresIn > 604800 requires a name.
  • Once expiresAt passes, authenticated requests carrying that token return 401 Invalid or expired session. You don’t need to call anything to clean expired tokens up — just stop using them.
  • Revocation is per-token: DELETE /user/tokens signed with the same Ed25519 key, targeting one tokenId. There is no bulk-revoke endpoint. Brokers can also revoke a user’s session tokens by rotating their public key — see below.
  • Storage is up to you. The bearer token is an opaque string, not derived from your private key. Persisting it in local storage is fine for a browser app; revoke the token if the device is lost.

Rotating the public key

Brokers rotate a user’s public key with PUT /broker/users/public-key. The effect on existing tokens: Rotation does not revoke named tokens — they keep working until their expiresAt or an explicit DELETE. To force a full re-auth after rotation, list named tokens with GET /user/tokens and delete each one. Future createToken calls must be signed with the private key matching the new public key.

Repeated POST /user/tokens calls

createToken is not deduplicated — each successful call returns a fresh id, token, and expiresAt. If you retry after a transient error, the previous call may have succeeded and you’ll end up with an unused token still counting down to expiry. Prefer serialising createToken requests on your side (one in flight at a time) so retries overwrite a known outcome. Named tokens must have a unique name per user — reusing a name returns 400 Name must be unique.

Errors

Error bodies follow the standard NestJS shape:

Common pitfalls

  • Timestamp in seconds instead of milliseconds. Send timestamp as Unix time in milliseconds. A seconds-resolution value is ~1000× too small and falls outside the ±2 minute window. (In JS, Date.now() is already milliseconds; Math.floor(Date.now() / 1000) gives the wrong, seconds value.)
  • Dropping empty slots. createToken:<ts>:<userId> (three colons) is not valid — keep all four slots even when expiresIn and name are absent.
  • Uppercase hex or 0x prefix. The regex is ^[0-9a-f]{128}$; anything else is rejected by schema validation before the signature is even checked.
  • Wrong private key representation. Most Ed25519 libraries sign with the 32-byte seed (64 hex chars), not the 64-byte expanded/secret key that some libraries expose (libsodium’s 64-byte secret key, for instance, holds the seed in its first 32 bytes). Store and transport the 32-byte seed. (@noble/curves/ed25519 expects this seed.)
  • UUID case-folding. Send userId exactly as your broker received it from POST /broker/users. Changing case changes the signed bytes and the signature will not verify.

Next steps

Quickstart

Use your token to place a first trade

Use your own keys

Connect any supported venue and start trading