> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cabalspy.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# OpenID Connect

> CabalSpy ID is a standard OpenID Connect provider: authorization code flow with mandatory PKCE, ES256 ID tokens, rotating refresh tokens.

CabalSpy ID is a standard OpenID Connect provider. Any certified OpenID Connect or OAuth 2.0 library works. The only flow is the authorization code flow, and PKCE with S256 is required for every client, confidential clients included.

## Endpoints

All endpoints are listed in the discovery document.

```bash theme={null}
curl "https://id.cabalspy.xyz/.well-known/openid-configuration"
```

| Endpoint | URL |
| - | - |
| Issuer | `https://id.cabalspy.xyz` |
| Authorization | `https://id.cabalspy.xyz/oauth/authorize` |
| Token | `https://id.cabalspy.xyz/oauth/token` |
| User info | `https://id.cabalspy.xyz/oauth/userinfo` |
| JWKS | `https://id.cabalspy.xyz/oauth/jwks` |
| Revocation | `https://id.cabalspy.xyz/oauth/revoke` |

| Capability | Supported values |
| - | - |
| Response types | `code` (response mode `query`) |
| Grant types | `authorization_code`, `refresh_token` |
| PKCE | `S256`, required |
| ID token signing | `ES256` |
| Client authentication | `client_secret_basic`, `client_secret_post`, `none` (public browser apps) |
| Prompt | `none`, `consent` |
| Authorization response `iss` parameter | Yes |

## Clients

