Realtime rendering
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.
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
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.
// 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
}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.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.// 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:
// 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.
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.
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.
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).)
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.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.product.mockupIdsare catalog scene codes —product.mockups[].id, opaque 6-char codes like the Framed Canvas’sFV1qjO. They say which mockup photos to render onto. A scene photographs specific variants (itsmockups[].gvids) — pass the catalogmockupsentries (anddefaultGvid) 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 classicmockupIds: product.mockups.map(m => m.id)trap.renderState(placement, …)’s first arg is the placement label — the print-area name, e.g.PrintorFront. It equals the catalogplacements[].label.- The canvas artboard
nameinside the state must equal that same placement label so the renderer maps your design to the right print area. product.variantIdis a variant id — read it fromoptions.combinations[].variantIdfor the chosen color/size combination. It is required for any product with a COLOR axis (hex-bearingoptions.attributeschoices); omit it there and the render stalls and eventually errors (~30s) — pass the catalogoptionsso 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.
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/canvas — not @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
// 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
// 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
// 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" />}
</>
);
}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.
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:
'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);
}}
/>
);
}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.)
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.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).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.
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.
'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 / 404shop is revokedgrant 403API key is not authorized for this shopgrant 403mockups:realtime scope. Use a key whose org owns the shop, with the scope.this shop requires a signed grant requestgrant 403signature + ts, or use the secret-key path (which satisfies it).shop quota exceededgrant 429asset_not_allowedws errorunsupported_schema_versionws errorcanvas_state.schemaVersion is newer than the renderer supports. Pin @snowcone-app/canvas to a matching version.state_ref_failedws errorstateId couldn’t be resolved (wrong id, or its state JSON is unreachable). Re-create it with createDesignState.grant expiredws closeRenderSession auto-renews; if you drive the socket yourself, refresh the grant before expiresAt.RenderSession options
shopstringshop.id (safe in the browser, like Stripe’s pk_). Required.grantUrlstring{ token, expiresAt } for POST { shop } — it calls mintRealtimeGrant server-side. Provide this or getToken.getToken() => Promise<RealtimeGrant>productRenderProduct — { productId, mockupIds, variantId?, options?, placements?, requiredPlacements?, mockups?, defaultGvid?, width?, ar? }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).wsUrlstringREALTIME_WS_URL (wss://cdn.snowcone.app/realtime).Exports — @snowcone-app/sdk
mintRealtimeGrant(opts) => Promise<RealtimeGrant>mockups:realtime scope). Never call from the browser.fetchRealtimeGrant(grantUrl, shop) => Promise<RealtimeGrant>RenderSession uses under the hood when given a grantUrl.RenderSessionclassonMockups, onError, connect, renderState, renderSavedState, updateMockupIds, close. All methods ship in the current published @snowcone-app/sdk ≥ 0.2.0.createDesignState(input) => Promise<{ stateId, … }>stateId for renderSavedState. Public endpoint — no key needed. Available in @snowcone-app/sdk ≥ 0.2.0 (the current published release).useRealtimeMockup(opts) => { … }@snowcone-app/sdk/react) — the lower-level handle when you want to drive the session yourself.REALTIME_WS_URLstringWhere it leads
The realtime renderer turns a canvas into a live mockup; the rest of the platform turns it into something buyable:

