Guide · async
Connect an app with one click
Let the people who use your app connect their VocalStack account with one Authenticate click — OAuth 2.0 authorization code with PKCE, where the access token is an API key — instead of pasting a key.
If your app runs on your users' own servers (a CMS plugin, a self-hosted tool), each of them needs an API key for their own VocalStack account. One-click Connect gets it for them: your app opens VocalStack's dashboard, the person signs in and clicks Authenticate, and your server receives a key named after your app. Nobody copies a key, and the key never passes through a browser.
It is OAuth 2.0's authorization code flow with PKCE (RFC 7636, S256 only), and the access token it issues is an ordinary API key. There is nothing to register: the consent screen shows the person the host of your redirect_uri, so they can tell whose server will hold the key.
- You send: open {dashboard}/connect?response_type=code&client_name=…&redirect_uri=…&state=…&code_challenge=…&code_challenge_method=S256&scope=read write (in a new tab)
- VocalStack sends: the consent screen: sign in, pick the account, Authenticate (owners and admins only)
- VocalStack sends: redirect_uri?code=…&state=… (or ?error=access_denied&state=… on Cancel)
- You send: POST /v1/connect/token grant_type=authorization_code, code, code_verifier, redirect_uri (from your server)
- VocalStack sends: { access_token: "vs_key_…", token_type: "api_key", key_id, scope, account: { name } }
Send the person to the consent screen
Make a random
stateand a PKCE verifier (43 to 128 characters ofA-Z a-z 0-9 - . _ ~; 32 or more random bytes, base64url-encoded, are right), keep both on your server for a few minutes, and open the dashboard's/connectpage with:Parameter Value response_typecodeclient_nameYour app as the person will recognise it, at most 100 characters: drbc.org (Payload CMS). The key is named after it.redirect_uriWhere the dashboard sends the browser back to: absolute https, orhttpforlocalhost/127.0.0.1while you develop. No fragment.stateYour random value; it comes back unchanged. code_challengeBASE64URL(SHA256(verifier)), without padding (43 characters).code_challenge_methodS256scoperead,writeorread write. Keys withrecordings:publishor full access are made by hand in the dashboard.A link that breaks a rule gets an error page and never redirects, so check it while you build.
The person authenticates
The dashboard asks the person to sign in if they are not, then shows your
redirect_uri's host (and whether it is https or a site on their own computer), the name you sent (marked as not checked), the account the key will act for — they choose when they belong to several — and what the scopes allow. Only owners and admins of an organization can connect an app to it.Authenticate sends the browser to
redirect_uri?code=…&state=…. Cancel sends it toredirect_uri?error=access_denied&state=…. Either way, checkstateagainst the one you kept before you do anything else.Redeem the code on your server
Within two minutes, post the code with the verifier and the same
redirect_uritoPOST /v1/connect/token, form-encoded, from your server. No API key is needed: the code and its verifier are the proof. The answer carries the key (access_token), its id, the grantedscopeand the account's name; store the key as you would one your user pasted. It is shown once, in this answer.A code works once. A second redemption, a wrong verifier, a different
redirect_urior an expired code answers400with{"error": "invalid_grant", "error_description": "…"}(and spends the code); a missing or repeated parameter isinvalid_request, anygrant_typebutauthorization_codeisunsupported_grant_type. A request from a web page is refused: keys are for servers.Afterwards
The key is listed in Developers → API keys under your
client_name, marked as a connected app, where the person can revoke it. Use it like any key:GET /v1/mesays who it is. Connecting again makes a new key and leaves the old one working until it is revoked, so revoke the key you replace, or let the person do it.
TypeScript SDK
import { createHash, randomBytes } from 'node:crypto';
import { VocalStack } from '@vocalstack/sdk';
const DASHBOARD = process.env.VOCALSTACK_DASHBOARD_URL ?? 'https://app.vocalstack.com';
const REDIRECT_URI = 'https://example.com/api/vocalstack/callback';
// One sign-in in flight; keep these in your session store instead.
const pending = new Map<string, string>();
export function connectUrl(): string {
const state = randomBytes(24).toString('base64url');
const verifier = randomBytes(48).toString('base64url');
pending.set(state, verifier);
const url = new URL('/connect', DASHBOARD);
url.search = new URLSearchParams({
response_type: 'code',
client_name: 'example.com (My CMS)',
redirect_uri: REDIRECT_URI,
state,
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
code_challenge_method: 'S256',
scope: 'read write',
}).toString();
return url.toString();
}
export async function callback(query: URLSearchParams): Promise<string> {
const verifier = pending.get(query.get('state') ?? '');
pending.delete(query.get('state') ?? '');
if (!verifier) throw new Error('unknown or reused state');
if (query.get('error')) throw new Error(`not connected: ${query.get('error')}`);
const vs = new VocalStack({ baseUrl: 'https://api.vocalstack.com' });
const token = await vs.connect.token({ code: query.get('code') ?? '', codeVerifier: verifier, redirectUri: REDIRECT_URI });
console.log(`connected to ${token.account.name} (${token.scope})`);
const me = await new VocalStack({ baseUrl: 'https://api.vocalstack.com', apiKey: token.access_token }).me.get();
console.log(me.apiKeyId === token.key_id);
return token.access_token;
}
Python SDK
import base64
import hashlib
import os
import secrets
from urllib.parse import urlencode
from vocalstack import VocalStack
DASHBOARD = os.environ.get("VOCALSTACK_DASHBOARD_URL", "https://app.vocalstack.com")
REDIRECT_URI = "https://example.com/api/vocalstack/callback"
# One sign-in in flight; keep these in your session store instead.
pending: dict[str, str] = {}
def connect_url() -> str:
state = secrets.token_urlsafe(24)
verifier = secrets.token_urlsafe(48)
pending[state] = verifier
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
return f"{DASHBOARD}/connect?" + urlencode({
"response_type": "code",
"client_name": "example.com (My CMS)",
"redirect_uri": REDIRECT_URI,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
"scope": "read write",
})
async def callback(query: dict[str, str]) -> str:
verifier = pending.pop(query.get("state", ""), None)
if verifier is None:
raise RuntimeError("unknown or reused state")
if "error" in query:
raise RuntimeError(f"not connected: {query['error']}")
async with VocalStack("https://api.vocalstack.com") as vs:
token = await vs.connect.token(code=query.get("code", ""), code_verifier=verifier, redirect_uri=REDIRECT_URI)
print(f"connected to {token['account']['name']} ({token['scope']})")
async with VocalStack("https://api.vocalstack.com", api_key=token["access_token"]) as vs:
me = await vs.me.get()
print(me["apiKeyId"] == token["key_id"])
return token["access_token"]
HTTP + WebSocket
#!/usr/bin/env bash
# curl, openssl and jq, no SDK.
set -euo pipefail
API=https://api.vocalstack.com
DASHBOARD=${VOCALSTACK_DASHBOARD_URL:-https://app.vocalstack.com}
REDIRECT_URI=https://example.com/api/vocalstack/callback
STATE=$(openssl rand -base64 24 | tr '+/' '-_' | tr -d '=')
VERIFIER=$(openssl rand -base64 48 | tr '+/' '-_' | tr -d '=\n')
CHALLENGE=$(printf %s "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=')
echo "Open: $DASHBOARD/connect?response_type=code&client_name=My%20script&redirect_uri=$(jq -rn --arg u "$REDIRECT_URI" '$u|@uri')&state=$STATE&code_challenge=$CHALLENGE&code_challenge_method=S256&scope=read%20write"
read -rp "Paste the code from the address you were sent back to: " CODE
TOKEN=$(curl -sS "$API/v1/connect/token" \
--data-urlencode grant_type=authorization_code \
--data-urlencode "code=$CODE" \
--data-urlencode "code_verifier=$VERIFIER" \
--data-urlencode "redirect_uri=$REDIRECT_URI")
jq '{token_type, key_id, scope, account, error, error_description}' <<<"$TOKEN"
curl -sSf "$API/v1/me" -H "Authorization: Bearer $(jq -r .access_token <<<"$TOKEN")" | jq '{principalId, apiKeyId, scopes}'