Realtime rendering

Send a design — or just its saved id — over a WebSocket and a finished mockup streams back; the server fetches the referenced artwork itself. No per-placement pixel upload. The same primitive that powers mockups on snowcone.app.

Applies to @snowcone-app/sdk@0.17.0 · @snowcone-app/canvas@0.33.1

What it is

A mockup URL composites one piece of artwork onto a product. A real design is often more: several layers, warped text, effects, multiple placements — the kind of thing the Canvas editor produces. This primitive renders that whole canvas server-side.

The browser sends a ~1 KB JSON description of the canvas (or just a saved design’s id) over a WebSocket — and the server fetches the artwork assets referenced inside the state itself. No artwork bytes cross the wire: a thin or mobile client never renders each placement to a PNG and uploads it, so it stays fast on slow networks. This is exactly how the snowcone.app product pages render their mockups.

The whole interface is two calls: mintRealtimeGrant on your server (a 60s grant, auto-renewed) and a RenderSession in the browser — it opens wss://cdn.snowcone.app/realtime?token=…, you send state per placement, and rendered mockup URLs arrive on onMockups. Everything past that interface is ours to change without notice.

Install

pnpm add @snowcone-app/sdk

The realtime surface ships in the SDK. @snowcone-app/canvas is only needed if you also produce live canvas states in the browser.

1. Mint a grant (server)

Renders bill to your organization, so the session is authorized by a secret API key — never exposed to the browser. Mint a grant on your backend and hand the { token, expiresAt } down to the client. The key needs the mockups:realtime scope and its organization must own the target shop. Like every billable operation, renders draw on the org’s shared balance — plan credits, then wallet, then a 429 (shop quota exceeded) at the grant; see Billing & limits for who meters whom, and put your per-user limits in the grant route’s gate.

tsx
// YOUR backend — e.g. a Next.js route handler at POST /api/realtime/grant.
// The secret API key (sk_…) never reaches the browser.
import { mintRealtimeGrant } from '@snowcone-app/sdk';

export async function POST(req: Request) {
  const { shop } = await req.json();
  const grant = await mintRealtimeGrant({
    apiKey: process.env.SNOWCONE_API_KEY!, // sk_… with the mockups:realtime scope
    shop,                                  // = shop.id (publishable, like Stripe pk_)
  });
  return Response.json(grant); // { token, expiresAt } — 60s, auto-renewed client-side
}
Create the key on the API keys page (snowcone.app/studio/api-keys) with the mockups:realtime scope — the same page shows your publishable shop.id and a one-click Create shop if you don’t have one yet. Treat sk_ keys like passwords — server-side only. See Secret API keys for the full issuance flow and scopes.
Sandbox shops: mint keyless. A sandbox shop (from Get started) does not come with an sk_ key — the scsec_… “shop secret” it returns is for signing image URLs, not for authorizing a grant. So for the sandbox path your /api/realtime/grant proxy calls mintRealtimeGrant({ shop }) with no apiKey — the SDK omits the Authorization header entirely, and the publishable shop.id alone authorizes the grant. This is the path that gets you from a freshly minted sandbox shop to a live mockup end-to-end.
tsx
// SANDBOX path — no API key. The publishable shop.id alone authorizes the
// grant; the SDK omits the Authorization header when you pass no apiKey
// (shipped in @snowcone-app/sdk — the keyless publishable grant path).
// NOTE: the scsec_… "shop secret" from POST /shops/sandbox is for SIGNING
// image URLs — it is NOT an sk_ key and does NOT go here.
import { mintRealtimeGrant } from '@snowcone-app/sdk';

export async function POST(req: Request) {
  const { shop } = await req.json();
  const grant = await mintRealtimeGrant({ shop }); // shop.id only — no apiKey
  return Response.json(grant); // { token, expiresAt }
}

Production path: once your human claims the shop, create an sk_ key on the API-keys page and switch to the secret-key mint above — it proves server-side possession, so a leaked shop.id can’t mint renders on your account, and it automatically satisfies the signed rung (require_signed_urls). On the signed rung without an sk_ key, use the drop-in handler below — it signs the grant request for you:

