StreamNook
StreamNook

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/cosmetics

How 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.

KindSlotWhat it isRenders on
badgebadgeA small square mark next to the name.chat, profile
atmosphereatmosphereA colour wash. In chat it tints the message row; on a profile it fills the card.chat, profile
frameframeA nine-slice border around the profile hero.profile
relicrelic_primaryAn 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/users

It 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 wantUse
A chat line that looks like StreamNookusers[id].applied, or active_userids
Everything a member has earned or boughtusers[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

FieldNotes
idAlways streamnook. Pairs with version the way Chatty's file does.
version / slugThe badge. Same slug the other endpoints use.
image_url, image_url_2, image_url_4The 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.
usernamesAlways 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 / countEveryone who holds the badge. Every member holds the default mark, so that entry is the whole membership.
active_userids / active_countThe subset rendering it right now.
usersKeyed 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

FieldTypeNotes
slugstringStable identifier. Safe to key your own cache on.
namestringDisplay name. Use it as the tooltip.
descriptionstring or nullOne line of flavour, and how it is earned. Atmospheres carry none.
kindstringbadge, atmosphere, frame, relic.
slotstringThe equip slot. One cosmetic per slot per member.
image_urlstring or nullAbsolute URL, mostly WebP on cdn.streamnook.app. Null for atmospheres built from CSS gradients alone.
animatedbooleanHonour reduced motion when true. Always true for atmospheres, which all carry motion.
surfacesstring[]Where it may render: chat, profile.
atmosphereobjectAtmospheres only. See below.
memberbooleanWhether this Twitch user is a StreamNook member at all.
member_numbernumber or nullJoin order. 1 is the first member.
equippedobjectSlot to cosmetic slug, already ownership-checked. The badge slot falls back to the default mark; every other slot is absent when unfilled.
badgestring or nullShorthand for equipped.badge. Null only for a non-member.
ownedstring[]Slugs the member has earned, sorted.
cosmeticsobjectOnly 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.

FieldUse
base_colorThe solid ground colour. Paint this first.
base_layers, layers, layers2CSS background-image layers, stacked in that order over the base. Any may be null.
imageA photographic layer, when the atmosphere has one.
chat_edgeTreatment for the message row's leading edge. "none" means no edge.
chat_frostApply a frosted-glass blur behind the row.
chat_blurDefocus radius in px for the wash. Null means sharp.
chat_rimA 1px gradient rim on the row. Null means no rim.
accentAn "r, g, b" triple for accenting text and borders.
swatchA ready-made CSS value for a preview chip in a picker.
motionNamed animation curve: aurora, drift.
rendererNormally 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 animated and fall back to a still first frame when the viewer prefers reduced motion.
  • Tooltip. name on the first line, description underneath, 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=1 always describes what it returns, so use it, or refetch /catalog when you meet a slug you do not recognise.

Caching and etiquette

EndpointEdge cacheNotes
/catalog, /badges1 hFetch once, hold it.
/badges/users5 minOne file. Re-fetch on a timer; do not poll it per message.
/users?ids=60 sBatch up to 200 ids per call.
/user/:id60 sPrefer 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

StatusBodyMeaning
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.
429rate limitedOver 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.