Skip to content
Request access

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_…

  1. 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.

  2. 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.

  3. 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.

  4. 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.

1 · Create a capture
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"
  }'
Answer 201
{
  "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://…" }, "…"]
  }
}
3 · Complete the upload
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": "\"…\"" }, "…"]
  }'
4 · Ask for the state
{
  "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.

stateMeaning
uploadingNo source registered in full yet — the upload is running or has not started.
checkingThe material has arrived; the pre-check is running or the automatic start is imminent.
queuedA step is waiting for a slot in the queue or for a GPU pod to come up.
processingA step is actually computing.
needs-reviewA 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.
doneDelivered — GET …/result hands out the files.
failedA 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.

flowMeaning
headlessThe default. Starts by itself unless the pre-check is red; a green gate passes, amber or red halts and becomes needs-review.
unattendedFor 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.
autoLike headless, but additionally halts at the splat gate.
guidedStarts 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.

Register a webhook
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
What arrives (capture.state)
{
  "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
  }
}
HeaderMeaning
X-Splatastic-EventThe 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-DeliveryThe id of this delivery, stable across every attempt — use it to deduplicate on your side.
X-Splatastic-TimestampUnix seconds of this attempt; part of the signature.
X-Splatastic-Signaturesha256=<hex HMAC-SHA256(secret, timestamp + "." + body)>
Verify the signature
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.

Required fields in meta.json
FieldValueMeaning
version1The 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.
metersPerUnit1The delivery is metric: one unit is one metre. A capture without any scale measurement is honestly not metric and is not delivered.
identityTransformtrueNothing is left to convert — the splat and the collision mesh carry the same bake.
In the package
FileContents
splat.plyThe splat itself.
collision.glbThe collision mesh, in the same frame as the splat (also as collision.ply).
meta.jsonThe delivery contract and the run’s metrics.
package.zipAll of it in one file.
GET /api/v1/captures/:id/result
{
  "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
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"
}'
TypeScript
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()
Python
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
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": "\"…\""
    }
  ]
}'
TypeScript
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()
Python
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
curl "https://splatastic.com/api/v1/captures/cap_…" \
  -H "Authorization: Bearer sk_…"
TypeScript
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()
Python
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
curl "https://splatastic.com/api/v1/captures?limit=50" \
  -H "Authorization: Bearer sk_…"
TypeScript
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()
Python
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
curl "https://splatastic.com/api/v1/captures/cap_…/result" \
  -H "Authorization: Bearer sk_…"
TypeScript
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()
Python
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
curl -X POST "https://splatastic.com/api/v1/webhooks/cap_…/test" \
  -H "Authorization: Bearer sk_…"
TypeScript
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()
Python
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.

codeWhen
validationA field in the call is missing or unknown.
unsupported_typeFile type not supported — that includes raw 360° camera files (.insv, .360).
too_largeLarger than this instance’s upload limit.
not_foundThe site, capture, upload or webhook does not exist — or belongs to someone else.
no_object_storeThis instance has no object store; without one there is no direct upload.
stage_conflictThe 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_runningcancel while nothing is running.
not_deliveredGET …/result before the capture is delivered, or the package is missing.
frame_staleThe deliveryId of a writing call belongs to an earlier trained state.
frame_mismatchThe bounds you sent land far away from the scene once converted back.
edit_conflictSomeone saved after the revision you hold; the answer carries the current one.
reexport_runningA re-export of this capture is already computing.
reexport_unavailableThe working data has expired; another export would be a full recomputation from the source material.
invalid_urlThe webhook url is not a valid https:// address, or it points to an internal or private network.
unauthorizedNo token, or an invalid one.
rate_limitedMore than 600 requests per minute for your access; Retry-After says after how many seconds you can continue.
idempotency_conflictThe same Idempotency-Key already came with a different body.
idempotency_in_progressThe first request with this Idempotency-Key is still running — wait a moment, then send again.
webhook_revokedTest delivery to a revoked webhook.

Want to order splats from your own system?

Request access