> ## Documentation Index
> Fetch the complete documentation index at: https://docs.splat-3d.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Changelog

> Changes to the Splat3D API, newest first.

**API 1.7.0**

Photo zips under 20 photos and 360° videos refused before the charge

**Breaking for small photo sets:** `/process` and `/retrain` refuse a photo zip with fewer than 20 JPEG or PNG photos with `422 source_unusable`, before anything is charged. They used to take 5. `details` holds one violation: `[{"field": "photo_count", "value": 12, "allowed": {"min": 20}, "hint": "..."}]`. After `/process` the scene stays `uploading`: upload a zip with more photos, taken all around the subject, to the same upload URL and call `/process` again. `/retrain` creates no new version.

* A zip holding a COLMAP model skips SfM, so it still needs only 5: a `sparse/<model>/` folder with `cameras`, `images` and `points3D`, all `.bin` or all `.txt`, SplatKing LiDAR exports included. Loose `.bin` files at the zip's root are not a model.
* A SplatKing video export (one `wide.mov`, under 5 photos) runs as a video and isn't counted. With 5 to 19 photos beside a `wide.mov`, the photos are processed, so the zip is refused.
* A zip under 5 photos is refused as before, but `details` now states `"allowed": {"min": 20}`, unless it holds a COLMAP model.
* A zip whose file list can't be read (ZIP64) launches as before.

**Breaking for 360° video:** `/process` and `/retrain` refuse a 360° video at any length with `422 source_unusable`, before anything is charged. 360° video isn't supported yet: COLMAP has no equirectangular camera model and the trainer can't render one, so such a job can't succeed. A video whose frame, as shown, is 2:1 within 1% gets `[{"field": "video_aspect", "value": 2, "allowed": {"not": 2}, "hint": "..."}]`; one of another shape carrying Spherical Video V2 metadata (`sv3d`) gets `[{"field": "video_projection", "value": "spherical", "allowed": ["flat"], "hint": "..."}]`. A 360° video under 30 seconds gets this refusal, not `video_seconds`. A video whose header can't be read launches as before.

