Limerino

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

  1. Fetch the full list and keep it.

    Request

    curl https://api.limerino.com/v1/badges/all

    Response

    {
      "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"], … }
    }

    badges lists every badge and its images. users maps each Twitch user ID to the IDs of the badges to draw beside that user’s name.

  2. For each message, find its author’s ID (user-id in IRC, chatter_user_id in EventSub) in users and draw the first badge listed. Find that badge in badges by its id. A file’s URL is "https:" + data.host.url + "/" + file.name, for example https://api.limerino.com/v1/badges/art/founder/4/2x.webp (see Choosing an image).

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-match and keep your copy on a 304.
  • 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 kind you 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’s EventSource does that for you.
  • If the stream is refused, carry on with the timed refresh and try again after retry-after seconds.
  • A client that uses lookups can apply each holders event 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’s etag as 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, a 5xx, 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-after seconds, 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 400 means 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
One badge at 18, 36, 54, and 72 CSS pixels.
  1. 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.
  2. Pick the smallest size that’s at least that big, or 4x if none is.
  3. 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.
  4. Build the URL: "https:" + data.host.url + "/" + file.name. Take the host and the revision from data.host.url every 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

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/badges/all

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"], … }
}
FieldMeaning
badgesarray of badge objectsThe catalog: every badge, highest priority first. Each element is a badge object.
usersobjectThe 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.

StatusMeaning
200 OKThe full list.
304 Not ModifiedYour copy is current.

Get the catalog

GET /v1/badges

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.

StatusMeaning
200 OKThe catalog.
304 Not ModifiedYour copy is current.

Get the holders

GET /v1/badges/holders

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 parameterRules
badgestring, optionalA 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'
StatusMeaning
200 OKThe list, possibly {"users":{}}.
304 Not ModifiedYour copy is current.
400 Bad Requestinvalid_badge_id: badge is empty or repeated, or isn’t a badge ID (see Badge object).
404 Not Foundnot_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 choiceWhat the API returns
Automatic, which is the default, or a badge they no longer holdThe first badge they hold in catalog order.
A badge they holdOnly that badge.
NoneNothing, as if they held no badges.

Get the holders in shards

GET /v1/badges/holders/shards/{shard}

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 parameterRules
shardinteger, requiredThe 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 users into 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 holders events from the live stream to your map as usual, then forget the ETag of each shard that holds a listed user. Their etag is 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.
StatusMeaning
200 OKThe shard, possibly with no users.
304 Not ModifiedYour copy is current.
400 Bad Requestinvalid_shard: shard isn’t one or two digits, or has a leading zero, such as 07.
404 Not Foundnot_found: there’s no shard with that number. Shards run from 0 to 63.

Stream changes

GET /v1/badges/events

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":[]}}
EventMeaning
syncRefresh 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.
holdersSome 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).
  • retry is 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 sync it 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 retry delay.
StatusMeaning
200 OKThe stream.
429 Too Many Requestsrate_limited: your address has too many streams open, or opened too many in a minute (see Rate limits).
503 Service Unavailablebadges_unavailable: the server has no room for another stream, or can’t serve badges right now.

Look up users

POST /v1/badges/users

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 fieldRules
twitch_idsarray of strings, required1 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.

StatusMeaning
200 OKThe lookup, possibly {"users":{}}.
400 Bad Requestinvalid_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 Largebody_too_large: the body is over 4,096 bytes. Even 100 IDs of 20 digits come to about 2,300.
415 Unsupported Media Typeunsupported_media_type: the body has no content type, or one other than JSON or plain text.

Look up users (GET)

GET /v1/badges/users?twitch_ids={ids}

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 parameterRules
twitch_idsstring, requiredThe 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"]}}
StatusMeaning
200 OKThe lookup.
304 Not ModifiedYour copy is current.
400 Bad Requestinvalid_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/badges/twitch/{id}

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 parameterRules
idstring, requiredA 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":[]}.

StatusMeaning
200 OKThe user’s badges, possibly none.
304 Not ModifiedYour copy is current.
400 Bad Requestinvalid_user_id: the ID isn’t 1 to 20 digits.

Get badge art

GET /v1/badges/art/{badge}/{revision}/{file}

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"
StatusMeaning
200 OKThe image: image/webp, image/gif, or image/png.
304 Not ModifiedYour copy is current.
404 Not Foundnot_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.

FieldMeaning
idstringThe 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.idstringThe same as id.
data.namestringThe badge’s name, for a list or a detail view: 1 to 64 characters (Unicode code points), with no control characters.
data.tooltipstringHover text and screen-reader label, with the same limits as name. Use it, not name, for both.
data.aboutstringWhat the badge stands for: one plain-text paragraph of at most 200 characters, or "" when the badge has none.
data.how_to_getstringHow 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.urlstringThe base URL of the badge’s current images, without a scheme, such as //api.limerino.com/v1/badges/art/founder/4.
data.host.filesarrayThe 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

