# DXGL Public API
Base URL: `https://api.dx.gl/v1`
## Authentication
All requests require a Bearer token in the `Authorization` header:
```
Authorization: Bearer dxgl_sk_...
```
Tokens are created in [Studio](https://dx.gl/studio): open the avatar menu (top-right) → **API** → **Keys** → **Create key**. The raw token is shown once on creation — store it securely.
**Server-to-server only.** A `dxgl_sk_` token is a full-account secret — never embed one in a web page or mobile app. The API grants no CORS access to browsers; call it from your backend. For public browser embeds, use the public share URLs of published renders instead.
### Workspace-bound keys
A key can be bound to a **team workspace** at creation (the workspace picker in the Create-key form; requires the *editor* role or above in that workspace). The binding is fixed for the key's lifetime — the key *is* the workspace selection, so nothing else in your integration changes: every endpoint documented here simply operates on the bound workspace.
- **Scope**: models, renders, and overlay/texture assets are the bound workspace's — the whole team's, not just yours. Assets you upload land in the workspace and resolve in the team's renders.
- **Billing**: renders are funded from the **workspace owner's** credit pool (`GET /v1/account` shows that pool and names the workspace); `GET /v1/purchases` returns only ledger rows attributable to this workspace.
- **Lifecycle**: your membership is re-checked on every request — if you're removed from the workspace (or the workspace is deleted), the key stops working immediately with `403 forbidden`.
A key created without a workspace (or before workspace keys existed) operates on your **personal** workspace, exactly as before.
---
## Response Format
**Success:**
```json
{
"data": { ... }
}
```
**List (paginated):**
```json
{
"data": [ ... ],
"meta": { "total": 142, "offset": 0, "limit": 50 }
}
```
**Cursor pagination (recommended for large catalogs).** `GET /v1/models` and `GET /v1/renders` also support keyset pagination: pass `cursor=` (empty for the first page, or the previous response's `meta.nextCursor`), and the response's meta becomes `{ "limit": 50, "nextCursor": "..." | null }` — walk until `nextCursor` is `null`. Cursor mode skips the per-page `COUNT(*)` and stays fast at any depth, unlike large `offset` values. Cursors are opaque; don't parse them. Both endpoints also accept `updatedSince=<ISO 8601>` (rows modified at/after that instant) for cheap delta polling — combine it with cursor mode to sync a big catalog incrementally.
**Error:**
```json
{
"error": {
"code": "model_not_found",
"message": "Model not found",
"status": 404
}
}
```
**IDs.** All resource IDs are short opaque strings (e.g. `Ab3kF9x2qL1m`). A note
on naming: when an endpoint returns a resource it just created, the field is
named `id` (e.g. `POST /v1/renders` → `{ "id": ... }`); when an endpoint returns
a related resource alongside a primary one, the related ID carries a qualified
name — uploading a model returns `{ "modelId": ..., "renderId": ... }`, and a
render always refers to its model as `modelId`. So the render you create is `id`
on `POST /v1/renders` but `renderId` on `POST /v1/models`.
---
## Models
A **model** is an uploaded 3D file (GLB) or a converted 3D scan. Uploading a model always creates a render job: with `renderSettings` it's the billed render you specified; without, it's a **free system preview** (see below).
### Upload Model
```
POST /v1/models
Content-Type: multipart/form-data
```
| Field | Type | Required | Description |
|---|---|---|---|
| `file` | File | Yes | `.glb` (glTF binary container) or `.zip` (OBJ+MTL+textures). A raw `.gltf` JSON file is rejected — pack it into a `.glb` first (all mainstream DCC tools and `gltf-transform` can). Max 1 GB. |
| `renderSettings` | JSON string | No | Render configuration (see below). **Omitting it makes the upload's render a free system preview; providing it creates a billed render at the settings given.** |
**Response** `201`:
```json
{
"data": {
"modelId": "Ab3kF9x2qL1m",
"renderId": "Xz7pQ4w8nR2k"
}
}
```
If the file's SHA-256 matches an existing upload, the existing model is reused and the response is `200` with `duplicate: true` (a fresh upload returns `201`). With `renderSettings`, a new billed render is created for the existing model; without, its existing system preview is returned.
**Free preview:** An upload without `renderSettings` creates a free system preview render (no credits deducted) — a lightweight 540p turntable that doubles as a model integrity check: if the preview fails, the model likely has issues (broken geometry, missing textures, invalid glTF). The preview is the `renderId` in the response and carries `isSystemPreview: true` in render list/detail responses.
**3D scan support:** Upload a `.zip` containing one OBJ file with its MTL and texture files (JPG/PNG). One model per ZIP. The server converts to GLB automatically, with materials set to roughness 1.0 for a natural matte finish. The converted GLB is stored as your model and can be downloaded via `GET /v1/models/:id/file`. Ideal for photogrammetry and structured-light scan exports (Artec Studio, RealityCapture, Metashape, etc.).
### Ingest from URL
```
POST /v1/models/ingest
Content-Type: application/json
```
```bash
curl -X POST https://api.dx.gl/v1/models/ingest \
-H "Authorization: Bearer dxgl_sk_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/model.glb", "renderSettings": {"aspect": "16:9"}}'
```
**Response** `201`: Same as upload (duplicates return `200` with `duplicate: true`).
Downloads the file from the URL (max 1 GB). URL ingest accepts GLB only. For OBJ scans, use the file upload endpoint with a ZIP.
**Pull from your own S3/R2 bucket.** Point `url` at a **presigned GET URL** from your bucket — no credentials leave your side. Presigned URLs sign the HTTP method, so DX.GL skips the availability HEAD probe for them and streams the object directly (the 1 GB cap still applies mid-download, and the file is GLB-validated on arrival). A common S3 default content-type (`binary/octet-stream`) is accepted. Example:
```bash
# 1. presign in your own infra (7-day max)
aws s3 presign s3://your-bucket/models/chair.glb --expires-in 3600
# 2. hand the URL to DX.GL
curl -X POST https://api.dx.gl/v1/models/ingest \
-H "Authorization: Bearer dxgl_sk_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://your-bucket.s3.amazonaws.com/models/chair.glb?X-Amz-Signature=..."}'
```
Combined with `GET /v1/renders/:id/download-url` (presigned egress), this gives a bucket-to-bucket workflow where DX.GL never holds your storage credentials. The download aborts after 45 seconds without progress, with a 10-minute absolute ceiling — a timed-out download returns `download_timeout` (400). SHA-256 deduplication applies. URL ingest accepts GLB only. For OBJ scans, use the file upload endpoint with a ZIP.
### Staged Upload (Presigned PUT)
For large models, flaky links, or bucket-to-bucket pipelines, skip the multipart POST and upload straight to storage:
```
POST /v1/models/create-upload-url → { "url", "ticket", "expiresIn", "maxBytes" }
PUT <url> (the GLB bytes — curl -T model.glb "<url>")
POST /v1/models/finalize { "ticket": "...", "filename": "chair.glb", "renderSettings": { ... } }
```
`create-upload-url` mints a presigned PUT to a staging area (default 1 h, `expires` 60–604800 s). After the PUT succeeds, `finalize` registers the model — it runs the **exact same pipeline** as the multipart upload (GLB validation, SHA-256 dedup with `duplicate: true` reuse, free system preview or billed render per `renderSettings`) and responds identically (`{ modelId, renderId }`). Staged uploads are GLB-only (max 1 GB — oversized staged objects are deleted at finalize). A ticket whose URL expired before the PUT, or whose PUT never happened, finalizes to `404 not_found`. Abandoned staging objects are garbage-collected; don't rely on staging as storage.
```bash
url_resp=$(curl -s -X POST https://api.dx.gl/v1/models/create-upload-url -H "Authorization: Bearer dxgl_sk_...")
curl -T chair.glb "$(echo "$url_resp" | jq -r .data.url)"
curl -s -X POST https://api.dx.gl/v1/models/finalize \
-H "Authorization: Bearer dxgl_sk_..." -H "Content-Type: application/json" \
-d "{\"ticket\": \"$(echo "$url_resp" | jq -r .data.ticket)\", \"filename\": \"chair.glb\"}"
```
### List Models
```
GET /v1/models?limit=50&offset=0&tags=furniture
```
| Param | Type | Default | Description |
|---|---|---|---|
| `limit` | integer | 50 | Max 100 |
| `offset` | integer | 0 | Pagination offset (offset mode) |
| `cursor` | string | — | Keyset pagination: empty for page 1, then `meta.nextCursor` (see [Response Format](#response-format)) |
| `updatedSince` | string | — | ISO 8601 — only rows modified at/after this instant |
| `tags` | string | — | Comma-separated tags (OR filter) |
| `status` | string | `active` | `active`, `archived`, or `any` (active + archived; each row carries its `status`). Deleted models are never listed. |
```bash
curl "https://api.dx.gl/v1/models?limit=10&tags=furniture" \
-H "Authorization: Bearer dxgl_sk_..."
```
**Response** `200`:
```json
{
"data": [
{
"id": "Ab3kF9x2qL1m",
"originalName": "chair.glb",
"title": "Ergonomic Chair",
"sku": "EC-1001",
"tags": ["furniture", "office"],
"fileSize": 4521984,
"sha256": "a1b2c3...",
"animationCount": 0,
"createdAt": "2026-02-15T20:00:00.000Z"
}
],
"meta": { "total": 42, "offset": 0, "limit": 50 }
}
```
### Get Model
```
GET /v1/models/:id
```
Returns the model detail with all its renders:
```bash
curl https://api.dx.gl/v1/models/Ab3kF9x2qL1m \
-H "Authorization: Bearer dxgl_sk_..."
```
```json
{
"data": {
"id": "Ab3kF9x2qL1m",
"originalName": "chair.glb",
"title": "Ergonomic Chair",
"sku": "EC-1001",
"tags": ["furniture", "office"],
"fileSize": 4521984,
"sha256": "a1b2c3...",
"animations": [
{ "name": "Walk", "duration": 1.933, "channels": 57 }
],
"meshNames": ["Body", "Cap", "Cap_1"],
"materialNames": ["woven", "rib"],
"createdAt": "2026-02-15T20:00:00.000Z",
"renders": [
{
"id": "Xz7pQ4w8nR2k",
"status": "done",
"renderSettings": { ... },
"createdAt": "2026-02-15T20:00:01.000Z",
"updatedAt": "2026-02-15T20:01:30.000Z"
}
]
}
}
```
### Update Model
```
PATCH /v1/models/:id
Content-Type: application/json
```
| Field | Type | Description |
|---|---|---|
| `title` | string | Display title |
| `sku` | string | Product code |
| `tags` | string[] | Up to 20 tags (lowercased, trimmed) |
All fields are optional — only fields present in the body are changed (an omitted field is never touched; passing `null` or `""` explicitly clears `title`/`sku`). A body with none of the three fields returns `400 bad_request`.
```bash
curl -X PATCH https://api.dx.gl/v1/models/Ab3kF9x2qL1m \
-H "Authorization: Bearer dxgl_sk_..." \
-H "Content-Type: application/json" \
-d '{"title": "Ergonomic Chair Pro", "sku": "ECP-2001", "tags": ["furniture", "office"]}'
```
### Archive Model
```
PATCH /v1/models/:id/archive
```
Archives the model. Archived models are hidden from the default list; enumerate them with `GET /v1/models?status=archived` (or `status=any`). Detail, metadata edits, file download, and delete keep working on archived models.
```bash
curl -X PATCH https://api.dx.gl/v1/models/Ab3kF9x2qL1m/archive \
-H "Authorization: Bearer dxgl_sk_..."
```
### Unarchive Model
```
PATCH /v1/models/:id/unarchive
```
Restores an archived model back to active status.
### Download Model File
```
GET /v1/models/:id/file
```
Downloads the original GLB file. Returns `Content-Type: model/gltf-binary` with a `Content-Disposition: attachment` header.
```bash
curl -o chair.glb https://api.dx.gl/v1/models/Ab3kF9x2qL1m/file \
-H "Authorization: Bearer dxgl_sk_..."
```
### Check Hash (Dedup Pre-flight)
```
POST /v1/models/check-hash
Content-Type: application/json
```
```json
{ "sha256": "a1b2c3d4..." }
```
**Response** `200`:
```json
{
"data": {
"exists": true,
"modelId": "Ab3kF9x2qL1m"
}
}
```
Pre-flight check to avoid uploading duplicate files. Compute the SHA-256 of your file locally, then call this endpoint before uploading.
### Delete Model
```
DELETE /v1/models/:id
```
Soft-deletes the model. Existing renders remain accessible until independently deleted.
---
## Renders
A **render** is a video generated from a model. Creating a render queues it for processing by the render pipeline.
### Create Render
```
POST /v1/renders
Content-Type: application/json
```
```json
{
"modelId": "Ab3kF9x2qL1m",
"renderSettings": {
"quality": "share",
"aspect": "16:9",
"bgColor": "#ffffff",
"length": 6,
"animation": "Walk",
"animationSpeed": 1,
"shadows": true,
"reflector": false,
"easing": true
}
}
```
> **Pricing note — read before your first render.** The `quality` field is optional and **defaults to `"standard"` (HD 1080p) at 4 credits per second** when omitted — a default 6-second render with no `quality` set costs **24 credits**. Pass `"share"` (540p, 1 credit/sec) explicitly for the cheapest tier: a 6s `share` render costs 6 credits. The other tiers: `"4k"` (16 credits/sec) and `"pro"` (ProRes 4444 with alpha, 64 credits/sec). A video render's cost is always tier rate × length in seconds. The JSON enum values are frozen for API stability and do not match the app's display labels one-to-one. See [Render Settings](#render-settings) for all options.
**Idempotent retries.** Send an `Idempotency-Key` header (any stable string ≤ 80 chars — e.g. a UUID minted once per submit intent, or your own order/SKU reference). If a render with the same key **and byte-identical `renderSettings`** was created within the last 30 minutes and hasn't errored, the request **replays**: you get the original render back with `deduped: true` and HTTP `200`, and nothing is charged. This makes timed-out-and-retried submissions safe. One caveat: the same key with *different* settings creates (and charges) a new render — the key does not lock settings. `POST /v1/renders/batch` accepts the same header — one key covers the whole batch, each item replays against its own prior row and comes back with `deduped: true`. (Independently of the header, an identical resubmit of the same model + settings within a few seconds is deduplicated automatically.)
**Response** `201` (`200` with `deduped: true` on an idempotent replay):
```json
{
"data": {
"id": "Xz7pQ4w8nR2k",
"modelId": "Ab3kF9x2qL1m",
"status": "pending",
"isPreview": false,
"renderSettings": { ... }
}
}
```
`isPreview` reports whether the render was funded from the account's free-credit pool (see [Credits](#render-credits)); on an idempotent replay the response also carries `deduped: true`.
### List Renders
```
GET /v1/renders?model=Ab3kF9x2qL1m&status=done&limit=50&offset=0
```
| Param | Type | Description |
|---|---|---|
| `model` | string | Filter by model ID |
| `status` | string | Filter by status |
| `limit` | integer | Max 100, default 50 |
| `offset` | integer | Pagination offset (offset mode) |
| `cursor` | string | Keyset pagination: empty for page 1, then `meta.nextCursor` |
| `updatedSince` | string | ISO 8601 — only renders modified at/after this instant (status changes update it — ideal for completion polling across a big batch) |
### Get Render
```
GET /v1/renders/:id
```
```json
{
"data": {
"id": "Xz7pQ4w8nR2k",
"modelId": "Ab3kF9x2qL1m",
"status": "done",
"renderSettings": { ... },
"fileSize": 2457600,
"errorMessage": null,
"failureCode": null,
"hasWebVariant": true,
"hasThumb": true,
"isPreview": false,
"isSystemPreview": false,
"unlockedAt": null,
"hlsStatus": "ready",
"hlsReady": true,
"datasetZipSize": null,
"createdAt": "2026-02-15T20:00:01.000Z",
"updatedAt": "2026-02-15T20:01:30.000Z"
}
}
```
| Field | Type | Description |
|---|---|---|
| `fileSize` | integer | Video file size in bytes (null until done) |
| `errorMessage` | string | Human-readable failure reason when status is `error` (first line of the worker's report) |
| `failureCode` | string | Machine-readable failure class when status is `error`, else `null`: `model_download_failed`, `env_download_failed`, `renderer_crash`, `renderer_error` (bad/unparseable model), `timeout`, `display_lost`, `encode_failed`, `upload_failed`, `reaped` (render node stopped responding). Branch on this, not on message text. |
| `hasWebVariant` | boolean | Whether a web-optimized MP4 is available |
| `hasThumb` | boolean | Whether a thumbnail video is available |
| `isPreview` | boolean | Whether this render was funded from the free-credit pool (legacy preview variant — see [Credits](#render-credits)) |
| `isSystemPreview` | boolean | Whether this is the free system preview created by a bare model upload |
| `unlockedAt` | string | Timestamp a preview render was unlocked (legacy — null for API-created renders), or null |
| `hlsStatus` | string | HLS packaging status: `pending`/`processing`/`ready`/`failed`/`skipped` |
| `hlsReady` | boolean | Whether HLS playback is ready (`hlsStatus === "ready"`) |
| `datasetZipSize` | integer | Dataset ZIP size in bytes (dataset renders only; null otherwise) |
These same fields appear on each item in **List Renders**.
### Dismiss Render
```
PATCH /v1/renders/:id/dismiss
```
Dismisses an errored render (transitions `error` → `failed`). Useful for acknowledging errors programmatically.
### Cancel Render
```
POST /v1/renders/:id/cancel
```
Cancels a render that is still **queued** (`pending`) and refunds the credits it was charged, to the pool they came from. The response reports the refund:
```json
{ "data": { "id": "Xz7pQ4w8nR2k", "cancelled": true, "refundedCredits": 24 } }
```
Once a worker has claimed the job (any status past `pending`), cancellation returns `409 render_started` — the GPU time is being spent and the render will complete or error normally.
### Delete Render
```
DELETE /v1/renders/:id
```
Permanently deletes a **terminal** render (`done`, `error`, `failed`) and its files (video, poster, thumbnail). Queued and in-flight renders return `409 render_in_flight` — cancel a queued render instead (which refunds); an in-flight render must finish or error first. Deleting a completed render never refunds (the GPU time was spent).
### Batch Render
```
POST /v1/renders/batch
Content-Type: application/json
```
```json
{
"renders": [
{ "modelId": "Ab3kF9x2qL1m", "renderSettings": { "aspect": "16:9", "bgColor": "#ffffff" } },
{ "modelId": "Ab3kF9x2qL1m", "renderSettings": { "aspect": "1:1", "bgColor": "#000000" } },
{ "modelId": "Yz9mK3v7pN4j", "renderSettings": { "aspect": "16:9" } }
]
}
```
Maximum 100 renders per batch. Each item requires a `modelId` and optional `renderSettings`. All credits are deducted atomically — if there aren't enough credits, the entire batch fails.
**Response** `201`:
```json
{
"data": [
{ "id": "Xz7pQ4w8nR2k", "modelId": "Ab3kF9x2qL1m", "status": "pending", "isPreview": false },
{ "id": "Bc5nL2x9mQ3r", "modelId": "Ab3kF9x2qL1m", "status": "pending", "isPreview": false },
{ "id": "Wv8jR6t4kP1s", "modelId": "Yz9mK3v7pN4j", "status": "pending", "isPreview": false }
]
}
```
Renders for the same model are automatically grouped into a batch — the worker renders shared frames once and encodes variants in parallel.
Batch requests honor the `Idempotency-Key` header (see [Create Render](#create-render)): one key covers the whole batch, and on a retried call each item that matches a prior row is returned with `deduped: true` instead of being re-charged.
---
## Assets
Download the output files of a completed render.
### Video
```
GET /v1/renders/:id/video
GET /v1/renders/:id/video?quality=web
```
Returns the video file. Supports `Range` headers for streaming.
Pass `?quality=web` to get the lighter web-optimized variant (when available, see `hasWebVariant`).
**Content-Type:** `video/mp4` (standard/4k) or `video/quicktime` (pro — ProRes .mov)
### Poster
```
GET /v1/renders/:id/poster
GET /v1/renders/:id/poster?quality=full
```
Returns the poster image (first frame). By default returns a small thumbnail suitable for previews. Pass `?quality=full` to get the full-resolution PNG at the video's native dimensions (e.g. 1920×1080).
**Content-Type:** `image/png`
### Thumbnail
```
GET /v1/renders/:id/thumb
```
Returns a quarter-resolution MP4 thumbnail video. Supports `Range` headers.
**Content-Type:** `video/mp4`
### Bundle (Zip Download)
```
GET /v1/renders/:id/bundle
```
Downloads all render assets as a single ZIP file. The zip is streamed directly — no server-side buffering, suitable for large ProRes files. Contains:
- `video.mp4` or `video.mov` (main video)
- `web.mp4` (web-optimized variant)
- `poster.png` (full-resolution poster)
- `thumb.mp4` (thumbnail video)
The response includes `Cache-Control: immutable` — renders never change after completion.
```bash
curl -o assets.zip https://api.dx.gl/v1/renders/Xz7pQ4w8nR2k/bundle \
-H "Authorization: Bearer dxgl_sk_..."
```
### HLS Streaming
```
GET /v1/renders/:id/hls
```
Returns the render's HLS packaging state and playback entry points:
```json
{
"data": {
"id": "Xz7pQ4w8nR2k",
"status": "ready",
"masterUrl": "/v1/renders/Xz7pQ4w8nR2k/hls/master.m3u8",
"thumbnailsVttUrl": "/v1/renders/Xz7pQ4w8nR2k/hls/thumbs.vtt"
}
}
```
`masterUrl`/`thumbnailsVttUrl` are `null` until `status` is `ready`. The manifests, fMP4 segments, VTT thumbnail track, and sprite JPEGs are served under:
```
GET /v1/renders/:id/hls/*
```
All HLS endpoints require the bearer token like every other `/v1` route — they are for server-side consumption or proxying (the API grants no browser CORS). For direct browser playback, serve the flat MP4 from your own CDN or use published share URLs.
### Presigned Download URLs
```
GET /v1/renders/:id/download-url?asset=video&expires=3600
GET /v1/models/:id/file-url?expires=3600
```
Returns a short-lived URL that downloads the asset **directly from object storage**, taking the API out of the data path — the efficient way to pull large files (a 4K ProRes bundle can be tens of GB) and to parallelize or resume downloads with your own tooling (`aws s3 cp`, `curl -C -`, etc.).
| Param | Description |
|---|---|
| `asset` | `video` (default), `web`, `poster`, `full_poster`, `thumb`, or `dataset` |
| `expires` | Link lifetime in seconds — default 3600, min 60, max 604800 (7 days) |
```json
{ "data": { "url": "https://…?X-Amz-Signature=…", "asset": "video", "expiresIn": 3600 } }
```
The URL is an unauthenticated capability link until it expires — treat it as a secret and keep `expires` short. `dataset` requires the render to be `done`; asset endpoints 404 if that variant wasn't produced. (HLS manifests and the on-the-fly `/bundle` zip can't be presigned — use their streaming endpoints.)
### Dataset (Vision Training ZIP)
```
GET /v1/renders/:id/dataset
```
Downloads the multi-view training dataset ZIP produced by a completed dataset
render (`output: "dataset"`). See [Dataset Export](#dataset-export-vision-training)
for the archive contents. The render must be `done`.
**Content-Type:** `application/zip`
```bash
curl -o dataset.zip https://api.dx.gl/v1/renders/Xz7pQ4w8nR2k/dataset \
-H "Authorization: Bearer dxgl_sk_..."
```
---
## Render Settings
| Field | Type | Default | Description |
|---|---|---|---|
| `quality` | string | `"standard"` | Quality tier: `"share"` (Standard, 540p H.264, 1 cr/sec), `"standard"` (HD, 1080p H.264, 4 cr/sec — **the default when omitted**), `"4k"` (4K H.264, 16 cr/sec), `"pro"` (ProRes 4444, 4K with alpha, 64 cr/sec). Display labels differ from JSON enum values; enums are frozen for API stability. |
| `effect` | string | `"turntable"` | Camera path: `"turntable"` (360°), `"hero-spin"` (fast spin to front), `"showcase"` (look-around), `"zoom-orbit"` (360° + zoom), `"reveal"` (scale-up entrance), `"keyframes"` (custom fly-through, see `keyframes`/`camera`), `"dataset"` (vision training; flat cost per set by tier) |
| `sweepAngle` | number | — | Override sweep angle in degrees (hero-spin default 540, reveal default 180). Range: 10–1080. |
| `easeOutRatio` | number | — | Hero-spin deceleration fraction (default 0.6). Range: 0–1. |
| `amplitude` | number | — | Showcase swing angle in degrees (default 60). Range: 5–180. |
| `zoomStart` | number | — | Zoom-orbit starting radius multiplier (default 1.0). Range: 0.1–3. |
| `zoomEnd` | number | — | Zoom-orbit ending radius multiplier (default 0.7). Range: 0.1–3. |
| `scaleFrom` | number | — | Reveal starting model scale (default 0). Range: 0–1. |
| `vignette` | number | — | Vignette intensity (0 = none, 1 = full). Range: 0–1. |
| `aspect` | string | `"16:9"` | Video aspect ratio: `"16:9"`, `"1:1"`, `"9:16"` |
| `bgColor` | string | `"#ffffff"` | Background hex color, e.g. `"#ff8800"` (6-digit hex, must include `#`) |
| `bgImageId` | string | — | Overlay asset ID for background image (overrides `bgColor`). See [Overlays](#overlays). |
| `bgAlign` | string | `"top-left"` | Crop anchor when background image doesn't match output aspect: `"top-left"`, `"top-center"`, `"top-right"`, `"center-left"`, `"center"`, `"center-right"`, `"bottom-left"`, `"bottom-center"`, `"bottom-right"` |
| `length` | number | `6` | Video duration in seconds: greater than `0`, up to `120` (ProRes 4444: up to `60`; admin accounts uncapped). Decimal OK, e.g. `4.7`. Credits = tier rate × seconds, 1-credit minimum. |
| `animation` | string | — | Animation clip name to play. Discover a model's clips via `GET /v1/models/:id` → `animations` (`[{ name, duration, channels }]`, seconds); `animationCount` appears on list rows. Use `"*"` for the first clip. |
| `animationSpeed` | number | `1` | Animation playback speed multiplier, −10 to 10, non-zero (e.g. `0.5` for half speed, `2` for double; negative values play the clip in reverse). |
| `shadows` | boolean | `true` | Enable ground shadows |
| `reflector` | boolean | `false` | Enable reflective ground plane |
| `easing` | boolean | `true` | Ease in/out on turntable rotation |
| `strictCredits` | boolean | `false` | Refuse the free-credit fallback: when paid credits are insufficient, fail with `402 no_credits` instead of silently funding the render from the free pool and flagging it `isPreview` (a legacy path — current signups don't accrue free-pool credits, see [Credits](#render-credits)). Recommended for unattended pipelines. In a batch, one strict item that would fall to free credits fails the whole (atomic) batch. |
| `rotateY` | number | `0` | Turntable camera starting angle in degrees (-180 to 180). Determines the initial camera direction before rotation begins. Does not rotate the model. |
| `tiltX` | number | `0` | Model pitch in degrees (-180 to 180), applied in the yawed frame — see [Model Orientation](#model-orientation). Useful for angling products in portrait aspect. Bypasses shadow caching. |
| `panX` | number | `0` | Model yaw in degrees (-180 to 180). Applied **first**, before `tiltX` and `rollZ`. |
| `rollZ` | number | `0` | Model roll around the Z axis in degrees (-180 to 180). Applied last. |
| `zoom` | number | `1.0` | Camera zoom factor (0.5 to 2.0): values **above 1 move the camera closer**, below 1 move it farther (distance = base ÷ zoom). |
| `output` | string | `"video"` | `"video"` or `"dataset"` — datasets also take `datasetQuality` and `coverage`; see [Dataset Export](#dataset-export-vision-training) |
### Advanced (Studio) settings
`renderSettings` accepts the **full** set of settings the Studio editor can
author — the same validator backs both surfaces. The fields above cover the
common cases; the following are also accepted (all optional). Invalid values
are rejected with `invalid_settings`.
> **Server-set keys.** `preview` and `thumbOnly` are stamped by the server and
> rejected if sent. Note that `renderSettings` objects echoed back by
> `GET /v1/renders` can contain a server-stamped `preview: true` (free-pool-funded
> renders) — strip it before re-submitting those settings to a new render.
| Group | Fields |
|---|---|
| **Filter / post** | `filter` (`none`/`sepia`/`bw`/`vivid`/`warm`/`cool`/`faded`/`noir`/`grain`/`toon`), `bloomEnabled`, `bloomStrength` (0–2), `dofEnabled`, `dofStrength` (0–1), `n8aoEnabled`, `n8aoIntensity` (0.5–32), `swayEnabled`, `swayIntensity` (0–2) |
| **Shadows & reflector** | `shadowMode` (`shader`/`flat`/`accumulated`), `shadowDarkness` (0–5), `shadowSoftness` (0–1), `reflectorMix` (0–1), `reflectorFade` (0.05–5), `reflectorDepthBlur` (0–10) |
| **Environment / IBL** | `envId` (mint via [`POST /v1/assets/envs`](#environments-envid) — a bad id hard-fails the render with a refund), `envRotation` (−180–180), `envExposure` (0.0156–4), `envMode` (`grounded`/`lighting`/`floating`), `envOpacity` (0–1), `envBlur` (0–1), `envScale` (0.25–4), `envTint` (hex), `envTintStrength` (0–1) |
| **Lighting** | `spotlightIntensity` (0–32), `lightsIntensity` (0–32), `ambientIntensity` (0–16), `glossIntensity` (0–16), `keyLightColor` (hex), `fillLightColor` (hex) |
| **Model** | `hiddenMeshNames` (string[], ≤512, each ≤256 chars) — see [Mesh Exclusions](#mesh-exclusions) |
| **Background** | `bgGradient` (`{ stops: [{ color }] (2–8), angle: 0–359 }` — both keys required when present) |
| **Materials** | `materialEdits` — object keyed by material name (≤256 keys, ≤32 KB serialized); each value takes `color`, `roughness`, `metalness`, `opacity`, `alphaMode`, `emissive`/`emissiveIntensity`, `aoMapIntensity`, `envMapIntensity`, `side`, `flatShading`, `wireframe`, `normalScale`, `bumpScale`, `displacementScale`, `transmission`, `clearcoat`/`clearcoatRoughness`, `sheen`/`sheenRoughness`/`sheenColor`, `iridescence`/`iridescenceIOR`, `specularIntensity`/`specularColor`, `thickness`, `ior`, `attenuationColor`, `textureMaps` toggles, `mapAssetId` (mint via [`POST /v1/assets/textures`](#textures-materialeditsnamemapassetid) — a wrong id silently keeps the original map) |
| **Decals** | `decals` — array (≤64) of `{ id, imageAssetId, position:[x,y,z], normal:[x,y,z], euler:[x,y,z], size, imageAspect? (>0–100) }` |
| **Keyframe camera** (`effect: "keyframes"`) | `keyframes` (array 1–64 of `{ pose: { position, lookAt, fov }, dwell?, easing? }` — all three `pose` fields required on every keyframe), `camera` (`{ target, radius, theta, phi, fov }` — all five required when present), `startAngle`/`endAngle`, `startTilt`/`endTilt`, `startZoom`/`endZoom`, `startFov`/`endFov`, `easeInSeconds`/`easeOutSeconds`, `kfSegmentDuration`, `loop` |
### Model Orientation
`panX`, `tiltX`, and `rollZ` rotate the **model**; `rotateY` offsets where the **camera** starts its orbit — the two are independent. The three model rotations compose in a fixed order — yaw first, then pitch in the yawed frame, then roll:
1. `panX` — yaw around the vertical axis (turn the model left/right)
2. `tiltX` — pitch around the yawed X axis (lean it forward/back)
3. `rollZ` — roll around the resulting Z axis
`zoom` scales the camera distance: `2.0` = twice as close, `0.5` = twice as far (distance = base ÷ zoom).
```bash
curl -X POST https://api.dx.gl/v1/renders \
-H "Authorization: Bearer dxgl_sk_..." \
-H "Content-Type: application/json" \
-d '{
"modelId": "Ab3kF9x2qL1m",
"renderSettings": {
"quality": "share",
"panX": 35,
"tiltX": -12,
"rotateY": 90,
"zoom": 1.3
}
}'
```
This yaws the model 35°, leans it toward the camera by 12°, starts the turntable orbit a quarter-turn around, and moves the camera 30% closer. A common product-shot recipe: `panX` to face the label, a small negative `tiltX` for a heroic angle, `zoom` 1.2–1.5.
### Mesh Exclusions
`hiddenMeshNames` hides named meshes for the duration of the render — useful for packaging variants, alternate trims, or scan scaffolding:
```json
{
"modelId": "Ab3kF9x2qL1m",
"renderSettings": {
"quality": "share",
"hiddenMeshNames": ["Packaging", "Stand_Base", "Cap_Alt"]
}
}
```
Names must match the mesh names in the GLB **exactly** (case-sensitive). Unknown names are a **silent no-op** — the render succeeds without hiding anything — so verify names against your source file. Up to 512 names, each ≤256 characters. Hidden meshes are also excluded from ground shadows and reflections.
> **Discovering names:** `GET /v1/models/:id` returns `meshNames` (the render-time mesh names, exactly as `hiddenMeshNames` matches them) and `materialNames` (the keys `materialEdits` matches). `null` means the model predates inventory extraction — re-upload, or ask us to run the backfill. Note mesh names are the *runtime* names (spaces become `_`, duplicate names gain `_1`/`_2` suffixes), which can differ from what your authoring tool shows.
### Quality Tiers
| JSON value | Display label | Resolution | Codec | Alpha | Credits / sec | Output |
|---|---|---|---|---|---|---|
| `share` | Standard | 960×540 | H.264 MP4 | No | 1 | `.mp4` |
| `standard` | HD (default when `quality` omitted) | 1920×1080 | H.264 MP4 | No | 4 | `.mp4` |
| `4k` | 4K | 3840×2160 | H.264 MP4 | No | 16 | `.mp4` |
| `pro` | ProRes 4444 | 3840×2160 | ProRes 4444 | Yes | 64 | `.mov` |
> The JSON enum values (`share`/`standard`/`4k`/`pro`) are frozen for API stability. The display labels were renamed in a later UX pass; use the JSON values shown in the first column when making API calls.
`pro` renders always include an alpha channel — the `bgColor` is applied only to the web/thumbnail/poster variants. The `.mov` file has a transparent background for compositing in professional editors (DaVinci Resolve, After Effects, Final Cut Pro).
Video is billed **per second** — a render's cost is the tier rate above × its length in seconds. The default length is 6s, so a 6s `share` turntable = 6 credits, and a 6s render with `quality` omitted (= `standard`/HD) = 24 credits. All tiers render with GPU-accelerated 2× supersampling (SSAA) for anti-aliased edges.
> **Note:** New accounts receive a 100-credit signup bonus. The bonus lands in the regular credit balance and behaves identically to purchased credits — no restrictions, any quality tier.
### Dataset Export (Vision Training)
Set `output: "dataset"` with `datasetQuality` to generate a NeRF/3DGS-ready training dataset. Three tiers:
| Tier (`datasetQuality`) | Views | Resolution | Credits (flat) |
|---|---|---|---|
| `100x800` | 100 | 800×800 | 40 |
| `196x1024` | 196 | 1024×1024 | 160 |
| `400x2048` | 400 | 2048×2048 | 640 |
Use `coverage: "hemisphere"` (default) or `coverage: "sphere"` for full sphere viewpoints.
Output ZIP contains:
- `images/` — RGB PNG frames (composited on white background)
- `depth/` — 8-bit grayscale depth PNGs (closer = darker, farther = lighter). Tight near/far planes maximize precision across the model's actual depth span.
- `depth_16bit/` — 16-bit grayscale depth PNGs (65,536 levels of precision for surface reconstruction)
- `normals/` — world-space normal map PNGs
- `masks/` — foreground/background alpha mask PNGs
- `transforms.json` — Camera intrinsics + per-frame 4×4 transform matrices (instant-ngp / nerfstudio format). Includes `depth_near` and `depth_far` for decoding: `depth = pixel_value/255 * (depth_far - depth_near) + depth_near`
- `overview.webp` — 4-quadrant contact sheet (10×10 grid of all views across RGB, depth, normals, masks)
```
POST /v1/renders
{ "modelId": "Ab3kF9x2qL1m", "renderSettings": { "output": "dataset", "datasetQuality": "100x800" } }
```
**Quick start:** Unzip the dataset and train a 3D Gaussian Splat with [nerfstudio](https://docs.nerf.studio/):
```bash
unzip dataset.zip -d mymodel
ns-train splatfacto --data ./mymodel \
--max-num-iterations 15000 \
--pipeline.model.sh-degree 3 \
--pipeline.model.background-color white
```
The `background-color white` flag is required because images are composited on a white background.
---
## Render Status
Renders progress through these statuses:
```
pending → poster-processing → video-processing → done
```
Any status can transition to `error` if the render fails. Poll for `done` or `error`; treat the intermediate statuses as opaque "in progress" states.
| Status | Description |
|---|---|
| `pending` | Queued, waiting for a worker to pick it up (cancellable) |
| `poster-processing` | Worker is rendering frames, poster extracted |
| `poster-done` | Recovery state: poster uploaded but the video pass was interrupted — a worker will resume it (not part of the happy path) |
| `video-processing` | Video being encoded (ffmpeg) |
| `done` | All assets ready (video, poster, thumbnail) |
| `error` | Render failed |
| `failed` | An errored render you dismissed via `PATCH /v1/renders/:id/dismiss` |
---
## Overlays
Overlay assets are reusable background (or foreground) images for renders. Upload once, reference by ID in render settings. System overlays (published by admins) are available to all users.
### Upload Overlay
```
POST /v1/overlays
Content-Type: multipart/form-data
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `file` | file | — | Image file (PNG, JPEG, WebP; max 25 MB) |
| `layer` | string | `"background"` | `"background"`, `"foreground"`, `"decal"` (referenced from `decals[].imageAssetId`), or `"texture"` (referenced from `materialEdits.<name>.mapAssetId` for base-color swaps). Any other value is rejected with `400 bad_request`. |
| `name` | string | — | Display name (max 100 chars) |
| `description` | string | — | Short description (max 500 chars) |
| `alignment` | string | `"top-left"` | Crop anchor when image doesn't match output aspect ratio |
| `opacity` | number | `1` | Overlay opacity, 0–1 (background/foreground compositing) |
**Response `200`:**
```json
{ "data": { "id": "abc123DEF456", "layer": "background", "alignment": "top-left", "name": "My Gradient" } }
```
List items additionally carry `fileSize`, `width`, `height`, `opacity`, `filename`, `isSystem`, and `createdAt`.
Uploading requires the `render` scope (listing requires `read`). Each account can hold up to **100 overlay assets** — at the cap, uploads return `429 limit_reached`; delete unused assets to free slots.
### List Overlays
```
GET /v1/overlays
GET /v1/overlays?layer=background
```
Returns your overlay assets plus all published system overlays.
### Delete Overlay
```
DELETE /v1/overlays/:id
```
Deletes an overlay you own. Removes the image from storage.
### Using Overlays in Renders
Pass the overlay `id` as `bgImageId` in render settings:
```json
{ "renderSettings": { "bgImageId": "abc123DEF456", "aspect": "16:9", "length": 6 } }
```
When `bgImageId` is set, `bgColor` is ignored. The image is scaled to cover the output dimensions and cropped from the `bgAlign` anchor (default: `top-left`), keeping logos safe across all aspect ratios.
### Resolve by SHA-256
```
POST /v1/overlays/resolve
{ "sha256": "<64-char hex>", "layer": "decal" }
```
Pre-flight for bulk pipelines: returns `{ "id": "...", "exists": true }` if this exact source content already exists as an asset of that layer in the workspace (uploads record a content sha) — skip the upload and reuse the id instead of re-uploading into the 100-asset cap. `layer` defaults to `background`; `texture` works too.
---
## Render Assets (Textures & Environments)
Beyond overlays, `renderSettings` references two more asset kinds — both mintable over the API. Assets are workspace-scoped: they resolve in the workspace's renders (see [Workspace-bound keys](#workspace-bound-keys)).
### Textures (`materialEdits.<name>.mapAssetId`)
```
POST /v1/assets/textures multipart: file (PNG/JPEG/WebP ≤ 25 MB), name?
GET /v1/assets/textures
DELETE /v1/assets/textures/:id
```
`POST` returns `201 { "id": ... }` — pass the id as `materialEdits.<materialName>.mapAssetId` to replace that material's base-color map. Re-POSTing identical bytes returns `200` with `deduped: true` and the existing id (content-sha dedup, per workspace). Quota: **100 textures / 500 MB per account**; at the cap uploads return `429 limit_reached`.
> **Failure mode:** a wrong or missing `mapAssetId` **silently degrades** — the render keeps the model's original map (deliberate stale-edit tolerance). Verify the id (it's echoed in `renderSettings`) and the material name (`GET /v1/models/:id` → `materialNames`, exact match).
### Environments (`envId`)
```
POST /v1/assets/envs multipart: sdr, gainmap, metadata, thumb?, name?, width?, height?
GET /v1/assets/envs
DELETE /v1/assets/envs/:id
```
Custom HDRI environments use the **pre-encoded gainmap triple** — the exact output of [gainmap-js](https://github.com/MONOGRID/gainmap-js) `encodeAndCompress` (the same encoder Studio runs in the browser): `sdr` (WebP), `gainmap` (WebP), `metadata` (JSON), plus an optional small `thumb` WebP. There is no server-side HDR→gainmap encoder — encode your `.hdr`/`.exr` with gainmap-js, or upload it through Studio's Environment panel and reuse the id. Parts are validated on upload (WebP magic, JSON metadata); caps: 48 MB per part, **20 environments / 200 MB per account** (`429 limit_reached` at the cap).
`POST` returns `201 { "id": ... }` — pass it as `envId` in render settings, with `envMode`/`envRotation`/`envExposure` etc. controlling how it's used.
> **Failure mode (asymmetric with textures):** a bad `envId` **hard-fails** the render — the worker can't fetch the environment, the render errors with `failureCode: "env_download_failed"`, and the credits are refunded. Textures degrade silently; environments fail loudly.
---
## Account
```
GET /v1/account
```
Returns the authenticated user's account info and credit balance.
```bash
curl https://api.dx.gl/v1/account \
-H "Authorization: Bearer dxgl_sk_..."
```
```json
{
"data": {
"id": "Ab3kF9x2qL1m",
"email": "[email protected]",
"credits": 100,
"paid": 100,
"free": 0,
"total": 100,
"scopes": ["read", "render"],
"workspace": null
}
}
```
| Field | Type | Description |
|---|---|---|
| `credits` | integer | Spendable credit balance (unchanged; equals `paid`) |
| `paid` | integer | Ordinary render credits — the signup bonus and any purchases land here |
| `free` | integer | Legacy free-credit grant type; **normally `0`** (accounts, including the signup bonus, are funded with ordinary `paid` credits) |
| `total` | integer | `paid + free` — matches what `/quote` reports as available |
| `scopes` | array | The scopes granted to the token making this request |
| `workspace` | object | The workspace this key is bound to (`{ id, name }`), or `null` for a personal key. For a bound key the balances above are the **workspace owner's** pool — the one that funds this key's renders. |
---
## Quote
Estimate the credit cost for a set of renders before committing. Useful for agents and scripts that need to budget credits across multiple models.
```
POST /v1/quote
Content-Type: application/json
```
```json
{
"renders": [
{ "quality": "standard", "length": 12 },
{ "quality": "4k" },
{ "output": "dataset", "datasetQuality": "196x1024" }
]
}
```
Each item in the `renders` array accepts the cost-relevant render fields: `quality`, `output`, `datasetQuality`, and `length` (video duration in seconds; defaults to 6). Two legacy aliases are also honored: `videoLength` (equivalent to `length`) and `effect: "dataset"` (equivalent to `output: "dataset"`). Video cost = tier rate × length; datasets are flat per set.
**Response** `200`:
```json
{
"data": {
"creditsRequired": 304,
"creditsAvailable": 500,
"sufficient": true,
"breakdown": [
{ "quality": "standard", "length": 12, "credits": 48 },
{ "quality": "4k", "length": 6, "credits": 96 },
{ "datasetQuality": "196x1024", "credits": 160 }
]
}
}
```
| Field | Type | Description |
|---|---|---|
| `creditsRequired` | integer | Total credits needed for all renders |
| `creditsAvailable` | integer | Current credit balance (paid + free) |
| `sufficient` | boolean | Whether the account has enough credits |
| `breakdown` | array | Per-item credit cost |
Maximum 100 items per quote request.
```bash
curl -X POST https://api.dx.gl/v1/quote \
-H "Authorization: Bearer dxgl_sk_..." \
-H "Content-Type: application/json" \
-d '{"renders": [{"quality": "4k"}, {"quality": "4k"}, {"quality": "4k"}]}'
```
---
## Billing
Read-only endpoints for discovering credit packs and reconciling spend.
Purchasing itself happens in the web app (Stripe checkout), not over the API.
### Products
```
GET /v1/products
```
Lists the purchasable credit packs.
```json
{
"data": [
{ "id": "…", "name": "1,000 credits", "credits": 1000, "priceCents": 7900, "stripePriceId": "price_…" }
]
}
```
### Purchases
```
GET /v1/purchases?limit=50&offset=0
```
Returns the caller's credit ledger (purchases, unlocks, refunds, grants),
newest first, scoped to the token's account.
| Param | Type | Description |
|---|---|---|
| `limit` | integer | Max 100, default 50 |
| `offset` | integer | Pagination offset |
```json
{
"data": [
{
"id": "…",
"createdAt": "2026-02-15T20:00:01.000Z",
"type": "purchase",
"credits": 1000,
"priceCents": 7900,
"source": "storefront",
"description": "1,000 credit pack",
"renderId": null
}
],
"meta": { "total": 12, "offset": 0, "limit": 50 }
}
```
`type` is one of `purchase`, `unlock`, `render`, `refund`, `promo`, `referral`,
`admin_grant`, `signup_bonus`. `renderId` is set for entries tied to a specific
render (e.g. an unlock), otherwise null.
---
## Health Check
```
GET /v1/health
```
No authentication required. Returns database connectivity status.
---
## Render Credits
Video is charged per second of output (cost = rate × seconds); datasets are a flat cost per set:
| Quality | Credits / sec |
|---|---|
| `share` | 1 |
| `standard` | 4 |
| `4k` | 16 |
| `pro` | 64 |
New accounts include a **100-credit signup bonus**, added to the regular credit balance — no restrictions, any quality tier. Credits never expire.
Batch renders deduct credits atomically based on the sum of all items' quality costs. If there aren't enough credits, the entire batch fails.
When credits are exhausted, render requests return `402` with error code `no_credits`.
**Free-pool fallback — for unattended runs.** Accounts can additionally hold a separate *free* credit pool (support grants and refunds of free-funded renders; current signups don't accrue it). If the paid balance can't cover a render but the free pool can, the render is funded from the free pool and flagged `isPreview: true` — the request still succeeds with HTTP 201. Unattended pipelines that want deterministic billing should either check `isPreview` on the response, or set `strictCredits: true` in `renderSettings` to hard-fail with `402 no_credits` instead of falling back.
---
## Rate Limits
Requests are rate-limited **per token account**. When a limit is hit, the API
returns `429` with error code `rate_limited` and a `Retry-After` header (in
seconds) — honor it and retry. Current defaults (subject to tuning):
| Scope | Limit |
|---|---|
| All authenticated requests | 300 / minute |
| `POST /v1/models/ingest` (URL fetches) | 20 / 10 minutes |
Batch endpoints make high throughput cheap within these limits — one
`POST /v1/renders/batch` call submits up to 100 renders.
Independent of request-rate limits, the following hard caps apply today:
| Limit | Value |
|---|---|
| Upload / ingest file size | 1 GB |
| Ingest download timeout | 45 s without progress (10 min absolute) |
| Overlay image size | 25 MB |
| Overlay assets per account | 100 |
| Renders per batch (`POST /v1/renders/batch`) | 100 |
| Items per quote (`POST /v1/quote`) | 100 |
| List page size (`limit`) | 100 |
---
## Errors
| Code | Status | Description |
|---|---|---|
| `unauthorized` | 401 | Missing, invalid, or revoked token |
| `token_expired` | 401 | Token has expired |
| `forbidden` | 403 | Account suspended, or operating on a resource you don't own (e.g. deleting a system overlay) |
| `insufficient_scope` | 403 | Token lacks required scope |
| `no_file` | 400 | No file in upload request |
| `bad_request` | 400 | Malformed request (e.g. no updatable fields in a PATCH, invalid overlay upload, invalid `cursor`/`updatedSince`, unknown `asset` param) |
| `invalid_format` | 400 | File extension not accepted (`.glb` / `.zip`) |
| `invalid_url` | 400 | Malformed URL |
| `invalid_url_scheme` | 400 | Ingest URL must be http(s) |
| `url_error` | 400 | Ingest URL could not be fetched |
| `download_timeout` | 400 | Ingest download stalled (45 s) or exceeded the 10-minute ceiling |
| `url_required` | 400 | Missing `url` field |
| `invalid_settings` | 400 | Invalid renderSettings value |
| `invalid_length` | 422 | Video length outside the allowed range for the tier |
| `model_id_required` | 400 | Missing `modelId` field |
| `file_too_large` | 400 | File exceeds the 1 GB limit |
| `no_credits` | 402 | No render credits remaining |
| `model_not_found` | 404 | Model does not exist |
| `render_not_found` | 404 | Render does not exist |
| `dataset_not_found` | 404 | Dataset ZIP not available (render not `done` or not a dataset render) |
| `not_found` | 404 | Unknown endpoint, HLS asset not ready, or resource missing (e.g. overlay on DELETE) |
| `asset_not_found` | 404 | Requested asset variant was not produced for this render (`/download-url`) |
| `rate_limited` | 429 | Rate limit hit — honor the `Retry-After` header (see [Rate Limits](#rate-limits)) |
| `render_started` | 409 | Cancel refused — the worker already claimed the job |
| `render_in_flight` | 409 | Delete refused — render is queued or in progress (cancel instead) |
| `renders_required` | 400 | Missing or empty renders array |
| `too_many` | 400 | Batch exceeds 100 renders |
| `upload_limit` | 429 | Free upload limit reached |
| `limit_reached` | 429 | Overlay asset cap (100) reached |
| `video_not_found` | 404 | Video not yet available |
| `poster_not_found` | 404 | Poster not yet available |
| `thumb_not_found` | 404 | Thumbnail not yet available |
| `file_not_found` | 404 | Model file not found in storage |
| `sha256_required` | 400 | Missing `sha256` field |
| `timeout` | 408 | URL pre-flight (HEAD) timed out |
| `upload_failed` | varies | Upload could not be processed (message has detail) |
| `ingest_failed` | varies | URL ingest failed (message has detail) |
| `internal` | 500 | Internal server error |
---
## Quick Start
```bash
# Upload a GLB model and start rendering
curl -X POST https://api.dx.gl/v1/models \
-H "Authorization: Bearer dxgl_sk_..." \
-F "[email protected]" \
-F 'renderSettings={"aspect":"16:9","bgColor":"#ffffff","length":6}'
# Upload a 3D scan (OBJ+MTL+textures in a ZIP)
curl -X POST https://api.dx.gl/v1/models \
-H "Authorization: Bearer dxgl_sk_..." \
-F "[email protected]" \
-F 'renderSettings={"aspect":"1:1","bgColor":"#ffffff","length":9}'
# Create a 4K render (16 credits/sec — default 6s = 96 credits)
curl -X POST https://api.dx.gl/v1/renders \
-H "Authorization: Bearer dxgl_sk_..." \
-H "Content-Type: application/json" \
-d '{"modelId":"Ab3kF9x2qL1m","renderSettings":{"quality":"4k","aspect":"16:9","bgColor":"#ffffff"}}'
# Create a Pro render (ProRes 4444 with alpha, 64 credits/sec — default 6s = 384 credits)
curl -X POST https://api.dx.gl/v1/renders \
-H "Authorization: Bearer dxgl_sk_..." \
-H "Content-Type: application/json" \
-d '{"modelId":"Ab3kF9x2qL1m","renderSettings":{"quality":"pro","aspect":"16:9","bgColor":"#000000"}}'
# Check render status
curl https://api.dx.gl/v1/renders/Xz7pQ4w8nR2k \
-H "Authorization: Bearer dxgl_sk_..."
# Download the video when done
curl -o chair.mp4 https://api.dx.gl/v1/renders/Xz7pQ4w8nR2k/video \
-H "Authorization: Bearer dxgl_sk_..."
# Download all assets as a zip
curl -o chair.zip https://api.dx.gl/v1/renders/Xz7pQ4w8nR2k/bundle \
-H "Authorization: Bearer dxgl_sk_..."
```
### Python Example
```python
import requests, time
API = "https://api.dx.gl/v1"
HEADERS = {"Authorization": "Bearer dxgl_sk_..."}
# Upload
with open("chair.glb", "rb") as f:
r = requests.post(f"{API}/models", headers=HEADERS,
files={"file": f},
data={"renderSettings": '{"aspect":"16:9","length":6}'})
render_id = r.json()["data"]["renderId"]
# Poll
while True:
r = requests.get(f"{API}/renders/{render_id}", headers=HEADERS)
status = r.json()["data"]["status"]
if status == "done": break
if status == "error": raise Exception("Render failed")
time.sleep(5)
# Download
r = requests.get(f"{API}/renders/{render_id}/video", headers=HEADERS)
with open("chair.mp4", "wb") as f:
f.write(r.content)
```
### Node.js Example
```javascript
const fs = require('fs');
const API = 'https://api.dx.gl/v1';
const headers = { 'Authorization': 'Bearer dxgl_sk_...' };
// Upload (Node 20+: openAsBlob pairs with the built-in fetch/FormData —
// a ReadStream would be stringified by the built-in FormData, not streamed)
const form = new FormData();
form.append('file', await fs.openAsBlob('chair.glb'), 'chair.glb');
form.append('renderSettings', JSON.stringify({ aspect: '16:9', length: 6 }));
const upload = await fetch(API + '/models', { method: 'POST', headers, body: form });
const renderId = (await upload.json()).data.renderId;
// Poll
let status;
do {
await new Promise(r => setTimeout(r, 5000));
const res = await fetch(API + '/renders/' + renderId, { headers });
status = (await res.json()).data.status;
} while (status !== 'done' && status !== 'error');
// Download
const video = await fetch(API + '/renders/' + renderId + '/video', { headers });
fs.writeFileSync('chair.mp4', Buffer.from(await video.arrayBuffer()));
```