# Omniscape Renders API — v1

> Reference for coding agents. Revision 25 August 2026. Source: https://omniscape.io/docs/api

Turn a photo you already hold plus a line of text into a finished vertical MP4 with the caption burned in.

## Read this first

- **It is asynchronous, and slow on purpose.** A render takes about 2 minutes. That is real generation work, the same work the product does in the browser — not queue latency you can tune away. Design for a job queue and a poll loop. A synchronous HTTP request will time out.
- **It renders one thing: a Pic to Video Render.** A photo you already hold, plus a line of text, out as a vertical MP4 with the caption burned in. Slideshow Renders and video memes are not on the API in v1; asking for one is refused rather than silently substituted.
- **It takes your images as bytes, never as a URL.** You can upload a photo over the API (POST /api/v1/media) or add it in the portal — either way you then render it by its media_id. What the API never accepts is a URL or path for it to go fetch; you send the image itself. You can also bulk-import a Google Drive folder you own — that takes a folder id, still never a URL, and reads only from Google. That is what keeps the surface safe to expose.
- **Nothing is pushed to you.** There are no webhooks in v1. A finished render is something you poll for. Polling is free and uncounted, so poll on a sane interval and stop worrying about it.

## Plans and what each one may render

Two separate gates. Holding a key is one thing; having renders included is another.

| Plan | API keys | Pic to Video Renders included | Notes |
|---|---|---|---|
| Free | no | — | Cannot hold an API key. Requests are 403 tier_not_eligible. |
| Starter | yes | 0 per cycle | A key works, but Pic to Video Renders are not included on this plan — every create is 402 quota_exceeded. Use it to build and test against the read endpoints; upgrade before you render. |
| Studio | yes | 100 per cycle | The first plan that can actually render through the API. |
| Agency | yes | 500 per cycle | Same surface, higher allowance. |
| Custom | yes | negotiated | Uncapped in code; the allowance is whatever your agreement says. Rate limits and spend caps are set per key. |

**If you are on Starter, expect `402 quota_exceeded` on every create.** The key is not broken. The plan includes no renders of this type.

## Authentication

All requests: `Authorization: Bearer <key>` against `https://omniscape.io`. There is no other auth scheme, no cookie, and no unauthenticated endpoint.

Keys carry explicit scopes and are default-deny — an operation is permitted only if its exact scope is present:

| Scope | Grants |
|---|---|
| `media:read` | `GET /api/v1/media`, `GET /api/v1/media/{id}/preview` |
| `renders:create` | `POST /api/v1/renders` |
| `renders:read` | `GET /api/v1/renders/{id}`, `GET /api/v1/renders/{id}/download`, `GET /api/v1/usage` |

Every response carries an `X-Request-Id`. Log it; it is what support can trace.

## Endpoints

### GET /api/v1/media

Scope: `media:read`

List the photos in your workspace. This is where a media_id comes from — the render endpoint accepts no URLs, so every render starts here.

| Query param | Type | Required | Notes |
|---|---|---|---|
| `limit` | `integer` | no | How many to return. 1–100, default 25. Out of range or non-numeric is 422 validation_failed — it is not clamped, so do not send limit=1000 and expect 100 back. |
| `cursor` | `string` | no | The next_cursor from a previous page. Pages backward in time. |

Request:

```bash
curl -s "https://omniscape.io/api/v1/media?limit=5" \
  -H "Authorization: Bearer $OMNISCAPE_API_KEY"
```

Response:

```
{
  "object": "list",
  "data": [
    {
      "object": "media",
      "id": "c35c8330-5dd2-4170-b2b0-bae80ed7182f",
      "created_at": "2026-08-12T01:09:49.562Z",
      "label": "#cross #christianity #rosary",
      "tags": ["faith"],
      "width": 1080,
      "height": 1920,
      "mime_type": "image/jpeg",
      "file_size_bytes": 72267,
      "preview_url": "https://omniscape.io/api/v1/media/c35c8330-…/preview",
      "preview_expires_at": null
    }
  ],
  "has_more": true,
  "next_cursor": "2026-08-12T01:07:46.032Z"
}
```