tsx
// SIGNED rung — drop-in. @snowcone-app/sdk/server signs the grant request
// with your scsec_… shop secret server-side, so you never compute the HMAC by
// hand. Same POST { shop } → { token, expiresAt } contract as the mints above.
import { createRealtimeGrantHandler } from '@snowcone-app/sdk/server';

export const POST = createRealtimeGrantHandler({
  secret: process.env.SNOWCONE_SHOP_SECRET!, // scsec_… (server-only)
  shop: process.env.SNOWCONE_SHOP_ID!,       // = shop.id
  // gate: async (req) => isLoggedIn(req), // optional auth / rate-limit
});

So there are two server recipes, and the rung decides which: keyless mintRealtimeGrant({ shop }) for sandbox / publishable shops, and the signed drop-in createRealtimeGrantHandler({ secret, shop }) (the scsec_… shop secret) once the shop is on the signed rung. Both expose the same POST { shop } → { token, expiresAt } contract your grantUrl proxy points at.

2. Render (browser)

Point a RenderSession at your grant proxy. It hides the connect → grant → config → renew → send-state choreography. Register onMockups, then describe your design and render it.

The recommended way is design() + renderDesign: compose a design by intent — a full-bleed background, an anchored title, a shape in a corner — and the SDK places everything for you. You never touch raw coordinates, so the classic mistake (an image at x:0, y:0 centred on the artboard’s corner, rendering a quadrant) can’t happen.

tsx
import { RenderSession } from '@snowcone-app/sdk';
import { design } from '@snowcone-app/canvas';

const session = new RenderSession({
  shop: 'YOUR_SHOP_ID',            // publishable
  grantUrl: '/api/realtime/grant', // your proxy → mintRealtimeGrant (above)
  // mockupIds are catalog scene codes (product.mockups[].id), NOT placement names.
  product: { productId: 'BC43BE', mockupIds: ['yoALRE'] }, // iPhone 17 Pro Max Case
});
session.onMockups((results) => { img.src = results[0].imageUrl; });
session.onError((message: string) => console.error('render error:', message));

// Compose by INTENT — never raw coordinates. A full-bleed background can't land
// in a corner; anchored text/shapes place themselves. The artboard size is the
// placement's pixel dimensions (from getProduct(productId).placements[]).
const d = design({ name: 'Front', width: 1480, height: 2328 })
  .background('https://dqMwU8Jct3.storage.snowcone.app/demo/sunset.avif', {
    aspectRatio: 1480 / 2328,      // fills the case, centre-cropped — no distortion
  })
  .text('YOUR NAME', { anchor: 'top' })
  .shape('star', { anchor: 'bottom-left', fill: '#000' });

// renderDesign(placement, design, throttleMs?) ==
//   renderState(placement, design.toServerRequest(), throttleMs).
// No hand-built canvas_state, no centre-coordinate math.
await session.renderDesign('Front', d, 200);

.background() fills the whole placement; pass the source’s aspectRatio and it cover-fits (centre-cropped, no distortion). Anchors are a nine-point grid (top, bottom-left, center, …) with optional padding. Add as many .text() / .shape() / .image() layers as you like — order is back-to-front.

The same design drives the interactive editor. A design is just a list of layers — hand design(…).toJSON() to <SnowconeCanvas /> and the shopper can drag, scale, and rotate those same layers. Author headlessly, render live, edit interactively — one design, three surfaces.

From a canvas state (when you already have an editor)

If your design comes from a live SnowconeCanvas editor (the shopper is dragging layers), render its state directly with renderState + serializeStateForServer. The editor produces correct coordinates, so you render its onChange output as-is — this is the path under renderDesign, exposed for when you hold a full canvas state.

tsx
import { RenderSession, getProduct } from '@snowcone-app/sdk';
import { serializeStateForServer } from '@snowcone-app/canvas';

const product = await getProduct('BEEB77');
const mockupId = product.mockups?.[0]?.id ?? 'FV1qjO';
const variantId = product.options?.combinations?.find((c) => c.variantId)?.variantId;

