ClutchCall runs two auth surfaces:
  • Control plane (control-plane API tRPC: voice control, streams CRUD, agents, webhooks, analytics) — long-lived API keys scoped to an org and a set of capabilities.
  • Data plane (MoQT publish / subscribe, audio bridge, robotics topics, games rooms, data pub/sub) — short-lived relay tokens scoped to a set of namespaces, verified by the relay’s namespace_auth hook on every publish or subscribe.
The two are linked: a client uses its API key to mint relay tokens scoped to exactly the namespaces it needs.

Control-plane: API keys

API keys are created in the dashboard or via streams.apiKeys.create({ orgId, label, scopes }). The key string is returned once, at creation; the control-plane API stores only the hash.
export CLUTCHCALL_API_KEY=tqs_live_abc123
Every control-plane API call (voice originate, streams create, webhook list, analytics, agent attach) authenticates with this key over HTTPS. The control-plane API rejects keys that don’t have the right capability scope for the procedure being called.

Data-plane: relay tokens

The relay never sees the API key. The control-plane API mints a short-lived relay token — a signed JWT carrying namespace claims — and the client presents that during the MoQT handshake. The relay’s namespace_auth hook verifies the JWT signature, extracts the namespace scope, and gates every subsequent publish / subscribe against it. A typical relay token looks like:
{
  "alg": "EdDSA",
  "kid": "rk_live_abc…",
  "iss": "clutchcall-bff",
  "sub": "tenant_abc",
  "ns":  ["voice/sid_xyz/*", "playback/li_xyz"],
  "exp": 1733900000
}
ClaimMeaning
kidSigning-key id; the relay fetches the matching public key from Redis.
issThe control-plane API that minted the token.
subTenant id the relay stamps on the session.
nsNamespace patterns this token may publish or subscribe to.
expExpiry (typically 1h for client tokens, longer for service-to-service).
Every modality client knows which namespaces it needs and asks the control-plane API for an appropriately scoped token. You don’t construct these JWTs by hand — the SDK’s modality clients call the control-plane API’s token-mint procedures for you when given a control-plane API key.

Browser tokens

In the browser, you cannot ship a long-lived API key. Instead, your server-side mints a short-lived relay token for each browser session and the SPA passes it in:
// server (your control-plane API)
const token = await streams.signingKeys.mintPlaybackToken({
  inputId: "li_xyz", scopes: ["playback/li_xyz"], ttlSeconds: 3600,
});

// browser
const viewer = await BroadcastViewer.open(signedUrl);
For voice in a browser:
// server
const token = await voice.audioBridge.mintBrowserToken({
  callSid: "sid_xyz", ttlSeconds: 1800,
});
// browser
const v = new Voice({ baseUrl: controlPlaneApi, browserToken: token });
browserToken skips the API-key-on-client antipattern; the SDK presents it directly to the relay.

Signing-key rotation

Every relay token is signed with a per-org signing key. Rotate keys without downtime by:
  1. streams.signingKeys.create({ orgId, label }) — issues a new active key
  2. wait for clients with old tokens to expire (1h by default)
  3. streams.signingKeys.retire({ id }) — old key stops minting new tokens
The relay hydrates the active set of public keys from Redis on a 30 s refresh, so retirements take effect within that window.

Legacy: service-account JWT

The original control plane (the ClutchCallClient root import — dial, hangup, barge, push_audio) authenticated with an RSA service-account JSON file pointed at by CLUTCHCALL_CREDENTIALS:
{
  "tenant_id": "acme",
  "private_key": "-----BEGIN PRIVATE KEY-----\n\n-----END PRIVATE KEY-----",
  "private_key_id": "kid-2026-01"
}
The SDK signed an RS256 JWT (iss=clutchcall-sdk, sub=tenant_id) and presented it on the QUIC handshake. This still works for backwards compat — new code should use API keys + relay tokens via the Voice modality.