Developers
Limerino Badge API
Display Limerino badges in your own Twitch chat client. Limerino is a native Twitch chat app for iPhone and iPad, and its badges appear beside a user’s name in chat, the way 7TV’s do. The API is public and read-only, and needs no key.
- Base URL
https://api.limerino.com- Version
v1- Auth
- None
- Updated
How it works
One request gets everything. The full list names every badge with its images, and maps the Twitch user ID of everyone showing a badge to the badges they show.
Your client loads it at startup and draws badges from memory. A live stream then sends each change as it happens, and a timed refresh covers a client that can’t keep a stream open. FrankerFaceZ and BetterTTV badges load the same way, as one list at startup, but here users is keyed by Twitch user ID, not by badge.
The full list also comes in two halves: the catalog of badges and the holders list of users. A client that would rather not keep every user can ask about the users it sees instead, up to 100 IDs per lookup.
Limerino makes every badge’s text and images. Users can’t change them. Each user chooses which of their badges to show, if any, and every response already applies that choice (see Which badges a user shows).
A 7TV badge decoder can read Limerino badges once it handles the differences from 7TV.
Quick start
-
Fetch the full list and keep it.
Request
curl https://api.limerino.com/v1/badges/allResponse
{ "badges": [ { "id": "founder", "kind": "BADGE", "data": { "id": "founder", "name": "Limerino Founder", "tooltip": "Limerino Founder", "about": "For the people who were here first.", "how_to_get": "Earned by being an early part of the Limerino community. If you hold it, link Discord on your Limerino profile to show it.", "needs": ["discord_link"], "host": { "url": "//api.limerino.com/v1/badges/art/founder/4", "files": [ { "name": "1x.webp", "static_name": "1x.png", "width": 18, "height": 18, "frame_count": 29, "format": "WEBP", "size": 12638 }, … ] } } }, … ], "users": { …, "54698159": ["founder"], … } }badgeslists every badge and its images.usersmaps each Twitch user ID to the IDs of the badges to draw beside that user’s name. -
For each message, find its author’s ID (
user-idin IRC,chatter_user_idin EventSub) inusersand draw the first badge listed. Find that badge inbadgesby itsid. A file’s URL is"https:" + data.host.url + "/" + file.name, for examplehttps://api.limerino.com/(see Choosing an image).v1/ badges/ art/ founder/ 4/ 2x.webp
The same two steps in JavaScript, in a module or an async function:
JavaScript
const res = await fetch("https://api.limerino.com/v1/badges/all");
if (!res.ok) throw new Error(`Limerino badges: HTTP ${res.status}`);
const { badges, users } = await res.json();
const known = badges.filter((badge) => badge.kind === "BADGE");
const badgeById = new Map(known.map((badge) => [badge.id, badge]));
const badgeIdsByUser = new Map(Object.entries(users));
// The badge objects to draw beside a message from Twitch user `id`, a string.
function badgesFor(id) {
const ids = badgeIdsByUser.get(id) ?? [];
return ids.map((badgeId) => badgeById.get(badgeId)).filter(Boolean);
}
Before you ship, add the refreshes, the live stream, and the failure handling in Building a client.
Building a client
The reference client at the end of this section puts these rules into code.
The full list
- Fetch the full list at startup and keep it in memory, its badges keyed by ID.
- Keep one copy for the whole app, across every channel, window, and tab.
- Refresh it when your copy is 10 minutes old. Check that age on a timer, and again when the app returns to the foreground or the network comes back.
- Don’t refresh less often. Without the live stream, a user’s change reaches you only at a refresh, so a badge a user hides keeps showing in your client until then.
- Revalidate every refresh: send the stored ETag in
if-none-matchand keep your copy on a304. - You can store the list and its ETag between launches. Draw from the stored copy at startup and revalidate it.
- Match IDs as strings. A Twitch user ID can run to 20 digits, which doesn’t fit exactly in a JavaScript number.
- Skip a badge whose
kindyou don’t know. - If a user’s badges include one your copy doesn’t have, skip that badge. It can happen only after a live update, and the badge arrives with the next refresh.
Live updates
The live stream sends each change to the holders list as it happens, so a new badge reaches your client in seconds. It adds to the timed refresh and never replaces it.
- Open one stream for the whole app.
- On
sync, refresh the list. One arrives when the stream opens, and again when a badge itself changes or a change is too large to send. Apply patches only after that refresh succeeds, and ignore them after a failed one. - On
holders, set each listed user’s badges to the list given, and remove a user whose list is empty. Then forget your stored ETag: your copy has changed since you got it, so the next refresh downloads the list whole. - If an event arrives during a refresh, refresh once more when it succeeds. A queued patch may predate the downloaded snapshot, so replaying it can undo a newer change.
- When the stream ends, reconnect after the wait it gave in
retry. A browser’sEventSourcedoes that for you. - If the stream is refused, carry on with the timed refresh and try again after
retry-afterseconds. - A client that uses lookups can apply each
holdersevent to the results it has cached.
Two lists instead
The catalog and the holders list are the full list’s two halves, each with its own ETag. Once the holders list is large, keeping them apart saves downloads: a holders event carries the holders list’s new ETag, so the next refresh is a 304.
- Fetch and refresh both as above, revalidating each with its own ETag.
- On
sync, refresh both. After a failed refresh, fetch the holders list without an ETag. - On
holders, update the listed users as above, but instead of forgetting an ETag, keep the event’setagas your holders ETag. Your catalog ETag stays as it is. - If the holders list names a badge your catalog doesn’t have, skip that badge. The two lists refresh together, so it appears at the next refresh.
- For a copy too large to revalidate whole, the holders list also comes in 64 shards.
Lookups instead
A lookup returns the same badges as the full list, for only the users you name. A tool that asks about one user can use the single-user lookup instead.
- Fetch the catalog and refresh it as above, and send no lookups until it has loaded.
- Keep one queue for the whole app, across every channel and window. Send one request at a time, with 1 to 100 IDs, and at most one every 2 seconds.
- Queue only IDs that are strings of 1 to 20 digits.
- Cache each ID’s result for 10 minutes if the user shows a badge and 30 minutes if they show none. A user’s change reaches you when their result expires, so don’t cache it longer. Keep drawing an expired result until its replacement arrives.
- Cap the cache and the queue. The Limerino app keeps 10,000 results and evicts the oldest first.
- Don’t hold a message back for its lookup. Draw it, then add the badge when the result arrives.
- Over IRC, also look up the signed-in user when they send a message: Twitch doesn’t echo a message to the IRC connection that sent it.
- If a result names a badge your catalog doesn’t have, skip that badge and refresh the catalog, at most once a minute.
- To learn about everyone, fetch the full list. Don’t walk ranges of IDs.
Failures
- Give every request but the live stream a timeout. One that never completes would otherwise stall everything behind it.
- On a
429, a5xx, a timeout, or a network error, keep what you have: your copy of the list, your cached results, and the IDs still waiting for a lookup. Never turn a failed lookup into a no-badge result. - Then wait
retry-afterseconds, or 60 when the response has none, and double that wait after each consecutive failure, to at most 10 minutes. - Add a few random seconds to each wait, so clients behind one address don’t all retry together.
- A
400means the request itself is wrong, so don’t resend it. Treat each ID of a rejected lookup as looked up: keep drawing the result you had, or no badge, until its cache time passes.
Display
- Draw the user’s displayed badge. Each user entry contains one badge ID.
- The Limerino app draws Limerino badges right after Twitch’s own badges and before other services’ badges. Place them wherever suits your client.
Reference client
The full list, the live stream, and the failure rules in JavaScript, for a current browser or Node 18 and later:
JavaScript
const API = "https://api.limerino.com";
const REFRESH = 10 * 60_000; // how often to refresh, and the longest backoff
const RETRY = 60_000; // the first wait after a failure with no retry-after
let badges = new Map(); // badge ID -> badge object
let users = new Map(); // Twitch user ID -> badge IDs, in catalog order
let etag = null; // the ETag of the copy you keep; null downloads it whole
let failures = 0;
let refreshing = false;
let canPatch = false; // a complete copy has loaded since the last sync
let again = false; // an event arrived during a refresh: refresh once more
let nextAt = 0; // when the next refresh is due
let timer;
async function load() {
const res = await fetch(API + "/v1/badges/all", {
cache: "no-store", // never take an older copy from the HTTP cache
headers: etag ? { "if-none-match": etag } : {},
signal: AbortSignal.timeout(10_000),
});
if (res.status === 304) return; // unchanged: keep your copy
if (!res.ok) {
const error = new Error(`/v1/badges/all: HTTP ${res.status}`);
error.retryAfter = Number(res.headers.get("retry-after")) * 1000;
throw error;
}
const list = await res.json();
const known = list.badges.filter((badge) => badge.kind === "BADGE");
badges = new Map(known.map((badge) => [badge.id, badge]));
users = new Map(Object.entries(list.users));
etag = res.headers.get("etag");
}
async function refresh() {
if (refreshing) {
again = true;
return;
}
refreshing = true;
clearTimeout(timer);
let wait = REFRESH;
try {
await load();
canPatch = true;
failures = 0;
} catch (err) {
canPatch = false;
console.warn("Badge refresh failed; keeping the last copy.", err);
// retry-after, or a minute; doubled per failure, to at most 10 minutes.
wait = Math.min((err.retryAfter || RETRY) * 2 ** failures, REFRESH);
failures += 1;
}
refreshing = false;
// Redraw the messages on screen here, so new badges appear.
const rerun = again && failures === 0;
again = false;
if (rerun) return refresh();
nextAt = Date.now() + wait;
// A few random seconds, so clients behind one address don't retry together.
timer = setTimeout(refresh, wait + Math.random() * 5_000);
}
// One holders event: each listed user's new badges. An empty list removes them.
function patch(event) {
if (refreshing) {
again = true;
return;
}
if (!canPatch) return;
for (const [id, list] of Object.entries(event.users)) {
if (list.length) users.set(id, list);
else users.delete(id);
}
etag = null; // your copy changed, so the next refresh downloads it whole
// Redraw those users' messages here.
}
// Call for each message you draw, with its author's Twitch user ID as a string.
// Returns the author's badge objects, in the order to draw them.
function badgesFor(twitchId) {
const ids = users.get(twitchId) ?? [];
return ids.map((id) => badges.get(id)).filter(Boolean);
}
// The live stream. A browser reconnects a dropped stream by itself; a refused
// one (429 or 503) stays closed, so try again in a minute.
function listen() {
const events = new EventSource(API + "/v1/badges/events");
events.addEventListener("sync", () => {
canPatch = false;
refresh();
});
events.addEventListener("holders", (event) => patch(JSON.parse(event.data)));
events.onerror = () => {
if (events.readyState === EventSource.CLOSED) setTimeout(listen, RETRY);
};
}
refresh();
// Without EventSource (Node, for one), the timed refresh alone keeps you current.
if (typeof EventSource !== "undefined") listen();
// A browser throttles timers in a hidden tab. Catch up when the tab is shown
// again or the network comes back. Outside a browser, call catchUp() from your
// app's own foreground and network events.
function catchUp() {
if (Date.now() >= nextAt) refresh();
}
globalThis.addEventListener?.("online", catchUp);
globalThis.document?.addEventListener("visibilitychange", () => {
if (!document.hidden) catchUp();
});
Choosing an image
Every badge comes in four square sizes, 1x to 4x, listed in data.host.files (see Image files). The 18, 36, and 72 pixel sizes match Twitch’s own badges, so a Limerino badge fits the slot you already draw those in.
- 1x · 18 px
- 2x · 36 px
- 3x · 54 px
- 4x · 72 px
- Multiply the slot size, in points or CSS pixels, by the display scale. An 18-point slot needs 18 pixels on a 1x display and 54 on a 3x display.
- Pick the smallest size that’s at least that big, or
4xif none is. - Take the first file at that size in a format you can decode. PNG is always there, so use it if a WebP or GIF fails to decode.
- Build the URL:
"https:" + data.host.url + "/" + file.name. Take the host and the revision fromdata.host.urlevery time; both can change.
The same steps in JavaScript:
JavaScript
// The image for `badge` in a slot `slot` CSS pixels wide. Pass animate = false
// if your client doesn't play animated images. Outside a browser, use your
// display's own scale in place of devicePixelRatio.
function badgeImage(badge, slot = 18, animate = true) {
const { url, files } = badge.data.host;
const need = slot * (globalThis.devicePixelRatio || 1);
const size = [18, 36, 54, 72].find((px) => px >= need) ?? 72;
// The formats your client decodes. `files` lists WEBP, then GIF, then PNG.
const formats = ["WEBP", "GIF", "PNG"];
const file = files.find((f) => f.width === size && formats.includes(f.format));
const png = files.find((f) => f.width === size && f.format === "PNG");
if (!file || !png) return null;
const still = !animate ||
globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
const name = still && file.static_name ? file.static_name : file.name;
return {
src: `https:${url}/${name}`,
// The PNG, to try once if `src` fails to decode; null when `src` is the PNG.
fallback: name === png.name ? null : `https:${url}/${png.name}`,
alt: badge.data.tooltip,
};
}
Switch to fallback at most once. A PNG that fails too usually means the badge has left the catalog, so refresh the catalog instead of retrying.
Images are served from the host in data.host.url, today api.limerino.com. A loader that works from a host allowlist needs that host on it.
Animated badges
A file with a frame_count above 1 is animated. Play it as encoded, with its own frame timing and loop count.
Its static_name names the still PNG at the same size. Draw the still when your client doesn’t animate images or the viewer has turned animation off. On the web, that’s prefers-reduced-motion: reduce. The Limerino app draws the still when its own animation setting or Auto-Play Animated Images is off, or Dim Flashing Lights is on.
Endpoints
-
GET
/v1/Every badge and everyone showing one, in one responsebadges/ all -
GET
/v1/Every badge, in catalog orderbadges -
GET
/v1/Everyone showing a badge, or everyone showing one badgebadges/ holders -
GET
/v1/The holders list in 64 parts, for clients that keep a large copybadges/ holders/ shards/ {shard} -
GET
/v1/The live stream of changes to the holders listbadges/ events -
POST
/v1/The badges of 1 to 100 users you namebadges/ users -
GET
/v1/The same lookup as a cacheable GETbadges/ users? twitch_ids=… -
GET
/v1/The displayed badge and owned collection, as full badge objectsbadges/ twitch/ {id} -
GET
/v1/One badge imagebadges/ art/ {badge}/ {revision}/ {file}
Any endpoint can also return 429 (see Rate limits), 500, or 503 (see Errors).
The examples run as written. Responses are trimmed to the headers that matter, and longer bodies are formatted, or shortened with …, for reading.
Get the full list
GET /v1/
Returns every badge and everyone who shows one, in one response. There are no parameters and no pagination.
Request
curl -i https://api.limerino.com/v1/badges/all
Response
HTTP/2 200
cache-control: public, no-cache
content-type: application/json; charset=utf-8
etag: "e66ff2c266eadc995e725f1332327f31"
{
"badges": [
{ "id": "founder", "kind": "BADGE", "data": { … } },
…
],
"users": { …, "54698159": ["founder"], … }
}
| Field | Meaning |
|---|---|
badgesarray of badge objects | The catalog: every badge, highest priority first. Each element is a badge object. |
usersobject | The holders list: each key is a Twitch user ID, as a string, and its value is the IDs of the badges that user shows, in catalog order (see Which badges a user shows). Draw the first one. A user who shows no badge isn’t listed, so users can be empty. |
Both halves are taken at the same moment, so every badge ID in users is in badges. The list changes when a badge’s definition changes and when someone gains, loses, or picks a badge, so it’s sent with cache-control: no-cache: an HTTP cache, your client’s included, checks its copy by ETag before using it.
| Status | Meaning |
|---|---|
200 OK | The full list. |
304 Not Modified | Your copy is current. |
Get the catalog
GET /v1/
Returns every badge in catalog order, highest priority first. The full list, the holders list, and the lookups list each user’s badges in the same order, so the first badge in any list is the one to draw. There are no parameters and no pagination.
Request
curl -i https://api.limerino.com/v1/badges
Response
HTTP/2 200
cache-control: public, max-age=300, stale-while-revalidate=3600
content-type: application/json; charset=utf-8
etag: "55e184843e44850cf65c8dfb6bd96e60"
{
"badges": [
{
"id": "founder",
"kind": "BADGE",
"data": {
"id": "founder",
"name": "Limerino Founder",
"tooltip": "Limerino Founder",
"about": "For the people who were here first.",
"how_to_get": "Earned by being an early part of the Limerino community. If you hold it, link Discord on your Limerino profile to show it.",
"needs": ["discord_link"],
"host": {
"url": "//api.limerino.com/v1/badges/art/founder/4",
"files": [
{ "name": "1x.webp", "static_name": "1x.png", "width": 18, "height": 18,
"frame_count": 29, "format": "WEBP", "size": 12638 },
{ "name": "2x.webp", "static_name": "2x.png", "width": 36, "height": 36,
"frame_count": 29, "format": "WEBP", "size": 28894 },
{ "name": "3x.webp", "static_name": "3x.png", "width": 54, "height": 54,
"frame_count": 29, "format": "WEBP", "size": 49360 },
{ "name": "4x.webp", "static_name": "4x.png", "width": 72, "height": 72,
"frame_count": 29, "format": "WEBP", "size": 70688 },
{ "name": "1x.png", "width": 18, "height": 18,
"frame_count": 1, "format": "PNG", "size": 862 },
{ "name": "2x.png", "width": 36, "height": 36,
"frame_count": 1, "format": "PNG", "size": 2250 },
{ "name": "3x.png", "width": 54, "height": 54,
"frame_count": 1, "format": "PNG", "size": 4018 },
{ "name": "4x.png", "width": 72, "height": 72,
"frame_count": 1, "format": "PNG", "size": 6047 }
]
}
}
},
…
]
}
The catalog changes only when a badge’s definition does, never when someone gains, loses, or picks a badge. Most refreshes end in a 304.
| Status | Meaning |
|---|---|
200 OK | The catalog. |
304 Not Modified | Your copy is current. |
Get the holders
GET /v1/
Returns everyone who shows a badge, each with the badges they show. The whole list comes in one response, with no pagination, at about 25 bytes per user before compression.
| Query parameter | Rules |
|---|---|
badgestring, optional | A badge ID from the catalog. The response then lists only the users who show that badge, still with every badge each of them shows. Send the parameter once. Other query parameters are ignored. |
Request
curl -i https://api.limerino.com/v1/badges/holders
Response
HTTP/2 200
cache-control: public, max-age=60, stale-while-revalidate=300
content-type: application/json; charset=utf-8
etag: "b09de8e30062898be02f9664a310f20a"
{"users":{…,"54698159":["founder"],…}}
users maps each Twitch user ID to the IDs of the badges that user shows, in catalog order. A user who shows no badge isn’t listed, so users can be empty: {"users":{}}.
With badge, the response keeps the same shape, so the number of keys is how many users show that badge:
Request
curl -i 'https://api.limerino.com/v1/badges/holders?badge=founder'
| Status | Meaning |
|---|---|
200 OK | The list, possibly {"users":{}}. |
304 Not Modified | Your copy is current. |
400 Bad Request | invalid_badge_id: badge is empty or repeated, or isn’t a badge ID (see Badge object). |
404 Not Found | not_found: the catalog has no badge with that ID. Refresh your catalog. |
Which badges a user shows
Each user picks one of these in the Limerino app:
| Their choice | What the API returns |
|---|---|
| Automatic, which is the default, or a badge they no longer hold | The first badge they hold in catalog order. |
| A badge they hold | Only that badge. |
| None | Nothing, as if they held no badges. |
Get the holders in shards
GET /v1/
Returns one of 64 shards of the holders list. Each user who shows a badge is in exactly one shard, so together the 64 shards are the whole list.
Most clients should fetch the full list. Shards are for a client that keeps a large copy and revalidates it often, such as a service that passes badges on to other apps. Each shard has its own ETag, so a change to one user costs you one shard instead of the whole list.
| Path parameter | Rules |
|---|---|
shardinteger, required | The shard number, 0 to 63, with no leading zeros. |
A user is in the shard numbered by their Twitch user ID mod 64. An ID can be too long for a JavaScript number, but 1,000,000 is a multiple of 64, so the last six digits give the same shard: Number(id.slice(-6)) % 64.
Request
curl -i https://api.limerino.com/v1/badges/holders/shards/47
Response
HTTP/2 200
cache-control: public, max-age=60, stale-while-revalidate=300
content-type: application/json; charset=utf-8
etag: "3b9e0d4c1f7a2e58c6d0b1a9e4f37c22"
{"shard":47,"shards":64,"users":{…,"54698159":["founder"],…}}
users has the holders list’s shape, with only this shard’s users. shard is the shard’s number and shards is how many there are.
To keep the holders list in shards:
- Fetch all 64 shards, merge their
usersinto one map, and keep each shard’s ETag. - To refresh, revalidate each shard with its own ETag. On a
200, remove every user in your map whose ID is in that shard, then add the ones it lists. - Apply
holdersevents from the live stream to your map as usual, then forget the ETag of each shard that holds a listed user. Theiretagis the whole holders list’s, so it fits no shard. The next refresh downloads those shards whole and revalidates the rest. - On
sync, refresh the catalog and revalidate every shard, following the rules in Live updates.
| Status | Meaning |
|---|---|
200 OK | The shard, possibly with no users. |
304 Not Modified | Your copy is current. |
400 Bad Request | invalid_shard: shard isn’t one or two digits, or has a leading zero, such as 07. |
404 Not Found | not_found: there’s no shard with that number. Shards run from 0 to 63. |
Stream changes
GET /v1/
Keeps the response open and sends each change to the holders list as a server-sent event. Any server-sent events client reads it; in a browser, that’s EventSource.
Request
curl -N https://api.limerino.com/v1/badges/events
Response
HTTP/2 200
cache-control: no-store, no-transform
content-type: text/event-stream; charset=utf-8
retry: 7312
event: sync
data: {"catalog":"\"55e184843e44850cf65c8dfb6bd96e60\"","holders":"\"b09de8e30062898be02f9664a310f20a\""}
: ping
event: holders
data: {"etag":"\"5d3f0c1b7a9e42f6b8c0d1e2f3a4b5c6\"","users":{"54698159":["founder"],"12826":[]}}
| Event | Meaning |
|---|---|
sync | Refresh what you keep. catalog and holders are the current ETags of the catalog and the holders list; a client keeping those two can skip a list whose ETag matches its own, ignoring any leading W/. A client keeping the full list refreshes it. Sent when the stream opens, when the catalog changes, and when a change is too large to send as a patch: more than 1,000 users, or 64 KiB. |
holders | Some users now show different badges. users maps each of them to their new badge IDs, with an empty list for a user who now shows none. etag is the holders list’s ETag after the change. A client keeping the full list forgets its own ETag instead (see Live updates). |
retryis how many milliseconds to wait before you reconnect.- A line that starts with a colon is a comment. One arrives every 20 seconds to keep the connection open.
- The stream doesn’t replay what you missed. The
syncit opens with brings you up to date after any gap. - The server renews streams every 15 to 19 minutes. A stream can end sooner, such as when the server restarts; reconnect after the
retrydelay.
| Status | Meaning |
|---|---|
200 OK | The stream. |
429 Too Many Requests | rate_limited: your address has too many streams open, or opened too many in a minute (see Rate limits). |
503 Service Unavailable | badges_unavailable: the server has no room for another stream, or can’t serve badges right now. |
Look up users
POST /v1/
Takes 1 to 100 Twitch user IDs and returns the badges each user shows (see Which badges a user shows). The IDs travel in the body, which keeps them out of URLs (see Privacy).
| Body field | Rules |
|---|---|
twitch_idsarray of strings, required | 1 to 100 distinct Twitch user IDs (user-id in IRC, chatter_user_id in EventSub), each a string of 1 to 20 digits, sent exactly as Twitch gives it. A repeated ID counts once toward the 100. The API doesn’t normalize IDs. |
The body must be a JSON object with only this field, at most 4,096 bytes. Send content-type: application/json (a charset parameter is fine).
Request
curl -i https://api.limerino.com/v1/badges/users \
-H 'content-type: application/json' \
-d '{"twitch_ids":["54698159","12826"]}'
Response
HTTP/2 200
cache-control: no-store
content-type: application/json; charset=utf-8
{"users":{"54698159":["founder"]}}
users maps each ID that shows a badge to its badge IDs, in catalog order. IDs that show none, including IDs Twitch never issued, are left out, so users can be empty: {"users":{}}. Don’t rely on its key order.
| Status | Meaning |
|---|---|
200 OK | The lookup, possibly {"users":{}}. |
400 Bad Request | invalid_twitch_ids: the body isn’t a JSON object with only twitch_ids, breaks the ID rules above, or was sent as plain text (what fetch sends when you set no content-type). |
413 Payload Too Large | body_too_large: the body is over 4,096 bytes. Even 100 IDs of 20 digits come to about 2,300. |
415 Unsupported Media Type | unsupported_: the body has no content type, or one other than JSON or plain text. |
Look up users (GET)
GET /v1/
Returns the same body as the POST lookup, with the IDs in the query string so that HTTP caches, such as a proxy or CDN you run, can store the response. In a chat client, prefer the POST lookup (see Privacy).
| Query parameter | Rules |
|---|---|
twitch_idsstring, required | The same 1 to 100 distinct IDs, separated by commas (raw or encoded as %2C) with no spaces. Send the parameter once. Other query parameters are ignored. |
Request
curl -i 'https://api.limerino.com/v1/badges/users?twitch_ids=54698159,12826'
Response
HTTP/2 200
cache-control: public, max-age=60
content-type: application/json; charset=utf-8
etag: "63869ed68482118e55d9a3d6f1e0b534"
{"users":{"54698159":["founder"]}}
| Status | Meaning |
|---|---|
200 OK | The lookup. |
304 Not Modified | Your copy is current. |
400 Bad Request | invalid_twitch_ids: the parameter is missing, empty, or repeated, has an entry that isn’t 1 to 20 digits (an empty one included), or names more than 100 distinct IDs. |
Look up one user
GET /v1/
Returns the badge one user displays in badges, and their collection of published badges in owned_badges. Both arrays contain full badge objects. Use badges beside their name in chat and owned_badges to show their collection.
| Path parameter | Rules |
|---|---|
idstring, required | A Twitch user ID: 1 to 20 digits. |
Request
curl -i https://api.limerino.com/v1/badges/twitch/54698159
Response
HTTP/2 200
cache-control: public, max-age=60, stale-while-revalidate=300
content-type: application/json; charset=utf-8
etag: "2506a98a27ce66bcc4cd636b7661bca7"
{
"user_id": "54698159",
"badges": [
{ "id": "helper", "kind": "BADGE", "data": { … } }
],
"owned_badges": [
{ "id": "founder", "kind": "BADGE", "data": { … } },
{ "id": "helper", "kind": "BADGE", "data": { … } }
]
}
badges contains at most one badge. Choosing None empties both public arrays; the signed-in owner can still read their full collection through the account API. A user who holds no published badges gets {"user_id":"12826","badges":[],"owned_badges":[]}.
| Status | Meaning |
|---|---|
200 OK | The user’s badges, possibly none. |
304 Not Modified | Your copy is current. |
400 Bad Request | invalid_user_id: the ID isn’t 1 to 20 digits. |
Get badge art
GET /v1/
Returns one image. Build its URL from a badge object (see Choosing an image), never from a badge ID and a revision of your own.
An image URL’s bytes never change: new art gets a new revision number, and so a new URL (see HTTP caching). Older revisions keep working while the badge is in the catalog, so a client with an old catalog still draws its badges until it refreshes.
Request
curl -I https://api.limerino.com/v1/badges/art/founder/4/2x.webp
Response
HTTP/2 200
cache-control: public, max-age=31536000, immutable
content-type: image/webp
etag: "fec291956f15d4ffd20beb5d3978578a516483d714a5a4d2dd40f901a5f750e4"
| Status | Meaning |
|---|---|
200 OK | The image: image/webp, image/gif, or image/png. |
304 Not Modified | Your copy is current. |
404 Not Found | not_found: no such badge, revision, or file, or the badge has left the catalog. Refresh your catalog. |
Badge object
The full list, the catalog, and the single-user lookup return badge objects. The holders list and the POST and GET lookups return badge IDs only. Every field in this table is always present.
| Field | Meaning |
|---|---|
idstring | The badge ID: 1 to 32 lowercase letters, digits, or hyphens, starting with a letter or digit. It never changes. |
kindstring | "BADGE", the only kind today. Skip an entry whose kind you don’t know. |
data.idstring | The same as id. |
data.namestring | The badge’s name, for a list or a detail view: 1 to 64 characters (Unicode code points), with no control characters. |
data.tooltipstring | Hover text and screen-reader label, with the same limits as name. Use it, not name, for both. |
data.aboutstring | What the badge stands for: one plain-text paragraph of at most 200 characters, or "" when the badge has none. |
data.how_to_getstring | How the badge is earned, in the same form, or "". |
data.needsarray of strings | ["discord_link"] when earning the badge takes a linked Discord account, the only value today, and [] otherwise. Skip values you don’t know. |
data.host.urlstring | The base URL of the badge’s current images, without a scheme, such as //api.limerino.com/. |
data.host.filesarray | The image files (see Image files). |
about, how_to_get, and needs describe a badge for an info view. A client that only draws badges in chat can ignore them.
Image files
| Field | Meaning |
|---|---|
namestring | 1x, 2x, 3x, or 4x, then .webp, .gif, or .png. |
formatstring | "WEBP", "GIF", or "PNG". |
width, heightnumber | 18, 36, 54, or 72 pixels, for 1x to 4x. Always square. |
frame_countnumber | 1 for a still image, more for an animation. |
static_namestring, optional | Only on animated files: the name of the still PNG at the same size, such as "2x.png". |
sizenumber | The file size in bytes. |
- Every badge has a still PNG at all four sizes.
- WebP and GIF are optional, and each comes at all four sizes or not at all.
- Files are listed WebP first, then GIF, then PNG, each from
1xto4x. - A still file is at most 128 KiB. An animated one is at most 256 KiB and 400 frames. A badge’s files total at most 1 MiB.
- Animation is set per file, so check each file’s own
frame_count.
Differences from 7TV
A badge object matches the object of a 7TV v3 cosmetic.create event, not the whole event. A 7TV badge decoder can read it once it handles these differences:
static_nameis present only on animated files, where it names a PNG. 7TV sends it on every file. When it’s missing, usename, and never build_staticfile names.- Only PNG is guaranteed. 7TV badge files are always WebP, so a decoder that keeps only WebP files drops a PNG-only badge.
- An animated badge lists still PNGs beside its animated files; 7TV lists only the animated ones. Read
frame_countper file. - There’s no
data.tag, and badge IDs are short names, not 7TV object IDs. about,how_to_get, andneedsare Limerino additions. A decoder that rejects unknown fields has to allow them.- There are no entitlement events and no subscriptions per channel. One live stream sends every change, and the full list, the holders list, or a lookup says who shows a badge now.
HTTP caching
| Response | cache-control |
|---|---|
| Catalog | public, max-age=300, stale-while-revalidate=3600 |
| Full list | public, no-cache |
| Holders list and holder shards | public, max-age=60, stale-while-revalidate=300 |
| Live stream | no-store, no-transform |
| POST lookup | no-store |
| GET lookup | public, max-age=60 |
| Single-user lookup | public, max-age=60, stale-while-revalidate=300 |
| Badge art | public, max-age=31536000, immutable |
A 404 from badge art, the holders list, or a holder shard may be cached for 60 seconds. A 503 is never stored.
Every 200 except the POST lookup’s and the live stream’s carries an ETag. To revalidate, send it back in if-none-match, unchanged. A compressed response carries a weak ETag, W/"…", and that form matches too. If nothing changed, you get a 304 with no body:
Request
curl -i https://api.limerino.com/v1/badges \
-H 'if-none-match: "55e184843e44850cf65c8dfb6bd96e60"'
Response
HTTP/2 304
cache-control: public, max-age=300, stale-while-revalidate=3600
etag: "55e184843e44850cf65c8dfb6bd96e60"
A browser preflights a request that sets if-none-match (see CORS).
For lookup results, follow the cache times in Building a client, not these HTTP lifetimes.
Rate limits
Limits apply per IP address, counted in a fixed one-minute window that opens with the address’s first request. Every request counts, including errors and 304 responses.
| Endpoint | Requests per minute |
|---|---|
| Full list | 5,000 |
| Catalog | 600 |
| Holders list | 5,000 |
| Holder shards, shared by all 64 | 640 |
| Live stream, new streams | 30 |
| POST and GET lookups, shared | 1,200 |
| Single-user lookup | 600 |
| Badge art | 3,000 |
Preflights (OPTIONS) of /v1/ | 1,200 |
| Preflights of every path under it, shared | 1,200 |
Clients behind one address, such as an office network or a mobile carrier’s gateway, share its limits. A client that keeps the full list, or the two lists, sends a request or two every 10 minutes, so those limits fit thousands of clients behind one address. One that uses lookups sends at most 30 a minute, so the lookup limit fits about 40 of them.
One client address can keep 50 live streams open at once. If the server cannot distinguish client addresses from proxy addresses, both stream limits per address are disabled; the server-wide limit of 2,000 open streams still applies. A HEAD request counts against its own limit, the same size as the endpoint’s GET.
| Header | Meaning |
|---|---|
x-ratelimit-limit | Requests allowed per window. |
x-ratelimit-remaining | Requests left in this window. |
x-ratelimit-reset | Seconds until the window resets. A count, not a timestamp. |
retry-after | With a 429: seconds left in the window. |
Over the limit, you get a 429:
Response
HTTP/2 429
content-type: application/json; charset=utf-8
retry-after: 46
x-ratelimit-limit: 1200
x-ratelimit-remaining: 0
x-ratelimit-reset: 46
{"error":"rate_limited"}
Act on the 429 and retry-after (see Failures), not on x-ratelimit-remaining or the numbers above, which can change.
If the limits get in your way, say so (see Help).
Errors
Every error the API sends has a JSON body with one field, error, that holds a code, such as {"error":"invalid_twitch_ids"}. Branch on the code (see Versioning).
| Status | Error |
|---|---|
400 Bad Request | invalid_twitch_idsA lookup’s IDs or body break the rules (see Failures). |
400 Bad Request | invalid_user_idThe single-user lookup’s ID isn’t valid. |
400 Bad Request | invalid_badge_idThe holders list’s badge parameter isn’t a badge ID. |
400 Bad Request | invalid_shardA holder shard’s number isn’t one or two digits, or has a leading zero. |
404 Not Found | not_foundNo such badge, badge image, or holder shard. For a badge or an image, refresh your catalog. |
413 Payload Too Large | body_too_largeA lookup’s body is over 4,096 bytes. |
415 Unsupported Media Type | unsupported_A lookup’s body has no content type, or an unsupported one. |
429 Too Many Requests | rate_limitedThe request is over a rate limit, or your address has too many live streams open. Back off (see Failures). |
500 Internal Server Error | internal_errorSomething failed on the server. Back off (see Failures). |
503 Service Unavailable | badges_unavailableThe server can’t serve badges right now, or has no room for another live stream. It sends retry-after only for the stream. Back off the same way. |
A request the API never sees gets the framework’s answer instead, with no CORS headers and a different body: a 404 when it matches no endpoint (a trailing slash, an extra segment, the wrong method), a 400 when its path has broken percent-encoding or its body doesn’t match its content-length, and a 414 when a path segment is over 512 characters. Browser code sees each as a network error. If you get one, check the URL.
CORS
- Every response from these endpoints carries
access-control-allow-origin: *, including every error the API sends. Code in a web page can call the API directly. - Send no credentials. The default
fetchsettings are right.credentials: "include"fails against a wildcard origin. - A page can read
etag,retry-after, and the threex-ratelimitheaders:access-control-expose-headerslists them. - Every endpoint answers a preflight.
OPTIONSreturns204and allowsGETandPOSTwith thecontent-typeandif-none-matchheaders. - A browser caches that preflight, so the POST lookup, or a GET that sets
if-none-match, doesn’t always cost two requests. Chromium keeps it for up to 2 hours and Safari for up to 10 minutes. - Any other header that isn’t CORS-safelisted, such as
authorizationor anx-header, fails the preflight. EventSourceopens the live stream with no preflight.
Privacy
When your client calls the API:
- Limerino sees each request’s IP address and, in a lookup, the IDs it asks about. There are no keys, tokens, or cookies. Fetching the full list or the holders list, or opening the live stream, says nothing about who is in your chat.
- Limerino’s server doesn’t log individual lookups, but the hosting provider’s edge logs each request’s URL and IP address. The GET and single-user lookups put IDs in the URL, so those IDs appear there. IDs in a POST body don’t.
Badges are meant to be seen in chat, so who shows one is public:
- The full list and the holders list name the Twitch user ID of every user who shows a badge, and anyone can read them. The live stream announces each change.
- Users are named by Twitch user ID only. No response carries a username.
- Choosing None hides both the displayed badge and the collection in public lookups. Otherwise, the single-user lookup includes the full published collection in
owned_badges; chat lists contain only the displayed badge.
The privacy policy says how badges are given and removed.
Versioning
Within v1, these don’t change:
- Field names and types.
- Error codes.
- The format of badge IDs and Twitch user IDs.
- What catalog order means: the first badge in a list is the one to draw.
These can change without notice:
- New fields can appear in any object, new endpoints under
/v1, and new events on the live stream. Ignore the fields and events you don’t know. - New values can appear for
kind,needs, andformat. Skip a catalog entry whosekindyou don’t know, and skipneedsvalues and image formats you don’t know; the PNGs stay. - The badges in the catalog, their order, and their images.
- The host and revision in
data.host.url. - The order of fields and keys.
A breaking change ships as /v2, next to /v1, and the changelog announces it.
v1 has changed six times, on , , , , , and . The changelog describes each change.
Changelog
- October 15, 2026
-
- Added:
owned_badgeson the single-user lookup, for the full published collection. Choosing None hides this collection from public lookups too. - Changed: Automatic resolves to one displayed badge on the server. The user lists and live updates contain that badge alone; their JSON format is unchanged.
- Added:
-
- Added: The full list,
GET /v1/: every badge and everyone showing one, in one response, so a client needs one request. The catalog and the holders list are unchanged.badges/ all - Changed: The holders list allows 5,000 requests a minute, up from 60, the same as the full list.
- Changed:
about,how_to_get, andneedsare on every badge object, as""or[]when a badge has none. Every badge already had all three, so no response changed. If your client checked whether these fields were present, check for a non-emptyaboutorhow_to_getand for"discord_link"inneedsinstead.
- Added: The full list,
-
- Removed:
availabilityfrom the badge object. This stays inv1becausev1always said to treat any value butretiredas available, and a missing value reads the same way. A decoder that requires the field should treat it as optional.
- Removed:
-
- Added: Holder shards,
GET /v1/: the holders list in 64 parts, each with its own ETag, for clients that keep a large copy. The holders list itself is unchanged.badges/ holders/ shards/ {shard}
- Added: Holder shards,
-
- Added: The holders list,
GET /v1/, with everyone showing a badge in one response. Itsbadges/ holders badgeparameter narrows it to the users showing one badge. - Added: The live stream,
GET /v1/, which sends each change to the holders list as it happens.badges/ events - Added: Every endpoint answers a CORS preflight that allows
if-none-match, and a web page can readetag,retry-after, and the rate-limit headers. - Changed:
413,415, and429responses have the one-field body every other error has, with the codesbody_too_large,unsupported_, andmedia_ type rate_limited. TheirstatusCode,message, andcodefields are gone.
- Added: The holders list,
-
- Changed: Lookups return the badges each user shows, not every badge they hold, since users can now pick one badge or none. Responses kept their shape, and users on Automatic still get every badge they hold.
- Added:
about,how_to_get,availability, andneedson the badge object. - Fixed: A weak ETag sent back in
if-none-matchnow gets a304.
- ·
v1 -
- Released: The catalog, the POST and GET lookups, the single-user lookup, and badge art in PNG, WebP, and GIF, with a still PNG for each animated file.
Help
Questions, or something here that doesn’t match what the API does? Email hello@meipath.com or ask in the Limerino Discord. The status page’s API row covers the server behind this API.