Platform integration tutorial

Build a CS2 Marketplace Item Page

Combine catalog identity, free marketplace prices, and an embedded 3D preview in a runnable Node.js item page.

Last updated 2026-09-15 · Reviewed by OpenSkin · 30 minutes · Intermediate

What you will build

Build one marketplace-style item page, not a complete marketplace. The free Pricing API supplies recorded asks; the paid Platform resolves catalog identity and supplies the hosted 3D session. This starter previews standard weapon finishes. Exact owned items, checkout, payments, custody, accounts, and trading are outside this example.

Completed Redline item page with an interactive 3D preview and five marketplace asks
The complete example running against the test Platform and free Pricing API. Prices and checked times change.

Before you start

  • Node.js 20 or newer and mkcert installed for a trusted local HTTPS certificate.
  • A Platform API key with catalog:read, render:create, and scenes:read. Keep it on your server.
  • A saved scene from your Platform Scene Builder, using a weapon-compatible camera. Copy its scene ID. Scene setup has its own usage costs.
  • An account with Platform access. Each successful run uses one catalog lookup (1 unit) and one viewer session (5 units). The free v1 price lookup needs no key and consumes no Platform units. At $1 per 1,000 PAYG units, six units cost $0.006; plan allowances may apply. Retries or scene setup are additional.

Project files

marketplace-item/
  server.mjs
  index.html
  localhost.pem (generated)
  localhost-key.pem (generated)

Build it

  1. Prepare a saved scene and scoped key

    Use an existing scene in your account with a weapon-compatible camera. The key needs all three scopes listed below. Do not paste the key into index.html or commit it.

  2. Create the two files

    Copy server.mjs and index.html below into a new directory. The server fetches once at startup, so browser reloads do not create more metered sessions.

  3. Enable local HTTPS

    Install mkcert following its official instructions at https://github.com/FiloSottile/mkcert, then run the commands below. mkcert -install adds a local development CA to your machine. Keep its private key private. An HTTP parent origin is rejected by the hosted viewer.

  4. Start and verify

    Supply the scene ID and API key, start the server, and open the HTTPS address. Catalog lookup errors stop startup. Pricing and viewer failures are shown independently so one does not hide the other.

server.mjs

Server-only API requests and local HTTPS delivery

import {createServer} from 'node:https'
import {readFile} from 'node:fs/promises'

const api = process.env.OPENSKIN_BASE_URL ?? 'https://api-v2.openskin.dev'
const pricing = process.env.PRICING_BASE_URL ?? 'https://api.openskin.dev'
const key = process.env.OPENSKIN_API_KEY
const scene = process.env.OPENSKIN_SCENE_ID
const name = process.env.ITEM_NAME ?? 'AK-47 | Redline (Field-Tested)'
if (!key || !scene) throw new Error('Set OPENSKIN_API_KEY and OPENSKIN_SCENE_ID.')

// Validate local files before making any metered requests.
const [cert, privateKey, page] = await Promise.all([
  readFile('localhost.pem'), readFile('localhost-key.pem'), readFile('index.html'),
])
async function request(url, options = {}) {
  const response = await fetch(url, {...options, signal: AbortSignal.timeout(60000)})
  const body = await response.json()
  if (!response.ok) throw new Error(response.status + ': ' + (body.error?.message ?? body.message ?? 'API request failed'))
  return body
}
const headers = {'X-API-Key': key, 'Content-Type': 'application/json'}
const query = new URLSearchParams({q: name, limit: '100'})
const catalog = await request(api + '/v2/catalog/items?' + query, {headers})
const item = catalog.data.items.find(entry => entry.market_hash_name === name)
if (!item) throw new Error('Exact item not found. Check the full Steam market name, including exterior.')
const render = item.render
if (item.stattrak || item.souvenir || render?.family !== 'weapon' || !['validated', 'published', 'approved'].includes(render.availability)) {
  throw new Error('This starter requires a standard, renderable weapon finish. Use inspect resolution for exact owned items.')
}
// This is a representative catalog preview, not an individual sale listing.
const state = {}
if (render.wear.supported) {
  const wear = Number(render.wear.default)
  if (render.wear.default == null || !Number.isFinite(wear)) throw new Error('Catalog wear default is missing.')
  state.wear = wear
}
if (render.pattern.supported) {
  if (!Number.isInteger(render.pattern.default)) throw new Error('Catalog pattern default is missing.')
  state.pattern = render.pattern.default
}
const identity = {family: render.family, definition_index: render.definition_index}
if (render.paint_index != null) identity.paint_index = render.paint_index

