Authentication
The KYC API uses the OAuth 2.0 client-credentials grant (RFC 6749 Β§4.4). Your backend exchanges a client_id and client_secret for a short-lived Bearer token, then presents that token on every /v1/* call.
Credentials are server-side only. Never ship a
client_secretβ or an access token β to a browser or mobile app. Your customers interact with the widget, never with the API.
Request a Token
Endpoint: POST /oauth/token
Content-Type: application/x-www-form-urlencoded
curl --request POST \
--url https://{FQDN}/oauth/token \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data grant_type=client_credentials \
--data client_id=$KYC_CLIENT_ID \
--data client_secret=$KYC_CLIENT_SECRET
Credentials may also be sent as HTTP Basic authentication (client_secret_basic) instead of in the body:
curl --request POST \
--url https://{FQDN}/oauth/token \
--user "$KYC_CLIENT_ID:$KYC_CLIENT_SECRET" \
--data grant_type=client_credentials
Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIsβ¦",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "sessions verifications"
}
grant_type must be client_credentials β it is the only grant this endpoint supports.
Scopes
Each endpoint requires a specific scope. Tokens are issued with the scopes granted to your client; request a narrower set with an optional scope parameter when a component of your system only needs one of them.
| Scope | Grants access to |
|---|---|
sessions | POST /v1/sessions, GET /v1/sessions/{sessionId} |
verifications | POST /v1/verifications, GET /v1/verifications/{verificationId} |
Each door reads at its own path, and only its own rows. A sessions token reads widget sessions; a verifications token reads the verifications it created. Neither reaches the other's records, so a component of your system that only submits server-to-server verifications needs nothing but verifications.
--data scope=sessions
Requesting an unknown scope is rejected rather than silently ignored. A token that lacks the scope an endpoint requires returns 403, not 401 β the token is valid, it just is not authorized for that call.
Using the Token
Present the token as a Bearer credential:
curl --request POST \
--url https://{FQDN}/v1/sessions \
--header "Authorization: Bearer $ACCESS_TOKEN" \
--header 'Content-Type: application/json' \
--data '{"userRef": "user-123"}'
Token Lifetime and Refresh
Tokens are RS256-signed JWTs and expire β expires_in tells you when, in seconds. Cache the token and refresh it before expiry; do not request a new token per API call, and do not hardcode a token anywhere.
A robust client:
- Caches the token in memory with its expiry timestamp.
- Refreshes when the remaining lifetime drops below a small margin (30-60 seconds).
- Retries once on a
401, in case the token expired between the check and the call. - Treats a
503as a transient infrastructure failure rather than an auth failure β backs off and retries with the same token.
Error Responses
Token endpoint errors follow RFC 6749 Β§5.2 β an error code with a human-readable error_description:
| Status | error | Cause |
|---|---|---|
400 | unsupported_grant_type | grant_type was absent or not client_credentials |
400 | invalid_scope | The requested scope contains an unknown value |
401 | invalid_client | Missing, unknown, or incorrect client credentials. The response carries a WWW-Authenticate: Basic challenge |
{
"error": "invalid_client",
"error_description": "Missing client credentials"
}
Every refusal on a /v1/* call β not just authentication β answers with the same RFC 9457 application/problem+json document. Branch on errorCode; errors[] names the fields a validation failure applies to, and carries one entry with no field for a refusal that is not about a field.
{
"type": "docs#/components/schemas/ProblemDetails",
"title": "Unauthorized",
"status": 401,
"errorCode": "unauthorized",
"errors": [
{ "message": "Missing Bearer token (obtain one at POST /oauth/token)", "type": "unauthorized" }
],
"correlationId": "c0d60e879b6d412488ffe83d5213aa8b",
"instance": "/v1/sessions/nope"
}
Quote the correlationId when you contact support β it identifies the exact request in our logs. The token endpoint is the one exception: POST /oauth/token follows RFC 6749 as shown above.
Errors on /v1/* calls:
| Status | Cause | How to fix |
|---|---|---|
401 | No Authorization: Bearer header | Send the header; obtain a token at POST /oauth/token |
401 | Token expired | Refresh the token and retry |
401 | Token invalid or unverifiable | Confirm you are using the token verbatim, and against the matching environment |
403 | Token is valid but lacks the endpoint's scope | Request a token carrying the required scope |
503 | The identity provider could not be reached to verify the token's signature | Retry with backoff β your token is still valid |
The 403 response includes WWW-Authenticate: Bearer error="insufficient_scope" naming the scope that was required.
A 503 is transient and is not an authentication failure. Your credentials and your cached token are unaffected β Inyo could not reach the identity provider to verify the token's signature. Retry with exponential backoff and keep the token you have: re-authenticating does not help, and a POST /oauth/token call made during the same outage is likely to fail too.
{
"errorCode": "service_unavailable",
"errors": [
{ "message": "Identity provider unreachable β cannot verify access tokens", "type": "service_unavailable" }
]
}
Environment Isolation
Sandbox and production issue separate credentials, and each token is valid only in the environment that issued it. Sessions are strictly scoped to the tenant that created them: a token can never read another tenant's session, and a request for one returns 404.
Rotating Your Secret
Client secrets are rotated by Inyo on request β contact your Inyo representative rather than attempting rotation through the API. Rotate immediately if a secret may have been exposed.
Next Steps
- Getting Started β run a full verification with your new token
- Verification Sessions β every field on
POST /v1/sessions
