Skip to Content

OAuth

OAuth lets your app act as a VOIX user. The user signs in to VOIX, picks a workspace and approves what your app may do. Your app gets its own token, limited to what that user can see and do, which the user can disconnect at any time.

VOIX follows OAuth 2.1: the authorization code flow with PKCE, for public clients. There are no client secrets. Apps with a client ID metadata document can also sign their token requests with their own key.

OAuth or API keys

UseWhen
OAuthYour app acts for a person: an AI assistant, an MCP client, or an app that each user connects to their own VOIX account.
API keysServer-to-server: your phone system sending calls, or a sync that reads data for the whole workspace.

An OAuth token never has more access than the user who approved it. If they lose a permission or leave the workspace, the token loses it too.

Discovery

Clients that support discovery, such as MCP clients, need only the resource URL. The metadata documents describe everything else:

DocumentURL
Authorization server (RFC 8414 )https://api.voix.tech/.well-known/oauth-authorization-server
REST API resource (RFC 9728 )https://api.voix.tech/.well-known/oauth-protected-resource/public/v1
MCP server resource (RFC 9728 )https://mcp.voix.tech/.well-known/oauth-protected-resource/mcp

The issuer is https://api.voix.tech.

Scopes and resources

Every authorization names one resource, the API the token is for, with the resource parameter (RFC 8707 ). A token only works on that resource.

ResourceAPIScopes allowed
https://api.voix.tech/public/v1REST APIread, manage, offline_access
https://mcp.voix.tech/mcpMCP serverread, offline_access
ScopeAllows
readRead calls, contacts, tasks, notes, documents, members and team reports the user can see. The REST API serves calls, tasks, contacts, topics, lenses and checks.
manageCreate and change lenses, checks and topics, if the user’s role allows it.
offline_accessGet a refresh token, so your app stays connected after the access token expires.

OAuth tokens cannot send calls. Use an API key with the Ingest API for that.

Client ID metadata documents

The preferred way to identify your app is a client ID metadata document (draft-ietf-oauth-client-id-metadata-document ). You host a small JSON document on your own domain, and its URL is your client_id. There is nothing to register: VOIX fetches the document the first time a user connects. The authorization server metadata advertises this with client_id_metadata_document_supported: true. MCP clients such as Claude, ChatGPT and VS Code use it automatically.

Dynamic registration still works, but MCP deprecates it in favour of metadata documents.

