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, capture.retention and more — on request also capture.gate and capture.deleted, plus webhook.test for the probe. 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.
You choose which events you receive with events when registering. Without it you keep the existing ones; new events such as capture.gate (the capture waits for a human) andcapture.deleted only arrive if you name them. POST /api/v1/webhooks/:id/testsends a probe, GET …/deliveries shows the last 50 deliveries with their status.
Deliveries for one capture go out in order — a retried older one is never overtaken by a newer one. Still compare occurredAt before you overwrite a stored state: the order is a promise about sending, not about arriving.
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.
Code examples
The same calls in curl, TypeScript and Python — generated from the OpenAPI specification, so with exactly the paths and fields the API knows.
The specification itself is available without signing in at/api/v1/openapi.json (OpenAPI 3.1) — for client generators and to look up every response shape.
POST /api/v1/captures
Create a capture — with an Idempotency-Key, so a retry after a network error does not create a second one.
curl -X POST "https://splatastic.com/api/v1/captures" \
-H "Authorization: Bearer sk_…" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"filename": "rundgang.mp4",
"sizeBytes": 214748364,
"siteName": "Halle 12",
"clientRef": "acme:asset-42",
"quality": "standard",
"flow": "headless"
}'const res = await fetch('https://splatastic.com/api/v1/captures', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_…',
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
"filename": "rundgang.mp4",
"sizeBytes": 214748364,
"siteName": "Halle 12",
"clientRef": "acme:asset-42",
"quality": "standard",
"flow": "headless"
}),
})
if (!res.ok) {
const { error, code } = await res.json()
throw new Error(`${code}: ${error}`)
}
const body = await res.json()import uuid
import requests
res = requests.post(
"https://splatastic.com/api/v1/captures",
headers={"Authorization": "Bearer sk_…", "Idempotency-Key": str(uuid.uuid4())},
json={
"filename": "rundgang.mp4",
"sizeBytes": 214748364,
"siteName": "Halle 12",
"clientRef": "acme:asset-42",
"quality": "standard",
"flow": "headless",
},
timeout=30,
)
if not res.ok:
problem = res.json()
raise RuntimeError(f"{problem['code']}: {problem['error']}")
body = res.json()POST /api/v1/captures/{id}/complete
Complete the upload once every part went to its URL by PUT.
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": "\"…\""
}
]
}'const res = await fetch('https://splatastic.com/api/v1/captures/cap_…/complete', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_…',
'Content-Type': 'application/json',
},
body: JSON.stringify({
"uploadId": "up_…",
"parts": [
{
"partNumber": 1,
"etag": "\"…\""
}
]
}),
})
if (!res.ok) {
const { error, code } = await res.json()
throw new Error(`${code}: ${error}`)
}
const body = await res.json()import requests
res = requests.post(
"https://splatastic.com/api/v1/captures/cap_…/complete",
headers={"Authorization": "Bearer sk_…"},
json={
"uploadId": "up_…",
"parts": [
{
"partNumber": 1,
"etag": "\"…\"",
},
],
},
timeout=30,
)
if not res.ok:
problem = res.json()
raise RuntimeError(f"{problem['code']}: {problem['error']}")
body = res.json()GET /api/v1/captures/{id}
Read the state.
curl "https://splatastic.com/api/v1/captures/cap_…" \
-H "Authorization: Bearer sk_…"const res = await fetch('https://splatastic.com/api/v1/captures/cap_…', {
headers: {
Authorization: 'Bearer sk_…',
},
})
if (!res.ok) {
const { error, code } = await res.json()
throw new Error(`${code}: ${error}`)
}
const body = await res.json()import requests
res = requests.get(
"https://splatastic.com/api/v1/captures/cap_…",
headers={"Authorization": "Bearer sk_…"},
timeout=30,
)
if not res.ok:
problem = res.json()
raise RuntimeError(f"{problem['code']}: {problem['error']}")
body = res.json()GET /api/v1/captures
Your captures page by page — next is the cursor of the following page.
curl "https://splatastic.com/api/v1/captures?limit=50" \
-H "Authorization: Bearer sk_…"const res = await fetch('https://splatastic.com/api/v1/captures?limit=50', {
headers: {
Authorization: 'Bearer sk_…',
},
})
if (!res.ok) {
const { error, code } = await res.json()
throw new Error(`${code}: ${error}`)
}
const page = await res.json()import requests
res = requests.get(
"https://splatastic.com/api/v1/captures?limit=50",
headers={"Authorization": "Bearer sk_…"},
timeout=30,
)
if not res.ok:
problem = res.json()
raise RuntimeError(f"{problem['code']}: {problem['error']}")
page = res.json()GET /api/v1/captures/{id}/result
Fetch the result.
curl "https://splatastic.com/api/v1/captures/cap_…/result" \
-H "Authorization: Bearer sk_…"const res = await fetch('https://splatastic.com/api/v1/captures/cap_…/result', {
headers: {
Authorization: 'Bearer sk_…',
},
})
if (!res.ok) {
const { error, code } = await res.json()
throw new Error(`${code}: ${error}`)
}
const body = await res.json()import requests
res = requests.get(
"https://splatastic.com/api/v1/captures/cap_…/result",
headers={"Authorization": "Bearer sk_…"},
timeout=30,
)
if not res.ok:
problem = res.json()
raise RuntimeError(f"{problem['code']}: {problem['error']}")
body = res.json()POST /api/v1/webhooks/{id}/test
Send a test delivery to a registered webhook.
curl -X POST "https://splatastic.com/api/v1/webhooks/cap_…/test" \
-H "Authorization: Bearer sk_…"const res = await fetch('https://splatastic.com/api/v1/webhooks/cap_…/test', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_…',
},
})
if (!res.ok) {
const { error, code } = await res.json()
throw new Error(`${code}: ${error}`)
}
const body = await res.json()import requests
res = requests.post(
"https://splatastic.com/api/v1/webhooks/cap_…/test",
headers={"Authorization": "Bearer sk_…"},
timeout=30,
)
if not res.ok:
problem = res.json()
raise RuntimeError(f"{problem['code']}: {problem['error']}")
body = res.json()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. That holds for a missing token and for the rate limit too: at most 600 requests per minute per access, above that429 with Retry-After.
| 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, or it points to an internal or private network. |
unauthorized | No token, or an invalid one. |
rate_limited | More than 600 requests per minute for your access; Retry-After says after how many seconds you can continue. |
idempotency_conflict | The same Idempotency-Key already came with a different body. |
idempotency_in_progress | The first request with this Idempotency-Key is still running — wait a moment, then send again. |
webhook_revoked | Test delivery to a revoked webhook. |
Want to order splats from your own system?
Request access