Skip to main content
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.
  • 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.
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.
  • 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.
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.
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.
  • 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.
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 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 503s 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.
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 and scene.failed, with each payload field and the X-Splat-Signature header. See 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.
One error envelope, one scene shape
Breaking: a request that fails an endpoint’s schema answers 400 in the documented error envelope, 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.