Casino Thumbnails API

One call returns every game in your lobby with a thumbnail in your style, your own game ids next to ours, and image links ready for your front end. New games, new thumbnails and style changes arrive on their own: fetch the list once a day, or let a webhook tell you when something changed.

Quick start

  1. Sign in and create a brand. No card needed.
  2. In API & delivery, make a sandbox key. It works at once and returns our demo library: 100 well-known games in your style.
  3. Call GET /v1/games from your server and put the image links in your lobby.
  4. When you're ready, upload your lobby (CSV or the API), publish, and switch to a live key.
curl "https://api.casinothumbnails.com/v1/games?format=3x4&width=500" \
  -H "Authorization: Bearer $CT_KEY"

Keys and sandbox

Every request sends a key: Authorization: Bearer ct_live_…. Keys belong to one brand, so the brand always comes from the key, never from the request. Owners create and revoke them in the dashboard; we show a key once and keep only its hash. Keep keys on your server, never in a browser or an app.

ct_live_…Your published lobby. Can also change the lobby (PUT/POST /v1/lobby/games). At most two per brand, so you can rotate.
ct_test_…Sandbox: the demo library (100 well-known games, 5 per provider) in your style, whether or not your lobby is in. Read only.

Where your lobby has a demo game, the sandbox response carries your own ref for it, so you can test your mapping too.

List your games

GET/v1/games

Your live games, in your brand's style. Only games with an approved thumbnail are returned; the rest arrive when our studio finishes them.

format3x4 (portrait), 1x1 (square) or all. Default all.
width250, 500, 750 or 1000. Default: all four.
updated_sinceISO 8601 time: only games whose thumbnail or listing changed since then.
limit1 to 1000. Default 500.
cursorThe next_cursor from the previous page.
{
  "data": [
    {
      "id": "ct_7y79ysk6ng",            // our id for the game
      "ref": "gates-of-olympus",       // your own id, from your lobby
      "provider": "Pragmatic Play",
      "title": "Gates of Olympus",
      "style": "v2",
      "updated_at": "2026-10-18T09:12:44Z",
      "images": {
        "3x4": { "500": "https://cdn.casinothumbnails.com/t/…/500.webp?e=…&k=k1&s=…" }
      }
    }
  ],
  "brand": "br_…",
  "catalog_version": 12,
  "env": "live",
  "expires_at": "2026-10-26T00:00:00Z",  // image links work until then
  "refresh_after": "2026-10-19T09:30:00Z", // fetch the list again by then
  "next_cursor": null                     // more pages: pass ?cursor=…
}
// Every game, page by page (up to 1000 per call)
async function allGames(key) {
  const games = [];
  let cursor = 0;
  do {
    const res = await fetch(`https://api.casinothumbnails.com/v1/games?limit=1000&cursor=${cursor}`, {
      headers: { Authorization: `Bearer ${key}` },
    });
    if (!res.ok) throw new Error((await res.json()).error.message);
    const page = await res.json();
    games.push(...page.data);
    cursor = page.next_cursor;
  } while (cursor);
  return games;
}

One game

GET/v1/games/{id}

Look a game up by our id (ct_…) or by your own ref. Same fields as the list, as data; 404 when the game isn't live in this brand.

Images and caching

Image links are signed and work until expires_at, about a week. Fetch the list again by refresh_after(once a day is enough) and swap in the new links. Within a day the links stay the same, so browsers and your CDN can cache them. Images are WebP; a new thumbnail gets a new link, so nothing goes stale in a cache.

Each list response has an ETag. Send it back as If-None-Match and you get 304 Not Modifiedwith no body when nothing changed.

Sync your lobby

PUT/v1/lobby/games
POST/v1/lobby/games

Instead of uploading a CSV, your server sends its game list: provider, title and your own id. PUT replaces the lobby (games left out are removed); POST only adds or updates the games you send. We match each row to our library exactly as the dashboard does, request the games we don't have yet, and, with "publish": true, make the result live. Needs a live key; up to 20,000 games per call.