let prices = []
let priceError = ''
try {
  const quotes = await request(pricing + '/v1/prices?' + new URLSearchParams({item: item.market_hash_name}))
  prices = Object.entries(quotes.prices ?? {}).flatMap(([marketplace, quote]) =>
    typeof quote?.ask === 'number' && Number.isFinite(quote.ask)
      ? [{marketplace, ask: quote.ask, checked: quote.updated_at ?? null}] : [])
} catch (error) { priceError = error.message }

let reviewUrl = null
let viewerError = ''
try {
  const receipt = await request(api + '/v2/viewer/sessions', {
    method: 'POST', headers, body: JSON.stringify({scene_id: scene, item: identity, state}),
  })
  const review = new URL(receipt.session.review_url)
  if (review.protocol !== 'https:') throw new Error('Expected an HTTPS viewer URL.')
  reviewUrl = review.href
} catch (error) { viewerError = error.message }

// Whitelist browser fields. Never return the key, ticket, or full session receipt.
const result = {name: item.market_hash_name, catalogId: item.id, state, prices, priceError, reviewUrl, viewerError}
createServer({cert, key: privateKey}, (req, res) => {
  res.setHeader('Cache-Control', 'no-store')
  res.setHeader('X-Content-Type-Options', 'nosniff')
  res.setHeader('Referrer-Policy', 'no-referrer')
  if (req.method !== 'GET' || !['/', '/item'].includes(req.url)) {
    res.writeHead(404).end('Not found')
    return
  }
  res.setHeader('Content-Type', req.url === '/item' ? 'application/json' : 'text/html; charset=utf-8')
  res.end(req.url === '/item' ? JSON.stringify(result) : page)
}).listen(Number(process.env.PORT ?? 8090), '127.0.0.1', function () {
  console.log('Open https://localhost:' + this.address().port)
})

index.html

