Sentent CoreAPI Referencev1 · 1.1.0

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.

Base URL

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.

Start here
curl https://core.sentent.ai/.well-known/openid-configuration

Standards implemented

  • OAuth 2.1

    Authorization code for people, client_credentials for 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 — /authorize rejects any request without a code_challenge, and plain is never accepted.

  • OpenID Connect Core + Discovery

    openid profile email scopes, an id_token on 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_endpoint that ends the browser's Core session and revokes the one session your id_token_hint names. 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_token carrying sid to 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/v2 alongside 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 honour Idempotency-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_basic

HTTP 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 parameters

The 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 bearer

A 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_bearer

A 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.

get/.well-known/openid-configurationOpenID provider metadata

The 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.

Authentication: none — this endpoint is public

Example request

curl
curl https://core.sentent.ai/.well-known/openid-configuration

Response

200 The provider metadata.

Example response
{
  "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"
  ]
}
get/.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.

Authentication: none — this endpoint is public

Example request

curl
curl https://core.sentent.ai/.well-known/jwks.json

Response

200 The key set.

Example response
{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "x": "Nn0qLQ2sV7bR9tXcYpJ4wKmE1gHdU6zAoS3fIvB5rTk",
      "kid": "f23aH3lG_gjkQO3UZVjHmWxqpL2GYYv-n2JQV7CV44I",
      "alg": "EdDSA",
      "use": "sig"
    }
  ]
}

Errors

StatusCodeMeaning
500server_errorThe key set could not be read. Keep using your cached copy and retry.

Authorization

The browser-facing half of the flow: send the user here, get an authorization code back.

get/authorizeAuthorization request (code + PKCE)

The OAuth 2.1 authorization endpoint. This is a BROWSER redirect, not an API call — send the user here, do not fetch it.

PKCE is mandatory: send a code_challenge with code_challenge_method=S256 on every request, whatever kind of client you are.

Core requires a signed-in Core session; if there is none it bounces to /login and returns here afterwards. It then confirms the user is an active member of the application's tenant, mints a single-use authorization code, and redirects back to your redirect_uri. First-party applications (the default) get no consent screen — the membership check is the gate. A third-party application (first_party off) sends the user to Core's consent screen once; the recorded grant lets later authorizes pass silently until it is revoked or the requested scope grows beyond it.

Open-redirect guard — every error detected BEFORE redirect_uri is validated returns a plain-text 400 and never redirects. Errors after that point are delivered as OAuth error parameters on the now-trusted redirect_uri.

Authentication: none — this endpoint is public

Parameters

ParameterInTypeRequiredDescription
client_idquerystringrequiredThe registered application's client id.
redirect_uriquerystringrequiredMust EXACTLY match one of the application's registered redirect URIs. No prefix or wildcard matching.
response_typequerycoderequiredOnly the authorization-code flow is supported.
scopequerystringoptionalSpace-delimited, from openid profile email. Unsupported values are silently DROPPED rather than rejected — read the scope echoed by the token endpoint to see what you actually got. Omit it entirely for a no-scope grant (no id_token, pre-OIDC response shape).
statequerystringoptionalOpaque CSRF value. Echoed back on success and on any error delivered to redirect_uri. Always send one and always verify it.
code_challengequerystringrequiredPKCE challenge — base64url(SHA-256(code_verifier)). Required on every authorization request; a request without one is rejected with invalid_request. The token exchange then requires the matching code_verifier.
code_challenge_methodqueryS256requiredMust be S256. Anything else — including plain — is rejected with invalid_request.
noncequerystringoptionalOIDC replay guard. Bound to the authorization code and echoed once in the id_token from the code exchange (refresh-issued ID tokens omit it).

Example request

Browser redirect
https://core.sentent.ai/authorize
  ?client_id=app_reporting_portal
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback
  &response_type=code
  &scope=openid%20profile%20email
  &state=kR3xw9
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  &nonce=n-0S6_WzA2Mj

Response

