<!-- Generated by tools/docs-md.js from the pages under https://inspekt.gg/docs. Do not edit by hand. -->

# Inspekt for AI agents

Everything needed to put CS2 skins on a live site with Inspekt: the embed, the
Render API and the inventory lookup, with every parameter, bound, refusal code
and limit. Generated from the published documentation, so it says what the
service does today.

## 1. What Inspekt is

Inspekt renders a specific CS2 item, at its float, pattern, stickers, charm,
StatTrak counter and name tag. Two surfaces do that: `GET /api/still`, which
returns a rendered webp, and `/embed`, an iframe holding the live 3D viewer.
Both take a Steam inspect link or a catalog id from
https://inspekt.gg/assets/skins.json, and both are on every plan for a site. Lens, the plan
for one person's Inspekt Lens browser extension, includes the Render API alone.

## 2. Sign up and get a key

1. Open https://inspekt.gg/dashboard and sign in with an email address. There is no
   password: the site emails a single-use link.
2. A publishable key is issued on the spot. It looks like
   `cs2pk_` followed by 32 hex characters.
3. Pick a plan on the same page, or start a 10-day free trial of one,
   which needs no card. A key with neither, or whose trial has ended, is refused
   with `plan_required` and HTTP 402 on all three surfaces until the account
   subscribes.
4. Add the origin your page runs on under Allowed origins. A new key has none,
   so a request from your page is refused with `origin_not_allowed` until the
   origin is added. Section 3 has the exact rule.

## 3. The key and the origin allowlist

A key is required on all three surfaces. A request with no key, from another
site's page or from a server, is refused with HTTP 401 and `key_required`.
Only inspekt.gg's own pages, and the Open Graph images those pages publish for
link previews, which carry a `sig` parameter, are served without one. A signed
image is served to a server fetching it for a link preview and to a browser tab
opening it directly; loaded inside another site's page it is refused like any
request with no key.

The publishable key appears in the page source, in the iframe's `src` and
the `<img>` URL. The origin allowlist decides where it works, and the
surfaces check it in two ways.

- `/embed`: the server reads the page's origin from the `Referer` the
  iframe sends, so a key lifted from one site and pasted into another gets the
  refusal card with 403 `origin_not_allowed`. A served frame also carries a
  `Content-Security-Policy: frame-ancestors` header listing the key's
  registered origins, and the visitor's browser draws the viewer only when
  every page around it is on that list.
- `/api/still` and `/api/inventory-lookup`: the server reads the request's
  `Origin` header, or the origin in its `Referer` when it has none. An
  `<img>` sends only the `Referer`, and a browser puts the page's origin in
  it by default. A registered origin is served, and any other is refused with
  403 `origin_not_allowed`.

A keyed request that is missing both headers is refused with 403
`origin_required`. Put `referrerpolicy="strict-origin"` on every `<img>`
and on every `<iframe>` you build yourself (`CS2Embed.mount()` sets it on
its own frames), so the browser sends the origin even on a page whose own
`Referrer-Policy` withholds the referrer. From a server, send the key
together with an `Origin` header naming one of the key's registered origins:
that is how a server pre-warms stills.

An account may also hold a secret key (`cs2sk_` and 48 hex characters) and
mint tokens with it (`cs2tk_` and 50 base64url characters). The secret key is
a credential for a server: it is shown once, held as a hash, works with no
Origin or Referer, and is outside the per-address daily cap. A token is minted
by POST /api/token with the secret key, lasts at most 3600 seconds, and works
wherever the publishable key works, against the same origin list. An account may
switch on "Require tokens", and then its publishable key answers 403
`token_required` while tokens and the secret key keep serving. Section 7 has
the endpoint, the refusal codes and a Node example.

Key shape accepted on the wire: `/^[a-z0-9_-]{4,64}$/i`, which every one of
the three formats above is inside.

An origin must match `^https?://(\*\.)?LABEL(\.LABEL)*(:\d{1,5})?$`, where
`LABEL` is `[a-z0-9](?:[a-z0-9-]*[a-z0-9])?`, be at most 255 characters, and
carry a port in 1 to 65535 if it carries one at all. A `*.` wildcard matches
any subdomain at any depth but not the apex, and needs at least two labels
under it. Origins are stored lowercased and without a trailing slash. Scheme
and port are both part of the match, so `https://shop.example` and
`http://localhost:3000` are two separate entries.

| Origin | Result |
| --- | --- |
| `https://shop.example` | accepted |
| `http://localhost:3000` | accepted |
| `https://*.example.com` | accepted |
| `https://shop.example/listings` | refused |
| `https://*.com` | refused |
| `https://shop.example:99999` | refused |


## 4. Quickstart

_From https://inspekt.gg/docs/quickstart_

Paste this tag into a page to show a CS2 item. The image works in every browser.

```html
<img src="https://inspekt.gg/api/still?id=ak47_cu_ak47_cobra_gen&wear=0.1662&seed=55&view=front&w=640&h=400&key=cs2pk_your_key_here"
     referrerpolicy="strict-origin">
```