- Only photos this workspace owns, not archived, and images only — every id returned here is one POST /api/v1/renders will accept. The list and the render endpoint agree by construction, so a media_id from this list never comes back as unknown_media.
- Page with cursor until has_more is false. Cursors run backward in time; a new photo added mid-pagination will not shift the page under you.

### POST /api/v1/media

Scope: `media:write`

Add a photo to your workspace library from raw bytes, and get back a media_id you can render immediately. This is what lets an integration run fully headless — no portal upload step.

| Field | Type | Required | Notes |
|---|---|---|---|
| `file` | `file (multipart)` | yes | The image itself. JPEG, PNG, WebP or GIF; up to 15 MB. The format is detected from the bytes, not the filename. |
| `label` | `string` | no | Optional caption/label shown when browsing the library. |
| `tags` | `string[]` | no | Optional. Repeat the field to send several. |

Request:

```bash
curl -s -X POST "https://omniscape.io/api/v1/media" \
  -H "Authorization: Bearer $OMNISCAPE_API_KEY" \
  -F "file=@photo.jpg" \
  -F "label=spring campaign"
```

Response:

```
HTTP/1.1 201 Created

{
  "object": "media",
  "id": "c35c8330-5dd2-4170-b2b0-bae80ed7182f",
  "created_at": "2026-08-28T00:00:00.000Z",
  "label": "spring campaign",
  "tags": [],
  "width": null,
  "height": null,
  "mime_type": "image/jpeg",
  "file_size_bytes": 72267,
  "preview_url": "https://omniscape.io/api/v1/media/c35c8330-…/preview",
  "preview_expires_at": null
}
```

- Send the image BYTES — the API takes no URL. That is deliberate: nothing here fetches a location you supply, which is what keeps the surface safe to expose.
- The format is detected from the file itself, so a mislabeled Content-Type is rejected. JPEG, PNG, WebP and GIF are accepted (not SVG or video) — the same image formats the in-app media library takes.
- The returned id is renderable straight away: hand it to POST /api/v1/renders. It also appears in GET /api/v1/media like any other library photo.
- Uploads are rate-limited per key and count against your workspace storage — an over-budget upload is 402 quota_exceeded.
- Needs a key with the media:write scope; a read-only key cannot upload.

### GET /api/v1/media/{id}/preview

Scope: `media:read`

The photo itself, so an operator can see what they are about to render. Returns image bytes, not JSON.

Request:

```bash
curl -s "https://omniscape.io/api/v1/media/c35c8330-5dd2-4170-b2b0-bae80ed7182f/preview" \
  -H "Authorization: Bearer $OMNISCAPE_API_KEY" -o photo.jpg
```

Response:

```
HTTP/1.1 200 OK
Content-Type: image/jpeg
Cache-Control: private, no-store
X-Request-Id: 0f1c…

<binary image data>
```

- This is preview_url from the list response. It is on our domain and it needs the same Authorization header — it is a way to browse your own library, not a public image URL you can put in an <img> tag or hand to a browser.
- It does not expire, because your key is what authorises it rather than a token in the URL.
- A media id that is not yours returns 404 unknown_media — identical to one that does not exist.

### GET /api/v1/integrations/google/connect

Scope: `media:write`

Begin a one-time Google Drive connection. Returns a Google consent URL to open once; after that, imports run without asking again.

Request:

```bash
curl -s "https://omniscape.io/api/v1/integrations/google/connect" \
  -H "Authorization: Bearer $OMNISCAPE_API_KEY"
```

Response:

```
{
  "object": "oauth_connect",
  "provider": "google_drive",
  "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
  "expires_at": "2026-08-30T22:10:00.000Z"
}
```

- Open authorization_url in a browser and grant read-only Drive access. We store a connection for your workspace and never see your Google password.
- The grant is drive.readonly — we can list and download, never modify or delete anything in your Drive.
- A machine key starts the flow, but the consent is a human step, so this is the one call whose result you act on in a browser.