const session = new RenderSession({
  shop: 'YOUR_SHOP_ID',            // publishable
  grantUrl: '/api/realtime/grant', // your proxy → mintRealtimeGrant (above)
  // mockupIds are opaque catalog scene codes (the product's mockups[].id) —
  // NOT placement names. (BEEB77 = Framed Canvas; FV1qjO is one of its mockups.)
  // variantId is REQUIRED for any product with a color/variant option. Read it
  // from the live catalog and pass options so the SDK can validate immediately.
  product: { productId: product.id, mockupIds: [mockupId], variantId, options: product.options },
});

// Rendered mockup URLs arrive here — repaint your <img> on every update.
session.onMockups((results) => { img.src = results[0].imageUrl; });
// onError receives a STRING message (not an Error object).
session.onError((message: string) => console.error('render error:', message));

// First arg is the PLACEMENT (print-area name, e.g. 'Front') — a different id
// from mockupIds, and it must match an artboard in the state. Read the exact
// label from getProduct('BEEB77').placements[].label. The server fetches the
// artwork referenced inside; you never upload a pixel. Safe on every edit.
await session.renderState('Front', serializeStateForServer(canvasState), 200);

The third argument to renderState (and renderDesign) is throttleMs — an optional throttle interval in milliseconds (defaults to 0, i.e. no throttle). It is trailing-edge: it caps how often state is sent (at most one send per interval) and always sends the latest state, so a shopper dragging doesn’t flood the renderer. A burst of edits coalesces to the final one — the trailing state is never dropped, so it is safe to call straight from onChange.

onError receives a plain string message — (error: string) => void, not an Error object. And you never need to call connect(): the session connects, authorizes, and configures lazily on the first renderState. (connect() is available if you want to warm the socket ahead of the first edit.)

What to expect for latency: the first live mockup typically lands ~5–6 seconds after connecting (connection setup plus a cold render), then ~5 seconds per render after that. That is rendering time, deliberately distinct from fetching an already-rendered mockup image URL (the img.snowcone.app GET), which is sub-second. Use throttleMs during a drag so you don’t queue renders faster than they complete.

Every render tells you what it cost. Each result that arrives on onMockups reports its own telemetry: renderMs (server-side render time for that mockup, in milliseconds), costUsd (what that render cost, in USD), and renderCache (hit or miss). Log them during development to see exactly where your latency and spend go — they are the same numbers the HTTP path returns as the x-render-ms / x-render-cost-usd response headers on a mockup URL fetch (see Cost & timing headers).

Sharpness — size product.width to your display. The rendered mockup is 1000 px wide by default. Request roughly the CSS width you display it at × devicePixelRatio, or the preview looks soft: a 650-px-wide <img> on a 2× display needs ~1300 source px, so pass width: 1400 (or 2000 for a crisp zoomed/cropped view). To change it mid-session — low-res while the shopper drags, sharp on release (e.g. 600 → 1200) — re-send the product with only width changed: session.setProduct({ ...product, width: 1200 }). Same product/variant is a cheap config-only update — nothing the session already sent is re-sent. (The lower-level useRealtimeMockup hook exposes this directly as updateWidth(n).)