To name the item with an inspect link, replace `id=…&wear=…&seed=…` with `inspect=<the link>`. [Render API](https://inspekt.gg/docs/render-api) lists every parameter, the cache rules and the placeholder.

Keep `referrerpolicy="strict-origin"` on every `<img>` and on every `<iframe>` you write yourself; `CS2Embed.mount()` sets it on the frames it builds. It makes the browser send your origin even when the page's own `Referrer-Policy` withholds the referrer. A keyed request without an `Origin` or `Referer` header is refused with [`origin_required`](https://inspekt.gg/docs/errors#access-codes).

The **Download for AI agents** button above downloads one Markdown file for a coding agent, with every endpoint, bound, refusal code and worked example in these docs.

### Render API or embed

|  | Render API | Embed |
| --- | --- | --- |
| Output | One webp image of the item | The live 3D viewer, in an iframe |
| Integration | One `<img>` tag | A script tag and an element |
| Needs WebGPU | No | Yes, in the visitor's browser |
| Time to ship | An afternoon | A day or two |
| Counts as | One render | One embed load |

Start with stills, then add the embed to the pages that need 3D. [The listing grid demo](https://inspekt.gg/embed-demo.html) works this way and shows the code for each card.

### Embed in four steps

> Every snippet below needs its `key` line. From your site or your server, a request without a key is refused with `401` and `key_required` ([Error codes](https://inspekt.gg/docs/errors#access-codes)).
>
> Get a key before you start. [Sign in](https://inspekt.gg/dashboard) with an email address to get one. The key works once the account starts a 10-day free trial or picks a plan. While you are signed in, this page replaces every `cs2pk_your_key_here` with your key, so Copy gives you a working snippet. A new key has **no allowed origins**. Register the origin your page runs on, or the frame shows an `origin_not_allowed` card.

1. **Copy an inspect link**

   Use a link that carries the item as hex after `csgo_econ_action_preview`. Two kinds do: a link copied from an item inside CS2, and a "gen" link like this one:

   inspect link:

   ```
   steam://rungame/730/76561202255233023/+csgo_econ_action_preview 001807209A0228043004388F85D7F30340950512E67D72
   ```

   Send the whole link, URL-encoded or not, or the hex alone.

   A link from a Steam Community Market listing or a Steam inventory page ends in `M…A…D…` or `S…A…D…`. It points at an item stored on Steam's servers, which only a signed-in CS2 session can read. The Render API refuses it with a `400`, and the embed shows its [`bad-inspect` card](https://inspekt.gg/docs/errors#fallback-codes).

   To name an item by its catalog id instead, pass `s` with `wear` and `seed` (see [Embed parameters](https://inspekt.gg/docs/embed)). [Catalog ids](https://inspekt.gg/docs/render-api#catalog-ids) lists every id.
2. **Add the loader script, once per page**

   ```html
   <script src="https://inspekt.gg/cs2embed.js"></script>
   ```

   The loader is a few kilobytes and needs no dependencies or build step. It adds one global, `CS2Embed`.
3. **Mount the viewer on an element with a size**

   ```html
   <div id="viewer" style="width:640px;height:400px"></div>
   <script src="https://inspekt.gg/cs2embed.js"></script>
   <script>
     var viewer = CS2Embed.mount(document.getElementById('viewer'), {
       inspect: 'steam://rungame/730/76561202255233023/+csgo_econ_action_preview 001807209A0228043004388F85D7F30340950512E67D72',
       key: 'cs2pk_your_key_here',
       autorotate: true,
     });

     viewer.on('loaded', function (e) {
       console.log('rendered', e.id, 'in', e.ms, 'ms');
     });
   </script>
   ```

   `mount()` returns a handle with `setView`, `setScene`, `screenshot`, and `on` for the `loaded`, `error` and `state` events. [postMessage API](https://inspekt.gg/docs/postmessage) lists every method and event, and [Embed parameters](https://inspekt.gg/docs/embed) lists every option.
4. **Reload and check**

   The frame shows a still of your item within a second, then switches to the live 3D view once the textures load. Rotating and zooming then run in the visitor's browser.

### Before you ship

- **The first request for a new still waits while it renders.** A render takes about a second, and longer while the render queue is long. Pre-warm the URL from your server so no visitor waits, or send `wait=0` to show a placeholder at once; see [Caching and the placeholder](https://inspekt.gg/docs/render-api#caching-and-the-placeholder).
- **`origin_not_allowed` means the page's origin is not registered.** Add it under Allowed origins in the [dashboard](https://inspekt.gg/dashboard). Scheme and port both count, so `https://shop.example` and `http://localhost:3000` are two entries.
- **`key_required` means the `key` option is missing or empty.** [Error codes](https://inspekt.gg/docs/errors) lists every code.
- **Log refusals in code.** A refused frame shows a card with the reason and fires an `error` postMessage event with the same code.

### Next

#### Read now

[Render API Every parameter of the `<img>` above, and the cache.](https://inspekt.gg/docs/render-api) [Embed parameters Each option with its bounds and default.](https://inspekt.gg/docs/embed) [Error codes Each refusal and what to do about it.](https://inspekt.gg/docs/errors)

#### Later

[Frameworks Copy-paste HTML, React, Vue, or a raw iframe.](https://inspekt.gg/docs/frameworks) [postMessage API Every event in and out of the frame.](https://inspekt.gg/docs/postmessage) [Listing grid demo A marketplace page using both, with the code for each card.](https://inspekt.gg/embed-demo.html) [Inventory API Look up a Steam account's items.](https://inspekt.gg/docs/inventory-api) [Limits & SLA Quotas, rate limits, embed loads and uptime.](https://inspekt.gg/docs/limits) [Pricing Flat monthly plans with hard quota caps.](https://inspekt.gg/pricing)

## 5. Embed parameters

_From https://inspekt.gg/docs/embed_

The smallest working embed:

```javascript
CS2Embed.mount(el, { inspect: inspectLink, key: 'cs2pk_your_key_here' });
```

Only the item (`inspect` or `s`) and `key` are required. Pass each option to `CS2Embed.mount()` or as a query parameter on the `/embed` URL. The server and the frame both enforce the bounds. An out-of-range or malformed value is replaced by its default and named in the `X-CS2-Dropped` response header. Only a query string over 4,096 bytes is refused, with a `400`.

### Required input

Pass either `inspect` or `s`.

- **`inspect`**: an inspect link that carries the item as hex, either copied from an item inside CS2 or built as a "gen" link. The frame decodes the item, its float and pattern, stickers, charm and StatTrak count from it. Use this by default. A Steam Community Market or Steam inventory link ends in `M…A…D…` or `S…A…D…`, carries no item data, and shows the [`bad-inspect` card](https://inspekt.gg/docs/errors#fallback-codes).
- **`s`**: a catalog id, with `wear`, `seed`, `st` and `name` alongside it. Each id is a row of `/assets/skins.json`; see [Catalog ids](https://inspekt.gg/docs/render-api#catalog-ids).

> **If you pass both, `inspect` wins.** The frame ignores `s`, `wear`, `seed` and `st` and uses the link's own float, pattern, stickers, charm and StatTrak count.

### Item and condition

| Param | Type / bounds | Default | Notes |
| --- | --- | --- | --- |
| inspect | Steam inspect link, up to 1,024 characters | n/a | Decoded client-side. Carries its own wear, seed and stickers; see above. |
| s | `/^[a-z0-9][a-z0-9_.-]*$/i`, up to 128 characters | n/a | A catalog id. An unknown id fails to load (see [fallback codes](https://inspekt.gg/docs/errors#fallback-codes)). |
| wear | number, `0`-`1` | kit default | The float. A value outside 0 to 1, or one that is not a number, is replaced by the kit default. A value outside the finish's own range, such as 0.05 on a finish made from 0.10 to 0.70, is drawn at the nearest end of that range. |
| seed | integer, `0`-`1000`, the game's own range | `0` | Pattern seed. Without one, the item shows seed 0, the pattern in Valve's inventory picture. |
| st | `slot:stickerId:wear[:offsetX:offsetY:rotation]`, comma-separated, up to 5, slots `0`-`7` | none | Stickers, with CS2's free placement. Same grammar and bounds as the [Render API](https://inspekt.gg/docs/render-api#stickers). A malformed string is dropped whole, and the item is shown without stickers. An offset that puts a sticker's centre outside the weapon's sticker area is dropped on its own. The sticker then sits at its slot's default position, and `x-cs2-dropped` names the offset, as in `st slot 2 offset`. A seventh field is ignored and named as `st scale`. |
| charm | `defIndex[:offsetX:offsetY:offsetZ][@seed][~stickerId]` | none | One charm, where it hangs, its pattern seed, and the sticker sealed in a Sticker Slab. The grammar, the bounds, the charm list, spots and seeds are in the Render API's [Charms](https://inspekt.gg/docs/render-api#charms) section, and the frame's still and its 3D view draw the same charm. A malformed or out-of-bounds value is dropped whole: the item is shown without a charm, and `x-cs2-dropped` names `charm`. A charm we hold no model for is skipped. |
| stattrak | integer, `0`-`999999` | none | The kill counter. Any value makes the item StatTrak, so `stattrak=0` is a StatTrak item with zero kills. A StatTrak knife shows no counter, because the game etches its count into the blade. |
| name | ≤20 printable characters, no control characters | none | A name tag, trimmed. A value over 20 characters or with a control character is dropped whole. |

### Scene, view and chrome

| Param | Type / bounds | Default | Notes |
| --- | --- | --- | --- |
| scene | `/^(plate\|pano\|bg)_[a-z0-9_]{1,58}$/i` | none (see `bg`) | A backdrop id, such as `plate_train_video`. `plate_` is a video loop of a CS2 map, `pano_` a 360° panorama and `bg_` a plain background. An unknown id falls back to the default and is named in `x-cs2-dropped`. With `scene` and `bg` both unset, the item stands on its rarity plate and the frame can open on a still. A video backdrop adds a few tens of megabytes to your page. See [First paint](#first-paint). |
| bg | `rarity` \| `black` \| `white` \| `charcoal` \| `ember` \| `steel` \| `violet` \| the eight `rarity-*` tier plates | `rarity`, or the backdrop `scene=` named | The background behind the item. The same list as the [Render API](https://inspekt.gg/docs/render-api#backgrounds), except the stills-only `transparent`. `rarity` is the item's own tier plate, resolved the same way as for a still, so the opening still and the 3D view match. `bg` and `scene` share one setting. A plain background brings its own lighting and replaces the backdrop, so `bg` wins if you send both. |
| light | `icon` \| `inspect` | `icon` | The lighting, the same two as the [Render API](https://inspekt.gg/docs/render-api#lighting), for the opening still and the 3D view alike. It lights a plain background or a rarity plate. A `scene=` map loop or panorama keeps its own light. An unknown value falls back to the default and is named in `x-cs2-dropped`. |
| view | `front` \| `back` \| `top` \| `hero` | `front` | The starting camera framing. The same set as the [Render API](https://inspekt.gg/docs/render-api#views), where the default is `hero`. |
| autorotate | `1`/`true` to enable | off | Off when the visitor has `prefers-reduced-motion` set. Stops at the visitor's first pointer-down and stays off. |
| ui | `minimal` \| `none` | `minimal` | `none` hides the name plate. The corner wordmark shows with either value. See [Frame chrome](#ui-chrome). |
| theme | `dark` \| `light` | `dark` | Styles the name plate and wordmark only. |

### Identity and access

| Param | Type / bounds | Default | Notes |
| --- | --- | --- | --- |
| key | `/^[a-z0-9_-]{4,64}$/i` | required | Your publishable key. It is visible in the iframe's `src` and your page source. The origins you register in the [dashboard](https://inspekt.gg/dashboard) protect it. |
| origin | an `http(s)` origin | the page's referrer, if any | Your page's origin, where the frame sends its postMessage events. `CS2Embed.mount()` sets it. Set it yourself on a hand-built iframe; see [postMessage API](https://inspekt.gg/docs/postmessage). |

#### Frame chrome

`ui=minimal`, the default, shows a small plate with the item's name, and `ui=none` hides it. The Inspekt wordmark in the bottom-right corner shows on every plan, with either value. Clicking it opens the item in the full studio with the same wear, seed, stickers, charm, StatTrak count and name tag. No plan requires other attribution.

### The loader: `CS2Embed.mount(el, options)`

The loader takes every parameter above as an options object, plus two fields of its own:

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| title | string | `"3D skin viewer"` | The iframe's accessible `title` attribute. |
| lazy | boolean | `true` | Pass `false` to load eagerly (`loading="eager"`) instead of the browser's native lazy loading. |

```javascript
var viewer = CS2Embed.mount(document.getElementById('viewer'), {
  inspect: 'steam://rungame/730/76561202255233023/+csgo_econ_action_preview 001807209A0228043004388F85D7F30340950512E67D72',
  scene: 'plate_train_video',
  view: 'front',
  autorotate: true,
  ui: 'minimal',
  theme: 'dark',
  key: 'cs2pk_your_key_here',
  title: 'AK-47 | Redline in 3D',
  lazy: false,
});
```

`mount()` returns a handle with the methods `setItem`, `setScene`, `setView`, `setAutorotate`, `setQuality`, `resetView`, `screenshot` and `destroy`, plus `on` and `off` for events. [postMessage API](https://inspekt.gg/docs/postmessage) lists every command and event, and [Frameworks](https://inspekt.gg/docs/frameworks) has React and Vue wrappers.

The loader frames `/embed` on the host it was loaded from and accepts messages only from that host. Load it from `https://inspekt.gg` with a script tag. It then frames `https://inspekt.gg/embed` and sets `CS2Embed.origin` to `https://inspekt.gg`. A copy bundled into your scripts, or served from your own site, frames your site's `/embed` instead. It builds each iframe with `referrerpolicy="strict-origin"`, so the frame sends your page's origin even when the page's own `Referrer-Policy` withholds the referrer.

### Raw iframe

To embed without the loader script, build the iframe yourself. Every parameter above is a query parameter on `GET /embed`:

```html
<iframe
  src="https://inspekt.gg/embed?inspect=steam%3A%2F%2Frungame%2F730%2F76561202255233023%2F%2Bcsgo_econ_action_preview%20001807209A0228043004388F85D7F30340950512E67D72&autorotate=1&key=cs2pk_your_key_here&origin=https%3A%2F%2Fyoursite.example"
  style="width:640px;height:400px;border:0"
  loading="lazy"
  allow="autoplay; fullscreen"
  referrerpolicy="strict-origin"
  title="3D skin viewer"
></iframe>
```

> Set `origin` on a hand-built iframe. Without it, the frame uses your page's `document.referrer`, which some browsers withhold. With neither, the frame addresses its postMessage events to `*`.

### First paint

The frame opens on a still of your item, the same picture [GET /api/still](https://inspekt.gg/docs/render-api) serves for that camera, background and size. It switches to the live 3D view once that view has drawn its first frame. The still is about 18 KB and arrives in the first second. The 3D view loads tens of megabytes of textures, so how long it takes depends on the visitor's connection. The still is part of the embed load and does not count as a render.

Two events tell you which is on screen. `loaded` fires when the item is visible, usually as the still. `interactive` fires when the 3D view has drawn and accepts `setView`, `screenshot` and the other commands. `autorotate` starts once the 3D view has drawn.

In three cases the frame opens on a loading card instead of the still:

1. You set a `scene` backdrop. Stills are rendered on the default plates only.
2. The frame's size needs a still outside the Render API's 160 to 1600 px bounds, such as a box smaller than 160 px or wider than 10:1.
3. The still for that item and size has not been rendered yet. The request queues the render, so the next visitor gets the still. Ask us to pre-render your catalog before you launch.

### Access control

Two checks decide whether the frame renders. Our server reads your page's origin from the `Referer` the iframe sends and checks it against the key's registered origins, so a key copied from your page source and used on another site gets the refusal card with `origin_not_allowed`. The visitor's browser checks the origin again: a served frame carries a `Content-Security-Policy: frame-ancestors` header listing the key's registered origins, and the browser draws the viewer only when every page around it is on that list. Manage origins in the [dashboard](https://inspekt.gg/dashboard). [Error codes](https://inspekt.gg/docs/errors) shows what a refusal looks like.

### Embed loads

An embed load counts once per viewer, per item, per 24 hours. Twelve different items on a listing page are twelve loads. The same visitor scrolling back to one, reloading, or returning within 24 hours of the load that counted is not counted again. Rotating and zooming run on the visitor's device and do not count.

The item is `inspect` or `s` plus `wear`, `seed`, `st`, `charm`, `stattrak` and `name`. Changing `view`, `bg`, `light`, `scene`, `ui`, `theme` or `autorotate` does not start a new count. Each served `/embed` response carries an `X-CS2-Counted` header, `1` when the load counted and `0` when it did not, and the refusal card leaves the header out. `/api/still` sends the same header on every answer, with `0` on a refusal. [Limits & SLA](https://inspekt.gg/docs/limits#embed-loads) has the full rule, including how a returning viewer is recognised without cookies.

## 6. postMessage API

_From https://inspekt.gg/docs/postmessage_

Use [the loader](#using-the-loader) unless you drive a hand-built `<iframe>`. Its `handle.on()` and `handle.set…()` methods build every message on this page for you.

### The envelope

Every message, in either direction, is a plain object with `channel: "cs2embed"` and a `type`. Both sides ignore any other message on the window, such as one from a browser extension or an ad script.

#### Origin pinning

Both directions are pinned to an origin:

- **Outbound (frame → host)** messages go to the `origin` parameter on the embed URL, which the loader sets. Without it they go to your page's `document.referrer`, and with neither to `*`. They carry the item's id, wear, seed and name tag, timings, load progress, refusal and fallback codes, and screenshot images.
- **Inbound (host → frame)** messages are accepted only from `window.parent`, at the exact origin resolved above.
- **The `origin` parameter** must be a bare `http` or `https` origin of at most 255 characters. A trailing slash is trimmed. A value with a path, a query string or another scheme is ignored, and messages fall back to `*`.

### Frame → host: events

Subscribe with `handle.on(type, fn)`. Without the loader, listen for `message` events on `window`.

| Type | Payload | When |
| --- | --- | --- |
| ready | `{ backend }` | The frame has started and accepts commands. The loader queues anything you send earlier and sends it when this fires. |
| loaded | `{ id, name, ms, notes }` | The item is on screen. Usually this is the opening still ([first paint](https://inspekt.gg/docs/embed#first-paint)), about a second in, before the 3D view has drawn. It also fires alongside a `fallback`, because the still stays visible under the fallback's note. `ms` counts from the frame's start. `notes` lists non-fatal decode notes and is usually empty. |
| interactive | `{ id, ms, notes }` | The 3D view has drawn its first frame. The visitor can now orbit, and `screenshot` returns the live render. The loader queues commands sent before this. `notes` is the complete list, including notes from applying the inspect link's stickers and charm. Notes number sticker slots from 0, as `st` does. |
| error | `{ code, message }` | An **access** refusal, such as an unknown key, an unregistered origin or a spent quota. Fires instead of loading the frame. See [Error codes](https://inspekt.gg/docs/errors). |
| fallback | `{ code, message, id }` | An **item** problem after access was granted: missing WebGPU support, a bad inspect link, an item outside the catalog, or a load failure. The frame keeps its still and shows the reason in a note over it. A frame without a still shows a 2D card instead. See [fallback codes](https://inspekt.gg/docs/errors#fallback-codes). |
| progress | `{ value, label }` | Load progress, `value` from `0` to `1`. |
| state | `{ id, wear, seed, nametag }` | The current item state, sent after each change, including changes from your own `setItem` commands. |
| screenshot | `{ requestId, blob, error? }` | The answer to a `screenshot` command, matched by `requestId`. `blob` is `null` and `error` is set on failure. The loader turns this into the `Promise` that `handle.screenshot()` returns. |

### Host → frame: commands

Send with the matching `handle.set…()` / `handle.resetView()` / `handle.screenshot()` method, or post the object yourself.

| Type | Payload | Effect |
| --- | --- | --- |
| setItem | `{ inspect?, s?, wear?, seed?, st?, name? }` | Swap the item without remounting the frame. Takes the same fields and rules as the embed parameters. |
| setScene | `{ scene }` | Change the backdrop. |
| setView | `{ view }` | `front` \| `back` \| `top` \| `hero`. |
| setAutorotate | `{ on }` | Turn autorotate on or off. |
| setQuality | `{ renderScale }` | Set the internal render resolution. |
| resetView | `{}` | Return the camera to its default framing. |
| screenshot | `{ requestId, width?, height?, format?, quality? }` | Capture the current frame as a blob, answered by a `screenshot` event with the same `requestId`. |

### With the loader

```javascript
var viewer = CS2Embed.mount(el, { inspect: inspectLink });

viewer.on('loaded', function (e) { console.log(e.id, e.ms + ' ms'); });
viewer.on('error', function (e) { console.error('access refused:', e.code, e.message); });
viewer.on('fallback', function (e) { console.warn('showing fallback:', e.code); });

viewer.setView('back');
viewer.setAutorotate(false);

viewer.screenshot({ width: 1200, height: 630, format: 'image/webp', quality: 0.9 })
  .then(function (blob) { /* a Blob, ready to upload or download */ })
  .catch(function (err) { console.error(err); });

viewer.destroy(); // unmounts the iframe and drops all pending listeners
```

### With a raw iframe

```javascript
var EMBED_ORIGIN = 'https://inspekt.gg';
var frame = document.querySelector('iframe.cs2-viewer');

// send commands once the frame is ready
window.addEventListener('message', function (e) {
  if (e.origin !== EMBED_ORIGIN) return;
  var d = e.data;
  if (!d || d.channel !== 'cs2embed') return;

  if (d.type === 'ready') {
    frame.contentWindow.postMessage(
      { channel: 'cs2embed', type: 'setView', view: 'back' },
      EMBED_ORIGIN
    );
  }
  if (d.type === 'error') {
    console.error('embed refused:', d.code, d.message);
  }
});
```

Add `origin=https://yoursite.example` to the iframe's `src` so the frame addresses its messages to your page. See [Embed parameters](https://inspekt.gg/docs/embed).

## 7. Render API

_From https://inspekt.gg/docs/render-api_

GET `/api/still` returns one `image/webp` of one CS2 item, drawn by the same renderer as the live [embed](https://inspekt.gg/docs/embed). Use the URL as the `src` of an `<img>` or as an Open Graph image.

> **The first request for a picture waits while it renders.** A render takes about a second, and while many are queued the wait is longer, or the request is answered at once with a placeholder and the picture keeps its place; see [The render queue](#render-queue). Every request after it is served from the cache. To answer at once with a placeholder every time, send `wait=0`; see [Caching and the placeholder](#caching-and-the-placeholder).

### Request

Name the item with its Steam inspect link, URL-encoded, and say how to draw it:

```http
GET https://inspekt.gg/api/still?inspect=steam%3A%2F%2Frungame%2F730%2F76561202255233023%2F%2Bcsgo_econ_action_preview%20001807209A0228043004388F85D7F30340950512E67D72&view=front&bg=rarity&w=640&h=400&key=cs2pk_your_key_here
```

That link is an AK-47 | Redline at float 0.24 and pattern 661. `view=front` turns it square-on, `bg=rarity` puts it on its Classified plate, and `w` and `h` make the picture 640 × 400.

The link sets the item: the finish, by its definition index and paint index, and its float, pattern, stickers, charm, StatTrak count and name tag. The link's rarity and quality fields are ignored, and `bg=rarity` takes the tier from the catalog. Only `view`, `bg`, `light`, `w`, `h`, `wait` and `key` are read beside the link. A link works when it carries the item's data as hex: one copied for an item inside CS2, or a "gen" link like this one, including one you build yourself. A link from a Steam Community Market listing or a Steam inventory page ends in `S…A…D…` or `M…A…D…` and is refused with a `400`.

#### Catalog ids

To name an item without a link, send its catalog id and the item parameters instead. `id=ak47_cu_ak47_cobra_gen&wear=0.24&seed=661` is the same Redline and returns the same cached file.

Every catalog id is a row of [`/assets/skins.json`](https://inspekt.gg/assets/skins.json), one row per finish the service renders. A row carries the `id`, the `name` as Steam shows it (`AK-47 | Redline`), the `itemIndex` and `paintKitIndex` an inspect link carries as its definition index and paint index (`7` and `282`), and the float range the finish is made in, `wearRemapMin` to `wearRemapMax`. The file is about 0.8 MB and has no CORS headers, so fetch it from your server or a build step. An id is the row's `weapon` without its `weapon_` prefix, then its `kit`, then `_gen`: `weapon_ak47` and `cu_ak47_cobra` make `ak47_cu_ak47_cobra_gen`. The [studio](https://inspekt.gg/) shows the same id as `s=` in its address bar while the item is open.

### Response

The response is the render. The first request for a picture waits while it renders, and every request after it gets the same file from the cache:

```http
HTTP/1.1 200 OK
content-type: image/webp
cache-control: private, max-age=86400
x-cs2-counted: 1
x-request-id: b545991495f2
```

With `wait=0`, the first request gets a placeholder at once instead:

```http
HTTP/1.1 200 OK
content-type: image/webp
cache-control: no-store
x-cs2-notes: this is a placeholder at the default wear and pattern, with no stickers, charm, StatTrak counter or name tag: retry for the exact render
x-cs2-counted: 1
x-request-id: 1820c7979e64
```

Without `wait=0`, a request from a server whose picture is far down the render queue gets `202 Accepted` at once, uncounted, with no picture: a few bytes of JSON and two headers, `Retry-After`, the seconds until the picture is expected, and `x-cs2-queued`, how many pictures are drawn before it. Ask again after the `Retry-After`, and keep only a `200`. A request for an image gets the placeholder as a `200`, which a browser can draw; see [The render queue](#render-queue).

```http
HTTP/1.1 202 Accepted
content-type: application/json; charset=utf-8
cache-control: no-store
retry-after: 118
x-cs2-queued: 760
x-cs2-counted: 0
x-request-id: 3f0a9c21d7b4

{"queued":true,"ahead":760,"retryAfter":118}
```

`x-cs2-notes` lists, separated by semicolons, what the picture shows differently from the request: that it is a placeholder, a wear outside the finish's range, a sticker in a slot the weapon cannot show or lacks a position for, a sticker or sealed sticker missing from our art, an ignored sticker scale, or, from an inspect link, a charm we hold no model for or a sticker past the five a weapon draws. A note numbers a sticker's slot from 0, as `st` does. `x-cs2-counted` is `1` when the request counted toward the plan's render quota and `0` when it did not; see [Quota](#quota-and-billing). Every response carries an `x-request-id`. Quote it when you contact us about a request.

### Parameters

| Param | Values | Default |
| --- | --- | --- |
| inspect | A Steam inspect link, URL-encoded | required, unless `id` is sent, or `charm` for a [standalone charm](#standalone-charms), or `sticker` for a [standalone sticker](#standalone-stickers) |
| id | A catalog id, `/^[a-z0-9][a-z0-9_.-]*$/i`; see [Catalog ids](#catalog-ids) | required, unless `inspect` is sent, or `charm` for a [standalone charm](#standalone-charms), or `sticker` for a [standalone sticker](#standalone-stickers) |
| wear | `0` to `1`. A value outside the finish's own range is drawn at the nearest end of it and named in `x-cs2-notes` | the finish's default |
| seed | Whole number, `0` to `1000` | `0`, the pattern in Valve's inventory picture |
| st | Up to 5 stickers; see [Stickers](#stickers) | none |
| sticker | Sent with no `id`, one sticker by itself, by its Steam definition index; see [Standalone stickers](#standalone-stickers) | none |
| charm | One charm and where it hangs; see [Charms](#charms). Sent with no `id`, the charm by itself; see [Standalone charms](#standalone-charms) | none |
| stattrak | Whole number, `0` to `999999`. Sending it makes the item StatTrak, so `stattrak=0` is a StatTrak item at zero kills | a plain item |
| name | Name tag text, up to 20 characters, without control characters | none |
| view | `hero`, `front`, `back` or `top`; see [Views](#views) | `hero` |
| bg | A background name; see [Backgrounds](#backgrounds) | `steel` |
| light | `icon` or `inspect`; see [Lighting](#lighting) | `icon` |
| w, h | Whole numbers, `160` to `1600`. Larger with a key once [HQ stills](#hq-stills) are turned on | `1200` × `630` |
| wait | `1` waits for the render when its turn is near: 45 seconds plus its place in the queue, up to 240 seconds, and past that answers at once with the placeholder, uncounted; see [The render queue](#render-queue). `0` answers at once, with a placeholder when the picture is not cached yet | `1` |
| key | Your publishable key, `/^[a-z0-9_-]{4,64}$/i`. A server can send it in an `x-cs2-key` header instead; see [Keys and origins](#keys-and-origins) | required |

A value outside these bounds is refused with a `400` that names the parameter and its bounds; see [Errors](#errors).

### Stickers

`st` is up to five stickers separated by commas, each `slot:stickerId:wear`, optionally followed by `:offsetX:offsetY:rotation`. Leave a placement field empty to keep the slot's default: `3:1044:0.12:::90` only rotates the sticker in slot 3.

A slot is one of the sticker positions the weapon's model numbers, and an offset moves the sticker from that position's default. Since CS2's free placement, the game puts several stickers on one position and uses positions past the first five: the M4A4's model numbers seven, `0` to `6`. So a slot can appear more than once, and runs to `7`. An M4A4 | Howl carrying a sticker in slot 0, one in slot 3 and three in slot 6, with a Sticker Slab (see [Sticker Slab](#sticker-slab)):

```http
GET https://inspekt.gg/api/still?id=m4a1_cu_m4a1_howling_gen&wear=0.170842&seed=655&st=0:103:0.00,3:7916:0.98:-0.3514:0.08:151.5,6:5952:0.00:0.2242:-0.0444:6,6:7916:0.98:0.0027:0.0179:130.5,6:7916:0.98:-0.0266:0.075:171&charm=37:15.9565:0.4259:14.7431~103&view=front&w=640&h=400&key=cs2pk_your_key_here
```

| Field | Bounds | Default |
| --- | --- | --- |
| slot | `0` to `7`, as often as the item has it | required |
| stickerId | Steam definition index, `1` to `999999` | required |
| wear | `0` to `1`, up to 4 decimals | required |
| offsetX, offsetY | `-1` to `1`, up to 4 decimals | `0`, the slot's own position |
| rotation | `-360` to `360` degrees, up to 4 decimals | `0` |

- A sticker's centre has to stay inside the area CS2's sticker editor allows on that weapon. An offset that moves it outside is refused with a `400` that names the slot and the nearest offset inside; see [Errors](#errors).
- A sticker is drawn at its slot's size. A seventh field (a scale) is ignored and named in `x-cs2-notes`. An item from an inspect link keeps the scale its link records.
- Four weapons have slots with no surface to draw on: slot 3 of the M249 and the Tec-9, slot 0 of the Nova, and slots 2 and 4 of the R8 Revolver. A sticker sent to one is left off the picture and named in `x-cs2-notes`.
- A slot the weapon's model does not number, such as slot 6 on an AK-47, is drawn at the position of slot number modulo the weapon's first four or five, and named in `x-cs2-notes`.
- Overlapping stickers are drawn in slot order, and stickers sharing a slot in the order `st` lists them, the later one on top.
- A value out of bounds, or an `st` that fails to parse, refuses the whole request.

#### Standalone stickers

Send `sticker` with no `id` and no `inspect`, and the picture is the sticker by itself, flat and square-on, as its inventory picture shows it. The value is the sticker's Steam definition index, the same `stickerId` that `st` takes. Paper, holo, foil, gold, glitter and lenticular stickers are each drawn in their own finish. Space Wolves on a transparent background, 512 × 384:

```http
GET https://inspekt.gg/api/still?sticker=9362&bg=transparent&w=512&h=384&key=cs2pk_your_key_here
```

Space Wolves, `sticker=9362`

- The sticker's square fills 94% of the picture's height or width, whichever it reaches first, the margin of its inventory picture.
- `bg` takes the backgrounds a gun takes, `transparent` included, and defaults to `steel` as it does for a gun. `w`, `h`, `wait` and `key` work as they do for a gun.
- A standalone sticker costs one render: it counts once toward the plan's monthly render quota, as any still does (see [Quota](#quota-and-billing)). It is a still of its own, cached under the sticker, the size and the background.
- With `wait=0`, a sticker that is not cached yet answers at once with its inventory picture on the background you asked for, and `x-cs2-notes` names it a placeholder.

A standalone sticker refuses these with a `400`:

| Request | Error message |
| --- | --- |
| `sticker=9362&wear=0.1`, and the same for `seed`, `st`, `stattrak`, `name` and `charm` | "wear needs an id: a sticker drawn alone takes sticker, bg, w, h and wait" |
| `sticker=9362&view=front`, or any view besides `hero` | "view must be hero for a sticker drawn alone" |
| `sticker=9362&light=inspect`, which lights a gun | "light must be icon for a sticker drawn alone" |
| `sticker=9362&bg=rarity`, which names a finish's tier plate | "bg=rarity needs an id: a sticker drawn alone takes one of black, white, …, transparent" |
| `sticker=1.5`, or `0`, or past `999999` | "sticker must be an integer 1..999999" |
| `sticker=999999`, a number with no sticker behind it | "no such sticker: 999999" |

### Charms

`charm` hangs one charm on the gun: any of the 143 charms in CS2, from any keychain spot the game offers on that gun, at any pattern seed. The value is the charm's Steam definition index, optionally followed by `:offsetX:offsetY:offsetZ`, a point on the gun that picks the spot, then by `@seed`, and then, for a Sticker Slab, by `~stickerId`, the sticker sealed in it; see [Sticker Slab](#sticker-slab). On the M4A1-S | Fade, `charm=60` hangs Lil' Chirp from the default spot, `charm=60:-10.4688:0.345:2.4583` moves it to the stock end, and `charm=60@75000` hangs it from the default spot at seed 75000, teal where seed 0 is pink. The [overview](https://inspekt.gg/docs#charms) shows four charms beside Valve's pictures of them. Sent with no `id`, `charm` draws the charm by itself; see [Standalone charms](#standalone-charms).

| Field | Bounds | Default |
| --- | --- | --- |
| defIndex | Steam definition index, `1` to `999999`; see [Finding a charm](#finding-a-charm) | required |
| offsetX, offsetY, offsetZ | `-64` to `64`, up to 4 decimals, in Source units (inches) in the gun model's own space; see [Spots](#charm-spots) | the gun's default spot |
| seed | Whole number, `0` to `100000`: the charm's Charm Template in CS2; see [Seeds](#charm-seeds) | `0` |
| stickerId | Steam definition index, `1` to `999999`: the sticker sealed in a Sticker Slab; see [Sticker Slab](#sticker-slab) | none |

#### Examples

Each of these draws a 640 × 400 picture from the front. Glitter Bomb on the Glock-18 | Neo-Noir, from the default spot:

```http
GET https://inspekt.gg/api/still?id=glock_cu_glock_noir_gen&wear=0.03&seed=100&charm=73&view=front&w=640&h=400&key=cs2pk_your_key_here
```

Lil' Chirp on the Desert Eagle | Printstream at seed 75000:

```http
GET https://inspekt.gg/api/still?id=deagle_cu_deag_printstream_gen&wear=0.03&seed=100&charm=60@75000&view=front&w=640&h=400&key=cs2pk_your_key_here
```

Glitter Bomb at the stock end of the AK-47 | Redline, at seed 40000:

```http
GET https://inspekt.gg/api/still?id=ak47_cu_ak47_cobra_gen&wear=0.24&seed=661&charm=73:-9.874:0.718:-1.735@40000&view=front&w=640&h=400&key=cs2pk_your_key_here
```

8 Ball IGL on the same AK-47, sent a point 35 inches past the muzzle, which hangs it from the spot nearest that point, at the muzzle end:

```http
GET https://inspekt.gg/api/still?id=ak47_cu_ak47_cobra_gen&wear=0.24&seed=661&charm=78:60:0:0&view=front&w=640&h=400&key=cs2pk_your_key_here
```

#### Finding a charm

The 143 charms are the Missing Link (17), Missing Link Community (23), Small Arms (16) and Dr Boom (22) collections, the Sticker Slab, and 64 tournament highlight charms: one each from Austin 2025 and Budapest 2025, and 62 from Cologne 2026. Each is a row of [`/assets/charms/index.json`](https://inspekt.gg/assets/charms/index.json):

```json
{"id":"kc_missinglink_lilhatchling","name":"Lil' Chirp","defIndex":60,"icon":"econ/charms/kc_missinglink_lilhatchling.webp","group":"Missing Link Community Charm Collection"}
```

`defIndex` is the number `charm` takes, and `name` is the charm's name as Steam shows it. `group` is its collection or tournament. `icon` is Valve's inventory picture of the charm, a 256 × 192 webp at that path under `/assets/`, so Lil' Chirp's is [`/assets/econ/charms/kc_missinglink_lilhatchling.webp`](https://inspekt.gg/assets/econ/charms/kc_missinglink_lilhatchling.webp). The 62 Cologne 2026 rows share one picture, and the Budapest 2025 Highlight's row has no `icon`. The file is about 26 KB and has no CORS headers, so fetch it from your server or a build step.

An inspect link names its charm by the same number, the keychain's `sticker_id`, and carries the charm's spot and seed as well, so a request with `inspect` draws the charm where its owner hung it. Every gun takes a charm, the Zeus x27 included: 35 in all. Knives and gloves have no keychain spots, and a charm sent with one is left off the picture. A Sticker Slab is drawn with the sticker its owner sealed in it, the keychain's `wrapped_sticker_id`.

#### Sticker Slab

The Sticker Slab, `defIndex` 37, holds one sticker, named by its Steam definition index after a `~`: `charm=37~103` hangs a Slab with sticker 103 sealed in it from the default spot, and `charm=37:15.9565:0.4259:14.7431@512~103` from another spot at seed 512. The sticker is drawn in the window below the Slab's label, where the game's own model places it. `charm=37` is the Slab with no sticker, as its inventory picture shows it.

- Only the Slab holds a sticker. A sticker sent with any other charm is refused with a `400`; the still the [embed](https://inspekt.gg/docs/embed) draws leaves it out and names it in `x-cs2-notes`.
- A sticker we hold no art for is left out of the picture and named in `x-cs2-notes`.
- The sealed sticker is part of the key a still is cached under, so `charm=37` and `charm=37~103` are two stills.

#### Spots

A gun has from 7 keychain spots (the Glock-18) to 74 (the Galil AR and the M4A1-S), and they are the spots the game lets a player drag a charm to. One of them is the default, where the game first hangs a charm, and a `charm` without offsets hangs from it.

The three offsets are a point in the gun model's own space, in Source units (inches): X runs from the stock toward the muzzle, Y across the gun and Z up. They are the numbers an inspect link carries as its keychain's `offset_x`, `offset_y` and `offset_z`. The charm hangs at the point on the gun's spots nearest to the one you send, as the game clamps a charm its owner drags, so any point inside the bounds lands on the gun.

- To pick a spot by eye, open the gun in the [studio](https://inspekt.gg/), add the charm, press **Move** and click a dot. The address bar's `charm=` then holds that spot's point, and the studio's share link carries it too.
- Send all three offsets or none. `charm=60:0:0:0` is the default spot, the same request as `charm=60`.

#### Seeds

The seed is the charm's Charm Template in CS2, a whole number from 0 to 100000, and the `pattern` an inspect link carries for its keychain. It changes a charm the way the game's own charm materials do. On 135 of the 143 charms it shifts the hue, and on some of those the brightness and saturation too. On 7 more it moves something else: the glitter on Diamond Dog and Lil' Serpent, where the print sits on Diner Dog and Lil' Teacup, POP Art's iridescent lettering, Lil' Boo's pearl sheen, and the colour of Butane Buddy's fluid. The Sticker Slab looks the same at every seed. `charm=60` and `charm=60@0` are the same request and the same picture, because a charm sent without a seed is drawn at seed 0.

#### Caching

The charm, its offsets and its seed are part of the key a still is cached under (see [Caching and the placeholder](#caching-and-the-placeholder)), so each charm, spot and seed is a still of its own: its first request renders, and it counts toward the quota as any still does. `charm=60`, `charm=60@0` and `charm=60:0:0:0` are one still. Offsets are keyed as sent, so two points that land on the same place are two stills of the same picture. Send each spot's point the same way each time.

#### Errors

A `charm` outside these rules refuses the whole request with a `400`:

| Request | Error message |
| --- | --- |
| `charm=60:1.5:2`, two offsets, or a letter, or a fifth decimal | "charm must be defIndex\[:offsetX:offsetY:offsetZ\]\[@seed\]\[~stickerId\]" |
| `charm=37~0` | "charm sticker must be an integer 1..999999" |
| `charm=60~103`, a sticker on a charm that holds none | "charm 60 cannot hold a sticker, so sticker id 103 is left out: only the Sticker Slab can" |
| `charm=0` | "charm must be an integer 1..999999" |
| `charm=60:70:0:0` | "charm offset must be -64..64" |
| `charm=60@200000` | "charm seed must be 0..100000" |

These checks read the value's form and bounds. A number with no charm behind it, such as `charm=34` (the index skips 34 and 35), passes them, so take the `defIndex` from the index. From an inspect link, `x-cs2-notes` names a charm we hold no model for. The [embed](https://inspekt.gg/docs/embed) drops a `charm` it cannot read, shows the item without it and names `charm` in `x-cs2-dropped`. A share card drops it the same way.

#### Standalone charms

Send `charm` with no `id` and no `inspect`, and the picture is the charm by itself, as its inventory picture shows it, hanging at rest from its ring. The value is `defIndex[@seed][~stickerId]`, the same definition index, seed and sealed sticker a charm takes on a gun. Lil' Chirp at seed 75000:

```http
GET https://inspekt.gg/api/still?charm=60@75000&w=640&h=400&key=cs2pk_your_key_here
```

Lil' Chirp at seed 75000, `charm=60@75000`

The Austin 2025 Highlight as a cut-out on a transparent background, 512 × 384:

```http
GET https://inspekt.gg/api/still?charm=36&bg=transparent&w=512&h=384&key=cs2pk_your_key_here
```

- The charm fills 96% of the picture's height or width, whichever it reaches first, as a gun fills its hero still. The 64 tournament highlight charms and the Sticker Slab are framed as their inventory pictures are.
- `bg` takes the backgrounds a gun takes, `transparent` included, and defaults to `steel` as it does for a gun. `w`, `h`, `wait` and `key` work as they do for a gun.
- A standalone charm is a still of its own, cached under the charm, its seed, the size and the background, and it counts toward the quota as any still does. `charm=60` and `charm=60@0` are one still.
- With `wait=0`, a charm that is not cached yet answers at once with its inventory picture on the background you asked for, and `x-cs2-notes` names it a placeholder. The Budapest 2025 Highlight has no inventory picture, so its placeholder is a `503` until the render is cached.

The parameters that describe a gun refuse a standalone charm with a `400`:

| Request | Error message |
| --- | --- |
| `charm=60&wear=0.1`, and the same for `seed`, `st`, `stattrak` and `name` | "wear needs an id: a charm drawn alone takes charm, bg, w, h and wait" |
| `charm=60&view=front`, or any view besides `hero` | "view must be hero for a charm drawn alone" |
| `charm=60&light=inspect`, which lights a gun | "light must be icon for a charm drawn alone" |
| `charm=60:-10.4688:0.345:2.4583`, a spot on a gun | "charm offset needs an id: a charm drawn alone is charm=defIndex\[@seed\]" |
| `charm=60~103`, a sticker on a charm that holds none | "charm 60 cannot hold a sticker, so sticker id 103 is left out: only the Sticker Slab can" |
| `charm=60&bg=rarity`, which names a finish's tier plate | "bg=rarity needs an id: a charm drawn alone takes one of black, white, …, transparent" |
| `charm=34`, a number with no charm behind it | "no such charm: 34" |

### Views

| Value | What you get |
| --- | --- |
| `hero` | The default. The item as its Steam inventory icon shows it. A gun is shown side-on at its icon's angle, and a knife lies on the diagonal with the tip to the upper left. |
| `front` | Square-on to the sticker side through a 10° lens, muzzle left. The shot for a listing row. |
| `back` | The other flank, muzzle right. |
| `top` | Straight down, muzzle left, lined up with `front`. |

Each view fits the whole item in the frame, so match `w` and `h` to the item's shape, for example a wide frame for a rifle.

### Backgrounds

| Value | What you get |
| --- | --- |
| `steel` | The default. A cool wash at the top falling to near-black. |
| `black` | `#000000`. |
| `white` | `#ffffff`, for a listing row on a light page. |
| `charcoal` | `#141619`, the catalog cards' colour. A black finish keeps its silhouette on it. |
| `ember` | Orange rising off the bottom edge into black corners. |
| `violet` | A violet halo behind the item. |
| `rarity` | The plate of the item's own rarity tier, looked up for you. Knives and gloves get Covert, and an item whose rarity we do not recognise gets `rarity-stock`. |
| `rarity-stock` … `rarity-contraband` | One plate per CS2 rarity tier, the tier's colour rising off the bottom edge out of black: `rarity-stock`, `rarity-consumer`, `rarity-industrial`, `rarity-milspec`, `rarity-restricted`, `rarity-classified`, `rarity-covert`, `rarity-contraband`. |
| `transparent` | Only the item. The webp keeps its alpha channel, for compositing over your own background. Stills only. |

- `white` and `black` are exact in every corner, so a `white` still sits on a white page without a visible edge. The gradient backgrounds pass through lossy webp compression and can be a level off, so to match a rarity plate to your page, draw it in CSS as below.
- `bg=rarity` and the plate it resolves to are the same request and the same cached file.
- To ask for a background outside this list, email [hello@inspekt.gg](mailto:hello@inspekt.gg).

#### Rarity plates in CSS

A gradient plate baked into a webp shows banding. On a page that loads a stylesheet, request `bg=transparent` and draw the plate behind the item in CSS. `plateCss()` builds it from the colours, stops and shape of the render's own plates:

```javascript
var PLATE_COLOR = {
  stock: '#9aa0a6', consumer: '#b0c3d9', industrial: '#5e98d9', milspec: '#4b69ff',
  restricted: '#8847ff', classified: '#d32ce6', covert: '#eb4b4b', contraband: '#e4ae39',
};
// [position along the ramp, share of the tier colour left there]
var PLATE_STOPS = [[0, 0.8], [0.2, 0.4], [0.42, 0.124], [0.68, 0.036], [1, 0]];
// An ellipse 95% of the frame wide and 95% tall, centred 15% below its bottom edge.
function plateCss(tier) {
  var hex = PLATE_COLOR[tier];
  if (!hex) return null;
  var n = parseInt(hex.slice(1), 16);
  var rgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  var stops = PLATE_STOPS.map(function (s) {
    var c = rgb.map(function (v) {
      return ('0' + Math.round(v * s[1]).toString(16)).slice(-2);
    }).join('');
    return '#' + c + ' ' + Number((s[0] * 100).toFixed(4)) + '%';
  }).join(', ');
  return 'radial-gradient(95% 95% at 50% 115%, ' + stops + ')';
}
```

Pass it the tier `bg=rarity` would resolve to, and set the result as the `background` of the element around the `<img>`. For the Redline, `plateCss('classified')` gives:

```html
<div style="background: radial-gradient(95% 95% at 50% 115%, #a923b8 0%, #54125c 20%, #1a051d 42%, #080208 68%, #000000 100%)"><img src="https://inspekt.gg/api/still?id=ak47_cu_ak47_cobra_gen&wear=0.24&seed=661&view=front&bg=transparent&w=640&h=400&key=cs2pk_your_key_here" width="640" height="400" referrerpolicy="strict-origin" alt="AK-47 | Redline, Field-Tested"></div>
```

Keep `bg=rarity` for an Open Graph image, and anywhere else a single file is fetched without a stylesheet.

### Lighting

| Value | What you get |
| --- | --- |
| `icon` | The default. The clean studio look of the inventory. |
| `inspect` | The look of the in-game inspect screen. Every view is lit from the viewer's side, and foil and gold stickers reflect it as they do in the game. |

- Any other value is refused with a `400`: "light must be one of icon, inspect".
- An `icon` still and an `inspect` still of the same item are two files, each cached and counted as a still.
- A standalone charm takes `icon` only.

### Keys and origins

A key is required. A request without one is refused with `401` and `key_required`, from another site's page and from a server alike. Image links shared from inspekt.gg carry a `sig` parameter in place of a key. They serve link previews and open in a browser tab, and inside another site's page they are refused like any request without a key.

A request with a key is checked against the key's registered origins, using the request's `Origin` header or, when it has none, the origin in its `Referer`. An `<img>` sends only the `Referer`, and a browser puts the page's origin in it by default. A registered origin is served. Any other is refused with `403` and `origin_not_allowed`.

A keyed request missing both headers is refused with `403` and `origin_required`. Put `referrerpolicy="strict-origin"` on the `<img>`, and on an `<iframe>` you build yourself, as the snippets on this site do, so the browser sends the origin even on a page whose own `Referrer-Policy` withholds the referrer. `CS2Embed.mount()` sets it on the frames it builds. From your own server, send the key with an `Origin` header naming one of the key's registered origins.

### Secret keys and tokens

The publishable key is enough when your own pages make every request. Two optional credentials, both managed in the [dashboard](https://inspekt.gg/dashboard), cover calls from your server and short-lived keys for your pages.

| Credential | Where it goes | What checks it |
| --- | --- | --- |
| `cs2pk_…` | Your pages, in the page source. | The origin list, and for the embed the browser's own `frame-ancestors`. |
| `cs2sk_…` | Your server, and your server alone. | The credential itself. It carries no origin list, so keep it out of anything a browser downloads. |
| `cs2tk_…` | Your pages, in place of the publishable key. Minted by your server and valid for up to an hour. | The same origin list the publishable key is checked against. |

#### The secret key

Create one under **Secret key** in the dashboard. It is shown once, and we keep only a hash of it. Creating a new one retires the old one and every token it minted. Revoking it does the same and also turns off [Require tokens](#require-tokens).

The secret key works without an `Origin` or `Referer` header, so a backend can call with it, and it is exempt from the [daily limit per visitor](https://inspekt.gg/docs/limits#per-visitor-daily-limit). It counts toward the same monthly quota and requests-per-second allowance as the publishable key, and appears in the same usage records.

#### Minting a token

A token is a publishable key that expires. Mint one with the secret key and send it in place of `key=`. It works on `/embed`, `/api/still` and `/api/inventory-lookup`, checked against the same registered origins.

```http
POST https://inspekt.gg/api/token
x-cs2-key: cs2sk_your_secret_key_here
content-type: application/json

{"ttl": 3600}

200 OK
{"ok": true, "token": "cs2tk_…", "expiresIn": 3600, "expiresAt": "2026-09-22T19:04:11.000Z"}
```

`ttl` is in seconds, from 1 to 3600, and defaults to 3600. A larger value mints a 3600-second token, and 0 or less a 1-second one. Only the secret key can mint a token; a publishable key or a token answers `403` and `secret_key_required`. Minting is rate limited per account and per address, so mint one token per server process and reuse it until it nears expiry.

```javascript
// One token per process, reused until it is nearly out of time. Minting one per
// request would spend a round trip on every page view and meet the rate limit.
let token = null;
let expiresAt = 0;

async function inspektToken() {
  if (token && Date.now() < expiresAt - 60_000) return token;
  const res = await fetch('https://inspekt.gg/api/token', {
    method: 'POST',
    headers: {
      'x-cs2-key': process.env.INSPEKT_SECRET_KEY,  // server-side only
      'content-type': 'application/json',
    },
    body: JSON.stringify({ ttl: 3600 }),
  });
  if (!res.ok) throw new Error(`inspekt token: ${res.status} ${await res.text()}`);
  const body = await res.json();
  token = body.token;
  expiresAt = Date.parse(body.expiresAt);
  return token;
}

// In the page handler, the token goes where the publishable key used to:
app.get('/listing/:id', async (req, res) => {
  const key = await inspektToken();
  const item = await lookup(req.params.id);
  res.send(renderPage({
    still: `https://inspekt.gg/api/still?inspect=${encodeURIComponent(item.inspectLink)}`
      + `&view=front&w=640&h=400&key=${key}`,
  }));
});
```

The 60-second margin leaves the browser time to use a token before it expires. A request with an expired token answers `401` and `token_expired`. A token whose signature fails, such as one minted by a secret key you have since rotated, answers `401` and `token_invalid`. In both cases, mint another.

#### Require tokens

**Require tokens** is a dashboard setting, off by default. While it is on, your publishable key answers `403` and `token_required` on all three surfaces, and tokens and the secret key keep working. A token copied from your page source then works for an hour at most, and only from a registered origin.

**Turn it on in this order.** Create the secret key, send tokens from your pages, check that your traffic is served, and turn the setting on last. Turning it on while your pages still send the publishable key refuses your own visitors.

#### Tokens and the cache

A token changes the `<img>` URL every hour, so a browser fetches a still again where it would otherwise use its own cache. A still fetched with a token therefore counts once per viewer, per picture, per 24 hours, the rule an [embed load](https://inspekt.gg/docs/limits#embed-loads) follows. A viewer's first fetch of a picture counts as one render, as it does with the publishable key. A repeat fetch within 24 hours is served with `X-CS2-Counted: 0`.

### Caching and the placeholder

A still is cached under its item and picture parameters (`id`, `wear`, `seed`, `st`, `charm`, `stattrak`, `name`, `view`, `bg`, `light`, `w`, `h`) and the renderer's version. The same values return the same file, whichever form named the item, and `view=hero` is the same request as leaving `view` off.

- **Hit.** The webp, with `cache-control: private, max-age=86400`: the viewer's browser keeps it for 24 hours, and a CDN or proxy between us and the viewer does not keep it. When the renderer changes, the URL returns the new picture, and a browser that cached the old one shows it for up to a day.
- **Miss.** When its turn in the queue is near, the response waits for the render and returns it: 45 seconds, plus the time the renders queued ahead of it take, up to 240 seconds. When its turn is further off, it answers at once, and the render keeps its place (see [The render queue](#render-queue)). A render takes about 0.9 seconds for a skin whose assets are not yet loaded and 0.4 seconds for one already loaded (medians, measured 15 September 2026).
- **Miss with `wait=0`.** The placeholder, with `cache-control: no-store`, and the render is queued. The same URL returns the render once it lands. The render keeps its place while the queue has room; a full queue may make room from it after every uncounted picture of the same caller, and the next request queues it again. When the render cannot be queued, the placeholder is still sent, and it is not counted.

A placeholder is always a webp of your `w` × `h`, so an `<img>` keeps its shape when the render replaces it. It shows the skin at its default wear and pattern, without stickers, charm, StatTrak counter or name tag, in one of three ways:

- **The skin's cut-out for your view**, fitted inside the frame on your background, painted the way the render paints it. `transparent` keeps the frame clear around the item, and `top` uses the hero cut-out.
- **The skin's catalog thumbnail**, when the cache holds no cut-out for that view yet. The thumbnail is a 512 × 512 picture of the item on near-black, `#020406`, fitted inside the frame, and the rest of the frame is that near-black, or transparent for `bg=transparent`. Your background is left off, because the thumbnail's own background is part of the picture.
- **A plain frame** of that near-black, or a transparent one for `bg=transparent`, with only the colour in it. It answers when the server is busy drawing other pictures and no draw slot comes free within 1.5 seconds.

A skin missing both pictures answers `503`; see [Errors](#errors).

A request for an image, such as an `<img>`, gets the placeholder in four cases where a server gets an error: the render queue refuses it, rendering is unavailable, the render fails, or its wait runs out. The answer is a `200` with `cache-control: no-store`, and it is not counted, so the page shows the skin and its next load asks for the render again. [Errors](#errors) says which requests are for an image.

On a page, use one of these:

- **Leave `wait` off.** The first visitor to ask for a picture waits for its render, about a second while the queue is short, and sees the exact item.
- **Pre-warm**, so that no visitor waits. Request each still once from your server before a page shows it, for example when the listing is created, and discard the body. Send the key as `key=` or in an `x-cs2-key` header, with an `Origin` header naming one of the key's registered origins; see [Keys and origins](#keys-and-origins). The cache is shared across keys, so the `<img>` with the same item and picture parameters gets the file the pre-warm rendered. The pre-warm counts as one render; see [Quota](#quota-and-billing).
- **Send `wait=0`** when the page must paint at once and you cannot pre-warm, such as a grid of items nobody has asked for yet. The first visitor sees placeholders, and the next load gets the renders.

You may store an image the API returns and show your copy for up to 24 hours from when we sent it. After that, request it again: [Terms 3.5](https://inspekt.gg/legal/terms#licence). Pointing the `<img>` at our URL needs no copy at all.

### The render queue

A miss waits in one queue with every other miss, and the renderer draws new pictures from it at the rate it has. Requests for the same picture share one render, whichever keys sent them.

- **Size.** Up to 25,000 pictures queued for all keys together. Each visitor's address holds up to 200 of them, under a publishable key, a token or no key, and a free trial up to 500. A secret key's share is held by its plan's requests per second and its monthly quota; see [Limits & SLA](https://inspekt.gg/docs/limits).
- **Order.** Plans take turns in proportion to their tier: Enterprise 16, Pro 5M 8, Pro 1.5M 4, Pro 500k 2, Hobby 1, free trials 1, and visitors to inspekt.gg's own pages 1. Every plan with pictures queued gets its turns, and a plan alone in the queue gets all of them. Inside a plan the keys take turns, and inside a key the visitors take turns: a publishable key or a token counts each visitor's address apart, and a secret key is one caller. Each caller's pictures are drawn in the order it asked for them.
- **Waiting, or an answer at once.** A request waits for its picture when the picture is expected within 195 seconds, at the turns the plans, keys and visitors with pictures queued take now, and a request for an image when it is expected within 240 seconds. Either waits up to 240 seconds. A burst into a quiet queue waits whole: 40 pictures come back in about 6 seconds and 500 in about 80. A request whose picture is further off is answered at once, uncounted, and the picture keeps its place: a server gets `202 Accepted` with a few bytes of JSON, `Retry-After` and `x-cs2-queued`, a request for an image the placeholder as a `200`. Ask again then: the picture is cached, or still queued, and the request that gets the picture is the one counted. A request that waits while pictures of a higher tier arrive can run out of time: a server then gets a `504` with a `Retry-After`, an image the placeholder, and the picture keeps its place.
- **Full.** With 25,000 queued, the queue makes room fairly, weighted by tier, with each key and each visitor counted apart: a secret key is one caller, and each visitor of a publishable key or a token is a caller of its own. A picture nobody is waiting on can make way: one answered at once first, and one a `wait=0` request was counted for after every uncounted picture of the same caller. A picture with a request waiting on it stays. A new picture takes the place of the newest picture that can make way from the caller holding the most of them against its tier's weight (Enterprise 16 down to Hobby, Lens and visitors to inspekt.gg's own pages 1, so an Enterprise key keeps 16 for a Hobby key's one), and that caller finds a fresh miss when it asks again. A server gets `503` with `code: "queue_full"` and a `Retry-After` of 15 seconds or more when, with the new picture, it would be that caller itself, or when it reaches the 200 a visitor may hold or the 500 a trial may hold. A request for an image gets the placeholder. Neither counts. Fair room promises no wait: a picture that gets in waits its turn behind the others, however long the queue is.
- **Left behind.** A picture whose requests all close before its turn, such as the images of a tab that was closed, is drawn when the queue is otherwise empty, and cached for the next request.

Send a page's requests together. Over HTTP/2, which inspekt.gg serves, a browser sends a page's images at once, and the queue draws them in the order they arrived.

### Errors

An error body is JSON, `{"error":"…","requestId":"…"}`. An access refusal and a full render queue add a `code`, and a `404` adds the `defindex` and `paintindex` the link decoded to. An access refusal opened in a browser tab is shown as a page instead.

An access refusal to a request for an image, such as an `<img>`, is a webp at the refusal's own status: a dark card of the request's `w` × `h` with the HTTP status and the `code` drawn on it. A side that is missing, or outside 160 to 1600, takes its default, 1200 or 630. The page shows the card where the image would be, at the size the layout expects. A request is for an image when its `Sec-Fetch-Dest` header is `image`, or, without that header, when its `Accept` header ranks an image type above `text/html`, `application/json` and `*/*`. The card carries `Retry-After` and `x-request-id` as the JSON does.

A miss that fails, the `502`, `504` and first three `503` rows below, answers a request for an image with the placeholder instead; see [Caching and the placeholder](#caching-and-the-placeholder).

| Status | Request | Error message |
| --- | --- | --- |
| 400 | `wear=1.5`, or any value outside the bounds above | "wear must be 0..1": the parameter and its bounds |
| 400 | `id=ak47_redline`, an id the catalog lacks | "no such skin: ak47_redline" |
| 400 | `id=ak47_cu_ak47_cobra_gen&st=0:1044:0.1:0.5`, a sticker offset past the weapon's sticker area | "st slot 0 offset puts the sticker's centre at 0.6540,-0.4380, outside the area CS2 lets a sticker go on this weapon; the nearest offset inside is 0.3238,0.0308" |
| 400 | an `inspect` link ending in `S…A…D…` or `M…A…D…`, or a value that fails to decode | "inspect link: this is a market/inventory inspect link (S…A…D…): …" |
| 400 | an `inspect` link to an item without a finish, such as a vanilla knife | "inspect link names no paint kit" |
| 404 | an `inspect` link to a finish the catalog lacks | "item 7/1234 is not in the catalog" |
| 503 | a miss with 25,000 pictures queued and the caller holding the most pictures nobody waits on against its tier's weight, or with the 200 a visitor may hold or the 500 a trial may hold queued; see [The render queue](#render-queue) | "the still queue holds 25000 stills, its most; retry in 21 s", "this caller has 200 stills queued, …" or "this trial has 500 stills queued, …", with `code: "queue_full"` and `Retry-After` in seconds |
| 503 | a miss while rendering is unavailable | "render node unreachable, retry", with `Retry-After: 15` |
| 502 | a miss whose render fails | "still render failed: …" |
| 504 | a miss whose wait runs out: 45 seconds plus its place in the queue, up to 240 | "still render timed out. Retry, it may be cached by then", with a `Retry-After` in seconds when its render is still queued; it keeps its place |
| 503 | a miss still waiting while we deploy a new version | "the origin is restarting, retry", with `Retry-After: 2` |
| 503 | a placeholder for a skin with no placeholder picture, which is rare | "still not rendered yet. Retry shortly", with `Retry-After: 10` |

An access refusal answers `401`, `402`, `403` or `429`, and its `code` names the cause: an unknown or disabled key, an account with no plan, an unregistered origin, too many requests or a spent quota. `key=x` gets a `401` with "That is not a valid key." and `key_unknown`. A `429` carries `Retry-After`. See [Error codes](https://inspekt.gg/docs/errors).

### Quota

A request counts once toward the plan's monthly render quota when it gets a cached still, the render it waited for, or, with `wait=0`, a placeholder whose render it started or joined. A placeholder sent in place of a render that could not be queued, failed, outlasted its wait or was answered at once because its turn was further off is exempt, and so is every error. A browser keeps a still for 24 hours (see [Caching and the placeholder](#caching-and-the-placeholder)), so a viewer who comes back within the day is shown it without a request, and counts again the next day. A still fetched with a token counts once per viewer, per picture, per 24 hours; see [Tokens and the cache](#tokens-and-the-cache). Every response says which it was in `X-CS2-Counted`: `1` when the request counted and `0` when it did not, as on the [embed](https://inspekt.gg/docs/embed).

Past the quota, `/api/still` answers `429` with `code: "quota_exceeded"` until the billing period renews, on the day of the month and at the UTC time the subscription started. See [Limits & SLA](https://inspekt.gg/docs/limits#monthly-quotas).

### HQ stills

**Not turned on yet.** Until it is, `w` and `h` stop at `1600` for every caller and every still counts one render, as above. This section says what changes when it is.

A request with a key (a publishable key, a secret key or a token) on a plan may then ask for a still up to a larger bound, 2560 px a side. A request without a key keeps `1600`. Every answer to a key that may ask for more says how much in `X-CS2-Still-Max`.

A still within 1600 × 1600 counts one render whatever its size, as now. A larger one counts one render per 1600 × 1600 of pixels, rounded up, and says how many in `X-CS2-Units`. A cached one counts the same.

| Size | Pixels | Renders |
| --- | --- | --- |
| 1600 × 1600 or smaller | up to 2.56 million | 1 |
| 2048 × 1075 | 2.20 million | 1 |
| 2560 × 1344 | 3.44 million | 2 |
| 2560 × 1440 | 3.69 million | 2 |
| 2560 × 2560 | 6.55 million | 3 |

A still that counts more renders than the key has left this billing period is refused with `429` and `quota_exceeded`, and the message says how many are left; a still of 1600 px or less still counts one. A key may ask for 30 stills larger than 1600 px a minute; past that it gets `429` and `rate_limited` with a `Retry-After`, and its smaller stills are served as before. A larger still waits in the same [render queue](#render-queue) as every other still and takes as many of its key's turns as the renders it counts, so one that counts 2 waits as long as two smaller ones would. It also takes longer to render than a 1200 × 630 one.

## 8. Inventory API

_From https://inspekt.gg/docs/inventory-api_

GET `/api/inventory-lookup` returns the CS2 items in a public Steam inventory. Results are cached for 5 minutes and then discarded.

### Request

```http
GET /api/inventory-lookup?steamid=76561197960287930&key=cs2pk_your_key_here
```

| Param | Type / bounds | Notes |
| --- | --- | --- |
| steamid | ≤200 characters, no whitespace or quote characters | A SteamID64, a vanity profile name or a full profile URL. |
| key | `/^[a-z0-9_-]{4,64}$/i` | Your publishable key. A request without one is refused; see [Error codes](https://inspekt.gg/docs/errors). |

### Response

200 OK:

```json
{
  "steamid": "76561197960287930",
  "count": 214,
  "items": [ /* … */ ],
  "cached": true,
  "ageMs": 42130
}
```

`cached` says whether the answer came from the 5-minute per-account cache, and `ageMs` says how old it is.

### Caching and rate limits

Steam limits inventory requests per IP address, so lookups follow three rules:

- **Per-account cache, 5 minutes.** A repeat lookup of the same `steamid` inside that window is served from cache. A vanity name and its resolved SteamID64 are cached separately, and the cache holds at most 200 accounts, oldest evicted first.
- **One lookup at a time reaches Steam.** New lookups wait in a queue of up to 12, shared across the service. A cache hit answers immediately and skips the queue.
- **5 cache misses per IP per 60 seconds.** Only misses count toward this limit, so reloading a cached inventory stays under it.

If Steam returns a rate limit (429), all lookups pause for 60 seconds, and a request during the pause gets a `503` at once.

### Errors

| Status | Body | Cause |
| --- | --- | --- |
| 400 | `{ error: "Enter a SteamID, a profile name, or a link to your profile." }` | Missing, empty, too long, or contains whitespace or quote characters. |
| 403 | `{ error: "That inventory is private. …" }` | The account's inventory is not public. |
| 404 | `{ error: "Couldn't find a Steam profile for \"…\"." }` | The SteamID, vanity name or URL did not resolve to an account. |
| 429 | `{ error: "Busy right now …" }`, `Retry-After: 15` | The lookup queue is full (12 waiting). |
| 429 | `{ error: "That is a lot of lookups …" }`, `Retry-After: 60` | Your IP exceeded 5 cache misses in the last 60 seconds. |
| 503 | `{ error: "Steam is rate-limiting us right now. …" }` | Steam returned 429, and lookups are paused for 60 seconds. |
| 502 | `{ error: "The lookup did not finish. Try again in a minute." }` | Any other failure fetching from Steam, including a fetch that took longer than 60 seconds. |

Access refusals (bad key, wrong origin, no plan, over quota) use the same JSON error shape at `401`, `402`, `403` or `429`, and are checked before any of the above. See [Error codes](https://inspekt.gg/docs/errors).

Every error body carries a `requestId`, repeated in the `x-request-id` response header. Quote it when you contact us about a failure. See [Request IDs](https://inspekt.gg/docs/errors#request-ids).

## 9. Frameworks

_From https://inspekt.gg/docs/frameworks_

Pick your stack and copy the whole file. Each tab is a complete integration.

Replace `cs2pk_your_key_here` with your key from the [dashboard](https://inspekt.gg/dashboard). The HTML, React and Vue snippets load the loader from `https://inspekt.gg`, which frames `https://inspekt.gg/embed` ([The loader](https://inspekt.gg/docs/embed#the-loader)). Every snippet leaves `scene` unset, so the frame opens on a still of the item on its rarity plate. Setting `scene` replaces that still with a loading card, and a video backdrop such as `plate_train_video` adds a few tens of megabytes to the page ([First paint](https://inspekt.gg/docs/embed#first-paint)).

```html
<script src="https://inspekt.gg/cs2embed.js"></script>
<div id="viewer" style="width:640px;height:400px"></div>
<script>
  var viewer = CS2Embed.mount(document.getElementById('viewer'), {
    inspect: 'steam://rungame/730/76561202255233023/+csgo_econ_action_preview 001807209A0228043004388F85D7F30340950512E67D72',
    key: 'cs2pk_your_key_here',
    autorotate: true,
  });

  viewer.on('loaded', function (e) {
    console.log('rendered', e.id, 'in', e.ms, 'ms');
  });
  viewer.on('error', function (e) {
    console.error('embed refused:', e.code, e.message);
  });
</script>
```

```jsx
import { useEffect, useRef } from 'react';

// cs2embed.js is a script, with no npm package. It loads once per page, and
// every mounted viewer shares it.
let loaderPromise;
function loadCs2Embed() {
  if (window.CS2Embed) return Promise.resolve(window.CS2Embed);
  if (!loaderPromise) {
    loaderPromise = new Promise((resolve, reject) => {
      const script = document.createElement('script');
      script.src = 'https://inspekt.gg/cs2embed.js';
      script.onload = () => resolve(window.CS2Embed);
      script.onerror = reject;
      document.head.appendChild(script);
    });
  }
  return loaderPromise;
}

export function SkinViewer({ inspect, scene, autorotate = true }) {
  const hostRef = useRef(null);
  const viewerRef = useRef(null);

  useEffect(() => {
    let cancelled = false;
    loadCs2Embed().then((CS2Embed) => {
      if (cancelled || !hostRef.current) return;
      viewerRef.current = CS2Embed.mount(hostRef.current, {
        inspect, key: 'cs2pk_your_key_here', scene, autorotate,
      });
      viewerRef.current.on('error', (e) => console.error('embed refused:', e.code, e.message));
    });
    return () => {
      cancelled = true;
      viewerRef.current?.destroy();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [inspect, scene, autorotate]);

  return <div ref={hostRef} style={{ width: '100%', height: 400 }} />;
}

// usage: <SkinViewer inspect={item.inspectLink} />
```

```vue
<template>
  <div ref="host" style="width: 100%; height: 400px"></div>
</template>

<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';

const props = defineProps({
  inspect: { type: String, required: true },
  scene: { type: String },
});

const host = ref(null);
let viewer = null;
let loaderPromise;

function loadCs2Embed() {
  if (window.CS2Embed) return Promise.resolve(window.CS2Embed);
  if (!loaderPromise) {
    loaderPromise = new Promise((resolve, reject) => {
      const script = document.createElement('script');
      script.src = 'https://inspekt.gg/cs2embed.js';
      script.onload = () => resolve(window.CS2Embed);
      script.onerror = reject;
      document.head.appendChild(script);
    });
  }
  return loaderPromise;
}

onMounted(async () => {
  const CS2Embed = await loadCs2Embed();
  viewer = CS2Embed.mount(host.value, {
    inspect: props.inspect,
    key: 'cs2pk_your_key_here',
    scene: props.scene,
    autorotate: true,
  });
  viewer.on('error', (e) => console.error('embed refused:', e.code, e.message));
});

onBeforeUnmount(() => viewer?.destroy());

watch(() => props.inspect, (inspect) => viewer?.setItem({ inspect }));
</script>
```

html, no script tag:

```html
<iframe
  src="https://inspekt.gg/embed?inspect=steam%3A%2F%2Frungame%2F730%2F76561202255233023%2F%2Bcsgo_econ_action_preview%20001807209A0228043004388F85D7F30340950512E67D72&autorotate=1&key=cs2pk_your_key_here&origin=https%3A%2F%2Fyoursite.example"
  style="width:640px;height:400px;border:0"
  loading="lazy"
  allow="autoplay; fullscreen"
  referrerpolicy="strict-origin"
  title="3D skin viewer"
></iframe>

<script>
  // optional: catch access refusals and item fallbacks without the loader
  window.addEventListener('message', function (e) {
    if (e.origin !== 'https://inspekt.gg') return;
    var d = e.data;
    if (!d || d.channel !== 'cs2embed') return;
    if (d.type === 'error') console.error('embed refused:', d.code, d.message);
  });
</script>
```

> [Embed parameters](https://inspekt.gg/docs/embed) lists every option, and [postMessage API](https://inspekt.gg/docs/postmessage) lists every command and event behind `on()`, `setItem()` and the other handle methods. The [sandbox](https://inspekt.gg/sandbox) builds these four snippets from the item, scene and view you choose.

## 10. Error codes

_From https://inspekt.gg/docs/errors_

Check the `code` field first. It puts the failure in one of two families, and each family has its own fix.

- **[Access codes](#access-codes)** mean the key, origin or quota check refused the request. The embed and both JSON APIs share them. Fix the key, the origin or the plan.
- **[Fallback codes](#fallback-codes)** come from the embed alone. Access was granted and the frame loaded, and then the item could not be rendered in the visitor's browser. A new key or origin will not fix one.

### Request IDs

Every response carries an `x-request-id` header, and every error repeats it in the body as `requestId`: a 12-character hex string, unique to that request.

a refusal, with its id:

```http
HTTP/1.1 401 Unauthorized
x-request-id: 4f9c1ad3e0b7

{"error":"That is not a valid key.","code":"key_unknown","requestId":"4f9c1ad3e0b7"}
```

Log it, and include it when you contact us, especially about a 5xx. With the id, we can find the exact request in our logs.

### Access codes

| Code | HTTP status | Meaning |
| --- | --- | --- |
| key_required | 401 | The request has no key. Add `key=<your key>`, or send the key from a backend in an `x-cs2-key` header. |
| key_unknown | 401 | The key is unrecognized: wrong format, a typo, or a key that does not exist. |
| key_disabled | 403 | The key has been disabled. If that is unexpected, email [hello@inspekt.gg](mailto:hello@inspekt.gg). |
| tenant_suspended | 403 | The account is suspended, and every request on it is refused. Email [hello@inspekt.gg](mailto:hello@inspekt.gg). |
| plan_required | 402 | The account has no plan: it is new, its free trial has ended, or its subscription has ended. Every request is refused until the account starts a free trial or subscribes in the [dashboard](https://inspekt.gg/dashboard). See [Terms 5.7 and 5.8](https://inspekt.gg/legal/terms#plans). |
| not_in_plan | 402 | The account's plan leaves this request out. Lens, the plan for the [Inspekt Lens](https://inspekt.gg/docs/extension) browser extension, includes renders through the account's secret key and the 3D viewer the extension opens, so a Lens key is refused on the Inventory API, and its publishable key and tokens are refused on renders and lookups. The refusal is not counted. Move to Hobby or Pro in the [dashboard](https://inspekt.gg/dashboard) to use them. |
| origin_not_allowed | 403 | The requesting page's origin is not registered for this key. Add it in the [dashboard](https://inspekt.gg/dashboard). [Keys and origins](https://inspekt.gg/docs/render-api#keys-and-origins) explains how the origin is read. |
| origin_required | 403 | The request carried a key without an `Origin` or `Referer` header. Put `referrerpolicy="strict-origin"` on the `<img>`, and on the `<iframe>` if you build it yourself (`CS2Embed.mount()` sets it), so the browser sends the origin even when the page's `Referrer-Policy` withholds the referrer. From a server, send an `Origin` header naming a registered origin. See [Keys and origins](https://inspekt.gg/docs/render-api#keys-and-origins). |
| token_required | 403 | The account has **Require tokens** on, and the request carried the publishable key. Send a token minted by your server with the secret key. See [Require tokens](https://inspekt.gg/docs/render-api#require-tokens). |
| token_expired | 401 | The token has expired. Mint another with your secret key. The [Node example](https://inspekt.gg/docs/render-api#minting-a-token) replaces its token a minute before it expires. |
| token_invalid | 401 | The token is one we did not issue, or the secret key that minted it has been rotated or revoked, or the account's publishable key has been rotated since. Mint another. |
| secret_key_required | 403 | `POST /api/token` was called with a publishable key or a token. Only the account's secret key can mint a token. See [Minting a token](https://inspekt.gg/docs/render-api#minting-a-token). |
| quota_exceeded | 429 | The plan's monthly quota for this kind of request is spent. Requests of that kind are refused until the quota resets at the end of the billing period, the time given in the `Retry-After` header and the error message. Other kinds keep working, and moving up a plan takes effect on the next request. See [Limits & SLA](https://inspekt.gg/docs/limits#monthly-quotas). |
| ip_daily_limit | 429 | One visitor address has reached the account's daily limit for this kind of request on this key. `Retry-After` points at the next 00:00 UTC. Other visitors are served as usual. Set the limit, or turn it off, in the [dashboard](https://inspekt.gg/dashboard); see [Daily limit per visitor](https://inspekt.gg/docs/limits#per-visitor-daily-limit). |
| rate_limited | 429 | Requests arrived faster than the plan's requests-per-second allowance, which all three surfaces share. Retry after the number of seconds in `Retry-After`. See [Requests per second](https://inspekt.gg/docs/limits#burst-limit). |

A refused request does not count toward any quota.

#### Refusals by surface

| Surface | Reported as |
| --- | --- |
| `/api/still`, `/api/inventory-lookup` | JSON: `{ error, code, requestId }` (plus `limit`/`used` for `quota_exceeded`), at the status above. On `/api/still`, a request for an image, such as an `<img>`, gets a webp card of the requested `w` × `h` with the status and the code drawn on it, at the same status. See [Render API](https://inspekt.gg/docs/render-api#errors). |
| `/embed` | A refusal card and a postMessage `error` event. See below. |

> **The embed is a page, so it cannot return JSON.** A refused `/embed` does both of these at once:
>
> 1. **Draws a refusal card** in place of the viewer, with one sentence and the error code. The card can be framed on any site, so it appears wherever the misconfiguration is.
> 2. **Fires a postMessage `error` event**: `{ channel: "cs2embed", type: "error", code, message }`, addressed to the `origin` you set, or `*` with none. See [postMessage API](https://inspekt.gg/docs/postmessage).
>
> Use the card while you integrate, and log the event in production.

The visitor's browser also enforces the origin list. A served `/embed` sends the key's registered origins in a `Content-Security-Policy: frame-ancestors` header, so the browser draws the viewer only when every page around it is on that list. The refusal card goes out with `frame-ancestors *`, so it shows on whichever site used the key.

### Fallback codes (embed only)

These occur after access is granted, when the item cannot be shown in the visitor's browser. The frame keeps the still it has already painted and puts the reason in a note over it. A frame with no still shows a 2D card in place of the 3D view. Either way, it fires a postMessage `fallback` event: `{ channel: "cs2embed", type: "fallback", code, message, id }`.

| Code | Card text | Cause |
| --- | --- | --- |
| no-webgpu | "The live 3D view needs WebGPU. Open this in Chrome or Edge." | The visitor's browser has no WebGPU support. |
| no-item | "No item was specified." | Both `inspect` and `s` are missing. |
| bad-inspect | "That inspect link could not be read." | The `inspect` value cannot be decoded, for example a Steam Market or inventory link ending in `M…A…D…` or `S…A…D…`. |
| not-in-catalog | "This finish is not renderable yet." | The inspect link names a real item and finish that the catalog does not have yet. |
| unknown-id | "That item is not in the catalog." | The `s` catalog id matches no item. |
| index-unavailable | "The viewer is temporarily unavailable." | The catalog index failed to load. |
| boot-failed | "The 3D renderer could not start on this device." | WebGPU was available, and the renderer failed to initialize. |
| load-failed | "This item could not be loaded." | The item's assets failed to load after the renderer started. |

If you build embed URLs from your own data, validate the inspect links and catalog ids first. Invalid ones produce `bad-inspect` and `unknown-id`, the two fallback codes an integration produces most often.

## 11. Limits and SLA

_From https://inspekt.gg/docs/limits_

Every plan is billed as one flat monthly fee, with no per-request or overage charges. Each plan has a monthly quota for each kind of request. Past a quota, the answer is `429` with `code: "quota_exceeded"` until the billing period renews.

### Monthly quotas

Each key has a quota per billing period for each kind of request: embed loads, renders and inventory lookups. A billing period starts on the day of the month your subscription started, at the same time of day in UTC, and ends when the subscription renews. In a month without that day, the period starts on the month's last day: a plan bought on 31 January resets on 28 February (29 in a leap year), then on 31 March.

Buying a plan for an account without one starts a new billing period at that moment, with the full quota. Moving up a plan keeps the billing period. An account without a subscription counts its periods from the start of its free trial, or from its creation if it has had no trial. An Enterprise agreement can name another date. The [dashboard](https://inspekt.gg/dashboard) shows when the current period resets.

| Plan | Embed loads | Renders | Inventory lookups | Over quota |
| --- | --- | --- | --- | --- |
| Hobby | 50,000 / mo | 10,000 / mo | 2,500 / mo | Throttled: `429` until the period renews |
| Pro · 500k loads | 500,000 / mo | 125,000 / mo | 50,000 / mo | Throttled: `429` until the period renews |
| Pro · 1.5M loads | 1,500,000 / mo | 375,000 / mo | 150,000 / mo | Throttled: `429` until the period renews |
| Pro · 5M loads | 5,000,000 / mo | 1,000,000 / mo | 500,000 / mo | Throttled: `429` until the period renews |
| Enterprise | Uncapped | Uncapped | Uncapped | n/a |
| Lens | 5,000 / mo | 5,000 / mo | None | Throttled: `429` until the period renews |

**Lens includes renders and the 3D viewer, through the secret key.** It is the plan for the [Inspekt Lens](https://inspekt.gg/docs/extension) browser extension. A Lens key is refused on the Inventory API with HTTP `402` and `code: "not_in_plan"`, ahead of the origin check, the rate limit and the quota, and so is the account's publishable key or a token on a render or a lookup, whatever origins the account kept. The publishable key and tokens open the 3D viewer from CSFloat, tradeit.gg, DMarket and Market.CSGO pages and from no other website, which is how the extension's 3D window uses them. A refusal is not counted.

**A plan stops at its quota.** On Lens, Hobby and Pro, once a quota is spent, requests of that kind are refused with HTTP `429` and `code: "quota_exceeded"`, with a `Retry-After` at the end of the billing period. Each kind is counted separately, so spending the render quota leaves embed loads and lookups working. Moving up a plan raises the cap on the next request. Enterprise is uncapped by agreement. The dashboard lists a spent kind under *At quota*, with the date and UTC time the period resets.

An embed load counts once per viewer, per item, per 24 hours; see [Embed loads](#embed-loads). A render and an inventory lookup count once per request served, including a request answered from our cache ([Terms 5.6](https://inspekt.gg/legal/terms#plans)), so size the render quota by the stills your pages show. A still a visitor's browser already holds, for up to 24 hours after we sent it, is shown from that copy without a request to us. A still fetched with a [token](https://inspekt.gg/docs/render-api#tokens-and-the-cache) counts once per viewer, per picture, per 24 hours, as an embed load does.

**A key serves requests only on a plan or a free trial.** A trial lasts 10 days, needs no card, and gives the key that plan's quotas, burst limit and origin limit. On a new account, after a trial ends and after a subscription lapses, the key is refused with HTTP `402` and `plan_required` on all three surfaces, and refused requests are not counted. Subscribing in the [dashboard](https://inspekt.gg/dashboard) restores service on the next request, with the account, its origins and its usage history intact. Try the product without an account in the [sandbox](https://inspekt.gg/sandbox) or the [demo grid](https://inspekt.gg/embed-demo.html).

The [dashboard](https://inspekt.gg/dashboard) shows the month's total and a bar per day for each kind. The daily view goes back 13 months, the time request logs are kept ([Privacy § 6](https://inspekt.gg/legal/privacy#retention)).

### Embed loads

**One load is one viewer seeing one item, counted once per 24 hours.** A listing page showing twelve different skins is twelve loads. A visitor who scrolls back to one of them, reloads the page or returns within 24 hours of the load that counted is served without a new count. The 24 hours run from that load, and the first view after them counts again.

Rotating, zooming and changing the backdrop happen inside the frame and send no request. Wear, pattern seed, stickers, charm, StatTrak and name tag define the item, so the same knife at two floats is two items. The camera angle (`view`), the backdrop (`bg`, `scene`) and the frame settings (`ui`, `theme`, `autorotate`, or a cache-busting parameter) sit outside the item and start no new count.

Each page `/embed` serves carries `x-cs2-counted`: `1` if the load counted, `0` if it did not. The dashboard shows both, so you can reconcile its count with your own analytics.

> **Recognising returning viewers.** The embed sets no cookies. The requesting address and user-agent are hashed with your key and the item, under a secret held only in memory, and the hash is kept in memory for 24 hours. The hash is one-way and includes your key, so the same person on two customers' sites gives two unrelated values. See [Privacy 2.2](https://inspekt.gg/legal/privacy#embed-privacy).
>
> The count is approximate. An office behind one address reads as one viewer, and a phone that moves from wifi to cellular reads as two.

### Requests per second

Each key has one requests-per-second allowance, shared across `/embed`, `/api/still` and `/api/inventory-lookup` and measured over a sliding ten-second window.

| Plan | Requests / 10s |
| --- | --- |
| Hobby | 60 |
| Pro · 500k loads | 300 |
| Pro · 1.5M loads | 600 |
| Pro · 5M loads | 1200 |
| Enterprise | Uncapped |
| Lens | 60 |

Over it, the response is `429` with `code: "rate_limited"` and a `Retry-After` header giving the seconds to wait. A refused request does not count toward your monthly quota.

If your workload needs a higher limit, email [hello@inspekt.gg](mailto:hello@inspekt.gg).

### Daily limit per visitor

Each key limits how many requests one visitor address can make in a UTC day, counted separately for each of the three surfaces.

| Plan | Embed loads / day | Renders / day | Lookups / day |
| --- | --- | --- | --- |
| Hobby | 1,000 | 1,000 | 1,000 |
| Pro · 500k loads | 5,000 | 1,250 | 1,000 |
| Pro · 1.5M loads | 15,000 | 3,750 | 1,500 |
| Pro · 5M loads | 50,000 | 10,000 | 5,000 |
| Enterprise | Uncapped | Uncapped | Uncapped |

Each number is 1% of the plan's monthly quota for that surface, with a floor of 1,000 a day. Over it, the response is `429` with `code: "ip_daily_limit"` and a `Retry-After` at the next 00:00 UTC. A refused request does not count toward your monthly quota. An embed load already counted that day keeps its 24-hour window, so the visitor can still see that item (see [Embed loads](#embed-loads)).

Set your own limit in the [dashboard](https://inspekt.gg/dashboard) under Keys: one number for all three surfaces, or `0` to turn the limit off. Raise it if many of your visitors share one address, as on mobile networks, company proxies and university networks.

The limit counts requests made with a publishable key or a token. Lens renders through the secret key, which is outside it, as every secret key is. Its 3D window loads the embed with a token, which the limit counts.

### Operational rate limits

These limits are the same on every plan and apply in addition to the limits above.

| Surface | Limit |
| --- | --- |
| Inventory lookups | 5 cache misses per IP per minute. Up to 12 lookups can wait in the queue at once. A Steam 429 pauses all lookups for 60 seconds. See [Inventory API](https://inspekt.gg/docs/inventory-api). |
| Renders | Up to 25,000 distinct stills queued for all keys together, 200 for one visitor's address and 500 for a free trial. Plans take turns by tier, keys take turns inside a plan, and each caller's stills are rendered in the order it asked. A full queue makes room from whichever key or visitor holds the most stills nobody is waiting on, counted against its tier's weight. A miss expected within 195 seconds waits for its render, up to 240 seconds, and an image within 240; one further off is answered at once and keeps its place. With `wait=0`, a miss gets the placeholder at once. See [The render queue](https://inspekt.gg/docs/render-api#render-queue). |

### Origins per key

| Plan | Registered origins |
| --- | --- |
| Hobby | 3 |
| Pro · 500k loads | 10 |
| Pro · 1.5M loads | 20 |
| Pro · 5M loads | 50 |
| Enterprise | 100 |
| Lens | 0 |

Manage origins in the [dashboard](https://inspekt.gg/dashboard). A key needs at least one registered origin before it serves a page. Lens registers none: its extension sends the secret key, which works without one, and its 3D window is served from CSFloat, tradeit.gg, DMarket and Market.CSGO pages, which Lens includes without registering them. On Hobby and Pro the extension's 3D window opens on those pages too, with no origin registered. A Lens account's publishable key and tokens are refused with `not_in_plan` on renders and lookups, and with `origin_not_allowed` on the embed from any other website, even where it kept origins from an earlier plan.

### Uptime and coverage

- **99.5% monthly uptime**, under the [SLA](https://inspekt.gg/legal/sla).
- **2,105 of 2,105 weapon and finish combinations render**, as of 23 September 2026.
- **New CS2 cases are renderable within 7 days** of release ([Terms section 9](https://inspekt.gg/legal/terms#coverage)).

## 12. Plans and quotas

_From https://inspekt.gg/pricing_

Flat monthly fees with hard quotas. Every paid plan starts with a 10-day free trial that needs no card, and billing starts the day you subscribe. The [sandbox](https://inspekt.gg/sandbox) and the [demo grid](https://inspekt.gg/embed-demo.html) are open to anyone without signing in.

- [Personal For one person: Inspekt Lens, the browser extension and Pro Studio, $5 a month.](#personal)
- [Business For sites using inspekt.gg: Hobby, Pro and Enterprise, from $20 a month.](#business)

### Business

For sites that show skins on their own pages. Every plan includes the live [embed](https://inspekt.gg/docs/embed), the [Render API](https://inspekt.gg/docs/render-api) and the Inventory API, each with its own monthly quota. A larger plan costs less per unit. Origins per plan are in [Limits & SLA](https://inspekt.gg/docs/limits#monthly-quotas).

Hobby

$20/mo

- 50,000 embed loads / mo
- 10,000 renders / mo
- 2,500 inventory lookups / mo
- First reply within 2 business days

[Start a 10-day free trial](https://inspekt.gg/dashboard#plan) [Get a key](https://inspekt.gg/dashboard)

The trial needs no card.

Pro

Pro · 500k loads

$100/mo

- 500,000 embed loads / mo
- 125,000 renders / mo
- 50,000 inventory lookups / mo
- First reply within 1 business day

Pro · 1.5M loads

$200/mo

- 1,500,000 embed loads / mo
- 375,000 renders / mo
- 150,000 inventory lookups / mo
- First reply within 1 business day

Pro · 5M loads

$500/mo

- 5,000,000 embed loads / mo
- 1,000,000 renders / mo
- 500,000 inventory lookups / mo
- First reply within 1 business day

[Start a 10-day free trial](https://inspekt.gg/dashboard#plan) [Get a key](https://inspekt.gg/dashboard)

The trial needs no card. Pick the size when you start it.

Enterprise

Need more? Contact us.

- Uncapped embed loads, renders and inventory lookups, by agreement
- Direct support, and billing arranged by email

[Email us](mailto:hello@inspekt.gg?subject=Inspekt%20Enterprise)

Sign in at the [dashboard](https://inspekt.gg/dashboard) to get a key and register your origins. The key serves requests once the account starts a trial or subscribes. The [comparison page](https://inspekt.gg/compare) sets these plans against building the same thing in house.

### Personal

Inspekt Lens is two things for one person: our [browser extension](https://inspekt.gg/docs/extension) and [Pro Studio](https://inspekt.gg/pro). On CSFloat, tradeit.gg, DMarket and Market.CSGO the extension draws our render over each listing, shows an item's front and back in HQ, compares items side by side and opens the item in 3D over the listing. In Pro Studio, on this site, you set the lighting, the pose and the background, your own picture or video with blur included, and capture a clean picture of your skin. A Lens key works only in the extension; the Render API and the embed need a Business plan.

Lens

$5/mo

- 5,000 embed loads / mo
- 5,000 renders / mo
- Inventory lookups not included
- Our render over each listing
- An item's front and back in HQ
- Compare and the 3D viewer over the listing
- Pro Studio: set the lighting, pose and background, your own picture or video with blur, and capture a clean picture up to 4K
- 3 cinematic renders a day

Coming soon

[How Lens works](https://inspekt.gg/docs/extension)

[Install the extension](https://inspekt.gg/docs/extension#install)

### Details

#### What counts as an embed load?

**One viewer seeing one item, once per 24 hours.** A page showing twelve different skins is twelve loads. A reload, or a return the same day, is not counted again. Rotating and zooming happen in the frame and do not count. The item is the skin and its configuration (wear, pattern seed, stickers, charm, StatTrak, name tag), so the same knife at two floats is two items.

Each `/embed` response carries an `x-cs2-counted` header, `1` if the load counted and `0` if not. See [Limits & SLA](https://inspekt.gg/docs/limits#embed-loads) and [Terms 5.6](https://inspekt.gg/legal/terms#plans).

#### What counts as a render?

One served request to `GET /api/still`, including one answered from our cache. The [Render API](https://inspekt.gg/docs/render-api#quota-and-billing) page has the exact rule.

#### What happens at the quota?

Requests of that kind are refused with HTTP `429` and `quota_exceeded`, with a `Retry-After` that runs to the end of the period. Each kind is counted separately. A quota resets each month on the day and at the UTC time your subscription started. Moving up a plan raises the cap on the next request.

Every plan is one flat monthly fee, with no overage charges. A still served from our cache counts like a fresh render, and a visitor's browser keeps each still for up to 24 hours. For a bigger quota, [email us](mailto:hello@inspekt.gg?subject=Inspekt%20Enterprise) about Enterprise. See [Limits & SLA](https://inspekt.gg/docs/limits#monthly-quotas).

#### How does the free trial work?

There is a free trial and no free plan. Hobby, every size of Pro and Lens have a 10-day trial, one per account, with no card. Sign in at the [dashboard](https://inspekt.gg/dashboard) and start it on the plan you want to try. For those 10 days the key gets that plan's quotas and origin limit. Subscribing during the trial ends it and starts billing that day.

Before a trial or subscription, and after a trial ends without one, every request is refused with `plan_required`. The key and its origins stay saved.

#### Can I cancel, and do you refund?

Plans are month to month, with no setup fee or contract. Cancel in the dashboard and the plan runs until the paid period ends. Then the key stops serving until you pick a plan again, which restores service the same minute. We do not refund a month already started, and we do refund our own billing errors. The [Refunds and Cancellation](https://inspekt.gg/legal/refunds) policy covers plan changes and failed payments.

#### What does every plan cover?

The full catalog: 2,105 of 2,105 weapon and finish combinations render, as of 23 September 2026. All 143 charms render on the 35 guns that carry one, at any spot and seed; see [Charms](https://inspekt.gg/docs/render-api#charms). New CS2 cases are renderable within 7 days of release. See [Limits & SLA](https://inspekt.gg/docs/limits#uptime-and-catalog-freshness).

#### What is Enterprise?

A plan set by a written agreement, which fixes its fees, quotas and origin limit. The rest of the [Terms](https://inspekt.gg/legal/terms), including the SLA, applies as to any other plan. [Email us](mailto:hello@inspekt.gg?subject=Inspekt%20Enterprise) to arrange one.

## 13. Worked example: a marketplace listing page

One still per listing card, and the live embed on the listing's own page. The
still is a plain `<img>`: no JavaScript, no iframe, and it draws in every
browser.

```html
<!-- the card in a grid: the square-on listing shot, on the item's rarity plate -->
<img src="https://inspekt.gg/api/still?inspect=INSPECT_LINK_URL_ENCODED&view=front&bg=rarity&w=640&h=400&key=cs2pk_your_key_here"
     width="640" height="400" loading="lazy" referrerpolicy="strict-origin"
     alt="AK-47 | Redline, Field-Tested">

<!-- the listing page itself: the live viewer -->
<div id="viewer" style="width:640px;height:400px"></div>
<script src="https://inspekt.gg/cs2embed.js"></script>
<script>
  var viewer = CS2Embed.mount(document.getElementById('viewer'), {
    inspect: 'steam://rungame/730/76561202255233023/+csgo_econ_action_preview 001807209A0228043004388F85D7F30340950512E67D72',
    key: 'cs2pk_your_key_here',
    view: 'front',
  });
  viewer.on('error', function (e) { console.error('embed refused:', e.code, e.message); });
  viewer.on('fallback', function (e) { console.warn('item problem:', e.code); });
</script>
```

Counting: each of the twelve cards in a grid is one embed load only if it
mounts the viewer. A still counts as one render. The same visitor returning to
an item they already loaded that day is served and not counted again.

## 14. Worked example: a server-side `<img>`

Rendering the tag on the server, for an Open Graph image or a page with no
JavaScript. Build the URL and hand it to the browser; do not fetch the image
server-side on the request path.

```javascript
function stillUrl(inspectLink, opts) {
  var q = new URLSearchParams({
    inspect: inspectLink,
    view: opts.view || 'hero',        // hero | front | back | top
    bg: opts.bg || 'steel',           // steel is the default
    w: String(opts.w || 1200),        // 160..1600
    h: String(opts.h || 630),         // 160..1600
    key: process.env.INSPEKT_KEY,     // publishable; it ends up in the HTML
  });
  return 'https://inspekt.gg/api/still?' + q.toString();
}

// <meta property="og:image" content={stillUrl(link, { w: 1200, h: 630 })}>
```

A first request for a configuration nobody has rendered waits while it
renders, about a second, and returns the render. Pre-warm the URL once off the
critical path (fetch it and throw the bytes away) so that no crawler or visitor
waits. Send that pre-warm with an `Origin` header naming one of the key's
registered origins; a keyed request missing both `Origin` and `Referer` is
refused with 403 `origin_required`. For a page that must paint at once, send
`wait=0`: a miss then returns a placeholder immediately and queues the render.

## 15. Plans and quotas, from the code

| Plan | Monthly | Embed loads / mo | Renders / mo | Inventory lookups / mo | Requests / 10s | Origins |
| --- | --- | --- | --- | --- | --- | --- |
| Hobby | $20/mo | 50,000 | 10,000 | 2,500 | 60 | 3 |
| Pro · 500k loads | $100/mo | 500,000 | 125,000 | 50,000 | 300 | 10 |
| Pro · 1.5M loads | $200/mo | 1,500,000 | 375,000 | 150,000 | 600 | 20 |
| Pro · 5M loads | $500/mo | 5,000,000 | 1,000,000 | 500,000 | 1200 | 50 |
| Enterprise | by agreement | uncapped | uncapped | uncapped | uncapped | 100 |
| Lens | $5/mo | 5,000 | 5,000 | not included | 60 | 0 |

Quota is per billing period, per event kind, per key. A billing period starts
on the day of the month the subscription started, at the same time of day in
UTC, and ends when Stripe renews the subscription; in a month without that
day it starts on the month's last day. Buying a plan starts a new period with
the full quota. A request past a quota is refused and is not counted. The
requests-per-10s column is a separate allowance shared across
`/embed`, `/api/still` and `/api/inventory-lookup`; over it the answer is
429 `rate_limited` with a `Retry-After` in seconds, and the refused request
is not counted. A kind the plan leaves out, such as the embed on Lens, is
refused with 402 `not_in_plan` and is not counted either.

A third allowance caps one visitor's address: a hundredth of the plan's monthly
quota for that surface, with a floor of 1,000, per key per UTC day. Over it the
answer is 429 `ip_daily_limit` with a `Retry-After` that lands on the next
00:00 UTC, and the refused request is not counted. The account sets its
own number in the dashboard, or 0 to serve every address whatever it asks for.

## 16. Go live: the ten-line checklist

1. Sign in at https://inspekt.gg/dashboard and copy the publishable key.
2. Pick a plan. A key with no plan answers 402 `plan_required`.
3. Register every origin the page runs on, staging and production, with the
   scheme and port each one uses.
4. Put `key=` on every `/api/still` and `/embed` request, and
   `referrerpolicy="strict-origin"` on every `<img>` and every hand-built
   `<iframe>` (`CS2Embed.mount()` sets it on its own), so the browser tells
   us which registered origin the request came from.
5. Size the still to the item: `view=front` with a wide frame for a rifle,
   `w` and `h` inside 160 to 1600.
6. Pre-warm the stills your first page will show, once, before launch.
7. Handle the two events the embed fires: `error` for an access refusal,
   `fallback` for an item that could not be drawn.
8. Log the `x-request-id` header on every failure; support asks for it.
9. Point the `<img>` at the still URL itself. A render answers
   `cache-control: private, max-age=86400`, so the viewer's browser keeps it
   for a day; a placeholder answers `no-store` and must not be cached. A copy
   you store yourself may be shown for 24 hours, then requested again (Terms
   3.5).
10. Watch the quota in the dashboard, and set the daily limit per visitor
    address while you are there. A spent quota answers 429 `quota_exceeded`
    until the billing period renews, and the dashboard shows the date and UTC
    time it resets.