### GET /api/v1/integrations/google

Scope: `media:read`

Whether this workspace is connected to Google Drive, and with which scopes.

Request:

```bash
curl -s "https://omniscape.io/api/v1/integrations/google" \
  -H "Authorization: Bearer $OMNISCAPE_API_KEY"
```

Response:

```
{
  "object": "integration",
  "provider": "google_drive",
  "connected": true,
  "connected_at": "2026-08-30T21:00:00.000Z",
  "scopes": ["https://www.googleapis.com/auth/drive.readonly"]
}
```

- connected is false until the consent step completes. The stored token is never returned here.

### DELETE /api/v1/integrations/google

Scope: `media:write`

Disconnect Google Drive: revoke the token at Google and forget the connection.

Request:

```bash
curl -s -X DELETE "https://omniscape.io/api/v1/integrations/google" \
  -H "Authorization: Bearer $OMNISCAPE_API_KEY"
```

Response:

```
{
  "object": "integration",
  "provider": "google_drive",
  "connected": false,
  "revoked": true
}
```

- Immediate and self-serve — the same one-call revoke you have for an API key. To import again afterwards, reconnect.

### GET /api/v1/integrations/google/folders

Scope: `media:read`

List the sub-folders of a Drive folder, so you can find the folder_id to import from.

| Query param | Type | Required | Notes |
|---|---|---|---|
| `parent` | `string` | no | A Drive folder id, or 'root' (the default) for the top level. |
| `cursor` | `string` | no | The next_cursor from a previous page. |

Request:

```bash
curl -s "https://omniscape.io/api/v1/integrations/google/folders?parent=root" \
  -H "Authorization: Bearer $OMNISCAPE_API_KEY"
```

Response:

```
{
  "object": "list",
  "data": [ { "id": "1AbC...", "name": "Campaign Photos" } ],
  "has_more": false,
  "next_cursor": null
}
```

- Reads only from Google, over your connection. If you are not connected this is 409 integration_not_connected.
- A browse helper — you can also paste a folder id you already have straight into the import call.

### POST /api/v1/media/import

Scope: `media:write`

Bulk-import every image in a Google Drive folder into your library. Returns an import id to poll — the work runs in the background.

| Field | Type | Required | Notes |
|---|---|---|---|
| `provider` | `string` | yes | Currently 'google_drive'. |
| `folder_id` | `string` | yes | The Drive folder to import from — a folder id, NOT a URL. We resolve it through the Drive API and download only from Google. |
| `recursive` | `boolean` | no | Reserved; v1 imports the folder itself, not sub-folders. Default false. |

Request:

```bash
curl -s -X POST "https://omniscape.io/api/v1/media/import" \
  -H "Authorization: Bearer $OMNISCAPE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: import-campaign-1" \
  -d '{"provider":"google_drive","folder_id":"1AbC..."}'
```

Response:

```
HTTP/1.1 202 Accepted

{
  "object": "media_import",
  "id": "imp_9f2a1c3b7e40",
  "status": "pending",
  "provider": "google_drive",
  "folder_id": "1AbC...",
  "counts": { "discovered": 0, "imported": 0, "skipped": 0, "failed": 0 },
  "created_at": "2026-08-30T21:05:00.000Z"
}
```

- You give a folder id, never a URL — the API never fetches a location you name, exactly like the rest of it. Each file is byte-sniffed and stored under your workspace, the same as a direct upload.
- Only images are imported (JPEG, PNG, WebP, GIF). Re-importing the same folder skips files already imported, so it is safe to run again.
- Bounded: up to 500 files per import, and it stops when your storage cap is reached and reports a partial. A Google connection is required first (409 integration_not_connected otherwise).
- Send an Idempotency-Key to make a retried request return the same import instead of starting a second one.

### GET /api/v1/media/imports/{id}

Scope: `media:read`

The progress and result of one bulk import: counts, and a per-file breakdown.

Request:

```bash
curl -s "https://omniscape.io/api/v1/media/imports/imp_9f2a1c3b7e40" \
  -H "Authorization: Bearer $OMNISCAPE_API_KEY"
```