Treat the returned imageUrl as opaque. Rendered mockup URLs may arrive from a different snowcone.app host than the WebSocket endpoint, and the URLs are temporary — display what onMockups hands back, don’t parse or persist it.
variantId is required for COLOR-variant products. A product with a color axis — an options.attributes entry whose choices carry a hex, like BEEB77’s Frame — needs product.variantId (the options.combinations[].variantId for the chosen combination): the server auto-fills the color placement from it, so omitting it stalls the render. Pass the catalog options block into RenderSession too; that is what lets the SDK reject a missing/invalid variantId immediately for color products. A NON-color variant axis (size, accessory, …) renders fine without one — the server uses the default variant and the SDK only logs a warning listing the valid ids, so you can pin the combination you sell.
Two ways to drive this from a live canvas — same outcome. Shipping state with RenderSession (this page, the recommended live-PDP path) lets the server fetch the artwork itself, so no pixels cross the wire. The alternative is to ship pixels: if you already have a rasterized canvas blob, sendCanvasBlob pushes it straight to the renderer — see Live mockup preview on the Canvas page. Prefer ship-state unless you specifically need the pixel-push path.
Four look-alike ids — keep them straight. They are separate id spaces:
  • product.mockupIds are catalog scene codes product.mockups[].id, opaque 6-char codes like the Framed Canvas’s FV1qjO. They say which mockup photos to render onto. A scene photographs specific variants (its mockups[].gvids) — pass the catalog mockups entries (and defaultGvid) on the product and the SDK warns when a requested scene’s variant isn’t the one being rendered: such a scene silently composites no artwork (a bare product photo), the classic mockupIds: product.mockups.map(m => m.id) trap.
  • renderState(placement, …)’s first arg is the placement label — the print-area name, e.g. Print or Front. It equals the catalog placements[].label.
  • The canvas artboard name inside the state must equal that same placement label so the renderer maps your design to the right print area.
  • product.variantId is a variant id — read it from options.combinations[].variantId for the chosen color/size combination. It is required for any product with a COLOR axis (hex-bearing options.attributes choices); omit it there and the render stalls and eventually errors (~30s) — pass the catalog options so the SDK throws immediately instead. On a non-color variant axis it’s optional: the server renders the default variant and the SDK warns with the valid ids.
Hard constraint: artboard.name === renderState placement arg === catalog placements[].label (matched case-insensitively). One forgiving case: a state with exactly one artboard is used for whatever placement you pass, so a single-placement render “just works”. With multiple artboards, a name mismatch mis-renders that placement. mockupIds live in a different id space — never pass a placement name there. On a multi-placement product, send a renderState per required placement — the render completes only once every required placement has a state (a missing one returns incomplete_canvas_placements — see Troubleshooting).
serializeStateForServer is exported by @snowcone-app/canvasnot @snowcone-app/sdk. It converts the editor’s state into exactly what renderState expects — always pass its output to renderState, and never hand-author or hand-edit that payload. (The createDesignState path below takes the raw editor state instead — see that section.)

Copy-paste: Next.js App Router

The whole thing, end-to-end, in three files. It uses the keyless sandbox grant path (no API key), the canonical Framed Canvas (BEEB77) with its required variantId, and a Snowcone-hosted, CORS-clean seed asset — so it renders on a freshly minted sandbox shop. The editor loads via dynamic(() => import('./Editor'), { ssr: false }) because it’s browser-only — so the app/page.tsx that calls it is itself a 'use client' entry ({ ssr: false } is not allowed in a Server Component). The pure helper serializeStateForServer is still safe to import anywhere. Three files:

app/api/realtime/grant/route.ts

tsx
// app/api/realtime/grant/route.ts — your same-origin grant proxy.
// Sandbox path: the publishable shop.id alone authorizes the grant, so
// mintRealtimeGrant takes NO apiKey (apiKey is optional: apiKey?: string | null).
import { mintRealtimeGrant } from '@snowcone-app/sdk';

export async function POST(req: Request) {
  const { shop } = await req.json();
  const grant = await mintRealtimeGrant({ shop }); // no apiKey → keyless sandbox
  return Response.json(grant); // { token, expiresAt }
}

app/page.tsx

tsx
// app/page.tsx — a thin Client Component entry that lazy-loads the
// browser-only editor. It must be a Client Component: next/dynamic with
// { ssr: false } is not allowed in a Server Component (Next 15/16 throws
// "ssr: false is not allowed with next/dynamic in Server Components").
// (Importing PURE helpers like serializeStateForServer from
// @snowcone-app/canvas is still server-safe — only the interactive
// <SnowconeCanvas/> editor must stay client-side.)
'use client';
import dynamic from 'next/dynamic';

const Editor = dynamic(() => import('./Editor'), { ssr: false });

export default function Page() {
  return <Editor />;
}

app/Editor.tsx