{ "client_id": "https://acme.example.com/oauth/client-metadata.json", "client_name": "Acme CRM", "client_uri": "https://acme.example.com", "redirect_uris": ["https://acme.example.com/oauth/callback"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none" }

The URL must use https and have a path. It cannot have a query, a fragment, user info or . and .. segments, and is at most 2,000 characters. VOIX compares it exactly as a string, so send the same client_id on every request.

FieldRequiredValue
client_idYesExactly the URL the document is served from.
client_nameYesShown on the consent page, unless VOIX recognises your app.
redirect_urisYesThe same rules as registration: https, or http on loopback with any port.
token_endpoint_auth_methodNonone or private_key_jwt; see Signed client authentication.
jwks_uriNoYour public keys, for private_key_jwt. Must use https on the same origin as your client_id.
grant_typesNoMust include authorization_code. Grant types VOIX does not issue are ignored.
client_uriNoYour website.

A document that carries client_secret or client_secret_expires_at, or names a client_secret_* authentication method, is refused. VOIX never loads logo_uri.

Serving the document:

  • VOIX accepts only a 200 response with a JSON content type. Redirects are not followed.
  • The document must be under 5 KB and arrive within 5 seconds.
  • Your domain must resolve to a public address.
  • VOIX caches the document for its Cache-Control: max-age, between 5 minutes and 24 hours. Without max-age, it fetches again after 5 minutes. Failed fetches are never cached.
  • If your document cannot be fetched when a user connects, the consent page asks them to try again. Token and refresh requests use the stored copy and check the document again after 7 days. If it is then removed (404 or 410) or no longer valid, the app’s connections stop refreshing. If your server is only unreachable, the stored copy keeps working for up to 30 days.
  • Changes to redirect_uris apply to new authorizations; existing connections keep working.

Signed client authentication

A metadata document that lists private_key_jwt, as its token_endpoint_auth_method or in token_endpoint_auth_methods_supported, and gives a jwks_uri must prove every code exchange and refresh with a signed JWT (RFC 7523 ). Revoke requests may carry one too. A stolen code or refresh token is then useless without your private key. The document must still offer none, as its token_endpoint_auth_method or in token_endpoint_auth_methods_supported. Inline jwks are not supported, and registered clients stay public.

Send the assertion with the request. client_id is optional when an assertion is sent:

curl https://api.voix.tech/oauth/token \ -d grant_type=refresh_token \ -d refresh_token=your_refresh_token \ -d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \ -d client_assertion=eyJhbGciOiJFUzI1NiIs...
PartRule
algRS256, ES256, PS256 or EdDSA, with a kid from your jwks_uri.
typclient-authentication+jwt, or left out.
iss, subYour client_id.
audThe issuer, https://api.voix.tech, as a single string; see below for alternatives.
expRequired, at most 5 minutes ahead.
jtiRequired and single use: VOIX refuses an assertion it has already seen.

aud may also be the token endpoint URL, https://api.voix.tech/oauth/token, at every endpoint, or the URL of the endpoint you call. An array is refused.

VOIX allows 30 seconds of clock skew for exp, iat and nbf. It caches your key set for 10 minutes, and fetches it again at most once a minute when an assertion names a kid it does not know, so publish a new key before you sign with it. The key set must be under 64 KB.

A code exchange or refresh without an assertion fails with invalid_client. A refused code exchange still spends the code. A refused refresh does not spend the refresh token, so you can retry it with an assertion. This includes connections made before you added a key. Once a connection has been created or refreshed with an assertion, it needs one on every refresh. If your document later drops private_key_jwt, those connections can no longer refresh and users must connect your app again. Revoke works without an assertion, but one that is sent is checked. An app whose document does not list private_key_jwt gets invalid_client if it sends an assertion.

How VOIX shows your app: the consent page and Connected apps show the domain of your client_id, and the consent page always shows where the user returns to. VOIX recognises a small set of well-known apps by their exact client_id and shows its own name and logo for them, whatever their document says. A recognised app returning over https to its own website is marked Verified. A recognised app returning to a loopback address is shown as running on the user’s computer, with no badge. Every other app is shown as unverified.

1. Register a client

Skip this step if you use a client ID metadata document.

Register your app once with dynamic client registration (RFC 7591 ). All clients are public clients (token_endpoint_auth_method is none).

curl https://api.voix.tech/oauth/register \ -H "Content-Type: application/json" \ -d '{ "client_name": "Acme CRM", "client_uri": "https://acme.example.com", "redirect_uris": ["https://acme.example.com/oauth/callback"] }'

Response (201 Created):

{ "client_id": "your_client_id", "client_name": "Acme CRM", "client_uri": "https://acme.example.com", "redirect_uris": ["https://acme.example.com/oauth/callback"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none" }

Store the client_id. Redirect URIs must use https, or http on a loopback address (127.0.0.1, [::1] or localhost, any port) for desktop and command line apps. They cannot contain a fragment. Registration is rate limited.

2. Send the user to VOIX

Create a random code_verifier (43 to 128 characters) and its code_challenge, the base64url-encoded SHA-256 of the verifier. Keep the verifier and a random state for this sign-in, then open the authorize URL in the user’s browser:

https://console.voix.tech/oauth/authorize ?response_type=code &client_id=your_client_id &redirect_uri=https%3A%2F%2Facme.example.com%2Foauth%2Fcallback &scope=read%20offline_access &state=af0ifjsldkj &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM &code_challenge_method=S256 &resource=https%3A%2F%2Fapi.voix.tech%2Fpublic%2Fv1
ParameterRequiredValue
response_typeYescode.
client_idYesFrom registration, or the URL of your metadata document.
redirect_uriYesExactly one of the registered redirect URIs. On a loopback address the port may differ.
scopeNoSpace-separated scopes, allowed for the resource. Defaults to read offline_access.
stateRecommendedA random value you check when the user comes back.
code_challengeYesThe base64url SHA-256 of your code_verifier.
code_challenge_methodYesS256. plain is not accepted.
resourceYesThe resource the token is for, from Scopes and resources.

3. The user approves

VOIX asks the user to sign in if they have not, then shows a consent page with your app’s name, the domain it redirects to and what it will be able to do. Apps registered through dynamic registration are shown as unverified; see how VOIX shows your app for metadata documents. If the user belongs to more than one workspace, they choose which one to connect.

Connecting an app needs a passkey and a recent sign-in. If the user signed in more than 15 minutes ago, VOIX asks them to confirm with their passkey before approving. A user without a passkey signs in again if needed and adds one first. Nothing changes for your app: the redirect back to your redirect_uri comes after these steps.

If the client or redirect URI is not valid, VOIX shows an error and does not redirect. Otherwise the browser returns to your redirect_uri, with a code or with an error such as invalid_request, unsupported_response_type, invalid_scope or invalid_target:

https://acme.example.com/oauth/callback?code=your_code&state=af0ifjsldkj&iss=https%3A%2F%2Fapi.voix.tech

Check that state matches and that iss is https://api.voix.tech (RFC 9207 ). If the user cancels, the redirect carries error=access_denied instead of a code.

The code is single use and expires after 60 seconds.

4. Exchange the code

Send the code, your verifier and the same redirect_uri and resource to the token endpoint, form-encoded:

curl https://api.voix.tech/oauth/token \ -d grant_type=authorization_code \ -d client_id=your_client_id \ -d code=your_code \ -d redirect_uri=https://acme.example.com/oauth/callback \ -d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk \ -d resource=https://api.voix.tech/public/v1
{ "access_token": "eyJhbGciOiJSUzI1NiIs...", "token_type": "Bearer", "expires_in": 900, "scope": "read offline_access", "refresh_token": "your_refresh_token" }

Send the access token as a bearer token to the resource it was issued for:

curl https://api.voix.tech/public/v1/calls \ -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."

Access tokens last 15 minutes. A refresh_token is returned only when the user approved offline_access.

5. Refresh

When the access token expires, exchange the refresh token for a new pair:

curl https://api.voix.tech/oauth/token \ -d grant_type=refresh_token \ -d client_id=your_client_id \ -d refresh_token=your_refresh_token \ -d resource=https://api.voix.tech/public/v1

Refresh tokens rotate: every refresh returns a new refresh token and the old one stops working. Store the new one before you use the access token. If an old refresh token is used again, VOIX treats it as stolen and revokes the whole connection; the user has to approve your app again.

You can pass a narrower scope when refreshing. The resource cannot change. If the user no longer has access to the workspace, the refresh fails with invalid_grant.

6. Revoke

When the user disconnects in your app, revoke the refresh token (RFC 7009 ). This ends the connection and every token issued from it.

curl https://api.voix.tech/oauth/revoke \ -d client_id=your_client_id \ -d token=your_refresh_token

The response is always 200, even for a token that was already revoked.

Connected apps

Users see every app they have connected under Settings, Apps in the VOIX console, with its workspace, access and when it was last used. Disconnecting an app there revokes it straight away: its refresh token stops working and its access token expires within 15 minutes.

Errors

The OAuth endpoints answer with the error codes from RFC 6749 , as JSON:

{ "error": "invalid_grant", "error_description": "The authorization code has expired" }
ErrorCommon cause
invalid_requestA required parameter is missing or repeated, or the request is not form-encoded.
invalid_clientUnknown client_id, a metadata document that is not valid, or a client assertion that is missing, invalid or already used.
invalid_grantThe code or refresh token is invalid, expired or already used, the code_verifier or redirect_uri does not match, or the user lost access.
unsupported_grant_typeA grant_type other than authorization_code or refresh_token.
invalid_scopeA scope that is unknown or not allowed for the resource.
invalid_targetA resource other than the ones listed above, or a different one than the code was issued for.
invalid_redirect_uriRegistration only: a redirect URI that is not https or loopback, or has a fragment.
invalid_client_metadataRegistration only: a missing or invalid client_name, or an unsupported grant or response type.
access_deniedAuthorization only: the user cancelled.
unsupported_response_typeAuthorization only: a response_type other than code.

On the REST API, an invalid or expired OAuth token returns 401 with WWW-Authenticate: Bearer error="invalid_token". A request without a token returns 401 with a challenge that has no error code. A token without the scope a request needs returns 403 with WWW-Authenticate: Bearer error="insufficient_scope". See Errors.

Last updated on