FieldMeaning
namestring1x, 2x, 3x, or 4x, then .webp, .gif, or .png.
formatstring"WEBP", "GIF", or "PNG".
width, heightnumber18, 36, 54, or 72 pixels, for 1x to 4x. Always square.
frame_countnumber1 for a still image, more for an animation.
static_namestring, optionalOnly on animated files: the name of the still PNG at the same size, such as "2x.png".
sizenumberThe 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 1x to 4x.
  • 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_name is present only on animated files, where it names a PNG. 7TV sends it on every file. When it’s missing, use name, and never build _static file 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_count per file.
  • There’s no data.tag, and badge IDs are short names, not 7TV object IDs.
  • about, how_to_get, and needs are 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

Responsecache-control
Catalogpublic, max-age=300, stale-while-revalidate=3600
Full listpublic, no-cache
Holders list and holder shardspublic, max-age=60, stale-while-revalidate=300
Live streamno-store, no-transform
POST lookupno-store
GET lookuppublic, max-age=60
Single-user lookuppublic, max-age=60, stale-while-revalidate=300
Badge artpublic, 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.

EndpointRequests per minute
Full list5,000
Catalog600
Holders list5,000
Holder shards, shared by all 64640
Live stream, new streams30
POST and GET lookups, shared1,200
Single-user lookup600
Badge art3,000
Preflights (OPTIONS) of /v1/badges1,200
Preflights of every path under it, shared1,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.

HeaderMeaning
x-ratelimit-limitRequests allowed per window.
x-ratelimit-remainingRequests left in this window.
x-ratelimit-resetSeconds until the window resets. A count, not a timestamp.
retry-afterWith 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).

StatusError
400 Bad Requestinvalid_twitch_idsA lookup’s IDs or body break the rules (see Failures).
400 Bad Requestinvalid_user_idThe single-user lookup’s ID isn’t valid.
400 Bad Requestinvalid_badge_idThe holders list’s badge parameter isn’t a badge ID.
400 Bad Requestinvalid_shardA holder shard’s number isn’t one or two digits, or has a leading zero.
404 Not Foundnot_foundNo such badge, badge image, or holder shard. For a badge or an image, refresh your catalog.
413 Payload Too Largebody_too_largeA lookup’s body is over 4,096 bytes.
415 Unsupported Media Typeunsupported_media_typeA lookup’s body has no content type, or an unsupported one.
429 Too Many Requestsrate_limitedThe request is over a rate limit, or your address has too many live streams open. Back off (see Failures).
500 Internal Server Errorinternal_errorSomething failed on the server. Back off (see Failures).
503 Service Unavailablebadges_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 fetch settings are right. credentials: "include" fails against a wildcard origin.
  • A page can read etag, retry-after, and the three x-ratelimit headers: access-control-expose-headers lists them.
  • Every endpoint answers a preflight. OPTIONS returns 204 and allows GET and POST with the content-type and if-none-match headers.
  • 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 authorization or an x- header, fails the preflight.
  • EventSource opens 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, and format. Skip a catalog entry whose kind you don’t know, and skip needs values 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_badges on 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: The full list, GET /v1/badges/all: every badge and everyone showing one, in one response, so a client needs one request. The catalog and the holders list are unchanged.
  • Changed: The holders list allows 5,000 requests a minute, up from 60, the same as the full list.
  • Changed: about, how_to_get, and needs are 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-empty about or how_to_get and for "discord_link" in needs instead.
  • Removed: availability from the badge object. This stays in v1 because v1 always said to treat any value but retired as available, and a missing value reads the same way. A decoder that requires the field should treat it as optional.
  • Added: Holder shards, GET /v1/badges/holders/shards/{shard}: the holders list in 64 parts, each with its own ETag, for clients that keep a large copy. The holders list itself is unchanged.
  • Added: The holders list, GET /v1/badges/holders, with everyone showing a badge in one response. Its badge parameter narrows it to the users showing one badge.
  • Added: The live stream, GET /v1/badges/events, which sends each change to the holders list as it happens.
  • Added: Every endpoint answers a CORS preflight that allows if-none-match, and a web page can read etag, retry-after, and the rate-limit headers.
  • Changed: 413, 415, and 429 responses have the one-field body every other error has, with the codes body_too_large, unsupported_media_type, and rate_limited. Their statusCode, message, and code fields are gone.
  • 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, and needs on the badge object.
  • Fixed: A weak ETag sent back in if-none-match now gets a 304.
· 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.