Skip to content
All pages
  1. Overview
  2. Quick start
  3. Authentication
  4. API reference
    1. GET /media
    2. POST /media
    3. GET /media/{id}/preview
    4. GET /integrations/google/connect
    5. GET /integrations/google
    6. DELETE /integrations/google
    7. GET /integrations/google/folders
    8. POST /media/import
    9. GET /media/imports/{id}
    10. POST /renders
    11. GET /renders/{id}
    12. GET /renders/{id}/download
    13. GET /usage
  5. Code examples
  6. Error handling
  7. Rate limits & quotas
  8. Troubleshooting
  9. Changelog

Tutorial

Code examples

The same program twice: create a render, poll it to a terminal state, download the file. Both are complete and runnable — copy one and change the caption.

The poll loop is the part worth copying. Both versions do the four things a correct one has to do: branch on completed, failed and canceled by name; keep waiting on anything else, including a status added after you wrote your client; back off with a cap; and give up on a deadline rather than spinning.

JavaScript

Node 18+. No dependencies — fetch, crypto and fs are built in.

import { writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';

const BASE = 'https://omniscape.io';
const KEY = process.env.OMNISCAPE_API_KEY;

/** One place that knows how this API reports failure. */
class ApiError extends Error {
  constructor(status, body, requestId) {
    super(body?.error?.message ?? `HTTP ${status}`);
    this.status = status;
    this.code = body?.error?.code ?? 'unknown';   // match on THIS
    this.requestId = requestId;                   // quote this to support
  }
}

async function api(path, init = {}) {
  const res = await fetch(BASE + path, {
    ...init,
    headers: { Authorization: `Bearer ${KEY}`, ...(init.headers ?? {}) },
  });
  const requestId = res.headers.get('x-request-id');
  if (!res.ok) {
    const body = await res.json().catch(() => null);
    throw new ApiError(res.status, body, requestId);
  }
  return res;
}

const json = async (path, init) => (await api(path, init)).json();

// 1 — pick a photo. The render endpoint takes no URLs, so ids come from here.
const { data: media } = await json('/api/v1/media?limit=5');
if (!media.length) throw new Error('no photos in the workspace yet');

// 2 — start the render. The Idempotency-Key makes this retry-safe: if the
//     request times out and you send it again, you get the SAME render back
//     rather than a second one you also pay for.
const render = await json('/api/v1/renders', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'Idempotency-Key': randomUUID() },
  body: JSON.stringify({
    media_id: media[0].id,
    quote: 'stay soft',
    caption_style: 'quote',
    bold: true,
    metadata: { job: 'demo-1' },
  }),
});
console.log('queued', render.id);

// 3 — poll to a terminal state.
const TERMINAL = new Set(['completed', 'failed', 'canceled']);

async function waitFor(id, { deadlineMs = 20 * 60_000 } = {}) {
  const giveUpAt = Date.now() + deadlineMs;
  let delay = 5_000;
  for (;;) {
    const r = await json(`/api/v1/renders/${id}`);

    // Branch on terminal states by NAME. Anything else — including a status
    // added after you wrote this — means "keep waiting", not "give up".
    if (r.status === 'completed') return r;
    if (TERMINAL.has(r.status)) {
      throw new Error(`render ${r.status}: ${r.error?.code ?? 'no detail'}`);
    }

    if (Date.now() > giveUpAt) throw new Error(`timed out waiting for ${id}`);
    await new Promise((ok) => setTimeout(ok, delay));
    delay = Math.min(delay * 1.5, 30_000);   // back off, cap at 30s
  }
}

const done = await waitFor(render.id);

// 4 — download. asset.url does not expire; your key is what authorises it.
const mp4 = await api(new URL(done.asset.url).pathname);
await writeFile('render.mp4', Buffer.from(await mp4.arrayBuffer()));
console.log('saved render.mp4');

Python

Python 3.9+ with requests (pip install requests).

import os
import time
import uuid

import requests

BASE = "https://omniscape.io"
KEY = os.environ["OMNISCAPE_API_KEY"]

session = requests.Session()
session.headers["Authorization"] = f"Bearer {KEY}"


class ApiError(Exception):
    """One place that knows how this API reports failure."""

    def __init__(self, response):
        body = {}
        try:
            body = response.json()
        except ValueError:
            pass
        err = body.get("error", {})
        self.status = response.status_code
        self.code = err.get("code", "unknown")          # match on THIS
        self.request_id = response.headers.get("X-Request-Id")
        super().__init__(err.get("message", f"HTTP {response.status_code}"))


def call(method, path, **kwargs):
    r = session.request(method, BASE + path, timeout=60, **kwargs)
    if not r.ok:
        raise ApiError(r)
    return r


# 1 — pick a photo. The render endpoint takes no URLs, so ids come from here.
media = call("GET", "/api/v1/media", params={"limit": 5}).json()["data"]
if not media:
    raise SystemExit("no photos in the workspace yet")

# 2 — start the render. The Idempotency-Key makes this retry-safe: if the
#     request times out and you send it again, you get the SAME render back
#     rather than a second one you also pay for.
render = call(
    "POST",
    "/api/v1/renders",
    headers={"Idempotency-Key": str(uuid.uuid4())},
    json={
        "media_id": media[0]["id"],
        "quote": "stay soft",
        "caption_style": "quote",
        "bold": True,
        "metadata": {"job": "demo-1"},
    },
).json()
print("queued", render["id"])

# 3 — poll to a terminal state.
TERMINAL = {"completed", "failed", "canceled"}


def wait_for(render_id, deadline_s=20 * 60):
    give_up_at = time.monotonic() + deadline_s
    delay = 5.0
    while True:
        r = call("GET", f"/api/v1/renders/{render_id}").json()

        # Branch on terminal states by NAME. Anything else — including a status
        # added after you wrote this — means "keep waiting", not "give up".
        if r["status"] == "completed":
            return r
        if r["status"] in TERMINAL:
            code = (r.get("error") or {}).get("code", "no detail")
            raise RuntimeError(f"render {r['status']}: {code}")

        if time.monotonic() > give_up_at:
            raise TimeoutError(f"timed out waiting for {render_id}")
        time.sleep(delay)
        delay = min(delay * 1.5, 30.0)   # back off, cap at 30s


done = wait_for(render["id"])

# 4 — download. asset.url does not expire; your key is what authorises it.
with call("GET", done["asset"]["url"].replace(BASE, ""), stream=True) as mp4:
    with open("render.mp4", "wb") as fh:
        for chunk in mp4.iter_content(1 << 16):
            fh.write(chunk)
print("saved render.mp4")

Building this with an AI assistant?

Hand it the markdown version of this whole reference — every endpoint, field, error code and both examples in one flat file it can read in a single pass. That is what the download button in the header is for. These pages are split up for people; that file is one piece for machines.