Copy-paste API clients for Node.js and Python
A small, complete client in each language: authentication, errors, retries, idempotency and polling. Paste it into your project and call the API in one line.
Updated
What the clients handle
There is no official SDK yet. These two files are the next best thing: small, dependency-light clients you can read in a few minutes, paste into your project and own.
| Concern | What the client does |
|---|---|
| Authentication | Reads your key from SKIPSHOOT_SECRET_KEY and sends it as a bearer token. |
| Errors | Throws SkipShootError with the status, the stable type and the message. |
| Rate limits | Waits for Retry-After on 429, then sends the same request again. |
| Safe retries | Resends after a dropped connection or a 5xx only when that cannot charge twice: a GET, or a request with an Idempotency-Key. |
| Idempotency | Gives every render request an Idempotency-Key, and lets you pass your own. |
| Polling | waitForScene and waitForRender poll with a growing wait and a deadline. |
Node.js client
Save as skipshoot.mjs. It needs Node.js 18 or later and nothing else.
// skipshoot.mjs: a small client for the Skip Shoot API. No dependencies; Node.js 18 or later.
import { randomUUID } from "node:crypto";
export class SkipShootError extends Error {
constructor(status, type, message) {
super(message);
this.name = "SkipShootError";
this.status = status;
this.type = type;
}
}
const FINAL = new Set(["completed", "failed", "cancelled"]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export class SkipShoot {
constructor({
apiKey = process.env.SKIPSHOOT_SECRET_KEY,
baseUrl = "https://www.skipshoot.com/api/v1",
maxRetries = 4,
} = {}) {
if (!apiKey) {
throw new Error("Set SKIPSHOOT_SECRET_KEY, or pass apiKey.");
}
this.apiKey = apiKey;
this.baseUrl = baseUrl;
this.maxRetries = maxRetries;
}
/** One request. Retries what is safe to retry, and throws SkipShootError otherwise. */
async request(method, path, { body, form, idempotencyKey } = {}) {
const payload = form ?? (body === undefined ? undefined : JSON.stringify(body));
// Without an answer we cannot know whether a POST landed, so only a GET,
// or a request with an Idempotency-Key, is sent again.
const safeToResend = method === "GET" || Boolean(idempotencyKey);
for (let attempt = 0; ; attempt++) {
let response;
try {
response = await fetch(this.baseUrl + path, {
method,
body: payload,
headers: {
Authorization: `Bearer ${this.apiKey}`,
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
});
} catch (error) {
if (safeToResend && attempt < this.maxRetries) {
await sleep(1000 * 2 ** attempt);
continue;
}
throw 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 && safeToResend) ||
(response.status === 409 && data.error.type === "invalid_request" && Boolean(idempotencyKey));
if (!retryable || attempt >= this.maxRetries) {
throw new SkipShootError(response.status, data.error.type, data.error.message);
}
const retryAfter = Number(response.headers.get("Retry-After"));
await sleep((retryAfter > 0 ? retryAfter : 2 ** attempt) * 1000);
}
}
account() {
return this.request("GET", "/me");
}
async listProducts(type) {
const query = type ? `?type=${encodeURIComponent(type)}` : "";
return (await this.request("GET", `/products${query}`)).products;
}
async listScenes() {
return (await this.request("GET", "/scenes")).scenes;
}
async getScene(id) {
return (await this.request("GET", `/scenes/${id}`)).scene;
}
/** Add a scene from a public picture URL. Free. */
async createSceneFromUrl(imageUrl, { name, setting } = {}) {
const body = { image_url: imageUrl, name, setting };
return (await this.request("POST", "/scenes", { body })).scene;
}
/** Add a scene by uploading a picture: a Blob, or a Buffer with its type. Free. */
async uploadScene(file, { filename = "room.jpg", type = "image/jpeg", name, setting } = {}) {
const form = new FormData();
form.append("image", file instanceof Blob ? file : new Blob([file], { type }), filename);
if (name) {
form.append("name", name);
}
if (setting) {
form.append("setting", setting);
}
return (await this.request("POST", "/scenes", { form })).scene;
}
/**
* Queue one render, or an array of up to 100. Always sent with an
* Idempotency-Key: pass your own, stored with your job, to make a rerun
* after a crash safe too.
*/
createRenders(renders, { upscale = false, idempotencyKey = randomUUID() } = {}) {
const body = Array.isArray(renders) ? { renders, upscale } : { ...renders, upscale };
return this.request("POST", "/renders", { body, idempotencyKey });
}
async getRender(id) {
return (await this.request("GET", `/renders/${id}`)).render;
}
async cancelRender(id) {
return (await this.request("DELETE", `/renders/${id}`)).render;
}
async waitForScene(id, { timeoutMs = 10 * 60_000 } = {}) {
return this.#waitFor(() => this.getScene(id), (scene) => scene.status !== "analyzing", timeoutMs);
}
async waitForRender(id, { timeoutMs = 30 * 60_000 } = {}) {
return this.#waitFor(() => this.getRender(id), (render) => FINAL.has(render.status), timeoutMs);
}
async #waitFor(read, done, timeoutMs) {
const deadline = Date.now() + timeoutMs;
for (let wait = 5000; Date.now() < deadline; wait = Math.min(wait * 1.5, 15000)) {
await sleep(wait);
const value = await read();
if (done(value)) {
return value;
}
}
throw new Error("Timed out waiting for a final status.");
}
}Python client
Save as skipshoot.py. It needs Python 3.8 or later and the requests package (pip install requests).
# skipshoot.py: a small client for the Skip Shoot API. Needs Python 3.8+ and requests.
import json
import os
import time
import uuid
import requests
FINAL = {"completed", "failed", "cancelled"}
class SkipShootError(Exception):
def __init__(self, status, type, message):
super().__init__(message)
self.status = status
self.type = type
class SkipShoot:
def __init__(self, api_key=None, base_url="https://www.skipshoot.com/api/v1", max_retries=4):
api_key = api_key or os.environ.get("SKIPSHOOT_SECRET_KEY")
if not api_key:
raise ValueError("Set SKIPSHOOT_SECRET_KEY, or pass api_key.")
self.base_url = base_url
self.max_retries = max_retries
self.session = requests.Session()
self.session.headers["Authorization"] = f"Bearer {api_key}"
def request(self, method, path, body=None, files=None, data=None, idempotency_key=None):
"""One request. Retries what is safe to retry, and raises SkipShootError otherwise."""
headers = {}
payload = data
if body is not None:
payload = json.dumps(body)
headers["Content-Type"] = "application/json"
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
# Without an answer we cannot know whether a POST landed, so only a GET,
# or a request with an Idempotency-Key, is sent again.
safe_to_resend = method == "GET" or bool(idempotency_key)
for attempt in range(self.max_retries + 1):
try:
response = self.session.request(
method, self.base_url + path, data=payload, files=files, headers=headers, timeout=120
)
except requests.ConnectionError:
if safe_to_resend and attempt < self.max_retries:
time.sleep(2**attempt)
continue
raise
try:
result = response.json()
except ValueError:
result = {"error": {"type": "server_error", "message": response.reason}}
if response.ok:
return result
error = result["error"]
retryable = (
response.status_code == 429
or (response.status_code >= 500 and safe_to_resend)
or (response.status_code == 409 and error["type"] == "invalid_request" and bool(idempotency_key))
)
if not retryable or attempt >= self.max_retries:
raise SkipShootError(response.status_code, error["type"], error["message"])
retry_after = int(response.headers.get("Retry-After", 0) or 0)
time.sleep(retry_after if retry_after > 0 else 2**attempt)
def account(self):
return self.request("GET", "/me")
def list_products(self, type=None):
query = f"?type={requests.utils.quote(type)}" if type else ""
return self.request("GET", f"/products{query}")["products"]
def list_scenes(self):
return self.request("GET", "/scenes")["scenes"]
def get_scene(self, scene_id):
return self.request("GET", f"/scenes/{scene_id}")["scene"]
def create_scene_from_url(self, image_url, name=None, setting=None):
"""Add a scene from a public picture URL. Free."""
body = {"image_url": image_url}
if name:
body["name"] = name
if setting:
body["setting"] = setting
return self.request("POST", "/scenes", body=body)["scene"]
def upload_scene(self, path, name=None, setting=None, content_type="image/jpeg"):
"""Add a scene by uploading a picture file. Free."""
fields = {key: value for key, value in {"name": name, "setting": setting}.items() if value}
with open(path, "rb") as picture:
files = {"image": (os.path.basename(path), picture.read(), content_type)}
return self.request("POST", "/scenes", files=files, data=fields)["scene"]
def create_renders(self, renders, upscale=False, idempotency_key=None):
"""Queue one render (a dict) or a list of up to 100. Always sent with an
Idempotency-Key: pass your own, stored with your job, to make a rerun
after a crash safe too."""
body = {"renders": renders, "upscale": upscale} if isinstance(renders, list) else {**renders, "upscale": upscale}
return self.request("POST", "/renders", body=body, idempotency_key=idempotency_key or str(uuid.uuid4()))
def get_render(self, render_id):
return self.request("GET", f"/renders/{render_id}")["render"]
def cancel_render(self, render_id):
return self.request("DELETE", f"/renders/{render_id}")["render"]
def wait_for_scene(self, scene_id, timeout=600):
return self._wait_for(lambda: self.get_scene(scene_id), lambda scene: scene["status"] != "analyzing", timeout)
def wait_for_render(self, render_id, timeout=1800):
return self._wait_for(lambda: self.get_render(render_id), lambda render: render["status"] in FINAL, timeout)
def _wait_for(self, read, done, timeout):
deadline = time.monotonic() + timeout
wait = 5
while time.monotonic() < deadline:
time.sleep(wait)
value = read()
if done(value):
return value
wait = min(wait * 1.5, 15)
raise TimeoutError("Timed out waiting for a final status.")Use the clients
Add a room, wait for it, render three products into it and save the images:
import { writeFile } from "node:fs/promises";
import { SkipShoot, SkipShootError } from "./skipshoot.mjs";
const skipshoot = new SkipShoot();
try {
const scene = await skipshoot.createSceneFromUrl("https://example.com/rooms/library.jpg", { name: "Library" });
const ready = await skipshoot.waitForScene(scene.id);
if (ready.status !== "ready") {
throw new Error("No surfaces were found in that picture.");
}
const { renders } = await skipshoot.createRenders(
[4410, 4411, 4412].map((product_id) => ({ scene_id: ready.id, product_id })),
{ idempotencyKey: "library-velvets-1" },
);
for (const queued of renders) {
const render = await skipshoot.waitForRender(queued.id);
if (render.status === "completed") {
const image = await fetch(render.image_url);
await writeFile(`${render.id}.png`, Buffer.from(await image.arrayBuffer()));
}
}
} catch (error) {
if (error instanceof SkipShootError && error.type === "insufficient_credits") {
console.error("Out of credits:", error.message);
} else {
throw error;
}
}The clients follow the API reference exactly. Prefer a generated client? Use the OpenAPI specification.
Something these pages do not answer? Email admin@skipshoot.com and we’ll help.