tsx
// app/Editor.tsx — the client editor wired to a RenderSession.
'use client';
import { useEffect, useMemo, useState } from 'react';
import { SnowconeCanvas, serializeStateForServer, type AnyElementConfig } from '@snowcone-app/canvas';
import { RenderSession } from '@snowcone-app/sdk';
import '@snowcone-app/canvas/style.css';

// A CORS-clean placeholder hosted on a Snowcone demo shop bucket.
const SEED_ASSET = 'https://dqMwU8Jct3.storage.snowcone.app/demo/demo-008.jpg';
const ARTBOARD = { name: 'Front', width: 1200, height: 1200 };
const INITIAL_ELEMENTS: AnyElementConfig[] = [{
  id: 'headline',
  transformType: 'custom',
  name: 'headline',
  text: 'SNOWCONE',
  x: 600,
  y: 360,
  fontSize: 96,
  fontFamily: 'Arial',
  color: '#111111',
  textAlign: 'center',
  bold: true,
}];

export default function Editor() {
  const [mockup, setMockup] = useState<string>();
  const session = useMemo(() => {
    const s = new RenderSession({
      shop: 'YOUR_SHOP_ID',            // your publishable shop.id
      grantUrl: '/api/realtime/grant', // the proxy above
      // BEEB77 has a Frame color option → variantId is REQUIRED. In a complete
      // PDP, read this from getProduct('BEEB77').options.combinations[].
      product: { productId: 'BEEB77', mockupIds: ['FV1qjO'], variantId: 'Pv1sLC' },
    });
    s.onMockups((r) => setMockup(r[0]?.imageUrl));
    s.onError((message: string) => console.error('render error:', message));
    return s;
  }, []);

  useEffect(() => {
    // PDPs need a mockup before the shopper edits. Do this explicitly instead
    // of waiting for SnowconeCanvas.onChange to fire.
    void session.renderState('Front', serializeStateForServer({
      artboards: [ARTBOARD],
      elements: INITIAL_ELEMENTS,
    }));
    // Tear the session down on unmount so the WebSocket is closed and the
    // grant-renewal timer stops. The teardown method is close() — NOT
    // disconnect().
    return () => session.close();
  }, [session]);

  return (
    // No wrapper class needed — the canvas scopes its own styles.
    <>
      <SnowconeCanvas
        // artboard name === placement === catalog placements[].label ('Front').
        artboards={[ARTBOARD]}
        initialElements={INITIAL_ELEMENTS}
        // A Snowcone-hosted, CORS-clean seed asset (your-shop bucket origins are
        // CORS-enabled by default). Swap for your own shop bucket art.
        imageConfig={{ src: SEED_ASSET, scaleMode: 'contain' }}
        // renderState connects lazily on first call — no explicit connect() needed.
        onChange={(state) =>
          session.renderState('Front', serializeStateForServer(state), 200)
        }
      />
      {mockup && <img src={mockup} alt="live mockup" />}
    </>
  );
}
Replace YOUR_SHOP_ID with your own publishable shop.id (mint one from Get started) and point imageConfig.src at art on your own your-shop-id.storage.snowcone.app bucket (CORS-enabled by default). The demo asset here is a Snowcone-hosted placeholder for copy-paste, not your art.

Multi-placement products

The quickstart renders a single-placement product (the Framed Canvas — one Front area). Most apparel has several print areas. The model is the same, repeated: call renderState(placement, state) once per required placement. The server collects them and renders only once it holds a state for every placement the product requires — a missing one comes back as an incomplete_canvas_placements error on onError.

Read the real placement labels and mockup ids from getProduct(productId) (or search.snowcone.app) — the placements[].label values (and the renderer’s own requiredPlacements[].label contract) are the exact strings you pass as the first arg, and the mockups[].id values are the opaque scene codes for mockupIds. For a product with a color/size variant, pass the chosen variantId (from options.combinations[].variantId) and pass the catalog options block to RenderSession: the renderer then auto-fills that variant’s color and per-placement geometry, and the SDK can fail fast on a missing or invalid variant.

