Voznest Forge

Workspace

WorkspaceProjects

Development

DocsAPI ReferenceSDKsEmbed StudioChangelog
  1. Forge
  2. /Docs

Start here

Getting StartedQuickstartPlatform ConceptsEnvironments

Build

ProjectsIdentityAgentsGatewayCapabilitiesPermissions ReferenceInstallationsLive activity

Commands & interactions

CommandsContext-menu CommandsInteractionsComponentsModals (Forms)Activities (Embedded Apps)

Acting in hubs

MessagesEmbedsEmbeds v2 (Blocks)Embeds v3Incoming WebhooksHub Management APIEvent StreamDelivery

Sign in with Voznest

AccessOpenID Connect

API

APIAuthenticationErrorsRate LimitsSecurity

Publish & operate

App Market & ListingTemplates & SandboxObservability & Team Tools

Tools

SDKsExamplesFAQ & TroubleshootingReference

Sign in with Voznest

Access

Sign in with Voznest: OAuth 2.0 with PKCE, and OpenID Connect.

Access lets a person sign in to your site with Voznest, share their hubs and roles, or add your agent to a hub they manage — with their explicit consent. It follows OAuth 2.0 authorization code with PKCE, and Voznest is also a full OpenID Connect provider (see OpenID Connect).

Value
Authorizehttps://voznest.com/oauth/authorize
TokenPOST https://api.voznest.com/api/v1/oauth/token
RevokePOST https://api.voznest.com/api/v1/oauth/revoke
IntrospectPOST https://api.voznest.com/api/v1/oauth/introspect
UserInfo (OIDC)GET https://api.voznest.com/api/v1/oauth/userinfo
Client ID<project id> (Production) or <project id>.development / .staging — each environment has its own redirect URLs and secret
PKCES256 only. Required when the environment says so (Access).
LifetimesCode 5 min · access token 1 h · refresh token 30 days · ID token 1 h

Scopes

