API reference
Every endpoint with example requests and responses, scopes, claims, and error codes.
Every example on this page comes from real server responses, with tokens cut short. Replace YOUR_CLIENT_ID and kelas.society-ai.org with your own.
Endpoints
| Purpose | Method | Path |
|---|---|---|
| Discovery | GET | /.well-known/openid-configuration |
| Start sign-in | GET (browser) | /oauth2/auth |
| Swap code, renew tokens | POST | /oauth2/token |
| Member data | GET | /userinfo |
| Signing keys | GET | /.well-known/jwks.json |
| Revoke a token | POST | /oauth2/revoke |
| Sign out | GET (browser) | /oauth2/sessions/logout |
| Back-channel logout | POST (to your server) | the URL you registered |
Every path starts with https://accounts.society-ai.org. Read them from discovery if your library supports it, so you don't have to write them out.
curl https://accounts.society-ai.org/.well-known/openid-configuration
scopes_supported in discovery only lists the built-in scopes. profile, email, and membership still work once they are approved for your app.
Swap the code for tokens: POST /oauth2/token
Do this from your server, within 10 minutes of the callback. Each code works once.
curl -X POST https://accounts.society-ai.org/oauth2/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code="$CODE" \
-d redirect_uri=https://kelas.society-ai.org/auth/callback \
-d code_verifier="$VERIFIER"
Apps without a server send client_id in the body, without a secret:
curl -X POST https://accounts.society-ai.org/oauth2/token \
-d client_id="$CLIENT_ID" \
-d grant_type=authorization_code \
-d code="$CODE" \
-d redirect_uri=https://kelas.society-ai.org/callback \
-d code_verifier="$VERIFIER"
The response:
{
"access_token": "ory_at_...",
"expires_in": 899,
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ii4uLiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "ory_rt_...",
"scope": "openid profile email membership offline_access",
"token_type": "bearer"
}
| Field | Notes |
|---|---|
access_token | For calling /userinfo. Not a JWT, so don't try to read it. Valid for 15 minutes. |
id_token | A JWT with the member's data. Check its signature before using it. Valid for 15 minutes. |
refresh_token | Only present when offline_access was requested and approved. Valid for 30 days. |
expires_in | Seconds left on the access token |
What's in the ID token
{
"iss": "https://accounts.society-ai.org",
"sub": "01a15d78-725f-73c5-98b5-63fd015a97d2",
"aud": ["YOUR_CLIENT_ID"],
"exp": 1792640870,
"iat": 1792639970,
"auth_time": 1792639970,
"sid": "ca7ee7c7-fb98-4ccc-a4af-8d32f23d1d61",
"name": "Aruna Noviyanti",
"locale": "id",
"email": "aruna@society-ai.org",
"email_verified": true,
"membership": {
"status": "active",
"number": "8316202601000015",
"expires_at": "2027-10-21T14:58:57+07:00"
},
"role": "member"
}
| Claim | Scope | Notes |
|---|---|---|
sub | openid | The member ID. Never changes. Use it to recognise the member. |
auth_time | openid | When the member last entered a code. After prompt=login or max_age, check that it is recent. |
sid | openid | The browser session ID. Used for back-channel logout. |
iss, aud, exp, iat | openid | Always check: iss equals the issuer, aud contains your Client ID, exp has not passed. |
name | profile | The member's name |
locale | profile | Preferred language: id or en |
email, email_verified | email | Email and whether it is verified. Emails can change. |
membership.status | membership | none, active, expired, or cancelled |
membership.number | membership | 16-digit member number, or null |
membership.expires_at | membership | End date (ISO 8601), or null |
role | membership | member, staff, moderator, owner, or admin. This is the role in Society AI. Permissions inside your service are still yours to decide. |
membership and role are read fresh every time a new token is made, renewals included.
Renew tokens
curl -X POST https://accounts.society-ai.org/oauth2/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=refresh_token \
-d refresh_token="$REFRESH_TOKEN"
The response looks like the code swap, including a new refresh token. The old refresh token stops working right away, so always keep the latest. Using the old one returns invalid_grant with "The refresh token was already used".
If the member's account was suspended, or the member was removed from a testing app's tester list, renewal is refused with 403 access_denied. Treat the member as signed out.
Member data: GET /userinfo
curl https://accounts.society-ai.org/userinfo \
-H "Authorization: Bearer $ACCESS_TOKEN"
{
"sub": "01a15d78-725f-73c5-98b5-63fd015a97d2",
"name": "Aruna Noviyanti",
"locale": "id",
"email": "aruna@society-ai.org",
"email_verified": true,
"membership": {
"status": "active",
"number": "8316202601000015",
"expires_at": "2027-10-21T14:58:57+07:00"
},
"role": "member",
"iss": "https://accounts.society-ai.org",
"aud": ["YOUR_CLIENT_ID"],
"auth_time": 1792639970,
"iat": 1792639970
}
It holds the same claims as the ID token for the granted scopes. Use it if you'd rather not check JWTs yourself, or to check an access token sent to your API.
Signing keys: GET /.well-known/jwks.json
Tokens are signed with RS256. Cache the JWKS, for example for an hour. If a token has a kid you don't know, fetch the JWKS once more before rejecting it, because keys can be replaced.
Revoke a token: POST /oauth2/revoke
curl -X POST https://accounts.society-ai.org/oauth2/revoke \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d token="$REFRESH_TOKEN"
Replies 200 with an empty body. Use it when a member disconnects your service from their account, or when you drop a refresh token you no longer need.
Sign out: GET /oauth2/sessions/logout
Clear your own session first, then send the browser to:
https://accounts.society-ai.org/oauth2/sessions/logout?id_token_hint=LATEST_ID_TOKEN
Use the latest ID token you received. The member is signed out of society-ai.org and every other service, then sees "You are signed out". For now members are not sent back to your app, so don't send post_logout_redirect_uri.
Back-channel logout (called on your server)
When a member signs out on society-ai.org or in another service, Society AI sends a POST to your back-channel URL with a logout_token form field. It holds a JWT like this:
{
"iss": "https://accounts.society-ai.org",
"aud": ["YOUR_CLIENT_ID"],
"iat": 1792640105,
"jti": "9f71b9cd-1d82-4454-8601-b452f6d27d56",
"sid": "96e2228d-3558-47a6-a1b6-d6afdabaf0f0",
"events": {
"http://schemas.openid.net/event/backchannel-logout": {}
}
}
Your server must:
- Check its signature with the JWKS.
- Make sure
issequals the issuer andaudcontains your Client ID. - Make sure
eventscontainshttp://schemas.openid.net/event/backchannel-logoutand there is nononce. - Delete the session that matches
sid. The token has nosub, so store thesidand session pair when the member signs in. - Reply
200. The route has no CSRF, no login, and must not redirect.
Error codes
| Status | error | When | What to do |
|---|---|---|---|
| 400 | invalid_grant | Code already used or expired, wrong code_verifier, or an old refresh token | Start sign-in again |
| 401 | invalid_client | Wrong Client ID or secret | Check .env. The secret may have been replaced. |
| 401 | request_unauthorized | Wrong or expired access token on /userinfo | Renew the token, or ask the member to sign in again |
| 403 | access_denied | Account suspended, or not a tester of a testing app | Sign the member out of your service |
| callback | invalid_scope | A scope that isn't approved for your app | Match the scopes to what the admin approved |
| callback | login_required | prompt=none and the member isn't signed in | Show the page as a guest |
Error responses from /oauth2/token look like this:
{
"error": "invalid_grant",
"error_description": "The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, ..."
}