302 Redirect. On success: back to redirect_uri with code and your state. On a post-validation failure: back to redirect_uri with error plus stateunsupported_response_type, invalid_request (missing code_challenge, or a code_challenge_method other than S256), or access_denied (including a declined consent). With no Core session: to /login?next=…, which returns here after sign-in. Third-party applications without a covering grant bounce to /consent first, then return here.

Errors

StatusCodeMeaning
400invalid_requestPlain text, no redirect. Returned when client_id or redirect_uri is missing, the client is unknown or disabled, or the redirect_uri is not registered for the application.
429temporarily_unavailableRate limited. This endpoint allows 10 requests per minute per IP, burst 10 — the strictest bucket in the API. The body is { "error": "temporarily_unavailable" }; retry after a short backoff.

Tokens

The back-channel half: exchange the code, rotate refresh tokens, read claims for an access token.

post/api/oauth/tokenExchange a code, rotate a refresh token, or mint a service token

The 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.

Authentication: client_secret_basic

Parameters

ParameterInTypeRequiredDescription
grant_typeformauthorization_code | refresh_token | client_credentialsrequiredWhich grant to run. Anything else is unsupported_grant_type.
codeformstringoptionalauthorization_code grant — the single-use code from /authorize.
redirect_uriformstringoptionalauthorization_code grant — must be byte-identical to the one used at /authorize.
code_verifierformstringoptionalauthorization_code grant — the PKCE verifier. Always required in practice, since /authorize will not mint a code without a challenge.
refresh_tokenformstringoptionalrefresh_token grant — the current refresh token.
scopeformstringoptionalclient_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_idformstringoptionalRequired only when authenticating with client_secret_post. Omit when using HTTP Basic.
client_secretformstringoptionalRequired only when authenticating with client_secret_post. Omit when using HTTP Basic.

Example request

Authorization code
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_wW1gFWFOEjXk
Refresh
curl -X POST https://core.sentent.ai/api/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=refresh_token \
  -d refresh_token="$REFRESH_TOKEN"
Service account
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.

Example response
{
  "access_token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImYyM2FIM2xHIiwidHlwIjoiSldUIn0.eyJ0ZW5hbnQiOiJiNDFkN2MyNiJ9.Yx8s2Q",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "kUu0aVQ8Xn2rP7dLm9ZfHcT4bWyEoS1gJvNqIx3RtA0",
  "scope": "openid profile email",
  "id_token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImYyM2FIM2xHIiwidHlwIjoiSldUIn0.eyJlbWFpbCI6ImRhbmFAY2xpZW50LmV4YW1wbGUifQ.7Kd1Rw"
}

Errors

StatusCodeMeaning
400invalid_grantunsupported_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.
401invalid_clientClient authentication failed — unknown client_id, disabled application, or wrong secret. Deliberately indistinguishable between those cases.
429temporarily_unavailableRate limited. This endpoint allows 20 requests per minute per IP, burst 20. The body is { "error": "temporarily_unavailable" }; retry after a short backoff.
500server_errorIssuance failed server-side — most often the signing key is not configured for this deployment. Nothing was issued; retry or contact the operator.
get/api/oauth/userinfoClaims for an access token

OIDC 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.

Authentication: bearer

Example request

curl
curl https://core.sentent.ai/api/oauth/userinfo \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

200 Claims for the granted scope.

