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.
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.
- Build the message string for the operation (templates below).
- Encode it as UTF-8 bytes.
- Sign the bytes with your Ed25519 private key.
- Encode the signature as lowercase hex, 128 characters, no
0xprefix.
@noble/curves/ed25519(Node, browsers, Deno, Bun)tweetnaclcryptographyorPyNaCl(Python)crypto/ed25519(Go)ed25519-dalek(Rust)
Message format
Every signed request uses the same shape:action— the operation name (createTokenordeleteToken).timestamp— Unix time in milliseconds. Must be within ±2 minutes of server time or the server rejects the request with401 Signature expired. If a client’s clock may drift, read the server’s clock fromGET /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
ThePOST /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, no0xprefix.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, default604800(7 days).name(create only) — unique per user, and required whenexpiresIn > 604800. A token created without anameis 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 aname. The signed message
therefore has empty slots for both expiresIn and name.
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 bothexpiresIn 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 returnedtoken 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
expiresInis 604800 seconds (7 days).expiresInmust be greater than zero.expiresIn > 604800requires aname. - Once
expiresAtpasses, authenticated requests carrying that token return401 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/tokenssigned with the same Ed25519 key, targeting onetokenId. 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 withPUT /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
timestampas 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 whenexpiresInandnameare absent. - Uppercase hex or
0xprefix. 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/ed25519expects this seed.) - UUID case-folding. Send
userIdexactly as your broker received it fromPOST /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