Accessible item details, marketplace table, and hosted 3D iframe

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <meta name="referrer" content="no-referrer">
  <title>CS2 marketplace item page</title>
  <style>
    :root {color-scheme:dark; font-family:system-ui,sans-serif; background:#181818; color:#ddd}
    body {margin:0; padding:24px} main {max-width:1000px; margin:auto}
    h1 {font-size:clamp(24px,5vw,40px)} p {line-height:1.6}
    iframe {display:block; width:100%; height:480px; border:1px solid #444; border-radius:6px}
    table {width:100%; border-collapse:collapse} th,td {padding:12px 8px; text-align:left; border-bottom:1px solid #444}
    .table {overflow:auto} a {color:inherit} :focus-visible {outline:2px solid #b2a46c; outline-offset:4px}
    @media(max-width:600px) {body {padding:16px} iframe {height:320px} th,td {font-size:13px}}
  </style>
</head>
<body><main>
  <h1 id="name">CS2 marketplace item page</h1>
  <p id="status" role="status">Loading item details...</p>
  <p>Catalog preview, not a sale listing. Wear and pattern are representative defaults, not an owned item's exact state.</p>
  <p id="state"></p>
  <section aria-label="3D item preview" id="viewer"></section>
  <h2>Recorded marketplace asks</h2>
  <p id="price-status" role="status"></p>
  <div class="table"><table>
    <thead><tr><th>Marketplace</th><th>Ask (USD)</th><th>Checked</th></tr></thead>
    <tbody id="prices"></tbody>
  </table></div>
  <p>Prices describe the market-name variant above, not a guaranteed quote for the preview's exact pattern or wear.</p>
</main>
<script type="module">
const status = document.querySelector('#status')
try {
  const response = await fetch('/item')
  if (!response.ok) throw new Error('Could not load item details.')
  const item = await response.json()
  document.querySelector('#name').textContent = item.name
  document.querySelector('#state').textContent = 'Preview wear: ' + (item.state.wear ?? 'not applicable') + '; pattern: ' + (item.state.pattern ?? 'not applicable')
  status.textContent = item.viewerError ? '3D unavailable: ' + item.viewerError : 'Item details loaded. The 3D viewer loads separately below.'
  if (item.reviewUrl) {
    const url = new URL(item.reviewUrl)
    url.searchParams.set('parentOrigin', location.origin)
    const frame = document.createElement('iframe')
    frame.title = 'Interactive 3D preview of ' + item.name
    frame.allowFullscreen = true
    frame.src = url.href
    document.querySelector('#viewer').append(frame)
  }
  document.querySelector('#price-status').textContent = item.priceError
    ? 'Prices unavailable: ' + item.priceError
    : item.prices.length ? 'One recorded ask per available marketplace.' : 'No current asks are available.'
  for (const quote of item.prices) {
    const row = document.createElement('tr')
    const checked = quote.checked ? new Date(quote.checked) : null
    for (const value of [quote.marketplace, new Intl.NumberFormat('en-US', {style:'currency', currency:'USD'}).format(quote.ask), checked && !Number.isNaN(checked.getTime()) ? checked.toLocaleString() : 'Not provided']) {
      const cell = document.createElement('td')
      cell.textContent = value
      row.append(cell)
    }
    document.querySelector('#prices').append(row)
  }
} catch (error) { status.textContent = error.message }
</script></body></html>

Run it

cd marketplace-item
mkcert -install
mkcert -cert-file localhost.pem -key-file localhost-key.pem localhost 127.0.0.1
export OPENSKIN_API_KEY="YOUR_SCOPED_KEY"
export OPENSKIN_SCENE_ID="YOUR_SAVED_SCENE_ID"
node server.mjs
# Open https://localhost:8090

Expected output

Open https://localhost:8090

AK-47 | Redline (Field-Tested)
Recorded marketplace asks
Marketplace    Ask (USD)    Checked
[Available marketplace rows use live response values]
[Interactive 3D catalog preview]

How it works

  • The full Steam market name selects one catalog item. Rendering uses the returned render contract, not a guessed paint index. Unsupported variants fail explicitly instead of becoming a Standard preview.
  • Only the review URL and display fields reach the browser. The iframe uses the same protected hosted renderer as Platform integrations; this tutorial changes no rendering code.
  • The server binds to loopback and is a local starter, not a publicly deployable multi-user backend. Before public hosting, add authentication, bounded session creation and application-specific caching. Never expose an unlimited session-creation endpoint.
  • Restarting the server repeats metered calls and refreshes its snapshot. Reloading the browser does not. Review URLs expire; obtain a new session when needed rather than constructing asset URLs. Treat review URLs as capabilities and keep them out of logs and analytics.
  • The default wear and pattern illustrate the catalog finish. A market-name price is not an exact float, sticker, or pattern valuation. Use inspect-link resolution when presenting an actual owned item.

Verify the result

Open https://localhost:8090. Confirm the exact item name, separate marketplace asks and checked times, and a fully rendered interactive 3D preview. Drag to rotate the item. Missing prices must not appear as $0; a viewer error must leave the price table readable. Browser requests must never include your Platform key.

Troubleshooting

What you seeLikely causeWhat to do
401 or 403Invalid key, missing scope, or inaccessible scene.Check the same-account scene ID and all three key scopes. Do not move the API key into browser code.
Exact item not found or unsupported previewIncorrect full market name or a non-standard/non-renderable item.Start with the documented Redline name. This starter intentionally rejects StatTrak, Souvenir, and non-weapon families rather than misrepresenting them.
3D frame rejected or certificate warningHTTP parent origin or an untrusted local certificate.Use https://localhost:8090 with the trusted mkcert certificate. Do not disable TLS verification or use a wildcard parentOrigin.
Prices missingPrice lookup failed or no source returned an ask.Read the message and retry later. An absent quote is not a zero price; the 3D preview can still be used.
Viewer unavailable or expiredSession creation failed, the saved camera is incompatible, or the review capability expired.Check the displayed error and scene camera, then restart once to request a new session. Do not loop retries; each run can consume units.