Zum Inhalt springen
Zugang anfragen

Splats per Schnittstelle bestellen

Ein fremdes System beauftragt eine Aufnahme, verfolgt sie und importiert das fertige Paket — ohne dass ein Mensch durch unsere Oberfläche geht. Diese Seite beschreibt den Vertrag: Ablauf, Zustände, Webhooks und das, was am Ende herauskommt.

Gedacht ist sie für Plattformen, Digital-Twin-Software und Viewer, die Räume ihrer eigenen Kundschaft zeigen. Die Schnittstelle ist ein Nebenweg, nicht der Hauptweg: Wer selbst mit dem Handy aufnimmt, braucht sie nicht.

Der Ablauf in vier Schritten

Anlegen, hochladen, abschließen, verfolgen. Jeder Aufruf braucht einen Bearer-Token deines Zugangs; die Beispiele schreiben ihn als sk_…

  1. Anlegen

    POST /api/v1/captures mit Dateiname und Größe, optional Standort, Name, eigener Referenz (clientRef), Qualität und Ablauf. Die Antwort enthält presignte Teile für den Upload.

  2. Direkt in den Bucket hochladen

    Jeder Teil geht per PUT an seine presignte URL — an uns vorbei. Die Steuerungsebene sieht die Bytes nie; sie erfährt nur, dass sie angekommen sind.

  3. Abschließen

    POST /api/v1/captures/:id/complete mit den ETags der Teile. Ab hier läuft es von selbst: Vorab-Check, Start, Prüfschritte, Training, Kollisionsmesh, Paket.

  4. Verfolgen und abholen

    GET /api/v1/captures/:id pollen, bis der Zustand done ist — oder einen Webhook registrieren. Das Paket liefert GET /api/v1/captures/:id/result.

1 · Aufnahme anlegen
curl -X POST https://splatastic.com/api/v1/captures \
  -H "Authorization: Bearer sk_…" -H "Content-Type: application/json" \
  -d '{
    "filename": "rundgang.mp4",
    "sizeBytes": 214748364,
    "siteName": "Halle 12",
    "clientRef": "acme:asset-42",
    "quality": "standard",
    "flow": "headless"
  }'
Antwort 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 · Upload abschließen
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 · Zustand abfragen
{
  "id": "cap_…", "name": "Halle 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
}

Der Zustandsautomat: sieben Werte

state ist der Vertrag. stage daneben nennt die interne Stufe und ist rein informativ — verlass dich nicht darauf.

stateBedeutung
uploadingNoch keine Quelle vollständig registriert — der Upload läuft oder hat nicht begonnen.
checkingDas Material ist da, der Vorab-Check läuft oder der automatische Start steht unmittelbar bevor.
queuedEin Schritt wartet auf einen Platz in der Warteschlange oder auf einen startenden GPU-Pod.
processingEin Schritt rechnet tatsächlich.
needs-reviewEin Mensch wird gebraucht: ein Prüfschritt wartet, der automatische Start konnte nicht, oder der laufende Schritt wurde angehalten. needsReview nennt Grund, Text und einen Deep-Link.
doneAusgeliefert — GET …/result gibt die Dateien heraus.
failedEin Schritt ist fehlgeschlagen; error trägt die Meldung.

Abläufe: wie viel Mensch der Auftrag braucht

flow beim Anlegen entscheidet, ob ein gelbes oder rotes Prüfurteil anhält oder durchläuft.

flowBedeutung
headlessVorgabe. Startet von selbst, sobald der Vorab-Check nicht rot ist; eine grüne Ampel läuft durch, gelb oder rot hält an und wird needs-review.
unattendedFür ein System, das das Ergebnis ohnehin selbst prüft: jede Ampel läuft durch, angehalten wird nur bei einem Fehler. Das Urteil steht trotzdem in den Messwerten der Aufnahme.
autoWie headless, hält aber zusätzlich am Splat-Gate an.
guidedStartet erst auf Klick und hält an jedem Prüfschritt — der Weg für einen Menschen an der Oberfläche.

unattended ist für ein System gedacht, das das Ergebnis ohnehin selbst prüft und nur Splat und Kollisionsnetz will: Es tauscht die Ampel gegen Durchsatz. Ein Lauf, dessen Kamerabahn rot war, liefert trotzdem — und ist an seinen Messwerten als solcher zu erkennen.