tsx
import { RenderSession } from '@snowcone-app/sdk';
import { serializeStateForServer } from '@snowcone-app/canvas';

// A multi-placement product. The Cotton Women's Tee (KMYKUK) has FIVE print
// areas — its catalog placements[].label values: 'Front', 'Back', 'Left sleeve',
// 'Right sleeve', 'Collar' — and one mockup scene (mockups[].id = '3jnLNX').
// Read them from getProduct('KMYKUK'): placements[].label (and the renderer's
// own contract, requiredPlacements[].label) are the EXACT strings to pass.
const session = new RenderSession({
  shop: 'YOUR_SHOP_ID',
  grantUrl: '/api/realtime/grant',
  product: {
    productId: 'KMYKUK',
    mockupIds: ['3jnLNX'],
    // KMYKUK has a Size variant — pass the variantId for the chosen variant so
    // the renderer auto-fills that variant's color/placement geometry. Omit a
    // required variantId on a color/variant product and the SDK fails fast.
    variantId: 'qsFaIZ', // the 'S' Size variant (from options.combinations[].variantId)
  },
});
session.onMockups((results) => { img.src = results[0].imageUrl; });

// THE PER-PLACEMENT MODEL: call renderState ONCE PER required placement. The
// server holds them and renders only once it has a state for EVERY placement
// the product requires — a missing one surfaces as an
// incomplete_canvas_placements error on onError. Each placement arg must equal
// an artboard.name in that placement's state AND the catalog placements[].label.
await session.renderState('Front', serializeStateForServer(frontState));
await session.renderState('Back', serializeStateForServer(backState));
await session.renderState('Left sleeve', serializeStateForServer(leftState));
await session.renderState('Right sleeve', serializeStateForServer(rightState));
await session.renderState('Collar', serializeStateForServer(collarState));

From a live editor. You don’t hand-author those per-placement states when a shopper is editing — one multi-artboard <SnowconeCanvas> hosts every print area, and its onChange state carries artboard membership: every element is tagged with the name of the artboard it lives on (element.artboard). serializeStateForServer emits every artboard, each holding its own elements, so the per-placement states fall out of one loop:

tsx
'use client';
import { RenderSession } from '@snowcone-app/sdk';
import { SnowconeCanvas, serializeStateForServer } from '@snowcone-app/canvas';

// FROM A LIVE EDITOR: one multi-artboard <SnowconeCanvas> hosts every print
// area (artboard name === catalog placements[].label), and onChange's
// CanvasState tags each element with the artboard it lives on
// (element.artboard). serializeStateForServer therefore emits EVERY artboard,
// each holding its own elements — slice it per placement instead of
// hand-authoring frontState/backState:
const session = new RenderSession({
  shop: 'YOUR_SHOP_ID',
  grantUrl: '/api/realtime/grant',
  product: { productId: 'KMYKUK', mockupIds: ['3jnLNX'], variantId: 'qsFaIZ' },
});
session.onMockups((results) => { img.src = results[0].imageUrl; });

export function Customizer() {
  return (
    <SnowconeCanvas
      // One artboard per required placement — labels/sizes from
      // getProduct('KMYKUK').placements[].
      artboards={[
        { name: 'Front', width: 1713, height: 2318 },
        { name: 'Back', width: 1713, height: 2367 },
        { name: 'Left sleeve', width: 1359, height: 803 },
        { name: 'Right sleeve', width: 1359, height: 803 },
        { name: 'Collar', width: 600, height: 300 },
      ]}
      onChange={(state) => {
        // One render state PER placement, derived from the one editor.
        const wire = serializeStateForServer(state);
        for (const ab of wire.artboards) {
          void session.renderState(ab.name, {
            schemaVersion: wire.schemaVersion,
            artboards: [ab],
          }, 200);
        }
        // Save/reload: persist the RAW onChange state. Handing state.elements
        // back via initialElements restores each element onto its named
        // artboard (elements saved before the artboard tag existed land on
        // the first artboard, as before).
        saveDraft(state);
      }}
    />
  );
}
Save/reload round-trips placements too. Persist the RAW onChange state (as always — not the serializeStateForServer() output). On reload, pass state.elements back as initialElements: each element is restored onto its named artboard. Elements saved before the artboard tag existed (or with a name the product no longer has) land on the first artboard — the previous behavior. Requires @snowcone-app/canvas ≥ 0.8; older releases emit only the first artboard, holding all elements.

