You build the gallery, we deliver the products. This example loads your tenant's products via the REST API, shows them as your own gallery and switches the embedded iframe to the product on click. A custom export button next to it renders the MP4 – all in a few lines of JavaScript, no framework, copy-paste, testable locally.
An API key for the REST API (Authorization: Bearer …). To try it out you may use the public demo key below – it only reads the demo products. You get your own key with your tenant.
A whitelisted domain for the iframe (frame-ancestors, see iframe guide, step 1). For the demo tenant http://localhost:8080 is whitelisted as well – so you can test locally before your domain goes live.
The demo key can be rotated at any time. In production, don't put a key into public frontend code if it can do more than read products – pass the product list through your own backend instead.
Exactly this port:http://localhost:8080 is whitelisted – not 127.0.0.1, not another port. On any other origin the iframe stays blank (the gallery still loads, because the API is reachable from anywhere).
CORS is open: the API answers with Access-Control-Allow-Origin: * and allows the Authorization header – a fetch straight from the browser works, no proxy needed.
Step 2
Load products via the API
GET /v1/products returns every product the API key may see – paginated (limit/offset, max 200), optionally filtered (?type=film|bild|clip, ?search=). The full reference is in the API documentation.
View code JS
JS
const API = "https://api.virtualcampaign.dev";const API_KEY = "vc_4b3efe1be755a77ba77b95e67067d8e84176fdf7621ebd3b2d94e"; // demo key – replace with your ownconst SIMULATOR = "https://demo.kreativsimulator.com/"; // your tenant: https://<tenant>.kreativsimulator.com/// GET /v1/products → { products: [...], total, limit, offset, hasMore }async function loadProducts() { const res = await fetch(API + "/v1/products?limit=50", { headers: { Authorization: "Bearer " + API_KEY }, }); if (!res.ok) throw new Error("API error " + res.status); const data = await res.json(); return data.products; // [{ productCode, productName, productType, thumbnailUrl, placeholders, … }]}loadProducts().then((products) => console.log(products));
The fields you need for a gallery
Field
Meaning
productCode
The code the iframe expects for setProduct – the key between API and simulator.
productName
Display name for the card.
productType
film · clip · bild (photo product with Ken-Burns effects).
durationSek
Length in seconds – e.g. as a badge on the card.
thumbnailUrl
Relative URL of the preview image (/v1/products/<code>/thumbnail). Available without an API key – use it directly in <img src>; ?res=ldpi|mdpi|hdpi picks the size.
placeholders[]
loaderName + aspectRatio per poster area – for your own upload fields (see setCreative).
The thumbnailUrl is relative – prefix it with https://api.virtualcampaign.dev. The endpoint redirects (302) to the actual JPEG; the browser follows automatically.
Step 3
Build the gallery, drive the iframe
Each product becomes a card with preview image and name. A click sends setProduct with the productCode to the iframe. The iframe runs in kiosk mode (?embed=kiosk): canvas only, your gallery is the UI.
Two details make the gallery robust: the cards stay disabled until the ready event arrives (before that the iframe accepts no commands). And only cards whose code the iframe reported in ready.products become active – the active card is highlighted via productChanged.
View code HTML + JS
HTML + JS
<!-- iframe on the left, your gallery on the right --><div style="display:grid; grid-template-columns: 2fr 1fr; gap: 16px;"> <iframe id="ks" src="https://demo.kreativsimulator.com/?embed=kiosk" title="Kreativsimulator" style="width:100%; aspect-ratio:16/9; border:0" allow="fullscreen"></iframe> <div id="gallery" style="display:grid; gap:8px; align-content:start"></div></div><script> const API = "https://api.virtualcampaign.dev"; const API_KEY = "vc_4b3efe1be755a77ba77b95e67067d8e84176fdf7621ebd3b2d94e"; // demo key – replace with your own const SIMULATOR = "https://demo.kreativsimulator.com/"; // your tenant: https://<tenant>.kreativsimulator.com/ const iframe = document.getElementById("ks"); const send = (msg) => iframe.contentWindow.postMessage(msg, "*"); const gallery = document.getElementById("gallery"); let simulatorCodes = null; // product codes the iframe knows (from the ready event) // 1) Build one card per API product. Cards stay disabled until the iframe is ready. function renderGallery(products) { gallery.innerHTML = ""; for (const p of products) { const card = document.createElement("button"); card.type = "button"; card.dataset.code = p.productCode; card.disabled = true; card.style.cssText = "display:flex; gap:10px; align-items:center; text-align:left; padding:6px; border:2px solid #ddd; border-radius:8px; background:#fff; cursor:pointer"; const img = document.createElement("img"); img.src = API + p.thumbnailUrl + "?res=mdpi"; // public, no key needed img.alt = ""; img.style.cssText = "width:96px; aspect-ratio:16/9; object-fit:cover; border-radius:4px"; const name = document.createElement("span"); name.textContent = p.productName; card.append(img, name); // 2) Click → the iframe switches to this product. card.onclick = () => send({ type: "kreativsimulator:setProduct", code: p.productCode }); gallery.appendChild(card); } syncCards(); } // Enable only cards the iframe can show; highlight the active one. function syncCards(activeCode) { for (const card of gallery.children) { const known = !simulatorCodes || simulatorCodes.has(card.dataset.code); card.disabled = !simulatorCodes || !known; card.title = known ? "" : "Not available in this simulator tenant"; card.style.borderColor = card.dataset.code === activeCode ? "#ff5722" : "#ddd"; } } // 3) Events from the iframe: ALWAYS filter on source === "kreativsimulator". window.addEventListener("message", (e) => { const m = e.data; if (!m || m.source !== "kreativsimulator") return; // Canvas shape: ready/productChanged carry `render`, stageChanged carries it // itself. A photo product is NOT 16:9 — follow it, then there is no letterbox. const r = m.type === "stageChanged" ? m : m.render; if (r) iframe.style.aspectRatio = r.width + " / " + r.height; if (m.type === "ready") { simulatorCodes = new Set(m.products.map((p) => p.code)); syncCards(m.product); } if (m.type === "productChanged") syncCards(m.code); if (m.type === "error") console.warn("Kreativsimulator:", m.message); }); fetch(API + "/v1/products?limit=50", { headers: { Authorization: "Bearer " + API_KEY } }) .then((r) => r.json()) .then((data) => renderGallery(data.products)) .catch((err) => (gallery.textContent = "Could not load products: " + err.message));</script>
Ordering: send creatives via setCreative only after productChanged arrived for the new product – a product switch resets all creatives.
Preview image:<img> needs no key. Only the product list needs the Bearer header.
Insert names safely: set product names with textContent, not innerHTML – as in the example.
Follow the canvas shape: not every product is 16:9 – photo products use the aspect ratio of the photo. ready and productChanged carry a render block (width/height/aspectRatio), the stageChanged event carries the same fields directly. Feed it into the aspect-ratio of your iframe and no letterbox is left – that is what the example below does.
Step 4
Your own thumbnails instead of API thumbnails
Want to show your own product images – your photos, your design, your host? Then you don't need the API at runtime at all. A small list in your code maps each image to its productCode; the click sends setProduct with exactly that code. Everything else stays the same.
Get the code once: from GET /v1/products (field productCode), from the iframe's ready event (products[].code) or from the VirtualCampaign UI.
View code HTML + JS
HTML + JS
<!-- Your own images (hosted by you) mapped to product codes. --><div style="display:grid; grid-template-columns: 2fr 1fr; gap: 16px;"> <iframe id="ks" src="https://demo.kreativsimulator.com/?embed=kiosk" title="Kreativsimulator" style="width:100%; aspect-ratio:16/9; border:0" allow="fullscreen"></iframe> <div id="gallery" style="display:grid; gap:8px; align-content:start"></div></div><script> // The product code is the key. Take it from GET /v1/products (productCode), // from the iframe's `ready` event (products[].code) or from the VirtualCampaign UI. const MY_PRODUCTS = [ { code: "29a5c31b", name: "City-Light München", image: "/img/citylight-muenchen.jpg" }, { code: "2aff892d", name: "Billboard Bursa", image: "/img/billboard-bursa.jpg" }, { code: "e6bc7001", name: "ePanel Bahnhof", image: "/img/epanel.jpg" }, ]; const iframe = document.getElementById("ks"); const send = (msg) => iframe.contentWindow.postMessage(msg, "*"); const gallery = document.getElementById("gallery"); let simulatorCodes = null; for (const p of MY_PRODUCTS) { const card = document.createElement("button"); card.type = "button"; card.dataset.code = p.code; card.disabled = true; // until `ready` card.style.cssText = "display:flex; gap:10px; align-items:center; text-align:left; padding:6px; border:2px solid #ddd; border-radius:8px; background:#fff; cursor:pointer"; const img = document.createElement("img"); img.src = p.image; // your own file – any host, no CORS needed for <img> img.alt = ""; img.style.cssText = "width:96px; aspect-ratio:16/9; object-fit:cover; border-radius:4px"; const name = document.createElement("span"); name.textContent = p.name; card.append(img, name); card.onclick = () => send({ type: "kreativsimulator:setProduct", code: p.code }); gallery.appendChild(card); } function syncCards(activeCode) { for (const card of gallery.children) { const known = simulatorCodes && simulatorCodes.has(card.dataset.code); card.disabled = !known; card.title = simulatorCodes && !known ? "Unknown product code in this tenant" : ""; card.style.borderColor = card.dataset.code === activeCode ? "#ff5722" : "#ddd"; } } window.addEventListener("message", (e) => { const m = e.data; if (!m || m.source !== "kreativsimulator") return; // Canvas shape: ready/productChanged carry `render`, stageChanged carries it // itself. A photo product is NOT 16:9 — follow it, then there is no letterbox. const r = m.type === "stageChanged" ? m : m.render; if (r) iframe.style.aspectRatio = r.width + " / " + r.height; if (m.type === "ready") { simulatorCodes = new Set(m.products.map((p) => p.code)); syncCards(m.product); } if (m.type === "productChanged") syncCards(m.code); if (m.type === "error") console.warn("Kreativsimulator:", m.message); });</script>
Your own images need no CORS – they go into an <img> on your page, not into the iframe. Only creatives for setCreative need CORS.
Wrong code? Cards whose code the iframe doesn't report in ready.products stay disabled (tooltip). Typos show up immediately.
Mixing works too: load the list from the API and only swap the image URL for your own (map productCode → image).
Step 5
Remote-control the export button
The built-in export button is hidden in kiosk mode – export still works via postMessage. Your own button sends export with width/height; the resolutions come from ready.render.presets. A second button saves a still image (JPG) via exportImage.
View code HTML + JS
HTML + JS
<select id="res"></select><button id="export">Export MP4</button><button id="still">Save image (JPG)</button><script> const P = "kreativsimulator:"; const res = document.getElementById("res"); // Fill the resolution picker from the presets the iframe reports in `ready`. window.addEventListener("message", (e) => { const m = e.data; if (!m || m.source !== "kreativsimulator" || m.type !== "ready") return; res.innerHTML = ""; for (const r of m.render.presets) { const o = document.createElement("option"); o.value = r.width + "x" + r.height; o.textContent = r.label; o.selected = r.width === m.render.width; // native resolution preselected res.appendChild(o); } }); const size = () => res.value.split("x").map(Number); // Export works even when the built-in export button is hidden (?embed=kiosk). document.getElementById("export").onclick = () => { const [width, height] = size(); send({ type: P + "export", filename: "kreation", width, height }); }; // Still image of the current frame (photo products: the whole photo). document.getElementById("still").onclick = () => { const [width, height] = size(); send({ type: P + "exportImage", filename: "kreation", width, height }); };</script>
Demo tenant: every export carries the “Vorschau” (preview) watermark – cannot be disabled. On your tenant it is off (or enabled on purpose via watermark: true / ?watermark=1).
The download goes to the user (browser download from the iframe) – your page doesn't receive the file. For server-side renders use the Orders API.
A specific frame as image:pause/seek first, then exportImage.
Step 6
Notice dialog before the download
Often the user has to confirm something before the download – e.g. a copyright notice. The pattern: your export button first opens a native <dialog>; only “Confirm” sends export or exportImage to the iframe. “Cancel”, the ×, Esc or a click outside send nothing.
The function confirmThen(action) remembers the intended action and runs it on the form's submit event – i.e. on “Confirm” by click or Enter. Text, colors and buttons are yours – the example uses the VirtualCampaign look (dark, orange); adapt it to your site.
View code HTML + CSS + JS
HTML + CSS + JS
<!-- Notice dialog — shown before every download. Wording is yours. --><dialog id="notice" class="notice"> <form method="dialog"> <button type="button" class="notice-close" aria-label="Schließen" data-cancel>×</button> <h2>Hinweis</h2> <p> Die erstellte Simulation ist urheberrechtlich geschützt. Sie darf nur intern von Ihnen bzw. bei Agenturen auch gegenüber (potentiellen) Kunden zu Präsentationszwecken genutzt werden. Eine darüber hinausgehende Nutzung, insbesondere eine Veröffentlichung (Website, Social Media etc.), ist nicht gestattet. </p> <div class="notice-actions"> <button type="button" class="notice-secondary" data-cancel>Abbrechen</button> <button type="submit" value="confirm" class="notice-primary">Bestätigen →</button> </div> </form></dialog><style>/* Notice dialog — dark, orange accent (matches the VirtualCampaign look) */.notice { border: 1px solid #2a2a2a; border-radius: 16px; background: #141414; color: #eee; padding: 0; margin: auto; width: min(640px, calc(100vw - 32px)); box-shadow: 0 30px 80px rgba(0,0,0,.6); }.notice::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px); }.notice form { position: relative; padding: 32px 36px 28px; margin: 0; }.notice h2 { margin: 0 0 14px; font-size: 28px; font-weight: 600; letter-spacing: -.01em; }.notice p { margin: 0 0 26px; color: #b5b5b5; line-height: 1.65; font-size: 15px; }.notice-actions { display: flex; justify-content: flex-end; gap: 10px; }.notice-primary { background: #ff5722; color: #fff; border: 0; border-radius: 999px; padding: 12px 24px; font: inherit; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; cursor: pointer; }.notice-primary:hover { background: #e64a19; }.notice-secondary { background: transparent; color: #b5b5b5; border: 1px solid #333; border-radius: 999px; padding: 12px 20px; font: inherit; cursor: pointer; }.notice-secondary:hover { color: #fff; border-color: #666; }.notice-close { position: absolute; top: 14px; right: 16px; background: none; border: 0; color: #ff5722; font-size: 26px; line-height: 1; cursor: pointer; }</style><script> // Ask first, download afterwards: confirmThen(fn) opens the dialog and runs fn // only when the user confirms. Works for export (MP4) and exportImage (JPG) alike. const notice = document.getElementById("notice"); let pendingAction = null; function confirmThen(action) { pendingAction = action; notice.showModal(); } // "Bestätigen" submits the <form method="dialog"> (click or Enter) → run the action. notice.querySelector("form").addEventListener("submit", () => { if (pendingAction) pendingAction(); pendingAction = null; }); // "Abbrechen" / × / Esc just close the dialog — nothing is sent. notice.querySelectorAll("[data-cancel]").forEach((b) => (b.onclick = () => notice.close())); notice.addEventListener("cancel", () => (pendingAction = null)); // Usage: wrap the export calls (send() and P from the gallery example). document.getElementById("export").onclick = () => confirmThen(() => send({ type: P + "export", filename: "kreation", width: 1280, height: 720 })); document.getElementById("still").onclick = () => confirmThen(() => send({ type: P + "exportImage", filename: "kreation", width: 1280, height: 720 }));</script>
No framework needed:<dialog> with showModal() brings focus trapping, Esc and the backdrop – in every current browser.
Ask only once? After the first “Confirm” set e.g. sessionStorage.setItem("noticeOk", "1") and run the action directly in confirmThen when the value is set.
The export still runs in the iframe: the dialog lives on your page, the download comes from the iframe as usual (demo tenant: watermarked).
Step 7
Complete example (one file)
Everything in one index.html: iframe on the left, gallery on the right, export bar with notice dialog below. Save it, serve it locally on port 8080, open it – done. To adapt it, change the three constants at the top of the script.
Save the file as index.html.
Replace API_KEY and SIMULATOR with your values (the demo values work right away).
Serve it locally at http://localhost:8080 (step 8) or publish it on your whitelisted domain.
index.html
<!doctype html><html lang="de"><head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Kreativsimulator – Produkt-Galerie</title> <style> body { margin: 0; font-family: system-ui, sans-serif; background: #111; color: #eee; } .layout { display: grid; grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr); gap: 16px; padding: 16px; } @media (max-width: 900px) { .layout { grid-template-columns: 1fr; } } iframe { width: 100%; aspect-ratio: 16 / 9; border: 0; background: #000; border-radius: 8px; } #gallery { display: grid; gap: 8px; align-content: start; } .card { display: flex; gap: 10px; align-items: center; text-align: left; padding: 6px; border: 2px solid #333; border-radius: 8px; background: #1b1b1b; color: #eee; cursor: pointer; } .card:disabled { opacity: .4; cursor: default; } .card.active { border-color: #ff5722; } .card img { width: 96px; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 4px; background: #000; } .bar { display: flex; gap: 8px; align-items: center; margin-top: 12px; } button, select { font: inherit; padding: 8px 12px; border-radius: 6px; border: 1px solid #444; background: #222; color: #eee; } button:not(:disabled):hover { border-color: #ff5722; } #status { font-size: 12px; color: #999; margin-left: auto; } /* Notice dialog — dark, orange accent (matches the VirtualCampaign look) */ .notice { border: 1px solid #2a2a2a; border-radius: 16px; background: #141414; color: #eee; padding: 0; margin: auto; width: min(640px, calc(100vw - 32px)); box-shadow: 0 30px 80px rgba(0,0,0,.6); } .notice::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px); } .notice form { position: relative; padding: 32px 36px 28px; margin: 0; } .notice h2 { margin: 0 0 14px; font-size: 28px; font-weight: 600; letter-spacing: -.01em; } .notice p { margin: 0 0 26px; color: #b5b5b5; line-height: 1.65; font-size: 15px; } .notice-actions { display: flex; justify-content: flex-end; gap: 10px; } .notice-primary { background: #ff5722; color: #fff; border: 0; border-radius: 999px; padding: 12px 24px; font: inherit; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; cursor: pointer; } .notice-primary:hover { background: #e64a19; } .notice-secondary { background: transparent; color: #b5b5b5; border: 1px solid #333; border-radius: 999px; padding: 12px 20px; font: inherit; cursor: pointer; } .notice-secondary:hover { color: #fff; border-color: #666; } .notice-close { position: absolute; top: 14px; right: 16px; background: none; border: 0; color: #ff5722; font-size: 26px; line-height: 1; cursor: pointer; } </style></head><body> <div class="layout"> <div> <!-- Only the canvas: header, sidebar and the built-in export button are hidden. --> <iframe id="ks" src="https://demo.kreativsimulator.com/?embed=kiosk" title="Kreativsimulator" allow="fullscreen"></iframe> <div class="bar"> <select id="res"></select> <button id="export" disabled>Export MP4</button> <button id="still" disabled>Save image (JPG)</button> <span id="status">Loading …</span> </div> </div> <div id="gallery"></div> </div> <!-- Notice dialog — shown before every download. Wording is yours. --> <dialog id="notice" class="notice"> <form method="dialog"> <button type="button" class="notice-close" aria-label="Schließen" data-cancel>×</button> <h2>Hinweis</h2> <p> Die erstellte Simulation ist urheberrechtlich geschützt. Sie darf nur intern von Ihnen bzw. bei Agenturen auch gegenüber (potentiellen) Kunden zu Präsentationszwecken genutzt werden. Eine darüber hinausgehende Nutzung, insbesondere eine Veröffentlichung (Website, Social Media etc.), ist nicht gestattet. </p> <div class="notice-actions"> <button type="button" class="notice-secondary" data-cancel>Abbrechen</button> <button type="submit" value="confirm" class="notice-primary">Bestätigen →</button> </div> </form> </dialog> <script> const API = "https://api.virtualcampaign.dev"; const API_KEY = "vc_4b3efe1be755a77ba77b95e67067d8e84176fdf7621ebd3b2d94e"; // demo key – replace with your own const SIMULATOR = "https://demo.kreativsimulator.com/"; // your tenant: https://<tenant>.kreativsimulator.com/ const P = "kreativsimulator:"; const iframe = document.getElementById("ks"); const gallery = document.getElementById("gallery"); const res = document.getElementById("res"); const status = document.getElementById("status"); const exportBtn = document.getElementById("export"); const stillBtn = document.getElementById("still"); const send = (msg) => iframe.contentWindow.postMessage(msg, "*"); let simulatorCodes = null; // ── 1. Gallery from the REST API ──────────────────────────────────── async function loadProducts() { const r = await fetch(API + "/v1/products?limit=50", { headers: { Authorization: "Bearer " + API_KEY }, }); if (!r.ok) throw new Error("API error " + r.status); return (await r.json()).products; } function renderGallery(products) { gallery.innerHTML = ""; for (const p of products) { const card = document.createElement("button"); card.type = "button"; card.className = "card"; card.dataset.code = p.productCode; card.disabled = true; // until the iframe says `ready` const img = document.createElement("img"); img.src = API + p.thumbnailUrl + "?res=mdpi"; img.alt = ""; const name = document.createElement("span"); name.textContent = p.productName; card.append(img, name); card.onclick = () => send({ type: P + "setProduct", code: p.productCode }); gallery.appendChild(card); } syncCards(); } function syncCards(activeCode) { for (const card of gallery.children) { const known = simulatorCodes && simulatorCodes.has(card.dataset.code); card.disabled = !known; card.title = simulatorCodes && !known ? "Not available in this simulator tenant" : ""; card.classList.toggle("active", card.dataset.code === activeCode); } } // ── 2. Events from the iframe ─────────────────────────────────────── window.addEventListener("message", (e) => { const m = e.data; if (!m || m.source !== "kreativsimulator") return; // Canvas shape + export presets: ready/productChanged carry `render`, // stageChanged carries the fields itself. A photo product is NOT 16:9. const shape = m.type === "stageChanged" ? m : m.render; if (shape) { iframe.style.aspectRatio = shape.width + " / " + shape.height; res.innerHTML = ""; for (const r of shape.presets) { const o = document.createElement("option"); o.value = r.width + "x" + r.height; o.textContent = r.label; o.selected = r.width === shape.width; res.appendChild(o); } } if (m.type === "ready") { simulatorCodes = new Set(m.products.map((p) => p.code)); exportBtn.disabled = stillBtn.disabled = false; syncCards(m.product); status.textContent = "Ready"; } if (m.type === "productChanged") syncCards(m.code); if (m.type === "error") status.textContent = "Error: " + m.message; }); // ── 3. Notice dialog + remote export (works with the built-in button hidden) // Ask first, download afterwards: confirmThen(fn) opens the dialog and runs fn // only when the user confirms. Works for export (MP4) and exportImage (JPG) alike. const notice = document.getElementById("notice"); let pendingAction = null; function confirmThen(action) { pendingAction = action; notice.showModal(); } // "Bestätigen" submits the <form method="dialog"> (click or Enter) → run the action. notice.querySelector("form").addEventListener("submit", () => { if (pendingAction) pendingAction(); pendingAction = null; }); // "Abbrechen" / × / Esc just close the dialog — nothing is sent. notice.querySelectorAll("[data-cancel]").forEach((b) => (b.onclick = () => notice.close())); notice.addEventListener("cancel", () => (pendingAction = null)); const size = () => res.value.split("x").map(Number); exportBtn.onclick = () => { const [width, height] = size(); confirmThen(() => send({ type: P + "export", filename: "kreation", width, height })); }; stillBtn.onclick = () => { const [width, height] = size(); confirmThen(() => send({ type: P + "exportImage", filename: "kreation", width, height })); }; loadProducts() .then(renderGallery) .catch((err) => (status.textContent = "Could not load products: " + err.message)); </script></body></html>
Step 8
Test locally on localhost:8080
Any static web server will do. Opening the file directly by double-click (file://) does not work – the browser then has no origin and the iframe stays blank.
In the folder of index.html:
Terminal
# Option A – Node (npx, no install)npx serve -l 8080 .# Option B – Python 3python -m http.server 8080# then open: http://localhost:8080
If something doesn't work
The gallery loads, the iframe stays black/blank
The origin is not whitelisted. Locally it must be exactly http://localhost:8080 (not 127.0.0.1, no other port, no file://). The console (F12) shows “Refused to frame … frame-ancestors”. For your domain: request whitelisting.
API responds 401
The Bearer key is missing or wrong/rotated. Check the header: Authorization: Bearer vc_… (with a space after Bearer).
API responds 403 GROUP_REQUIRED
The key is not assigned to a user group and therefore sees no products. Contact us – we assign it.
Cards stay greyed out
The ready event hasn't arrived yet (iframe loading or blocked, see above) – or the productCode is not active in the simulator tenant. The card then shows a tooltip.
Preview image 404
No thumbnail is stored for this product yet. Set a fallback in img-onerror (e.g. a placeholder color).
Live
Exactly this example – live
The real iframe on the left, on the right the gallery this page just loaded via fetch from api.virtualcampaign.dev. Click → setProduct. Below: the remote-controlled export.