# PUT replaces your lobby: games left out are removed. POST only adds or updates.
curl -X PUT "https://api.casinothumbnails.com/v1/lobby/games" \
  -H "Authorization: Bearer $CT_LIVE_KEY" -H "Content-Type: application/json" \
  -d '{
    "games": [
      { "provider": "Pragmatic Play", "title": "Gates of Olympus", "ref": "gates-of-olympus" },
      { "provider": "Play'"'"'n GO", "title": "Book of Dead", "ref": "book-of-dead" }
    ],
    "publish": true
  }'
{
  "import": "im_4k2r9x0q1b",
  "stats": { "matched": 1838, "needs_review": 4, "unmatched": 12, "removed": 3, "requested": 12 },
  "published": { "catalog_version": 13, "live": 1838 }
}

Rows we're not sure about (needs_review) wait for a person in the dashboard. Rows we don't have yet are requested for you (requested).

Imports

GET/v1/imports/{id}

The summary of one import again: import, source (csv or api), rows, stats and created_at.

Requests

GET/v1/requests

Games and providers being made for you. A missing game is due within 24 hours (five at a time per brand); a new provider takes two to three business days.

{
  "auto_request": true,
  "counts": { "queued": 3, "in_production": 2, "done": 41 },
  "requests": [
    { "request": "rq_…", "kind": "thumbnail", "provider": "Hacksaw Gaming", "title": "Le Bandit", "ref": "le-bandit",
      "state": "in_production", "position": null, "due_at": "2026-10-19T08:00:00Z", "created_at": "…", "done_at": null, "game": null }
  ]
}

Webhooks

Add an endpoint in API & delivery → Webhooks (https only, up to five per brand) and we POST to it when something changes, so your server doesn't have to ask all day. Answer with any 2xx within 10 seconds. If you don't, we try again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then stop.

POST /hooks/casino-thumbnails
Content-Type: application/json
CT-Event: catalog.published
CT-Delivery: ev_…
CT-Signature: t=1792300000,v1=5f1c…e9

{ "id": "ev_…", "type": "catalog.published", "created_at": "…", "data": { … } }
catalog.publishedYour lobby changed (a publish from the dashboard or the API). Fetch the list again.{ "catalog_version": 13, "changed": 42, "style": "v2", "live": 1840 }
game.updatedA live game in your lobby has a new or changed thumbnail. Sent at most once an hour per game.{ "id": "ct_7y79ysk6ng", "ref": "gates-of-olympus", "title": "Gates of Olympus", "provider": "Pragmatic Play", "style": "v2" }
request.doneA game or provider you were missing is ready.{ "request": "rq_…", "kind": "thumbnail", "provider": "Hacksaw Gaming", "title": "Le Bandit", "ref": "le-bandit", "game": "ct_…" }
pingSent by Send test in the dashboard.{ "message": "Test event from Casino Thumbnails", "brand": "br_…" }

Check the signature. v1 is an HMAC-SHA256 of {t}.{raw body} with your endpoint's secret (the whole whsec_… value). Compare it in constant time and refuse old timestamps. The same event can arrive twice; the event id tells you.

import crypto from "node:crypto";
import express from "express";

const app = express();
// Verify against the raw body, exactly as it arrived.
app.post("/hooks/casino-thumbnails", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("CT-Signature") ?? "";
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto
    .createHmac("sha256", process.env.CT_WEBHOOK_SECRET) // the whole whsec_… value
    .update(`${t}.${req.body}`)
    .digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
  const ok = v1 && v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
  if (!fresh || !ok) return res.sendStatus(400);

  const event = JSON.parse(req.body);
  if (event.type === "catalog.published") refreshLobby(); // fetch /v1/games again
  res.sendStatus(200); // answer fast; do slow work afterwards
});

Errors

Errors come back as { "error": { "code": "…", "message": "…" } } with a plain-language message.

401missing_keyNo Authorization: Bearer header.
401invalid_keyThe key is wrong or was revoked.
403brand_inactiveThe brand is read-only or archived (billing).
403live_key_neededChanging the lobby needs a live key; sandbox keys only read.
400bad_format · bad_width · bad_paging · bad_updated_sinceA query parameter is out of range; the message says which values work.
400bad_body · empty_list · bad_game · bad_publishThe lobby body is not what we expect; the message names the row.
413too_many_gamesMore than 20,000 games in one call.
404not_foundNo such game, import or request in this brand.
500server_errorOur side. Try again in a minute.