Response:

```
{
  "object": "media_import",
  "id": "imp_9f2a1c3b7e40",
  "status": "completed",
  "provider": "google_drive",
  "folder_id": "1AbC...",
  "counts": { "discovered": 42, "imported": 40, "skipped": 1, "failed": 1 },
  "stopped_reason": null,
  "items": [
    { "source_ref": "drive-file-1", "name": "a.jpg", "status": "imported", "media_id": "c35c..." },
    { "source_ref": "drive-file-2", "name": "b.svg", "status": "skipped", "reason": "unsupported_media_type" }
  ],
  "created_at": "2026-08-30T21:05:00.000Z",
  "completed_at": "2026-08-30T21:06:30.000Z"
}
```

- Poll until status is completed, partial, or failed. partial means some files imported and some did not — a failure, or a cap was hit (see stopped_reason).
- Each imported media_id renders like any other library photo. A foreign or unknown id is 404 import_not_found, never 403.

### POST /api/v1/renders

Scope: `renders:create` — **this is the only call that costs money**

Start a render. Returns 202 immediately with an opaque id — the render itself takes about 2 minutes.

| Field | Type | Required | Notes |
|---|---|---|---|
| `media_id` | `uuid` | yes | A photo id from GET /api/v1/media. |
| `quote` | `string ≤500` | no | The caption to burn onto the video. "" is a deliberate no-caption render. |
| `render_type` | `"aesthetic_quote"` | no | Optional. The only accepted value; anything else is refused. |
| `caption_style` | `"quote" | "meme"` | no | Caption treatment. |
| `text_color` | `"white" | "yellow" | "pastel-yellow"` | no | Caption fill. A closed set — a hex value or any other colour name is 422. |
| `text_size` | `"tiny" | "smallest" | "smaller" | "default" | "larger"` | no | Caption size. Also a closed set. |
| `bold` | `boolean` | no | Heavier caption weight. |
| `bold_outline` | `boolean` | no | Outline behind the caption. |
| `page_theme` | `string 1–100` | no | A page theme name, if your workspace uses them. |
| `image_class` | `"person" | "doll_figurine" | "landscape_nature" | "product_object" | "interior_space" | "other"` | no | Override the subject classification. Left off, it is inferred from the photo; set it when the inference is wrong for your material. |
| `sub_prompt` | `string ≤150` | no | Extra direction for the generated motion. Optional and advisory — treat its effect as best-effort rather than a guarantee. |
| `render_prompt` | `string ≤200` | no | Overrides the generated motion description outright. Same caveat: advisory, and easy to make worse than the default. |
| `metadata` | `object` | no | Up to 20 scalar keys, echoed back on the render so you can correlate it with your own job. |

Request:

```bash
curl -s -X POST "https://omniscape.io/api/v1/renders" \
  -H "Authorization: Bearer $OMNISCAPE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: my-job-0001" \
  -d '{
    "media_id": "c35c8330-5dd2-4170-b2b0-bae80ed7182f",
    "quote": "stay soft",
    "caption_style": "quote",
    "bold": true,
    "metadata": { "campaign": "spring" }
  }'
```

Response:

```
HTTP/1.1 202 Accepted
X-Request-Id: 0f1c…

{ "object": "render", "id": "rnd_7ef2da3bde9a", "status": "queued" }
```

- Always send an Idempotency-Key. A repeat with the same key and the same body replays the original response — same render id, no second render, no second charge — and comes back with an Idempotency-Replayed: true header so you can tell. The same key with a different body is 409 idempotency_conflict.
- The request shape is strict: any field not listed above is 422 validation_failed, including url, storage_path and preview_url. There is no way to point the API at an image it does not already hold, which is what makes it safe to expose.
- This is the only call that costs money, and it is refused before anything is charged if the caption cannot be composited (502 caption_prep_failed) or if the key is at its ceiling (402 spend_cap_reached).
- A 202 means queued, not started. Move straight to polling; do not sleep a fixed 6 minutes and check once.

