Sync a product catalog from your PIM
Render every product in your catalog into your rooms from a script you can rerun safely. It batches, waits, downloads and never pays twice.
Updated
What the sync does
The script on this page renders every product in a list of SKUs into each of your chosen rooms, and saves the images to a folder. It is the whole pattern a PIM, DAM or storefront integration needs:
- matches your SKUs to Skip Shoot products;
- checks there are enough credits before spending any;
- queues renders 100 at a time;
- skips a pairing the API refuses (a SKU that fits no surface in one of the rooms, say) and queues the rest, with nothing charged for it;
- retries safely, so nothing is ever charged twice;
- downloads each image as it finishes;
- keeps its progress in a file, so you can stop and rerun it.
Plan the job
Add your products
Products are added in the app, each with its SKU, so the script can find them. See Products and surface types.
Choose your rooms
Add a few scenes that suit the products, wait for them to be ready, and note their ids. See Choose good scenes.
Count the credits
SKUs × rooms is the number of credits the run needs. 40 SKUs in 3 rooms is 120 renders: 120 credits.
Match products by SKU
List all products returns every product with its sku, so your own SKUs are the join key: no Skip Shoot ids need to be stored in your PIM. SKUs the script cannot find are skipped and listed, so you can add them.
Queue renders in batches
Each batch of up to 100 is charged together or not at all. Each gets an Idempotency-Key made from its own contents, so the same batch, sent again by a rerun, gets its first answer back instead of paying again. See Idempotent requests.
Collect and store the images
Renders run 3 at a time and finish in the order they were sent. The script polls the oldest unfinished ones every 10 seconds and saves each image as SKU-scene.png. Swap the file write for an upload to your own storage or DAM.
Make it safe to rerun
- Progress is saved after every batch and every image, in
JOB.json. A rerun with the same job name skips what is done and picks up the rest. - Failed renders are recorded and cost nothing. To try them again, remove their entries from the progress file and rerun.
- Idempotency-Keys are remembered for 24 hours. If a run stopped in the middle of queuing, rerun it within that time, so a batch that may already be paid for is answered, not sent again.
Full script
Put your SKUs in skus.txt, one per line, set the scene ids and a job name, and run it:
// sync-catalog.mjs: run with node sync-catalog.mjs
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
const BASE = "https://www.skipshoot.com/api/v1";
const KEY = process.env.SKIPSHOOT_SECRET_KEY;
const JOB = "fall-2026"; // name this run; run it again with the same name to resume
const SCENES = [812, 815, 820]; // the rooms to render into
const STATE = `${JOB}.json`; // progress, so a rerun picks up where it stopped
// Final for a render, and "skipped" for a pairing the API refused (a SKU that
// fits no surface in one of the rooms, say).
const FINAL = new Set(["completed", "failed", "cancelled", "skipped"]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// One request, retried on 429, 5xx and an Idempotency-Key still in progress.
async function api(path, { method = "GET", body, idempotencyKey } = {}) {
const payload = body === undefined ? undefined : JSON.stringify(body);
for (let attempt = 1; ; attempt++) {
const response = await fetch(`${BASE}${path}`, {
method,
body: payload,
headers: {
Authorization: `Bearer ${KEY}`,
...(payload ? { "Content-Type": "application/json" } : {}),
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
});
// A gateway error may not be JSON; treat it as a server error.
const data = await response
.json()
.catch(() => ({ error: { type: "server_error", message: response.statusText } }));
if (response.ok) {
return data;
}
const retryable =
response.status === 429 ||
response.status >= 500 ||
(response.status === 409 && Boolean(idempotencyKey));
if (!retryable || attempt === 5) {
throw new Error(`${response.status} ${data.error.type}: ${data.error.message}`);
}
await sleep(Number(response.headers.get("Retry-After") ?? 2 ** attempt) * 1000);
}
}
const state = JSON.parse(await readFile(STATE, "utf8").catch(() => '{"renders":{}}'));
const save = () => writeFile(STATE, JSON.stringify(state, null, 2));
// 1. Your SKUs, one per line, exported from your PIM.
const skus = (await readFile("skus.txt", "utf8")).split("\n").map((s) => s.trim()).filter(Boolean);
// 2. Match them to Skip Shoot products.
const { products } = await api("/products");
const bySku = new Map(products.filter((p) => p.sku).map((p) => [p.sku, p]));
const missing = skus.filter((sku) => !bySku.has(sku));
if (missing.length) {
console.warn("Not in Skip Shoot yet, skipped:", missing.join(", "));
}
// 3. Every product in every room, less what an earlier run already queued.
const todo = skus
.filter((sku) => bySku.has(sku))
.flatMap((sku) => SCENES.map((scene_id) => ({ key: `${sku}@${scene_id}`, scene_id, product_id: bySku.get(sku).id })))
.filter((item) => !state.renders[item.key]);
// 4. Stop before spending if there are not enough credits for all of it.
const { credits } = await api("/me");
if (credits.remaining !== null && credits.remaining < todo.length) {
throw new Error(`This run needs ${todo.length} credits and ${credits.remaining} are left.`);
}
// 5. Queue in batches of 100. The key comes from the batch's contents, so a
// rerun of the same batch gets its first answer back instead of paying again.
// A batch is all or nothing: when one render in it is refused, nothing is
// charged, and the batch goes again one render at a time, so the refused
// pairings are skipped and the rest are queued.
async function queue(chunk) {
const renders = chunk.map(({ scene_id, product_id }) => ({ scene_id, product_id }));
const idempotencyKey = `${JOB}-${createHash("sha256").update(JSON.stringify(renders)).digest("hex").slice(0, 40)}`;
const answer = await api("/renders", { method: "POST", body: { renders }, idempotencyKey });
answer.renders.forEach((render, index) => {
state.renders[chunk[index].key] = { id: render.id, status: render.status };
});
}
const refused = (error) => /^(404|422) /.test(error.message);
for (let start = 0; start < todo.length; start += 100) {
const chunk = todo.slice(start, start + 100);
try {
await queue(chunk);
} catch (error) {
if (!refused(error)) {
throw error;
}
for (const item of chunk) {
try {
await queue([item]);
} catch (single) {
if (!refused(single)) {
throw single;
}
state.renders[item.key] = { status: "skipped", error: single.message };
}
}
}
await save();
console.log(`Queued ${chunk.length} renders.`);
}
// 6. Collect each image as its render finishes.
await mkdir("renders", { recursive: true });
const unfinished = () => Object.entries(state.renders).filter(([, entry]) => !FINAL.has(entry.status));
while (unfinished().length > 0) {
await sleep(10_000);
for (const [key, entry] of unfinished().slice(0, 20)) {
const { render } = await api(`/renders/${entry.id}`);
if (!FINAL.has(render.status)) {
continue;
}
if (render.status === "completed") {
const image = await fetch(render.image_url);
if (!image.ok) {
continue; // the next round fetches the render, and a fresh link, again
}
// PNG or JPEG, as the model made it: the Content-Type says which.
const extension = image.headers.get("content-type")?.includes("jpeg") ? "jpg" : "png";
await writeFile(`renders/${key.replace(/[^\w.-]+/g, "-")}.${extension}`, Buffer.from(await image.arrayBuffer()));
}
state.renders[key] = { id: render.id, status: render.status, error: render.error };
await save();
}
}
const failed = Object.values(state.renders).filter((entry) => entry.status === "failed");
const skipped = Object.values(state.renders).filter((entry) => entry.status === "skipped");
console.log(`Done. ${failed.length} failed, and their credits were given back. ${skipped.length} pairings were refused, with nothing charged; see ${STATE} for why.`);It needs Node.js 18 or later, or Python 3 with requests, and your key in SKIPSHOOT_SECRET_KEY.
Something these pages do not answer? Email admin@skipshoot.com and we’ll help.