* A photo zip of more than 400 photos is thinned to 400, evenly spread by name, first and last kept. It used to keep 800. Videos still keep up to 800 frames.
* With no `sfm.options.matcher`, video of up to 200 frames (about 2 minutes 15 seconds) matches every pair of frames (`exhaustive`), as photos do. It used to match sequentially. Longer video still does. See [`sfm.matcher`](/guides/training-parameters#sfm-matcher).
* **Fixed:** `GET` and `HEAD /v1/scenes` counted each request twice against the per-minute request limit, so listing reached `429 rate_limited` at about half the documented 300 a minute. Each request now counts once.
* The spec's `info.version` is `1.7.0`.

**API 1.6.0**

Key scopes enforced; videos under 30 seconds refused before the charge

**Breaking for keys created as Read:** the API checks a key's scopes, which it used to ignore. A request whose key lacks the scope an endpoint needs answers `403 insufficient_scope`, before it reads or changes any scene, quota or credit balance. Keys created with Write or All (the default) are unaffected. See [Scopes](/authentication.md#scopes).

* `read:scenes` (Read): `GET /v1/scenes`, `GET /v1/scenes/{id}` and `/download`.
* `write:scenes` (Write): those, plus create, `PATCH`, `DELETE`, `/process`, `/retrain` and `/cancel`.
* `GET /v1/usage` and `/thumbnail` take any key, as before.
* `insufficient_scope` joins the `error.code` list, and each guarded operation in the spec documents its `403`.

**Breaking for short videos:** `/process` and `/retrain` refuse a video under 30 seconds with `422 source_unusable`, before anything is charged. `details` holds one violation: `[{"field": "video_seconds", "value": 12.4, "allowed": {"min": 30}, "hint": "..."}]`. After `/process` the scene stays `uploading`: upload a longer video to the same upload URL and call `/process` again. `/retrain` creates no new version.

* The length is read from the MP4 or MOV header. A video whose length can't be read that way (WebM, a fragmented MP4 that records no total length, a damaged header) launches as before.
* A SplatKing video export (a zip holding `wide.mov`) is not checked.
* The spec's `info.version` is `1.6.0`.

**API 1.5.0**

Scenes carry their preset, training time, processing times and model sizes

Scenes in the list, `GET` and `PATCH` answers carry new fields, all nullable. Not breaking, unless a client refuses unknown keys:

* `preset`: `fast`, `standard`, `quality`, `ultra` or `custom`, the preset the scene was created or retrained with. `null` when its stored parameters name none.
* `psnr`: mean PSNR in dB over the training images, set on `complete` and kept if the scene later fails. In-sample, so usually higher than `psnr_holdout`.
* `training_time_seconds`: seconds the GPU training stage took, set on `complete` and kept if the scene later fails. The whole job runs from `processing_started_at` to `processing_completed_at`.
* `processing_started_at`: when processing was launched. `processing_completed_at`: when the scene was recorded complete, on `complete` only.
* `spz_size_bytes`, `sog_size_bytes`, `ply_size_bytes`: each model file's size, on `complete` when the scene has that file.
* The spec's `info.version` is `1.5.0`.

**API 1.4.0**

SPZ is the default download; per-minute rate limits

**Breaking:** `GET /v1/scenes/{id}/download` without `format` sends the scene's SPZ when it has one, otherwise its SOG, then its PLY. It used to send SOG, then PLY. `X-Splat-Format` names the file sent. `Content-Disposition` now follows that file, not `format`: an SPZ or PLY comes as an attachment, a SOG doesn't. `SceneDetail.format` names what `download_url` sends, so it can be `spz`. Pass `?format=sog` to keep the old file. `format=sog`, `ply` and `spz` send the files they did before, but a `format=ply` that falls back to SOG is no longer an attachment, and a `format=sog` that falls back to PLY now is.

**Breaking for clients above it:** every `/v1` request counts against a per-minute limit: 300 requests per account (all its keys together), or per client IP for a request with no key or a key that matches no account, and within them 30 create, `/process` and `/retrain` requests per account. Past it the API answers `429 rate_limited` with `Retry-After: 60` and the limit in `meta.limit`, before it reads or changes any scene, quota or credit balance: repeat the request after `Retry-After`. Polling every 5 to 10 seconds stays well under it. The limits are approximate: a fast burst can pass one several times over before its first `429`. See [Rate limits](/authentication.md#rate-limits).

* `X-RateLimit-Limit` names the route's real limit, `300`, or `30` on create, `/process` and `/retrain`, on every `/v1` response. It read `100` on authenticated responses and limited nothing. There is no `X-RateLimit-Remaining`.
* Browsers on splat-3d.com can read `Retry-After` and `X-RateLimit-Limit`: CORS now exposes them.
* Every `/v1` operation in the spec documents the `429` with `Retry-After` and `X-RateLimit-Limit`, and `rate_limited` joins the `error.code` list.
* The spec's `info.version` is `1.4.0`.

**API 1.3.0**

DELETE answers 404 for other accounts' scenes; strict request bodies; sfm.options held to the tier envelope; preview\_ready removed; snake\_case launch answers; retrain fixes

**Breaking:** `DELETE /v1/scenes/{id}` on a scene another account owns answers `404 not_found`, with the same body as an unknown id. It used to answer `403 forbidden`, which told the caller the scene existed.

* `DELETE` is a soft delete, as in the dashboard: every call answers `404` for the scene at once, and a daily cleanup removes its files 24 to 48 hours later. An unfinished scene is still cancelled first, under the cancel refund rules.
* A database failure while deleting answers `503 service_unavailable` with `Retry-After`. A failed read used to answer `404`, a failed delete `500`.
* `DELETE` can answer `409 conflict` when the scene's status changes while it is being deleted. Try again.

**Breaking for loose request bodies:** these now answer `400 invalid_input`, where they used to be accepted. Nothing is created, claimed or charged.

* A create, `PATCH`, `/process` or `/retrain` body with a key the endpoint doesn't take, e.g. `{"presets": "ultra"}`. `error.details` names each key as `{"field": "presets", "message": "unrecognized key"}`. These keys used to be dropped, so that body made a `standard` scene.
* A `webhook_url` that isn't `https://` (any letter case): `http://` (`localhost` included) and `ftp://` are refused. Deliveries come from our servers, which can't reach your machine: test through a tunnel's https URL.
* A `training_model` other than `gsplat`, e.g. `fastgs`. It used to be stored, or replaced by `gsplat`; every scene trains with gsplat.
* A `training_tier` other than its legacy aliases `fast`, `standard`, `quality`, `pro`, `hd` and `ultra`, e.g. `ultraa`. It used to make a `standard` scene.

With the stricter bodies, not breaking:

* Create takes `null` for `title` and `address`, meaning none. It answered `400 invalid_input`.
* A create whose `sfm`, `training` or `output` fails a check, e.g. `training.sh_degreee` or `training.iterations: 500`, lists each problem in `error.details` as `{field, message}`, as other `400 invalid_input` answers do.
* The spec marks the create, `PATCH`, `/process` and `/retrain` bodies `additionalProperties: false` at the top level (create's `sfm`, `training` and `output` stay open objects in the spec; the server refuses their unknown keys), lists `gsplat` as the one `training_model` and the `training_tier` aliases, and gives `webhook_url` a `pattern` requiring `https://`.

**Breaking:** scenes no longer carry `preview_ready`. The API never served the preview it announced, and the pipeline no longer makes one, so it would read `false` on every new scene. Drop it from clients that require the key; `thumbnail_url` is the image to show while a scene processes. New scenes no longer pass through the `preview_*` statuses.

**Breaking:** `sfm.options.max_image_size` and `sfm.options.matcher` are held to the [tier envelope](/guides/training-parameters.md#per-tier-parameter-envelopes) of `sfm.max_image_size` and `sfm.matcher`. A create outside it answers `422 invalid_input`, its violation's `field` naming `sfm.options.max_image_size` or `sfm.options.matcher`, and makes no scene. It used to be accepted, so a `fast` scene could run full-resolution SfM (`0`) or `exhaustive` matching on a video.

* The flat key and the options key are checked separately: SfM reads `sfm.options`, and `sfm.max_image_size` sets only the training resolution.
* The floor applies too: `sfm.options.max_image_size` from `400` to `511`, which SfM accepts, is refused on `standard`, `quality` and `ultra`, whose floor is `512`.
* `/retrain` drops an inherited `sfm.options.max_image_size` or `sfm.options.matcher` that the target tier refuses. Other `sfm.options` keys still carry over.

Additive: launch answers carry snake\_case keys. Create answers `scene_id`, `id` and `upload_url`, `/process` answers `scene_id` and `id`, and `/retrain` answers `scene_id` beside `id`, plus `source_scene_id`, the scene it retrained. `sceneId` and `uploadUrl` stay, marked deprecated: read the snake\_case keys.

* `/retrain` takes `preset` (`fast`, `standard`, `quality`, `ultra`) beside `quality_tier`, priced as the tier it names (`quality` is `pro`). Sent together, they must name the same tier, or the request answers `400 invalid_input`.
* **Fixed:** a `/retrain` refused before any charge (`402`, `429 quota_exceeded`, or a `503` before the launch was reserved) keeps no new scene and counts nothing against the scene-creation quota. It used to leave an `uploading` version; only `429 queue_full` removed it.
* **Fixed:** an empty body on `/process` or `/retrain` with `Content-Type: application/json` means `{}`, as no body does. It answered `400 invalid_input` since 1.1.0.
* `/process` answers its `503`s with `service_unavailable`, not `internal_error`. A `/retrain` whose read of the source scene fails answers `503 service_unavailable`, not `404`.
* The `/retrain` description no longer says it cancels a run in progress: it never did. It makes a new scene and leaves the source as it is.
* The spec's `info.version` is `1.3.0`.

**API 1.2.0**

OpenAPI 3.1, webhooks in the spec

**Breaking for generated clients:** the spec is OpenAPI 3.1. It said `3.1.0` before but held 3.0 schemas. Nullable fields are type arrays (`"type": ["string", "null"]`), not `nullable: true`, and `SceneDetail.format` lists `null` in its enum. Regenerate clients from the new spec. Responses don't change.

* The spec has a `webhooks` section: [`scene.completed`](/api-reference/webhooks/scene-completed) and [`scene.failed`](/api-reference/webhooks/scene-failed), with each payload field and the `X-Splat-Signature` header. See [Webhooks](/api-reference/overview.md#webhooks) for how to verify it.
* `GET /v1/usage`: `gpu_seconds_used` and `storage_bytes` are deprecated. Nothing counts them, so they read `0`. `limits.gpu_seconds` and `limits.storage_bytes` are deprecated too: nothing enforces them.
* The thumbnail is documented as `image/png`, not JPEG.
* The spec's `info.version` is `1.2.0`.

**API 1.1.0**

One error envelope, one scene shape

**Breaking:** a request that fails an endpoint's schema answers `400` in the documented [error envelope](/api-reference/overview.md#error-responses), with code `invalid_input` and one `{field, message}` per problem in `error.details`. It used to answer `{"success": false, "error": {"issues": [...], "name": "ZodError"}}`. Read `error.details` where you read `error.issues`.

**Breaking:** one scene shape, status and cursor for every call.

* `GET /v1/scenes/{id}` returns the stored `status`, as the list and `PATCH` do. It no longer answers `processing` or `preview_ready`. Live progress is in `processing_stage` and `processing_pct`; `preview_ready` is now a boolean field.

* `processing_stage` and `processing_pct` are `null` unless the scene is processing. A complete scene used to keep its last stage, e.g. `compressing` and `98`.

* List items have the shape `GET /v1/scenes/{id}` returns (`SceneDetail`): they gain `ssim`, `psnr_holdout`, `ssim_holdout`, `processing_error`, `viewer_url`, `download_url`, `format` and `lod_meta_url`.

* Every scene carries `training_model`, `description`, `preview_ready` and `thumbnail_url`.

* `created_at` and `updated_at` are UTC with milliseconds (`2026-04-13T12:00:00.000Z`), not microseconds with `+00:00`.

* `next_cursor` is opaque: pass it back unchanged. Timestamp cursors still work. A cursor the API can't read answers `400 invalid_input`, not `500`.

* A JSON body that doesn't parse (malformed, or empty with `Content-Type: application/json`) answers `400 invalid_input`, not `500`.

* A database failure while listing, creating or updating a scene answers `503 service_unavailable`, not `500 internal_error`. The list carries `Retry-After`. Create and `PATCH` don't: check `GET /v1/scenes` before repeating a create.

* The OpenAPI spec declares `400` and `500` wherever they can occur, `402` and `502` on `/process`, and `402`, `413` and `502` on `/retrain`. `ErrorResponse.meta` declares the `429` fields (`period`, `limit`, `field`, `current`), and the `error.code` description lists every code the API sends.

* The spec's `info.version` is `1.1.0`. Endpoints stay under `/v1`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.