Skip to content
DeutschRequest 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 or capture.retention. 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.

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.

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.

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.

Want to order splats from your own system?

Request access