Inyo

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.

ScopeGrants access to
sessionsPOST /v1/sessions, GET /v1/sessions/{sessionId}
verificationsPOST /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:

  1. Caches the token in memory with its expiry timestamp.
  2. Refreshes when the remaining lifetime drops below a small margin (30-60 seconds).
  3. Retries once on a 401, in case the token expired between the check and the call.
  4. Treats a 503 as 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:

StatuserrorCause
400unsupported_grant_typegrant_type was absent or not client_credentials
400invalid_scopeThe requested scope contains an unknown value
401invalid_clientMissing, 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:

StatusCauseHow to fix
401No Authorization: Bearer headerSend the header; obtain a token at POST /oauth/token
401Token expiredRefresh the token and retry
401Token invalid or unverifiableConfirm you are using the token verbatim, and against the matching environment
403Token is valid but lacks the endpoint's scopeRequest a token carrying the required scope
503The identity provider could not be reached to verify the token's signatureRetry 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