### GET /api/v1/renders/{id}

Scope: `renders:read`

Check a render. This is the call your poll loop makes. Polling is free and uncounted.

Request:

```bash
curl -s "https://omniscape.io/api/v1/renders/rnd_7ef2da3bde9a" \
  -H "Authorization: Bearer $OMNISCAPE_API_KEY"
```

Response:

```
{
  "object": "render",
  "id": "rnd_7ef2da3bde9a",
  "type": "aesthetic_quote",
  "status": "completed",
  "progress": 100,
  "created_at": "2026-08-24T14:20:55.225Z",
  "updated_at": "2026-08-24T14:30:08.443Z",
  "completed_at": "2026-08-24T14:30:08.442Z",
  "asset": {
    "url": "https://omniscape.io/api/v1/renders/rnd_7ef2da3bde9a/download",
    "expires_at": null,
    "content_type": null,
    "width": null,
    "height": null,
    "byte_size": null,
    "duration_sec": 553.2
  },
  "error": null,
  "idempotency_key": "my-job-0001",
  "metadata": { "campaign": "spring" }
}
```

- status is one of queued, processing, completed, failed, canceled. Only completed, failed and canceled are terminal — branch on those and keep polling on anything else, so a status added later does not spin your loop forever.
- asset is null until status is completed. Read asset.url, do not build it yourself.
- When status is failed, error is { code, message } with one of render_failed, render_timeout, render_rejected, render_canceled.
- Known issue: progress does not move. It sits at 0 for the whole render and jumps to 100 at the end. Drive your UI off status, not progress.
- Known issue: asset.duration_sec currently reports render wall-clock rather than the length of the video. Do not rely on it; probe the file.
- A render id that is not yours returns 404 render_not_found — the same answer as one that does not exist, so the API cannot be used to discover whether an id is real.

### GET /api/v1/renders/{id}/download

Scope: `renders:read`

The finished MP4. Returns video bytes, not JSON.

Request:

```bash
curl -s "https://omniscape.io/api/v1/renders/rnd_7ef2da3bde9a/download" \
  -H "Authorization: Bearer $OMNISCAPE_API_KEY" -o render.mp4
```

Response:

```
HTTP/1.1 200 OK
Content-Type: video/mp4
Content-Disposition: attachment; filename="rnd_7ef2da3bde9a.mp4"
Cache-Control: private, no-store
X-Request-Id: 0f1c…

<binary video data>
```

- This is asset.url from the render. It does not expire — your API key authorises it, not a token in the link — so you can fetch it days later, and you can re-fetch it as many times as you like at no cost.
- It needs the Authorization header, which means it is not a link you can email to someone or put in a <video> tag. Download the bytes and host or share them yourself.
- Before the render completes, or if the file is not available, this is 404 asset_unavailable rather than an empty body.

### GET /api/v1/usage

Scope: `renders:read`

This billing cycle's render counts. Counts only — never dollars, cost, or provider detail.

Request:

```bash
curl -s "https://omniscape.io/api/v1/usage" \
  -H "Authorization: Bearer $OMNISCAPE_API_KEY"
```

Response:

```
{
  "object": "usage",
  "cycle_start": "2026-08-04T01:17:11.000Z",
  "aesthetic_quotes": { "used": 16, "included": null }
}
```

- included: null means the plan’s allowance is unlimited.
- This reports the account allowance for the billing cycle. It is a different number from your key’s daily spend cap, which is per key and rolls off over 24 hours — see Rate limits & quotas.

## Errors

Every failure is `{ "error": { "code", "message" }, "request_id" }` with the code echoed in `X-Request-Id`. **Match on `code`, never on `message`** — codes are frozen, messages are wording and may be reworded.

