Token-Based Authentication

View as Markdown

Anyone can extract an API key from a browser or mobile app. Keep the key on your server, mint a short-lived access token from it with POST /waves/v1/auth/token, and hand the token to the client. The client can use that token for TTS, STT and speech-to-speech inference, plus voice listing. It cannot mint tokens, clone voices, or read analytics, and it expires after 15 minutes at most.

Server-side code keeps using the API key directly. These tokens are for TTS, STT and speech-to-speech model inference. Voice-agent browser sessions use a different token, described in the Browser Voice Cookbook.

How it works

1

Mint a token on your server

Call POST /waves/v1/auth/token with your API key. The body is optional. ttl_seconds sets how long the token lives, from 30 to 900 seconds. The default is 300.

curl -X POST "https://api.smallest.ai/waves/v1/auth/token" \
-H "Authorization: Bearer $SMALLEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{"ttl_seconds": 300}'

The response is 201 Created:

{
"access_token": "wat_...",
"token_type": "Bearer",
"expires_in": 300,
"expires_at": "2026-09-22T13:01:14.533Z"
}

Only an API key can mint a token. A request that carries a token instead returns 403.

2

Hand the token to your client

Return access_token and expires_at to the client over your own authenticated channel, for example the session endpoint the client already calls. A token works any number of times until it expires, including for concurrent requests. Tokens cannot be renewed or extended, so mint a new one shortly before it expires. Compute the expiry on the client from expires_in, so a wrong client clock does not matter, and keep a small margin:

Browser: refresh before expiry
let cached = null; // { access_token, expiresAtMs }
async function getAccessToken() {
if (cached && cached.expiresAtMs - Date.now() > 10_000) {
return cached.access_token;
}
const token = await fetch("/api/token", { method: "POST" }).then((r) => r.json());
cached = { access_token: token.access_token, expiresAtMs: Date.now() + token.expires_in * 1000 };
return cached.access_token;
}

Expired tokens return 401. Browser code can read that status, since error responses carry CORS headers. Mint a new token and retry.

3

Call the API with the token

Use the token exactly like an API key, in the Authorization header.

curl -X POST "https://api.smallest.ai/waves/v1/stt/?model=pulse&language=en" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://github.com/smallest-inc/cookbook/raw/main/speech-to-text/getting-started/samples/audio.wav"}'

WebSocket connections accept the token either in the Authorization: Bearer header or as the api_key query parameter. The query parameter works on WebSocket connections only. HTTP requests must use the header. Use the header when your client can set one, since query strings can end up in access logs.

A WebSocket that is already open keeps working after its token expires. The token is checked when the connection is made. Opening a new connection needs a token that is still valid.

What a token can call

Tokens work on TTS, STT and speech-to-speech inference, both the unified routes and the dedicated Lightning v3.1 routes, plus voice listing. Treat any route not listed here as unavailable to tokens.

RouteToken accepted
POST /waves/v1/stt/ (Pulse and Pulse Pro, including webhook_url requests)Yes
WSS /waves/v1/stt/liveYes
POST /waves/v1/tts and POST /waves/v1/tts/liveYes
WSS /waves/v1/tts/liveYes
POST /waves/v1/lightning-v3.1/get_speech, POST /waves/v1/lightning-v3.1/stream, WSS /waves/v1/lightning-v3.1/get_speech/streamYes
WSS /waves/v1/s2s (speech to speech)Yes. The token authenticates the connection, the same as an API key
GET /waves/v1/{model}/get_voices, GET /waves/v1/voice/get-all-modelsYes. Returns the public voice catalog. Cloned voices are not included
POST /waves/v1/pulse/get_textNo, returns 403. Use POST /waves/v1/stt/?model=pulse instead
POST /waves/v1/auth/tokenNo, returns 403. Only an API key can mint tokens
Voice cloning, pronunciation dictionaries, analytics, LLM chat completionsNo, returns 403

The older POST /waves/v1/pulse/get_text path accepts API keys but rejects short-lived tokens. Client code that authenticates with a token must call the unified POST /waves/v1/stt/ endpoint.

Token lifecycle

  • A token expires at expires_at. Requests after that return 401 with {"error": "Invalid or expired access token"}. Mint a new token; there is no refresh.
  • Deleting the API key that minted a token invalidates the token shortly afterwards. It is not instant.
  • A token is valid only in the region that minted it. api.smallest.ai routes each request to the nearest region, so a token minted by a server in one region is rejected with 401 when a client in another region uses it. If your server and your users can be in different regions, mint and call through the same region-pinned hostname: api.india.smallest.ai (Mumbai) or api.us.smallest.ai (Oregon).
  • Requests made with a token are billed to the API key that minted it. Plan, rate limits, and concurrency are unchanged.
  • The mint endpoint is rate limited and returns RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers. Mint one token per client session, not one per request.

Token errors

StatusCause
400 Bad Requestttl_seconds is not an integer between 30 and 900
401 UnauthorizedToken is expired, malformed, or missing
403 ForbiddenToken used on an HTTP route outside the list above, or a token was used to mint another token. WebSocket routes outside the list may accept the connection without granting anything beyond the listed routes
429 Too Many RequestsToo many mint requests. Wait and retry
503 Service UnavailableThe endpoint is temporarily unavailable. Retry with backoff

For the full request and response schema, see Create Access Token.

End-to-end example

A complete flow in two files. The server holds the key and exposes one route. On a button click, the page fetches a token, plays synthesized speech, and opens an STT WebSocket ready for microphone audio.

import http from "node:http";
import fs from "node:fs";
const API_KEY = process.env.SMALLEST_API_KEY;
http.createServer(async (req, res) => {
if (req.method === "POST" && req.url === "/api/token") {
// Authenticate your own user here before minting.
const r = await fetch("https://api.smallest.ai/waves/v1/auth/token", {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ ttl_seconds: 120 }),
});
res.writeHead(r.status, { "Content-Type": "application/json" });
res.end(await r.text());
return;
}
res.writeHead(200, { "Content-Type": "text/html" });
res.end(fs.readFileSync("index.html"));
}).listen(3000);

Run SMALLEST_API_KEY=... node server.mjs and open http://localhost:3000. The API key never reaches the page.