Create a client in the [API Dashboard](https://apidashboard.cabalspy.xyz) on the **CabalSpy ID** tab. The Instance ID is the `client_id` (`csid_...`).

Confidential clients (server-side apps) authenticate with a client secret (`csid_sk_...`). Only a hash of the secret is stored, so copy it when you create it. Several secrets can be active at once: when you roll a secret the old one stays valid for a time you choose, so you can deploy the new one without downtime.

Public clients (browser apps, mobile apps) have no secret and use `none` as authentication method. PKCE protects their flow.

Redirect URIs must be registered and match exactly. Live instances need every redirect URI and web origin on a verified domain.

## Scopes

| Scope | What the user approves |
| - | - |
| `openid` | Sign in with CabalSpy ID. Required. |
| `profile` | Profile: name, handle, avatar, KOL badge and, when the user has X, their X account with follower counts. |
| `wallets` | Verified wallet addresses and the chains they are verified on. Needs a review for live instances. |
| `onchain` | The latest 7-day trading volume. Needs a review for live instances. |
| `offline_access` | A refresh token, so the app keeps access while the user is away. |
| `thesis` | Post theses and replies, react and report in your name when you use the app. See [Token theses](/cabalspy-id/theses). |

An instance can only request the scopes enabled for it in the dashboard. The user approves the scopes once per app. Asking for more scopes later shows the consent screen again.

## Authorization request

Create a random `code_verifier` (43 to 128 characters), its S256 `code_challenge`, a random `state` and a random `nonce`, then redirect the user.

```text theme={null}
https://id.cabalspy.xyz/oauth/authorize
  ?response_type=code
  &client_id=csid_...
  &redirect_uri=https://app.example.com/auth/csid/callback
  &scope=openid profile wallets offline_access
  &state=STATE
  &nonce=NONCE
  &code_challenge=CODE_CHALLENGE
  &code_challenge_method=S256
```

<ParamField query="response_type" type="string" required>
  Always `code`.
</ParamField>

<ParamField query="client_id" type="string" required>
  Your Instance ID.
</ParamField>

<ParamField query="redirect_uri" type="string" required>
  A registered redirect URI, matched exactly.
</ParamField>

<ParamField query="scope" type="string" required>
  Space-separated scopes. Must contain `openid`.
</ParamField>

<ParamField query="code_challenge" type="string" required>
  Base64url SHA-256 of the code verifier (43 characters).
</ParamField>

<ParamField query="code_challenge_method" type="string" required>
  Always `S256`.
</ParamField>

<ParamField query="state" type="string">
  Random value you check on the callback. Up to 500 characters.
</ParamField>

<ParamField query="nonce" type="string">
  Random value that comes back in the ID token. Up to 300 characters.
</ParamField>

<ParamField query="prompt" type="string">
  `none` signs in silently or returns `login_required` or `consent_required`. `consent` always shows the consent screen. `none` cannot be combined with other values.
</ParamField>

The user signs in to CabalSpy ID if needed, with X, Telegram or Google, and approves the scopes. CabalSpy ID then redirects back with `code`, `state` and `iss`. Check that `state` matches and that `iss` is `https://id.cabalspy.xyz`. The code is single use and valid for 60 seconds.

```text theme={null}
https://app.example.com/auth/csid/callback?code=CODE&state=STATE&iss=https%3A%2F%2Fid.cabalspy.xyz
```

A request with an unknown client or an unregistered redirect URI is never redirected. CabalSpy ID shows an error page instead.

## Token request

Exchange the code at the token endpoint with the code verifier.

<CodeGroup>
  ```bash Confidential client theme={null}
  curl -X POST "https://id.cabalspy.xyz/oauth/token" \
    -u "csid_...:csid_sk_..." \
    -d grant_type=authorization_code \
    -d code=CODE \
    -d redirect_uri=https://app.example.com/auth/csid/callback \
    -d code_verifier=CODE_VERIFIER
  ```

  ```bash Public client theme={null}
  curl -X POST "https://id.cabalspy.xyz/oauth/token" \
    -d grant_type=authorization_code \
    -d client_id=csid_... \
    -d code=CODE \
    -d redirect_uri=https://app.example.com/ \
    -d code_verifier=CODE_VERIFIER
  ```
</CodeGroup>

```json Response theme={null}
{
  "access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6...",
  "token_type": "Bearer",
  "expires_in": 900,
  "id_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6...",
  "scope": "openid profile wallets offline_access",
  "refresh_token": "csid_rt_..."
}
```

`refresh_token` is only returned when `offline_access` was granted.

## Tokens

| Token | Format | Lifetime |
| - | - | - |
| Authorization code | Opaque, single use | 60 seconds |
| Access token | ES256 JWT, header `typ: at+jwt`, `aud` = your client\_id | 15 minutes |
| ID token | ES256 JWT, `aud` = your client\_id | 15 minutes |
| Refresh token | Opaque, `csid_rt_...` | 30 days of inactivity, 90 days at most |

Verify ID tokens with the keys at `https://id.cabalspy.xyz/oauth/jwks`. Check the signature (ES256, select the key by `kid`), `iss` = `https://id.cabalspy.xyz`, `aud` = your client\_id, `exp`, and `nonce` if you sent one. New signing keys are published in the JWKS before they are used, so cache the JWKS but refresh it when you see an unknown `kid`.

```json JWKS theme={null}
{
  "keys": [
    {
      "kty": "EC",
      "crv": "P-256",
      "use": "sig",
      "alg": "ES256",
      "kid": "20261009-50277aee",
      "x": "CeEkzMniNB_BEK9V4xBLFLxJylTAG-w6B3fQ7BXYXw8",
      "y": "nEE3Bwd4rNXiu3i-1zZjtL9OJPjZPxv_L9zFqhW7P9U"
    }
  ]
}
```

## Refresh tokens

With `offline_access` you get a refresh token. Exchange it for new tokens at the token endpoint.

```bash theme={null}
curl -X POST "https://id.cabalspy.xyz/oauth/token" \
  -u "csid_...:csid_sk_..." \
  -d grant_type=refresh_token \
  -d refresh_token=csid_rt_...
```

Refresh tokens rotate. Every refresh returns a new refresh token and the old one stops working. If a used refresh token is presented again, the whole token family is revoked and the user has to sign in again, so always store the newest one. A refresh token expires after 30 days without use, and every family ends 90 days after the sign-in.

A refresh fails with `invalid_grant` when the user removed access to your app.

## Revocation

Revoke a refresh token when the user signs out of your app. This ends its whole family. Unknown tokens are not an error.

```bash theme={null}
curl -X POST "https://id.cabalspy.xyz/oauth/revoke" \
  -u "csid_...:csid_sk_..." \
  -d token=csid_rt_...
```

Access tokens are short-lived JWTs and expire on their own.

## User info

```bash theme={null}
curl "https://id.cabalspy.xyz/oauth/userinfo" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

Returns the claims for the granted scopes. The ID token itself carries `sub` and, with `profile`, `name`, `preferred_username`, `picture` and `kol_badge`. The full `x`, `wallets` and `onchain` claims come from the user info endpoint.

## Claims

<ResponseField name="sub" type="string">
  Stable user id. Use it as the user's key in your database. It is the `csid_id` in the Data API.
</ResponseField>

<ResponseField name="name" type="string">
  Display name: from X, otherwise from Telegram or Google. Scope `profile`.
</ResponseField>

<ResponseField name="preferred_username" type="string">
  The X handle without the @. Users without X get `tg.<telegram username>` or `id.<10 hex characters>`, see [Users without X](#users-without-x). Scope `profile`.
</ResponseField>

<ResponseField name="picture" type="string">
  Avatar URL from X or Google, can be null. Scope `profile`.
</ResponseField>

<ResponseField name="profile" type="string">
  Public CabalSpy ID profile URL. Scope `profile`.
</ResponseField>

<ResponseField name="kol_badge" type="boolean">
  Whether the user holds the reviewed KOL badge. Always false without X. Scope `profile`.
</ResponseField>

<ResponseField name="x" type="object">
  X account details, or null when the user has no X account connected. Scope `profile`.

  <Expandable title="x">
    <ResponseField name="id" type="string">
      Numeric X user id.
    </ResponseField>

    <ResponseField name="username" type="string">
      X handle.
    </ResponseField>

    <ResponseField name="followers" type="integer">
      Followers.
    </ResponseField>

    <ResponseField name="following" type="integer">
      Accounts followed.
    </ResponseField>

    <ResponseField name="posts" type="integer">
      Number of posts.
    </ResponseField>

    <ResponseField name="listed" type="integer">
      Number of lists the account is on.
    </ResponseField>

    <ResponseField name="verified" type="boolean">
      X verification.
    </ResponseField>

    <ResponseField name="verified_type" type="string">
      X verification type.
    </ResponseField>

    <ResponseField name="created_at" type="string">
      When the X account was created (ISO 8601).
    </ResponseField>

    <ResponseField name="fetched_at" type="string">
      When the profile was last read from X (ISO 8601).
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="wallets" type="array">
  Verified wallets. Scope `wallets`.

  <Expandable title="wallet">
    <ResponseField name="address" type="string">
      Wallet address.
    </ResponseField>

    <ResponseField name="family" type="string">
      Address family, for example `solana` or `evm`.
    </ResponseField>

    <ResponseField name="chains" type="array">
      Chains the wallet is verified on, for example `["base", "eth"]`.
    </ResponseField>

    <ResponseField name="verified_at" type="string">
      When the wallet was first verified (ISO 8601).
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="onchain" type="object">
  Latest on-chain volume check, or null. Scope `onchain`.

  <Expandable title="onchain">
    <ResponseField name="volume_7d_usd" type="number">
      Trading volume over 7 days in USD.
    </ResponseField>

    <ResponseField name="checked_at" type="string">
      When the volume was checked (ISO 8601).
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="sandbox" type="boolean">
  Present and true for made-up sandbox test users. Never trust such an identity in production.
</ResponseField>

<ResponseExample>
  ```json User info theme={null}
  {
    "sub": "0b7c4a52-3f1e-4f43-9d0a-2b5e7c1d9a11",
    "name": "Maze",
    "preferred_username": "MazeCCC",
    "picture": "https://pbs.twimg.com/profile_images/.../photo.jpg",
    "profile": "https://id.cabalspy.xyz/u/MazeCCC",
    "kol_badge": true,
    "x": {
      "id": "1440000000000000000",
      "username": "MazeCCC",
      "followers": 48210,
      "following": 812,
      "posts": 15320,
      "listed": 210,
      "verified": true,
      "verified_type": "blue",
      "created_at": "2021-08-11T09:12:44+00:00",
      "fetched_at": "2026-10-12T18:03:10.512000+00:00"
    },
    "wallets": [
      {
        "address": "7j7AA3HZR2zEjwAQEKPFh2qucLY4fqZpB9iodf39w8xW",
        "family": "solana",
        "chains": ["solana"],
        "verified_at": "2026-10-09T14:21:07.000000+00:00"
      }
    ],
    "onchain": {
      "volume_7d_usd": 182450.5,
      "checked_at": "2026-10-12T06:00:00+00:00"
    }
  }
  ```
</ResponseExample>

## Users without X

People sign in to CabalSpy ID with X, with Telegram through the CabalSpy ID bot, or with Google as an alternative. Nothing changes in the flow for your app, but users who signed in with Telegram or Google and have not connected X look different:

| Claim | With X | Without X |
| - | - | - |
| `x` | X account details | `null` |
| `preferred_username` | X handle, for example `MazeCCC` | `tg.<telegram username>`, or `id.<10 hex characters>` |
| `name` | X display name | Telegram name, else Google name, else `CabalSpy ID user` |
| `picture` | X avatar | Google picture, else null |
| `kol_badge` | true or false | false, the KOL badge requires X |

Handles of users without X always contain a dot, X handles never do. When a user connects X later, `preferred_username`, `name`, `picture` and `x` switch to their X account while `sub` stays the same, so always key your users on `sub`, never on the handle.

```json User info without X theme={null}
{
  "sub": "5e2d8c41-7a9b-4c3e-b1f0-6d5e4c3b2a19",
  "name": "Dan",
  "preferred_username": "tg.degen_dan",
  "picture": null,
  "profile": "https://id.cabalspy.xyz/u/tg.degen_dan",
  "kol_badge": false,
  "x": null
}
```

## Limited users

Your plan sets how many users you receive data for. Everyone can always sign in, but above the limit the person gets an anonymous `sub` that starts with `anon_` and no profile, wallet or on-chain data. The ID token and the user info then look like this:

```json theme={null}
{
  "sub": "anon_k3x9w2...",
  "limited": true,
  "reason": "plan_user_limit",
  "message": "This platform has reached the user limit of its CabalSpy ID plan. It receives no data for this user until it upgrades."
}
```

The anonymous `sub` is stable for that person and your domain, so you can still keep an account for them. After you upgrade, people who sign in again receive their real `sub` and data. See [Plans and limits](/cabalspy-id/plans).

## OAuth errors

The token, user info and revocation endpoints return errors in the standard OAuth format.

```json theme={null}
{ "error": "invalid_grant", "error_description": "The code is invalid, expired or already used." }
```

| Error | Where | Meaning |
| - | - | - |
| invalid\_request | authorize | Missing or malformed parameter, for example no PKCE or an unregistered redirect URI. |
| invalid\_client | authorize, token | Unknown or disabled client, or client authentication failed (401). |
| invalid\_scope | authorize | `openid` missing, unknown scope, or a scope not enabled for the instance. |
| unsupported\_response\_type | authorize | Only `response_type=code` is supported. |
| login\_required | authorize | `prompt=none` and the user is not signed in. |
| consent\_required | authorize | `prompt=none` and the user has not approved the app yet. |
| access\_denied | authorize | The user declined, the user is blocked by your instance, or the instance is in development and the user is not a tester. |
| invalid\_grant | token | Code invalid, expired, used or issued to another client or redirect URI, PKCE failed, refresh token invalid, or the user revoked access. |
| unsupported\_grant\_type | token | Use `authorization_code` or `refresh_token`. |
| invalid\_token | userinfo | Access token missing, invalid, expired or access revoked (401). |
| slow\_down | token, userinfo | Too many requests from your IP (429). |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.