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.

ConcernWhat the client does
AuthenticationReads your key from SKIPSHOOT_SECRET_KEY and sends it as a bearer token.
ErrorsThrows SkipShootError with the status, the stable type and the message.
Rate limitsWaits for Retry-After on 429, then sends the same request again.
Safe retriesResends after a dropped connection or a 5xx only when that cannot charge twice: a GET, or a request with an Idempotency-Key.
IdempotencyGives every render request an Idempotency-Key, and lets you pass your own.
PollingwaitForScene 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
// 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
# 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.