{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "schemaVersion": "2026-10-28",
  "title": "CP Elora public API — context file for AI coding assistants",
  "about": "Give this file to an AI client (Claude, Cursor, Copilot, ChatGPT, a local agent) before asking it to build anything against CP Elora. It describes the real API: every route, scope, field and limit below was taken from the running server, not written from memory.",
  "howToUse": [
    "Treat this file as the source of truth for the API surface. If something is not listed under endpoints, it does not exist.",
    "Read endpoints and limitations before proposing a design. The API is read-only; there is no route to post chat messages, create clips or change a channel.",
    "Never invent a base URL. Resolve it at runtime using baseUrlResolution below, because the host changes when the PC runtime restarts.",
    "Send the access token as an Authorization: Bearer header. Tokens expire after 3600 seconds and are not refreshed automatically; request a new one.",
    "Every read route needs its scope. A token missing a scope gets 403 with the scope name in the error message.",
    "Ask the developer for their client id and client secret. Do not put a client secret in front-end code, a public repository or a mobile app bundle."
  ],

  "platform": {
    "name": "CP Elora",
    "siteUrl": "https://cpelora.com",
    "whatItIs": "A live streaming site for independent creators: channels, live video, chat, channel points, Dust tips, Boosts (subscriptions), Drops campaigns and creator earnings.",
    "developerPage": "https://cpelora.com/creator/developer",
    "terms": {
      "channel": "A creator's page, addressed by the creator's username in the URL: https://cpelora.com/{username}",
      "supporter": "A viewer who follows the channel. In this API 'supporters' are followers of that creator.",
      "dust": "The tipping currency. A cheer spends Dust on a channel.",
      "boost": "A paid subscription to a channel, measured in days, which can be gifted.",
      "drop": "A campaign that pays a reward after a required number of watch minutes.",
      "points": "Channel points, a free currency earned by watching, redeemed for creator rewards."
    }
  },

  "credentials": {
    "where": "Creator Studio → Developer API (https://cpelora.com/creator/developer). Each app gets a client id and a client secret; the secret is shown once, when the app is created or rotated.",
    "availability": "Closed beta: an account must be granted developer access before it can create apps. The page reports allowed:false until then.",
    "scopesChosenBy": "The app's owner, when creating the app. An app can only use the scopes it was given."
  },

  "baseUrlResolution": {
    "why": "The JSON API runs on the developer's own machine behind a Cloudflare Quick Tunnel. The hostname changes every time that runtime restarts, so it must be resolved, never hard-coded.",
    "how": "GET the CP Elora homepage and read the inline value window.ARCSTREAM_API_BASE from the HTML. It looks like https://governmental-judges-andy-delays.trycloudflare.com with no trailing slash. In the HTML the slashes are JavaScript-escaped (https:\\/\\/...), so unescape them before matching.",
    "inBrowser": "In a page served from cpelora.com the value is already available as the string window.ARCSTREAM_API_BASE.",
    "certificateNote": "The site currently serves an expired certificate on https, so Node, Python and curl reject https://cpelora.com with CERT_HAS_EXPIRED. Fall back to http, which serves the same HTML. Once the certificate is renewed the https attempt succeeds and the fallback is never used.",
    "cache": "Resolve once per process or session and reuse it. Re-resolve on a network failure rather than assuming a permanent address.",
    "example": "async function resolveApiBase() {\n  for (const url of ['https://cpelora.com/', 'http://cpelora.com/']) {\n    try {\n      const html = (await (await fetch(url)).text()).replace(/\\\\\\//g, '/');\n      const base = (html.match(/ARCSTREAM_API_BASE\\s*=\\s*\"(https:[^\"]+)\"/) || [])[1];\n      if (base) return base;\n    } catch (_) { /* try the next scheme */ }\n  }\n  throw new Error('CP Elora API address not found');\n}"
  },

  "authentication": {
    "type": "OAuth 2.0 client credentials (app-level). There is no user-facing OAuth flow for third parties.",
    "tokenEndpoint": "POST {API_BASE}/api/v1/oauth/token",
    "requestContentType": "application/json",
    "requestBody": {
      "client_id": "string, the app client id",
      "client_secret": "string, the app client secret"
    },
    "camelCaseAliases": "clientId and clientSecret are accepted as aliases for the snake_case keys.",
    "responseExample": {
      "access_token": "64-character hex string",
      "token_type": "bearer",
      "expires_in": 3600,
      "scope": "channel:read chat:read"
    },
    "useToken": "Authorization: Bearer {access_token} on every read request.",
    "tokenLifetimeSeconds": 3600,
    "failure": "401 with {\"error\":\"invalid_client\"} when the id or secret is wrong, revoked or rotated.",
    "rules": [
      "A rotated secret immediately invalidates tokens issued with the old one.",
      "A revoked or deleted app's tokens stop working at once.",
      "The secret is never returned by any route after creation or rotation."
    ]
  },

  "scopes": [
    { "name": "channel:read", "allows": "Channel list and single channel details", "routes": ["GET /api/v1/channels", "GET /api/v1/channels/{username}"] },
    { "name": "supporters:read", "allows": "Who follows a channel", "routes": ["GET /api/v1/channels/{username}/supporters"] },
    { "name": "chat:read", "allows": "The last 50 public chat messages of a channel", "routes": ["GET /api/v1/channels/{username}/chat"] },
    { "name": "user:read", "allows": "Public profile fields of one account", "routes": ["GET /api/v1/users/{username}"] },
    { "name": "drops:read", "allows": "Drops campaigns and their rewards", "routes": ["GET /api/v1/drops"] },
    { "name": "economy:read", "allows": "Dust packs, Boost plans and creator revenue shares", "routes": ["GET /api/v1/economy"] },
    { "name": "creator:read", "allows": "Reserved for creator dashboard integrations; no public route uses it yet", "routes": [] }
  ],

  "rateLimits": {
    "tokenEndpoint": "40 requests per minute per client IP",
    "readEndpoints": "240 requests per minute per app",
    "exceeded": "429 with {\"error\":\"Rate limit reached. Slow down.\"} on reads, {\"error\":\"Too many token requests. Slow down.\"} on the token endpoint",
    "advice": "Reuse one token for its full hour instead of requesting a new one per call. Cache reads that do not need to be live."
  },

  "conventions": {
    "readOnly": "Every /api/v1 route except the token endpoint is GET-only; anything else returns 405 with {\"error\":\"This API endpoint is read-only.\"}",
    "listEnvelope": "Collections return {data: [...], total: n} and, where a limit applies, also limit.",
    "itemEnvelope": "Single objects return {data: {...}}.",
    "errors": "Failures are always {\"error\": \"human readable message\"} with a 4xx/5xx status.",
    "usernames": "Case-insensitive on lookup. 1 to 24 characters, letters, numbers and underscore.",
    "times": "ISO 8601 strings in UTC, e.g. 2026-10-28T21:04:57.000Z.",
    "money": "Integer minor units (cents) plus a currency code. dustValueMilliMinor is thousandths of a minor unit per Dust."
  },

  "errors": {
    "400": "The request body was not valid JSON.",
    "401": "Missing, expired or invalid token: {\"error\":\"Send a valid app access token as a Bearer token.\"}",
    "403": "Token is valid but lacks the scope: {\"error\":\"This app is missing the chat:read scope.\"}",
    "404": "Unknown route, channel or user; unknown routes also return 404 rather than a generic page.",
    "405": "A non-GET method on a read endpoint.",
    "429": "Rate limit reached.",
    "500": "Server error; the response deliberately does not include a stack trace."
  },

  "endpoints": [
    {
      "id": "token",
      "method": "POST",
      "path": "/api/v1/oauth/token",
      "scope": null,
      "auth": "client id and secret in the JSON body",
      "requestBody": { "client_id": "string", "client_secret": "string" },
      "response": { "access_token": "string", "token_type": "bearer", "expires_in": 3600, "scope": "space separated scopes" },
      "notes": "The only non-GET route. Rate limited per IP, not per app."
    },
    {
      "id": "me",
      "method": "GET",
      "path": "/api/v1/me",
      "scope": null,
      "response": {
        "app": "the app object: id, clientId, name, scopes, status, createdAt, lastUsedAt",
        "owner": { "id": "string", "username": "string" },
        "scopes": ["channel:read"],
        "expiresAt": "ISO timestamp of the current token"
      },
      "notes": "Use this to confirm a token works and to see which scopes it really has before calling anything else."
    },
    {
      "id": "channels",
      "method": "GET",
      "path": "/api/v1/channels",
      "scope": "channel:read",
      "query": {
        "live": "'1' returns only channels that are live; omitted returns every channel",
        "limit": "1 to 100, default 20"
      },
      "response": { "data": ["channel objects"], "total": "number of matching channels", "limit": "the limit applied" },
      "ordering": "Live channels first, then by supporter count descending.",
      "notes": "This is the directory. Use it for a home page, an overlay ticker or a bot that follows one channel."
    },
    {
      "id": "channel",
      "method": "GET",
      "path": "/api/v1/channels/{username}",
      "scope": "channel:read",
      "response": { "data": "channel object" },
      "notes": "404 when the account exists but has no channel, or the username is unknown."
    },
    {
      "id": "channel-supporters",
      "method": "GET",
      "path": "/api/v1/channels/{username}/supporters",
      "scope": "supporters:read",
      "query": { "limit": "1 to 100, default 50" },
      "response": { "data": [{ "id": "string", "username": "string", "partner": false, "affiliate": false }], "total": "number of supporters" },
      "ordering": "Most recent follower first.",
      "notes": "Supporters here are followers of that creator."
    },
    {
      "id": "channel-chat",
      "method": "GET",
      "path": "/api/v1/channels/{username}/chat",
      "scope": "chat:read",
      "response": {
        "data": [{
          "id": "string",
          "username": "string",
          "message": "string",
          "system": "true when CP Elora posted it (follow, like, cheer, gift announcements)",
          "replyTo": "null or {id, username, message}",
          "createdAt": "ISO timestamp"
        }]
      },
      "notes": "Always the last 50 messages, oldest first. There is no pagination and no way to send a message through this API."
    },
    {
      "id": "user",
      "method": "GET",
      "path": "/api/v1/users/{username}",
      "scope": "user:read",
      "response": { "data": { "id": "string", "username": "string", "partner": false, "affiliate": false, "createdAt": "ISO timestamp" } },
      "notes": "Public fields only. No email, role, wallet, points or moderation state is ever exposed."
    },
    {
      "id": "drops",
      "method": "GET",
      "path": "/api/v1/drops",
      "scope": "drops:read",
      "response": {
        "data": [{
          "id": "string",
          "title": "string",
          "description": "string",
          "requiredMinutes": "number of watch minutes",
          "reward": "string or object describing what is granted",
          "creatorId": "null for platform campaigns, otherwise a user id",
          "active": "boolean"
        }],
        "total": "number of campaigns"
      },
      "notes": "Lists campaigns. Claiming a drop is done by watching on the site, not through the API."
    },
    {
      "id": "economy",
      "method": "GET",
      "path": "/api/v1/economy",
      "scope": "economy:read",
      "response": {
        "data": {
          "currency": "ISO currency code, e.g. USD",
          "dustValueMilliMinor": "thousandths of a minor unit per Dust",
          "minCheer": "smallest cheer in Dust",
          "maxCheer": "largest cheer in Dust",
          "dustPacks": [{ "id": "string", "dust": "number", "priceMinor": "integer cents" }],
          "boostPlans": [{ "id": "string", "days": "number", "priceMinor": "integer cents", "label": "string" }],
          "shares": { "standard": "percent", "affiliate": "percent", "partner": "percent" }
        }
      },
      "notes": "Prices are what the site charges. Values change with the platform economy, so read them instead of hard-coding amounts."
    }
  ],

  "managementEndpoints": {
    "auth": "These use a CP Elora account session token (the Authorization header the website itself uses), not an app access token. They are the routes behind Creator Studio → Developer API.",
    "endpoints": [
      { "method": "GET", "path": "/api/developer", "returns": "allowed, scopes, tokenTtl, apps, activeTokens" },
      { "method": "POST", "path": "/api/developer/apps", "body": { "name": "2 to 60 characters", "scopes": ["channel:read"] }, "returns": "app plus clientSecret, shown once" },
      { "method": "POST", "path": "/api/developer/apps/rotate", "body": { "appId": "string" }, "returns": "app plus a new clientSecret" },
      { "method": "POST", "path": "/api/developer/apps/delete", "body": { "appId": "string" }, "effect": "revokes the app; it stays listed as a record" },
      { "method": "POST", "path": "/api/developer/apps/remove", "body": { "appId": "string" }, "effect": "deletes the app and its credentials permanently" }
    ]
  },

  "dataModels": {
    "channel": {
      "id": "string (UUID)",
      "username": "the creator's username, used in the API path",
      "displayName": "string",
      "title": "current stream title",
      "category": "string, e.g. Just Chatting",
      "language": "ISO language code",
      "isLive": "boolean, derived from the media server rather than from a stored flag",
      "url": "canonical channel page",
      "supporters": "number of followers",
      "activeBoosts": "number of active Boosts",
      "partner": "boolean",
      "affiliate": "boolean"
    },
    "chatMessage": {
      "id": "string",
      "username": "string; 'CP Elora' for system announcements",
      "message": "plain text as typed",
      "system": "boolean; true means the platform posted it",
      "replyTo": "null or {id, username, message}",
      "createdAt": "ISO timestamp"
    },
    "supporter": { "id": "string", "username": "string", "partner": "boolean", "affiliate": "boolean" },
    "user": { "id": "string", "username": "string", "partner": "boolean", "affiliate": "boolean", "createdAt": "ISO timestamp" }
  },

  "examples": {
    "curl": "API=https://governmental-judges-andy-delays.trycloudflare.com\nTOKEN=$(curl -s -X POST \"$API/api/v1/oauth/token\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"client_id\":\"YOUR_CLIENT_ID\",\"client_secret\":\"YOUR_CLIENT_SECRET\"}' \\\n  | python -c 'import json,sys; print(json.load(sys.stdin)[\"access_token\"])')\n\ncurl -s \"$API/api/v1/channels?live=1&limit=5\" -H \"Authorization: Bearer $TOKEN\"",
    "node": "const base = await resolveApiBase(); // see baseUrlResolution\nconst tokenResponse = await fetch(base + '/api/v1/oauth/token', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json' },\n  body: JSON.stringify({ client_id: process.env.CP_CLIENT_ID, client_secret: process.env.CP_CLIENT_SECRET })\n});\nif (!tokenResponse.ok) throw new Error('token failed: ' + (await tokenResponse.json()).error);\nconst { access_token } = await tokenResponse.json();\n\nconst channels = await fetch(base + '/api/v1/channels?live=1', {\n  headers: { Authorization: 'Bearer ' + access_token }\n});\nconst { data } = await channels.json();"
  },

  "limitations": [
    "Read-only: no route sends chat, creates clips, edits a channel, redeems points or starts a stream.",
    "No webhooks or event stream: poll the routes at a sensible interval and respect the rate limit.",
    "No third-party user login: app tokens identify an app, not a viewer, so you cannot act for a user.",
    "The site's https certificate is currently expired; discovery falls back to http until it is renewed, as the snippet in baseUrlResolution already does.",
    "Browser apps: the API only accepts cross-origin calls from cpelora.com, so a front end on another domain is blocked. Call the API from a server or overlay process instead.",
    "The API host is a rotating tunnel address; resolve it at runtime as described in baseUrlResolution.",
    "Developer access is a closed beta, so an account without access gets allowed:false from /api/developer.",
    "chat:read returns the last 50 messages only, so a bot that needs history must keep its own buffer."
  ],

  "links": {
    "developerPage": "https://cpelora.com/creator/developer",
    "site": "https://cpelora.com",
    "roadmap": "https://cpelora.com/roadmap.html",
    "thisFile": "https://cpelora.com/assets/cp-elora-ai-context.json"
  }
}