Example response
{
  "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

StatusCodeMeaning
401invalid_tokenEmpty 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.
403insufficient_scopeEmpty body with WWW-Authenticate: Bearer error="insufficient_scope" — the token is valid but was not granted openid.
429temporarily_unavailableRate 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.
post/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.

Authentication: bearer

Example request

curl
curl -X POST https://core.sentent.ai/api/oauth/userinfo \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

200 Claims for the granted scope.

Example response
{
  "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

StatusCodeMeaning
401invalid_tokenEmpty body with WWW-Authenticate: Bearer error="invalid_token".
403insufficient_scopeEmpty body with WWW-Authenticate: Bearer error="insufficient_scope".
429temporarily_unavailableRate 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.

post/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.

Authentication: client_secret_basic

Parameters

ParameterInTypeRequiredDescription
tokenformstringoptionalThe access token to inspect. Omitting it is not an error — you get { "active": false }.
client_idformstringoptionalRequired only when authenticating with client_secret_post. Omit when using HTTP Basic.
client_secretformstringoptionalRequired only when authenticating with client_secret_post. Omit when using HTTP Basic.

Example request

curl
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.

Example response
{
  "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

StatusCodeMeaning
400invalid_requestNo client credentials were presented in either the Authorization header or the request body.
401invalid_clientClient authentication failed — unknown client_id, disabled application, or wrong secret. Deliberately indistinguishable between those cases.
429temporarily_unavailableRate limited. This endpoint allows 20 requests per minute per IP, burst 20. The body is { "error": "temporarily_unavailable" }; retry after a short backoff.
post/api/oauth/revokeRevoke a session

RFC 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.

Authentication: client_secret_basic

Parameters

ParameterInTypeRequiredDescription
tokenformstringoptionalA refresh token or an access token belonging to the session being revoked.
client_idformstringoptionalRequired only when authenticating with client_secret_post. Omit when using HTTP Basic.
client_secretformstringoptionalRequired only when authenticating with client_secret_post. Omit when using HTTP Basic.

Example request

curl
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

StatusCodeMeaning
400invalid_requestNo client credentials were presented in either the Authorization header or the request body.
401invalid_clientClient authentication failed — unknown client_id, disabled application, or wrong secret. Deliberately indistinguishable between those cases.
429temporarily_unavailableRate 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.

get/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.

Authentication: none — this endpoint is public

Parameters

ParameterInTypeRequiredDescription
id_token_hintquerystringoptionalA 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_idquerystringoptionalOptional 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_uriquerystringoptionalWhere 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).
statequerystringoptionalOpaque value echoed as a query parameter on the post-logout redirect. Ignored when no redirect happens.

Example request

Browser redirect
https://core.sentent.ai/api/oauth/logout
  ?id_token_hint=eyJhbGciOiJFZERTQSJ9…
  &post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2Fsigned-out
  &state=kR3xw9

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

StatusCodeMeaning
429temporarily_unavailableRate 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.
post/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.

Authentication: none — this endpoint is public

Parameters

ParameterInTypeRequiredDescription
id_token_hintformstringoptionalA 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_idformstringoptionalOptional 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_uriformstringoptionalWhere 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).
stateformstringoptionalOpaque value echoed as a query parameter on the post-logout redirect. Ignored when no redirect happens.

Example request

Form POST
<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

StatusCodeMeaning
429temporarily_unavailableRate 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.

get/api/v1/tenants/{tenantId}Read the tenant

Read 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.

Authentication: service_bearer

Parameters

ParameterInTypeRequiredDescription
tenantIdpathstringrequiredThe 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
curl https://core.sentent.ai/api/v1/tenants/$TENANT_ID \
  -H "Authorization: Bearer $SERVICE_ACCESS_TOKEN"

Response

200 The tenant and its policy.

Example response
{
  "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

StatusCodeMeaning
401UNAUTHORIZEDNo 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.
403FORBIDDENThe 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.
404NOT_FOUNDNo 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.
429RATE_LIMITEDRate 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.
500INTERNAL_ERRORCore 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.
get/api/v1/tenants/{tenantId}/membershipsList memberships

Every 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.

Authentication: service_bearer

Parameters

ParameterInTypeRequiredDescription
tenantIdpathstringrequiredThe 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.
limitqueryintegeroptionalRows per page, 1-100. Values above 100 are clamped, not rejected. Defaults to 50.
cursorquerystringoptionalThe 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
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.

Example response
{
  "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

StatusCodeMeaning
401UNAUTHORIZEDNo 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.
403FORBIDDENThe 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.
404NOT_FOUNDNo 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.
422VALIDATION_ERRORThe body did not validate. field_errors names the offending fields.
429RATE_LIMITEDRate 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.
500INTERNAL_ERRORCore 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.
post/api/v1/tenants/{tenantId}/membershipsInvite a member

Grant 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.

Authentication: service_bearer

Parameters

ParameterInTypeRequiredDescription
tenantIdpathstringrequiredThe 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-KeyheaderstringoptionalOptional 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.
emailbodystringrequiredThe person's mailbox. Lower-cased and trimmed; it is the sign-in key.
rolebodyowner | admin | memberoptionalDefaults 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
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.

Example response
{
  "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

StatusCodeMeaning
401UNAUTHORIZEDNo 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.
403FORBIDDENThe 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.
404NOT_FOUNDNo 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.
409CONFLICTThe 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.
422VALIDATION_ERRORThe body did not validate. field_errors names the offending fields.
429RATE_LIMITEDRate 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.
500INTERNAL_ERRORCore 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.
get/api/v1/tenants/{tenantId}/memberships/{id}Read one membership

One 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.

Authentication: service_bearer

Parameters

ParameterInTypeRequiredDescription
tenantIdpathstringrequiredThe 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.
idpathstringrequiredThe membership id, as returned by the list and create operations.

Example request

curl
curl https://core.sentent.ai/api/v1/tenants/$TENANT_ID/memberships/$MEMBERSHIP_ID \
  -H "Authorization: Bearer $SERVICE_ACCESS_TOKEN"

Response

200 The membership.

Example response
{
  "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

StatusCodeMeaning
401UNAUTHORIZEDNo 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.
403FORBIDDENThe 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.
404NOT_FOUNDNo 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.
429RATE_LIMITEDRate 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.
500INTERNAL_ERRORCore 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.
patch/api/v1/tenants/{tenantId}/memberships/{id}Change a member's role or status

Change 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.

Authentication: service_bearer

Parameters

ParameterInTypeRequiredDescription
tenantIdpathstringrequiredThe 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.
idpathstringrequiredThe membership id, as returned by the list and create operations.
Idempotency-KeyheaderstringoptionalOptional 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.
rolebodyowner | admin | memberoptionalOmit to keep the current role, whatever it is — including a custom one.
statusbodyactive | inactiveoptionalOmit to keep the current status. invited is not writable: it is entered by being invited and left by signing in.

Example request

curl
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.

Example response
{
  "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

StatusCodeMeaning
401UNAUTHORIZEDNo 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.
403FORBIDDENThe 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.
404NOT_FOUNDNo 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.
409CONFLICTThe 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.
422VALIDATION_ERRORThe body did not validate. field_errors names the offending fields.
429RATE_LIMITEDRate 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.
500INTERNAL_ERRORCore 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.
delete/api/v1/tenants/{tenantId}/memberships/{id}Remove a member

Remove 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.

Authentication: service_bearer

Parameters

ParameterInTypeRequiredDescription
tenantIdpathstringrequiredThe 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.
idpathstringrequiredThe membership id, as returned by the list and create operations.
Idempotency-KeyheaderstringoptionalOptional 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
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.

Example response
{
  "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

StatusCodeMeaning
401UNAUTHORIZEDNo 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.
403FORBIDDENThe 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.
404NOT_FOUNDNo 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.
409CONFLICTThe 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.
429RATE_LIMITEDRate 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.
500INTERNAL_ERRORCore 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.
post/api/v1/tenants/{tenantId}/session-revocationsSign a member out of this tenant

Revoke 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.

Authentication: service_bearer

Parameters

ParameterInTypeRequiredDescription
tenantIdpathstringrequiredThe 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-KeyheaderstringoptionalOptional 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_idbodystringrequiredThe Core identity id — the sub claim of that person's tokens, or user_id from a membership.

Example request

curl
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.

Example response
{
  "user_id": "3f1c2b9e-7a41-4d2e-9c05-2b8f6d1a4e77",
  "tenant_id": "b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6",
  "revoked": 3
}

Errors

StatusCodeMeaning
401UNAUTHORIZEDNo 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.
403FORBIDDENThe 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.
404NOT_FOUNDNo 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.
409CONFLICTThe 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.
422VALIDATION_ERRORThe body did not validate. field_errors names the offending fields.
429RATE_LIMITEDRate 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.
500INTERNAL_ERRORCore 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.

ClaimTypePresenceDescription
issstringalwaysIssuer — the configured Core origin.
substringalwaysCore user id. Stable for the lifetime of the identity.
audstringalwaysAudience — the client_id the token was issued to. Reject tokens minted for another app.
principal_typeuseralwaysAlways 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.
tenantstringalwaysTenant (customer organization) id this grant is scoped to.
appstringalwaysThe client_id, repeated as a private claim for convenience.
rolestringalwaysThe user's role in that tenant: owner, admin, member, or an operator-defined custom role. Core issues the string; the meaning is yours.
statusactivealwaysMembership status at issuance. Always active — a non-active membership never gets a token.
sidstringalwaysSession id. Pass it to your own logs; revocation is per-session.
scopestringconditionalGranted OIDC scope, RFC 9068 style. Omitted for a no-scope grant.
amrarrayconditionalRFC 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.
idpstringconditionalIdentity 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.
iatintegeralwaysIssued-at, seconds since epoch.
expintegeralwaysExpiry, seconds since epoch. 15 minutes after iat.
Decoded payload
{
  "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.

ClaimTypePresenceDescription
issstringalwaysIssuer — the configured Core origin.
substringalwaysCore user id. Matches the access token's sub.
audstringalwaysThe client_id this ID token was issued to.
noncestringconditionalEchoed from the authorization request when supplied. Verify it, then discard.
emailstringconditionalPresent when the email scope was granted.
email_verifiedbooleanconditionalPresent with email. Always true — the upstream identity provider is Google and accounts are whitelist-gated besides.
namestringconditionalPresent when the profile scope was granted AND a display name is known. Omitted, never null, when unknown.
picturestringconditionalPresent when the profile scope was granted AND an avatar is known.
tenantstringalwaysPrivate claim — tenant id, so an off-the-shelf OIDC client gets tenancy without a userinfo call.
rolestringalwaysPrivate claim — the user's role in that tenant.
sidstringalwaysSession 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.
amrarrayconditionalRFC 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.
idpstringconditionalIdentity provider used for this sign-in, matching the access token's idp: google, azure, or email (magic link). Absent when unknown.
iatintegeralwaysIssued-at, seconds since epoch.
expintegeralwaysExpiry, seconds since epoch.
Decoded payload
{
  "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.

ClaimTypePresenceDescription
issstringalwaysIssuer — the configured Core origin.
substringalwaysThe service account's client_id (always svc_-prefixed). Never a user id.
principal_typeservicealwaysAlways service. Reject this token anywhere your API expects a person.
tenantstringalwaysThe tenant that owns the service account. The vendor tenant means a platform-internal service.
scopestringconditionalThe granted subset of the account's scope allow-list. Omitted when the account holds no scopes.
iatintegeralwaysIssued-at, seconds since epoch.
expintegeralwaysExpiry, seconds since epoch. 5 minutes after iat — much shorter than a user token, because there is no session to revoke.
Decoded payload
{
  "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.

ClaimTypePresenceDescription
issstringalwaysIssuer — must equal the iss you verify ID tokens against.
audstringalwaysYour client_id.
substringalwaysCore user id — the same sub as that user's ID token.
sidstringalwaysThe session that was revoked. Matches the sid claim of the ID token Core issued for it.
jtistringalwaysUnique token id. Remember it for the token's short lifetime to make replay a no-op.
eventsobjectalwaysFixed: {"http://schemas.openid.net/event/backchannel-logout": {}}. Its presence is what makes this a logout token — reject anything without it.
iatintegeralwaysIssued-at, seconds since epoch.
expintegeralwaysExpiry, seconds since epoch. Two minutes after iat.
Decoded payload
{
  "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.

Signature
PropertyValue
Headersentent-core-signature
Scheme labelv1
AlgorithmHMAC-SHA256, lower-case hex
Signed input${t}.${rawBody} — the timestamp from the header, a literal ., then the exact request body bytes
Replay window300 seconds
Response timeout10 seconds
Retries7 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.
Example headers
POST /your/webhook/endpoint
sentent-core-signature: t=1783792245,v1=5257a869e7…
content-type: application/json

What a verifier must do

  1. 1.Read the RAW body before any JSON parse. Re-serializing changes the bytes and the MAC will not match.
  2. 2.Parse the sentent-core-signature header into t (unix seconds) and v1 (hex MAC). Ignore labels you do not recognise — that is how a future scheme is added without breaking you.
  3. 3.Reject if |now − t| > 300. This is a replay window, not the security boundary; the boundary is deduping on id.
  4. 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. 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. 6.Your response body is never read and never stored. The status code is the whole answer.
Node — verify and handle
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

TypeWhat it means
user.updatedThe 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.deactivatedThe 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.createdSomeone was granted a role in the tenant. Provision or un-hide their workspace if you pre-create local records.
membership.role_changedAn existing member's role changed. data.previous_role is the old one — refresh cached authorization.
membership.revokedA member lost access to the tenant, by deactivation or removal. Force re-auth; do not wait for their token to expire.
session.revokedOne 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.

ClaimTypePresenceDescription
idstringalwaysUnique per event. Your dedupe key — the same value on every retry.
typeuser.updated | user.deactivated | membership.created | membership.role_changed | membership.revoked | session.revokedalwaysCatalog 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_atstringalwaysWhen the change happened in Core (RFC 3339, UTC). Not when it was delivered.
tenant_idstringalwaysThe tenant the event belongs to — the tenant that owns your application.
dataobjectalwaysPost-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.
Request body
{
  "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.

Authorization10 / minute / IP, burst 10
GET /authorize

The strictest bucket. Capacity 10, refilling at one request every six seconds.

Token20 / minute / IP, burst 20
POST /api/oauth/token

Capacity 20, so a burst of 20 drains it; it then refills at one request every three seconds.

Service tokens30 / minute / service account, burst 30
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.

UserInfo20 / minute / IP, burst 20
GET /api/oauth/userinfoPOST /api/oauth/userinfo

Capacity 20, so a burst of 20 drains it; it then refills at one request every three seconds. GET and POST share this bucket.

Introspection20 / minute / IP, burst 20
POST /api/oauth/introspect

Capacity 20, so a burst of 20 drains it; it then refills at one request every three seconds.

Revocation20 / minute / IP, burst 20
POST /api/oauth/revoke

Capacity 20, so a burst of 20 drains it; it then refills at one request every three seconds.

Logout10 / minute / IP, burst 10
GET /api/oauth/logoutPOST /api/oauth/logout

The 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.

Management API (per IP)120 / minute / IP, burst 120
Everything under /api/v1

Checked 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.

Management API (per service account)60 / minute / service account, burst 60
Everything under /api/v1

The 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.

CodeStatusMeaningWhat to do
invalid_request400A 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_client401Client 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_grant400The 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_typeredirectA 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_scoperedirectA 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_typeredirectA 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_deniedredirectThe 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_token401The 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_scope403The 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_unavailable429The per-IP rate limit for that bucket was exhausted.Back off and retry. See the rate-limit table for the bucket sizes.
server_error500Core 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.
UNAUTHORIZED401/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.
FORBIDDEN403/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_FOUND404/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.
CONFLICT409/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_ERROR422/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_LIMITED429/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_ERROR500/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.

DateVersionChange
2026-07-251.1.0Introspection 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-251.0.0Event 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-251.0.0Back-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-251.0.0RP-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-251.0.0Service-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-251.0.0PKCE 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-251.0.0Initial public reference. Documents the authorization endpoint, the token endpoint (authorization_code and refresh_token grants), UserInfo, introspection, revocation, and the two discovery documents.