# CreateWith — Twitch creator discovery API for agents CreateWith finds under-utilised and up-and-coming Twitch streamers for brands to partner with. Twitch's own API returns **point-in-time snapshots only** — there is no endpoint for a creator's history — so this service polls it and builds the time-series itself. Everything here is collected data: 566k+ channels (growing daily — `GET /api/v1/stats` has the live count), hourly snapshots since **2026-06-08**, plus off-platform research on ~1,800 of them. Nothing is backfilled. What was not observed is not held. All paths below are relative to this host. JSON in, JSON out. Status: **free beta**. Metering is live and every price is $0. **No key is needed to read or search.** A call with no `Authorization` header is served as a guest — same response, `meta.billing.lane: "guest"` — metered per IP against two daily caps. `POST /api/v1/research` is the one endpoint that requires a key. curl "https://createwith.dev/api/v1/creators/search?limit=3" `GET /api/v1` is free, needs no key, and lists every endpoint, its price class and worked examples as JSON. `/llms.txt` is the short version of this document. The homepage `/` is the agent-first landing page — a human-readable rendering of this same contract, not a dashboard. Nothing here needs a browser: the product is the API. `GET /api/v1/creators/search?…` (menu below) returns the same creators as JSON, and the interactive board a person drives is private — there is no public page. --- ## What a person can ask — and what each ask needs ### The menu The table below is what a person can ask for. Each row names the blanks that ask needs from them, one sentence a person might really say, the call that answers it, and that call with every blank filled in. An open-ended ask — "find me some creators", "who should we sponsor", "what's good on Twitch" — is the case this menu exists for: the rows are the search types that exist, and the *blanks* column is what is missing before any of them can run. A specific ask usually matches one row outright, and `meta.relay.recipe` on the response names which one it matched. | search type | blanks it needs | a person might say | the call | worked example | |---|---|---|---|---| | Rising in a game | game name · follower ceiling · language (optional) | "Find rising English-language VALORANT streamers under 10k followers" | `GET /api/v1/creators/search?sort=momentum_score&dir=desc&language=en&maxFollowers=10000&game=&hideInsufficient=true&minStreams=3` | `/api/v1/creators/search?sort=momentum_score&dir=desc&language=en&maxFollowers=10000&game=VALORANT&hideInsufficient=true&minStreams=3` | | Trending right now | size tier (micro/small/mid/large) · window (optional) | "Show me the small Twitch channels blowing up this week" | `GET /api/v1/trending?window=7d&tier=micro` | `/api/v1/trending?window=7d&tier=micro` | | Undervalued engagement | follower band (optional) · game or language (optional) | "Find creators whose live audience is unusually big for their follower count" | `GET /api/v1/creators/search?sort=viewer_to_follower_ratio&dir=desc&hideInsufficient=true&maxFollowers=` | `/api/v1/creators/search?sort=viewer_to_follower_ratio&dir=desc&hideInsufficient=true&minFollowers=1000&maxFollowers=20000&minStreams=3` | | Creator lookup | login or display name | "Tell me everything you know about the streamer quiggy_" | `GET /api/v1/creators/search?q= then GET /api/v1/creators/{id} with rows[0].id, then GET /api/v1/creators/{id}/research` | `/api/v1/creators/search?q=quiggy_` | | Off-platform interest | the interest or topic · reach band (optional) | "Find Twitch streamers who are also working musicians" | `GET /api/v1/creators/search?enrichedQuery=&hideInsufficient=true then GET /api/v1/creators/{id}/research to confirm the interest is in the dossier` | `/api/v1/creators/search?enrichedQuery=music&hideInsufficient=true` | | Market-targeted | market/country · game or interest (optional) | "Find German-speaking streamers for a campaign running in Germany" | `GET /api/v1/creators/search?language=&sort=momentum_score&dir=desc then GET /api/v1/creators/{id}/research and check location` | `/api/v1/creators/search?language=de&sort=momentum_score&dir=desc` | | Brand-safe shortlist | game or audience · reach band · shortlist size | "Build a brand-safe shortlist of Fortnite creators between 1k and 50k followers" | `GET /api/v1/creators/search?brandSafeOnly=true&game=&minFollowers=&maxFollowers=&hideInsufficient=true&sort=momentum_score&dir=desc then GET /api/v1/creators/{id}/research for the finalists` | `/api/v1/creators/search?brandSafeOnly=true&game=Fortnite&minFollowers=1000&maxFollowers=50000&hideInsufficient=true&sort=momentum_score&dir=desc` | | What's live now | nothing — the call takes no parameters | "What's happening on Twitch right now?" | `GET /api/v1/live` | `/api/v1/live` | Three things that are true of every row above: - **`brandSafeOnly=true` composes with all of them** — a filter, not a separate search. - **`minStreams=3` is evidence, not taste.** It drops channels that streamed once or twice in the window, which is how a single lucky night tops a momentum sort. `hideInsufficient=true` implies `minStreams=3` unless `minStreams` is set explicitly — and an explicit `minStreams=0` opts back out. - **`language` is a geography proxy, not a country.** Market-targeted returns candidates; the only real location signal is `location` inside a dossier. Per-recipe caveats, from the same source the index serves them from: - **Rising in a game.** minStreams=3 keeps one-off spikes out (hideInsufficient=true implies 3+ streams unless you set minStreams yourself); brandSafeOnly=true composes onto this call, as it does onto every recipe here; unsure the game is covered? GET /api/v1/categories?q= and GET /api/v1/stats are free. - **Creator lookup.** {id} is the NUMERIC Twitch user id read from rows[0].id of the search — never the login. No dossier? Commission one: POST /api/v1/research with {"creator_id":""} then poll GET /api/v1/research/{id} every 15s; a job takes 1-4 minutes. - **Off-platform interest.** enrichedQuery is full text over dossiers, so expect false positives — a dossier that merely MENTIONS the word matches, which is why the confirming GET /api/v1/creators/{id}/research is part of this recipe; coverage is partial (~1,800 dossiers), so a thin result means few are researched, not that none fit. `GET /api/v1` serves this menu as `recipes` (and the ask→call pairs as `example_asks`), from the same source. Where this file and the live index disagree, the index is the current one. ### Facts One line each; `GET /api/v1` serves the same block as `facts`, and the MCP mount returns them in its `initialize` instructions. - Status: free beta — metering is live, every price is $0 today, and no card is involved. - No key is needed to start — every read and search runs as a guest, cached research dossiers (GET /api/v1/creators/{id}/research) included, metered per IP; a free email-only key (POST /api/v1/keys with {"email":"you@example.com"}) keeps usage on one account. POST /api/v1/research — commissioning new research — is the one endpoint that requires a key. - A keyed account starts with 200 free read/search calls; only keyed metered calls draw on them — guest calls run against their own per-IP daily cap, and the free endpoints cost nothing to anyone. - Every number here is from snapshots we collected ourselves: collection began 2026-06-08, and nothing before that exists or can be backfilled. - Only the 3d/7d/14d metric windows are computed — 30d unlocks ~2026-09-06 and 90d ~2027-03-05. - Off-platform dossiers cover ~1,800 creators out of 566k+ tracked; GET /api/v1/stats is free and carries both live counts, plus where the dossiers are by language and reach band. ### What the menu is made of Four capabilities: the searches above, over 566k+ tracked channels (this is the product); one creator's full profile and every computed window; their cached off-platform dossier — other channels, real location, interests, prior brand deals with sources; and fresh research commissioned on a creator nobody has covered yet, async in minutes. ### Compound searches Combining filters — "FPS creators, UK, who also make music" — is supported, but it is several calls rather than one, and two of the filters a brief like that implies do not exist: **there is no genre filter and no location filter.** A genre is several `game` values resolved first; a country is a dossier check afterwards. Anything that promises either in a single call is guessing. Worked example — "UK-based FPS creators who also make music", about six calls: 1. **Resolve the genre, free.** `GET /api/v1/categories?q=fps` matches category *names*, so it returns things like "FPS Chess" — not the genre. The 2–4 titles actually meant have to be named and confirmed one at a time: `?q=valorant`, `?q=counter-strike`, `?q=apex`. Free too: `GET /api/v1/stats`, whose `enrichment` breakdown says whether dossiers exist for this slice at all — the cheapest way to learn that step 3 will come back empty. 2. **One search per resolved game.** `game` is a single case-insensitive substring, not a list, so this is 2–4 metered calls, not one: `GET /api/v1/creators/search?game=VALORANT&language=en&enrichedQuery=music&hideInsufficient=true&minStreams=3`. The rest of the filters stack here — the `min/max` bands, `brandSafeOnly` — and the pages merge client-side, deduped on `id`. 3. **Verify the finalists only.** `enrichedQuery` is full text over research dossiers and `language` is only a geography *proxy*, so for the handful being presented — rather than every row of every page — `GET /api/v1/creators/{id}/research` confirms the interest is really in `interests`/`topics` and the country really in `location`. **Full-text false positives are expected**: a dossier that merely mentions "music" matches. 4. **What the answer owes the person reading it.** Which games were swept, that "UK" came from dossiers rather than `language`, and how many candidates went unverified. Budget it: 2–4 searches plus one dossier per finalist. Reading a dossier for every row of every page is how a compound ask turns into fifty calls. **An empty enriched result is an answer, not a failure.** When `enrichedQuery`/`enrichedOnly` matches nothing, the response carries `data.coverage` (see the search endpoint below) and is **refunded**. Its `note` says the honest thing — "nobody has researched this slice yet", rather than "there are none". The recipes above are the supported way to combine filters — not the legacy UI routes, which are unversioned and uncontracted (see Envelope). ### What a response says about itself Every metered response carries a `meta.relay` block. It is not decoration: it is the caveat list for *that exact response*, composed from what the response actually contains. "relay": { "recipe": "rising-in-game", "recipe_source": "header", "window": "14d", "say": ["Rising in a game — 14d window: metrics are our own snapshots since 2026-06-08, recomputed daily.", "momentum_score is a 0–100 rank against peers, not a growth rate."] } - **`say`** holds at most four short lines, each one true of this response rather than a generic disclaimer, and written to be passed on as they are — verbatim or paraphrased. Empty means nothing about this response needed flagging. - **`recipe` and `window`** name the search that ran and the metric window its numbers came from: "the rising-in-a-game search, 14d window". `window` is `null` where a response is not window-scoped (a dossier, a research job), and `say[0]` already opens with the recipe's title and window. - **`recipe_source`** is `header` when the call carried `X-CreateWith-Recipe`, `inferred` when the path and parameters matched a recipe's signature, and `null` when neither — in which case the filters that were used are the honest description of what ran. The header is the reliable half: inference matches a recipe's *required* parameters, so an unusual combination can land on a looser recipe or on none. - **`meta.billing.status`** is `settled` or `refunded`, on every metered response. A coverage answer, a `not_enriched` dossier and a cached research POST are refunded — which of those happened is more informative than a price. It is absent on the free unmetered endpoints and on a 5xx body. - **`charged`** appears in `relay` on the two research routes only, counting the flow in units: `{ "enrich_job": 1, "polls": 0 }`. ### Presenting results A creator row is not a profile. It is **the metrics computed for one window out of the collected snapshots**, plus that creator's latest-stream context. Without the window, the number means nothing. Five fields carry most of the meaning; the rest is supporting detail. | field | what it is | |---|---| | `avg_viewers` | mean concurrent viewers across in-window snapshots while live | | `follower_total` | latest follower count observed. `follower_total_as_of` says when, and `follower_total_source` is `window` (this window computed it), `fallback` (carried from another window) or `none` (never observed, and the number is null) | | `viewer_to_follower_ratio` | the moneyball tell — watchers per follower. ~0.3–3% is typical; >5% is anomalously engaged | | `momentum_score` | 0–100 rank against peers, blending viewer growth (50%), follower-gain pace (30%) and category percentile (20%). A rank, not a rate | | `primary_game_name` | what they actually stream, from their latest stream | **`viewer_growth`**, the field most often misread, is a *signed ratio* against the prior window of the same length — not a percentage, not a multiplier. `0.25` = +25%, `-0.1` = −10%, `0` = flat; a `14d` row compares the last 14 days with the 14 before them. "+25% versus the previous 14 days" is the honest reading; "17× growth" is not — `17.37` would mean +1,737%, which on a low base is an artefact. The caveats that travel with a row: - **Which window.** Search defaults to `14d`, trending to `7d`. The same creator ranks differently at `3d`, so the window is part of the answer. - **`metrics_computed_at` vs `last_seen_at`.** The first is when the rollup last recomputed the row; the second is when that creator was last seen live. Neither is `meta.generated_at`, and neither is "now". - **`insufficient_data: true`.** That row's window is not adequately covered, so it is not a finding — `hideInsufficient=true` drops those rows, and a row kept anyway carries the flag with it. - **Enrichment coverage is partial.** ~1,800 creators out of 566k+. "No dossier" means nobody has researched them yet, rather than "nothing to find". - **Nothing predates 2026-06-08.** That is the day collection started. ### Limits of the data One line each; the reasoning is under Honesty semantics below. - **Subscriber counts, revenue, bits.** Not obtainable with a Twitch app token. This service holds none of them and estimates none of them. - **Audience demographics** — viewer age, gender, country. Not in Twitch's API at all, for anyone. - **Follower lists / who follows whom.** Twitch returns a `total` and no list. - **Chat volume or sentiment.** Not in the API this service polls. - **Any history before 2026-06-08.** There is no backfill and there never will be. - **The `30d` and `90d` windows do not exist yet.** They unlock ~2026-09-06 and ~2027-03-05. A request for one returns the 14d default, and `data.window` on the response says which window the numbers are actually from — so a 30d claim built on that response is a 14d number wearing a 30d label. - **Country from `language`.** Broadcaster language is a geography *proxy*. The only real location signal is `location` inside a research dossier. --- ## Auth **Optional on everything except `POST /api/v1/research`.** A call with no `Authorization` header runs as a **guest**: the same handler, the same rows, the same `meta.relay`, with `meta.billing.lane: "guest"`, `billed_micro: 0`, `free_calls_remaining: null`, and a `meta.account` block: "account": { "status": "guest", "note": "No key was sent; this call was free. …", "guest_calls_remaining_today": 297 } Guest calls are metered per IP per UTC day against two caps — **300 calls** and **2,000 rows**. Over either, `429 guest_limit` carries both counters, `resets_at`, a `Retry-After` and `upgrade.signup` (the same recipe the 402 carries). Same-origin page fetches do not spend the allowance. An `Idempotency-Key` on a guest call is accepted and does nothing — there is no ledger row to deduplicate against. A key changes three things: usage lands on one account, the daily IP caps stop applying, and `POST /api/v1/research` becomes callable. Authorization: Bearer tw_live_<32 chars> curl -X POST "https://createwith.dev/api/v1/keys" -H 'Content-Type: application/json' -d '{"email":"you@example.com"}' `/signup` is the same signup as a web page for a person; an agent does not need to open it. 201 returns `data.api_key` **once** — only a sha256 hash is stored, so it cannot be re-sent. An email that already has an account gets `409 email_taken`, whose body names the three ways forward: keep calling as a guest, ask the account's owner for the key, or use a different address. Signup is limited to 3 per IP per hour. The 201 body reports the starting balance as **`data.free_calls_remaining`** — the same name every envelope uses. `data.free_calls` remains as a deprecated duplicate for one release. **A key is worth persisting across sessions** — an env var (`CREATEWITH_API_KEY`), a file, a secrets store. Re-signing up per session burns the 3/IP/hour limit mid-task, splits the free tier across throwaway accounts, and once billing goes live it strands credits: **credits attach to the ACCOUNT**, and a freshly minted account has none. The signup response carries this as `data.persist`. For a stateless agent the key is the one piece of state worth keeping. New accounts start with **200 free read/search calls**. `meta.billing.free_calls_remaining` counts them down; `null` there means the free counter was not what paid for the call, **not** that it is unlimited — the commonest way to see `null` is an idempotent replay, see Idempotency below. `null` also appears on every **priced-class** response — `dossier`, `enrich_job` and `poll`. Those classes are not the free counter's lane: in the $0 beta they settle at zero cost without decrementing it, so there is no new number to report. Not unlimited, not an error, and not a balance — the last non-null figure seen, with when it was seen, is the honest report. A **malformed, unknown or revoked** key returns **401 `invalid_api_key`**: a wrong key is a bug, not a guest, so it is never silently downgraded to the keyless lane. A **402** on a read class now means only one thing — a keyed account out of credit (`reason: "insufficient"`). `POST /api/v1/research` is the one operation that returns `reason: "no_key"` to a keyless caller. `extra.api_key_lane` carries the whole way in, so a 402 needs no follow-up fetch: `signup` (`{ method: "POST", url: "/api/v1/keys", body: { email, name }, note }`), `human_url` (`/signup`, the web page for a person), `docs` (`/llms.txt`) and `index` (`/api/v1`). `signup_url` survives for one release as a deprecated alias of `human_url` — it was never the API route. ## Prices Every price is **$0 for the whole beta**. The numbers below are what each class is expected to cost when billing turns on, and `meta.billing.price_class` already reports the class today — the number a response carries is the authority. | class | beta | future price | what it covers | |---|---|---|---| | read | $0 | $0.001 | one indexed row/board read | | search | $0 | $0.005 | one filtered search over the corpus | | dossier | $0 | $0.05 | cached off-platform research for one creator | | enrich_job | $0 | $0.50 | commissioning fresh research on one creator | | poll | $0 | $0 (always) | polling a running job — free by design, so backing off politely costs nothing | A guest call is `billed_micro: 0` in every class and draws on no account balance; what it draws on is the per-IP daily allowance under Auth. ## Rate limit Two different limits, two different remedies. **Keyed: 60 requests per minute per account**, enforced in the same transaction as the debit. Over it: `429 rate_limited` with `error.limit_per_min`. This is per account, not per key or per IP. REST and MCP share the counter and return the same four headers: | header | value | meaning | |---|---|---| | `Retry-After` | `60` | **the actionable one.** Backing off this long is guaranteed under the cap | | `RateLimit-Limit` | `60` | the ceiling | | `RateLimit-Remaining` | `0` | none right now | | `RateLimit-Reset` | `60` | the longest a rolling window can take to clear | ⚠️ `RateLimit-Remaining` and `RateLimit-Reset` are **bounds, not live counters**. Enforcement is a rolling 60-second count in SQL, so there is no live remaining figure to publish; both values are true of a caller that has just been refused, and neither ticks. They appear on 429s only — there is no live budget on a 200. `Retry-After` is the field to pace off, rather than arithmetic on `Remaining`. **Keyless: 300 calls and 2,000 rows per IP per UTC day.** Over either, `429 guest_limit` — a different code because it has a different remedy. Its body carries `calls_today`, `rows_today`, both caps, `resets_at` (the next UTC midnight), a `Retry-After` derived from it, and `upgrade.signup`: the one POST that lifts the cap immediately. The per-minute limit still applies underneath. ## Idempotency Send `Idempotency-Key: ` (max 128 chars; longer is rejected with `400 invalid_idempotency_key`). Retrying with the same key never double-charges. Three things to internalise: 1. **The key is unique per ACCOUNT across ALL endpoints.** A second, *different* call carrying a key already used is served, but silently billed as a duplicate of the first — so one key per logical call. 2. Idempotency here means "never double-CHARGE", rather than "replay the stored body". No response bodies are stored, so a retry recomputes and may return fresher data than the first attempt did — for free. 3. ⚠️ **An idempotent replay returns `meta.billing.free_calls_remaining: null`.** That field is populated only when the free counter is what paid for the call. A replay charges nothing, so nothing was decremented, so there is no new number to report — `null`, *not* a current balance, and *not* the balance the first attempt reported. **`null` never means unlimited.** It reads as "this call did not move the free counter". Budget tracking carries forward the last non-null value seen. The same `null` appears on every unmetered response (`/api/v1`, `/api`, `/api/v1/stats`, `/api/v1/categories`), on every priced-class one (`dossier`, `enrich_job`, `poll` — see Auth) and on every guest response, for the same reason: the free counter was not the payer. The one place a number appears without a debit is the signup response, which reports the starting 200 as `data.free_calls_remaining`. On a guest call an `Idempotency-Key` is accepted and inert — there is no ledger row to deduplicate against, and nothing was charged to begin with. ## Envelope Success: { "data": , "meta": { "request_id": "...", "generated_at": "", "billing": { "price_class": "search", "billed_micro": 0, "lane": "guest", "free_calls_remaining": null, "status": "settled" }, "account": { "status": "guest", "note": "...", "guest_calls_remaining_today": 297 }, "relay": { "recipe": "...", "recipe_source": "...", "window": "14d", "say": ["..."] }, "freshness": "..." } } `meta.freshness` is present only where rolled-up or cached data is served (see the catalog). `billed_micro` is USD micro-dollars — 1,000,000 = $1. `meta.billing.lane` is `guest` for a keyless call, `free` while an account's free tier is paying, `credits` after that; `x402` is reserved and is never returned today. `meta.billing.status` is `settled` or `refunded` — the one field that says whether a 200 cost anything. `meta.account` appears on guest responses only. `meta.relay` is on every metered response and is described under "What a response says about itself". Error (every status except 402): { "error": { "code": "creator_not_found", "message": "...", "request_id": "...", "docs": "/llms.txt" } } `code` is the stable half to branch on; `message` is for logs and may be reworded. `X-Request-Id` is on every response. Keyed responses are `Cache-Control: private, no-store`; guest responses on `/api/v1/trending` and `/api/v1/live` are CDN-cacheable, with `Vary: Authorization` keeping the two apart. `error.docs` is on **every** error, so a caller that has only ever seen a failure still holds a pointer to the documented surface. It is a string (`"/llms.txt"`) everywhere except the unknown-path 404, which upgrades it to an object of three pointers (`index`, `llms`, `openapi`) — worth a type check before dereferencing. A call that is running one of the menu's recipes can say so with `X-CreateWith-Recipe: ` — the recipe's `id` in `GET /api/v1`'s `recipes` list, e.g. `rising-in-game` or `brand-safe-shortlist`. It is optional, free, and it makes `meta.relay.recipe_source` `header` rather than `inferred`; unknown values are recorded as `other`. It changes neither the response nor the price. Legacy UI routes (`/api/discover`, `/api/stats`, `/api/trending`, `/api/live`, `/api/research`, `/api/agent`, `/api/enriched`) predate this contract and are not part of it — unversioned, unmetered, no envelope, no `meta.relay`. They carry an `X-Api-Docs: /llms.txt` header pointing back here. The versioned equivalents are in the endpoint list below. ## Refunds - **5xx is auto-refunded.** A failure on this side is never the caller's problem. The 500 body is deliberately opaque (`internal_error`) — the `request_id` is what identifies it in the ledger. - **An MCP tool returning `isError` is auto-refunded.** - **4xx is NOT refunded by default** — a bad parameter still consumed a metered call. Specific endpoints refund specific 4xx cases anyway; they are marked below. - Some **200s are refunded**: a coverage answer or a queue rejection is not something worth charging for. Also marked below. - **A guest call is never charged in the first place**, so refunds are a keyed concept; `meta.billing.status` still reports `settled`/`refunded` so the two lanes read the same way. --- ## Endpoints - `GET /api/v1` — free, no key. This index: every endpoint, its price class, and example calls. - `GET /api/v1/creators/search` — class `search`. Filtered creator search, ≤25 rows/page (~20 filter params: window 3d/7d/14d, q, game, tag, language, broadcaster_type, min/maxAvgViewers, min/maxFollowers, minMomentum, minViewerGrowth, brandSafeOnly, enrichedOnly, enrichedQuery, sort, dir, limit, offset). viewer_growth is a signed ratio: 0.25 means +25% average viewers vs the prior window. - `GET /api/v1/creators/{id}` — class `read`. One creator's profile + metrics across all windows + trends (id = Twitch user id; resolve a login via search?q=). - `GET /api/v1/creators/{id}/research` — class `dossier`. Cached off-platform research dossier; coverage-aware (refunded when the creator is not enriched). - `GET /api/v1/trending` — class `read`. Disproportionately-rising creators by size tier (micro/small/mid/large), paginated per tier. - `GET /api/v1/live` — class `read`. Live platform overview snapshot. - `GET /api/v1/stats` — free, no key. Corpus shape: what's tracked and how values are distributed — call this before filtering. - `GET /api/v1/categories` — free, no key. Resolve a topic/game phrase to the category ids we actually track. - `POST /api/v1/research` — class `enrich_job`. Commission fresh off-platform research for one creator; returns a poll URL. - `GET /api/v1/research/{id}` — class `poll`. Poll a research job by creator id (polling advances the job; $0). - `POST /api/v1/keys` — free, no key. Self-serve signup: email in, API key + quickstart out (3/hour/IP). ### GET /api/v1 — FREE, no key — **start here** The self-describing index. One call, no credentials, and the whole surface is in hand: `name`, `description`, `facts`, `relay_note`, `docs` (this file, `/openapi.json`, `/signup`), `auth` (scheme, the keyless rule, the guest caps, free-tier size, rate limit), `endpoints` (every path with its price class and a one-line description), `mcp` (mount URL, transport, tool list, and the `Accept`-header warning), `recipes` (the menu above, in full — `id`, `title`, `one_liner`, `human_provides`, `example_ask`, `human_prompt`, `required_params`, `optional_params`, `call`, `returns`, `notes`), `example_asks` (that menu projected to ask → call pairs) and `quickstart` (copy-pasteable curls against the caller's own origin: `try_it` — a keyless search that returns creators — `keep_your_usage_together`, `then_store_it`, `call_with_a_key`). CDN-cached one hour (`public, s-maxage=3600, stale-while-revalidate=86400, stale-if-error=86400`). It changes only when an endpoint ships. ### GET /api — FREE, no key A signpost: `{ name, index: "/api/v1", docs }`. Nothing else lives here — the catalog is at `/api/v1` and is not duplicated. ### Unknown /api/* paths → JSON 404 Any path under `/api/` that matches no route returns a **JSON** 404 on **every** method, rather than an HTML page: { "error": { "code": "not_found", "message": "No route at /api/. GET /api/v1 lists every endpoint.", "request_id": "...", "docs": { "index": "/api/v1", "llms": "/llms.txt", "openapi": "/openapi.json" } } } So a guessed path costs one parse and hands back the index. A **405** means the opposite — that route exists, the method is wrong. ### GET /api/v1/creators/search — class `search` The main discovery surface. Filtered, sorted, paginated board of tracked creators. | param | default | notes | |---|---|---| | `window` | `14d` | `3d`, `7d`, `14d` only. Nothing else exists — see Honesty | | `sort` | `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` | | `dir` | `desc` | `asc` / `desc` | | `limit` | 25 | **hard cap 25 rows.** Higher values clamp silently | | `offset` | 0 | **max 500.** Refining the filters beats paging deep | | `active_within_days` | **14** | only creators seen live within N days (max 365) | | `include_dormant` | `false` | `true` disables the staleness filter entirely; wins over `active_within_days` | | `q` | — | login / display-name match | | `game` | — | category **name**, case-insensitive substring. Not an id — `/api/v1/categories` finds the exact name | | `tag` | — | exact match against the channel's latest stream tags | | `language` | — | Twitch broadcaster language code, e.g. `en` | | `broadcaster_type` | — | `partner`, `affiliate`, or `""` for neither | | `minAvgViewers`, `maxAvgViewers`, `minFollowers`, `maxFollowers` | — | integers | | `minMomentum`, `minViewerGrowth` | — | floats, may be negative | | `hideInsufficient` | `false` | `true` drops rows flagged `insufficient_data` **and** applies `minStreams=3`, unless `minStreams` is set explicitly | | `minStreams` | `0` | integer, clamped to 0–1000; 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. An explicit value always wins, including `0` | | `brandSafeOnly` | `false` | excludes content-labelled channels | | `enrichedOnly` | `false` | only creators research is held for. Searches **every** completed dossier, and its `total` is exact | | `enrichedQuery` | — | full-text search across that research | `viewer_growth` — a `sort` key above, a row field below, and the filter behind `minViewerGrowth` — is a **signed ratio** against the prior window of the same length: `0.25` is +25% average viewers, `-0.1` is −10%, `0` is flat. Not a percentage and not a multiplier; the long reading is under Presenting results. Unrecognised values fall back to the default rather than erroring. `campaign` is **not** an exposed parameter — it is parsed and discarded. `data`: `{ rows, total, totalIsEstimate, totalNote, window }`. `total` is a query-planner **estimate** whenever `totalIsEstimate: true` — a magnitude, not a count. It is `null` under a `game`/`tag` filter, where the planner has no usable estimate and a made-up number would be worse than none: **`null` means unknown, rather than zero**, and `totalNote` says so in words. `enrichedOnly`/`enrichedQuery` searches are the exception: they run against an id set, so their `total` is an **exact** count with `totalIsEstimate: false`. Otherwise `total` does not support a claim of the form "there are N creators". When an `enrichedOnly`/`enrichedQuery` search matches nothing, `data.coverage` explains the emptiness and the call is refunded (`meta.billing.status: "refunded"`): "coverage": { "enriched_in_scope": 1789, "enriched_total": 1789, "note": "Nobody has researched this slice yet — missing coverage, not proof that no such creator exists. …", "enrich": { "how": "POST /api/v1/research {\"creator_id\":\"\"}", "price_class": "enrich_job", "note": "async, minutes" } } `enriched_in_scope` equals `enriched_total` when nothing but the enrichment filter narrowed the search; add any other filter (`game`, `language`, a band, `hideInsufficient`) and it is **`null`**, because counting inside those filters costs a second query an empty answer does not justify — the `note` says so. Both are `null` if the count was unavailable. **`null` = "not counted", rather than zero.** `coverage` is absent when the search was not enrichment-scoped, and absent (and charged) when a positive exact `total` with zero rows just means the caller paged past the end. Each row carries identity (`id`, `login`, `display_name`, `broadcaster_type`, `primary_language`, `description`, `twitch_created_at`), the window's metrics (`avg_viewers`, `peak_viewers`, `stream_count`, `follower_total`, `followers_gained`, `viewer_to_follower_ratio`, `peak_to_avg_ratio`, `category_percentile`, `follower_gain_velocity`, `viewer_growth`, `viewer_acceleration`, `momentum_score`), latest-stream context (`primary_game_name`, `latest_title`, `latest_tags`, `latest_is_mature`, `latest_stream_started_at`, `is_branded_content`, `content_labels`), and the honesty fields `metrics_computed_at`, `last_seen_at`, `insufficient_data`, `has_research`, `follower_total_as_of` and `follower_total_source`. Nulls are real: a null metric means the value was not computed, rather than zero. `follower_total` is the exception — where a follower count has ever been observed it is carried in, and `follower_total_source: "fallback"` says it came from another window, with `follower_total_as_of` for when. ### GET /api/v1/creators/{id} — class `read` One creator's full profile. **`{id}` is a Twitch USER ID, not a login.** There is no login lookup on this route — `/api/v1/creators/search?q=` resolves one, and `rows[0].id` is the id. `data`: `{ channel, metrics, latest, followerTrend, viewerTrend, dataNote }`. `metrics` is one row per computed window (each with its own `computed_at` and `insufficient_data`). `viewerTrend` is `null` until at least 6 snapshots exist. `meta.relay.window` names the mature window the headline metrics come from. `404 creator_not_found` **is billed** — the lookup happened. `400 invalid_creator_id` comes back if the id contains nothing usable. ### GET /api/v1/creators/{id}/research — class `dossier` Cached off-platform research: other channels and handles with follower counts, real geographic `location` (the only country signal in the system), `interests`, `topics`, `audience`, `engagement`, `brand_collaborations` with source URLs, `confidence` (`high`/`med`/`low`) and `sources`. `data.coverage` discriminates: - `"enriched"` → `{ coverage, creator_id, login, completed_at, research }`. Billed. - `"not_enriched"` → `{ coverage, creator_id, enrich: { how, price_class, note } }`. **200 and refunded.** Being told nothing is held is not a chargeable answer. Coverage is partial — ~1,800 creators out of 566k+. `not_enriched` is the honest answer, and the fix is an `enrich_job` (below). `404 creator_not_found` is refunded. `meta.freshness` on the enriched path says what it is: research reflects the web at `completed_at` and is never refreshed automatically. ### GET /api/v1/trending — class `read` Creators rising disproportionately fast, in four follower size tiers. | param | default | notes | |---|---|---| | `window` | `7d` | `3d`, `7d`, `14d` | | `tier` | all four | `micro` (<1K followers), `small` (1K–10K), `mid` (10K–100K), `large` (100K+) | | `limit` | 10 | max 25 — **per tier** | | `offset` | 0 | max 500 — **per tier** | | `campaign` | — | honored here (unlike search) | **Paging is per tier.** With no `tier`, `offset` skips rows inside *every* section at once, which is almost never the intent. One band at a time: `?tier=micro&offset=25`. Each section echoes `offset` and `returned`; `returned < limit` means that tier is exhausted. Empty sections are omitted. Rows add `trending_score`, `category_relative_growth`, `trending_confidence` (`high`/`medium`/`low`), `size_tier`, category context, machine-written `reasons`, and a 14-point daily `series`. Trending rows carry raw `computed_at`, rather than `metrics_computed_at`. A guest response here is CDN-cacheable for 60 seconds. ### GET /api/v1/live — class `read` Right-now snapshot of the polled categories: `captured_at`, `concurrent_viewers`, `channels_live`, `unique_games_live`, `creators_tracked`, `top_channels`, `top_games`, `top_clips`. No parameters. No `meta.freshness` — the payload timestamps itself. A guest response is CDN-cacheable for 60 seconds. ### GET /api/v1/stats — FREE, no key Corpus totals: `creators_tracked`, `snapshots_total`, `follower_snapshots_total`, `capture_cycles`, `target_games`, `collecting_since`, `oldest_retained_snapshot_at`, `last_capture_at`, and breakdowns by broadcaster type / language / game / viewer bucket. CDN-cached 5 minutes. It sizes the corpus before anything is spent. **`enrichment` — where the dossiers actually are.** The free way to answer "is there coverage for X?" before spending a search on it: "enrichment": { "total": 1789, "by_language": [ { "key": "en", "count": 1526 }, … ], "by_reach_band": [ { "key": "<1K", "count": … }, … ] } `by_language` is untruncated, sorted by count descending. `by_reach_band` always returns all seven bands in this order — `<1K`, `1–5K`, `5–10K`, `10–20K`, `20–50K`, `50–100K`, `100K+` — with explicit zeros, banded on the 14d `follower_total`, so a `0` is a real zero rather than an absence. The block is **omitted** rather than zeroed if it could not be computed, so its presence is the thing to branch on. **`notes`** ships two caveats in the body: `by_game` is the polled categories, **not** a share of all creators (it does not size a market), and the `enrichment` buckets count only dossier creators that also have a 14d metrics row, so they can sum to **less than** `total`. Two dates that are not the same thing: `collecting_since` is when collection began (**2026-06-08**, the history floor). `oldest_retained_snapshot_at` is the oldest RAW snapshot still on disk — raw snapshots are pruned at 30 days after the computed windows are rolled up from them, so it rolls forward daily. The metrics do not lose history when raw snapshots are pruned. ### GET /api/v1/categories — FREE, no key Resolve a game name to a Twitch `game_id`. `?q=&limit=<1..100>` (default 25). `data`: `{ query, returned, categories }`, each `{ id, name, is_target }`. CDN-cached 1 hour. **`is_target` is a collection fact, not a quality signal.** `true` means that category is polled, so creators are discovered there continuously; `false` means the row is a name-to-id resolution and nothing more. It does **not** mean no creators are held there — 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. `is_target` rows sort first. ### POST /api/v1/research — class `enrich_job` — **the one endpoint that needs a key** Commission fresh off-platform research. A keyless call gets `402` with `reason: "no_key"` and the signup recipe inline: this opens a real research session that costs real money on this side, which is why it is the single exception to the keyless lane. **One creator per call:** {"creator_id": "123456789"} Enrichment takes minutes and is **always async** — there is no synchronous variant. Responses: - `{ status: "running", poll: "/api/v1/research/", poll_every_s: 15 }` — a session was opened. **This is the only outcome that is billed.** - `{ status: "done", cached: true, research: {...}, completed_at }` — fresh research was already held. Refunded. - `{ status: "running", ... }` for a job already running for that creator. Refunded. - `{ status: "error", error: "..." }` — it could not be started. 200, refunded. **Cadence: one poll every 15 seconds; a job takes 1–4 minutes end to end.** Polls are free, and polling faster does not make the research finish sooner. A `running` body carries `poll_every_s: 15`, and the cache-hit branch (`done`, `cached: true`) is refunded — `meta.relay.charged` is `{ "enrich_job": 0, "polls": 0 }` and `meta.billing.status` is `refunded`. Gates, all refunded: `400 invalid_request`, `404 creator_not_found`, `429 too_many_inflight_jobs` (**max 2 in flight per account**, `Retry-After: 60`), `429 daily_capacity_reached` (global daily budget, `Retry-After: 3600`). ### GET /api/v1/research/{id} — class `poll`, always $0 Poll a job. `{id}` is the **creator id** that was POSTed, not a job id. Returns `{ status: "running" | "done" | "error", ... }`; `done` carries the research. `404 no_research_job` if no job was ever started. Never charged, on any branch, and keyless like every other read. The cadence and the cost do not have to be reconstructed. A `running` body carries `poll_every_s: 15`. A `done` body carries `completed_at` — when the research actually finished, i.e. what the dossier is current as of — and `meta.relay.charged`, `{ "enrich_job": 1, "polls": 0 }`: one job, no charge for any of the polls. That pair is the closure: what it cost, and when the answer was true. ### POST /api/v1/keys — FREE, no key Signup, described under Auth. Not metered. --- ## MCP POST /api/v1/mcp Stateless JSON-RPC over Streamable HTTP (streamable-http (stateless, POST only)). Same account, same ledger, same prices as REST — **and the same keyless lane**: `initialize`, `tools/list` and `ping` need no key and do not touch the guest daily cap, and a keyless `tools/call` runs as a guest exactly as over REST. **Every request sends:** Accept: application/json, text/event-stream Content-Type: application/json ⚠️ **Without that exact `Accept` header the transport returns 406 — AND a 4xx from the transport is NOT refunded.** MCP client libraries set it automatically; raw HTTP callers have to. The same applies to a wrong `Content-Type` (415) and to malformed JSON-RPC. **One `tools/call` per request.** A batch containing two calls is rejected with `400 batch_not_supported`: two calls have no single price, no single idempotency identity and no unambiguous refund. `initialize` returns `instructions` — the same menu, facts and relay note this document opens with, so an MCP client that never fetches a URL still has the contract. `9` tools, billed per call by class: | tool | class | |---|---| | `search_creators` | search | | `search_enriched_creators` | search | | `get_channel_profile` | read | | `get_trending` | read | | `get_live` | read | | `get_overview_stats` | free | | `list_categories` | free | | `list_recipes` | free | | `present_shortlist` | free | Every tool result ends with a `relay:` block carrying the recipe, the window and the `say` lines the REST envelope carries in `meta.relay`. A tool that answers `isError` is refunded. `Idempotency-Key` works as it does on REST; the JSON-RPC message id is a poor choice for it, because clients restart those at 1 per connection and unrelated calls would collide. MCP has no envelope for `meta.billing`, so per-call billing is not visible in the tool response — the `relay:` block and this document are where that meaning lives. --- ## Honesty semantics — the ground under every number - **There is no history Twitch did not expose.** Collection began 2026-06-08. Metrics get stronger with calendar time; they are not backfillable. - **Only `3d`, `7d`, `14d` windows exist.** A metric window compares three consecutive periods, so it needs 3× its own length of collected history before it means anything. **30d unlocks ~2026-09-06; 90d ~2027-03-05.** A request for either today returns the default window rather than an error, and `data.window` says which one. - **`insufficient_data: true` means the row's window is not adequately covered.** `hideInsufficient=true` filters those rows out. - **`metrics_computed_at` and `last_seen_at` are per row, and they differ.** The first is when the rollup last recomputed; the second is when that creator was last seen live. Neither is `meta.generated_at`. - **Refresh cadence is not uniform**: 3d/7d roll up hourly, 14d **daily** (so up to 24h stale), and `follower_total` for long-tail channels can be weeks stale — the poller runs 300/hr against 566k+ channels. - **Raw snapshots are pruned at 30 days; computed metrics survive.** The metric windows are rolled up from raw snapshots before pruning, so history is not lost — but `oldest_retained_snapshot_at` in `/api/v1/stats` (the oldest raw snapshot on disk) rolls forward daily and is NOT the history floor. `collecting_since` (2026-06-08) is. - **Dormant creators are excluded by default** (`active_within_days=14`). Their metrics are frozen at whenever they stopped streaming; without the filter they sort alongside live creators and look identical. `include_dormant=true` brings them back, and totals roughly double on the 3d window. - **A null metric is "not computed", rather than zero.** - **These composites do not exist and are NULL in 100% of rows**, whatever any older spec says: `undervalued_index`, `engagement_score`, `reliability_score`, `consistency_score`, `brand_fit_score`, hours watched/streamed. - **Enrichment coverage is partial** — ~1,800 creators. `coverage: "not_enriched"` is the honest answer, rather than a failure. - **Subscriber counts, revenue, follower lists, demographics and chat sentiment are not available from Twitch with an app token.** They are not held here and are not estimated. ## Support `request_id` from `meta` or `X-Request-Id` is what identifies a call in the ledger. Every response carries a `Link` header naming the documents worth following: this file's short form (`rel="service-doc"`), `/api/v1` (`rel="service-desc"`), `/llms.txt` (`rel="llms-txt"`), `/skill.md` (`rel="agent-skills"`) and the MCP server card (`rel="mcp-server-card"`). The ~78 KB `/openapi.json` is deliberately **not** in that header — it stays reachable from ``, `/robots.txt`, the sitemap and the index's own `docs`. Also served: `/skill.md` (this contract as an Agent Skill), `/.well-known/skills/index.json`, `/.well-known/ai-catalog.json` and `/.well-known/mcp/server-card.json`. Human-readable version of this contract: the homepage `/` — same menu, same caveats, written for a person. A non-browser request to `/` is answered with this contract or with `GET /api/v1`, according to its `Accept` header. The discover board a person drives is private; an agent gets the same rows from `GET /api/v1/creators/search`.