| Code | HTTP | Meaning | What to do |
|---|---|---|---|
| `missing_api_key` | 401 | No Authorization header was sent. | Fix the request. Retrying it unchanged will fail again. |
| `invalid_api_key` | 401 | The key is malformed or not recognised. Deliberately the same code for an unknown key and a wrong secret. | Fix the key — mint a new one, or re-mint it with the scope it needs. |
| `api_key_revoked` | 401 | The key was revoked. Mint a new one. | Fix the key — mint a new one, or re-mint it with the scope it needs. |
| `api_key_expired` | 401 | The key passed its expiry. | Fix the key — mint a new one, or re-mint it with the scope it needs. |
| `tenant_inactive` | 403 | The account is not active. | An account-level setting. Talk to us; retrying will not change it. |
| `tier_not_eligible` | 403 | The plan cannot hold API keys. Any paid plan can; Free cannot. | An account-level setting. Talk to us; retrying will not change it. |
| `api_disabled` | 403 | API access is switched off for this account. | An account-level setting. Talk to us; retrying will not change it. |
| `insufficient_scope` | 403 | The key lacks the scope for this call. Re-mint it with the scope. | Fix the key — mint a new one, or re-mint it with the scope it needs. |
| `forbidden` | 403 | Reserved. v1 answers an object that is not yours with 404, never 403. | Reserved — v1 does not return this code. |
| `invalid_request` | 400 | The body could not be parsed. | Fix the request. Retrying it unchanged will fail again. |
| `validation_failed` | 422 | A field is invalid — including any field that is not in the accepted shape. | Fix the request. Retrying it unchanged will fail again. |
| `unsupported_render_type` | 422 | Only aesthetic_quote is available via the API. | Fix the request. Retrying it unchanged will fail again. |
| `unsupported_media_type` | 415 | The upload is not a JPEG, PNG, WebP or GIF image (checked from the file bytes, not the header). | Fix the request. Retrying it unchanged will fail again. |
| `payload_too_large` | 413 | The uploaded image is empty or over the 15 MB limit. | Fix the request. Retrying it unchanged will fail again. |
| `unknown_media` | 404 | That media_id is not in your library, or is archived. Re-list GET /api/v1/media. | Fix the request. Retrying it unchanged will fail again. |
| `caption_prep_failed` | 502 | The caption image could not be composited, so nothing was started and nothing was charged. | Safe to retry. Nothing was charged. |
| `idempotency_conflict` | 409 | That Idempotency-Key was already used with a different body. | Fix the request. Retrying it unchanged will fail again. |
| `idempotency_in_progress` | 409 | A previous request with that Idempotency-Key is still being resolved. Retry with the SAME key — a new key would create a second render. Carries Retry-After. | Safe to retry. Nothing was charged. |
| `render_conflict` | 409 | Reserved for render mutation. v1 exposes no endpoint that mutates a render. | Reserved — v1 does not return this code. |
| `integration_not_connected` | 409 | No Google Drive connection for this account. Start GET /api/v1/integrations/google/connect first. | Fix the request. Retrying it unchanged will fail again. |
| `method_not_allowed` | 405 | Wrong HTTP method. Reserved — an unmatched method is handled by the router, not this code. | Reserved — v1 does not return this code. |
| `rate_limit_exceeded` | 429 | Too many requests per minute for this key. | Back off and retry later. You are at a ceiling, not in error. |
| `concurrency_limit_reached` | 429 | Reserved for a per-key in-flight ceiling. v1 does not return it. | Reserved — v1 does not return this code. |
| `spend_cap_reached` | 402 | This key's daily spend ceiling is used up. It rolls off per render over 24h. | Back off and retry later. You are at a ceiling, not in error. |
| `quota_exceeded` | 402 | The account's render allowance for the cycle is used up — or the plan does not include this render type at all. On Starter it is the latter, always. | An account-level setting. Talk to us; retrying will not change it. |
| `render_not_found` | 404 | No render with that id for this account. | Fix the request. Retrying it unchanged will fail again. |
| `asset_unavailable` | 404 | The finished file is not available yet, or not available at all. | Safe to retry. Nothing was charged. |
| `unknown_folder` | 404 | The Drive folder was not found, or your connection cannot access it. | Fix the request. Retrying it unchanged will fail again. |
| `import_not_found` | 404 | No import with that id for this account. | Fix the request. Retrying it unchanged will fail again. |
| `internal_error` | 500 | A fault on our side. | Safe to retry. Nothing was charged. |
| `service_unavailable` | 503 | Temporarily unavailable, or a dependency could not be reached. | Safe to retry. Nothing was charged. |