Render a saved canvas by id

For fulfillment, re-renders, or an agent that only carries an id, render a design by reference instead of sending the JSON each time. Persist a state once with createDesignState to get an opaque stateId, then renderSavedState resolves it, fetches its assets, and renders. (A design saved through the Snowcone editor already has a stateId you can reuse.)

Version availability: createDesignState and RenderSession.renderSavedState are available in @snowcone-app/sdk ≥ 0.2.0 (the current published release). Both the live renderState path above and saved-state-by-reference work today on 0.2.0.
Footgun — store the RAW canvas state, not the render payload. createDesignState takes exactly what the editor hands you — the canvas’s onChange state (or toJSON()). Do NOT store serializeStateForServer() output here; that payload is for renderState and will mis-render if saved. Rule of thumb: serializeStateForServer()renderState (live); onChange/toJSON()createDesignState (saved).
tsx
import { createDesignState, RenderSession } from '@snowcone-app/sdk';

// 1. Persist a canvas state once and get back an opaque stateId.
//    (Public endpoint — no key needed. Skip this if you already have one.)
//    NOTE: createDesignState takes the RAW editor state (onChange / toJSON()) —
//    NOT the serializeStateForServer() payload used by renderState above;
//    don't cross them.
const { stateId } = await createDesignState({
  productId: 'BEEB77',
  state: rawCanvasState,
});

// 2. Render it by reference — no canvas JSON in hand at render time. Ideal for
//    fulfillment, re-renders, or an agent that only carries a stateId.
const session = new RenderSession({
  shop: 'YOUR_SHOP_ID',
  grantUrl: '/api/realtime/grant',
  // mockupIds = catalog scene codes (mockups[].id), not a placement. BEEB77 has a
  // Frame color option, so variantId (options.combinations[].variantId) is required.
  product: { productId: 'BEEB77', mockupIds: ['FV1qjO'], variantId: 'Pv1sLC' },
});
session.onMockups((results) => { img.src = results[0].imageUrl; });

await session.renderSavedState('Front', stateId);

Asset origins

Because the server fetches the artwork URLs inside the state, lock your shop to an asset-origin allowlist — the hostnames it’s allowed to fetch from (your own CDN, R2, etc.). An empty allowlist allows all origins; once set, a state that references a host you didn’t list is rejected with an explicit error rather than a blank render. This is the same origin model as the mockup URL security ladder.

The artwork URLs inside the state must be publicly fetchable (the server downloads them at render time — a private/auth-gated URL renders blank). If those same URLs are also loaded in the browser canvas that produced the state, they must additionally be CORS-enabled (Access-Control-Allow-Origin), since the canvas loads images with crossOrigin="anonymous". Hosting artwork on your shop’s own your-shop-id.storage.snowcone.app origin satisfies both.

React hook

Prefer RenderSession for most apps. If you want to drive the session from React state yourself, useRealtimeMockup gives you the lower-level handle — pass the same grant proxy as getToken.

tsx
'use client';
import { useRealtimeMockup } from '@snowcone-app/sdk/react';

function LivePreview({ shop, state }: { shop: string; state: object }) {
  const { mockupResults, sendCanvasState } = useRealtimeMockup({
    // Same grant proxy — the hook opens the WS with the returned token.
    getToken: () =>
      fetch('/api/realtime/grant', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ shop }),
      }).then((r) => r.json()),
  });

  // Call sendCanvasState('Front', state) after connecting + sending config.
  // RenderSession (above) wraps this choreography — prefer it unless you need
  // the lower-level handle.
  return <img src={mockupResults[0]?.imageUrl} alt="mockup" />;
}

Troubleshooting

Grant failures come back as HTTP errors from your proxy; render failures arrive on the session as onError messages with a code. The common ones:

