# Sentent Core API — v1 (1.1.0)

> OAuth 2.1 and OpenID Connect endpoints for applications that authenticate against Sentent Core.

Machine-readable OpenAPI 3.1 document: https://core.sententai.com/docs/openapi.json — human reference: https://core.sententai.com/docs. This file is generated from that same document; it is never edited by hand.

## Overview

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.

## Authentication

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

```text
Authorization: Basic $(printf '%s:%s' "$CLIENT_ID" "$CLIENT_SECRET" | base64)
```

### client_secret_post

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.

```text
client_id=app_reporting_portal&client_secret=sk_live_…
```

### Bearer access token

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.

```text
Authorization: Bearer eyJhbGciOiJFZERTQSIsImtpZCI6…
```

### Bearer service token (management API)

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.

```text
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"
```

## Endpoints

### Discovery

Public, cacheable documents that let a client configure itself. No authentication, CORS open.

#### GET /.well-known/openid-configuration — OpenID 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 (public)

**Example request — curl**

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

**Response 200** — The provider metadata.

```json
{
  "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"
  ]
}
```

Anchor on the human reference: https://core.sententai.com/docs#op-getopenidconfiguration

#### GET /.well-known/jwks.json — Public 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 (public)

**Example request — curl**

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

**Response 200** — The key set.

```json
{
  "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. |

Anchor on the human reference: https://core.sententai.com/docs#op-getjwks

### Authorization

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

#### GET /authorize — Authorization 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 (public)

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `client_id` | query | string | yes | The registered application's client id. |
| `redirect_uri` | query | string | yes | Must EXACTLY match one of the application's registered redirect URIs. No prefix or wildcard matching. |
| `response_type` | query | code | yes | Only the authorization-code flow is supported. |
| `scope` | query | string | no | Space-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). |
| `state` | query | string | no | Opaque CSRF value. Echoed back on success and on any error delivered to `redirect_uri`. Always send one and always verify it. |
| `code_challenge` | query | string | yes | PKCE 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_method` | query | S256 | yes | Must be `S256`. Anything else — including `plain` — is rejected with `invalid_request`. |
| `nonce` | query | string | no | OIDC 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**

```text
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 `state` — `unsupported_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**

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `invalid_request` | Plain 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. |
| 429 | `temporarily_unavailable` | Rate 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. |

Anchor on the human reference: https://core.sententai.com/docs#op-authorize

### Tokens

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

#### POST /api/oauth/token — Exchange 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**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `grant_type` | form | authorization_code \| refresh_token \| client_credentials | yes | Which grant to run. Anything else is `unsupported_grant_type`. |
| `code` | form | string | no | `authorization_code` grant — the single-use code from `/authorize`. |
| `redirect_uri` | form | string | no | `authorization_code` grant — must be byte-identical to the one used at `/authorize`. |
| `code_verifier` | form | string | no | `authorization_code` grant — the PKCE verifier. Always required in practice, since `/authorize` will not mint a code without a challenge. |
| `refresh_token` | form | string | no | `refresh_token` grant — the current refresh token. |
| `scope` | form | string | no | `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 | no | Required only when authenticating with `client_secret_post`. Omit when using HTTP Basic. |
| `client_secret` | form | string | no | Required only when authenticating with `client_secret_post`. Omit when using HTTP Basic. |

**Example request — Authorization code**

```bash
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
```

**Example request — Refresh**

```bash
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"
```

**Example request — Service account**

```bash
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.

