{"openapi":"3.1.0","info":{"title":"Sentent Core API","version":"1.1.0","summary":"OAuth 2.1 and OpenID Connect endpoints for applications that authenticate against Sentent Core.","description":"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.\n\nEverything 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.\n\nThere 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.","contact":{"name":"SententAI","email":"scott@sententai.com"}},"servers":[{"url":"{issuer}","description":"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.","variables":{"issuer":{"default":"https://core.sentent.ai","description":"Base URL of the Core deployment — the same value that appears as `iss` in every issued token."}}}],"tags":[{"name":"Discovery","description":"Public, cacheable documents that let a client configure itself. No authentication, CORS open."},{"name":"Authorization","description":"The browser-facing half of the flow: send the user here, get an authorization code back."},{"name":"Tokens","description":"The back-channel half: exchange the code, rotate refresh tokens, read claims for an access token."},{"name":"Token lifecycle","description":"Authoritative checks on tokens you already hold — is it still active, and stop honouring it."},{"name":"Logout","description":"Ending a session. Browser-facing, like authorization — send the user here rather than fetching it."},{"name":"Management API","description":"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."}],"paths":{"/.well-known/openid-configuration":{"get":{"operationId":"getOpenIdConfiguration","summary":"OpenID provider metadata","tags":["Discovery"],"description":"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.","responses":{"200":{"description":"The provider metadata.","headers":{"Cache-Control":{"description":"`public, max-age=300`","schema":{"type":"string"}},"Access-Control-Allow-Origin":{"description":"`*` — the document is public.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpenIdConfiguration"}}}}},"x-codeSamples":[{"lang":"bash","label":"curl","source":"curl https://core.sentent.ai/.well-known/openid-configuration"}]}},"/.well-known/jwks.json":{"get":{"operationId":"getJwks","summary":"Public signing keys (JWKS)","tags":["Discovery"],"description":"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.","responses":{"200":{"description":"The key set.","headers":{"Cache-Control":{"description":"`public, max-age=300, must-revalidate`","schema":{"type":"string"}},"Access-Control-Allow-Origin":{"description":"`*` — public keys only.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Jwks"}}}},"500":{"description":"The key set could not be read. Keep using your cached copy and retry.","x-error-code":"server_error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"example":{"error":"server_error"}}}}},"x-codeSamples":[{"lang":"bash","label":"curl","source":"curl https://core.sentent.ai/.well-known/jwks.json"}]}},"/authorize":{"get":{"operationId":"authorize","summary":"Authorization request (code + PKCE)","tags":["Authorization"],"description":"The OAuth 2.1 authorization endpoint. This is a BROWSER redirect, not an API call — send the user here, do not fetch it.\n\nPKCE is mandatory: send a `code_challenge` with `code_challenge_method=S256` on every request, whatever kind of client you are.\n\nCore 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.\n\nOpen-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`.","parameters":[{"name":"client_id","in":"query","required":true,"description":"The registered application's client id.","schema":{"type":"string"},"example":"app_reporting_portal"},{"name":"redirect_uri","in":"query","required":true,"description":"Must EXACTLY match one of the application's registered redirect URIs. No prefix or wildcard matching.","schema":{"type":"string","format":"uri"},"example":"https://app.example.com/auth/callback"},{"name":"response_type","in":"query","required":true,"description":"Only the authorization-code flow is supported.","schema":{"type":"string","enum":["code"]},"example":"code"},{"name":"scope","in":"query","description":"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).","schema":{"type":"string"},"example":"openid profile email"},{"name":"state","in":"query","description":"Opaque CSRF value. Echoed back on success and on any error delivered to `redirect_uri`. Always send one and always verify it.","schema":{"type":"string"},"example":"kR3xw9"},{"name":"code_challenge","in":"query","required":true,"description":"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`.","schema":{"type":"string"},"example":"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"},{"name":"code_challenge_method","in":"query","required":true,"description":"Must be `S256`. Anything else — including `plain` — is rejected with `invalid_request`.","schema":{"type":"string","enum":["S256"]},"example":"S256"},{"name":"nonce","in":"query","description":"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).","schema":{"type":"string"},"example":"n-0S6_WzA2Mj"}],"responses":{"302":{"description":"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.","headers":{"Location":{"description":"The redirect target.","schema":{"type":"string","format":"uri"}}}},"400":{"description":"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.","x-error-code":"invalid_request","content":{"text/plain":{"schema":{"type":"string"},"example":"redirect_uri is not registered for this application."}}},"429":{"description":"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.","x-error-code":"temporarily_unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"example":{"error":"temporarily_unavailable"}}}}},"x-codeSamples":[{"lang":"text","label":"Browser redirect","source":"https://core.sentent.ai/authorize\n  ?client_id=app_reporting_portal\n  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback\n  &response_type=code\n  &scope=openid%20profile%20email\n  &state=kR3xw9\n  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM\n  &code_challenge_method=S256\n  &nonce=n-0S6_WzA2Mj"}]}},"/api/oauth/token":{"post":{"operationId":"token","summary":"Exchange a code, rotate a refresh token, or mint a service token","tags":["Tokens"],"description":"The token endpoint. Confidential clients only — call it from your backend, never from a browser.\n\n`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.\n\nRefresh tokens are single-use. The value you send is dead the moment the response is written — persist the new one.\n\n`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.","security":[{"client_secret_basic":[]}],"requestBody":{"required":true,"description":"`application/x-www-form-urlencoded` per RFC 6749. A JSON body with the same keys is also accepted.","content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["grant_type"],"properties":{"grant_type":{"type":"string","enum":["authorization_code","refresh_token","client_credentials"],"description":"Which grant to run. Anything else is `unsupported_grant_type`."},"code":{"type":"string","description":"`authorization_code` grant — the single-use code from `/authorize`."},"redirect_uri":{"type":"string","description":"`authorization_code` grant — must be byte-identical to the one used at `/authorize`."},"code_verifier":{"type":"string","description":"`authorization_code` grant — the PKCE verifier. Always required in practice, since `/authorize` will not mint a code without a challenge."},"refresh_token":{"type":"string","description":"`refresh_token` grant — the current refresh token."},"scope":{"type":"string","description":"`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.","example":"crm:read billing:webhook"},"client_id":{"type":"string","description":"Required only when authenticating with `client_secret_post`. Omit when using HTTP Basic."},"client_secret":{"type":"string","description":"Required only when authenticating with `client_secret_post`. Omit when using HTTP Basic."}}}}}},"responses":{"200":{"description":"Tokens issued.","headers":{"Cache-Control":{"description":"`no-store`","schema":{"type":"string"}},"Pragma":{"description":"`no-cache`","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenResponse"}}}},"400":{"description":"`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.","x-error-code":"invalid_grant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"example":{"error":"invalid_grant","error_description":"PKCE verification failed"}}}},"401":{"description":"Client authentication failed — unknown `client_id`, disabled application, or wrong secret. Deliberately indistinguishable between those cases.","x-error-code":"invalid_client","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"example":{"error":"invalid_client"}}}},"429":{"description":"Rate limited. This endpoint allows 20 requests per minute per IP, burst 20. The body is `{ \"error\": \"temporarily_unavailable\" }`; retry after a short backoff.","x-error-code":"temporarily_unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"example":{"error":"temporarily_unavailable"}}}},"500":{"description":"Issuance failed server-side — most often the signing key is not configured for this deployment. Nothing was issued; retry or contact the operator.","x-error-code":"server_error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"example":{"error":"server_error"}}}}},"x-codeSamples":[{"lang":"bash","label":"Authorization code","source":"curl -X POST https://core.sentent.ai/api/oauth/token \\\n  -u \"$CLIENT_ID:$CLIENT_SECRET\" \\\n  -d grant_type=authorization_code \\\n  -d code=Ab3xQ9pL7t \\\n  -d redirect_uri=https://app.example.com/auth/callback \\\n  -d code_verifier=dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk"},{"lang":"bash","label":"Refresh","source":"curl -X POST https://core.sentent.ai/api/oauth/token \\\n  -u \"$CLIENT_ID:$CLIENT_SECRET\" \\\n  -d grant_type=refresh_token \\\n  -d refresh_token=\"$REFRESH_TOKEN\""},{"lang":"bash","label":"Service account","source":"curl -X POST https://core.sentent.ai/api/oauth/token \\\n  -u \"$SERVICE_CLIENT_ID:$SERVICE_CLIENT_SECRET\" \\\n  -d grant_type=client_credentials \\\n  -d scope=\"crm:read\""}]}},"/api/oauth/userinfo":{"get":{"operationId":"userinfoGet","summary":"Claims for an access token","tags":["Tokens"],"description":"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.\n\nCORS is wildcard on purpose: this is a Bearer endpoint with no cookie credentials. `OPTIONS` preflight returns 204 with the same CORS headers.","security":[{"bearer":[]}],"responses":{"200":{"description":"Claims for the granted scope.","headers":{"Cache-Control":{"description":"`no-store`","schema":{"type":"string"}},"Access-Control-Allow-Origin":{"description":"`*`","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserInfoResponse"}}}},"401":{"description":"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.","x-error-code":"invalid_token","headers":{"WWW-Authenticate":{"description":"Bearer error=\"invalid_token\"","schema":{"type":"string"}}}},"403":{"description":"Empty body with `WWW-Authenticate: Bearer error=\"insufficient_scope\"` — the token is valid but was not granted `openid`.","x-error-code":"insufficient_scope","headers":{"WWW-Authenticate":{"description":"Bearer error=\"insufficient_scope\"","schema":{"type":"string"}}}},"429":{"description":"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.","x-error-code":"temporarily_unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"example":{"error":"temporarily_unavailable"}}}}},"x-codeSamples":[{"lang":"bash","label":"curl","source":"curl https://core.sentent.ai/api/oauth/userinfo \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\""}]},"post":{"operationId":"userinfoPost","summary":"Claims for an access token (POST form)","tags":["Tokens"],"description":"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.","security":[{"bearer":[]}],"responses":{"200":{"description":"Claims for the granted scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserInfoResponse"}}}},"401":{"description":"Empty body with `WWW-Authenticate: Bearer error=\"invalid_token\"`.","x-error-code":"invalid_token"},"403":{"description":"Empty body with `WWW-Authenticate: Bearer error=\"insufficient_scope\"`.","x-error-code":"insufficient_scope"},"429":{"description":"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.","x-error-code":"temporarily_unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"example":{"error":"temporarily_unavailable"}}}}},"x-codeSamples":[{"lang":"bash","label":"curl","source":"curl -X POST https://core.sentent.ai/api/oauth/userinfo \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\""}]}},"/api/oauth/introspect":{"post":{"operationId":"introspect","summary":"Is this token still active?","tags":["Token lifecycle"],"description":"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).\n\nThe `role` in the response is re-read from the membership, so it is fresher than the one baked into the token.","security":[{"client_secret_basic":[]}],"requestBody":{"required":true,"description":"`application/x-www-form-urlencoded`; a JSON body with the same keys also works. Any additional RFC 7662 parameters (such as `token_type_hint`) are ignored.","content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"token":{"type":"string","description":"The access token to inspect. Omitting it is not an error — you get `{ \"active\": false }`."},"client_id":{"type":"string","description":"Required only when authenticating with `client_secret_post`. Omit when using HTTP Basic."},"client_secret":{"type":"string","description":"Required only when authenticating with `client_secret_post`. Omit when using HTTP Basic."}}}}}},"responses":{"200":{"description":"The introspection result. `active: false` for any token that fails verification, liveness, or membership — and for a request with no `token`.","headers":{"Cache-Control":{"description":"`no-store`","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IntrospectionResponse"}}}},"400":{"description":"No client credentials were presented in either the `Authorization` header or the request body.","x-error-code":"invalid_request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"example":{"error":"invalid_request"}}}},"401":{"description":"Client authentication failed — unknown `client_id`, disabled application, or wrong secret. Deliberately indistinguishable between those cases.","x-error-code":"invalid_client","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"example":{"error":"invalid_client"}}}},"429":{"description":"Rate limited. This endpoint allows 20 requests per minute per IP, burst 20. The body is `{ \"error\": \"temporarily_unavailable\" }`; retry after a short backoff.","x-error-code":"temporarily_unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"example":{"error":"temporarily_unavailable"}}}}},"x-codeSamples":[{"lang":"bash","label":"curl","source":"curl -X POST https://core.sentent.ai/api/oauth/introspect \\\n  -u \"$CLIENT_ID:$CLIENT_SECRET\" \\\n  -d token=\"$ACCESS_TOKEN\""}]}},"/api/oauth/revoke":{"post":{"operationId":"revoke","summary":"Revoke a session","tags":["Token lifecycle"],"description":"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.\n\nAlways 200, whether or not the token matched, so callers cannot probe for valid tokens. Revocation is idempotent.\n\nNote 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.","security":[{"client_secret_basic":[]}],"requestBody":{"required":true,"description":"`application/x-www-form-urlencoded`; a JSON body with the same keys also works.","content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"token":{"type":"string","description":"A refresh token or an access token belonging to the session being revoked."},"client_id":{"type":"string","description":"Required only when authenticating with `client_secret_post`. Omit when using HTTP Basic."},"client_secret":{"type":"string","description":"Required only when authenticating with `client_secret_post`. Omit when using HTTP Basic."}}}}}},"responses":{"200":{"description":"Empty body. Returned whether the token matched a live session, matched nothing, or was omitted entirely."},"400":{"description":"No client credentials were presented in either the `Authorization` header or the request body.","x-error-code":"invalid_request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"example":{"error":"invalid_request"}}}},"401":{"description":"Client authentication failed — unknown `client_id`, disabled application, or wrong secret. Deliberately indistinguishable between those cases.","x-error-code":"invalid_client","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"example":{"error":"invalid_client"}}}},"429":{"description":"Rate limited. This endpoint allows 20 requests per minute per IP, burst 20. The body is `{ \"error\": \"temporarily_unavailable\" }`; retry after a short backoff.","x-error-code":"temporarily_unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"example":{"error":"temporarily_unavailable"}}}}},"x-codeSamples":[{"lang":"bash","label":"curl","source":"curl -X POST https://core.sentent.ai/api/oauth/revoke \\\n  -u \"$CLIENT_ID:$CLIENT_SECRET\" \\\n  -d token=\"$REFRESH_TOKEN\""}]}},"/api/oauth/logout":{"get":{"operationId":"endSession","summary":"End the session (RP-initiated logout)","tags":["Logout"],"description":"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`.\n\nWhat 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.\n\nSend 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.\n\nRedirect 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.\n\nAlready-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.\n\nBack-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.\n\nThis is a BROWSER redirect, not an API call — send the user here, do not fetch it.","parameters":[{"name":"id_token_hint","in":"query","description":"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.","schema":{"type":"string"}},{"name":"client_id","in":"query","description":"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.","schema":{"type":"string"},"example":"app_reporting_portal"},{"name":"post_logout_redirect_uri","in":"query","description":"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).","schema":{"type":"string","format":"uri"},"example":"https://app.example.com/signed-out"},{"name":"state","in":"query","description":"Opaque value echoed as a query parameter on the post-logout redirect. Ignored when no redirect happens.","schema":{"type":"string"},"example":"kR3xw9"}],"responses":{"302":{"description":"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.","headers":{"Location":{"description":"The redirect target.","schema":{"type":"string","format":"uri"}}}},"429":{"description":"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.","x-error-code":"temporarily_unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"example":{"error":"temporarily_unavailable"}}}}},"x-codeSamples":[{"lang":"text","label":"Browser redirect","source":"https://core.sentent.ai/api/oauth/logout\n  ?id_token_hint=eyJhbGciOiJFZERTQSJ9…\n  &post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2Fsigned-out\n  &state=kR3xw9"}]},"post":{"operationId":"endSessionPost","summary":"End the session (POST form)","tags":["Logout"],"description":"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`.\n\nWhat 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.\n\nSend 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.\n\nRedirect 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.\n\nAlready-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.\n\nBack-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.\n\nIdentical 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.","requestBody":{"required":false,"description":"`application/x-www-form-urlencoded` with the same four optional parameters as the GET form.","content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"id_token_hint":{"type":"string","description":"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":{"type":"string","description":"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":{"type":"string","format":"uri","description":"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":{"type":"string","description":"Opaque value echoed as a query parameter on the post-logout redirect. Ignored when no redirect happens."}}}}}},"responses":{"302":{"description":"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.","headers":{"Location":{"description":"The redirect target.","schema":{"type":"string","format":"uri"}}}},"429":{"description":"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.","x-error-code":"temporarily_unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"example":{"error":"temporarily_unavailable"}}}}},"x-codeSamples":[{"lang":"text","label":"Form POST","source":"<form method=\"post\" action=\"https://core.sentent.ai/api/oauth/logout\">\n  <input type=\"hidden\" name=\"id_token_hint\" value=\"…\" />\n  <input type=\"hidden\" name=\"post_logout_redirect_uri\" value=\"https://app.example.com/signed-out\" />\n  <button type=\"submit\">Sign out</button>\n</form>"}]}},"/api/v1/tenants/{tenantId}":{"get":{"operationId":"getTenant","summary":"Read the tenant","tags":["Management API"],"description":"Read the organization your service principal administers, including its read-only policy.\n\nAuthenticate 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.\n\nA 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.","security":[{"service_bearer":[]}],"parameters":[{"name":"tenantId","in":"path","required":true,"description":"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.","schema":{"type":"string","format":"uuid"},"example":"b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6"}],"responses":{"200":{"description":"The tenant and its policy.","headers":{"Cache-Control":{"description":"`no-store`","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Tenant"}}}},"401":{"description":"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.","x-error-code":"UNAUTHORIZED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"UNAUTHORIZED","message":"Present a valid Core service access token as a Bearer credential."}}}}},"403":{"description":"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.","x-error-code":"FORBIDDEN","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"FORBIDDEN","message":"This token does not carry the core:tenants:read scope."}}}}},"404":{"description":"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.","x-error-code":"NOT_FOUND","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"NOT_FOUND","message":"No such tenant."}}}}},"429":{"description":"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.","x-error-code":"RATE_LIMITED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"RATE_LIMITED","message":"Too many requests. Slow down."}}}}},"500":{"description":"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.","x-error-code":"INTERNAL_ERROR","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"INTERNAL_ERROR","message":"Something went wrong. Please try again."}}}}}},"x-codeSamples":[{"lang":"bash","label":"curl","source":"curl https://core.sentent.ai/api/v1/tenants/$TENANT_ID \\\n  -H \"Authorization: Bearer $SERVICE_ACCESS_TOKEN\""}]}},"/api/v1/tenants/{tenantId}/memberships":{"get":{"operationId":"listMemberships","summary":"List memberships","tags":["Management API"],"description":"Every live membership in the tenant, oldest grant first, keyset-paginated.\n\nAuthenticate 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.\n\nPass `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.\n\nWith 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.","security":[{"service_bearer":[]}],"parameters":[{"name":"tenantId","in":"path","required":true,"description":"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.","schema":{"type":"string","format":"uuid"},"example":"b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6"},{"name":"limit","in":"query","description":"Rows per page, 1-100. Values above 100 are clamped, not rejected. Defaults to 50.","schema":{"type":"integer"},"example":"50"},{"name":"cursor","in":"query","description":"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.","schema":{"type":"string"}}],"responses":{"200":{"description":"One page of memberships.","headers":{"Cache-Control":{"description":"`no-store`","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MembershipPage"}}}},"401":{"description":"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.","x-error-code":"UNAUTHORIZED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"UNAUTHORIZED","message":"Present a valid Core service access token as a Bearer credential."}}}}},"403":{"description":"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.","x-error-code":"FORBIDDEN","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"FORBIDDEN","message":"This token does not carry the core:memberships:read scope."}}}}},"404":{"description":"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.","x-error-code":"NOT_FOUND","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"NOT_FOUND","message":"No such tenant."}}}}},"422":{"description":"The body did not validate. `field_errors` names the offending fields.","x-error-code":"VALIDATION_ERROR","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"VALIDATION_ERROR","message":"Please correct the highlighted fields.","field_errors":{"email":"That mail domain does not accept mail."}}}}}},"429":{"description":"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.","x-error-code":"RATE_LIMITED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"RATE_LIMITED","message":"Too many requests. Slow down."}}}}},"500":{"description":"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.","x-error-code":"INTERNAL_ERROR","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"INTERNAL_ERROR","message":"Something went wrong. Please try again."}}}}}},"x-codeSamples":[{"lang":"bash","label":"curl","source":"curl \"https://core.sentent.ai/api/v1/tenants/$TENANT_ID/memberships?limit=50\" \\\n  -H \"Authorization: Bearer $SERVICE_ACCESS_TOKEN\""}]},"post":{"operationId":"createMembership","summary":"Invite a member","tags":["Management API"],"description":"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.\n\nAuthenticate 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.\n\nThree checks stand between this call and an arbitrary address, and they are different questions:\n\n1. **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.\n2. **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.\n3. **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.\n\n**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.)\n\n**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.","security":[{"service_bearer":[]}],"parameters":[{"name":"tenantId","in":"path","required":true,"description":"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.","schema":{"type":"string","format":"uuid"},"example":"b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6"},{"name":"Idempotency-Key","in":"header","description":"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.","schema":{"type":"string"},"example":"8f0d1b7a-3c25-4d9e-9a10-6b2f4c8e1d33"}],"requestBody":{"required":true,"description":"`application/json`.","content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","format":"email","description":"The person's mailbox. Lower-cased and trimmed; it is the sign-in key.","example":"dana@client.example"},"role":{"type":"string","enum":["owner","admin","member"],"description":"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":{"email":"dana@client.example","role":"member"}}}},"responses":{"201":{"description":"The membership was created.","headers":{"Cache-Control":{"description":"`no-store`","schema":{"type":"string"}},"Idempotency-Replayed":{"description":"`true` when this body is a replay of an earlier identical request.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Membership"}}}},"401":{"description":"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.","x-error-code":"UNAUTHORIZED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"UNAUTHORIZED","message":"Present a valid Core service access token as a Bearer credential."}}}}},"403":{"description":"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.","x-error-code":"FORBIDDEN","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"FORBIDDEN","message":"This token does not carry the core:memberships:write scope."}}}}},"404":{"description":"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.","x-error-code":"NOT_FOUND","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"NOT_FOUND","message":"No such tenant."}}}}},"409":{"description":"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.","x-error-code":"CONFLICT","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"CONFLICT","message":"This is the only active owner. Grant ownership to someone else first."}}}}},"422":{"description":"The body did not validate. `field_errors` names the offending fields.","x-error-code":"VALIDATION_ERROR","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"VALIDATION_ERROR","message":"Please correct the highlighted fields.","field_errors":{"email":"That mail domain does not accept mail."}}}}}},"429":{"description":"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.","x-error-code":"RATE_LIMITED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"RATE_LIMITED","message":"Too many requests. Slow down."}}}}},"500":{"description":"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.","x-error-code":"INTERNAL_ERROR","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"INTERNAL_ERROR","message":"Something went wrong. Please try again."}}}}}},"x-codeSamples":[{"lang":"bash","label":"curl","source":"curl -X POST https://core.sentent.ai/api/v1/tenants/$TENANT_ID/memberships \\\n  -H \"Authorization: Bearer $SERVICE_ACCESS_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{\"email\":\"dana@client.example\",\"role\":\"member\"}'"}]}},"/api/v1/tenants/{tenantId}/memberships/{id}":{"get":{"operationId":"getMembership","summary":"Read one membership","tags":["Management API"],"description":"One membership in the tenant.\n\nAuthenticate 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.\n\nA 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.","security":[{"service_bearer":[]}],"parameters":[{"name":"tenantId","in":"path","required":true,"description":"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.","schema":{"type":"string","format":"uuid"},"example":"b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6"},{"name":"id","in":"path","required":true,"description":"The membership id, as returned by the list and create operations.","schema":{"type":"string","format":"uuid"},"example":"8d02a5f7-4c31-4f6a-b0e2-91c73de4a5b8"}],"responses":{"200":{"description":"The membership.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Membership"}}}},"401":{"description":"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.","x-error-code":"UNAUTHORIZED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"UNAUTHORIZED","message":"Present a valid Core service access token as a Bearer credential."}}}}},"403":{"description":"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.","x-error-code":"FORBIDDEN","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"FORBIDDEN","message":"This token does not carry the core:memberships:read scope."}}}}},"404":{"description":"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.","x-error-code":"NOT_FOUND","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"NOT_FOUND","message":"No such tenant."}}}}},"429":{"description":"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.","x-error-code":"RATE_LIMITED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"RATE_LIMITED","message":"Too many requests. Slow down."}}}}},"500":{"description":"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.","x-error-code":"INTERNAL_ERROR","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"INTERNAL_ERROR","message":"Something went wrong. Please try again."}}}}}},"x-codeSamples":[{"lang":"bash","label":"curl","source":"curl https://core.sentent.ai/api/v1/tenants/$TENANT_ID/memberships/$MEMBERSHIP_ID \\\n  -H \"Authorization: Bearer $SERVICE_ACCESS_TOKEN\""}]},"patch":{"operationId":"updateMembership","summary":"Change a member's role or status","tags":["Management API"],"description":"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.\n\nAuthenticate 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.\n\nFour guards apply, and they are the same ones Core's own delegated console enforces:\n\n- **Tenant scope.** The membership must be in the tenant named in the URL.\n- **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.\n- **What you may grant.** Changing a role additionally requires the authority to grant the new one.\n- **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.\n\nSetting `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.","security":[{"service_bearer":[]}],"parameters":[{"name":"tenantId","in":"path","required":true,"description":"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.","schema":{"type":"string","format":"uuid"},"example":"b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6"},{"name":"id","in":"path","required":true,"description":"The membership id, as returned by the list and create operations.","schema":{"type":"string","format":"uuid"},"example":"8d02a5f7-4c31-4f6a-b0e2-91c73de4a5b8"},{"name":"Idempotency-Key","in":"header","description":"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.","schema":{"type":"string"},"example":"8f0d1b7a-3c25-4d9e-9a10-6b2f4c8e1d33"}],"requestBody":{"required":true,"description":"`application/json`. At least one field.","content":{"application/json":{"schema":{"type":"object","properties":{"role":{"type":"string","enum":["owner","admin","member"],"description":"Omit to keep the current role, whatever it is — including a custom one."},"status":{"type":"string","enum":["active","inactive"],"description":"Omit to keep the current status. `invited` is not writable: it is entered by being invited and left by signing in."}}},"example":{"role":"member","status":"inactive"}}}},"responses":{"200":{"description":"The membership as it now stands.","headers":{"Idempotency-Replayed":{"description":"`true` when this body is a replay of an earlier identical request.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Membership"}}}},"401":{"description":"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.","x-error-code":"UNAUTHORIZED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"UNAUTHORIZED","message":"Present a valid Core service access token as a Bearer credential."}}}}},"403":{"description":"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.","x-error-code":"FORBIDDEN","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"FORBIDDEN","message":"This token does not carry the core:memberships:write scope."}}}}},"404":{"description":"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.","x-error-code":"NOT_FOUND","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"NOT_FOUND","message":"No such tenant."}}}}},"409":{"description":"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.","x-error-code":"CONFLICT","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"CONFLICT","message":"This is the only active owner. Grant ownership to someone else first."}}}}},"422":{"description":"The body did not validate. `field_errors` names the offending fields.","x-error-code":"VALIDATION_ERROR","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"VALIDATION_ERROR","message":"Please correct the highlighted fields.","field_errors":{"email":"That mail domain does not accept mail."}}}}}},"429":{"description":"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.","x-error-code":"RATE_LIMITED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"RATE_LIMITED","message":"Too many requests. Slow down."}}}}},"500":{"description":"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.","x-error-code":"INTERNAL_ERROR","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"INTERNAL_ERROR","message":"Something went wrong. Please try again."}}}}}},"x-codeSamples":[{"lang":"bash","label":"curl","source":"curl -X PATCH https://core.sentent.ai/api/v1/tenants/$TENANT_ID/memberships/$MEMBERSHIP_ID \\\n  -H \"Authorization: Bearer $SERVICE_ACCESS_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"status\":\"inactive\"}'"}]},"delete":{"operationId":"deleteMembership","summary":"Remove a member","tags":["Management API"],"description":"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.\n\nAuthenticate 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.\n\nThe 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.\n\nThe 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.","security":[{"service_bearer":[]}],"parameters":[{"name":"tenantId","in":"path","required":true,"description":"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.","schema":{"type":"string","format":"uuid"},"example":"b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6"},{"name":"id","in":"path","required":true,"description":"The membership id, as returned by the list and create operations.","schema":{"type":"string","format":"uuid"},"example":"8d02a5f7-4c31-4f6a-b0e2-91c73de4a5b8"},{"name":"Idempotency-Key","in":"header","description":"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.","schema":{"type":"string"},"example":"8f0d1b7a-3c25-4d9e-9a10-6b2f4c8e1d33"}],"responses":{"200":{"description":"The removed membership, as it stood before removal.","headers":{"Idempotency-Replayed":{"description":"`true` when this body is a replay of an earlier identical request.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Membership"}}}},"401":{"description":"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.","x-error-code":"UNAUTHORIZED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"UNAUTHORIZED","message":"Present a valid Core service access token as a Bearer credential."}}}}},"403":{"description":"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.","x-error-code":"FORBIDDEN","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"FORBIDDEN","message":"This token does not carry the core:memberships:write scope."}}}}},"404":{"description":"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.","x-error-code":"NOT_FOUND","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"NOT_FOUND","message":"No such tenant."}}}}},"409":{"description":"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.","x-error-code":"CONFLICT","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"CONFLICT","message":"This is the only active owner. Grant ownership to someone else first."}}}}},"429":{"description":"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.","x-error-code":"RATE_LIMITED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"RATE_LIMITED","message":"Too many requests. Slow down."}}}}},"500":{"description":"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.","x-error-code":"INTERNAL_ERROR","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"INTERNAL_ERROR","message":"Something went wrong. Please try again."}}}}}},"x-codeSamples":[{"lang":"bash","label":"curl","source":"curl -X DELETE https://core.sentent.ai/api/v1/tenants/$TENANT_ID/memberships/$MEMBERSHIP_ID \\\n  -H \"Authorization: Bearer $SERVICE_ACCESS_TOKEN\""}]}},"/api/v1/tenants/{tenantId}/session-revocations":{"post":{"operationId":"createSessionRevocation","summary":"Sign a member out of this tenant","tags":["Management API"],"description":"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.\n\nAuthenticate 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.\n\nModelled 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.\n\nA 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.\n\n`revoked: 0` is a success. It means they had no live sessions here.\n\nAlready-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.","security":[{"service_bearer":[]}],"parameters":[{"name":"tenantId","in":"path","required":true,"description":"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.","schema":{"type":"string","format":"uuid"},"example":"b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6"},{"name":"Idempotency-Key","in":"header","description":"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.","schema":{"type":"string"},"example":"8f0d1b7a-3c25-4d9e-9a10-6b2f4c8e1d33"}],"requestBody":{"required":true,"description":"`application/json`.","content":{"application/json":{"schema":{"type":"object","required":["user_id"],"properties":{"user_id":{"type":"string","format":"uuid","description":"The Core identity id — the `sub` claim of that person's tokens, or `user_id` from a membership."}}},"example":{"user_id":"3f1c2b9e-7a41-4d2e-9c05-2b8f6d1a4e77"}}}},"responses":{"200":{"description":"The revocation was applied.","headers":{"Idempotency-Replayed":{"description":"`true` when this body is a replay of an earlier identical request.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionRevocationResult"}}}},"401":{"description":"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.","x-error-code":"UNAUTHORIZED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"UNAUTHORIZED","message":"Present a valid Core service access token as a Bearer credential."}}}}},"403":{"description":"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.","x-error-code":"FORBIDDEN","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"FORBIDDEN","message":"This token does not carry the core:sessions:revoke scope."}}}}},"404":{"description":"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.","x-error-code":"NOT_FOUND","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"NOT_FOUND","message":"No such tenant."}}}}},"409":{"description":"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.","x-error-code":"CONFLICT","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"CONFLICT","message":"This is the only active owner. Grant ownership to someone else first."}}}}},"422":{"description":"The body did not validate. `field_errors` names the offending fields.","x-error-code":"VALIDATION_ERROR","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"VALIDATION_ERROR","message":"Please correct the highlighted fields.","field_errors":{"email":"That mail domain does not accept mail."}}}}}},"429":{"description":"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.","x-error-code":"RATE_LIMITED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"RATE_LIMITED","message":"Too many requests. Slow down."}}}}},"500":{"description":"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.","x-error-code":"INTERNAL_ERROR","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"INTERNAL_ERROR","message":"Something went wrong. Please try again."}}}}}},"x-codeSamples":[{"lang":"bash","label":"curl","source":"curl -X POST https://core.sentent.ai/api/v1/tenants/$TENANT_ID/session-revocations \\\n  -H \"Authorization: Bearer $SERVICE_ACCESS_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{\"user_id\":\"'\"$USER_ID\"'\"}'"}]}}},"components":{"schemas":{"TokenResponse":{"type":"object","title":"Token response","description":"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.\n\nThe `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).","required":["access_token","token_type","expires_in"],"properties":{"access_token":{"type":"string","description":"EdDSA-signed JWT. Verify offline against the JWKS; see the AccessTokenClaims schema."},"token_type":{"type":"string","const":"Bearer","description":"Always `Bearer`."},"expires_in":{"type":"integer","description":"Access-token lifetime in seconds. Currently 900 (15 minutes) — read it, do not hardcode it.","example":900},"refresh_token":{"type":"string","description":"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":{"type":"string","description":"The GRANTED scope, which may be narrower than requested (unsupported values are dropped). Omitted for a no-scope grant.","example":"openid profile email"},"id_token":{"type":"string","description":"OIDC ID token, present only when the `openid` scope was granted. See the IdTokenClaims schema."}},"example":{"access_token":"eyJhbGciOiJFZERTQSIsImtpZCI6ImYyM2FIM2xHIiwidHlwIjoiSldUIn0.eyJ0ZW5hbnQiOiJiNDFkN2MyNiJ9.Yx8s2Q","token_type":"Bearer","expires_in":900,"refresh_token":"kUu0aVQ8Xn2rP7dLm9ZfHcT4bWyEoS1gJvNqIx3RtA0","scope":"openid profile email","id_token":"eyJhbGciOiJFZERTQSIsImtpZCI6ImYyM2FIM2xHIiwidHlwIjoiSldUIn0.eyJlbWFpbCI6ImRhbmFAY2xpZW50LmV4YW1wbGUifQ.7Kd1Rw"}},"AccessTokenClaims":{"type":"object","title":"Access token claims","description":"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.","required":["iss","sub","aud","principal_type","tenant","app","role","status","sid","iat","exp"],"properties":{"iss":{"type":"string","description":"Issuer — the configured Core origin."},"sub":{"type":"string","description":"Core user id. Stable for the lifetime of the identity."},"aud":{"type":"string","description":"Audience — the `client_id` the token was issued to. Reject tokens minted for another app."},"principal_type":{"type":"string","const":"user","description":"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":{"type":"string","description":"Tenant (customer organization) id this grant is scoped to."},"app":{"type":"string","description":"The `client_id`, repeated as a private claim for convenience."},"role":{"type":"string","description":"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":{"type":"string","const":"active","description":"Membership status at issuance. Always `active` — a non-active membership never gets a token."},"sid":{"type":"string","description":"Session id. Pass it to your own logs; revocation is per-session."},"scope":{"type":"string","description":"Granted OIDC scope, RFC 9068 style. Omitted for a no-scope grant."},"amr":{"type":"array","items":{"type":"string"},"description":"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":{"type":"string","description":"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":{"type":"integer","description":"Issued-at, seconds since epoch."},"exp":{"type":"integer","description":"Expiry, seconds since epoch. 15 minutes after `iat`."}},"example":{"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}},"ServiceTokenClaims":{"type":"object","title":"Service token claims","description":"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.\n\nAuthorize 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.\n\n`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.","required":["iss","sub","principal_type","tenant","iat","exp"],"properties":{"iss":{"type":"string","description":"Issuer — the configured Core origin."},"sub":{"type":"string","description":"The service account's `client_id` (always `svc_`-prefixed). Never a user id."},"principal_type":{"type":"string","const":"service","description":"Always `service`. Reject this token anywhere your API expects a person."},"tenant":{"type":"string","description":"The tenant that owns the service account. The vendor tenant means a platform-internal service."},"scope":{"type":"string","description":"The granted subset of the account's scope allow-list. Omitted when the account holds no scopes."},"iat":{"type":"integer","description":"Issued-at, seconds since epoch."},"exp":{"type":"integer","description":"Expiry, seconds since epoch. 5 minutes after `iat` — much shorter than a user token, because there is no session to revoke."}},"example":{"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}},"IdTokenClaims":{"type":"object","title":"ID token claims","description":"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.","required":["iss","sub","aud","iat","exp","tenant","role","sid"],"properties":{"iss":{"type":"string","description":"Issuer — the configured Core origin."},"sub":{"type":"string","description":"Core user id. Matches the access token's `sub`."},"aud":{"type":"string","description":"The `client_id` this ID token was issued to."},"nonce":{"type":"string","description":"Echoed from the authorization request when supplied. Verify it, then discard."},"email":{"type":"string","format":"email","description":"Present when the `email` scope was granted."},"email_verified":{"type":"boolean","description":"Present with `email`. Always true — the upstream identity provider is Google and accounts are whitelist-gated besides."},"name":{"type":"string","description":"Present when the `profile` scope was granted AND a display name is known. Omitted, never null, when unknown."},"picture":{"type":"string","format":"uri","description":"Present when the `profile` scope was granted AND an avatar is known."},"tenant":{"type":"string","description":"Private claim — tenant id, so an off-the-shelf OIDC client gets tenancy without a userinfo call."},"role":{"type":"string","description":"Private claim — the user's role in that tenant."},"sid":{"type":"string","description":"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":{"type":"array","items":{"type":"string"},"description":"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":{"type":"string","description":"Identity provider used for this sign-in, matching the access token's `idp`: `google`, `azure`, or `email` (magic link). Absent when unknown."},"iat":{"type":"integer","description":"Issued-at, seconds since epoch."},"exp":{"type":"integer","description":"Expiry, seconds since epoch."}},"example":{"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}},"LogoutTokenClaims":{"type":"object","title":"Logout token claims","description":"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.\n\nValidate 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.\n\nUse `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.","required":["iss","aud","iat","exp","jti","sub","sid","events"],"properties":{"iss":{"type":"string","description":"Issuer — must equal the `iss` you verify ID tokens against."},"aud":{"type":"string","description":"Your `client_id`."},"sub":{"type":"string","description":"Core user id — the same `sub` as that user's ID token."},"sid":{"type":"string","description":"The session that was revoked. Matches the `sid` claim of the ID token Core issued for it."},"jti":{"type":"string","description":"Unique token id. Remember it for the token's short lifetime to make replay a no-op."},"events":{"type":"object","description":"Fixed: `{\"http://schemas.openid.net/event/backchannel-logout\": {}}`. Its presence is what makes this a logout token — reject anything without it."},"iat":{"type":"integer","description":"Issued-at, seconds since epoch."},"exp":{"type":"integer","description":"Expiry, seconds since epoch. Two minutes after `iat`."}},"example":{"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}},"WebhookEventEnvelope":{"type":"object","title":"Webhook event envelope","description":"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.\n\nDelivery 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.\n\n`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.","required":["id","type","occurred_at","tenant_id","data"],"properties":{"id":{"type":"string","format":"uuid","description":"Unique per event. Your dedupe key — the same value on every retry."},"type":{"type":"string","enum":["user.updated","user.deactivated","membership.created","membership.role_changed","membership.revoked","session.revoked"],"description":"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":{"type":"string","format":"date-time","description":"When the change happened in Core (RFC 3339, UTC). Not when it was delivered."},"tenant_id":{"type":"string","format":"uuid","description":"The tenant the event belongs to — the tenant that owns your application."},"data":{"type":"object","description":"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."}},"example":{"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"}}},"UserInfoResponse":{"type":"object","title":"UserInfo response","description":"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.","required":["sub","tenant","role"],"properties":{"sub":{"type":"string","description":"Core user id. Always equals the access token's `sub` — compare them, per OIDC Core 5.3.2."},"email":{"type":"string","format":"email","description":"Present when the `email` scope was granted."},"email_verified":{"type":"boolean","description":"Present with `email`. Always true."},"name":{"type":"string","description":"Present when `profile` was granted and a display name is known."},"picture":{"type":"string","format":"uri","description":"Present when `profile` was granted and an avatar is known."},"tenant":{"type":"string","description":"Tenant id from the access token."},"role":{"type":"string","description":"Current role in that tenant, re-read at request time."}},"example":{"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"}},"IntrospectionResponse":{"type":"object","title":"Introspection response","description":"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.\n\n`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`.","required":["active"],"properties":{"active":{"type":"boolean","description":"Whether the token is currently usable."},"principal_type":{"type":"string","enum":["user","service"],"description":"Which kind of principal the token represents. Active tokens only."},"sub":{"type":"string","description":"Core user id, or the service account's `client_id`. Active tokens only."},"tenant":{"type":"string","description":"Tenant id. Active tokens only."},"app":{"type":"string","description":"The `client_id` the token was issued to. `user` tokens only."},"role":{"type":"string","description":"CURRENT role, re-read from the membership rather than copied from the token. `user` tokens only."},"sid":{"type":"string","description":"Session id. `user` tokens only."},"exp":{"type":"integer","description":"Token expiry, seconds since epoch. Active tokens only."},"scope":{"type":"string","description":"Granted scope. Omitted for a no-scope grant (RFC 7662 §2.2 makes it optional)."},"iss":{"type":"string","format":"uri","description":"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."}},"example":{"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"}},"OAuthError":{"type":"object","title":"OAuth error","description":"RFC 6749 §5.2 error body. `error_description` is present only where it adds something the code does not already say.","required":["error"],"properties":{"error":{"type":"string","description":"Machine-readable error code."},"error_description":{"type":"string","description":"Human-readable detail. Optional — do not parse it."}},"example":{"error":"invalid_grant","error_description":"PKCE verification failed"}},"JsonWebKey":{"type":"object","title":"JSON Web Key","description":"An Ed25519 public key in JWK form.","required":["kty","crv","x","kid"],"properties":{"kty":{"type":"string","const":"OKP","description":"Key type — octet key pair."},"crv":{"type":"string","const":"Ed25519","description":"Curve."},"x":{"type":"string","description":"base64url public key material."},"kid":{"type":"string","description":"Key id. Match it against the JWT header's `kid`."},"alg":{"type":"string","const":"EdDSA","description":"Signing algorithm."},"use":{"type":"string","const":"sig","description":"Key use."}}},"Jwks":{"type":"object","title":"JWK Set","description":"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.","required":["keys"],"properties":{"keys":{"type":"array","items":{"$ref":"#/components/schemas/JsonWebKey"},"description":"Active and recently retired public keys."}},"example":{"keys":[{"kty":"OKP","crv":"Ed25519","x":"Nn0qLQ2sV7bR9tXcYpJ4wKmE1gHdU6zAoS3fIvB5rTk","kid":"f23aH3lG_gjkQO3UZVjHmWxqpL2GYYv-n2JQV7CV44I","alg":"EdDSA","use":"sig"}]}},"OpenIdConfiguration":{"type":"object","title":"OpenID Provider Metadata","description":"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.","required":["issuer","authorization_endpoint","token_endpoint","jwks_uri"],"properties":{"issuer":{"type":"string","description":"The `iss` value Core puts in every token."},"authorization_endpoint":{"type":"string"},"token_endpoint":{"type":"string"},"userinfo_endpoint":{"type":"string"},"introspection_endpoint":{"type":"string"},"revocation_endpoint":{"type":"string"},"end_session_endpoint":{"type":"string","description":"OIDC RP-Initiated Logout 1.0 — where you send a user who is signing out."},"backchannel_logout_supported":{"type":"boolean","description":"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":{"type":"boolean","description":"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":{"type":"string"},"response_types_supported":{"type":"array","items":{"type":"string"}},"response_modes_supported":{"type":"array","items":{"type":"string"}},"grant_types_supported":{"type":"array","items":{"type":"string"}},"scopes_supported":{"type":"array","items":{"type":"string"}},"claims_supported":{"type":"array","items":{"type":"string"}},"id_token_signing_alg_values_supported":{"type":"array","items":{"type":"string"}},"token_endpoint_auth_methods_supported":{"type":"array","items":{"type":"string"}},"code_challenge_methods_supported":{"type":"array","items":{"type":"string"}},"subject_types_supported":{"type":"array","items":{"type":"string"}}},"example":{"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"]}},"ApiError":{"type":"object","title":"Management API error","description":"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.\n\n`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.\n\nNote 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.","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["UNAUTHORIZED","FORBIDDEN","NOT_FOUND","CONFLICT","VALIDATION_ERROR","RATE_LIMITED","INTERNAL_ERROR"],"description":"Stable machine-readable code. See the error table."},"message":{"type":"string","description":"Human-readable, safe to log. Never carries internal detail."},"field_errors":{"type":"object","description":"Field name to message, when one specific input was rejected.","additionalProperties":{"type":"string"}}}}},"example":{"error":{"code":"FORBIDDEN","message":"Please correct the highlighted fields.","field_errors":{"role":"This principal cannot grant that role."}}}},"Membership":{"type":"object","title":"Membership","description":"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.\n\n`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\".","required":["id","user_id","tenant_id","role","status","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"Membership id. Addresses this row in the API."},"user_id":{"type":"string","format":"uuid","description":"Core identity id — the `sub` claim of that person's tokens."},"tenant_id":{"type":"string","format":"uuid","description":"The tenant this grant is in. Always the tenant in the URL."},"role":{"type":"string","description":"`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.","example":"member"},"status":{"type":"string","enum":["active","inactive"],"description":"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":{"type":"string","format":"date-time","description":"When the grant was made. Half of the pagination key."},"invited_at":{"type":"string","format":"date-time","description":"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).\n\nWhen 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\".\n\nAn 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.\n\nThe 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":{"type":"string","format":"email","description":"Requires the `core:users:read` scope. Omitted otherwise."}},"example":{"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"}},"MembershipPage":{"type":"object","title":"Membership page","description":"One keyset page, oldest grant first. `next_cursor` is null on the last page — do not issue an extra request to discover that.\n\nPagination 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.","required":["data","next_cursor"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Membership"},"description":"The page."},"next_cursor":{"type":"string","description":"Pass as `?cursor=` for the next page. Null when this is the last one."}},"example":{"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":{"type":"object","title":"Tenant","description":"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.\n\nIt 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.","required":["id","name","kind","status","created_at","settings"],"properties":{"id":{"type":"string","format":"uuid","description":"Tenant id. Matches the `tenant` claim in your service token."},"name":{"type":"string","description":"Display name, as the operator registered it.","example":"Acme Corp"},"kind":{"type":"string","enum":["client"],"description":"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":{"type":"string","enum":["active","suspended"],"description":"Lifecycle state of the organization."},"created_at":{"type":"string","format":"date-time","description":"When the tenant was registered."},"settings":{"type":"object","description":"Per-tenant policy. Read-only on this surface.","required":["require_mfa","allowed_email_domains"],"properties":{"require_mfa":{"type":"boolean","description":"When true, members of this tenant must hold a second-factor session before Core will issue them tokens."},"allowed_email_domains":{"type":"array","items":{"type":"string"},"description":"Exact-match mailbox domains this tenant admits. Null means unrestricted. Subdomains are NOT implied."}}}},"example":{"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"]}}},"SessionRevocationResult":{"type":"object","title":"Session revocation result","description":"How many live sessions the revocation covered. Zero is a success, not a failure — the person simply had none in this tenant.\n\nEvery 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.","required":["user_id","tenant_id","revoked"],"properties":{"user_id":{"type":"string","format":"uuid","description":"The identity whose sessions were revoked."},"tenant_id":{"type":"string","format":"uuid","description":"Scope of the revocation — other tenants are untouched."},"revoked":{"type":"integer","description":"Count of sessions moved to `revoked` by this call.","example":3}},"example":{"user_id":"3f1c2b9e-7a41-4d2e-9c05-2b8f6d1a4e77","tenant_id":"b41d7c26-9f3a-4e18-8d54-1c0a7b93e2f6","revoked":3}}},"securitySchemes":{"client_secret_basic":{"type":"http","scheme":"basic","description":"HTTP Basic with `client_id` as the username and `client_secret` as the password. Preferred, and the default for openid-client and Auth.js. Per RFC 6749 §2.3.1 both halves are form-urlencoded before base64 encoding — libraries handle this, hand-rolled clients often do not."},"bearer":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"A Core-issued access token in the `Authorization` header. Used by the UserInfo endpoint."},"service_bearer":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"A Core-issued SERVICE access token (`principal_type: \"service\"`) in the `Authorization` header, from `grant_type=client_credentials`. Distinct from `bearer` on purpose: the management API rejects human access tokens outright, so a scheme that accepted either would misdescribe it. Tokens live ~5 minutes — request a fresh one rather than caching across calls."}}}}