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
| Use | When |
|---|---|
| OAuth | Your app acts for a person: an AI assistant, an MCP client, or an app that each user connects to their own VOIX account. |
| API keys | Server-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:
| Document | URL |
|---|---|
| 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.
| Resource | API | Scopes allowed |
|---|---|---|
https://api.voix.tech/public/v1 | REST API | read, manage, offline_access |
https://mcp.voix.tech/mcp | MCP server | read, offline_access |
| Scope | Allows |
|---|---|
read | Read calls, contacts, tasks, notes, documents, members and team reports the user can see. The REST API serves calls, tasks, contacts, topics, lenses and checks. |
manage | Create and change lenses, checks and topics, if the user’s role allows it. |
offline_access | Get 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.
| Field | Required | Value |
|---|---|---|
client_id | Yes | Exactly the URL the document is served from. |
client_name | Yes | Shown on the consent page, unless VOIX recognises your app. |
redirect_uris | Yes | The same rules as registration: https, or http on loopback with any port. |
token_endpoint_auth_method | No | none or private_key_jwt; see Signed client authentication. |
jwks_uri | No | Your public keys, for private_key_jwt. Must use https on the same origin as your client_id. |
grant_types | No | Must include authorization_code. Grant types VOIX does not issue are ignored. |
client_uri | No | Your 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
200response 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. Withoutmax-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_urisapply 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...| Part | Rule |
|---|---|
alg | RS256, ES256, PS256 or EdDSA, with a kid from your jwks_uri. |
typ | client-authentication+jwt, or left out. |
iss, sub | Your client_id. |
aud | The issuer, https://api.voix.tech, as a single string; see below for alternatives. |
exp | Required, at most 5 minutes ahead. |
jti | Required 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| Parameter | Required | Value |
|---|---|---|
response_type | Yes | code. |
client_id | Yes | From registration, or the URL of your metadata document. |
redirect_uri | Yes | Exactly one of the registered redirect URIs. On a loopback address the port may differ. |
scope | No | Space-separated scopes, allowed for the resource. Defaults to read offline_access. |
state | Recommended | A random value you check when the user comes back. |
code_challenge | Yes | The base64url SHA-256 of your code_verifier. |
code_challenge_method | Yes | S256. plain is not accepted. |
resource | Yes | The 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.techCheck 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/v1Refresh 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_tokenThe 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"
}| Error | Common cause |
|---|---|
invalid_request | A required parameter is missing or repeated, or the request is not form-encoded. |
invalid_client | Unknown client_id, a metadata document that is not valid, or a client assertion that is missing, invalid or already used. |
invalid_grant | The 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_type | A grant_type other than authorization_code or refresh_token. |
invalid_scope | A scope that is unknown or not allowed for the resource. |
invalid_target | A resource other than the ones listed above, or a different one than the code was issued for. |
invalid_redirect_uri | Registration only: a redirect URI that is not https or loopback, or has a fragment. |
invalid_client_metadata | Registration only: a missing or invalid client_name, or an unsupported grant or response type. |
access_denied | Authorization only: the user cancelled. |
unsupported_response_type | Authorization 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.