Sentent Core API
OAuth 2.1 and OpenID Connect endpoints for applications that authenticate against Sentent Core.
Sentent Core is a standalone identity provider. It answers three questions — who is this user, which tenant do they belong to, and what role do they hold there — and hands your application a signed, verifiable identity.
Everything below is standards-based: OAuth 2.1 authorization code with mandatory PKCE, OpenID Connect Core with discovery, RFC 7662 introspection, and RFC 7009 revocation. Tokens are signed EdDSA (Ed25519) and verifiable offline against the published JWKS, so an off-the-shelf client (Auth.js v5, openid-client, any conformant OIDC library) works against Core with no custom code.
There is no client-registration endpoint. Applications are registered by a Core operator, who issues the client_id and client_secret and configures the exact redirect URIs.
https://core.sentent.ai
The deployment's issuer — Core runs per environment, and the default shown is the SententAI production issuer. Confirm yours from the issuer field of the discovery document, and build every other URL from that document rather than hardcoding paths.
curl https://core.sentent.ai/.well-known/openid-configurationStandards implemented
OAuth 2.1
Authorization code for people,
client_credentialsfor machines. No implicit, no hybrid, no password grant.Machine identities are separate
A service account is never a user: no membership, no session, no refresh token, and a
principal_type: "service"claim so your API can refuse to treat one as a person.PKCE (S256), required
Not optional and not just for public clients —
/authorizerejects any request without acode_challenge, andplainis never accepted.OpenID Connect Core + Discovery
openid profile emailscopes, anid_tokenon the code exchange, UserInfo, and a discovery document at the well-known path.RFC 7662 — Token introspection
The authoritative liveness check: signature, expiry, session revocation, and current membership in one call.
RFC 7009 — Token revocation
Revoke by refresh token or access token. Always 200, so a caller cannot probe for valid tokens.
OIDC RP-Initiated Logout 1.0
An
end_session_endpointthat ends the browser's Core session and revokes the one session yourid_token_hintnames. Post-logout redirect targets are registered, never taken on trust.OIDC Back-Channel Logout 1.0
Every session revocation — user-initiated, operator-initiated, or a deactivated membership — POSTs a signed
logout_tokencarryingsidto your registered endpoint. Best-effort, not queued: the short access-token TTL remains the backstop.EdDSA (Ed25519) signatures
Both token types are signed with the same key and verifiable offline against the published JWKS.
Versioned REST management API
/api/v1— plural nouns, nesting no deeper than two, actions modelled as resources (session-revocations) rather than verbs in URLs. Path-versioned and additive-only: a breaking change would publish/api/v2alongside this one, never mutate it.Keyset pagination and idempotent writes
Lists page by cursor on
(created_at, id), so a row created ahead of your position cannot push another past you unseen. Mutating calls honourIdempotency-Key: a retry replays the first answer, a reused key with a different body is a 409, and a 5xx releases the claim so the retry runs for real.One authority model, two front doors
The management API and Core's own delegated console enforce the SAME grant hierarchy from the same module — a tenant-scoped principal acts with admin authority, owner/admin grants need a vendor principal, and no path may leave a tenant with zero active owners. The rules are not re-derived per surface, so they cannot drift.
This page is generated from the OpenAPI 3.1 document. Pull it machine-readably at /docs/openapi.json, or as one plain-markdown file for agents and LLM-backed integrations at /docs/llms.txt.
Authentication
Applications are registered by a Core operator, who issues the client_id and client_secret. There is no self-service or dynamic client registration. Confidential-client credentials belong on your server — never in a browser bundle or a mobile binary.
client_secret_basicsecurityScheme client_secret_basicHTTP Basic on the token, introspection, and revocation endpoints. Preferred — it keeps the secret out of the request body and is what openid-client and Auth.js send by default. Both credential halves are form-urlencoded before base64 encoding, so a secret containing : or reserved characters round-trips correctly.
Authorization: Basic $(printf '%s:%s' "$CLIENT_ID" "$CLIENT_SECRET" | base64)client_secret_postrequest-body parametersThe same credentials as client_id and client_secret form parameters in the request body. Accepted everywhere Basic is, and used automatically when the Authorization header is absent or malformed. Not modelled as an OpenAPI security scheme — OpenAPI has no type for body-borne client credentials — so it appears as two request-body parameters instead.
client_id=app_reporting_portal&client_secret=sk_live_…Bearer access tokensecurityScheme bearerA Core-issued access token in the Authorization header, used by UserInfo. Your own APIs should verify these offline against the JWKS rather than calling Core per request.
Authorization: Bearer eyJhbGciOiJFZERTQSIsImtpZCI6…Bearer service token (management API)securityScheme service_bearerA SERVICE access token in the Authorization header — the whole of /api/v1. Get one from POST /api/oauth/token with grant_type=client_credentials and the service-account credentials a Core operator issued you; they are NOT your application's client_id/client_secret. Tokens carry principal_type: "service", live about five minutes, and cannot be refreshed — request another. What the token may do is decided by its core: scopes and by the tenant it belongs to, both fixed by the operator when the account was created.
curl -X POST https://core.sentent.ai/api/oauth/token -u "$SVC_ID:$SVC_SECRET" -d grant_type=client_credentials -d scope="core:memberships:read core:memberships:write"Discovery
Public, cacheable documents that let a client configure itself. No authentication, CORS open.
/.well-known/openid-configurationOpenID provider metadataThe discovery document. Point any standards-compliant OIDC client at the issuer and it will find everything else from here. Public, CORS-open, cacheable for 300 seconds. Endpoint URLs are built from the configured issuer, so they are correct per deployment.
Example request
curl https://core.sentent.ai/.well-known/openid-configurationResponse
200 The provider metadata.
{
"issuer": "https://core.sentent.ai",
"authorization_endpoint": "https://core.sentent.ai/authorize",
"token_endpoint": "https://core.sentent.ai/api/oauth/token",
"userinfo_endpoint": "https://core.sentent.ai/api/oauth/userinfo",
"introspection_endpoint": "https://core.sentent.ai/api/oauth/introspect",
"revocation_endpoint": "https://core.sentent.ai/api/oauth/revoke",
"end_session_endpoint": "https://core.sentent.ai/api/oauth/logout",
"backchannel_logout_supported": true,
"backchannel_logout_session_supported": true,
"jwks_uri": "https://core.sentent.ai/.well-known/jwks.json",
"response_types_supported": [
"code"
],
"response_modes_supported": [
"query"
],
"grant_types_supported": [
"authorization_code",
"refresh_token",
"client_credentials"
],
"scopes_supported": [
"openid",
"profile",
"email"
],
"claims_supported": [
"sub",
"iss",
"aud",
"exp",
"iat",
"nonce",
"email",
"email_verified",
"name",
"picture",
"tenant",
"role",
"sid"
],
"id_token_signing_alg_values_supported": [
"EdDSA"
],
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post"
],
"code_challenge_methods_supported": [
"S256"
],
"subject_types_supported": [
"public"
]
}/.well-known/jwks.jsonPublic signing keys (JWKS)Core's Ed25519 public keys. Fetch once, cache for the advertised 300 seconds, and verify access tokens and ID tokens offline — the happy path never calls Core. Re-fetch when you see a kid you do not recognise; that is how key rotation reaches you.
Example request
curl https://core.sentent.ai/.well-known/jwks.jsonResponse
200 The key set.
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "Nn0qLQ2sV7bR9tXcYpJ4wKmE1gHdU6zAoS3fIvB5rTk",
"kid": "f23aH3lG_gjkQO3UZVjHmWxqpL2GYYv-n2JQV7CV44I",
"alg": "EdDSA",
"use": "sig"
}
]
}Errors
| Status | Code | Meaning |
|---|---|---|
| 500 | server_error | The key set could not be read. Keep using your cached copy and retry. |
Tokens
The back-channel half: exchange the code, rotate refresh tokens, read claims for an access token.
/api/oauth/tokenExchange a code, rotate a refresh token, or mint a service tokenThe token endpoint. Confidential clients only — call it from your backend, never from a browser.
authorization_code exchanges the single-use code from /authorize for an access token, a refresh token, and a new revocable session. refresh_token rotates the refresh token and re-issues an access token, RE-VALIDATING that the user is still an active member of the tenant first: a deactivated user or removed membership gets invalid_grant and the session is revoked on the spot.
Refresh tokens are single-use. The value you send is dead the moment the response is written — persist the new one.
client_credentials is for MACHINE principals — a service authenticating as itself, with no user involved. Its credentials are a *service account*, issued separately by a Core operator, and are not interchangeable with an application's client_id/client_secret. The response carries no refresh token and no id_token; the access token lives ~5 minutes and carries principal_type: "service", so your API can tell a machine from a person. Re-request when it expires.
client_secret_basicParameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| grant_type | form | authorization_code | refresh_token | client_credentials | required | Which grant to run. Anything else is unsupported_grant_type. |
| code | form | string | optional | authorization_code grant — the single-use code from /authorize. |
| redirect_uri | form | string | optional | authorization_code grant — must be byte-identical to the one used at /authorize. |
| code_verifier | form | string | optional | authorization_code grant — the PKCE verifier. Always required in practice, since /authorize will not mint a code without a challenge. |
| refresh_token | form | string | optional | refresh_token grant — the current refresh token. |
| scope | form | string | optional | client_credentials grant only — a space-delimited SUBSET of the scopes your service account holds. Omit it to receive all of them. Asking for anything outside the allow-list is invalid_scope; Core never silently narrows a service grant. |
| client_id | form | string | optional | Required only when authenticating with client_secret_post. Omit when using HTTP Basic. |
| client_secret | form | string | optional | Required only when authenticating with client_secret_post. Omit when using HTTP Basic. |
Example request
curl -X POST https://core.sentent.ai/api/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code=Ab3xQ9pL7t \
-d redirect_uri=https://app.example.com/auth/callback \
-d code_verifier=dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXkcurl -X POST https://core.sentent.ai/api/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=refresh_token \
-d refresh_token="$REFRESH_TOKEN"curl -X POST https://core.sentent.ai/api/oauth/token \
-u "$SERVICE_CLIENT_ID:$SERVICE_CLIENT_SECRET" \
-d grant_type=client_credentials \
-d scope="crm:read"Response
200 Tokens issued.
{
"access_token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImYyM2FIM2xHIiwidHlwIjoiSldUIn0.eyJ0ZW5hbnQiOiJiNDFkN2MyNiJ9.Yx8s2Q",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "kUu0aVQ8Xn2rP7dLm9ZfHcT4bWyEoS1gJvNqIx3RtA0",
"scope": "openid profile email",
"id_token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImYyM2FIM2xHIiwidHlwIjoiSldUIn0.eyJlbWFpbCI6ImRhbmFAY2xpZW50LmV4YW1wbGUifQ.7Kd1Rw"
}Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_grant | unsupported_grant_type for an unknown grant; invalid_request for missing credentials or missing grant parameters; invalid_grant for a code that is expired, already used, bound to another client or redirect_uri, a failed PKCE check, an unknown or revoked refresh token, or a user whose access has been revoked; invalid_scope when a client_credentials request asks for a scope its service account does not hold. |
| 401 | invalid_client | Client authentication failed — unknown client_id, disabled application, or wrong secret. Deliberately indistinguishable between those cases. |
| 429 | temporarily_unavailable | Rate limited. This endpoint allows 20 requests per minute per IP, burst 20. The body is { "error": "temporarily_unavailable" }; retry after a short backoff. |
| 500 | server_error | Issuance failed server-side — most often the signing key is not configured for this deployment. Nothing was issued; retry or contact the operator. |
/api/oauth/userinfoClaims for an access tokenOIDC UserInfo. Requires the openid scope. Runs the SAME liveness stack as introspection — signature, expiry, session still live, membership still active — so a revoked session gets a 401 here even while the JWT is inside its 15-minute TTL.
CORS is wildcard on purpose: this is a Bearer endpoint with no cookie credentials. OPTIONS preflight returns 204 with the same CORS headers.
bearerExample request
curl https://core.sentent.ai/api/oauth/userinfo \
-H "Authorization: Bearer $ACCESS_TOKEN"Response
200 Claims for the granted scope.
{
"sub": "3f1c2b9e-7a41-4d2e-9c05-2b8f6d1a4e77",
"email": "dana@client.example",
"email_verified": true,
"name": "Dana Ruiz",
"picture": "https://lh3.googleusercontent.com/a/default-user",
"tenant": "b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6",
"role": "admin"
}Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | invalid_token | Empty body with WWW-Authenticate: Bearer error="invalid_token". Covers a missing or malformed header, a bad signature, an expired token, a revoked session, and a deactivated membership — all indistinguishable by design. |
| 403 | insufficient_scope | Empty body with WWW-Authenticate: Bearer error="insufficient_scope" — the token is valid but was not granted openid. |
| 429 | temporarily_unavailable | Rate limited. This endpoint allows 20 requests per minute per IP, burst 20; the GET and POST forms share one bucket. The body is { "error": "temporarily_unavailable" }; retry after a short backoff. |
/api/oauth/userinfoClaims for an access token (POST form)Identical to GET /api/oauth/userinfo — OIDC Core 5.3.1 permits either verb. The token still travels in the Authorization header; there are no body parameters.
bearerExample request
curl -X POST https://core.sentent.ai/api/oauth/userinfo \
-H "Authorization: Bearer $ACCESS_TOKEN"Response
200 Claims for the granted scope.
{
"sub": "3f1c2b9e-7a41-4d2e-9c05-2b8f6d1a4e77",
"email": "dana@client.example",
"email_verified": true,
"name": "Dana Ruiz",
"picture": "https://lh3.googleusercontent.com/a/default-user",
"tenant": "b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6",
"role": "admin"
}Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | invalid_token | Empty body with WWW-Authenticate: Bearer error="invalid_token". |
| 403 | insufficient_scope | Empty body with WWW-Authenticate: Bearer error="insufficient_scope". |
| 429 | temporarily_unavailable | Rate limited. This endpoint allows 20 requests per minute per IP, burst 20; the GET and POST forms share one bucket. The body is { "error": "temporarily_unavailable" }; retry after a short backoff. |
Token lifecycle
Authoritative checks on tokens you already hold — is it still active, and stop honouring it.
/api/oauth/introspectIs this token still active?RFC 7662 introspection — the authoritative revocation check. Offline JWKS verification is the happy path; call this when you need to know that the session has not been revoked and the membership is still active (before a privileged action, on a long-lived websocket, and so on).
The role in the response is re-read from the membership, so it is fresher than the one baked into the token.
client_secret_basicParameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| token | form | string | optional | The access token to inspect. Omitting it is not an error — you get { "active": false }. |
| client_id | form | string | optional | Required only when authenticating with client_secret_post. Omit when using HTTP Basic. |
| client_secret | form | string | optional | Required only when authenticating with client_secret_post. Omit when using HTTP Basic. |
Example request
curl -X POST https://core.sentent.ai/api/oauth/introspect \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d token="$ACCESS_TOKEN"Response
200 The introspection result. active: false for any token that fails verification, liveness, or membership — and for a request with no token.
{
"active": true,
"principal_type": "user",
"sub": "3f1c2b9e-7a41-4d2e-9c05-2b8f6d1a4e77",
"tenant": "b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6",
"app": "app_reporting_portal",
"role": "admin",
"sid": "5c9e0f13-2a76-4b8d-9e31-7f4c6a1d0b52",
"exp": 1785000900,
"scope": "openid profile email",
"iss": "https://core.sentent.ai"
}Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | No client credentials were presented in either the Authorization header or the request body. |
| 401 | invalid_client | Client authentication failed — unknown client_id, disabled application, or wrong secret. Deliberately indistinguishable between those cases. |
| 429 | temporarily_unavailable | Rate limited. This endpoint allows 20 requests per minute per IP, burst 20. The body is { "error": "temporarily_unavailable" }; retry after a short backoff. |
/api/oauth/revokeRevoke a sessionRFC 7009 revocation. Present either the refresh token or an access token; Core resolves it to the session and revokes it — the access token stops working at /api/oauth/userinfo and /api/oauth/introspect immediately, and the refresh token can no longer be rotated.
Always 200, whether or not the token matched, so callers cannot probe for valid tokens. Revocation is idempotent.
Note that an already-issued access token remains cryptographically valid until it expires, so purely offline verifiers will keep accepting it for up to 15 minutes. That is the trade-off for stateless verification — introspect when it matters.
client_secret_basicParameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| token | form | string | optional | A refresh token or an access token belonging to the session being revoked. |
| client_id | form | string | optional | Required only when authenticating with client_secret_post. Omit when using HTTP Basic. |
| client_secret | form | string | optional | Required only when authenticating with client_secret_post. Omit when using HTTP Basic. |
Example request
curl -X POST https://core.sentent.ai/api/oauth/revoke \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d token="$REFRESH_TOKEN"Response
200 Empty body. Returned whether the token matched a live session, matched nothing, or was omitted entirely.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | No client credentials were presented in either the Authorization header or the request body. |
| 401 | invalid_client | Client authentication failed — unknown client_id, disabled application, or wrong secret. Deliberately indistinguishable between those cases. |
| 429 | temporarily_unavailable | Rate limited. This endpoint allows 20 requests per minute per IP, burst 20. The body is { "error": "temporarily_unavailable" }; retry after a short backoff. |
Logout
Ending a session. Browser-facing, like authorization — send the user here rather than fetching it.
/api/oauth/logoutEnd the session (RP-initiated logout)OIDC RP-Initiated Logout 1.0 — the end_session_endpoint from the discovery document. Ends the Core SSO session in the user's browser and revokes the ONE Core session identified by your id_token_hint.
What it does NOT do: sign the user out of your *other* applications, or out of Core on their other devices. Core has no stored link between a browser cookie session and the app sessions minted from it, so "log out everywhere" is not a set it can compute here — that stays an explicit, separate action.
Send an id_token_hint (any Core-issued id_token for the session — an EXPIRED one is fine, Core tolerates expiry on the hint because people log out of stale tabs) and Core proceeds silently. Without a valid hint Core shows a confirmation page first and terminates nothing until the user clicks through: a bare link to this endpoint is trivially forgeable, and a drive-by logout is a nuisance the spec tells providers to guard against.
Redirect back only when provable. post_logout_redirect_uri is honoured only if a valid id_token_hint identifies your application AND the URI EXACTLY matches one your operator registered for it — same string-comparison rule as redirect_uri at /authorize. Anything else lands on Core's own signed-out page rather than erroring, because at that point there is no trusted place to send an error.
Already-issued access tokens stay cryptographically valid until they expire (up to 15 minutes), so an offline verifier keeps accepting them; introspection and UserInfo report the session as dead immediately.
Back-channel logout is now delivered. Whenever a Core session is revoked — here, through /api/oauth/revoke, by an operator, or by a membership being deactivated — Core POSTs application/x-www-form-urlencoded logout_token=<JWT> to the backchannel_logout_uri your operator registered for the application. See the LogoutTokenClaims schema for what to validate. Delivery is best-effort: 5-second timeout, redirects not followed, no retries, and your response body ignored. If you miss one, the next token refresh still fails — that has always been the backstop and it has not changed.
This is a BROWSER redirect, not an API call — send the user here, do not fetch it.
Parameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| id_token_hint | query | string | optional | A Core-issued id_token for the session being ended. Signature and issuer are verified; EXPIRY IS TOLERATED. Its sid names the session to revoke, and its aud names your application. Omit it and Core falls back to the confirmation page — it will not revoke a session it cannot identify. |
| client_id | query | string | optional | Optional cross-check. When present it must equal the hint's aud; a mismatch makes Core discard the hint entirely rather than trust half of it. |
| post_logout_redirect_uri | query | string | optional | Where to send the browser afterwards. Honoured only on an exact match against your application's registered post-logout redirect URIs — a separate allow-list from redirect_uris, so ask your operator to register it. Unregistered or unmatched values are ignored silently (no error redirect; there is no trusted target for one). |
| state | query | string | optional | Opaque value echoed as a query parameter on the post-logout redirect. Ignored when no redirect happens. |
Example request
https://core.sentent.ai/api/oauth/logout
?id_token_hint=eyJhbGciOiJFZERTQSJ9…
&post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2Fsigned-out
&state=kR3xw9Response
302 Redirect. To your post_logout_redirect_uri (with state echoed) when it was provable; otherwise to Core's signed-out page. A hint-less GET redirects to the confirmation page instead, having terminated nothing. The POST form answers 303 rather than 302.
Errors
| Status | Code | Meaning |
|---|---|---|
| 429 | temporarily_unavailable | Rate limited. This endpoint allows 10 requests per minute per IP, burst 10 — the same strict bucket as /authorize. The body is { "error": "temporarily_unavailable" }; retry after a short backoff. |
/api/oauth/logoutEnd the session (POST form)OIDC RP-Initiated Logout 1.0 — the end_session_endpoint from the discovery document. Ends the Core SSO session in the user's browser and revokes the ONE Core session identified by your id_token_hint.
What it does NOT do: sign the user out of your *other* applications, or out of Core on their other devices. Core has no stored link between a browser cookie session and the app sessions minted from it, so "log out everywhere" is not a set it can compute here — that stays an explicit, separate action.
Send an id_token_hint (any Core-issued id_token for the session — an EXPIRED one is fine, Core tolerates expiry on the hint because people log out of stale tabs) and Core proceeds silently. Without a valid hint Core shows a confirmation page first and terminates nothing until the user clicks through: a bare link to this endpoint is trivially forgeable, and a drive-by logout is a nuisance the spec tells providers to guard against.
Redirect back only when provable. post_logout_redirect_uri is honoured only if a valid id_token_hint identifies your application AND the URI EXACTLY matches one your operator registered for it — same string-comparison rule as redirect_uri at /authorize. Anything else lands on Core's own signed-out page rather than erroring, because at that point there is no trusted place to send an error.
Already-issued access tokens stay cryptographically valid until they expire (up to 15 minutes), so an offline verifier keeps accepting them; introspection and UserInfo report the session as dead immediately.
Back-channel logout is now delivered. Whenever a Core session is revoked — here, through /api/oauth/revoke, by an operator, or by a membership being deactivated — Core POSTs application/x-www-form-urlencoded logout_token=<JWT> to the backchannel_logout_uri your operator registered for the application. See the LogoutTokenClaims schema for what to validate. Delivery is best-effort: 5-second timeout, redirects not followed, no retries, and your response body ignored. If you miss one, the next token refresh still fails — that has always been the backstop and it has not changed.
Identical to the GET form — OIDC RP-Initiated Logout §2 permits either verb. Parameters arrive as application/x-www-form-urlencoded instead of query parameters, and a successful redirect is a 303 so the browser follows with a GET. This is also the verb Core's own confirmation page uses.
Parameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| id_token_hint | form | string | optional | A Core-issued id_token for the session being ended. Signature and issuer are verified; EXPIRY IS TOLERATED. Its sid names the session to revoke, and its aud names your application. Omit it and Core falls back to the confirmation page — it will not revoke a session it cannot identify. |
| client_id | form | string | optional | Optional cross-check. When present it must equal the hint's aud; a mismatch makes Core discard the hint entirely rather than trust half of it. |
| post_logout_redirect_uri | form | string | optional | Where to send the browser afterwards. Honoured only on an exact match against your application's registered post-logout redirect URIs — a separate allow-list from redirect_uris, so ask your operator to register it. Unregistered or unmatched values are ignored silently (no error redirect; there is no trusted target for one). |
| state | form | string | optional | Opaque value echoed as a query parameter on the post-logout redirect. Ignored when no redirect happens. |
Example request
<form method="post" action="https://core.sentent.ai/api/oauth/logout">
<input type="hidden" name="id_token_hint" value="…" />
<input type="hidden" name="post_logout_redirect_uri" value="https://app.example.com/signed-out" />
<button type="submit">Sign out</button>
</form>Response
302 Redirect. To your post_logout_redirect_uri (with state echoed) when it was provable; otherwise to Core's signed-out page. A hint-less GET redirects to the confirmation page instead, having terminated nothing. The POST form answers 303 rather than 302.
Errors
| Status | Code | Meaning |
|---|---|---|
| 429 | temporarily_unavailable | Rate limited. This endpoint allows 10 requests per minute per IP, burst 10 — the same strict bucket as /authorize. The body is { "error": "temporarily_unavailable" }; retry after a short backoff. |
Management API
Programmatic tenant and membership management for SERVICE principals, so your product can ship its own member-management UI with no Core operator in the loop. Versioned under /api/v1, authenticated with a client_credentials service token, authorized by core: scopes. Human access tokens are rejected here — people use the Core console.
/api/v1/tenants/{tenantId}Read the tenantRead the organization your service principal administers, including its read-only policy.
Authenticate with a SERVICE token — POST /api/oauth/token with grant_type=client_credentials using service-account credentials a Core operator issued you. Human access tokens are rejected here.
A tenant-scoped principal may read exactly one tenant — the one in its token's tenant claim. Any other id answers 404 with the same body, whether or not it exists.
service_bearerParameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| tenantId | path | string | required | The organization being addressed. For a tenant-scoped service account this MUST equal the tenant claim in your token — every other value answers 404, including tenants that really exist. |
Example request
curl https://core.sentent.ai/api/v1/tenants/$TENANT_ID \
-H "Authorization: Bearer $SERVICE_ACCESS_TOKEN"Response
200 The tenant and its policy.
{
"id": "b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6",
"name": "Acme Corp",
"kind": "client",
"status": "active",
"created_at": "2026-01-14T09:12:00.000Z",
"settings": {
"require_mfa": true,
"allowed_email_domains": [
"acme.com"
]
}
}Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | UNAUTHORIZED | No usable service token. Covers a missing or malformed Authorization header, a bad signature, an expired token, a token from another Core deployment, a disabled or deleted service account, and a HUMAN access token — people use the Core console, not this API. All indistinguishable by design. |
| 403 | FORBIDDEN | The token is valid but not permitted. Either it does not carry core:tenants:read, or the grant hierarchy refuses the specific change — a tenant-scoped principal acts with ADMIN authority, so it may grant only member and may not touch an owner or admin membership at all. |
| 404 | NOT_FOUND | No such tenant, membership, or member — OR your principal may not see it. The two are deliberately indistinguishable: a 403 here would confirm that another customer's tenant exists. |
| 429 | RATE_LIMITED | Rate limited, with Retry-After in seconds. Two buckets apply: 120/minute per source IP before authentication, and 60/minute per SERVICE ACCOUNT after it. The second is the one a busy integration meets. |
| 500 | INTERNAL_ERROR | Core could not complete the request. Nothing partial is left behind for the mutating routes, and an Idempotency-Key claim is RELEASED on a 5xx so your retry runs for real rather than replaying the failure. |
/api/v1/tenants/{tenantId}/membershipsList membershipsEvery live membership in the tenant, oldest grant first, keyset-paginated.
Authenticate with a SERVICE token — POST /api/oauth/token with grant_type=client_credentials using service-account credentials a Core operator issued you. Human access tokens are rejected here.
Pass next_cursor from the previous page back as ?cursor=. Keyset rather than offset because you are paging a list that changes underneath you: with offsets, a grant created ahead of your position pushes one row past you unseen.
With the core:users:read scope each row also carries email. Without it you still get user_id, which is the same value as the sub claim in that person's tokens and is the key you should be joining on anyway.
service_bearerParameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| tenantId | path | string | required | The organization being addressed. For a tenant-scoped service account this MUST equal the tenant claim in your token — every other value answers 404, including tenants that really exist. |
| limit | query | integer | optional | Rows per page, 1-100. Values above 100 are clamped, not rejected. Defaults to 50. |
| cursor | query | string | optional | The next_cursor from the previous page, verbatim. Opaque — do not construct one. A cursor Core did not issue is a 422 rather than a silent restart from page one. |
Example request
curl "https://core.sentent.ai/api/v1/tenants/$TENANT_ID/memberships?limit=50" \
-H "Authorization: Bearer $SERVICE_ACCESS_TOKEN"Response
200 One page of memberships.
{
"data": [
{
"id": "8d02a5f7-4c31-4f6a-b0e2-91c73de4a5b8",
"user_id": "3f1c2b9e-7a41-4d2e-9c05-2b8f6d1a4e77",
"tenant_id": "b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6",
"role": "member",
"status": "active",
"created_at": "2026-07-25T18:04:05.000Z"
}
],
"next_cursor": "MjAyNi0wNy0yNVQxODowNDowNS4wMDBafDNmMWMyYjllLTdhNDEtNGQyZS05YzA1LTJiOGY2ZDFhNGU3Nw"
}Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | UNAUTHORIZED | No usable service token. Covers a missing or malformed Authorization header, a bad signature, an expired token, a token from another Core deployment, a disabled or deleted service account, and a HUMAN access token — people use the Core console, not this API. All indistinguishable by design. |
| 403 | FORBIDDEN | The token is valid but not permitted. Either it does not carry core:memberships:read, or the grant hierarchy refuses the specific change — a tenant-scoped principal acts with ADMIN authority, so it may grant only member and may not touch an owner or admin membership at all. |
| 404 | NOT_FOUND | No such tenant, membership, or member — OR your principal may not see it. The two are deliberately indistinguishable: a 403 here would confirm that another customer's tenant exists. |
| 422 | VALIDATION_ERROR | The body did not validate. field_errors names the offending fields. |
| 429 | RATE_LIMITED | Rate limited, with Retry-After in seconds. Two buckets apply: 120/minute per source IP before authentication, and 60/minute per SERVICE ACCOUNT after it. The second is the one a busy integration meets. |
| 500 | INTERNAL_ERROR | Core could not complete the request. Nothing partial is left behind for the mutating routes, and an Idempotency-Key claim is RELEASED on a 5xx so your retry runs for real rather than replaying the failure. |
/api/v1/tenants/{tenantId}/membershipsInvite a memberGrant someone a role in the tenant. The membership takes effect immediately; the person's identity activates on their first sign-in, so the row starts invited until then.
Authenticate with a SERVICE token — POST /api/oauth/token with grant_type=client_credentials using service-account credentials a Core operator issued you. Human access tokens are rejected here.
Three checks stand between this call and an arbitrary address, and they are different questions:
1. **May this principal grant this role?** A tenant-scoped account acts with ADMIN authority and may grant member only — no peer creation, no escalation. owner and admin grants require a vendor (platform-internal) service account.
2. **Does the tenant admit this domain?** If the organization has an allowed_email_domains policy, addresses outside it are refused. Read it from GET /api/v1/tenants/{tenantId} so your UI can say so first.
3. **Can the mailbox receive mail?** Typo TLDs, known typosquats, disposable-inbox providers, and domains with no working mail exchanger are hard-blocked. An invite is a standing access grant, so a typo'd address is a grant sitting on a domain someone else can register.
**Core does not email the invitee.** The grant is what admits them; sending the "you have been invited" message is yours, because you own the branding and the surrounding flow. (Invites made by a human in Core's own console do get an email from Core.)
**Invitations expire, and re-POSTing is how you renew one.** An unclaimed invitation stops working 14 days after it was issued (see invited_at on the Membership schema). Sending this request again for someone who is already a member but has never signed in is therefore NOT a conflict: it re-issues their invitation, resets the clock, and returns their existing membership unchanged — same id, same role. A 409 here means the person has actually claimed their account already.
service_bearerParameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| tenantId | path | string | required | The organization being addressed. For a tenant-scoped service account this MUST equal the tenant claim in your token — every other value answers 404, including tenants that really exist. |
| Idempotency-Key | header | string | optional | Optional but recommended on every mutating call. Send a value you generate per logical operation (a UUID is fine) and Core executes it AT MOST ONCE: a retry with the same key and the same body replays the first answer verbatim, with Idempotency-Replayed: true. The same key with a DIFFERENT body is a 409 — Core will not guess which request you meant. Records expire after 24 hours. 1-255 printable ASCII characters. |
| body | string | required | The person's mailbox. Lower-cased and trimmed; it is the sign-in key. | |
| role | body | owner | admin | member | optional | Defaults to member. Only the three system roles can be granted through the API — Core cannot rank a custom role, whose meaning lives in your application, so it cannot tell whether granting one is an escalation. |
Example request
curl -X POST https://core.sentent.ai/api/v1/tenants/$TENANT_ID/memberships \
-H "Authorization: Bearer $SERVICE_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"email":"dana@client.example","role":"member"}'Response
201 The membership was created.
{
"id": "8d02a5f7-4c31-4f6a-b0e2-91c73de4a5b8",
"user_id": "3f1c2b9e-7a41-4d2e-9c05-2b8f6d1a4e77",
"tenant_id": "b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6",
"role": "member",
"status": "active",
"created_at": "2026-07-25T18:04:05.000Z",
"invited_at": null,
"email": "dana@client.example"
}Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | UNAUTHORIZED | No usable service token. Covers a missing or malformed Authorization header, a bad signature, an expired token, a token from another Core deployment, a disabled or deleted service account, and a HUMAN access token — people use the Core console, not this API. All indistinguishable by design. |
| 403 | FORBIDDEN | The token is valid but not permitted. Either it does not carry core:memberships:write, or the grant hierarchy refuses the specific change — a tenant-scoped principal acts with ADMIN authority, so it may grant only member and may not touch an owner or admin membership at all. |
| 404 | NOT_FOUND | No such tenant, membership, or member — OR your principal may not see it. The two are deliberately indistinguishable: a 403 here would confirm that another customer's tenant exists. |
| 409 | CONFLICT | The request cannot be applied as asked: the person is already a member, the change would leave the tenant with no active owner, or an Idempotency-Key was reused with a different body. |
| 422 | VALIDATION_ERROR | The body did not validate. field_errors names the offending fields. |
| 429 | RATE_LIMITED | Rate limited, with Retry-After in seconds. Two buckets apply: 120/minute per source IP before authentication, and 60/minute per SERVICE ACCOUNT after it. The second is the one a busy integration meets. |
| 500 | INTERNAL_ERROR | Core could not complete the request. Nothing partial is left behind for the mutating routes, and an Idempotency-Key claim is RELEASED on a 5xx so your retry runs for real rather than replaying the failure. |
/api/v1/tenants/{tenantId}/memberships/{id}Read one membershipOne membership in the tenant.
Authenticate with a SERVICE token — POST /api/oauth/token with grant_type=client_credentials using service-account credentials a Core operator issued you. Human access tokens are rejected here.
A membership id belonging to another tenant answers 404 — the lookup is tenant-scoped in the database, so it matches nothing rather than being refused after the fact.
service_bearerParameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| tenantId | path | string | required | The organization being addressed. For a tenant-scoped service account this MUST equal the tenant claim in your token — every other value answers 404, including tenants that really exist. |
| id | path | string | required | The membership id, as returned by the list and create operations. |
Example request
curl https://core.sentent.ai/api/v1/tenants/$TENANT_ID/memberships/$MEMBERSHIP_ID \
-H "Authorization: Bearer $SERVICE_ACCESS_TOKEN"Response
200 The membership.
{
"id": "8d02a5f7-4c31-4f6a-b0e2-91c73de4a5b8",
"user_id": "3f1c2b9e-7a41-4d2e-9c05-2b8f6d1a4e77",
"tenant_id": "b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6",
"role": "member",
"status": "active",
"created_at": "2026-07-25T18:04:05.000Z",
"invited_at": null,
"email": "dana@client.example"
}Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | UNAUTHORIZED | No usable service token. Covers a missing or malformed Authorization header, a bad signature, an expired token, a token from another Core deployment, a disabled or deleted service account, and a HUMAN access token — people use the Core console, not this API. All indistinguishable by design. |
| 403 | FORBIDDEN | The token is valid but not permitted. Either it does not carry core:memberships:read, or the grant hierarchy refuses the specific change — a tenant-scoped principal acts with ADMIN authority, so it may grant only member and may not touch an owner or admin membership at all. |
| 404 | NOT_FOUND | No such tenant, membership, or member — OR your principal may not see it. The two are deliberately indistinguishable: a 403 here would confirm that another customer's tenant exists. |
| 429 | RATE_LIMITED | Rate limited, with Retry-After in seconds. Two buckets apply: 120/minute per source IP before authentication, and 60/minute per SERVICE ACCOUNT after it. The second is the one a busy integration meets. |
| 500 | INTERNAL_ERROR | Core could not complete the request. Nothing partial is left behind for the mutating routes, and an Idempotency-Key claim is RELEASED on a 5xx so your retry runs for real rather than replaying the failure. |
/api/v1/tenants/{tenantId}/memberships/{id}Change a member's role or statusChange the role, the status, or both. Send only the fields you are changing — an omitted field is left exactly as it is, which is how a member an operator put on a CUSTOM role can still be deactivated without first reassigning them.
Authenticate with a SERVICE token — POST /api/oauth/token with grant_type=client_credentials using service-account credentials a Core operator issued you. Human access tokens are rejected here.
Four guards apply, and they are the same ones Core's own delegated console enforces:
- **Tenant scope.** The membership must be in the tenant named in the URL.
- **Who you may touch.** A tenant-scoped principal (admin authority) may act on members and on holders of custom roles, and on nobody else. Deactivating an owner is neutralizing an owner even though no role was written, so it is refused.
- **What you may grant.** Changing a role additionally requires the authority to grant the new one.
- **The last-owner floor.** No change may leave the tenant with zero ACTIVE owners. An invited owner does not count — nobody has proved they can reach that mailbox yet.
Setting status to inactive revokes the person's live sessions in this tenant immediately, fans out back-channel logout to any application that registered an endpoint, and emits membership.revoked. Setting active on someone who has never signed in leaves them invited: that state is left by signing in, not by an API call.
service_bearerParameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| tenantId | path | string | required | The organization being addressed. For a tenant-scoped service account this MUST equal the tenant claim in your token — every other value answers 404, including tenants that really exist. |
| id | path | string | required | The membership id, as returned by the list and create operations. |
| Idempotency-Key | header | string | optional | Optional but recommended on every mutating call. Send a value you generate per logical operation (a UUID is fine) and Core executes it AT MOST ONCE: a retry with the same key and the same body replays the first answer verbatim, with Idempotency-Replayed: true. The same key with a DIFFERENT body is a 409 — Core will not guess which request you meant. Records expire after 24 hours. 1-255 printable ASCII characters. |
| role | body | owner | admin | member | optional | Omit to keep the current role, whatever it is — including a custom one. |
| status | body | active | inactive | optional | Omit to keep the current status. invited is not writable: it is entered by being invited and left by signing in. |
Example request
curl -X PATCH https://core.sentent.ai/api/v1/tenants/$TENANT_ID/memberships/$MEMBERSHIP_ID \
-H "Authorization: Bearer $SERVICE_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status":"inactive"}'Response
200 The membership as it now stands.
{
"id": "8d02a5f7-4c31-4f6a-b0e2-91c73de4a5b8",
"user_id": "3f1c2b9e-7a41-4d2e-9c05-2b8f6d1a4e77",
"tenant_id": "b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6",
"role": "member",
"status": "active",
"created_at": "2026-07-25T18:04:05.000Z",
"invited_at": null,
"email": "dana@client.example"
}Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | UNAUTHORIZED | No usable service token. Covers a missing or malformed Authorization header, a bad signature, an expired token, a token from another Core deployment, a disabled or deleted service account, and a HUMAN access token — people use the Core console, not this API. All indistinguishable by design. |
| 403 | FORBIDDEN | The token is valid but not permitted. Either it does not carry core:memberships:write, or the grant hierarchy refuses the specific change — a tenant-scoped principal acts with ADMIN authority, so it may grant only member and may not touch an owner or admin membership at all. |
| 404 | NOT_FOUND | No such tenant, membership, or member — OR your principal may not see it. The two are deliberately indistinguishable: a 403 here would confirm that another customer's tenant exists. |
| 409 | CONFLICT | The request cannot be applied as asked: the person is already a member, the change would leave the tenant with no active owner, or an Idempotency-Key was reused with a different body. |
| 422 | VALIDATION_ERROR | The body did not validate. field_errors names the offending fields. |
| 429 | RATE_LIMITED | Rate limited, with Retry-After in seconds. Two buckets apply: 120/minute per source IP before authentication, and 60/minute per SERVICE ACCOUNT after it. The second is the one a busy integration meets. |
| 500 | INTERNAL_ERROR | Core could not complete the request. Nothing partial is left behind for the mutating routes, and an Idempotency-Key claim is RELEASED on a 5xx so your retry runs for real rather than replaying the failure. |
/api/v1/tenants/{tenantId}/memberships/{id}Remove a memberRemove the grant. The person keeps their Core identity and any membership they hold in other tenants; they simply lose this one, and their live sessions here are revoked on the spot.
Authenticate with a SERVICE token — POST /api/oauth/token with grant_type=client_credentials using service-account credentials a Core operator issued you. Human access tokens are rejected here.
The same "who you may touch" and last-owner guards as PATCH apply. Removal and deactivation are the same fact to a consuming app — access to this tenant is gone — so both emit membership.revoked rather than making you subscribe to two names for one outcome.
The response body is the membership as it stood before removal, so you can log what you removed. A second DELETE of the same id answers 404 — UNLESS you send the same Idempotency-Key, in which case the original 200 is replayed with Idempotency-Replayed: true, which is what makes a timed-out DELETE safe to retry.
service_bearerParameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| tenantId | path | string | required | The organization being addressed. For a tenant-scoped service account this MUST equal the tenant claim in your token — every other value answers 404, including tenants that really exist. |
| id | path | string | required | The membership id, as returned by the list and create operations. |
| Idempotency-Key | header | string | optional | Optional but recommended on every mutating call. Send a value you generate per logical operation (a UUID is fine) and Core executes it AT MOST ONCE: a retry with the same key and the same body replays the first answer verbatim, with Idempotency-Replayed: true. The same key with a DIFFERENT body is a 409 — Core will not guess which request you meant. Records expire after 24 hours. 1-255 printable ASCII characters. |
Example request
curl -X DELETE https://core.sentent.ai/api/v1/tenants/$TENANT_ID/memberships/$MEMBERSHIP_ID \
-H "Authorization: Bearer $SERVICE_ACCESS_TOKEN"Response
200 The removed membership, as it stood before removal.
{
"id": "8d02a5f7-4c31-4f6a-b0e2-91c73de4a5b8",
"user_id": "3f1c2b9e-7a41-4d2e-9c05-2b8f6d1a4e77",
"tenant_id": "b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6",
"role": "member",
"status": "active",
"created_at": "2026-07-25T18:04:05.000Z",
"invited_at": null,
"email": "dana@client.example"
}Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | UNAUTHORIZED | No usable service token. Covers a missing or malformed Authorization header, a bad signature, an expired token, a token from another Core deployment, a disabled or deleted service account, and a HUMAN access token — people use the Core console, not this API. All indistinguishable by design. |
| 403 | FORBIDDEN | The token is valid but not permitted. Either it does not carry core:memberships:write, or the grant hierarchy refuses the specific change — a tenant-scoped principal acts with ADMIN authority, so it may grant only member and may not touch an owner or admin membership at all. |
| 404 | NOT_FOUND | No such tenant, membership, or member — OR your principal may not see it. The two are deliberately indistinguishable: a 403 here would confirm that another customer's tenant exists. |
| 409 | CONFLICT | The request cannot be applied as asked: the person is already a member, the change would leave the tenant with no active owner, or an Idempotency-Key was reused with a different body. |
| 429 | RATE_LIMITED | Rate limited, with Retry-After in seconds. Two buckets apply: 120/minute per source IP before authentication, and 60/minute per SERVICE ACCOUNT after it. The second is the one a busy integration meets. |
| 500 | INTERNAL_ERROR | Core could not complete the request. Nothing partial is left behind for the mutating routes, and an Idempotency-Key claim is RELEASED on a 5xx so your retry runs for real rather than replaying the failure. |
/api/v1/tenants/{tenantId}/session-revocationsSign a member out of this tenantRevoke every live Core session a person holds in this tenant — across all of its applications — without changing their membership. The use case is "sign this person out everywhere, now": a suspected credential compromise, or your own app's admin ending a session.
Authenticate with a SERVICE token — POST /api/oauth/token with grant_type=client_credentials using service-account credentials a Core operator issued you. Human access tokens are rejected here.
Modelled as a collection you POST to rather than as .../{id}/revoke, because a verb in a URL is a resource that does not exist. The subject is the IDENTITY (user_id — the sub claim you already hold), and Core resolves it to a membership in this tenant first: a user who is not a member here answers 404, which is what keeps this from being a membership oracle across other customers.
A tenant-scoped principal cannot revoke an owner or admin member's sessions — killing every session someone holds is neutralizing them, and that is the same authority question as deactivating them.
revoked: 0 is a success. It means they had no live sessions here.
Already-issued access tokens stay cryptographically VALID until they expire (up to 15 minutes), so a purely offline verifier keeps accepting them. Introspection and UserInfo report the sessions dead immediately, back-channel logout tokens go out to any application that registered an endpoint, and one session.revoked webhook event fires per session.
service_bearerParameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| tenantId | path | string | required | The organization being addressed. For a tenant-scoped service account this MUST equal the tenant claim in your token — every other value answers 404, including tenants that really exist. |
| Idempotency-Key | header | string | optional | Optional but recommended on every mutating call. Send a value you generate per logical operation (a UUID is fine) and Core executes it AT MOST ONCE: a retry with the same key and the same body replays the first answer verbatim, with Idempotency-Replayed: true. The same key with a DIFFERENT body is a 409 — Core will not guess which request you meant. Records expire after 24 hours. 1-255 printable ASCII characters. |
| user_id | body | string | required | The Core identity id — the sub claim of that person's tokens, or user_id from a membership. |
Example request
curl -X POST https://core.sentent.ai/api/v1/tenants/$TENANT_ID/session-revocations \
-H "Authorization: Bearer $SERVICE_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"user_id":"'"$USER_ID"'"}'Response
200 The revocation was applied.
{
"user_id": "3f1c2b9e-7a41-4d2e-9c05-2b8f6d1a4e77",
"tenant_id": "b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6",
"revoked": 3
}Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | UNAUTHORIZED | No usable service token. Covers a missing or malformed Authorization header, a bad signature, an expired token, a token from another Core deployment, a disabled or deleted service account, and a HUMAN access token — people use the Core console, not this API. All indistinguishable by design. |
| 403 | FORBIDDEN | The token is valid but not permitted. Either it does not carry core:sessions:revoke, or the grant hierarchy refuses the specific change — a tenant-scoped principal acts with ADMIN authority, so it may grant only member and may not touch an owner or admin membership at all. |
| 404 | NOT_FOUND | No such tenant, membership, or member — OR your principal may not see it. The two are deliberately indistinguishable: a 403 here would confirm that another customer's tenant exists. |
| 409 | CONFLICT | The request cannot be applied as asked: the person is already a member, the change would leave the tenant with no active owner, or an Idempotency-Key was reused with a different body. |
| 422 | VALIDATION_ERROR | The body did not validate. field_errors names the offending fields. |
| 429 | RATE_LIMITED | Rate limited, with Retry-After in seconds. Two buckets apply: 120/minute per source IP before authentication, and 60/minute per SERVICE ACCOUNT after it. The second is the one a busy integration meets. |
| 500 | INTERNAL_ERROR | Core could not complete the request. Nothing partial is left behind for the mutating routes, and an Idempotency-Key claim is RELEASED on a 5xx so your retry runs for real rather than replaying the failure. |
Claims reference
What is inside the JWTs Core issues. All are signed EdDSA (Ed25519) with the same key; the protected header carries alg, typ and the kid to match against the JWKS. Verify signature, exp and iss before trusting anything below — plus aud on the two user-facing tokens. Check principal_type too: a service token represents a machine and carries no role, session, or audience.
Access token claims
Decoded payload of access_token. Signed EdDSA (Ed25519); the protected header carries alg: EdDSA, typ: at+jwt (RFC 9068) and the kid of the active signing key. Verify signature and exp offline against the JWKS, then check iss and aud yourself.
| Claim | Type | Presence | Description |
|---|---|---|---|
| iss | string | always | Issuer — the configured Core origin. |
| sub | string | always | Core user id. Stable for the lifetime of the identity. |
| aud | string | always | Audience — the client_id the token was issued to. Reject tokens minted for another app. |
| principal_type | user | always | Always user on this token type. Check it: a machine token (service) has no role, sid, or aud, and must never be treated as a person. Absent on tokens minted before service accounts shipped — treat absence as user. |
| tenant | string | always | Tenant (customer organization) id this grant is scoped to. |
| app | string | always | The client_id, repeated as a private claim for convenience. |
| role | string | always | The user's role in that tenant: owner, admin, member, or an operator-defined custom role. Core issues the string; the meaning is yours. |
| status | active | always | Membership status at issuance. Always active — a non-active membership never gets a token. |
| sid | string | always | Session id. Pass it to your own logs; revocation is per-session. |
| scope | string | conditional | Granted OIDC scope, RFC 9068 style. Omitted for a no-scope grant. |
| amr | array | conditional | RFC 8176 authentication methods. Contains mfa when the user verified a second factor for this grant, and is ABSENT otherwise — treat presence as proof and absence as no claim, never as []. You do not have to enforce this yourself: an operator can require MFA per tenant, in which case Core refuses to mint a token at all until the user has stepped up. |
| idp | string | conditional | Identity provider the user authenticated with for this grant: google, azure, or email (magic link). ABSENT when Core cannot tell (sessions minted before the claim existed) — a guessed value would be worse than none. |
| iat | integer | always | Issued-at, seconds since epoch. |
| exp | integer | always | Expiry, seconds since epoch. 15 minutes after iat. |
{
"iss": "https://core.sentent.ai",
"sub": "3f1c2b9e-7a41-4d2e-9c05-2b8f6d1a4e77",
"aud": "app_reporting_portal",
"principal_type": "user",
"tenant": "b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6",
"app": "app_reporting_portal",
"role": "admin",
"status": "active",
"sid": "5c9e0f13-2a76-4b8d-9e31-7f4c6a1d0b52",
"scope": "openid profile email",
"iat": 1785000000,
"exp": 1785000900
}ID token claims
Decoded payload of id_token, issued only when openid was granted. Same signing key as the access token. at_hash and auth_time are deliberately omitted (optional for the code flow). nonce is echoed only on the authorization-code exchange — refresh-issued ID tokens omit it, per OIDC Core 12.2.
| Claim | Type | Presence | Description |
|---|---|---|---|
| iss | string | always | Issuer — the configured Core origin. |
| sub | string | always | Core user id. Matches the access token's sub. |
| aud | string | always | The client_id this ID token was issued to. |
| nonce | string | conditional | Echoed from the authorization request when supplied. Verify it, then discard. |
| string | conditional | Present when the email scope was granted. | |
| email_verified | boolean | conditional | Present with email. Always true — the upstream identity provider is Google and accounts are whitelist-gated besides. |
| name | string | conditional | Present when the profile scope was granted AND a display name is known. Omitted, never null, when unknown. |
| picture | string | conditional | Present when the profile scope was granted AND an avatar is known. |
| tenant | string | always | Private claim — tenant id, so an off-the-shelf OIDC client gets tenancy without a userinfo call. |
| role | string | always | Private claim — the user's role in that tenant. |
| sid | string | always | Session id, matching the access token's sid. Keep the ID token (or just this value) if you intend to use the logout endpoint — an id_token_hint is how Core knows which session to end. |
| amr | array | conditional | RFC 8176 authentication methods, matching the access token's amr. Contains mfa when a second factor was verified; absent otherwise. Useful for showing a user how they authenticated — for gating, prefer the per-tenant policy, which Core enforces before it issues anything. |
| idp | string | conditional | Identity provider used for this sign-in, matching the access token's idp: google, azure, or email (magic link). Absent when unknown. |
| iat | integer | always | Issued-at, seconds since epoch. |
| exp | integer | always | Expiry, seconds since epoch. |
{
"iss": "https://core.sentent.ai",
"sub": "3f1c2b9e-7a41-4d2e-9c05-2b8f6d1a4e77",
"aud": "app_reporting_portal",
"nonce": "n-0S6_WzA2Mj",
"email": "dana@client.example",
"email_verified": true,
"name": "Dana Ruiz",
"picture": "https://lh3.googleusercontent.com/a/default-user",
"tenant": "b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6",
"role": "admin",
"sid": "5c9e0f13-2a76-4b8d-9e31-7f4c6a1d0b52",
"iat": 1785000000,
"exp": 1785000900
}Service token claims
Decoded payload of an access token minted by the client_credentials grant. Same signing key and offline JWKS verification as a user token, but a deliberately smaller claim set: a machine has no role, no membership status, and no session.
Authorize on scope. Core does not define what a scope means — the string is whatever an operator granted the service account, and Core only guarantees it is a subset of that allow-list.
aud is deliberately absent: Core does not know which service you intend to call, so there is nothing truthful to put there. Verify iss, the signature, exp, and that principal_type is what your endpoint expects.
| Claim | Type | Presence | Description |
|---|---|---|---|
| iss | string | always | Issuer — the configured Core origin. |
| sub | string | always | The service account's client_id (always svc_-prefixed). Never a user id. |
| principal_type | service | always | Always service. Reject this token anywhere your API expects a person. |
| tenant | string | always | The tenant that owns the service account. The vendor tenant means a platform-internal service. |
| scope | string | conditional | The granted subset of the account's scope allow-list. Omitted when the account holds no scopes. |
| iat | integer | always | Issued-at, seconds since epoch. |
| exp | integer | always | Expiry, seconds since epoch. 5 minutes after iat — much shorter than a user token, because there is no session to revoke. |
{
"iss": "https://core.sentent.ai",
"sub": "svc_ingest_worker",
"principal_type": "service",
"tenant": "b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6",
"scope": "crm:read billing:webhook",
"iat": 1785000000,
"exp": 1785000300
}Logout token claims
Decoded payload of the logout_token Core POSTs to your registered backchannel_logout_uri when one of your sessions is revoked (OIDC Back-Channel Logout 1.0 §2.4). Signed EdDSA with the same key as every other Core token, typ: "logout+jwt" in the header.
Validate it exactly as you would an ID token, plus three extra rules: the events claim MUST contain the back-channel-logout key, nonce MUST be absent (its presence means someone handed you an ID token), and the token is only ~2 minutes old at most — reject a stale one. Then terminate the session named by sid and answer 200. Your response body is ignored; only the status is read, and Core does not retry.
Use sid, not sub: sid names the single session that ended, and signing the user out of every device because one of them logged out is a bug your users will notice.
| Claim | Type | Presence | Description |
|---|---|---|---|
| iss | string | always | Issuer — must equal the iss you verify ID tokens against. |
| aud | string | always | Your client_id. |
| sub | string | always | Core user id — the same sub as that user's ID token. |
| sid | string | always | The session that was revoked. Matches the sid claim of the ID token Core issued for it. |
| jti | string | always | Unique token id. Remember it for the token's short lifetime to make replay a no-op. |
| events | object | always | Fixed: {"http://schemas.openid.net/event/backchannel-logout": {}}. Its presence is what makes this a logout token — reject anything without it. |
| iat | integer | always | Issued-at, seconds since epoch. |
| exp | integer | always | Expiry, seconds since epoch. Two minutes after iat. |
{
"iss": "https://core.sentent.ai",
"aud": "app_reporting_portal",
"sub": "3f1c2b9e-7a41-4d2e-9c05-2b8f6d1a4e77",
"sid": "5c9e0f13-2a76-4b8d-9e31-7f4c6a1d0b52",
"jti": "S2xk9QpVr7Nc0Ta4",
"events": {
"http://schemas.openid.net/event/backchannel-logout": {}
},
"iat": 1785000000,
"exp": 1785000120
}Webhooks
Core pushes identity changes to your application so you can drop a cached identity or kill a local session the moment access changes, instead of waiting for a token refresh to fail. Endpoints are registered by a Core operator, who hands you the signing secret once. Everything below is outbound: nothing here changes the HTTP surface you call.
| Property | Value |
|---|---|
| Header | sentent-core-signature |
| Scheme label | v1 |
| Algorithm | HMAC-SHA256, lower-case hex |
| Signed input | ${t}.${rawBody} — the timestamp from the header, a literal ., then the exact request body bytes |
| Replay window | 300 seconds |
| Response timeout | 10 seconds |
| Retries | 7 attempts over about 35 hours — 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, 24 hours between them, then the delivery is parked and shown to the operator. |
POST /your/webhook/endpoint
sentent-core-signature: t=1783792245,v1=5257a869e7…
content-type: application/jsonWhat a verifier must do
- 1.Read the RAW body before any JSON parse. Re-serializing changes the bytes and the MAC will not match.
- 2.Parse the
sentent-core-signatureheader intot(unix seconds) andv1(hex MAC). Ignore labels you do not recognise — that is how a future scheme is added without breaking you. - 3.Reject if
|now − t| > 300. This is a replay window, not the security boundary; the boundary is deduping onid. - 4.Recompute HMAC-SHA256 over
${t}.${rawBody}with your endpoint secret and compare in CONSTANT TIME. A plain===leaks the correct MAC a byte at a time. - 5.Only then parse the JSON, dedupe on
id, and act. Answer 2xx quickly — anything else, or slower than 10 seconds, is a failed attempt. - 6.Your response body is never read and never stored. The status code is the whole answer.
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.CORE_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
export async function POST(request) {
const raw = await request.text(); // RAW bytes, before JSON.parse
const header = request.headers.get("sentent-core-signature") ?? "";
const fields = Object.fromEntries(
header.split(",").map((part) => part.split("=", 2)),
);
const t = Number(fields.t);
if (!Number.isInteger(t)) return new Response("bad signature", { status: 400 });
if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) {
return new Response("stale", { status: 400 });
}
const expected = createHmac("sha256", SECRET).update(`${t}.${raw}`).digest("hex");
const got = Buffer.from(String(fields.v1 ?? ""), "utf8");
const want = Buffer.from(expected, "utf8");
if (got.length !== want.length || !timingSafeEqual(got, want)) {
return new Response("bad signature", { status: 401 });
}
const event = JSON.parse(raw);
if (await alreadySeen(event.id)) return new Response(null, { status: 200 });
await markSeen(event.id); // unique-keyed insert
switch (event.type) {
case "session.revoked":
await killLocalSession(event.data.session_id);
break;
case "membership.revoked":
case "user.deactivated":
await dropCachedIdentity(event.data.user_id);
break;
case "membership.role_changed":
case "membership.created":
case "user.updated":
await refreshCachedIdentity(event.data.user_id);
break;
}
return new Response(null, { status: 200 }); // 2xx fast; heavy work async
}Event types
| Type | What it means |
|---|---|
| user.updated | The person's sign-in email changed. data.previous_email carries the old one — refresh a cached identity, and re-key it if you key on address. |
| user.deactivated | The identity itself was disabled. Drop the cached identity and force re-auth. Reserved: nothing in Core emits this yet, so you may subscribe ahead of it. |
| membership.created | Someone was granted a role in the tenant. Provision or un-hide their workspace if you pre-create local records. |
| membership.role_changed | An existing member's role changed. data.previous_role is the old one — refresh cached authorization. |
| membership.revoked | A member lost access to the tenant, by deactivation or removal. Force re-auth; do not wait for their token to expire. |
| session.revoked | One session ended. data.session_id matches the sid claim of the id_token you were issued, and data.app_id lets you ignore sessions that were never yours. Kill the matching local session. |
Webhook event envelope
The JSON body Core POSTs to a webhook endpoint your operator registered for your application. Verify the sentent-core-signature header over the RAW body BEFORE parsing it — see the Webhooks section for the exact scheme.
Delivery is AT-LEAST-ONCE. A retry re-sends the same id, so dedupe on it (a unique-keyed insert is enough) and answer 200 to an event you have already handled. Answer fast and do heavy work asynchronously: Core gives you 10 seconds and treats anything else as a failed attempt.
data carries the post-change snapshot of the affected entity — for the *.revoked types, the pre-revoke identifiers. It contains identifiers, an email address, a role and a status, and never a secret, token, or hash. Treat webhooks as CACHE INVALIDATION, not as the source of truth: your authorization decisions still rest on token verification or introspection.
| Claim | Type | Presence | Description |
|---|---|---|---|
| id | string | always | Unique per event. Your dedupe key — the same value on every retry. |
| type | user.updated | user.deactivated | membership.created | membership.role_changed | membership.revoked | session.revoked | always | Catalog type. The catalog is append-only and you only receive types your endpoint subscribed to, so an unknown value means a new type was added — ignore it and answer 200. |
| occurred_at | string | always | When the change happened in Core (RFC 3339, UTC). Not when it was delivered. |
| tenant_id | string | always | The tenant the event belongs to — the tenant that owns your application. |
| data | object | always | Post-change snapshot. Present keys vary by type: user_id, email, previous_email, membership_id, tenant_id, role, previous_role, status, session_id, app_id. Read the ones your handler needs and ignore the rest — new keys are additive. |
{
"id": "6b2f0a7c-14d3-4c9e-9f21-8a5b3c7d6e40",
"type": "membership.revoked",
"occurred_at": "2026-07-25T18:04:05.000Z",
"tenant_id": "b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6",
"data": {
"user_id": "3f1c2b9e-7a41-4d2e-9c05-2b8f6d1a4e77",
"email": "dana@client.example",
"membership_id": "a7c4e8b1-0d92-4f36-8b57-2e1c9a4d3f68",
"tenant_id": "b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6",
"role": "member",
"status": "inactive"
}
}Rate limits
Per-IP token buckets on the auth surface. Buckets live in memory on each server instance, so treat the published numbers as a floor rather than a contract — and never as a reason to skip your own backoff.
GET /authorizeThe strictest bucket. Capacity 10, refilling at one request every six seconds.
POST /api/oauth/tokenCapacity 20, so a burst of 20 drains it; it then refills at one request every three seconds.
POST /api/oauth/token (grant_type=client_credentials)A second bucket applied after the service account's secret verifies, on top of the per-IP token bucket. A healthy machine client re-requests roughly every five minutes, so this only bites a runaway loop or a leaked credential.
GET /api/oauth/userinfoPOST /api/oauth/userinfoCapacity 20, so a burst of 20 drains it; it then refills at one request every three seconds. GET and POST share this bucket.
POST /api/oauth/introspectCapacity 20, so a burst of 20 drains it; it then refills at one request every three seconds.
POST /api/oauth/revokeCapacity 20, so a burst of 20 drains it; it then refills at one request every three seconds.
GET /api/oauth/logoutPOST /api/oauth/logoutThe same strict bucket as /authorize, and for the same reason: it is browser-facing, unauthenticated, and each request costs a signature verification. GET and POST share it.
Everything under /api/v1Checked BEFORE authentication, so it is what stands in front of the 401 paths — each of which costs a JWKS read and a signature verification before it can refuse. Deliberately its own bucket rather than a share of the token endpoint's, so an integration's API traffic cannot starve its own token refreshes.
Everything under /api/v1The bucket a real integration meets, applied after the token verifies. Per PRINCIPAL rather than per IP: a legitimate consumer funnels every call through a handful of egress addresses, while a leaked credential rotates them. Exceeding it returns 429 with Retry-After.
Exhausting a bucket returns 429 with { "error": "temporarily_unavailable" }. Back off and retry; nothing was consumed.
Error codes
Every error body follows RFC 6749 §5.2: a machine-readable error, optionally an error_description that is for humans and should not be parsed. Authorization-endpoint errors are delivered as query parameters on your registered redirect_uri rather than as a status code.
| Code | Status | Meaning | What to do |
|---|---|---|---|
| invalid_request | 400 | A required parameter is missing or malformed. At /authorize this is usually a missing code_challenge or a code_challenge_method other than S256; on the token endpoints it usually means no client credentials arrived in either the header or the body. | Check the parameter table for the endpoint. PKCE is required — code_challenge with code_challenge_method=S256 on every authorization request. |
| invalid_client | 401 | Client authentication failed: unknown client_id, a disabled application, or the wrong secret. | Confirm the credentials and that the application is still active. Core does not distinguish between these cases on purpose. |
| invalid_grant | 400 | The code or refresh token is not usable: expired, already spent, issued to a different client or redirect_uri, failed PKCE, or the user's access was revoked. | Start a fresh authorization request. If it happens on refresh, treat it as a signed-out user — the membership may have been deactivated. |
| unsupported_grant_type | redirect | A grant_type other than authorization_code, refresh_token, or client_credentials. | Core issues no other grants. There is no implicit, hybrid, password, or device grant. |
| invalid_scope | redirect | A client_credentials request asked for a scope its service account does not hold. Core never silently narrows a service grant — a machine has no UI in which to notice the downgrade. | Request a subset of the scopes the operator granted the account, or omit scope entirely to receive all of them. |
| unsupported_response_type | redirect | A response_type other than code. Delivered as a redirect back to the registered redirect_uri. | Use response_type=code. Implicit and hybrid flows are not supported. |
| access_denied | redirect | The signed-in user has no active membership in the application's tenant. Delivered as a redirect. | Have a Core operator add the user to that tenant, or send them somewhere useful instead of retrying. |
| invalid_token | 401 | The Bearer token failed verification, expired, or its session was revoked or deactivated. | Refresh, then retry once. A second failure means the user is signed out. |
| insufficient_scope | 403 | The token is valid but was not granted the scope the endpoint needs (openid, for UserInfo). | Request the scope at /authorize; a token cannot gain scope after issuance. |
| temporarily_unavailable | 429 | The per-IP rate limit for that bucket was exhausted. | Back off and retry. See the rate-limit table for the bucket sizes. |
| server_error | 500 | Core could not complete the request — most often the deployment's signing key is not configured. | Nothing was issued or changed; retry, and contact the operator if it persists. |
| UNAUTHORIZED | 401 | /api/v1 only. No usable service token: missing or malformed header, bad signature, expired, minted by another Core deployment, a disabled service account — or a HUMAN access token, which this API rejects outright. | Mint a fresh token with grant_type=client_credentials using service-account credentials. If you are holding a user's token, you want the console, not this API. |
| FORBIDDEN | 403 | /api/v1 only. Authenticated, and not permitted: the token lacks the core: scope the endpoint names, or the grant hierarchy refuses the change (a tenant-scoped principal may grant only member and may not touch an owner or admin). | Have the operator widen the service account's scopes, or use a vendor service account for owner/admin-level changes. Do not retry unchanged. |
| NOT_FOUND | 404 | /api/v1 only. The tenant, membership, or member does not exist — OR your principal may not see it. Deliberately indistinguishable, so the API is not an existence oracle for other customers. | Check the id against your own records. A tenant-scoped principal can only ever address the tenant in its token's tenant claim. |
| CONFLICT | 409 | /api/v1 only. The person is already a member, the change would leave the tenant with no active owner, or an Idempotency-Key was reused with a different body (or is still in flight). | For the owner floor, grant ownership to someone else first. For an idempotency mismatch, use a new key — never reuse one for a different request. |
| VALIDATION_ERROR | 422 | /api/v1 only. The body or query did not validate. field_errors names each offending field. | Fix the named fields. An email refused here is refused permanently — the address is a typo, disposable, or has no working mail exchanger. |
| RATE_LIMITED | 429 | /api/v1 only. A rate-limit bucket is exhausted. Retry-After says how long to wait. | Honour Retry-After. See the rate-limit table for the two buckets and which one you are likely meeting. |
| INTERNAL_ERROR | 500 | /api/v1 only. Core could not complete the request, or an external dependency (mail-domain DNS) was unavailable. | Retry with the SAME Idempotency-Key — a 5xx releases the claim, so your retry runs for real instead of replaying the failure. |
Versioning and changelog
This reference is versioned independently of the Core deployment. The current documented version is v1 at 1.1.0, semver: additive changes bump the minor, and a breaking change to the HTTP surface would publish a new version key alongside this one rather than rewrite it. The registry supports multiple versions; there is only one today, so there is nothing to choose between.
| Date | Version | Change |
|---|---|---|
| 2026-07-25 | 1.1.0 | Introspection now echoes iss (RFC 7662 §2.2, optional) on an active token, so a verifier handling more than one Core environment can tell them apart — two deployments sharing a signing key produce tokens that verify against each other's JWKS. Additive; ignore it and nothing changes.
The management API is live at /api/v1. A service principal holding core: scopes can now read its tenant, list/read/invite/change/remove memberships, and revoke a member's sessions — everything a product needs to ship its own member-management UI with no Core operator in the loop. Authentication is a client_credentials service token; human access tokens are rejected. Authorization mirrors Core's own delegated console exactly: a tenant-scoped principal acts with admin authority (grants member only, cannot touch owners or admins), a vendor principal with owner authority, and no path may leave a tenant with zero active owners. Mutations honour Idempotency-Key, lists are keyset-paginated, and every non-2xx carries the { error: { code, message, field_errors } } envelope. ADDITIVE — nothing on the OAuth surface changed, and an integration that ignores /api/v1 is unaffected. |
| 2026-07-25 | 1.0.0 | Event webhooks are delivered. Core now POSTs a signed JSON envelope (see WebhookEventEnvelope and the Webhooks section) to endpoints an operator registers for your application, covering membership changes, identity updates and session revocations. Signature is HMAC-SHA256 over ${t}.${rawBody} in a sentent-core-signature header with a 300-second replay window; delivery is at-least-once with a 10-second timeout and a backoff schedule spanning ~35 hours before a delivery is parked. No HTTP surface changed — this is outbound only, and an application without a registered endpoint is unaffected. |
| 2026-07-25 | 1.0.0 | Back-channel logout. Discovery now advertises backchannel_logout_supported and backchannel_logout_session_supported, and Core POSTs a signed logout_token (see LogoutTokenClaims) to the backchannel_logout_uri an operator registers for your application whenever one of its sessions is revoked — including revocations you did not initiate, such as an operator ending a session or a membership being deactivated. Register an endpoint to receive them; applications without one are skipped exactly as before. Delivery is best-effort with a 5-second timeout and no retries, so keep treating a failed refresh as the authoritative signal. |
| 2026-07-25 | 1.0.0 | RP-initiated logout. The discovery document now advertises an end_session_endpoint (/api/oauth/logout, GET or POST) that ends the Core SSO session and revokes the one Core session named by your id_token_hint. Every id_token now carries sid, which is how the hint identifies that session — additive, so nothing that ignores it breaks. post_logout_redirect_uri is honoured only on an exact match against a new per-application allow-list, registered by an operator alongside redirect_uris. Back-channel logout is NOT part of this: Core does not yet POST logout tokens to applications, and does not claim to. |
| 2026-07-25 | 1.0.0 | Service-to-service auth. The token endpoint accepts client_credentials for machine principals (service accounts, issued separately by a Core operator), returning a ~5-minute access token with principal_type: "service", no refresh token and no id_token. Every access token now carries principal_type — additive, and absence still means user — and introspection echoes it so a caller can tell a machine from a person before reading role. |
| 2026-07-25 | 1.0.0 | PKCE is mandatory. /authorize now rejects any request without a code_challenge (invalid_request, delivered to the registered redirect_uri), and S256 remains the only accepted method. Landed before this reference was published, so it is documented as the 1.0.0 behaviour rather than a version bump. |
| 2026-07-25 | 1.0.0 | Initial public reference. Documents the authorization endpoint, the token endpoint (authorization_code and refresh_token grants), UserInfo, introspection, revocation, and the two discovery documents. |