Public HTTP API
Cosmetics API
Query a Twitch user's applied StreamNook cosmetics from your own app. Public, keyless, CORS-open.
StreamNook publishes its cosmetic layer so other chat clients and browser extensions can render a StreamNook member's applied look alongside their own cosmetics, the same way StreamNook renders 7TV, BetterTTV, FrankerFaceZ and Chatterino badges.
It covers everything a member can have applied, not just badges. Every endpoint is public, keyless and CORS-open, with no registration and no API key, just a generous per-IP rate limit described under Caching and etiquette.
Info
Users are addressed by Twitch numeric user id, as a string. There is no login-name lookup here; resolve the name to an id with your own source first.
Base URL
https://streamnook.app/api/v1/cosmeticsHow it works
Two integration styles. If all you want is badges, take the second.
Look people up as you meet them
GET /catalog once for the art, then GET /users?ids= for up to 200 ids at
a time as chatters appear. Covers every cosmetic kind, not just badges, and
is current within 60 seconds.
Or hold one file and never ask again
GET /badges/users is every badge and every member who holds one, in a
single response you re-fetch every few minutes. Badges only, and no
per-chatter traffic at all.
The first is the same split FrankerFaceZ uses, and it is why a user lookup is small: a badge twenty people wear is described once in the catalog, not twenty times in your response. The second is the shape Chatty's badge file uses, and it is the better fit when you would rather not talk to us per chatter.
Note
Only /badges/users lists people in bulk, and it lists them because every id in
it is already rendering a StreamNook badge on every message it sends. The other
endpoints answer about ids you already have. Member numbers are never in a bulk
response; they come from /user/:id.
Narrowing with a kind filter
Every endpoint accepts ?kind=, a comma-separated list. It filters the catalog
listing, and a member's equipped map and owned list alike.
curl "https://streamnook.app/api/v1/cosmetics/catalog?kind=badge,atmosphere"
curl "https://streamnook.app/api/v1/cosmetics/user/249031143?kind=badge"GET /badges is a shorthand for GET /catalog?kind=badge.
What a member can have applied
A member equips at most one cosmetic per slot. The slots are independent, so a member can wear a badge and an atmosphere at the same time.
| Kind | Slot | What it is | Renders on |
|---|---|---|---|
badge | badge | A small square mark next to the name. | chat, profile |
atmosphere | atmosphere | A colour wash. In chat it tints the message row; on a profile it fills the card. | chat, profile |
frame | frame | A nine-slice border around the profile hero. | profile |
relic | relic_primary | An earned artifact, shown as a featured medallion. | profile |
Every cosmetic carries its own kind, slot and surfaces array, so you do
not have to hardcode this table.
Warning
Do not put a frame or a relic in a chat line. They are large profile
furniture and will look wrong inline. Check surfaces for "chat", or just
pass ?kind=badge.
GET /catalog
Every cosmetic, with no user data at all.
curl https://streamnook.app/api/v1/cosmetics/catalog{
"cosmetics": [
{
"slug": "streamnook-lumen",
"name": "Lumen",
"description": "The light itself. The Subscriber badge of the Prism set.",
"kind": "badge",
"slot": "badge",
"image_url": "https://cdn.streamnook.app/badges/streamnook-lumen.webp",
"animated": false,
"surfaces": ["chat", "profile"]
},
{
"slug": "dispersion",
"name": "Dispersion",
"description": null,
"kind": "atmosphere",
"slot": "atmosphere",
"image_url": "https://cdn.streamnook.app/atmospheres/dispersion.webp",
"animated": true,
"surfaces": ["chat", "profile"],
"atmosphere": {
"base_color": "#050507",
"base_layers": null,
"image": "https://cdn.streamnook.app/atmospheres/dispersion.webp",
"layers": null,
"layers2": null,
"chat_edge": "none",
"chat_frost": false,
"chat_blur": 14,
"chat_rim": "linear-gradient(112deg, rgba(110,215,245,0.04) 0%, ...)",
"accent": "246, 240, 225",
"swatch": "url(https://cdn.streamnook.app/atmospheres/dispersion.webp) center / cover",
"motion": "aurora",
"renderer": null
}
}
],
"count": 14,
"generated_at": "2026-09-04T20:24:57.397Z"
}Cached one hour at the edge.
GET /users?ids=
The batch lookup, up to 200 ids per call. This is the endpoint you will call most.
curl "https://streamnook.app/api/v1/cosmetics/users?ids=249031143,71092938&kind=badge"{
"users": {
"249031143": {
"twitch_user_id": "249031143",
"member": true,
"member_number": 1,
"equipped": { "badge": "streamnook-lumen", "atmosphere": "dispersion" },
"badge": "streamnook-lumen",
"owned": ["streamnook-default", "streamnook-lumen", "dispersion"]
},
"71092938": {
"twitch_user_id": "71092938",
"member": false,
"member_number": null,
"equipped": {},
"badge": null,
"owned": []
}
},
"count": 2,
"max_batch": 200,
"generated_at": "2026-09-04T20:24:57.397Z"
}equipped maps a slot to a cosmetic slug, which you look up in the catalog.
badge is a shorthand for equipped.badge, which is all a chat renderer needs.
badge is never null for a member, and neither is equipped.badge. A member
who has never opened the picker falls back to the default StreamNook mark,
which is exactly what the app draws for them, so you do not need a fallback of
your own. It always matches users[id].applied in
/badges/users and is always one of that member's owned.
Every other slot is simply absent when the member has not filled it.
Every id you ask for comes back, members and non-members alike, so you never have to diff what you sent against what you got. Ids are deduped and sorted before they become a cache key, so asking for the same set in a different order hits the same cached entry.
Add ?expand=1 and the response carries a cosmetics map of the definitions it
references, built once for the whole response rather than repeated per user. Use
it if you would rather not hold the catalog yourself.
GET /user/:id
The single-user form. It expands by default, so one call is enough to render without also holding the catalog.
curl https://streamnook.app/api/v1/cosmetics/user/249031143{
"twitch_user_id": "249031143",
"member": true,
"member_number": 1,
"equipped": { "badge": "streamnook-lumen", "atmosphere": "dispersion" },
"badge": "streamnook-lumen",
"owned": ["streamnook-default", "streamnook-lumen", "dispersion"],
"cosmetics": {
"streamnook-default": { "name": "StreamNook Member", "image_url": "https://streamnook.app/cosmetics/streamnook-logo.png" },
"streamnook-lumen": { "name": "Lumen", "image_url": "https://cdn.streamnook.app/badges/streamnook-lumen.webp" },
"dispersion": { "name": "Dispersion", "atmosphere": {} }
}
}Values in cosmetics are full cosmetic objects, trimmed here for brevity. Pass
?expand=0 to get slugs alone.
cosmetics describes every slug the response mentions, owned as well as
equipped, so an expanded call renders without also holding the catalog. That
matters most for a cosmetic marked hidden: /catalog does not list those, so a
per-user expansion is the only place they can be looked up. Some cosmetics
carry image_url: null and their look in the atmosphere block instead, so
draw from that rather than assuming an image.
A user who is not a StreamNook member gets a 200, not a 404:
{
"twitch_user_id": "71092938",
"member": false,
"member_number": null,
"equipped": {},
"badge": null,
"owned": []
}That is deliberate. Most chatters in most channels are not members, and a 404 path is the one people forget to cache.
GET /badges/users
Every badge and everyone who holds one, in a single cacheable file. Fetch it on a timer instead of asking us about each chatter. Badges only: for atmospheres, frames and relics use the per-user endpoints above.
curl https://streamnook.app/api/v1/cosmetics/badges/usersIt carries the same data twice, keyed both ways, so you never have to join it yourself:
{
"badges": [
{
"id": "streamnook",
"version": "streamnook-supporter",
"slug": "streamnook-supporter",
"name": "StreamNook Supporter",
"image_url": "https://streamnook.app/cosmetics/streamnook-badge-gold-animated.webp",
"image_url_2": "...",
"image_url_4": "...",
"animated": true,
"meta_title": "StreamNook Supporter",
"meta_url": "https://streamnook.app",
"usernames": [],
"userids": ["474731", "..."],
"count": 25,
"active_userids": ["474731", "..."],
"active_count": 9
}
],
"users": {
"474731": {
"badges": ["streamnook-default", "streamnook-supporter", "kindred"],
"applied": "kindred"
}
},
"count": 12,
"members": 515,
"entitlements": 622,
"ttl_seconds": 300,
"generated_at": "2026-09-16T16:12:04.512Z"
}Held versus applied
This is the one thing to get right. A member can hold several badges but StreamNook draws exactly one of them:
| You want | Use |
|---|---|
| A chat line that looks like StreamNook | users[id].applied, or active_userids |
| Everything a member has earned or bought | users[id].badges, or userids |
Warning
Rendering every badge in userids puts two badges on every supporter, because
every member also holds the default mark. If you draw one badge per user, use
applied / active_userids.
applied is never null for a member. Someone who has never opened the picker
falls back to the default StreamNook mark, which is exactly what the app draws
for them, so you do not need a fallback of your own. It is also always one of
that member's badges.
Summed across the file, active_count is the member count and no id appears
twice. count can overlap, and entitlements is its total.
Fields
| Field | Notes |
|---|---|
id | Always streamnook. Pairs with version the way Chatty's file does. |
version / slug | The badge. Same slug the other endpoints use. |
image_url, image_url_2, image_url_4 | The same file in all three: our art is stored well above the size a chat line draws it at, so there is no separate 2x and 4x to give you. |
usernames | Always empty. We resolve people by id, because a login can change and an id cannot. Present so a parser written against Chatty's format does not have to special-case us. |
userids / count | Everyone who holds the badge. Every member holds the default mark, so that entry is the whole membership. |
active_userids / active_count | The subset rendering it right now. |
users | Keyed by Twitch user id: badges (held, catalog order) and applied (rendered). |
A badge with owners but nobody wearing it still appears, with an empty
active_userids. Look it up in badges[] rather than assuming a slug in
users[id].badges is described elsewhere.
Fields
| Field | Type | Notes |
|---|---|---|
slug | string | Stable identifier. Safe to key your own cache on. |
name | string | Display name. Use it as the tooltip. |
description | string or null | One line of flavour, and how it is earned. Atmospheres carry none. |
kind | string | badge, atmosphere, frame, relic. |
slot | string | The equip slot. One cosmetic per slot per member. |
image_url | string or null | Absolute URL, mostly WebP on cdn.streamnook.app. Null for atmospheres built from CSS gradients alone. |
animated | boolean | Honour reduced motion when true. Always true for atmospheres, which all carry motion. |
surfaces | string[] | Where it may render: chat, profile. |
atmosphere | object | Atmospheres only. See below. |
member | boolean | Whether this Twitch user is a StreamNook member at all. |
member_number | number or null | Join order. 1 is the first member. |
equipped | object | Slot to cosmetic slug, already ownership-checked. The badge slot falls back to the default mark; every other slot is absent when unfilled. |
badge | string or null | Shorthand for equipped.badge. Null only for a non-member. |
owned | string[] | Slugs the member has earned, sorted. |
cosmetics | object | Only when expanded. Slug to full cosmetic object, covering every slug the response mentions: equipped and owned alike. Built once per response, so a definition twenty people own is carried once. |
The atmosphere block
An atmosphere is a set of CSS layers rather than a single image, so it carries its own render data. These are the exact values StreamNook's own hosted overlay renders from.
| Field | Use |
|---|---|
base_color | The solid ground colour. Paint this first. |
base_layers, layers, layers2 | CSS background-image layers, stacked in that order over the base. Any may be null. |
image | A photographic layer, when the atmosphere has one. |
chat_edge | Treatment for the message row's leading edge. "none" means no edge. |
chat_frost | Apply a frosted-glass blur behind the row. |
chat_blur | Defocus radius in px for the wash. Null means sharp. |
chat_rim | A 1px gradient rim on the row. Null means no rim. |
accent | An "r, g, b" triple for accenting text and borders. |
swatch | A ready-made CSS value for a preview chip in a picker. |
motion | Named animation curve: aurora, drift. |
renderer | Normally null. When set, the generic layers above cannot reproduce the look. |
Warning
If renderer is not null, skip the atmosphere rather than render it from the
generic layers. It names a bespoke look that the layer stack does not describe,
and painting it anyway produces the wrong result. Only cologne-chrome uses
this today.
Rendering notes
- Badge size. Badges are square and authored for a chat line. Render around 18px and let the browser scale the WebP down.
- Animation. Check
animatedand fall back to a still first frame when the viewer prefers reduced motion. - Tooltip.
nameon the first line,descriptionunderneath, matching how StreamNook shows it. - Ownership is already checked. A cosmetic only appears in a response if
the member genuinely owns it and genuinely has it equipped. You do not need
to validate anything against
owned. - Unknown slugs. A member can wear a cosmetic that is held out of the
browsable catalog while it is staged or limited.
?expand=1always describes what it returns, so use it, or refetch/catalogwhen you meet a slug you do not recognise.
Caching and etiquette
| Endpoint | Edge cache | Notes |
|---|---|---|
/catalog, /badges | 1 h | Fetch once, hold it. |
/badges/users | 5 min | One file. Re-fetch on a timer; do not poll it per message. |
/users?ids= | 60 s | Batch up to 200 ids per call. |
/user/:id | 60 s | Prefer the batch form when you have more than one. |
New members and newly equipped badges appear on their own: the data is read live and the response is cached for the window above, so a signup shows up within a few minutes with nothing to invalidate on your side or ours.
Everything is served from Cloudflare's edge.
The quota is 50 requests per 10 seconds per IP. That is deliberately far
above what any honest integration needs, and it is there to stop id enumeration
rather than to ration real traffic. Going over gets you a 429 for the next 10
seconds; back off and retry. Nothing is banned, and the limit clears itself.
Two notes on staying well under it:
- Batch, and cache what you get. At 200 ids per call, the quota covers 10,000 users every 10 seconds. Hold results for at least the 60 second cache window rather than re-asking per message.
- Proxying through your own backend? All your traffic arrives from a handful of IPs, so it shares one bucket. If you genuinely need more, ask and we will raise it for you rather than have you work around it.
Errors
| Status | Body | Meaning |
|---|---|---|
| 400 | {"error":"invalid_id"} | The id was not 1 to 15 digits. |
| 400 | {"error":"missing_ids"} | /users called without ids. |
| 400 | {"error":"no_valid_ids"} | Every id in ids was malformed. |
| 429 | rate limited | Over 50 requests in 10 seconds. Wait 10 seconds and retry. |
| 503 | {"error":"lookup_failed"} | Backend hiccup. Retry, and keep your last good copy in the meantime. |
A 503 is never cached, so a retry reaches a live backend rather than a stored failure.
Warning
A mistyped path under streamnook.app returns the website's HTML with a 200,
not a JSON 404. If your parser suddenly sees HTML, check the URL first.
What this API is not
Note
This is StreamNook cosmetics only. It deliberately returns nothing from 7TV, BetterTTV, FrankerFaceZ or Chatterino, and no Twitch badges. Those come from their own sources, and you almost certainly have them already.
There is a separate, older /api/v1/identity endpoint that aggregates
third-party badges for StreamNook's own overlay renderer. It is not the right
surface for a cosmetics integration: it fans out to several third-party
providers on every call and returns very little that is actually ours. Use the
endpoints on this page instead.
Questions
Open an issue on github.com/StreamNook/StreamNook or ask in Discord.