A render that fails after it was accepted reports it in `render.error`, not as an HTTP status:

| Code | Meaning |
|---|---|
| `render_failed` | Generation ran and did not produce a usable video. |
| `render_timeout` | Generation exceeded its time budget. |
| `render_rejected` | The request was refused before generation began. |
| `render_canceled` | The render was canceled. |

## Rate limits and quotas

Only POST /api/v1/renders costs anything. Reads are free and uncounted against your spend cap: polling a render, downloading the file, re-downloading it a hundred times, listing media, fetching previews, checking usage. Reads are rate limited but never billed, so poll every few seconds rather than continuously: a render takes minutes, so polling harder learns nothing sooner.

| Limit | Value | Applies | Notes |
|---|---|---|---|
| Renders started per minute | 60 by default | per key, on POST /api/v1/renders | Over it is 429 rate_limit_exceeded. Your key’s actual value is on Settings → API and can be raised for you. |
| Read requests per minute | 600 by default | per key, on every GET | Polling, downloads, previews, media listing and usage share this. Deliberately far above the create limit so a caller polling several renders at once is never throttled for following the guidance on this page. Over it is 429 rate_limit_exceeded. |
| Daily spend cap | set per key | per key | A ceiling on renders started in a rolling 24-hour window, so it refills continuously rather than resetting at midnight. The render that would cross the line is the one refused, with 402 spend_cap_reached — you are never taken over the cap. |
| Render allowance | set per plan | per account | The cycle allowance shared by everything in the workspace, API and portal alike: 0 on Starter, 100 on Studio, 500 on Agency, negotiated on Custom. GET /api/v1/usage reports it. Exhausted — or never granted — is 402 quota_exceeded. |
| Caption length | 500 characters | per render | Longer is 422 validation_failed. |
| Metadata | 20 keys | per render | Scalar values only, echoed back on the render unchanged. |
| Page size | 100 max, 25 default | GET /api/v1/media | Out of range is 422 validation_failed, not a clamp. |

## Working code

### JavaScript

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

```javascript
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).

```python
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")
```

### curl

The whole loop, by hand, for a first look.

```bash
KEY="$OMNISCAPE_API_KEY"

# 1 — find a photo (the render endpoint accepts no URLs, only ids from here)
curl -s "https://omniscape.io/api/v1/media?limit=5" -H "Authorization: Bearer $KEY"

# 2 — start the render
curl -s -X POST "https://omniscape.io/api/v1/renders" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: my-job-0001" \
  -d '{"media_id":"<id from step 1>","quote":"stay soft"}'
# => 202 { "id": "rnd_7ef2da3bde9a", "status": "queued" }

# 3 — poll until status is completed (free, and uncounted against your cap)
curl -s "https://omniscape.io/api/v1/renders/rnd_7ef2da3bde9a" -H "Authorization: Bearer $KEY"

# 4 — download it, with the same header
curl -s "https://omniscape.io/api/v1/renders/rnd_7ef2da3bde9a/download" \
  -H "Authorization: Bearer $KEY" -o render.mp4

