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 withcameras,imagesandpoints3D, all.binor all.txt, SplatKing LiDAR exports included. Loose.binfiles 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 awide.mov, the photos are processed, so the zip is refused. - A zip under 5 photos is refused as before, but
detailsnow states"allowed": {"min": 20}, unless it holds a COLMAP model. - A zip whose file list can’t be read (ZIP64) launches as before.
/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. Seesfm.matcher. - Fixed:
GETandHEAD /v1/scenescounted each request twice against the per-minute request limit, so listing reached429 rate_limitedat about half the documented 300 a minute. Each request now counts once. - The spec’s
info.versionis1.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,/retrainand/cancel.GET /v1/usageand/thumbnailtake any key, as before.insufficient_scopejoins theerror.codelist, and each guarded operation in the spec documents its403.
/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.versionis1.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,ultraorcustom, the preset the scene was created or retrained with.nullwhen its stored parameters name none.psnr: mean PSNR in dB over the training images, set oncompleteand kept if the scene later fails. In-sample, so usually higher thanpsnr_holdout.training_time_seconds: seconds the GPU training stage took, set oncompleteand kept if the scene later fails. The whole job runs fromprocessing_started_attoprocessing_completed_at.processing_started_at: when processing was launched.processing_completed_at: when the scene was recorded complete, oncompleteonly.spz_size_bytes,sog_size_bytes,ply_size_bytes: each model file’s size, oncompletewhen the scene has that file.- The spec’s
info.versionis1.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-Limitnames the route’s real limit,300, or30on create,/processand/retrain, on every/v1response. It read100on authenticated responses and limited nothing. There is noX-RateLimit-Remaining.- Browsers on splat-3d.com can read
Retry-AfterandX-RateLimit-Limit: CORS now exposes them. - Every
/v1operation in the spec documents the429withRetry-AfterandX-RateLimit-Limit, andrate_limitedjoins theerror.codelist. - The spec’s
info.versionis1.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.DELETEis a soft delete, as in the dashboard: every call answers404for 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_unavailablewithRetry-After. A failed read used to answer404, a failed delete500. DELETEcan answer409 conflictwhen the scene’s status changes while it is being deleted. Try again.
400 invalid_input, where they used to be accepted. Nothing is created, claimed or charged.- A create,
PATCH,/processor/retrainbody with a key the endpoint doesn’t take, e.g.{"presets": "ultra"}.error.detailsnames each key as{"field": "presets", "message": "unrecognized key"}. These keys used to be dropped, so that body made astandardscene. - A
webhook_urlthat isn’thttps://(any letter case):http://(localhostincluded) andftp://are refused. Deliveries come from our servers, which can’t reach your machine: test through a tunnel’s https URL. - A
training_modelother thangsplat, e.g.fastgs. It used to be stored, or replaced bygsplat; every scene trains with gsplat. - A
training_tierother than its legacy aliasesfast,standard,quality,pro,hdandultra, e.g.ultraa. It used to make astandardscene.
- Create takes
nullfortitleandaddress, meaning none. It answered400 invalid_input. - A create whose
sfm,trainingoroutputfails a check, e.g.training.sh_degreeeortraining.iterations: 500, lists each problem inerror.detailsas{field, message}, as other400 invalid_inputanswers do. - The spec marks the create,
PATCH,/processand/retrainbodiesadditionalProperties: falseat the top level (create’ssfm,trainingandoutputstay open objects in the spec; the server refuses their unknown keys), listsgsplatas the onetraining_modeland thetraining_tieraliases, and giveswebhook_urlapatternrequiringhttps://.
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, andsfm.max_image_sizesets only the training resolution. - The floor applies too:
sfm.options.max_image_sizefrom400to511, which SfM accepts, is refused onstandard,qualityandultra, whose floor is512. /retraindrops an inheritedsfm.options.max_image_sizeorsfm.options.matcherthat the target tier refuses. Othersfm.optionskeys still carry over.
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./retraintakespreset(fast,standard,quality,ultra) besidequality_tier, priced as the tier it names (qualityispro). Sent together, they must name the same tier, or the request answers400 invalid_input.- Fixed: a
/retrainrefused before any charge (402,429 quota_exceeded, or a503before the launch was reserved) keeps no new scene and counts nothing against the scene-creation quota. It used to leave anuploadingversion; only429 queue_fullremoved it. - Fixed: an empty body on
/processor/retrainwithContent-Type: application/jsonmeans{}, as no body does. It answered400 invalid_inputsince 1.1.0. /processanswers its503s withservice_unavailable, notinternal_error. A/retrainwhose read of the source scene fails answers503 service_unavailable, not404.- The
/retraindescription 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.versionis1.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
webhookssection:scene.completedandscene.failed, with each payload field and theX-Splat-Signatureheader. See Webhooks for how to verify it. GET /v1/usage:gpu_seconds_usedandstorage_bytesare deprecated. Nothing counts them, so they read0.limits.gpu_secondsandlimits.storage_bytesare deprecated too: nothing enforces them.- The thumbnail is documented as
image/png, not JPEG. - The spec’s
info.versionis1.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 storedstatus, as the list andPATCHdo. It no longer answersprocessingorpreview_ready. Live progress is inprocessing_stageandprocessing_pct;preview_readyis now a boolean field. -
processing_stageandprocessing_pctarenullunless the scene is processing. A complete scene used to keep its last stage, e.g.compressingand98. -
List items have the shape
GET /v1/scenes/{id}returns (SceneDetail): they gainssim,psnr_holdout,ssim_holdout,processing_error,viewer_url,download_url,formatandlod_meta_url. -
Every scene carries
training_model,description,preview_readyandthumbnail_url. -
created_atandupdated_atare UTC with milliseconds (2026-04-13T12:00:00.000Z), not microseconds with+00:00. -
next_cursoris opaque: pass it back unchanged. Timestamp cursors still work. A cursor the API can’t read answers400 invalid_input, not500. -
A JSON body that doesn’t parse (malformed, or empty with
Content-Type: application/json) answers400 invalid_input, not500. -
A database failure while listing, creating or updating a scene answers
503 service_unavailable, not500 internal_error. The list carriesRetry-After. Create andPATCHdon’t: checkGET /v1/scenesbefore repeating a create. -
The OpenAPI spec declares
400and500wherever they can occur,402and502on/process, and402,413and502on/retrain.ErrorResponse.metadeclares the429fields (period,limit,field,current), and theerror.codedescription lists every code the API sends. -
The spec’s
info.versionis1.1.0. Endpoints stay under/v1.