```json
{
  "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. |

Anchor on the human reference: https://core.sententai.com/docs#op-token

#### GET /api/oauth/userinfo — Claims 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**

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

**Response 200** — Claims for the granted scope.

```json
{
  "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. |

Anchor on the human reference: https://core.sententai.com/docs#op-userinfoget

#### POST /api/oauth/userinfo — Claims 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**

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

**Response 200** — Claims for the granted scope.

```json
{
  "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. |

Anchor on the human reference: https://core.sententai.com/docs#op-userinfopost

### Token lifecycle

Authoritative checks on tokens you already hold — is it still active, and stop honouring it.

#### POST /api/oauth/introspect — Is 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**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `token` | form | string | no | The access token to inspect. Omitting it is not an error — you get `{ "active": false }`. |
| `client_id` | form | string | no | Required only when authenticating with `client_secret_post`. Omit when using HTTP Basic. |
| `client_secret` | form | string | no | Required only when authenticating with `client_secret_post`. Omit when using HTTP Basic. |

**Example request — curl**

```bash
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`.

```json
{
  "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. |

Anchor on the human reference: https://core.sententai.com/docs#op-introspect

#### POST /api/oauth/revoke — Revoke 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**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `token` | form | string | no | A refresh token or an access token belonging to the session being revoked. |
| `client_id` | form | string | no | Required only when authenticating with `client_secret_post`. Omit when using HTTP Basic. |
| `client_secret` | form | string | no | Required only when authenticating with `client_secret_post`. Omit when using HTTP Basic. |

**Example request — curl**

```bash
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. |

Anchor on the human reference: https://core.sententai.com/docs#op-revoke

### Logout

Ending a session. Browser-facing, like authorization — send the user here rather than fetching it.

#### GET /api/oauth/logout — End 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 (public)

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id_token_hint` | query | string | no | 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 | no | 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 | no | 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 | no | Opaque value echoed as a query parameter on the post-logout redirect. Ignored when no redirect happens. |

**Example request — Browser redirect**

```text
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**

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

Anchor on the human reference: https://core.sententai.com/docs#op-endsession

#### POST /api/oauth/logout — End 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 (public)

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id_token_hint` | form | string | no | 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 | no | 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 | no | 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 | no | Opaque value echoed as a query parameter on the post-logout redirect. Ignored when no redirect happens. |

**Example request — Form POST**

```text
<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. |

Anchor on the human reference: https://core.sententai.com/docs#op-endsessionpost

### 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**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `tenantId` | path | string | yes | 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**

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

**Response 200** — The tenant and its policy.

```json
{
  "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. |

Anchor on the human reference: https://core.sententai.com/docs#op-gettenant

#### GET /api/v1/tenants/{tenantId}/memberships — List 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**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `tenantId` | path | string | yes | 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 | no | Rows per page, 1-100. Values above 100 are clamped, not rejected. Defaults to 50. |
| `cursor` | query | string | no | 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**

```bash
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.

```json
{
  "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. |

Anchor on the human reference: https://core.sententai.com/docs#op-listmemberships

#### POST /api/v1/tenants/{tenantId}/memberships — Invite 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**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `tenantId` | path | string | yes | 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 | no | 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. |
| `email` | body | string | yes | The person's mailbox. Lower-cased and trimmed; it is the sign-in key. |
| `role` | body | owner \| admin \| member | no | 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**

```bash
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.

```json
{
  "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. |

Anchor on the human reference: https://core.sententai.com/docs#op-createmembership

#### 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**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `tenantId` | path | string | yes | 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 | yes | The membership id, as returned by the list and create operations. |

**Example request — curl**

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

**Response 200** — The membership.

```json
{
  "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. |

Anchor on the human reference: https://core.sententai.com/docs#op-getmembership

#### 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**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `tenantId` | path | string | yes | 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 | yes | The membership id, as returned by the list and create operations. |
| `Idempotency-Key` | header | string | no | 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 | no | Omit to keep the current role, whatever it is — including a custom one. |
| `status` | body | active \| inactive | no | Omit to keep the current status. `invited` is not writable: it is entered by being invited and left by signing in. |

**Example request — curl**

```bash
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.

```json
{
  "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. |

Anchor on the human reference: https://core.sententai.com/docs#op-updatemembership

#### 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**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `tenantId` | path | string | yes | 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 | yes | The membership id, as returned by the list and create operations. |
| `Idempotency-Key` | header | string | no | 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**

```bash
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.

```json
{
  "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. |

Anchor on the human reference: https://core.sententai.com/docs#op-deletemembership

#### POST /api/v1/tenants/{tenantId}/session-revocations — Sign 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**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `tenantId` | path | string | yes | 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 | no | 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 | yes | The Core identity id — the `sub` claim of that person's tokens, or `user_id` from a membership. |

**Example request — curl**

```bash
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.

```json
{
  "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. |

Anchor on the human reference: https://core.sententai.com/docs#op-createsessionrevocation

## Claims and response shapes

### Token response (`TokenResponse`)

Issued by the token endpoint. `scope` and `id_token` appear only when a scope was granted; a request with no `scope` keeps the pre-OIDC response shape exactly.

The `client_credentials` grant returns a NARROWER body: `access_token`, `token_type`, `expires_in`, and `scope` when one was granted. There is no `refresh_token` (OAuth 2.1 §4.2.3 forbids it) and no `id_token` (there is no end user to describe).

| Claim | Type | Always present | Description |
| --- | --- | --- | --- |
| `access_token` | string | yes | EdDSA-signed JWT. Verify offline against the JWKS; see the AccessTokenClaims schema. |
| `token_type` | Bearer | yes | Always `Bearer`. |
| `expires_in` | integer | yes | Access-token lifetime in seconds. Currently 900 (15 minutes) — read it, do not hardcode it. |
| `refresh_token` | string | no | Opaque, single-use refresh token. Rotated on every use; the previous value stops working immediately. Absent for `client_credentials` — a service re-requests instead. |
| `scope` | string | no | The GRANTED scope, which may be narrower than requested (unsupported values are dropped). Omitted for a no-scope grant. |
| `id_token` | string | no | OIDC ID token, present only when the `openid` scope was granted. See the IdTokenClaims schema. |

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

### Access token claims (`AccessTokenClaims`)

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 | Always present | Description |
| --- | --- | --- | --- |
| `iss` | string | yes | Issuer — the configured Core origin. |
| `sub` | string | yes | Core user id. Stable for the lifetime of the identity. |
| `aud` | string | yes | Audience — the `client_id` the token was issued to. Reject tokens minted for another app. |
| `principal_type` | user | yes | 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 | yes | Tenant (customer organization) id this grant is scoped to. |
| `app` | string | yes | The `client_id`, repeated as a private claim for convenience. |
| `role` | string | yes | 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 | yes | Membership status at issuance. Always `active` — a non-active membership never gets a token. |
| `sid` | string | yes | Session id. Pass it to your own logs; revocation is per-session. |
| `scope` | string | no | Granted OIDC scope, RFC 9068 style. Omitted for a no-scope grant. |
| `amr` | array | no | 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 | no | 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 | yes | Issued-at, seconds since epoch. |
| `exp` | integer | yes | Expiry, seconds since epoch. 15 minutes after `iat`. |

```json
{
  "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
}
```

### Service token claims (`ServiceTokenClaims`)

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 | Always present | Description |
| --- | --- | --- | --- |
| `iss` | string | yes | Issuer — the configured Core origin. |
| `sub` | string | yes | The service account's `client_id` (always `svc_`-prefixed). Never a user id. |
| `principal_type` | service | yes | Always `service`. Reject this token anywhere your API expects a person. |
| `tenant` | string | yes | The tenant that owns the service account. The vendor tenant means a platform-internal service. |
| `scope` | string | no | The granted subset of the account's scope allow-list. Omitted when the account holds no scopes. |
| `iat` | integer | yes | Issued-at, seconds since epoch. |
| `exp` | integer | yes | Expiry, seconds since epoch. 5 minutes after `iat` — much shorter than a user token, because there is no session to revoke. |

```json
{
  "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
}
```

### ID token claims (`IdTokenClaims`)

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 | Always present | Description |
| --- | --- | --- | --- |
| `iss` | string | yes | Issuer — the configured Core origin. |
| `sub` | string | yes | Core user id. Matches the access token's `sub`. |
| `aud` | string | yes | The `client_id` this ID token was issued to. |
| `nonce` | string | no | Echoed from the authorization request when supplied. Verify it, then discard. |
| `email` | string | no | Present when the `email` scope was granted. |
| `email_verified` | boolean | no | Present with `email`. Always true — the upstream identity provider is Google and accounts are whitelist-gated besides. |
| `name` | string | no | Present when the `profile` scope was granted AND a display name is known. Omitted, never null, when unknown. |
| `picture` | string | no | Present when the `profile` scope was granted AND an avatar is known. |
| `tenant` | string | yes | Private claim — tenant id, so an off-the-shelf OIDC client gets tenancy without a userinfo call. |
| `role` | string | yes | Private claim — the user's role in that tenant. |
| `sid` | string | yes | 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 | no | 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 | no | Identity provider used for this sign-in, matching the access token's `idp`: `google`, `azure`, or `email` (magic link). Absent when unknown. |
| `iat` | integer | yes | Issued-at, seconds since epoch. |
| `exp` | integer | yes | Expiry, seconds since epoch. |

```json
{
  "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
}
```

### Logout token claims (`LogoutTokenClaims`)

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 | Always present | Description |
| --- | --- | --- | --- |
| `iss` | string | yes | Issuer — must equal the `iss` you verify ID tokens against. |
| `aud` | string | yes | Your `client_id`. |
| `sub` | string | yes | Core user id — the same `sub` as that user's ID token. |
| `sid` | string | yes | The session that was revoked. Matches the `sid` claim of the ID token Core issued for it. |
| `jti` | string | yes | Unique token id. Remember it for the token's short lifetime to make replay a no-op. |
| `events` | object | yes | Fixed: `{"http://schemas.openid.net/event/backchannel-logout": {}}`. Its presence is what makes this a logout token — reject anything without it. |
| `iat` | integer | yes | Issued-at, seconds since epoch. |
| `exp` | integer | yes | Expiry, seconds since epoch. Two minutes after `iat`. |

```json
{
  "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
}
```

### Webhook event envelope (`WebhookEventEnvelope`)

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 | Always present | Description |
| --- | --- | --- | --- |
| `id` | string | yes | 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 | yes | 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 | yes | When the change happened in Core (RFC 3339, UTC). Not when it was delivered. |
| `tenant_id` | string | yes | The tenant the event belongs to — the tenant that owns your application. |
| `data` | object | yes | 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. |

```json
{
  "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"
  }
}
```

### UserInfo response (`UserInfoResponse`)

Claims for the presented access token, shaped by its granted scope. `sub`, `tenant` and `role` are always present; `role` is read LIVE from the membership, so it reflects a role change made after the token was minted.

| Claim | Type | Always present | Description |
| --- | --- | --- | --- |
| `sub` | string | yes | Core user id. Always equals the access token's `sub` — compare them, per OIDC Core 5.3.2. |
| `email` | string | no | Present when the `email` scope was granted. |
| `email_verified` | boolean | no | Present with `email`. Always true. |
| `name` | string | no | Present when `profile` was granted and a display name is known. |
| `picture` | string | no | Present when `profile` was granted and an avatar is known. |
| `tenant` | string | yes | Tenant id from the access token. |
| `role` | string | yes | Current role in that tenant, re-read at request time. |

```json
{
  "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"
}
```

### Introspection response (`IntrospectionResponse`)

RFC 7662 response. `active: false` is the only field returned for any token that fails signature, expiry, session-liveness, or membership checks — and for a request with no `token` parameter at all. Never infer anything else from a false response.

`principal_type` tells you which shape you got. A `user` response carries `app`, `role`, and `sid`; a `service` response carries none of them, because a machine principal has no membership and no session — its liveness is the service account being enabled. Branch on `principal_type` before reading `role`.

| Claim | Type | Always present | Description |
| --- | --- | --- | --- |
| `active` | boolean | yes | Whether the token is currently usable. |
| `principal_type` | user \| service | no | Which kind of principal the token represents. Active tokens only. |
| `sub` | string | no | Core user id, or the service account's `client_id`. Active tokens only. |
| `tenant` | string | no | Tenant id. Active tokens only. |
| `app` | string | no | The `client_id` the token was issued to. `user` tokens only. |
| `role` | string | no | CURRENT role, re-read from the membership rather than copied from the token. `user` tokens only. |
| `sid` | string | no | Session id. `user` tokens only. |
| `exp` | integer | no | Token expiry, seconds since epoch. Active tokens only. |
| `scope` | string | no | Granted scope. Omitted for a no-scope grant (RFC 7662 §2.2 makes it optional). |
| `iss` | string | no | Which Core deployment signed the token (RFC 7662 §2.2, optional). Worth checking if you verify tokens from more than one environment: two deployments that share a signing key produce tokens that verify against each other's JWKS, and this claim is the only thing that tells them apart. Active tokens only. |

```json
{
  "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"
}
```

### OAuth error (`OAuthError`)

RFC 6749 §5.2 error body. `error_description` is present only where it adds something the code does not already say.

| Claim | Type | Always present | Description |
| --- | --- | --- | --- |
| `error` | string | yes | Machine-readable error code. |
| `error_description` | string | no | Human-readable detail. Optional — do not parse it. |

```json
{
  "error": "invalid_grant",
  "error_description": "PKCE verification failed"
}
```

### JSON Web Key (`JsonWebKey`)

An Ed25519 public key in JWK form.

| Claim | Type | Always present | Description |
| --- | --- | --- | --- |
| `kty` | OKP | yes | Key type — octet key pair. |
| `crv` | Ed25519 | yes | Curve. |
| `x` | string | yes | base64url public key material. |
| `kid` | string | yes | Key id. Match it against the JWT header's `kid`. |
| `alg` | EdDSA | no | Signing algorithm. |
| `use` | sig | no | Key use. |

### JWK Set (`Jwks`)

Core's public signing keys. Cache for the advertised 300 seconds and re-fetch on an unknown `kid` — that is how rotation reaches you without a redeploy.

| Claim | Type | Always present | Description |
| --- | --- | --- | --- |
| `keys` | array | yes | Active and recently retired public keys. |

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

### OpenID Provider Metadata (`OpenIdConfiguration`)

Discovery document (RFC 8414 / OIDC Discovery 1.0). Endpoint URLs are absolute and derived from the configured issuer — build your integration from these rather than hardcoding paths.

| Claim | Type | Always present | Description |
| --- | --- | --- | --- |
| `issuer` | string | yes | The `iss` value Core puts in every token. |
| `authorization_endpoint` | string | yes |  |
| `token_endpoint` | string | yes |  |
| `userinfo_endpoint` | string | no |  |
| `introspection_endpoint` | string | no |  |
| `revocation_endpoint` | string | no |  |
| `end_session_endpoint` | string | no | OIDC RP-Initiated Logout 1.0 — where you send a user who is signing out. |
| `backchannel_logout_supported` | boolean | no | True: Core POSTs a signed logout token to an application's registered `backchannel_logout_uri` whenever one of its sessions is revoked — by the user, by an operator, or by a membership being deactivated. Ask your operator to register the endpoint; without one your application is skipped. |
| `backchannel_logout_session_supported` | boolean | no | True: every logout token carries `sid`, so you can end exactly the session that was revoked rather than all of that user's. |
| `jwks_uri` | string | yes |  |
| `response_types_supported` | array | no |  |
| `response_modes_supported` | array | no |  |
| `grant_types_supported` | array | no |  |
| `scopes_supported` | array | no |  |
| `claims_supported` | array | no |  |
| `id_token_signing_alg_values_supported` | array | no |  |
| `token_endpoint_auth_methods_supported` | array | no |  |
| `code_challenge_methods_supported` | array | no |  |
| `subject_types_supported` | array | no |  |

```json
{
  "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"
  ]
}
```

### Management API error (`ApiError`)

The ONE body every non-2xx response from `/api/v1` carries. It is not the OAuth error shape used by the token endpoints — those follow RFC 6749 and this follows nothing but its own contract, so it can carry per-field detail.

`code` is stable and machine-readable; branch on it. `message` is written for a human debugging an integration and may be reworded without a version bump. `field_errors` appears only when a specific input was at fault.

Note what a 404 means here: either the tenant or membership does not exist, OR your principal may not see it. The two are deliberately indistinguishable — telling them apart would confirm that another customer's tenant exists.

| Claim | Type | Always present | Description |
| --- | --- | --- | --- |
| `error` | object | yes |  |

```json
{
  "error": {
    "code": "FORBIDDEN",
    "message": "Please correct the highlighted fields.",
    "field_errors": {
      "role": "This principal cannot grant that role."
    }
  }
}
```

### Membership (`Membership`)

A person's grant of a role inside one tenant. `user_id` is Core's stable identity id — the same value that appears as `sub` in an access token — so it is the key to join against whatever you already store.

`email` AND `invited_at` are present ONLY when your token carries `core:users:read`. Both are facts about the PERSON, not just about this grant: an address is an identity, and `invited_at` is identity-level (one invitation per person across every tenant, cleared everywhere by one sign-in), so returning it to a token that cannot read identities would let a caller probe whether an arbitrary address is already a Core user. They are omitted, not nulled, so "you did not ask" stays distinguishable from "there is none".

| Claim | Type | Always present | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Membership id. Addresses this row in the API. |
| `user_id` | string | yes | Core identity id — the `sub` claim of that person's tokens. |
| `tenant_id` | string | yes | The tenant this grant is in. Always the tenant in the URL. |
| `role` | string | yes | `owner`, `admin`, `member`, or an operator-defined custom role. The API can only ever SET one of the three system roles; a custom role is readable and preservable but not grantable, because Core cannot rank a role whose meaning lives in your application. |
| `status` | active \| inactive | yes | The state of the GRANT: `active` while it is effective, `inactive` once access is revoked (and the person's live sessions in this tenant were killed). A membership is created `active` and takes effect immediately, so this is never returned as `invited` — whether the PERSON has actually signed in yet is a separate, identity-level fact you read from `invited_at` (a non-null value means the invitation is still outstanding). The API may write `active` and `inactive`. |
| `created_at` | string | yes | When the grant was made. Half of the pagination key. |
| `invited_at` | string | no | Requires the `core:users:read` scope; omitted otherwise (it is identity-level, so exposing it to a memberships-only token would leak whether an address is already a Core user). When present: when this person's OUTSTANDING invitation was issued, or null once they have signed in — so a non-null value is exactly "still pending". An unclaimed invitation EXPIRES. Core sends no token with one (the identity provider is what proves the mailbox), so the invitation itself is the standing grant, and it stops working **14 days** after this instant on a default deployment — `SENTENT_CORE_INVITE_TTL_DAYS` on the Core deployment is the number, and your operator can tell you if it was changed. After that the person's sign-in is refused exactly like an address Core has never heard of. The recovery path is to invite them again: re-POSTing the same address into a tenant they already belong to re-issues the invitation and returns their existing membership with a fresh `invited_at`, rather than a `409`. It never creates a second grant, and it never changes the role they already hold. The invitation is identity-level, so this is the same value on every tenant the person belongs to, and one sign-in clears it everywhere. |
| `email` | string | no | Requires the `core:users:read` scope. Omitted otherwise. |

```json
{
  "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"
}
```

### Membership page (`MembershipPage`)

One keyset page, oldest grant first. `next_cursor` is null on the last page — do not issue an extra request to discover that.

Pagination is keyset, not offset, because you are paging a LIVE list: with offsets, a membership created ahead of your position silently pushes one row past you. Pass the `next_cursor` back verbatim as `?cursor=`; it is opaque and only meaningful to Core.

| Claim | Type | Always present | Description |
| --- | --- | --- | --- |
| `data` | array | yes | The page. |
| `next_cursor` | string | yes | Pass as `?cursor=` for the next page. Null when this is the last one. |

```json
{
  "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"
}
```

### Tenant (`Tenant`)

The organization your principal administers. `settings` is read-only here: the per-tenant policy is what BOUNDS an API principal's invite authority, so it is deliberately not editable by the principal it bounds — an operator or a tenant owner changes it, in Core's own surfaces.

It is exposed because it EXPLAINS a refusal you would otherwise hit blind: an invite to an address outside `allowed_email_domains` is a 422, and a UI you build on this API should be able to say so before someone types it.

| Claim | Type | Always present | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Tenant id. Matches the `tenant` claim in your service token. |
| `name` | string | yes | Display name, as the operator registered it. |
| `kind` | client | yes | Always `client`. The vendor (SententAI operator) tenant is not addressable through this API at all — membership in it IS platform-operator authority, and an API that could grant it would be an API that mints operators. |
| `status` | active \| suspended | yes | Lifecycle state of the organization. |
| `created_at` | string | yes | When the tenant was registered. |
| `settings` | object | yes | Per-tenant policy. Read-only on this surface. |

```json
{
  "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"
    ]
  }
}
```

### Session revocation result (`SessionRevocationResult`)

How many live sessions the revocation covered. Zero is a success, not a failure — the person simply had none in this tenant.

Every revoked session also fans out a back-channel `logout_token` to any application that registered one, and a `session.revoked` webhook event. Already-issued access tokens stay cryptographically valid until they expire (up to 15 minutes); introspection reports them dead immediately.

| Claim | Type | Always present | Description |
| --- | --- | --- | --- |
| `user_id` | string | yes | The identity whose sessions were revoked. |
| `tenant_id` | string | yes | Scope of the revocation — other tenants are untouched. |
| `revoked` | integer | yes | Count of sessions moved to `revoked` by this call. |

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

## Webhooks (outbound — Core POSTs to you)

Core pushes identity changes to an endpoint a Core operator registers for your application, so you can drop a cached identity or kill a local session without waiting for a token refresh to fail. Nothing here changes the HTTP surface you call.

| Property | Value |
| --- | --- |
| Signature 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 parked as dead |

### Verification rules

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-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. Reject if `|now − t| > 300`. This is a replay window, not the security boundary; the boundary is deduping on `id`.
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.

```javascript
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 | Meaning |
| --- | --- |
| `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. |

## Rate limits

| Scope | Limit | Endpoints | Notes |
| --- | --- | --- | --- |
| Authorization | 10 / minute / IP, burst 10 | `GET /authorize` | The strictest bucket. Capacity 10, refilling at one request every six seconds. |
| Token | 20 / 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 tokens | 30 / 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. |
| UserInfo | 20 / minute / IP, burst 20 | `GET /api/oauth/userinfo`, `POST /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. |
| Introspection | 20 / 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. |
| Revocation | 20 / 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. |
| Logout | 10 / minute / IP, burst 10 | `GET /api/oauth/logout`, `POST /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`. |

Exceeding a bucket returns `429` with `{ "error": "temporarily_unavailable" }`. Buckets are per-IP and in-memory per server instance, so treat the published numbers as a floor, not a contract.

## Error codes

| Code | Statuses | 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` | — | 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` | — | 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` | — | 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` | — | 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

This reference is versioned independently of the Core deployment. The current documented version is `v1` (`1.1.0`, semver). Additive changes bump the minor version; a breaking change to the HTTP surface would publish a new version key alongside this one rather than mutate it.

### Changelog

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