# 5 — that link does not expire, and re-downloading is free
```

## Troubleshooting

**422 validation_failed and the field I sent looks fine**

- Cause: The request shape is strict: an unrecognised field is rejected, not ignored. A typo, a stray url, or a field copied from another API will do it.
- Fix: Send only the fields in the API reference. Start from the minimum — media_id and quote — and add one field at a time.

**403 insufficient_scope on a call that worked yesterday**

- Cause: You are using a different key, and that key was minted without the scope this call needs.
- Fix: Check the key in Settings → API. Scopes are fixed at mint; re-mint the key with renders:create, renders:read and media:read as needed.

**403 tier_not_eligible with a key that authenticates fine**

- Cause: The key is real but the account’s plan does not include API access. Authentication and eligibility are separate gates.
- Fix: Talk to us about the plan. Nothing about the key or the request will change this.

**404 unknown_media on an id I definitely have**

- Cause: The photo is archived, is not an image, or belongs to another workspace. The render endpoint only accepts what GET /api/v1/media would list.
- Fix: Re-list GET /api/v1/media and use an id from that response. If it is not in the list, it is not renderable.

**The render sits at "queued" and progress stays 0**

- Cause: Expected. progress does not move in v1 — it is 0 until the render finishes, then 100. A queued render is normal for the first minute or two.
- Fix: Drive your UI off status, not progress. Only give up at a deadline of 20 minutes or so.

**My poll loop runs forever**

- Cause: It is waiting for "completed" and never checks the other terminal states, so a failed or canceled render never breaks the loop.
- Fix: Branch on completed, failed and canceled explicitly, keep polling on everything else, and give up on a deadline. Both code examples do this.

**429 rate_limit_exceeded when starting a batch of renders**

- Cause: The per-minute limit is on creates. Fanning a work queue out across many workers hits it quickly, because each worker starts renders as fast as it can dequeue them.
- Fix: Rate-limit your own producer to the key’s per-minute value, or catch the 429 and requeue with backoff. Ask us to raise the key if the ceiling is genuinely too low for your volume.

**402 quota_exceeded on my very first render, with a brand new key**

- Cause: Almost certainly a Starter plan. Starter can hold an API key and authenticate fine, but it includes zero Pic to Video Renders — so the key works and every create is refused. Two separate gates, and only the second one has anything to do with rendering.
- Fix: Check GET /api/v1/usage: included: 0 confirms it. Studio is the first plan with an allowance. The read endpoints work on Starter, so you can build the whole integration before you upgrade.

**402 spend_cap_reached but usage says I have allowance left**

- Cause: Two different limits. The spend cap is per key over a rolling 24 hours; the allowance in GET /api/v1/usage is per account per billing cycle.
- Fix: Wait for the rolling window to refill, use another key, or ask us to raise that key’s cap.

**502 caption_prep_failed**

- Cause: The caption could not be composited onto the photo, so the render was refused before it started.
- Fix: Nothing was charged. Retry; if it repeats on the same photo, try a shorter caption or a different photo and send us the request_id.

**asset.url returns 401 in the browser**

- Cause: It is not a public link. Every URL this API hands back is on our domain and authorised by your API key, not by a token in the URL.
- Fix: Fetch it server-side with the Authorization header and host or share the bytes yourself.

**I retried a create after a timeout and got charged twice**

- Cause: The retry went out without an Idempotency-Key, so it was a new request.
- Fix: Send an Idempotency-Key on every create and reuse it on the retry. A replay comes back with Idempotency-Replayed: true.

**asset.duration_sec does not match the video**

- Cause: Known defect: it currently reports render wall-clock rather than the length of the video.
- Fix: Probe the downloaded file. Do not build on that field until this note goes away.

## Known defects in v1

- `progress` does not move. It is `0` until the render completes, then `100`. Use `status`.
- `asset.duration_sec` reports render wall-clock, not the length of the video.
- There are no webhooks. Poll.

## Changelog

### 2026-08-25 (v1)

- Docs rebuilt: quick start separated from the reference, code examples in JavaScript and Python with a correct poll loop, a troubleshooting section, and this changelog.
- Added /docs/api.md — the whole reference as one markdown file, for handing to a coding agent.
- GET /api/v1/media/{id}/preview and GET /api/v1/renders/{id}/download are now documented as first-class endpoints rather than notes.

### 2026-08-24 (v1)

- Asset and preview links moved onto our own domain and no longer expire — your API key authorises them instead of a token in the URL. asset.expires_at is now always null.
- Added GET /api/v1/media so an integration can discover media_ids instead of being handed them.
- Captions are now composited before a render is queued, and a failure there returns 502 caption_prep_failed without charging.
- Polling no longer counts against a key’s daily spend cap. Only POST /api/v1/renders does.