ScopeGrants
openidOpenID Connect: an id_token in the token response, and /oauth/userinfo. Always allowed.
identifyUsername, display name, avatar and banner — GET /oauth/@me
profileBio, pronouns and badges (added to /oauth/@me)
emailEmail address and whether it's verified (added to /oauth/@me)
hubsThe hubs the person belongs to — GET /oauth/@me/hubs
hubs.members.readTheir roles and permissions in a hub — GET /oauth/@me/hubs/:id/member (e.g. only let a hub's admins in)
connectionsThe linked accounts they show on their profile (GitHub, Steam…) — GET /oauth/@me/connections
agent.installInstall the project's agent into a hub the person manages (they pick it on the consent screen)

Only scopes enabled for the environment in Access can be requested (openid always can). Consent is all or nothing: the person approves every scope you asked for, or cancels — ask only for what you need.

Redirect URLs

They must match exactly what you registered for that environment. In Development, a registered loopback URL (http://localhost/callback, http://127.0.0.1/…, http://[::1]/…) also matches on any port (RFC 8252): register it once and use :3000, :5173, :8080… If a redirect URL doesn't match, the error says what arrived, which environment was searched and, when it's registered elsewhere, which client_id you meant.

ts
// 1. Send the person to the consent screen (PKCE, S256)const verifier = base64url(crypto.randomBytes(32));const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());const url = new URL("https://voznest.com/oauth/authorize");url.search = new URLSearchParams({  client_id: "<project id>",            // "<project id>.development" for Development  redirect_uri: "https://example.com/callback",  // registered in Access, exact match  response_type: "code",  scope: "identify email",  state: csrfToken,  code_challenge: challenge,  code_challenge_method: "S256",}).toString();
ts
// 2. On your callback: exchange the code (valid 5 minutes, single use)const res = await fetch("https://api.voznest.com/api/v1/oauth/token", {  method: "POST",  headers: { "Content-Type": "application/json" },  body: JSON.stringify({    grant_type: "authorization_code",    code, redirect_uri: "https://example.com/callback",    client_id: "<project id>",    client_secret: process.env.VOZNEST_CLIENT_SECRET, // or code_verifier for public clients    code_verifier: verifier,  }),});const { access_token, refresh_token, expires_in, scope } = await res.json();// 3. Call the API as that personconst me = await fetch("https://api.voznest.com/api/v1/oauth/@me", { headers: { Authorization: `Bearer ${access_token}` } }).then((r) => r.json());

The token response

ts
// POST /oauth/token → a plain object (RFC 6749 §5.1), NOT the { success, data } envelope{  "access_token": "qat_…",  "token_type": "Bearer",  "expires_in": 3600,  "refresh_token": "qrt_…",  "scope": "openid identify email",   // the scopes granted (always all of the ones requested)  "id_token": "eyJhbGciOiJFUzI1NiIs…"  // only with the openid scope}

GET /oauth/@me

ts
// GET /oauth/@me  (envelope: { "success": true, "data": … }){  "id": "65f0a1b2c3d4e5f6a7b8c9d0",  "username": "ronard",  "displayName": "Ronard",  "avatarUrl": "https://api.voznest.com/files/avatars/….webp",   // absolute, or null  "bannerUrl": null,  "hue": 250,                 // fallback avatar colour (0-359) when avatarUrl is null  "bannerHue": 290,  "profileUrl": "https://voznest.com/app/profile/65f0a1b2c3d4e5f6a7b8c9d0",  "createdAt": "2024-03-12T18:22:05.000Z",  // profile scope  "bio": "…", "pronouns": "they/them", "badges": ["early_supporter"],  // email scope  "email": "[email protected]", "emailVerified": true}// Images: add ?s=64|128|256|512 for a resized WebP (cached for a year; the URL changes when the image does).

Refreshing

ts
await fetch("https://api.voznest.com/api/v1/oauth/token", {  method: "POST",  headers: { "Content-Type": "application/json" },  body: JSON.stringify({ grant_type: "refresh_token", refresh_token, client_id: "<project id>", client_secret }),});// → a NEW access_token and a NEW refresh_token. The old refresh token stops working;//   presenting it again revokes every token of that authorization.

Revoking and introspecting

ts
# Sign the person out of your app (either token of the pair revokes both)curl -X POST https://api.voznest.com/api/v1/oauth/revoke   -d token=$REFRESH_TOKEN -d token_type_hint=refresh_token   -d client_id=$CLIENT_ID -d client_secret=$CLIENT_SECRET# → 200 {} — always, even if the token was already revoked or never existed (RFC 7009 §2.2)
ts
# Is this token live? (confidential clients, their own tokens only)curl -u "$CLIENT_ID:$CLIENT_SECRET" -d "token=$TOKEN" https://api.voznest.com/api/v1/oauth/introspect# → { "active": true, "scope": "identify email", "client_id": "…", "sub": "…", "username": "…",#     "token_type": "Bearer", "exp": 1792653600, "iat": 1792650000, "iss": "https://voznest.com" }# → { "active": false }  for an expired, revoked or unknown token

Consent and prompt

Once someone authorized your app, asking again for the same (or fewer) scopes skips the consent screen. Add prompt=consent to always show it, or prompt=none to never show it: the browser comes straight back with a code, or with error=consent_required / error=login_required.

When a person revokes access

People see every app they signed in to in Settings → Authorized Apps and can revoke it. Subscribe to oauth.authorization.revoked and user.deleted (Event Stream) to erase what you keep about them; their tokens stop working immediately.

Errors

Errors from /oauth/token, /oauth/revoke and /oauth/introspect use the standard { "error": "invalid_grant", "error_description": "…" } shape (not the envelope). These endpoints accept JSON or application/x-www-form-urlencoded, and the client can authenticate with body fields or HTTP Basic.

errorWhen
invalid_requestA required parameter is missing or malformed
invalid_clientUnknown client_id, wrong client_secret, or a public client without PKCE (401)
invalid_grantThe code or refresh token is invalid, expired, already used, issued to another client, or the PKCE verifier/redirect_uri doesn't match
unauthorized_clientThe client isn't allowed to use this grant
unsupported_grant_typegrant_type isn't authorization_code or refresh_token
invalid_scopeA scope isn't enabled for the environment (returned to the redirect URL at authorize time)
PreviousDeliveryNext OpenID Connect