Webhooks statt Pollen

Jeder Zustandswechsel als signierter POST. Pollen bleibt daneben immer möglich — ein Webhook ist die Ergänzung, kein Ersatz.

Webhook registrieren
curl -X POST https://splatastic.com/api/v1/webhooks \
  -H "Authorization: Bearer sk_…" -H "Content-Type: application/json" \
  -d '{ "url": "https://dein-system.example/hooks/splatastic" }'
# → 201 { "id": "…", "url": "…", "secret": "…" }   ← das Secret steht nur hier im Klartext
Was ankommt (capture.state)
{
  "event": "capture.state",
  "occurredAt": "2026-09-10T13:40:00.000Z",
  "previousState": "processing",
  "capture": {
    "id": "cap_…", "name": "Halle 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
  }
}
KopfzeileBedeutung
X-Splatastic-EventDas Ereignis dieser Zustellung: capture.state, capture.reexported, capture.retention und weitere — auf Wunsch auch capture.gate und capture.deleted, dazu webhook.test für die Probe. Danach wird verzweigt — die Nutzlasten haben verschiedene Formen.
X-Splatastic-DeliveryKennung dieser Zustellung, stabil über alle Versuche — damit kannst du beim Empfangen entdoppeln.
X-Splatastic-TimestampUnix-Sekunden dieses Versuchs; fließt in die Signatur ein.
X-Splatastic-Signaturesha256=<hex HMAC-SHA256(secret, timestamp + "." + body)>
Signatur prüfen
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 wirft bei ungleicher Länge, statt einfach false zu sein.
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

rawBody muss der unveränderte Request-Body sein, vor jedem JSON-Parsen, und timestamp der Wert aus X-Splatastic-Timestampdesselben Requests.

Antwortet dein Endpunkt nicht mit 2xx (oder gar nicht innerhalb von zehn Sekunden), wird wiederholt — nach etwa einer Minute, fünf Minuten, einer Viertelstunde, einer Stunde und sechs Stunden.X-Splatastic-Delivery bleibt dabei gleich, entdoppeln kannst du also darüber.

Verzweige immer über X-Splatastic-Event: Es gibt mehr als ein Ereignis, und die Nutzlasten haben verschiedene Formen. Ein Ereignis, das du nicht kennst, verwirfst du — dann bist du fertig, bevor es losgeht.

Welche Ereignisse du bekommst, wählst du beim Registrieren mit events. Ohne die Angabe bleibt es bei den bisherigen; neue Ereignisse wie capture.gate (die Aufnahme wartet auf einen Menschen) und capture.deleted kommen nur, wenn du sie nennst.POST /api/v1/webhooks/:id/test schickt eine Probe, GET …/deliveries zeigt die letzten 50 Zustellungen mit Status.

Zustellungen einer Aufnahme gehen der Reihe nach hinaus — eine wiederholte ältere wird nicht von einer neueren überholt. Vergleiche trotzdem occurredAt, bevor du einen gespeicherten Zustand überschreibst: Die Reihenfolge ist eine Zusage über das Senden, nicht über das Ankommen.

Der Liefervertrag

Was du bekommst, und was daran garantiert ist. Bricht ein Paket den Vertrag, wird es nicht ausgeliefert — statt einer Nutzlast, die dein Import nicht lesen kann.

Pflichtfelder in meta.json
FeldWertBedeutung
version1Die Fassung des Pakets.
sourceType"splatastic"Woher das Paket kommt.
upAxis"Y"Mehr als „Y zeigt nach oben": Die Lieferung ist zum Boden ausgerichtet. Ist die Schätzung unsicher — Treppe, Rampe, ein Gang, der nur eine Linie beschreibt —, bleibt die Szene so stehen, wie sie rekonstruiert wurde.
metersPerUnit1Die Lieferung ist metrisch: eine Einheit ist ein Meter. Eine Aufnahme ganz ohne Maßstabsmessung ist ehrlich nicht metrisch und wird nicht ausgeliefert.
identityTransformtrueEs ist nichts mehr umzurechnen — Splat und Kollisionsnetz tragen denselben Bake.
Im Paket
DateiInhalt
splat.plyDer Splat selbst.
collision.glbDas Kollisionsnetz, im selben Rahmen wie der Splat (auch als collision.ply).
meta.jsonDer Liefervertrag und die Kennzahlen des Laufs.
package.zipAlles zusammen in einer Datei.
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, "…": "…" }
}

