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.

  1. 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)
  2. VocalStack sends: the consent screen: sign in, pick the account, Authenticate (owners and admins only)
  3. VocalStack sends: redirect_uri?code=…&state=… (or ?error=access_denied&state=… on Cancel)
  4. You send: POST /v1/connect/token grant_type=authorization_code, code, code_verifier, redirect_uri (from your server)
  5. VocalStack sends: { access_token: "vs_key_…", token_type: "api_key", key_id, scope, account: { name } }
  1. Make a random state and a PKCE verifier (43 to 128 characters of A-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 /connect page with:

    Parameter Value
    response_type code
    client_name Your app as the person will recognise it, at most 100 characters: drbc.org (Payload CMS). The key is named after it.
    redirect_uri Where the dashboard sends the browser back to: absolute https, or http for localhost / 127.0.0.1 while you develop. No fragment.
    state Your random value; it comes back unchanged.
    code_challenge BASE64URL(SHA256(verifier)), without padding (43 characters).
    code_challenge_method S256
    scope read, write or read write. Keys with recordings:publish or 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.

  2. 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 to redirect_uri?error=access_denied&state=…. Either way, check state against the one you kept before you do anything else.

  3. Redeem the code on your server

    Within two minutes, post the code with the verifier and the same redirect_uri to POST /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 granted scope and 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_uri or an expired code answers 400 with {"error": "invalid_grant", "error_description": "…"} (and spends the code); a missing or repeated parameter is invalid_request, any grant_type but authorization_code is unsupported_grant_type. A request from a web page is refused: keys are for servers.

  4. 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/me says 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

connect.ts

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

connect.py

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

connect.sh

#!/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}'