{
  "openapi": "3.1.0",
  "info": {
    "title": "CreateWith Agent API",
    "version": "1.0.0-beta",
    "summary": "Twitch creator discovery for agents — metered reads, cached off-platform research, and commissioned enrichment.",
    "description": "Twitch's API exposes point-in-time snapshots only, so CreateWith polls it and builds the time-series itself: 566k+ channels (GET /api/v1/stats has the live count), hourly snapshots since 2026-06-08. Nothing is backfilled.\n\nStatus: **free beta** — metering is live and every price is $0. New accounts get 200 free read/search calls.\n\n**No key is needed to start.** Every operation except `POST /api/v1/research` serves a caller with no Authorization header as a guest, metered per IP against two daily caps (published in `GET /api/v1` under `auth.guest`); the response is identical apart from `meta.billing.lane: \"guest\"` and a `meta.account` block. A free, email-only key (`POST /api/v1/keys`) keeps usage on one account. A malformed or revoked key is 401.\n\nEvery success response is `{ data, meta }`; every error except 402 is `{ error: { code, message, request_id } }`. `error.code` is the field that discriminates them.\n\n**Refunds:** 5xx is auto-refunded. 4xx is NOT refunded by default (a bad parameter still consumed a call) — the operations that do refund a 4xx say so. Some 200s are refunded too, where we would otherwise be charging for a coverage answer or a queue rejection.\n\n**Idempotency:** an `Idempotency-Key` (≤128 chars) makes a retry safe. The key is unique per ACCOUNT across ALL endpoints, so reusing one for two different calls gets the second served but billed as a duplicate of the first. No response bodies are stored, so a retry recomputes fresh data for free.\n\n**Honesty:** only the 3d/7d/14d windows exist (30d unlocks ~2026-09-06, 90d ~2027-03-05). Rows carry `metrics_computed_at`, `last_seen_at` and `insufficient_data`. Dormant creators are excluded by default (`active_within_days=14`). A null metric means \"not computed\" rather than zero. Enrichment coverage is partial — ~1,800 dossiers across 566k+ tracked creators — and `coverage: \"not_enriched\"` is the honest answer, not a failure.\n\n**Relay:** every metered response carries `meta.relay` = { recipe, recipe_source, window, say[] }. The `say` lines are the caveats for that exact response rather than a generic disclaimer, and `recipe` and `window` name the search that produced it and the metric window its numbers describe. `meta.billing.status` says whether the call settled or was refunded.\n\nProse version for agents: `/llms.txt`. Unknown `/api/*` paths return a JSON 404 (on every method) whose `error.docs` points at `/api/v1`, `/llms.txt` and `/openapi.json` — a guessed path costs one parse and hands back the index.",
    "contact": { "name": "CreateWith", "url": "/signup" }
  },
  "servers": [
    { "url": "/", "description": "This host — the API is served from the same origin as this document." }
  ],
  "security": [{ "apiKey": [] }, {}],
  "x-mcp": {
    "url": "/api/v1/mcp",
    "transport": "streamable-http-stateless",
    "protocol": "JSON-RPC 2.0",
    "description": "The same data over MCP. JSON-RPC does not map onto paths, so it is described here rather than under `paths`. Same bearer key, same account, same ledger.\n\n⚠️ The transport requires `Accept: application/json, text/event-stream` on EVERY request. Without that exact header it returns 406 — and a 4xx from the transport is NOT refunded, so a client that gets this wrong can burn its whole free tier without ever running a tool. The same applies to a wrong Content-Type (415) and to malformed JSON-RPC.\n\nExactly ONE `tools/call` per request is served; a batch of two is rejected with 400 `batch_not_supported`. `initialize`, `tools/list` and `ping` are free and need no key at all; an unkeyed request is served as a guest, exactly as over REST, and those three methods are not counted against the guest daily cap either. A keyless `tools/call` does count against it. A tool answering `isError` is refunded. The JSON-RPC message id makes a poor `Idempotency-Key`: clients restart those at 1 per connection, so unrelated calls would collide.\n\nMCP has no envelope for `meta.billing`, so per-call billing is not visible in tool responses.",
    "headers": {
      "Accept": "application/json, text/event-stream",
      "Content-Type": "application/json",
      "Authorization": "Bearer tw_live_..."
    },
    "tools": [
      { "name": "search_creators", "price_class": "search" },
      { "name": "search_enriched_creators", "price_class": "search" },
      { "name": "get_channel_profile", "price_class": "read" },
      { "name": "get_overview_stats", "price_class": "free" },
      { "name": "list_categories", "price_class": "free" },
      { "name": "present_shortlist", "price_class": "free", "note": "Re-serializes rows already paid for in a search." }
    ]
  },
  "x-pricing": {
    "note": "All classes bill $0 for the duration of the beta. These are the expected prices when billing is enabled; read meta.billing.billed_micro rather than assuming.",
    "unit": "USD micro-dollars (1000000 = $1)",
    "classes": {
      "read": { "beta_micro": 0, "future_micro": 1000 },
      "search": { "beta_micro": 0, "future_micro": 5000 },
      "dossier": { "beta_micro": 0, "future_micro": 50000 },
      "enrich_job": { "beta_micro": 0, "future_micro": 500000 },
      "poll": { "beta_micro": 0, "future_micro": 0, "note": "Free by design and permanently — polling is paid for by the enrich_job that created the job." }
    },
    "free_tier_calls": 200,
    "rate_limit_per_min_per_account": 60
  },
  "paths": {
    "/api/v1": {
      "get": {
        "operationId": "getApiIndex",
        "summary": "Self-describing API index — start here",
        "description": "Free, unauthenticated, and the one call an agent can make knowing nothing: what this API is over, every endpoint with its price class, the MCP mount, the search-recipe menu, worked ask→call examples, and three copy-pasteable quickstart curls built against the caller's own origin.\n\n`facts` and `relay_note` come first in the body on purpose: they are the answer to \"what does this service offer?\" (free beta, 200 free calls, email-only signup, the collection floor, the window set, the coverage figures) and a one-line account of what `meta.relay` carries. A cold agent that reads nothing else has at least read those two.\n\n`recipes` is the strategy layer above the endpoint list: eight named search types, each with the blanks a human has to fill and the exact call that answers it. The contract is **menu when vague** — on an open-ended ask the recipes are the options, and the blanks (game, follower band, market, interest) are what the human fills in; on a specific ask the matching recipe is the call, and its name is what identifies the results.\n\nEndpoints, MCP tools, recipes and examples come from one hand-authored catalog in the codebase, held against the route files by a test — so the index cannot describe a route that does not exist. `/llms.txt` is written from that same catalog; where the two disagree, this one is authoritative. CDN-cached for 1 hour.",
        "security": [],
        "responses": {
          "200": {
            "description": "The catalog.",
            "headers": {
              "X-Request-Id": { "$ref": "#/components/headers/XRequestId" },
              "Cache-Control": { "description": "public, s-maxage=3600, stale-while-revalidate=86400, stale-if-error=86400", "schema": { "type": "string" } }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "description": "Loosely typed on purpose — entries are added as endpoints ship. A reader takes the fields it recognises rather than validating the whole shape.",
                      "required": ["name", "description", "facts", "relay_note", "docs", "auth", "endpoints", "mcp", "recipes", "example_asks", "quickstart"],
                      "properties": {
                        "name": { "type": "string" },
                        "description": { "type": "string", "description": "What the API is over, including the no-backfill fact and the date collection began." },
                        "facts": { "type": "array", "description": "The handful of one-line facts that answer \"what is this service\": free beta, the 200-call free tier, email-only signup via POST /api/v1/keys, the 2026-06-08 collection floor, the 3d/7d/14d window set, and the size of the dossier corpus against the tracked corpus. Same block as llms.txt's \"Facts to relay\".", "items": { "type": "string" } },
                        "relay_note": { "type": "string", "description": "One sentence on what `meta.relay` carries on a metered response, and on what `facts` answers when the ask is \"what does this offer?\"." },
                        "docs": { "type": "object", "description": "`llms` (/llms.txt), `openapi` (this document) and `signup` (/signup).", "additionalProperties": { "type": "string" } },
                        "auth": { "type": "object", "description": "`scheme`, `rule` (the 402-for-no-key / 401-for-bad-key contract), `signup`, `free_tier_calls` and `rate_limit_per_min`.", "additionalProperties": true },
                        "endpoints": { "type": "array", "description": "Every endpoint as `{ method, path, price_class, description }`. `price_class` matches x-pricing.classes, widened with \"free\" for the unmetered routes.", "items": { "type": "object", "additionalProperties": true } },
                        "mcp": { "type": "object", "description": "`url`, `transport`, `tools` (name + price_class) and `accept_note` — the Accept-header warning restated from x-mcp, because a 406 from the transport is billed.", "additionalProperties": true },
                        "recipes": { "type": "array", "description": "The search-recipe menu: { id, title, one_liner, human_provides, example_ask, call, returns, notes? } — the options an agent presents to its human when the ask is vague; example_asks is derived from it.", "items": { "type": "object", "additionalProperties": true } },
                        "example_asks": { "type": "array", "description": "`{ ask, call }` pairs mapping a human request to the call that answers it, including the two-step search-then-fetch and post-then-poll patterns. Derived from `recipes` (each entry is one recipe's `example_ask` + `call`), so the two cannot disagree.", "items": { "type": "object", "additionalProperties": true } },
                        "quickstart": { "type": "object", "description": "Three curls against the requesting origin, in the order an agent makes them: `try_without_a_key`, `get_a_key`, `call_with_a_key`.", "additionalProperties": { "type": "string" } }
                      },
                      "additionalProperties": true
                    },
                    "meta": { "$ref": "#/components/schemas/Meta" }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api": {
      "get": {
        "operationId": "getApiPointer",
        "summary": "Pointer to the API index",
        "description": "A signpost, nothing more: `{ name, index: \"/api/v1\", docs }`. It exists because `/api` is the first path a caller tries, and without it that is an HTML 404 which reads as \"there is no API here\". The catalog is deliberately not duplicated — everything substantive is at /api/v1.",
        "security": [],
        "responses": {
          "200": {
            "description": "Where the real index is.",
            "headers": {
              "X-Request-Id": { "$ref": "#/components/headers/XRequestId" },
              "Cache-Control": { "description": "public, s-maxage=3600, stale-if-error=86400", "schema": { "type": "string" } }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["name", "index", "docs"],
                      "properties": {
                        "name": { "type": "string" },
                        "index": { "type": "string", "description": "Always \"/api/v1\"." },
                        "docs": { "type": "object", "description": "`llms` and `openapi`.", "additionalProperties": { "type": "string" } }
                      },
                      "additionalProperties": true
                    },
                    "meta": { "$ref": "#/components/schemas/Meta" }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/creators/search": {
      "get": {
        "operationId": "searchCreators",
        "summary": "Search tracked creators",
        "description": "The main discovery surface. Price class `search`.\n\nUnrecognised parameter values fall back to the default rather than erroring. `campaign` is not exposed — it is parsed and discarded.\n\n`total` is a query-planner estimate wherever `totalIsEstimate` is true — a magnitude, not a count — and is null under a `game`/`tag` filter, where the planner has no usable estimate; a null there means UNKNOWN rather than zero, and `data.totalNote` says so in words. enrichedOnly/enrichedQuery searches are the exception — they run against an id set, so their `total` is EXACT with totalIsEstimate false. Outside that case it is an order of magnitude, not an answer to \"how many creators are there\".\n\nWhen an enrichedOnly/enrichedQuery search matches nothing, `data.coverage` explains the emptiness and the call is refunded (meta.billing.status \"refunded\") — an empty enriched result is a coverage answer, not a failure.",
        "parameters": [
          { "$ref": "#/components/parameters/IdempotencyKey" },
          { "$ref": "#/components/parameters/CreateWithRecipe" },
          { "name": "window", "in": "query", "description": "Metric window. Only these three exist.", "schema": { "type": "string", "enum": ["3d", "7d", "14d"], "default": "14d" } },
          { "name": "sort", "in": "query", "schema": { "type": "string", "default": "avg_viewers", "enum": ["avg_viewers", "peak_viewers", "stream_count", "follower_total", "viewer_to_follower_ratio", "peak_to_avg_ratio", "momentum_score", "viewer_growth", "follower_gain_velocity", "category_percentile"] } },
          { "name": "dir", "in": "query", "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } },
          { "name": "limit", "in": "query", "description": "Hard cap 25. Higher values clamp silently.", "schema": { "type": "integer", "minimum": 1, "maximum": 25, "default": 25 } },
          { "name": "offset", "in": "query", "description": "Capped at 500 — refine filters rather than paging deep.", "schema": { "type": "integer", "minimum": 0, "maximum": 500, "default": 0 } },
          { "name": "active_within_days", "in": "query", "description": "Only creators seen live within N days. The default excludes dormant creators, whose metrics are frozen at the point they stopped streaming.", "schema": { "type": "integer", "minimum": 1, "maximum": 365, "default": 14 } },
          { "name": "include_dormant", "in": "query", "description": "`true` disables the staleness filter entirely and wins over `active_within_days`. Expect totals to roughly double on the 3d window.", "schema": { "type": "boolean", "default": false } },
          { "name": "q", "in": "query", "description": "Login / display-name match.", "schema": { "type": "string" } },
          { "name": "game", "in": "query", "description": "Category NAME, matched case-insensitively as a substring — not a game_id. Use /api/v1/categories to find the exact name. Setting this makes `total` null.", "schema": { "type": "string" } },
          { "name": "tag", "in": "query", "description": "Exact match against the channel's latest stream tags. Setting this makes `total` null.", "schema": { "type": "string" } },
          { "name": "language", "in": "query", "description": "Twitch broadcaster language code, e.g. `en`. The closest thing to a geo filter on Twitch-native data.", "schema": { "type": "string" } },
          { "name": "broadcaster_type", "in": "query", "description": "An empty string is a valid value and selects channels that are neither.", "schema": { "type": "string", "enum": ["partner", "affiliate", ""] } },
          { "name": "minAvgViewers", "in": "query", "schema": { "type": "integer", "minimum": 0 } },
          { "name": "maxAvgViewers", "in": "query", "schema": { "type": "integer", "minimum": 0 } },
          { "name": "minFollowers", "in": "query", "schema": { "type": "integer", "minimum": 0 } },
          { "name": "maxFollowers", "in": "query", "schema": { "type": "integer", "minimum": 0 } },
          { "name": "minMomentum", "in": "query", "description": "May be negative.", "schema": { "type": "number" } },
          { "name": "minViewerGrowth", "in": "query", "description": "May be negative.", "schema": { "type": "number" } },
          { "name": "hideInsufficient", "in": "query", "description": "Drops rows flagged `insufficient_data`, AND applies minStreams=3 unless minStreams is set explicitly. The flag for a caller that is going to act on the numbers.", "schema": { "type": "boolean", "default": false } },
          { "name": "minStreams", "in": "query", "description": "Keeps only creators with at least N streams in the window. A channel that streamed once can top a momentum sort on one lucky night; this is the filter that stops it. hideInsufficient=true implies 3; an explicit value always wins, including 0, which opts back out.", "schema": { "type": "integer", "minimum": 0, "maximum": 1000, "default": 0 } },
          { "name": "brandSafeOnly", "in": "query", "description": "Excludes content-labelled channels.", "schema": { "type": "boolean", "default": false } },
          { "name": "enrichedOnly", "in": "query", "description": "Only creators we hold off-platform research for. Searches EVERY completed dossier and returns an exact total — an earlier unpaginated read capped this at the first 1,000 dossiers and reported total: 1000 as a fact; that is fixed.", "schema": { "type": "boolean", "default": false } },
          { "name": "enrichedQuery", "in": "query", "description": "Full-text search across that research.", "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "A page of creators.",
            "headers": { "X-Request-Id": { "$ref": "#/components/headers/XRequestId" } },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["rows", "total", "totalIsEstimate", "window"],
                      "properties": {
                        "rows": { "type": "array", "items": { "$ref": "#/components/schemas/CreatorRow" } },
                        "total": { "type": ["integer", "null"], "description": "A planner estimate wherever totalIsEstimate is true; may be null under a game/tag filter. Inexact by construction — an order of magnitude, not a count." },
                        "totalIsEstimate": { "type": "boolean", "description": "True when `total` is a query-planner estimate." },
                        "totalNote": { "type": ["string", "null"], "description": "Why `total` is null, in words, when it is — agents in the 2026-08-29 cohort read an absent count as \"0 matches\", which is the opposite of what it means. Null when total is populated." },
                        "window": { "type": "string", "enum": ["3d", "7d", "14d"] },
                        "coverage": { "type": "object", "description": "Present ONLY when an enrichedOnly/enrichedQuery search returned no rows; the call is then refunded. Absent on a search that was not enrichment-scoped, and absent (and charged) when a positive exact total with zero rows just means the caller paged past the end. `enriched_in_scope` equals `enriched_total` when nothing but the enrichment filter narrowed the search, and is null when any other filter (game, language, a band, hideInsufficient, …) was also set, because counting inside those filters would cost a second board query that an empty answer does not justify — the `note` says so. Both are null if the dossier count was unavailable. Null here means NOT COUNTED rather than zero.", "required": ["enriched_in_scope", "enriched_total", "note", "enrich"], "properties": { "enriched_in_scope": { "type": ["integer", "null"] }, "enriched_total": { "type": ["integer", "null"], "description": "Completed dossiers we hold." }, "note": { "type": "string", "description": "One sentence for the human: missing coverage, not proof no such creator exists." }, "enrich": { "type": "object", "description": "How to fill the gap: { how, price_class: \"enrich_job\", note: \"async, minutes\" }.", "additionalProperties": true } }, "additionalProperties": true }
                      }
                    },
                    "meta": { "$ref": "#/components/schemas/Meta" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/api/v1/creators/{id}": {
      "get": {
        "operationId": "getCreator",
        "summary": "Get one creator's profile",
        "description": "Price class `read`.\n\n`metrics` holds one row per computed window, each with its own `computed_at` and `insufficient_data`. `viewerTrend` is null until at least 6 snapshots exist.\n\nA 404 here IS billed — we looked. The 400 is not refunded either.",
        "parameters": [
          { "$ref": "#/components/parameters/CreatorId" },
          { "$ref": "#/components/parameters/IdempotencyKey" }
        ],
        "responses": {
          "200": {
            "description": "The creator's profile.",
            "headers": { "X-Request-Id": { "$ref": "#/components/headers/XRequestId" } },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": { "$ref": "#/components/schemas/ChannelProfile" },
                    "meta": { "$ref": "#/components/schemas/Meta" }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`invalid_creator_id` — the id contained nothing usable after stripping to [A-Za-z0-9_]. Not refunded.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": {
            "description": "`creator_not_found` — no tracked creator with that Twitch user id. **Billed.** CreateWith only holds creators it has observed live.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/api/v1/creators/{id}/research": {
      "get": {
        "operationId": "getCreatorResearch",
        "summary": "Get cached off-platform research (dossier)",
        "description": "Price class `dossier`. `data.coverage` discriminates the payload.\n\n`not_enriched` returns 200 and is REFUNDED — there is no charge for being told we hold nothing. The 404 is refunded too. Coverage is partial: ~1,800 creators out of 566k+ tracked. An enrich_job via POST /api/v1/research is how a gap gets filled.\n\nOn the enriched path `meta.freshness` states that research reflects the web at `completed_at` and is not refreshed automatically.",
        "parameters": [
          { "$ref": "#/components/parameters/CreatorId" },
          { "$ref": "#/components/parameters/IdempotencyKey" }
        ],
        "responses": {
          "200": {
            "description": "Either the dossier (billed) or a not_enriched coverage answer (refunded).",
            "headers": { "X-Request-Id": { "$ref": "#/components/headers/XRequestId" } },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": { "$ref": "#/components/schemas/ResearchPayload" },
                    "meta": { "$ref": "#/components/schemas/Meta" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": {
            "description": "`creator_not_found` — unknown creator id. Refunded.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/api/v1/trending": {
      "get": {
        "operationId": "getTrending",
        "summary": "Creators rising disproportionately fast",
        "description": "Price class `read`. Four follower size tiers.\n\n**Paging is PER TIER.** With no `tier`, `offset` skips rows inside every section at once, which is rarely the intent — one band at a time (`?tier=micro&offset=25`) is the useful page. Each section echoes `offset` and `returned`; `returned < limit` means that tier is exhausted. Empty sections are omitted.\n\nTrending rows carry raw `computed_at`, not `metrics_computed_at`, and have no `has_research`.",
        "parameters": [
          { "$ref": "#/components/parameters/IdempotencyKey" },
          { "$ref": "#/components/parameters/CreateWithRecipe" },
          { "name": "window", "in": "query", "schema": { "type": "string", "enum": ["3d", "7d", "14d"], "default": "7d" } },
          { "name": "tier", "in": "query", "description": "Omit for all four. micro <1K followers, small 1K–10K, mid 10K–100K, large 100K+.", "schema": { "type": "string", "enum": ["micro", "small", "mid", "large"] } },
          { "name": "limit", "in": "query", "description": "Rows per tier.", "schema": { "type": "integer", "minimum": 1, "maximum": 25, "default": 10 } },
          { "name": "offset", "in": "query", "description": "Rows skipped within each tier.", "schema": { "type": "integer", "minimum": 0, "maximum": 500, "default": 0 } },
          { "name": "campaign", "in": "query", "description": "Campaign preset id. Honored here, unlike on /creators/search. Unknown ids are ignored.", "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "One section per requested tier.",
            "headers": { "X-Request-Id": { "$ref": "#/components/headers/XRequestId" } },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["sections", "window", "campaignId", "tier", "offset", "limit"],
                      "properties": {
                        "sections": { "type": "array", "items": { "$ref": "#/components/schemas/TrendingSection" } },
                        "window": { "type": "string", "enum": ["3d", "7d", "14d"] },
                        "campaignId": { "type": ["string", "null"] },
                        "tier": { "type": ["string", "null"], "enum": ["micro", "small", "mid", "large", null] },
                        "offset": { "type": "integer" },
                        "limit": { "type": "integer" }
                      }
                    },
                    "meta": { "$ref": "#/components/schemas/Meta" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/api/v1/live": {
      "get": {
        "operationId": "getLive",
        "summary": "Right-now snapshot of the tracked categories",
        "description": "Price class `read`. No parameters. No `meta.freshness` — the payload timestamps itself with `captured_at`.",
        "parameters": [{ "$ref": "#/components/parameters/IdempotencyKey" }],
        "responses": {
          "200": {
            "description": "The latest capture cycle.",
            "headers": { "X-Request-Id": { "$ref": "#/components/headers/XRequestId" } },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": { "$ref": "#/components/schemas/LiveOverview" },
                    "meta": { "$ref": "#/components/schemas/Meta" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/api/v1/stats": {
      "get": {
        "operationId": "getStats",
        "summary": "Corpus totals (free, no key)",
        "description": "Unauthenticated and unmetered. Use it to size the corpus before spending anything — and read `data.enrichment` to see where the off-platform dossiers actually are (by language, by reach band) before spending a search on a slice nobody has researched. `data.notes` carries the two caveats that matter: by_game is polled target categories, not a share of all creators, and the enrichment buckets can sum to less than total. CDN-cached for 5 minutes.",
        "security": [],
        "responses": {
          "200": {
            "description": "What we have collected.",
            "headers": {
              "X-Request-Id": { "$ref": "#/components/headers/XRequestId" },
              "Cache-Control": { "description": "public, s-maxage=300, stale-while-revalidate=600, stale-if-error=86400", "schema": { "type": "string" } }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": { "$ref": "#/components/schemas/CorpusStats" },
                    "meta": { "$ref": "#/components/schemas/Meta" }
                  }
                }
              }
            }
          },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/api/v1/categories": {
      "get": {
        "operationId": "listCategories",
        "summary": "Resolve a game name to a Twitch game_id (free, no key)",
        "description": "Unauthenticated and unmetered. CDN-cached for 1 hour. Results are ordered by `is_target` desc, then name asc.",
        "security": [],
        "parameters": [
          { "name": "q", "in": "query", "description": "Case-insensitive substring match. Omit to list everything (subject to `limit`).", "schema": { "type": "string" } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 25 } }
        ],
        "responses": {
          "200": {
            "description": "Matching categories.",
            "headers": {
              "X-Request-Id": { "$ref": "#/components/headers/XRequestId" },
              "Cache-Control": { "description": "public, s-maxage=3600, stale-if-error=86400", "schema": { "type": "string" } }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["query", "returned", "categories"],
                      "properties": {
                        "query": { "type": ["string", "null"], "description": "Echo of the normalized q." },
                        "returned": { "type": "integer" },
                        "categories": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "required": ["id", "name", "is_target"],
                            "properties": {
                              "id": { "type": "string", "description": "Twitch game_id." },
                              "name": { "type": "string" },
                              "is_target": { "type": ["boolean", "null"], "description": "A COLLECTION FACT, not a quality signal. True = we poll this category for live streams, so creators are discovered in it continuously. False = it is not polled, and the row is a name-to-id resolution and nothing more — it does NOT mean we hold no creators there, because a channel found in a target category and later seen playing something else still carries that game as latest_game_name, so a game= search can return plenty of rows for a non-target category." }
                            }
                          }
                        }
                      }
                    },
                    "meta": { "$ref": "#/components/schemas/Meta" }
                  }
                }
              }
            }
          },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/api/v1/research": {
      "post": {
        "operationId": "createResearchJob",
        "summary": "Commission fresh off-platform research",
        "description": "Price class `enrich_job`. **THE ONE OPERATION THAT REQUIRES A KEY** — a keyless call gets 402 `reason: \"no_key\"` carrying the signup recipe, because this is the only endpoint that spends real money on our side (an Anthropic research session that searches the web for minutes). Everything else on this API serves a keyless caller as a guest.\n\nONE creator per call — there is no batch body, so one call is always one price.\n\nEnrichment takes minutes and is ALWAYS async: this starts a job, and GET /api/v1/research/{id} polls it (at $0). There is no synchronous variant. Poll every 15 seconds; a job takes 1-4 minutes end to end.\n\nOnly one outcome keeps the money: a session we actually opened (`status: \"running\"` on a job that did not already exist). Every other outcome — fresh cache hit, already running, failed to start, and all four gate errors — is refunded.",
        "security": [{ "apiKey": [] }],
        "parameters": [{ "$ref": "#/components/parameters/IdempotencyKey" }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["creator_id"],
                "properties": {
                  "creator_id": { "type": "string", "description": "Twitch user id. Login and display name are read from our own row rather than taken on trust." }
                },
                "additionalProperties": false
              },
              "example": { "creator_id": "123456789" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Job state. `running` on a newly opened session is the only billed outcome.",
            "headers": { "X-Request-Id": { "$ref": "#/components/headers/XRequestId" } },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": { "$ref": "#/components/schemas/ResearchJob" },
                    "meta": { "$ref": "#/components/schemas/Meta" }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`invalid_request` — body was not `{\"creator_id\": \"...\"}`. Refunded.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": {
            "description": "`creator_not_found` — unknown creator id. Refunded.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "429": {
            "description": "Refunded. `too_many_inflight_jobs` — at most 2 enrichment jobs per account may be in flight (Retry-After: 60). `daily_capacity_reached` — the global daily research budget is spent (Retry-After: 3600). `rate_limited` — the 60/min account limit.",
            "headers": { "Retry-After": { "schema": { "type": "integer" } } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/api/v1/research/{id}": {
      "get": {
        "operationId": "pollResearchJob",
        "summary": "Poll a research job",
        "description": "Price class `poll` — **$0 on every branch, always**. Polling is paid for by the enrich_job that created the job. The polling interval is 15 seconds — a `running` body carries `poll_every_s: 15`, so it needs no guessing — and a job takes 1-4 minutes end to end. A `done` body carries `completed_at`, and `meta.relay.charged` reports what the job cost ({ enrich_job: 1, polls: 0 }); that pair is the closure a person needs.\n\n`{id}` is the CREATOR id that was POSTed, not a separate job id. The internal session id is not exposed.",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "description": "The creator id passed to POST /api/v1/research.", "schema": { "type": "string" } },
          { "$ref": "#/components/parameters/IdempotencyKey" }
        ],
        "responses": {
          "200": {
            "description": "Job state. `cached` is always false on this route.",
            "headers": { "X-Request-Id": { "$ref": "#/components/headers/XRequestId" } },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": { "$ref": "#/components/schemas/ResearchJob" },
                    "meta": { "$ref": "#/components/schemas/Meta" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": {
            "description": "`no_research_job` — nothing was ever started for that creator.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/api/v1/keys": {
      "post": {
        "operationId": "createApiKey",
        "summary": "Self-serve signup — create an account and its first key",
        "description": "Free and unmetered: the caller has no key yet. Limited to 3 signups per IP per hour.\n\n`data.api_key` is returned ONCE. We store only a sha256 hash of it and cannot re-send it. A lost key means a new account — key re-issue is manual during the beta.\n\n**THE KEY IS MEANT TO BE STORED AND REUSED ACROSS SESSIONS.** A fresh signup per session or per task burns the 3/IP/hour limit mid-task, splits the free tier across throwaway accounts, and once billing goes live it strands the credits: credits attach to the ACCOUNT, and a fresh account has none. The response carries this recipe as `data.persist`.\n\nThe human-facing equivalent of this endpoint is the /signup page.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["email"],
                "properties": {
                  "email": { "type": "string", "format": "email", "description": "Lowercased and trimmed. Used to identify the account and to reach its owner about the key." },
                  "name": { "type": "string", "maxLength": 64, "description": "Optional display label for the key." }
                },
                "additionalProperties": false
              },
              "example": { "email": "you@example.com", "name": "prod worker" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Account created. The key is in this body and nowhere else, ever.",
            "headers": { "X-Request-Id": { "$ref": "#/components/headers/XRequestId" } },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["api_key", "key_prefix", "free_calls_remaining", "free_calls", "persist", "quickstart", "docs"],
                      "properties": {
                        "api_key": { "type": "string", "description": "`tw_live_` + 32 base62 chars. Shown once." },
                        "key_prefix": { "type": "string", "description": "The non-secret first 16 chars, for identifying the key later." },
                        "free_calls_remaining": { "type": "integer", "description": "Free read/search calls the account starts with — the same name `meta.billing.free_calls_remaining` uses on every other response, so the balance reads the same way everywhere." },
                        "free_calls": { "type": "integer", "deprecated": true, "description": "DEPRECATED duplicate of free_calls_remaining, kept for one release." },
                        "persist": { "type": "string", "description": "The store-the-key recipe in words. Stateless agents were signing up once per session, splitting their free tier across throwaway accounts; this is the sentence that stops it." },
                        "quickstart": {
                          "type": "object",
                          "properties": {
                            "curl": { "type": "string", "description": "A ready-to-run REST call with the key already in it." },
                            "mcp": {
                              "type": "object",
                              "description": "Drop-in MCP client config.",
                              "properties": {
                                "url": { "type": "string" },
                                "headers": { "type": "object", "additionalProperties": { "type": "string" } },
                                "note": { "type": "string" }
                              }
                            }
                          }
                        },
                        "docs": { "type": "string", "description": "Path to the prose contract (/llms.txt)." }
                      }
                    },
                    "meta": { "$ref": "#/components/schemas/Meta" }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`invalid_email` — the body was not `{\"email\": \"...\"}` with a usable address.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "409": {
            "description": "`email_taken` — that email already has an account. Nothing is minted and no account details are disclosed; key re-issue is manual during the beta.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "429": {
            "description": "`rate_limited` — more than 3 signups from this IP in an hour.",
            "headers": { "Retry-After": { "schema": { "type": "integer" } } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "`Authorization: Bearer tw_live_<32 chars>`. OPTIONAL on every operation except POST /api/v1/research: a call with no Authorization header runs as a guest, metered per IP, and its envelope carries `meta.billing.lane: \"guest\"` plus `meta.account`. A key is free and email-only (POST /api/v1/keys) and keeps usage on one account. A malformed, unknown or revoked key returns 401 — a wrong key is a bug, not a guest."
      }
    },
    "parameters": {
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Makes a retry safe — the same key is charged at most once. Max 128 chars; longer is rejected with 400 `invalid_idempotency_key`. UNIQUE PER ACCOUNT ACROSS ALL ENDPOINTS: reusing one for two different calls gets the second served but billed as a duplicate of the first. No response bodies are stored, so a retry recomputes fresh data for free.",
        "schema": { "type": "string", "maxLength": 128 }
      },
      "CreateWithRecipe": {
        "name": "X-CreateWith-Recipe",
        "in": "header",
        "required": false,
        "description": "Optional and free. When the call is one of the menu's recipes, this header carries that recipe's `id` (the `recipes[].id` of GET /api/v1 — e.g. `rising-in-game`, `trending-now`, `undervalued-engagement`, `brand-safe-shortlist`), so we can see which searches agents actually run. Unknown values are recorded as `other`. It changes neither the response nor the price.",
        "schema": { "type": "string" }
      },
      "CreatorId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "Twitch USER ID — not a login. There is no login lookup on this route; resolve one with /api/v1/creators/search?q=<login> and read rows[0].id. Stripped to [A-Za-z0-9_].",
        "schema": { "type": "string" }
      }
    },
    "headers": {
      "XRequestId": {
        "description": "Echoes meta.request_id. Quote it in any support report — it is how we find the call in the ledger.",
        "schema": { "type": "string" }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Malformed request. Not refunded by default — a bad parameter still consumed a metered call. `invalid_idempotency_key` when the header exceeds 128 chars.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Unauthorized": {
        "description": "`invalid_api_key` — the Authorization header was malformed, or the key is unknown or revoked. Note that a MISSING key is not an error at all on these operations: it runs as a guest.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "PaymentRequired": {
        "description": "On every operation except POST /api/v1/research this means only one thing: a KEYED account is out of credit (`reason: \"insufficient\"`). A missing key is not a 402 on those — it runs as a guest. POST /api/v1/research is the exception and does return `reason: \"no_key\"` to a keyless caller, because it spends real money on our side; its body carries the signup recipe. The body advertises both payment lanes.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaymentRequired" } } }
      },
      "Forbidden": {
        "description": "`account_suspended` — the account exists but is suspended.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "TooManyRequests": {
        "description": "Two codes, two remedies. `rate_limited` — 60 requests per minute per ACCOUNT, enforced in the same transaction as the debit; `error.limit_per_min` carries the number, and the remedy is to slow down. `guest_limit` — a KEYLESS caller reached the per-IP daily allowance (`error.limit_per_day` calls or `error.rows_per_day` rows, both in `GET /api/v1` under `auth.guest`); `error.resets_at` is the next UTC midnight and `error.upgrade.signup` is the one POST that lifts it now. REST and the MCP mount share both counters and emit the same headers.",
        "headers": {
          "Retry-After": { "description": "Seconds to wait — the actionable one. A caller that backs off this long is guaranteed to be under the cap.", "schema": { "type": "integer" } },
          "RateLimit-Limit": { "description": "The ceiling: 60 requests per minute per account.", "schema": { "type": "integer" } },
          "RateLimit-Remaining": { "description": "Always 0 here. A BOUND, not a live counter — enforcement is a rolling 60-second count in SQL, so there is no live remaining figure to publish. True of a caller that has just been refused; it does not tick, and it is not emitted on 2xx.", "schema": { "type": "integer" } },
          "RateLimit-Reset": { "description": "Always 60. A BOUND, not a live counter — the longest a rolling window can take to clear, not a measured time-to-reset. Pace off Retry-After, not off arithmetic on Remaining.", "schema": { "type": "integer" } }
        },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "InternalError": {
        "description": "`internal_error`. Deliberately opaque — the detail is in our logs, correlated by request_id. **Auto-refunded**: the call was not charged.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    },
    "schemas": {
      "Billing": {
        "type": "object",
        "description": "What this call cost and which lane paid. billed_micro is 0 for the whole beta — a figure to read rather than assume.",
        "required": ["price_class", "billed_micro", "lane", "free_calls_remaining"],
        "properties": {
          "price_class": { "type": "string", "enum": ["read", "search", "dossier", "enrich_job", "poll", "free"] },
          "billed_micro": { "type": "integer", "description": "USD micro-dollars. 1000000 = $1." },
          "lane": { "type": "string", "enum": ["free", "credits", "guest", "x402"], "description": "`guest` is a call made with no Authorization header: nothing was billed, no free-call counter moved, and the per-IP allowance left today is in `meta.account.guest_calls_remaining_today`. x402 is reserved for a later phase and is not returned today." },
          "free_calls_remaining": { "type": ["integer", "null"], "description": "Non-null ONLY when the free counter was the lane that paid. Null means \"not applicable to this call\", NOT \"unlimited\". It is null on an idempotent replay, on every unmetered endpoint, and on every priced class (dossier, enrich_job, poll) — those are not the free counter's lane, so nothing decrements. The last non-null value seen is the one that still stands." },
          "status": { "type": "string", "enum": ["settled", "refunded"], "description": "Whether this call kept the money or gave it back. A 200 can be either: a coverage answer, a not_enriched dossier and a cached/duplicate research POST are all refunded 200s. Present on every metered response; absent on the free unmetered routes, which stay outside the metering wrapper, and on a 5xx internal_error body." }
        }
      },
      "Meta": {
        "type": "object",
        "description": "Everything about the call itself, kept separate from `data` so provenance fields can be added without reshaping a payload.",
        "required": ["request_id", "generated_at", "billing"],
        "properties": {
          "request_id": { "type": "string", "description": "The caller's Idempotency-Key when one was sent, else a server-generated id." },
          "generated_at": { "type": "string", "format": "date-time", "description": "When this response was produced. NOT when the underlying metrics were computed — that is per row." },
          "billing": { "$ref": "#/components/schemas/Billing" },
          "relay": { "type": "object", "description": "The caveats attached to THIS response. Present on every metered response. `recipe` is the search-recipe id that was run (echoed from X-CreateWith-Recipe or inferred from the call's parameters; null if neither), `recipe_source` is \"header\" | \"inferred\" | null, `window` is the metric window the numbers describe (null where a response is not window-scoped, such as a dossier or a research job), and `say` is at most four short caveat lines (<=120 chars each) composed from what this response actually contains — they are the caveat list for this exact response rather than a generic disclaimer, and an empty array means nothing needed flagging. `charged` ({ enrich_job, polls }) appears on the two research routes only. Inference matches the call's path and query against each recipe's published call and needs EVERY parameter of that call to be present, so a dropped published param can match a looser recipe or none at all; X-CreateWith-Recipe settles it outright.", "properties": { "recipe": { "type": ["string", "null"] }, "recipe_source": { "type": ["string", "null"], "enum": ["header", "inferred", null] }, "window": { "type": ["string", "null"] }, "say": { "type": "array", "items": { "type": "string" } }, "charged": { "type": "object", "additionalProperties": { "type": "integer" } } }, "additionalProperties": true },
          "freshness": { "type": "string", "description": "Present only on routes serving rolled-up or cached data (/creators/search, /creators/{id}, /trending, and the enriched dossier). States in words how stale the numbers may be." },
          "account": { "type": "object", "description": "Present only on a GUEST response (no Authorization header was sent). `status` is \"guest\", `note` says what a key buys, and `guest_calls_remaining_today` is the per-IP call allowance left today — null when the cap was not consulted for this call, which means \"not applicable\" rather than \"unlimited\".", "properties": { "status": { "type": "string", "enum": ["guest"] }, "note": { "type": "string" }, "guest_calls_remaining_today": { "type": ["integer", "null"] } }, "additionalProperties": true }
        }
      },
      "Error": {
        "type": "object",
        "description": "The shape of every failure except 402.",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "required": ["code", "message", "request_id"],
            "properties": {
              "code": { "type": "string", "description": "Stable and machine-readable. Branch on this." },
              "message": { "type": "string", "description": "For humans reading the logs. May be reworded at any time." },
              "request_id": { "type": "string" },
              "docs": { "type": ["string", "object"], "description": "Pointer(s) to documentation; string \"/llms.txt\" on most errors, an object of pointers ({ index, llms, openapi }) on the unknown-path 404. Present on every error so a caller that has only ever seen a failure still holds a route to the docs — type-check it before dereferencing.", "additionalProperties": { "type": "string" } }
            },
            "additionalProperties": true
          }
        }
      },
      "PaymentRequired": {
        "type": "object",
        "description": "HTTP 402. Deliberately NOT the standard error shape — it is an x402 envelope carrying both payment lanes. `accepts` is empty because no on-chain option is offered yet; a well-behaved x402 client reads that and falls through to `extra.api_key_lane`.",
        "required": ["x402Version", "error", "accepts", "extra"],
        "properties": {
          "x402Version": { "type": "integer", "const": 1 },
          "error": { "type": "string", "const": "payment_required" },
          "accepts": { "type": "array", "items": { "type": "object", "additionalProperties": true }, "description": "Empty for now." },
          "extra": {
            "type": "object",
            "properties": {
              "request_id": { "type": "string" },
              "resource": { "type": "string", "description": "The path that requires payment." },
              "price_class": { "type": ["string", "null"] },
              "reason": { "type": "string", "enum": ["no_key", "insufficient"] },
              "api_key_lane": {
                "type": "object",
                "description": "Everything needed to get past this 402 without fetching anything else.",
                "properties": {
                  "signup": { "type": "object", "description": "The API signup call, ready to make: { method: \"POST\", url: \"/api/v1/keys\", body: { email, name }, note }. This is the one an agent uses.", "properties": { "method": { "type": "string" }, "url": { "type": "string" }, "body": { "type": "object", "description": "The JSON body to POST: { email, name } — name is an optional label.", "additionalProperties": true }, "note": { "type": "string" } } },
                  "human_url": { "type": "string", "description": "The /signup web page, for a person. An agent does not need it — it is HTML and does not describe the API route." },
                  "index": { "type": "string", "description": "/api/v1 — the free self-describing index." },
                  "docs": { "type": "string", "description": "/llms.txt — the prose contract." },
                  "note": { "type": "string" },
                  "signup_url": { "type": "string", "deprecated": true, "description": "DEPRECATED alias of human_url, kept for one release. It was not the API route — signup.url is." }
                }
              }
            }
          }
        }
      },
      "CreatorRow": {
        "type": "object",
        "description": "A creator as returned by /creators/search. Identity, the selected window's metrics, latest-stream context, and the honesty fields documented below. Metric fields are nullable and a null means \"not computed\" rather than zero. Additional columns may appear over time, and a reader that tolerates them stays compatible.",
        "required": ["id", "login", "display_name", "metric_window", "insufficient_data", "metrics_computed_at", "last_seen_at"],
        "properties": {
          "id": { "type": "string", "description": "Twitch user id — the id every other endpoint takes." },
          "login": { "type": "string" },
          "display_name": { "type": "string" },
          "broadcaster_type": { "type": ["string", "null"], "description": "partner, affiliate, or empty for neither." },
          "primary_language": { "type": ["string", "null"] },
          "metric_window": { "type": "string", "enum": ["3d", "7d", "14d"], "description": "Which window these metrics describe." },
          "insufficient_data": { "type": "boolean", "description": "HONESTY FLAG. True when this creator's window is not adequately covered by collected snapshots. The metrics of a flagged row are not safe to act on; hideInsufficient=true filters them out." },
          "metrics_computed_at": { "type": ["string", "null"], "format": "date-time", "description": "HONESTY FIELD. When the rollup last recomputed this row. 3d/7d refresh hourly, 14d daily (so up to 24h stale). Not meta.generated_at, and not last_seen_at." },
          "last_seen_at": { "type": ["string", "null"], "format": "date-time", "description": "HONESTY FIELD. When we last observed this creator live. Rows older than active_within_days are excluded by default because their metrics are frozen at the point they stopped streaming." },
          "has_research": { "type": "boolean", "description": "HONESTY FIELD. Whether we hold cached off-platform research for this creator — i.e. whether /creators/{id}/research will return coverage \"enriched\". Search-only; other endpoints omit it." },
          "follower_total": { "type": ["integer", "null"], "description": "The latest follower count we have ever observed for this channel — carried in from another window when this one computed none, so it is null only when none has ever been observed. Can be weeks stale for long-tail channels: we poll ~300/hr against 566k+ channels." },
          "follower_total_as_of": { "type": ["string", "null"], "format": "date-time", "description": "HONESTY FIELD. When the follower_total on this row was computed." },
          "follower_total_source": { "type": ["string", "null"], "enum": ["window", "fallback", "none", null], "description": "HONESTY FIELD. \"window\" = this row's own window produced the figure; \"fallback\" = it was carried from another window because this one had none, and follower_total_as_of is the field to check before quoting it; \"none\" = no follower count has ever been observed for this channel and follower_total is null." },
          "avg_viewers": { "type": ["number", "null"] },
          "peak_viewers": { "type": ["number", "null"] },
          "stream_count": { "type": ["integer", "null"] },
          "momentum_score": { "type": ["number", "null"], "description": "A 0-100 RANK against peers, not a growth rate: viewer growth (50%), follower-gain pace (30%), category percentile (20%). Two creators with the same score are not growing at the same speed." },
          "viewer_growth": { "type": ["number", "null"], "description": "A SIGNED RATIO against the prior window of the same length — not a percentage and not a multiplier. 0.25 = +25%, -0.1 = -10%, 0 = flat. A 14d row compares the last 14 days with the 14 before them. It reads as \"+25% versus the previous 14 days\", not as \"17x growth\"." },
          "primary_game_name": { "type": ["string", "null"] }
        },
        "additionalProperties": true
      },
      "TrendingSection": {
        "type": "object",
        "description": "One follower size tier's worth of rising creators. Sections with no rows are omitted from the response.",
        "required": ["tier", "label", "band", "offset", "returned", "rows"],
        "properties": {
          "tier": { "type": "string", "enum": ["micro", "small", "mid", "large"] },
          "label": { "type": "string" },
          "band": { "type": "string", "description": "The follower range this tier covers, e.g. \"1K–10K followers\"." },
          "offset": { "type": "integer", "description": "Echo of the requested offset, within this tier." },
          "returned": { "type": "integer", "description": "Rows in this section. Less than `limit` means the tier is exhausted." },
          "rows": {
            "type": "array",
            "items": {
              "type": "object",
              "description": "A trending creator. Carries the same identity and metric fields as a search row, but with raw `computed_at` (not `metrics_computed_at`) and no `has_research`.",
              "required": ["id", "login", "display_name", "insufficient_data", "computed_at", "last_seen_at"],
              "properties": {
                "id": { "type": "string" },
                "login": { "type": "string" },
                "display_name": { "type": "string" },
                "computed_at": { "type": ["string", "null"], "format": "date-time", "description": "HONESTY FIELD — when the rollup last recomputed this row." },
                "last_seen_at": { "type": ["string", "null"], "format": "date-time", "description": "HONESTY FIELD — when we last saw this creator live." },
                "insufficient_data": { "type": "boolean", "description": "HONESTY FLAG — the window is not adequately covered." },
                "trending_score": { "type": ["number", "null"] },
                "category_relative_growth": { "type": ["number", "null"], "description": "Growth relative to the creator's category cohort — the signal that separates rising from merely large." },
                "trending_confidence": { "type": ["string", "null"], "enum": ["high", "medium", "low", null], "description": "Note this uses \"medium\"; research confidence uses \"med\"." },
                "size_tier": { "type": ["string", "null"] },
                "reasons": { "type": "array", "items": { "type": "string" }, "description": "Up to 4 machine-written explanations of why this row surfaced." },
                "series": {
                  "type": "array",
                  "description": "Up to 14 daily points. Empty if the series could not be built.",
                  "items": { "type": "object", "properties": { "t": { "type": "string" }, "value": { "type": "number" } } }
                }
              },
              "additionalProperties": true
            }
          }
        }
      },
      "ChannelProfile": {
        "type": "object",
        "description": "One creator's full profile.",
        "required": ["channel", "metrics", "latest", "followerTrend", "viewerTrend", "dataNote"],
        "properties": {
          "channel": {
            "type": "object",
            "description": "Identity and channel metadata.",
            "required": ["id", "login", "display_name"],
            "properties": {
              "id": { "type": "string" },
              "login": { "type": "string" },
              "display_name": { "type": "string" },
              "first_seen_at": { "type": ["string", "null"], "format": "date-time", "description": "HONESTY FIELD — when this creator entered our corpus. We have nothing before it." },
              "last_seen_at": { "type": ["string", "null"], "format": "date-time", "description": "HONESTY FIELD — when we last observed them live." }
            },
            "additionalProperties": true
          },
          "metrics": {
            "type": "array",
            "description": "One row per computed window. Only 3d/7d/14d exist.",
            "items": {
              "type": "object",
              "required": ["metric_window", "insufficient_data", "computed_at"],
              "properties": {
                "metric_window": { "type": "string", "enum": ["3d", "7d", "14d"] },
                "insufficient_data": { "type": "boolean", "description": "HONESTY FLAG, per window." },
                "computed_at": { "type": ["string", "null"], "format": "date-time", "description": "HONESTY FIELD, per window." }
              },
              "additionalProperties": true
            }
          },
          "latest": { "type": ["object", "null"], "description": "The most recent observed stream (title, game_name, tags, is_mature, viewer_count, stream_started_at, captured_at). Null if none has ever been captured.", "additionalProperties": true },
          "followerTrend": { "type": ["object", "null"], "description": "Null until we hold enough follower snapshots. { points, first, last, change }.", "additionalProperties": true },
          "viewerTrend": { "type": ["object", "null"], "description": "Null until at least 6 viewer snapshots exist — an honest null rather than a trend drawn through two points. { points, earlyAvg, recentAvg }.", "additionalProperties": true },
          "dataNote": { "type": "string", "description": "Plain-language caveat about trend strength. Worth surfacing verbatim to a human." }
        }
      },
      "ResearchPayload": {
        "description": "Discriminated on `coverage`. Read that field before anything else.",
        "oneOf": [
          {
            "type": "object",
            "title": "enriched",
            "description": "We hold research. This response is billed.",
            "required": ["coverage", "creator_id", "login", "completed_at", "research"],
            "properties": {
              "coverage": { "type": "string", "const": "enriched" },
              "creator_id": { "type": "string" },
              "login": { "type": "string" },
              "completed_at": { "type": ["string", "null"], "format": "date-time", "description": "HONESTY FIELD — the research reflects the web at this moment and is not refreshed automatically." },
              "research": { "$ref": "#/components/schemas/CreatorResearch" }
            }
          },
          {
            "type": "object",
            "title": "not_enriched",
            "description": "A real creator we hold no research for. HTTP 200 and REFUNDED — a coverage answer is not charged for.",
            "required": ["coverage", "creator_id", "enrich"],
            "properties": {
              "coverage": { "type": "string", "const": "not_enriched" },
              "creator_id": { "type": "string" },
              "enrich": {
                "type": "object",
                "description": "How to fill the gap yourself.",
                "properties": {
                  "how": { "type": "string" },
                  "price_class": { "type": "string" },
                  "note": { "type": "string" }
                }
              }
            }
          }
        ]
      },
      "CreatorResearch": {
        "type": "object",
        "description": "Off-platform research: independent web research about a creator, not Twitch-derived data. Includes personal data on a named individual — handle it accordingly.",
        "required": ["creator_id", "login", "summary", "interests", "other_channels", "topics", "audience", "engagement", "location", "brand_collaborations", "confidence", "sources", "notes"],
        "properties": {
          "creator_id": { "type": "string" },
          "login": { "type": "string" },
          "summary": { "type": "string" },
          "interests": { "type": "array", "items": { "type": "string" } },
          "topics": { "type": "array", "items": { "type": "string" } },
          "audience": { "type": "string" },
          "engagement": { "type": "string" },
          "location": { "type": ["string", "null"], "description": "The only real country/geo signal in the system — Twitch itself exposes none. Null when research could not establish it." },
          "other_channels": {
            "type": "array",
            "description": "Presences on other platforms.",
            "items": {
              "type": "object",
              "properties": {
                "platform": { "type": "string" },
                "handle": { "type": "string" },
                "url": { "type": "string" },
                "followers": { "type": ["integer", "null"] },
                "note": { "type": ["string", "null"] }
              }
            }
          },
          "brand_collaborations": {
            "type": "array",
            "description": "Always present, often empty. Each entry carries its source where one was found.",
            "items": {
              "type": "object",
              "properties": {
                "brand": { "type": "string" },
                "note": { "type": ["string", "null"] },
                "url": { "type": ["string", "null"] }
              }
            }
          },
          "brand_fit": { "type": "array", "items": { "type": "string" }, "description": "Omitted entirely when empty — check for the key before reading it." },
          "confidence": { "type": "string", "enum": ["high", "med", "low"], "description": "How much the research agent trusts its own findings. Note \"med\", not \"medium\"." },
          "sources": {
            "type": "array",
            "description": "Citations. Check these before acting on anything commercially material.",
            "items": { "type": "object", "properties": { "title": { "type": "string" }, "url": { "type": "string" } } }
          },
          "notes": { "type": ["string", "null"] }
        }
      },
      "ResearchJob": {
        "type": "object",
        "description": "The state of one creator's enrichment job, from POST /api/v1/research or GET /api/v1/research/{id}.",
        "required": ["status"],
        "properties": {
          "status": { "type": "string", "enum": ["running", "done", "error"] },
          "poll": { "type": "string", "description": "Present when running — the path to poll (at $0)." },
          "cached": { "type": "boolean", "description": "On POST, true means fresh research already existed and the call was refunded. Always false on the poll route." },
          "research": { "$ref": "#/components/schemas/CreatorResearch" },
          "completed_at": { "type": ["string", "null"], "format": "date-time", "description": "Present when status is done: when the research actually finished — what the dossier is current as of, and the closure a person needs." },
          "poll_every_s": { "type": "integer", "description": "Present when status is running: how often to poll, in seconds (15). A job takes 1-4 minutes; polls are free but polling faster does not finish it sooner." },
          "error": { "type": "string", "description": "Present when status is error." }
        }
      },
      "LiveOverview": {
        "type": "object",
        "description": "Point-in-time snapshot of the categories CreateWith polls — not the whole of Twitch.",
        "required": ["captured_at", "concurrent_viewers", "channels_live", "unique_games_live", "creators_tracked", "top_channels", "top_games", "top_clips"],
        "properties": {
          "captured_at": { "type": ["string", "null"], "format": "date-time", "description": "HONESTY FIELD — when this cycle was captured. Ingestion runs every ~10 minutes." },
          "concurrent_viewers": { "type": ["integer", "null"] },
          "channels_live": { "type": ["integer", "null"] },
          "unique_games_live": { "type": ["integer", "null"] },
          "creators_tracked": { "type": ["integer", "null"] },
          "top_channels": { "type": "array", "items": { "type": "object", "additionalProperties": true }, "description": "id, login, display_name, broadcaster_type, primary_language, viewer_count, game_name, title." },
          "top_games": { "type": "array", "items": { "type": "object", "additionalProperties": true }, "description": "game_id, name, viewers, channels." },
          "top_clips": { "type": "array", "items": { "type": "object", "additionalProperties": true }, "description": "id, view_count, created_at, channel_id, display_name, login, game_name." }
        }
      },
      "CorpusStats": {
        "type": "object",
        "description": "Totals for everything collected. Free and unmetered — size the corpus here before spending.",
        "required": ["creators_tracked", "snapshots_total", "follower_snapshots_total", "capture_cycles", "target_games", "collecting_since", "last_capture_at"],
        "properties": {
          "creators_tracked": { "type": ["integer", "null"] },
          "snapshots_total": { "type": ["integer", "null"], "description": "Hourly viewer snapshots — the time-series Twitch does not offer." },
          "follower_snapshots_total": { "type": ["integer", "null"] },
          "capture_cycles": { "type": ["integer", "null"] },
          "target_games": { "type": ["integer", "null"], "description": "Categories actively polled. Creators outside them are not tracked." },
          "collecting_since": { "type": ["string", "null"], "format": "date-time", "description": "HONESTY FIELD — when collection began (2026-06-08), the hard floor on our history. Nothing exists before this date. Not the same as oldest_retained_snapshot_at." },
          "oldest_retained_snapshot_at": { "type": ["string", "null"], "format": "date-time", "description": "Oldest RAW snapshot still on disk. Raw snapshots are pruned at 30 days after the metric windows are rolled up from them, so this rolls forward daily; computed metrics keep their history when it moves. NOT the history floor — collecting_since is." },
          "last_capture_at": { "type": ["string", "null"], "format": "date-time" },
          "by_broadcaster_type": { "$ref": "#/components/schemas/StatBuckets" },
          "by_language": { "$ref": "#/components/schemas/StatBuckets" },
          "by_game": { "$ref": "#/components/schemas/StatBuckets" },
        "enrichment": { "type": "object", "description": "Where the off-platform dossiers actually are — the free way to answer \"do you have coverage for X?\" before spending a search. `total` is the dossiers we hold; `by_language` is sorted by count descending and untruncated; `by_reach_band` always returns all seven bands in the fixed order <1K, 1–5K, 5–10K, 10–20K, 20–50K, 50–100K, 100K+ with explicit zeros, banded on the 14d follower_total, so two responses can be compared positionally and a 0 read as a real zero. OMITTED entirely rather than zeroed if it could not be computed, so its presence is the discriminator. Rides the same 5-minute CDN cache.", "required": ["total", "by_language", "by_reach_band"], "properties": { "total": { "type": "integer" }, "by_language": { "$ref": "#/components/schemas/StatBuckets" }, "by_reach_band": { "$ref": "#/components/schemas/StatBuckets" } } },
        "notes": { "type": "object", "description": "Caveats shipped in the body rather than left in the docs, because agents relay what the response carries. `by_game`: the target categories we poll for streams only, not a share of all creators, and not a basis for sizing a market. `enrichment`: by_language and by_reach_band count only dossier creators that also have a 14d metrics row, so they can sum to less than total, and a band's 0 is a real zero.", "additionalProperties": { "type": "string" } },
          "viewer_buckets": { "$ref": "#/components/schemas/StatBuckets" }
        }
      },
      "StatBuckets": {
        "type": "array",
        "items": {
          "type": "object",
          "required": ["key", "count"],
          "properties": { "key": { "type": "string" }, "count": { "type": "integer" } }
        }
      }
    }
  }
}