Dazu trägt meta.json alles, was sonst noch bekannt ist: Herkunft, Qualitätsstufe, Kennzahlen des Laufs und unter areaCrop die Stufe, mit der die gelieferte Fassung auf den erfassten Bereich beschnitten wurde. Seit dem 17.09.2026 ist generous die Vorgabe— wer die volle Szene will, schickt beim Anlegen ausdrücklich "areaCrop": "off" mit. Was tatsächlich angewandt wurde, steht in jedem Paket in meta.json; das ist die verlässliche Angabe.

Die URLs aus …/result sind Abholscheine und laufen nach einer Stunde ab: Ein dauerhaft gültiger Link wäre ein Passwort, das nie abläuft. Kopier dir die Bytes; ein erneuter Aufruf liefert jederzeit frische URLs.

Code-Beispiele

Dieselben Aufrufe in curl, TypeScript und Python — erzeugt aus der OpenAPI-Spezifikation, also mit genau den Pfaden und Feldern, die die Schnittstelle kennt.

Die Spezifikation selbst liegt ohne Anmeldung unter/api/v1/openapi.json (OpenAPI 3.1) — für Client-Generatoren und zum Nachschlagen jeder Antwortform.

POST /api/v1/captures

Aufnahme anlegen — mit Idempotency-Key, damit eine Wiederholung nach einem Netzfehler keine zweite anlegt.

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

Upload abschließen, nachdem jeder Teil per PUT an seine URL ging.

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}

Zustand abfragen.

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

Eigene Aufnahmen seitenweise — next ist der Cursor der nächsten Seite.

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

Ergebnis abholen.

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

Testzustellung an einen registrierten 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()

Fehlercodes

Eine Fehlerantwort hat immer die Form { "error": "<Text>", "code": "<Fehlercode>" } —error ist der Satz für Menschen, code das, was dein Programm abfragt. Das gilt auch für einen fehlenden Token und das Ratenlimit: höchstens 600 Anfragen pro Minute je Zugang, darüber429 mit Retry-After.

codeWann
validationEine Angabe im Aufruf fehlt oder ist unbekannt.
unsupported_typeDateityp nicht unterstützt — dazu gehören Rohdateien einer 360°-Kamera (.insv, .360).
too_largeGrößer als das Upload-Limit dieser Instanz.
not_foundStandort, Aufnahme, Upload oder Webhook gibt es nicht — oder er gehört jemand anderem.
no_object_storeDiese Instanz hat keinen Objektspeicher; ohne ihn gibt es keinen Direkt-Upload.
stage_conflictDie Aufnahme ist nicht (mehr) im passenden Schritt — ein Upload lässt sich nur einmal abschließen, und gelöscht wird nicht, während etwas läuft.
not_runningcancel, obwohl gerade nichts läuft.
not_deliveredGET …/result, bevor die Aufnahme ausgeliefert ist, oder das Paket fehlt.
frame_staleDie deliveryId eines schreibenden Aufrufs gehört zu einem früheren trainierten Stand.
frame_mismatchDie mitgeschickten bounds liegen zurückgerechnet weit neben der Szene.
edit_conflictJemand hat seit deinem Stand gespeichert; die Antwort trägt den aktuellen Stand.
reexport_runningEin Re-Export dieser Aufnahme rechnet gerade.
reexport_unavailableDie Arbeitsdaten sind abgelaufen; ein erneuter Export wäre eine Neuberechnung aus dem Quellmaterial.
invalid_urlDie Webhook-url ist keine gültige https://-Adresse oder zeigt auf ein internes bzw. privates Netz.
unauthorizedKein Token oder ein ungültiger.
rate_limitedMehr als 600 Anfragen pro Minute für deinen Zugang; Retry-After sagt, nach wie vielen Sekunden es weitergeht.
idempotency_conflictDerselbe Idempotency-Key kam schon mit einem anderen Body.
idempotency_in_progressDie erste Anfrage mit diesem Idempotency-Key läuft noch — kurz warten, dann erneut senden.
webhook_revokedProbezustellung an einen widerrufenen Webhook.

Du willst Splats aus deinem eigenen System bestellen?

Zugang anfragen