Errors

shop is required / shop not foundgrant 400 / 404
Missing or wrong shop.id on the grant request. Copy it from the API keys page.
shop is revokedgrant 403
The shop is disabled. Re-enable it (or pick another shop).
API key is not authorized for this shopgrant 403
The key’s org doesn’t own that shop, or it lacks the mockups:realtime scope. Use a key whose org owns the shop, with the scope.
this shop requires a signed grant requestgrant 403
The shop is on the signed rung. Send signature + ts, or use the secret-key path (which satisfies it).
shop quota exceededgrant 429
The org hit its render budget. Raise the plan/quota.
asset_not_allowedws error
A state references an asset host not on the shop’s allowlist. Add the hostname under Asset origins, or leave the allowlist empty to allow all.
unsupported_schema_versionws error
The canvas_state.schemaVersion is newer than the renderer supports. Pin @snowcone-app/canvas to a matching version.
state_ref_failedws error
A stateId couldn’t be resolved (wrong id, or its state JSON is unreachable). Re-create it with createDesignState.
grant expiredws close
The 60s token lapsed. RenderSession auto-renews; if you drive the socket yourself, refresh the grant before expiresAt.

RenderSession options

shopstring
Your publishable shop.id (safe in the browser, like Stripe’s pk_). Required.
grantUrlstring
Your same-origin proxy that returns { token, expiresAt } for POST { shop } — it calls mintRealtimeGrant server-side. Provide this or getToken.
getToken() => Promise<RealtimeGrant>
Full control over fetching the grant, instead of grantUrl.
productRenderProduct — { productId, mockupIds, variantId?, options?, placements?, requiredPlacements?, mockups?, defaultGvid?, width?, ar? }
What to render: a catalog productId and the mockupIds (the opaque catalog scene codes — not placements) you want back. variantId is required for color/variant products (from options.combinations[].variantId) — omitting it on such a product fails fast. The rest are advisory catalog passthroughs — pass them verbatim from getProduct(), they are never sent to the server: options validates variantId up front, placements warns on a renderState placement name that isn’t real, requiredPlacements (the renderer’s placement contract; variant-auto-filled color placements don’t count) lets the embed flag a multi-placement product at mount, and mockups ({ id, gvids }) + defaultGvid warn when a requested mockupId’s scene photographs a different variant than the one being rendered — it would composite no artwork. width is the rendered mockup width in px — default 1000; request ≈ your displayed CSS width × devicePixelRatio or the preview looks soft (a 650-px-wide image on a 2× display wants ~1300 → pass width: 1400, or 2000 for a crisp zoomed/cropped view). Can also be set later via setProduct — re-sending with only width changed is a cheap config-only update (low-res while dragging, sharp on release).
wsUrlstring
Realtime WS endpoint. Defaults to REALTIME_WS_URL (wss://cdn.snowcone.app/realtime).

Exports — @snowcone-app/sdk

mintRealtimeGrant(opts) => Promise<RealtimeGrant>
Server-side. Mint a 60s grant with a secret API key (mockups:realtime scope). Never call from the browser.
fetchRealtimeGrant(grantUrl, shop) => Promise<RealtimeGrant>
Browser. Fetch a grant from your proxy. This is what RenderSession uses under the hood when given a grantUrl.
RenderSessionclass
The high-level facade: onMockups, onError, connect, renderState, renderSavedState, updateMockupIds, close. All methods ship in the current published @snowcone-app/sdk ≥ 0.2.0.
createDesignState(input) => Promise<{ stateId, … }>
Persist a canvas state and get back a stateId for renderSavedState. Public endpoint — no key needed. Available in @snowcone-app/sdk ≥ 0.2.0 (the current published release).
useRealtimeMockup(opts) => { … }
React hook (from @snowcone-app/sdk/react) — the lower-level handle when you want to drive the session yourself.
REALTIME_WS_URLstring
The production realtime WebSocket endpoint constant.

Where it leads

The realtime renderer turns a canvas into a live mockup; the rest of the platform turns it into something buyable: