Order splats through the API
Another system commissions a capture, tracks it and imports the finished package — with no human passing through our interface. This page is the contract: the flow, the states, the webhooks and what comes out at the end.
It is meant for platforms, digital-twin software and viewers that show their own customers' spaces. The API is a side door, not the main one: if you capture with your phone yourself, you do not need it.
The flow, in four steps
Create, upload, complete, track. Every call carries a bearer token of your access; the examples write it as sk_…
Create
POST /api/v1/captures with a file name and a size, optionally a site, a name, your own reference (clientRef), a quality and a flow. The answer carries presigned parts for the upload.
Upload straight into the bucket
Every part goes by PUT to its presigned URL — past us. The control plane never sees the bytes; it only learns that they arrived.
Complete
POST /api/v1/captures/:id/complete with the ETags of the parts. From here it runs on its own: pre-check, start, review steps, training, collision mesh, package.
Track and fetch
Poll GET /api/v1/captures/:id until the state is done — or register a webhook. The package comes from GET /api/v1/captures/:id/result.
curl -X POST https://splatastic.com/api/v1/captures \
-H "Authorization: Bearer sk_…" -H "Content-Type: application/json" \
-d '{
"filename": "walkthrough.mp4",
"sizeBytes": 214748364,
"siteName": "Hall 12",
"clientRef": "acme:asset-42",
"quality": "standard",
"flow": "headless"
}'{
"id": "cap_…",
"siteId": "site_…",
"clientRef": "acme:asset-42",
"state": "uploading",
"upload": {
"uploadId": "up_…",
"key": "captures/cap_…/sources/….mp4",
"partSize": 16777216,
"partCount": 13,
"parts": [{ "partNumber": 1, "url": "https://…" }, "…"]
}
}curl -X POST https://splatastic.com/api/v1/captures/cap_…/complete \
-H "Authorization: Bearer sk_…" -H "Content-Type: application/json" \
-d '{
"uploadId": "up_…",
"parts": [{ "partNumber": 1, "etag": "\"…\"" }, "…"]
}'{
"id": "cap_…", "name": "Hall 12", "siteId": "site_…",
"clientRef": "acme:asset-42", "flow": "headless",
"state": "processing",
"stage": "training",
"progress": 0.62,
"etaSeconds": 480,
"needsReview": null,
"error": null,
"hasResult": false,
"createdAt": 1757500000000,
"updatedAt": 1757500600000
}The state machine: seven values
state is the contract. The stage next to it names the internal step and is purely informative — do not rely on it.
| state | Meaning |
|---|---|
uploading | No source registered in full yet — the upload is running or has not started. |
checking | The material has arrived; the pre-check is running or the automatic start is imminent. |
queued | A step is waiting for a slot in the queue or for a GPU pod to come up. |
processing | A step is actually computing. |
needs-review | A human is needed: a review step is waiting, the automatic start could not proceed, or the running step was halted. needsReview carries the reason, a text and a deep link. |
done | Delivered — GET …/result hands out the files. |
failed | A step failed; error carries the message. |
How much human the job needs
The flow you pass when creating decides whether an amber or red review verdict halts the run or passes through.
| flow | Meaning |
|---|---|
headless | The default. Starts by itself unless the pre-check is red; a green gate passes, amber or red halts and becomes needs-review. |
unattended | For a system that checks the result itself anyway: every gate passes, only an error halts. The verdict is still recorded in the capture’s metrics. |
auto | Like headless, but additionally halts at the splat gate. |
guided | Starts only on a click and halts at every review step — the path for a human at the interface. |
unattended is meant for a system that inspects the result itself anyway and only wants the splat and the collision mesh: it trades the gate for throughput. A run whose camera path was red still delivers — and can be recognised as such from its metrics.
Webhooks instead of polling
Every state change as a signed POST. Polling stays available next to it — a webhook is an addition, not a replacement.
curl -X POST https://splatastic.com/api/v1/webhooks \
-H "Authorization: Bearer sk_…" -H "Content-Type: application/json" \
-d '{ "url": "https://your-system.example/hooks/splatastic" }'
# → 201 { "id": "…", "url": "…", "secret": "…" } ← the secret is in this answer only{
"event": "capture.state",
"occurredAt": "2026-09-10T13:40:00.000Z",
"previousState": "processing",
"capture": {
"id": "cap_…", "name": "Hall 12", "clientRef": "acme:asset-42",
"flow": "headless",
"state": "needs-review",
"needsReview": {
"reason": "gate",
"text": "Die Kamerabahn wartet auf deine Prüfung.",
"url": "https://splatastic.com/aufnahmen/cap_…"
},
"error": null, "hasResult": false,
"createdAt": 1757500000000, "updatedAt": 1757500600000
}
}| Header | Meaning |
|---|---|
X-Splatastic-Event | The event of this delivery: capture.state, capture.reexported or capture.retention. Branch on this — the payloads have different shapes. |
X-Splatastic-Delivery | The id of this delivery, stable across every attempt — use it to deduplicate on your side. |
X-Splatastic-Timestamp | Unix seconds of this attempt; part of the signature. |
X-Splatastic-Signature | sha256=<hex HMAC-SHA256(secret, timestamp + "." + body)> |
const crypto = require('node:crypto')
function verify(secret, timestamp, rawBody, header) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')
const a = Buffer.from(header)
const b = Buffer.from(expected)
// timingSafeEqual throws on a length mismatch instead of just being false.
return a.length === b.length && crypto.timingSafeEqual(a, b)
}rawBody has to be the untouched request body, before any JSON parsing, and timestamp the value of X-Splatastic-Timestamp from the same request.
If your endpoint does not answer 2xx (or does not answer within ten seconds), we retry — after roughly one minute, five minutes, a quarter of an hour, an hour and six hours.X-Splatastic-Delivery stays the same across all of them, so you can deduplicate on it.
Always branch on X-Splatastic-Event: there is more than one event, and the payloads have different shapes. An event you do not know is one you discard — then you are done before it starts.
The delivery contract
What you get, and what is guaranteed about it. A package that breaks the contract is not delivered — better that than a payload your import cannot read.
| Field | Value | Meaning |
|---|---|---|
version | 1 | The version of the package. |
sourceType | "splatastic" | Where the package comes from. |
upAxis | "Y" | More than “Y points up”: the delivery is levelled to the floor. Where that estimate is uncertain — stairs, a ramp, a walk that traces a single line — the scene stays as it was reconstructed. |
metersPerUnit | 1 | The delivery is metric: one unit is one metre. A capture without any scale measurement is honestly not metric and is not delivered. |
identityTransform | true | Nothing is left to convert — the splat and the collision mesh carry the same bake. |
| File | Contents |
|---|---|
splat.ply | The splat itself. |
collision.glb | The collision mesh, in the same frame as the splat (also as collision.ply). |
meta.json | The delivery contract and the run’s metrics. |
package.zip | All of it in one file. |
{
"deliveryId": "del_…",
"builtAt": 1757500600000,
"expiresAt": 1757504200000,
"files": {
"splatPly": { "url": "https://…", "filename": "splat.ply", "sizeBytes": 214748364 },
"collisionGlb": { "url": "https://…", "filename": "collision.glb", "sizeBytes": 5242880 },
"metaJson": { "url": "https://…", "filename": "meta.json", "sizeBytes": 512 },
"packageZip": { "url": "https://…", "filename": "package.zip", "sizeBytes": 225000000 }
},
"meta": { "version": 1, "metersPerUnit": 1, "upAxis": "Y", "…": "…" },
"metrics": { "psnr": 27.3, "splatCount": 812000, "…": "…" }
}Beyond those, meta.json carries whatever else is known: provenance, quality preset, the run's metrics, and under areaCrop the level at which the delivered version was cropped to the area you actually walked. Since 17 September 2026 generous is the default— if you want the full scene, pass "areaCrop": "off" explicitly when creating the capture. What was really applied is in every package's meta.json; that is the reliable statement.
The URLs from …/result are pickup slips and expire after an hour: a permanently valid link would be a password that never expires. Copy the bytes; another call hands out fresh URLs at any time.
Error codes
An error answer always has the shape { "error": "<text>", "code": "<error code>" } —error is the sentence for a human, code is what your program branches on.
| code | When |
|---|---|
validation | A field in the call is missing or unknown. |
unsupported_type | File type not supported — that includes raw 360° camera files (.insv, .360). |
too_large | Larger than this instance’s upload limit. |
not_found | The site, capture, upload or webhook does not exist — or belongs to someone else. |
no_object_store | This instance has no object store; without one there is no direct upload. |
stage_conflict | The capture is not (or no longer) in the right step — an upload can be completed once, and nothing is deleted while a step is running. |
not_running | cancel while nothing is running. |
not_delivered | GET …/result before the capture is delivered, or the package is missing. |
frame_stale | The deliveryId of a writing call belongs to an earlier trained state. |
frame_mismatch | The bounds you sent land far away from the scene once converted back. |
edit_conflict | Someone saved after the revision you hold; the answer carries the current one. |
reexport_running | A re-export of this capture is already computing. |
reexport_unavailable | The working data has expired; another export would be a full recomputation from the source material. |
invalid_url | The webhook url is not a valid https:// address. |
Want to order splats from your own system?
Request access