> ## Documentation Index
> Fetch the complete documentation index at: https://perps-api.vooi.io/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# API Tokens

> Sign token requests with Ed25519 and exchange them for a bearer token

Every authenticated request to the VOOI Perps API carries a bearer token
(`vooi_…`). Tokens are issued by [`POST /user/tokens`](/docs/api-reference/tokens/create-a-new-api-token) and revoked by
[`DELETE /user/tokens`](/docs/api-reference/tokens/delete-an-api-token). Both endpoints require an Ed25519 signature over a
deterministic message — this page documents that message byte-for-byte and
provides round-trippable examples.

<Info>
  This page is for **manual token generation** — signing `POST /user/tokens`
  requests yourself with an Ed25519 key. If you signed up on
  [Ultra](https://ultra.vooi.io) or another broker's app, your token is
  usually issued through that app's UI or SDK — check there first.
</Info>

## Prerequisites

* A `userId` (UUID) — assigned when you sign up on [Ultra](https://ultra.vooi.io) 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](/docs/broker) for how to register users and set their public keys.

## Signing algorithm

Two operations are signed with your Ed25519 key: [`POST /user/tokens`](/docs/api-reference/tokens/create-a-new-api-token) (`createToken`) and [`DELETE /user/tokens`](/docs/api-reference/tokens/delete-an-api-token) (`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:

* [`@noble/curves/ed25519`](https://github.com/paulmillr/noble-curves) (Node, browsers, Deno, Bun)
* [`tweetnacl`](https://github.com/dchest/tweetnacl-js)
* `cryptography` or `PyNaCl` (Python)
* `crypto/ed25519` (Go)
* `ed25519-dalek` (Rust)

## Message format

Every signed request uses the same shape:

```text theme={null}
<action>:<timestamp>:<sorted-data-values>
```

* `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`](/docs/api-reference/server/get-current-server-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`.

```text theme={null}
createToken:<timestamp>:<expiresIn>:<name>:<userId>
```

| Body fields sent | Signed message |
| - | - |
| `expiresIn` + `name` | `createToken:<ts>:<expiresIn>:<name>:<userId>` |
| `expiresIn` only | `createToken:<ts>:<expiresIn>::<userId>` |
| `name` only | `createToken:<ts>::<name>:<userId>` |
| neither | `createToken:<ts>:::<userId>` |

### `deleteToken` template

Data keys sorted alphabetically: `tokenId`, `userId`.

```text theme={null}
deleteToken:<timestamp>:<tokenId>:<userId>
```

## Field reference

The [`POST /user/tokens`](/docs/api-reference/tokens/create-a-new-api-token) and [`DELETE /user/tokens`](/docs/api-reference/tokens/delete-an-api-token) 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](#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

<Note>
  The code snippets below are written in Node.js / TypeScript using
  [`@noble/curves/ed25519`](https://github.com/paulmillr/noble-curves). 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).
</Note>

### Session token

A session token is any token created without a `name`. The signed message
therefore has empty slots for both `expiresIn` and `name`.

<CodeGroup>
  ```ts Node / TypeScript theme={null}
  import { ed25519 } from '@noble/curves/ed25519';
  import { bytesToHex, hexToBytes } from '@noble/hashes/utils';

  const userId = '0191f6b3-2d4a-7c8e-9f01-2a3b4c5d6e7f';
  const privateKey = hexToBytes(process.env.VOOI_PRIVATE_KEY!); // 32-byte seed

  const timestamp = Date.now();
  const message = `createToken:${timestamp}:::${userId}`;
  const signature = bytesToHex(
    ed25519.sign(new TextEncoder().encode(message), privateKey),
  );

  const res = await fetch('https://perps-api.vooi.io/user/tokens', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ userId, timestamp, signature }),
  });
  const { id, token, expiresAt } = await res.json();
  ```

  ```bash curl theme={null}
  # After computing $TIMESTAMP and $SIGNATURE with the steps above:
  curl -X POST https://perps-api.vooi.io/user/tokens \
    -H 'content-type: application/json' \
    -d '{
      "userId":    "0191f6b3-2d4a-7c8e-9f01-2a3b4c5d6e7f",
      "timestamp": '"$TIMESTAMP"',
      "signature": "'"$SIGNATURE"'"
    }'
  ```
</CodeGroup>

Response:

```json theme={null}
{
  "id": "0191f6b3-3e5b-7d8f-a012-b3c4d5e6f708",
  "token": "vooi_1a2b3c4d...",
  "expiresAt": "2026-04-28"
}
```

<Note>
  `expiresAt` is returned as a calendar date (`YYYY-MM-DD`), not a full
  timestamp — don't expect a time component.
</Note>

### Long-lived API token

For tokens that should outlive a 7-day session, pass both `expiresIn` and
`name`. The full four-slot template applies.

<CodeGroup>
  ```ts Node / TypeScript theme={null}
  import { ed25519 } from '@noble/curves/ed25519';
  import { bytesToHex, hexToBytes } from '@noble/hashes/utils';

  const userId = '0191f6b3-2d4a-7c8e-9f01-2a3b4c5d6e7f';
  const privateKey = hexToBytes(process.env.VOOI_PRIVATE_KEY!);

  const timestamp = Date.now();
  const expiresIn = 60 * 60 * 24 * 30; // 30 days
  const name = 'ci-bot';
  const message = `createToken:${timestamp}:${expiresIn}:${name}:${userId}`;
  const signature = bytesToHex(
    ed25519.sign(new TextEncoder().encode(message), privateKey),
  );

  const res = await fetch('https://perps-api.vooi.io/user/tokens', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ userId, timestamp, expiresIn, name, signature }),
  });
  ```

  ```bash curl theme={null}
  curl -X POST https://perps-api.vooi.io/user/tokens \
    -H 'content-type: application/json' \
    -d '{
      "userId":    "0191f6b3-2d4a-7c8e-9f01-2a3b4c5d6e7f",
      "timestamp": '"$TIMESTAMP"',
      "expiresIn": 2592000,
      "name":      "ci-bot",
      "signature": "'"$SIGNATURE"'"
    }'
  ```
</CodeGroup>

<Note>
  `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.
</Note>

### List your named tokens

[`GET /user/tokens`](/docs/api-reference/tokens/list-named-api-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](#session-vs-long-lived-tokens).)

### Delete a token

<CodeGroup>
  ```ts Node / TypeScript theme={null}
  import { ed25519 } from '@noble/curves/ed25519';
  import { bytesToHex, hexToBytes } from '@noble/hashes/utils';

  const userId = '0191f6b3-2d4a-7c8e-9f01-2a3b4c5d6e7f';
  const tokenId = '0191f6b3-3e5b-7d8f-a012-b3c4d5e6f708';
  const privateKey = hexToBytes(process.env.VOOI_PRIVATE_KEY!);

  const timestamp = Date.now();
  const message = `deleteToken:${timestamp}:${tokenId}:${userId}`;
  const signature = bytesToHex(
    ed25519.sign(new TextEncoder().encode(message), privateKey),
  );

  await fetch('https://perps-api.vooi.io/user/tokens', {
    method: 'DELETE',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ userId, tokenId, timestamp, signature }),
  });
  ```

  ```bash curl theme={null}
  curl -X DELETE https://perps-api.vooi.io/user/tokens \
    -H 'content-type: application/json' \
    -d '{
      "userId":    "0191f6b3-2d4a-7c8e-9f01-2a3b4c5d6e7f",
      "tokenId":   "0191f6b3-3e5b-7d8f-a012-b3c4d5e6f708",
      "timestamp": '"$TIMESTAMP"',
      "signature": "'"$SIGNATURE"'"
    }'
  ```
</CodeGroup>

## Using the token

Include the returned `token` as a bearer credential on every authenticated
request:

```text theme={null}
Authorization: Bearer vooi_1a2b3c4d...
```

[`GET /user`](/docs/api-reference/user/get-current-user-info) returns `{ id, broker }` and is a convenient sanity check.

## Session vs long-lived tokens

| | Session token | Long-lived API token |
| - | - | - |
| `name` in request | omitted | required |
| `expiresIn` | `> 0`, `≤ 604800` (7 days) | any positive integer, no server-side upper bound |
| Listed in [`GET /user/tokens`](/docs/api-reference/tokens/list-named-api-tokens) | No | Yes |
| Revoked when the broker rotates the public key | Yes | **No** |

[`GET /user/tokens`](/docs/api-reference/tokens/list-named-api-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`](/docs/api-reference/tokens/delete-an-api-token) 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`](/docs/api-reference/broker/set-or-remove-a-public-key-for-a-user). The effect on existing tokens:

| | Session tokens (unnamed) | Long-lived tokens (named) |
| - | - | - |
| Same key submitted | No change | No change |
| Different key submitted | **All revoked** | Not affected |
| `publicKey: null` submitted | **All revoked** | Not affected |

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`](/docs/api-reference/tokens/list-named-api-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

| Scenario | HTTP | Body `message` |
| - | - | - |
| `timestamp` outside ±2 minutes of server time | 401 | `Signature expired` |
| Signature fails Ed25519 verification | 400 | `Invalid signature` |
| `userId` does not exist | 404 | `User not found` |
| User has no registered public key | 400 | `Public key not set` |
| `expiresIn <= 0` | 400 | `expiresIn must be in positive` |
| `expiresIn > 604800` and `name` omitted | 400 | `Name is required for tokens with long lifetime` |
| `name` collides with an existing token for this user | 400 | `Name must be unique` |
| `DELETE /user/tokens` target does not exist | 404 | (default 404) |

Error bodies follow the standard NestJS shape:

```json theme={null}
{
  "statusCode": 400,
  "message": "Invalid signature",
  "error": "Bad Request"
}
```

## 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`](/docs/api-reference/broker/register-a-new-user). Changing case changes the signed bytes and the
  signature will not verify.

## Next steps

<CardGroup cols={2}>
  <Card title="Quickstart" icon="rocket" href="/docs/quickstart">
    Use your token to place a first trade
  </Card>

  <Card title="Use your own keys" icon="key" href="/docs/exchanges">
    Connect any supported venue and start trading
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.