{"openapi":"3.1.0","info":{"title":"Twinbly Splat API","version":"1.6.0","x-updated":"2026-09-21","description":"Reading this schema: anything marked `x-status: planned` is specified and merged but NOT deployed yet — calling it returns 404 or omits the field. Everything unmarked is live. `info.version` and `info.x-updated` change whenever this file does.\n\nTurn image sets, video, or a reconstruction you already solved into a 3D\nGaussian Splat.\n\n## Job lifecycle\n\nEvery job is three calls:\n\n1. `POST /v1/jobs` — describe the work, get back a `jobId` and an `uploadUrl`\n2. `PUT {uploadUrl}` — upload your input\n3. `POST /v1/jobs/{jobId}/start` — begin processing\n\nThen poll `GET /v1/jobs/{jobId}` until `status` is `complete` or `failed`, or\nsupply `callbackUrl` at creation and be notified instead. Training takes\ntens of minutes to hours depending on input size and iterations, so poll on\nthe order of seconds-to-minutes, not continuously.\n\n## Input types\n\n| `inputType` | Upload | Notes |\n|---|---|---|\n| `zip` | ZIP of images | the default |\n| `video` / `mov` | a video file | frames extracted server-side |\n| `360` | 360 video | equirectangular, extracted to cube/cylinder faces |\n| `colmap` | ZIP of a COLMAP model + frames | **bring your own poses** — SfM skipped |\n\n`colmap` is the fast lane: you supply the reconstruction (from on-device\nARKit/ARCore tracking, a rig, or your own SfM) and training starts\nimmediately. It is also the only path that works on captures where feature\nmatching fails outright — textureless rooms and blank walls. See\n`docs/API_BRING_YOUR_OWN_POSES.md`.\n\n## Authentication\n\nEither an API key or a Firebase ID token. If both are present the bearer\ntoken wins. Full detail in `docs/API_AUTH.md`.\n\nA key issued to a device or third party speaks only for itself: `userId` is\nderived from the credential, sending a different one is refused, and it can\nonly see its own jobs. Do not send `userId` unless your credential is\nauthorised to act on behalf of users.\n\n## Bundle layouts\n\nThe two upload lanes that carry geometry, documented because being wrong here\ncosts a full training run rather than a 400.\n\n### inputType: colmap — you built the model\n\nA ZIP containing a COLMAP model and the frames it references. **Either binary or\ntext is accepted**; a text model is converted to binary server-side.\n\n    sparse/0/cameras.{bin,txt}     PINHOLE recommended (see below)\n    sparse/0/images.{bin,txt}      world-to-camera: QW QX QY QZ TX TY TZ\n    sparse/0/points3D.{bin,txt}    empty tracks are fine (depth-derived clouds have none)\n    sparse/0/frames.{bin,txt}      optional, COLMAP 3.12+ rig model — tolerated\n    sparse/0/rigs.{bin,txt}        optional\n    images/<NAME>                  every NAME referenced by images, exactly\n\nRules that are enforced and will reject the bundle:\n\n- **Filenames must match `images` exactly.** They are preserved verbatim; the\n  image lanes sanitize names but this lane must not, because renaming breaks the\n  image-to-pose association in a way nothing downstream can detect. A bundle\n  naming a frame it does not ship is rejected up front.\n- A zip **of** the folder works as well as a zip of its contents.\n- **Use PINHOLE if your frames are already rectilinear.** Undistortion is skipped\n  automatically for PINHOLE/SIMPLE_PINHOLE; any other model runs undistortion,\n  which re-rectifies already-rectilinear frames and degrades them.\n\n### inputType: capture — the server builds the model\n\nPreferred for a device capture. A ZIP of a raw capture; the server runs the\nconversion, so the client never reimplements it.\n\n    metadata.json\n    rgb/<i>.jpg\n    depth/<i>.exr                  float16 metres, single channel 'R'\n\nWhy this lane exists: a uniform global mirror survives every self-consistency\ncheck a tracker can run on itself, because points unprojected through its own\nposes agree with those poses by construction. The proof that this conversion is\nnot mirrored (9.0x rotation-vs-reflection, control-verified) attaches to the\nserver's converter AS RUN. A second implementation does not inherit it, and the\nfailure mode is a splat that trains perfectly and comes out backwards.\n\n## For server-side integrators\n\n### Non-interactive credentials\nA Firebase ID token is an interactive *user* credential and expires hourly — no\nbackend can hold one. Use an issued **API key** instead: revocable, scoped to an\naccount (tenant), several per account, sent as `X-API-Key`. Only a SHA-256 hash\nis stored. Ask for one; they are minted with\n`scripts/api-credential.js issue --tenant <acct> --app <name>`.\n\nA key that may act for *your* end users needs `--delegating`, which lets it set\n`userId` per request. Without it the key speaks only for itself.\n\n### Completion webhooks — already available\nPass `callbackUrl` on create and the terminal state is POSTed to it. **Signed with\nHMAC-SHA256**, so verify the signature rather than trusting the body. This\nremoves polling entirely; do not poll at scale.\n\n### Actual cost\n`billing.actualCost` and `billing.gpuMinutes` are recorded on the job after\ncompletion — the quote is an estimate, these are the truth. `GET /v1/usage`\naggregates per user.\n\n### Idempotency\nSend `captureId` or an `Idempotency-Key` header on create; a repeat returns the\nexisting job. See the create endpoint.\n\n### Rate limits\nEvery response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and\n`X-RateLimit-Reset` (unix seconds). Back off before you are throttled; a 429 also\ncarries `Retry-After`.\n\n## Opening a finished capture\n\n`GET /v1/jobs/{jobId}` carries two nullable links, and the null is the\nimportant part — it is the difference between \"not yet\" and \"broken\".\n\n| field | who it is for | null until |\n|---|---|---|\n| `editorUrl` | the owner, to review and publish | there is something to open |\n| `viewerUrl` | anyone, to view or share | the owner publishes, which is never automatic |\n\nDo not build either URL yourself. They are assembled from storage details\nthis API does not own, so a hand-made link can look correct and resolve to\nnothing. Poll the field.\n\n`editorUrl` needs a signed-in session and authentication is Firebase\nclient-side rather than a cookie — a fresh webview is signed OUT and\nredirects to a login screen even for a user who is signed in to your app.\nCall `signInWithCustomToken()` in the webview before navigating.\n`viewerUrl` needs nothing.\n\n## Compatibility policy\n\n- **Fields are added, never removed or repurposed.** Decode permissively and\n  ignore unknown fields; `additionalProperties` is unset on request schemas, so\n  extra fields you send are tolerated.\n- **Enum values may grow.** Treat an unrecognised value as unknown rather than\n  as an error — `status`, `inputType` and `error.code` will all gain members.\n- **`error.code` is append-only and stable.** Match on it, never on `message`.\n- **Breaking changes get a new path prefix** (`/v2`); `/v1` will not break under\n  you.\n- **This document is the contract.** Fetch `/v1/openapi.json` at build time\n  rather than transcribing it; a routed endpoint missing from it fails our build.\n"},"servers":[{"url":"https://rg-splats.web.app","description":"Hosting alias (60s request cap — avoid for long operations)"},{"url":"https://us-central1-rg-splats.cloudfunctions.net","description":"Direct function URLs (no request cap)"}],"security":[{"ApiKeyAuth":[]},{"FirebaseToken":[]}],"tags":[{"name":"Jobs","description":"Create, start, and monitor splat jobs"},{"name":"Estimates","description":"Cost and GPU planning before committing to a job"},{"name":"Account","description":"Usage and billing"}],"paths":{"/v1/jobs":{"post":{"tags":["Jobs"],"summary":"Create a job","description":"Reserves a job and returns an upload target. Nothing is processed and\nnothing is billed until you upload and call `/start`.\n\nFor `inputType: colmap` and `inputType: capture`, see **Bundle layouts** in the top-level description for the exact ZIP contents.\n\n### How a job is paid for\n\n_Not deployed, and when deployed it ships log-only: `/start` refuses nothing until the gate is set to enforce. (This paragraph is prose inside a live operation, so it carries no `x-status` marker of its own — see the `402` and `503` responses on `/start`.)_\n\nJobs are paid from the Twinbly wallet of the account that created the API key.\n`/start` is refused with 402 when the quoted estimate exceeds that balance; the\nfinal charge is the measured cost, never more than the balance.\n\n### Idempotency\n\nSend `captureId` (or an `Idempotency-Key` header). A repeat create with the same\nvalue, from the same credential, returns the EXISTING job with\n`idempotent: true` and HTTP 200 instead of creating a second one.\n\nThis exists for the expensive failure: a create that SUCCEEDS and whose response\nis lost to a dropped connection. The client cannot distinguish that from a\nfailure, so it retries — and without this it is billed twice. Double-tapping is\na client-side concern; a lost response is not.\n\nOnly pre-upload and failed jobs are reused. Once a job is training, a repeat\ncreate is treated as a genuinely new job.","operationId":"createJob","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateJobRequest"},"examples":{"recipeDriven":{"summary":"Recipe-driven (preferred)","value":{"recipe":{"engine":"lichtfeld","iterations":30000,"targetMegapixels":"preserve","useGut":false},"inputType":"zip","projectId":"proj456"}},"bringYourOwnPoses":{"summary":"Bring your own poses — SfM skipped","value":{"inputType":"colmap","recipe":{"engine":"lichtfeld","iterations":30000,"targetMegapixels":"preserve","useGut":false}}},"manifestInput":{"summary":"Input by reference - your images stay in your storage","description":"The response's `uploadUrl` is signed for manifest.json. PUT the InputManifest document there, then call /start. Sign every file URL for at least 6 hours.","value":{"inputType":"zip","inputSource":"manifest","recipe":{"engine":"lichtfeld","iterations":30000,"targetMegapixels":"preserve","useGut":false},"analysis":{"schemaVersion":2,"capture":{"actualFrameCount":172}},"projectId":"proj456"}},"legacyQuality":{"summary":"Legacy quality string (still accepted)","value":{"quality":"standard","inputType":"video"}}}}}},"responses":{"200":{"description":"`dryRun: true` — the request is valid and nothing was created.","content":{"application/json":{"schema":{"type":"object","properties":{"dryRun":{"type":"boolean"},"valid":{"type":"boolean"},"message":{"type":"string"},"recipe":{"$ref":"#/components/schemas/Recipe"},"effective":{"type":"object","description":"The splat budget the trainer will actually read, and which precedence level produced it. `adjustments` says what we CHANGED; this says what the job IS, including when the number is a default you never sent.","properties":{"maxGaussians":{"type":"integer","nullable":true},"maxGaussiansSource":{"type":"string","enum":["analysisResult.lichtfeldConfig","recipe","default:captureTier","default:gpuBudget"]}}},"warnings":{"type":"array","items":{"type":"string"},"description":"Things that are true about this job which you would otherwise discover only after paying for it: a splat budget below the routed GPU's, or one above the conversion ceiling (that job produces a .ply but no .sog/.sogs/tileset). Empty when neither applies."},"gpu":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string"},"pricePerHour":{"type":"number","description":"Our COST basis for this GPU, not a retail rate: `pricePerMinute` × 60. **Do not price a job as `minutes` × `pricePerHour`** — that is exactly half what the job costs, on every lane and every GPU. Every `cost` field this API returns already includes the service multiple over raw GPU time. Size from `cost`; this field is here so you can see what the machine itself costs."},"gaussianBudget":{"type":"integer","nullable":true}}},"estimate":{"type":"object","properties":{"currency":{"type":"string"},"cost":{"type":"number","description":"What the job is expected to cost, in `currency`. This is the number to quote: it already includes the service multiple over raw GPU time, so it does NOT equal `minutes` × `gpu.pricePerHour` — it is twice that."},"costHigh":{"type":"number","description":"360 lanes only (`inputType: insv` or `360`): the top of the MEASURED range, not a P90 from the rolling model. There is no `costLow` — no low end is measured on this lane."},"minutes":{"type":"integer","description":"Expected GPU minutes. A duration, not a price input: multiplying it by an hourly rate under-prices the job by half. See `cost`."},"minutesHigh":{"type":"integer","description":"360 lanes only: the minutes sibling of `costHigh`. Four complete production jobs at 308-400 positions took 266-656 VM-wall minutes, and position count explains none of that spread, so the pair is a typical and a high rather than a line."},"confidence":{"type":"string"},"source":{"type":"string"}}},"adjustments":{"type":"array","items":{"type":"object"},"description":"What we changed about your request, and why. A clamp here means the price quoted and the work that would run are not the same request."},"next":{"type":"string"}}},"example":{"dryRun":true,"valid":true,"message":"Request is valid. Nothing was created, uploaded, or billed.","recipe":{"engine":"lichtfeld","iterations":30000,"targetMegapixels":"preserve","useGut":false,"curate":true,"maxGaussians":2500000},"effective":{"maxGaussians":2500000,"maxGaussiansSource":"default:gpuBudget"},"warnings":[],"gpu":{"id":"rtx_4090","label":"RTX 4090","pricePerHour":0.69,"gaussianBudget":2500000},"estimate":{"currency":"USD","cost":3.87,"minutes":165,"confidence":"high","source":"live"},"adjustments":[],"next":"Send the same body without `dryRun` to create the job for real."}}}},"201":{"description":"Job created; upload next","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateJobResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}},"callbacks":{"x-status":"planned","x-status-note":"PARTLY deployed: a callback IS delivered once at completion (deployed 2026-09-20). The retry schedule and `deliveryId` live in the reconciler, which is not deployed, and `POST|GET|DELETE /v1/me/webhook-secret` still 404s — so sign with an operator-provisioned secret and treat a single delivery as best effort.","jobTerminal":{"{$request.body#/callbackUrl}":{"post":{"summary":"Job reached a terminal state","description":"The terminal state of a job you created with `callbackUrl`, POSTed to that URL. Two events: `job.completed` and `job.failed`.\n\n### Headers\n\n| header | value |\n|---|---|\n| `X-Splat-Event` | `job.completed` or `job.failed` |\n| `X-Splat-Timestamp` | unix seconds |\n| `X-Splat-Signature` | `sha256=<hex>` — HMAC-SHA256 over `` `${timestamp}.${body}` ``, present when a secret is configured |\n| `X-Splat-Delivery` | the payload's `deliveryId` |\n\nMint the signing secret yourself with `POST /v1/me/webhook-secret`; it signs every callback for jobs created with that API key. Without one the callback still arrives, unsigned.\n\n### Verifying the signature\n\n    const crypto = require('crypto');\n    const ts   = req.get('X-Splat-Timestamp');\n    const sig  = req.get('X-Splat-Signature');        // \"sha256=<hex>\"\n    const mine = crypto.createHmac('sha256', SECRET)\n      .update(`${ts}.${rawBody}`).digest('hex');      // RAW body, not re-serialized\n    const ok = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(`sha256=${mine}`));\n\nSign the **raw** request body. Re-serializing the parsed JSON reorders keys and\nthe signature will not match.\n\n### Retries and idempotency\n\nUp to **5 attempts over about 5 hours** — immediately, then +5 min, +15 min, +1 h, +4 h. Anything outside 2xx is a failure and is retried; each attempt has an 8-second timeout. After the fifth we stop.\n\n**Every attempt re-sends the identical body, including the same `deliveryId`.** That is your idempotency key: record it and ignore a delivery you have already processed. Respond 2xx as soon as you have durably accepted the event — do your work afterwards, not before answering.\n\n### No credentials in the body\n\nThere is no download URL in the payload, deliberately: receivers log webhook bodies, and a signed URL in a log is a capability that outlives the log line. `downloadsUrl` and `jobUrl` are authenticated endpoints — call them with your API key.\n\n### `webhookDelivered` is not about you\n\n`webhookDelivered` on the job is an internal Twinbly notification and says nothing about your callback — read `callback.delivered`.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["job.completed","job.failed"]},"jobId":{"type":"string"},"deliveryId":{"type":"string","format":"uuid","description":"Stable across retries of the same event. Your idempotency key."},"status":{"$ref":"#/components/schemas/JobStatus"},"captureId":{"type":"string","nullable":true},"createdBy":{"type":"object","properties":{"userId":{"type":"string","nullable":true},"projectId":{"type":"string","nullable":true}}},"billing":{"type":"object","description":"Null, never zero, when unknown — a failed job is unbilled, not free.","properties":{"actualCost":{"type":"number","nullable":true},"gpuMinutes":{"type":"number","nullable":true}}},"outputs":{"type":"object","properties":{"plyUrl":{"type":"string","nullable":true,"description":"`gs://` URI. Legacy, kept for existing integrations; fetch through `downloadsUrl` instead."}}},"downloadsUrl":{"type":"string","format":"uri","description":"GET it with your API key for time-limited artifact URLs."},"jobUrl":{"type":"string","format":"uri"},"error":{"type":"string","description":"Present on `job.failed` only; the key is absent on success."}},"example":{"event":"job.completed","jobId":"abc12345","deliveryId":"6b1f2c4e-9d3a-4f51-8b7c-2e0a9d5f1c33","status":"complete","captureId":"north-wall-2026-09-19","createdBy":{"userId":"user123","projectId":"proj456"},"billing":{"actualCost":2.35,"gpuMinutes":41},"outputs":{"plyUrl":"gs://rg-splats-pipeline/jobs/abc12345/output/splat.ply"},"downloadsUrl":"https://rg-splats.web.app/v1/jobs/abc12345/downloads","jobUrl":"https://rg-splats.web.app/v1/jobs/abc12345"}}}}},"responses":{"2XX":{"description":"Accepted. Anything else is retried on the schedule above."}}}}}}}},"/v1/jobs/list":{"get":{"tags":["Jobs"],"summary":"List jobs","description":"Returns jobs visible to the calling credential, newest first. A\ncredential that speaks for a single subject sees only its own jobs\nregardless of the `userId` parameter.\n","operationId":"listJobs","parameters":[{"name":"status","in":"query","schema":{"$ref":"#/components/schemas/JobStatus"}},{"name":"userId","in":"query","schema":{"type":"string"},"description":"Only valid for credentials authorised to act for users."},{"name":"projectId","in":"query","schema":{"type":"string"}},{"name":"captureId","in":"query","schema":{"type":"string","maxLength":128},"description":"Your own id for the capture, as sent on POST /v1/jobs. The only handle that finds a job without having stored our jobId. Combining it with `status` or `projectId` is supported; those are applied after the lookup, over at most 50 matching jobs."},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":100},"description":"Clamped to 1..100. A value outside that range, or one that is not a number, falls back to 20 rather than erroring."}],"responses":{"200":{"description":"Matching jobs","content":{"application/json":{"schema":{"type":"object","properties":{"jobs":{"type":"array","items":{"$ref":"#/components/schemas/JobSummary"}},"count":{"type":"integer"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/v1/jobs/{jobId}":{"get":{"tags":["Jobs"],"summary":"Get job status","operationId":"getJob","parameters":[{"$ref":"#/components/parameters/JobId"}],"responses":{"200":{"description":"Job detail","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Job"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Not found — also returned when the job exists but belongs to\nanother credential, so job ids cannot be probed for existence.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/jobs/{jobId}/start":{"post":{"tags":["Jobs"],"summary":"Start processing","description":"Begins preprocessing and training. Requires the upload to have\ncompleted. Also re-runs a job that previously failed.\n","operationId":"startJob","parameters":[{"$ref":"#/components/parameters/JobId"}],"responses":{"200":{"description":"Job started","content":{"application/json":{"schema":{"type":"object","properties":{"jobId":{"type":"string"},"status":{"$ref":"#/components/schemas/JobStatus"},"message":{"type":"string"}}}}}},"400":{"description":"Upload missing, or job is in a state that cannot be started","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"x-status":"planned","x-status-note":"Not deployed, and when deployed it ships log-only: /start refuses nothing until the gate is set to enforce.","description":"The wallet that pays for this job cannot cover the quoted estimate, so\nno GPU was booked. The job is UNCHANGED — the upload is intact and the\nsame `/start` call works once the wallet is funded.\n\n`details` carries `estimatedCents`, `balanceCents` and `needCents` when\nthe payer reported them, or `reason: \"no_billing_account\"` when the\ncredential has no Twinbly account behind it to debit.\n\nNot retryable unchanged: top up first.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"503":{"x-status":"planned","x-status-note":"Not deployed, and when deployed it ships log-only: /start refuses nothing until the gate is set to enforce.","description":"The wallet balance could not be verified — the billing service did not\nanswer. Retryable: the job is UNCHANGED and nothing was started. Wait\n`Retry-After` seconds and call `/start` again.\n\nAn outage pauses API jobs; it never makes them free.\n","headers":{"Retry-After":{"schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/jobs/{jobId}/downloads":{"get":{"tags":["Jobs"],"summary":"Time-limited download URLs for a job's artifacts","description":"A job's `outputs` are `gs://` URIs, which only resolve for callers with\ncredentials on the bucket. This returns signed HTTPS URLs anyone can\nfetch — the way to actually retrieve a result from outside the project.\n\nKept separate from `getJob` on purpose: `getJob` is polled throughout\ntraining, and signing on every poll would be wasted work and would hand\nout capability URLs nobody asked for.\n\nA signed URL is a bearer capability — anyone holding it can fetch until\nit expires. Ownership is checked before issuing; treat the URLs as\nsecrets and do not log them.\n","operationId":"getJobDownloads","parameters":[{"$ref":"#/components/parameters/JobId"}],"responses":{"200":{"description":"Signed URLs. Empty `artifacts` with a `message` when the job has not\nproduced anything yet.\n\n`artifacts` covers both what the trainer reported and the converted\nfiles found next to the `.ply` (those carry `derived: true`).\nDirectory-shaped assets cannot be signed at all and are listed under\n`bundles`; `conversion` says what was decided, including a refusal, and\n`receipt` says what the converter then did — including whether Tidy\nculled the splat and where the subject sits inside it.\n","content":{"application/json":{"schema":{"type":"object","properties":{"jobId":{"type":"string"},"status":{"$ref":"#/components/schemas/JobStatus"},"expiresAt":{"type":"string","format":"date-time"},"message":{"type":"string"},"artifacts":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","description":"e.g. plyUrl, sogsUrl, camerasUrl, thumbnailUrl"},"url":{"type":"string","format":"uri"},"signed":{"type":"boolean"},"contentType":{"type":"string"},"sizeBytes":{"type":"integer","nullable":true},"expiresAt":{"type":"string","format":"date-time"},"derived":{"type":"boolean","description":"True when this entry was NOT reported by the trainer but found by\nprobing the converter's output paths, which are fixed siblings of\nthe `.ply`. False when the trainer reported it.\n\nPresent on EVERY artifact, so `derived === false` selects the\ntrainer's own outputs. (It was absent rather than false on those\nentries until 2026-09-19, which made that filter match nothing.)\n"}}}},"bundles":{"type":"array","description":"Converted assets that are a DIRECTORY, not a file — the SOGS ladder\nand the 3D Tiles tileset. Listed when their manifest exists, and\nnever signed: a manifest resolves its chunks relative to its own\nURL, so a per-object signed URL cannot deliver one however many of\nthem we issue. `manifestGsUri` is the address; a fetchable URL\narrives with the conversion receipt (specs 075 + 081).\n","items":{"type":"object","properties":{"name":{"type":"string","description":"sogs | tileset"},"manifestGsUri":{"type":"string"},"signed":{"type":"boolean","description":"Always false."},"reason":{"type":"string"},"fileCount":{"type":"integer","nullable":true,"description":"How many files the bundle holds, from the conversion receipt.\n`null` when there is no receipt to read it from — every conversion\nbefore 2026-09-19. Always present; never absent.\n"},"totalBytes":{"type":"integer","nullable":true,"description":"Total size of the bundle in bytes, same source and same `null` rule\nas `fileCount`. A bundle cannot be signed, so these two are how you\nsize the transfer before arranging one.\n"}}}},"conversion":{"$ref":"#/components/schemas/Conversion"},"receipt":{"type":"object","nullable":true,"description":"What the converter actually DID, as opposed to what `conversion` says was\ndecided. `null` when the conversion predates this field or wrote no\nreceipt — including every conversion made before 2026-09-20 18:40 UTC,\nwhen the converter that writes it went live.\n\nTwo things are readable nowhere else. **Did Tidy cull it?** `tidy` carries\nthe cross-check over every stage of the conversion, so `applied` is true\nonly if the cull really happened on all of them, and `capped` tells you\nthe request hit the worker's ceiling and removed LESS than you asked.\n**Where is the subject?** `sourceBounds` answers that without downloading\nthe PLY.\n","properties":{"schema":{"type":"integer","description":"Always `1`. A receipt of any other schema is withheld entirely rather\nthan returned unvetted.\n"},"jobId":{"type":"string","description":"The conversion job's own id, e.g. `sog-job-d8e693ed` — not this job's."},"sourceJobId":{"type":"string","nullable":true},"revision":{"type":"integer","nullable":true,"description":"Which conversion of the job this receipt describes: 0 = the automatic one, n = the n-th re-conversion. `null` on receipts written before 2026-09-20 19:30 UTC — use the `-r<n>` suffix of `jobId` for those."},"convertedAt":{"type":"string","format":"date-time"},"conversionStandard":{"type":"integer","description":"The converter era. Compare it to decide whether an asset is stale."},"convertedWith":{"type":"object","description":"The knobs actually used: `shStripBand`, `shIterations`, `spz`,\n`lodLadder`. Omitted if the converter recorded none. The identity of\nthe machine that ran the conversion is not part of this response.\n"},"outputs":{"type":"object","description":"Size in bytes of each artifact, `null` where it was not requested or\nnot produced: `sog`, `spz`, `sogs`, `tileset`, `proxy`, `hero`, `pano`,\nplus `sogsFileCount` and `tilesetFileCount`.\n"},"requested":{"type":"object","nullable":true,"description":"What this conversion set out to produce: the caller's `outputs` for `sog`, `sogs`, `tileset` and `proxy`, plus the worker's defaults (`spz` and `share` ride along with `sog`/`sogs`). Read it with `outputs`: `requested.proxy: false` + `outputs.proxy: null` = never asked for; `requested.share: true` + `outputs.hero: null` = attempted and not produced (see `errors`). `share` covers `hero` and `pano`. `null` on older receipts."},"errors":{"type":"object","description":"`{proxy, share}` booleans: true when that optional artifact failed.\nNeither failure fails the conversion, so this is the only record that\none was attempted. The underlying message is the converter's own log —\nit is machine-local diagnostics, not part of this response. Why it failed is in `errorCodes`.\n"},"errorCodes":{"x-status":"planned","x-status-note":"Needs the conversion worker restarted with this change.","type":"object","description":"Why an optional artifact is missing, when `errors` says it failed: `timeout` — the stage exceeded its time limit and was stopped; `unsupported` — the converter on the worker cannot make it yet; `failed` — anything else, including an upload failure. `null` when it did not fail, was not requested (see `requested`), or the receipt predates this field. Never the converter's own message.","properties":{"proxy":{"type":"string","nullable":true,"enum":["timeout","unsupported","failed",null]},"share":{"type":"string","nullable":true,"enum":["timeout","unsupported","failed",null]}}},"tidy":{"type":"object","description":"`{requested, applied, capped, removedFrac, stages, stageStatus}`. `applied:false`\nwith `requested:true` means the cull was asked for and did not happen.\n`degraded:true` means one stage fell back to the raw splat, and\n`inconsistent:true` that the stages disagreed on what they removed.\n","properties":{"stageStatus":{"type":"object","description":"Whether each optional Tidy stage actually ran. `stages.speckle: 0` with `stageStatus.speckle: ran` means nothing qualified; any other status means the stage did not run and 0 says nothing about the scene. When `capped` is true, `stages` and `stageStatus` report what WOULD have been removed; nothing was applied.","properties":{"speckle":{"type":"string","enum":["ran","off","too_few_splats","grid_too_large","unavailable","error"]}}}}},"r2MirrorOk":{"type":"boolean","nullable":true,"description":"`null` = no mirror configured; `false` = the hosted copy is missing."},"publicUrls":{"type":"object","description":"Hosted CDN addresses for this job's converted assets — one per artifact\n(`sog`, `spz`, `sogs`, `tileset3d`, `proxy`, `hero`, `pano`). `tileset` is an alias\nof `tileset3d` (same URL) so the key matches the bundle's name; both are returned.\n\n**They are unauthenticated bearer capabilities and they do not expire.**\nUnlike the signed `files[].url` above there is no TTL and nothing to\nrevoke: anyone who gets one of these can fetch that asset for as long as\nit exists. Treat them exactly as you treat your API key — keep them out\nof logs, tickets, screenshots and client bundles, and hand them only to\npeople you mean to give the asset to.\n\nThey are safe to hold because the path is unguessable: your job's mirror\nlives under a private 192-bit prefix rather than under the job id, which\nis 8 hex characters and therefore guessable by anyone. The field is\npresent only when that prefix was applied, and withheld entirely\notherwise — a withheld `publicUrls` means the hosted copy sits at a\nderivable address and we will not hand it out.\n\nThe prefix belongs to the JOB and is stable; the ADDRESS is not. Every\nre-conversion (`POST /v1/jobs/{jobId}/convert`) is published at its OWN\npath — `<prefix>/r{revision}/…` — and every object is written exactly\nonce, so a caching host can never serve you the wrong bytes at one of\nthese URLs.\n\nAn address you already hold is never retired. Every conversion's copy is\nkept for as long as the job exists, so a link you embedded a year ago\nstill resolves to the bytes it resolved to then, and a re-conversion can\nnever break a page that already cites one. Read `publicUrls` again after\neach re-conversion to learn the NEW address — the old one keeps working,\nit simply stops being the latest — and never parse or rebuild one: the\nhost, the path and the revision segment may all change.\n\nThe signed `files[].url` above are the other half of this and behave\ndifferently: they come from GCS, whose object NAMES are fixed and\noverwritten in place, so while `conversion.queued` is true a signed URL\nmay hand you either conversion's bytes. `receipt.jobId` is the authority\nfor both — revision *N* writes `sog-job-{jobId}-r{N}`, and the automatic\nconversion writes the bare `sog-job-{jobId}`.\n"},"sourceBounds":{"type":"object","nullable":true,"description":"Geometry of the INPUT splat, measured during conversion; `null` if that\nmeasurement failed (see `sourceBoundsError`).\n\n`bulkBox` is `{k, min[3], max[3], inside, insideFrac}` — the median ± 4×IQR\nbox on each axis, which is what to frame, crop and size an LOD on without\ndownloading the PLY. Prefer it to the full bounding box and to `r90`:\nbackground/sky splats routinely make up ~23% of a capture, which inflates\nthe full box by orders of magnitude and drags `r90` out into the shell\nwith it (measured: half-extent 293 against a full box of 22,502).\n`insideFrac` is the fraction of splats the box holds.\n\nAlso carries `count`, the full-box `dx/dy/dz/diag/cx/cy/cz`, the median\nposition `mx/my/mz`, radii `r50/r90/r99` around it, per-axis percentiles\nin `axes.{x,y,z}`, and `scale` (splat sizes; `giants20x` counts the\noversized shell splats) when the PLY carries scale properties.\n\n**Frame:** these numbers are in the PLY's own frame. The 3D Tiles output is in the converter's frame — (x, z, −y) relative to the PLY — so do not frame a tileset from `sourceBounds` without that swap (the PLY's +y is the tileset's −z).\n"},"sourceBoundsError":{"type":"string","nullable":true,"enum":["unavailable",null],"description":"`\"unavailable\"` when the measurement failed, otherwise `null`."}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/estimate":{"get":{"tags":["Estimates"],"summary":"Estimate cost before committing","operationId":"getEstimate","parameters":[{"name":"frames","in":"query","schema":{"type":"integer"}},{"name":"megapixels","in":"query","schema":{"type":"number"}},{"name":"width","in":"query","schema":{"type":"integer"},"description":"Pixel width of one frame. Used with `height` when `megapixels` is absent."},{"name":"height","in":"query","schema":{"type":"integer"},"description":"Pixel height of one frame. Used with `width` when `megapixels` is absent."},{"name":"iterations","in":"query","schema":{"type":"integer"}},{"name":"engine","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Cost estimate with confidence band","content":{"application/json":{"schema":{"type":"object","properties":{"estimatedCost":{"type":"number","description":"What the job is expected to cost, in `currency`. This is the number to quote: it already includes the service multiple over raw GPU time, so it does NOT equal `minutes` × `gpu.pricePerHour` — it is twice that."},"p50":{"type":"number"},"p90":{"type":"number"},"confidence":{"type":"string"},"gpu":{"type":"string"},"inputs":{"type":"object","description":"What the routing actually ran on, and which of it was assumed rather than supplied.","properties":{"frames":{"type":"integer"},"megapixels":{"type":"number"},"framesAssumed":{"type":"boolean"},"megapixelsAssumed":{"type":"boolean","description":"True when no megapixels/width+height was sent and 4 MP was assumed. A capture above 12 MP routes to a different GPU at a different price."}}},"warnings":{"type":"array","items":{"type":"string"},"description":"Empty when nothing was assumed. Read it before reading the cost."}}}}}}},"description":"A routing-and-price sketch for a slider. It never refuses a missing input: anything absent is assumed, and `inputs.*Assumed` plus `warnings` say which. Quote an END USER from `POST /v1/jobs/quote` instead — that endpoint requires `frames`, reports the clamps the dispatcher would apply, and is the number a create will agree with.\n"}},"/v1/gpu-catalog":{"get":{"tags":["Estimates"],"summary":"Available GPUs, capacities, and rates","operationId":"getGpuCatalog","responses":{"200":{"description":"GPU catalog","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}}},"/v1/usage":{"get":{"tags":["Account"],"summary":"Usage and spend","operationId":"getUsage","parameters":[{"name":"view","in":"query","description":"Legacy: completed-only. Activity: all statuses.","schema":{"type":"string","enum":["legacy","activity"],"default":"legacy"}},{"name":"userId","in":"query","description":"Self credentials may only name their own qualified subject.","schema":{"type":"string"}},{"name":"projectId","in":"query","description":"Optional project filter.","schema":{"type":"string"}},{"name":"startDate","in":"query","description":"Inclusive ISO date or timestamp.","schema":{"type":"string"}},{"name":"endDate","in":"query","description":"Inclusive ISO date or timestamp; date-only means midnight UTC.","schema":{"type":"string"}}],"responses":{"200":{"description":"Usage summary","content":{"application/json":{"schema":{"type":"object","properties":{"appId":{"type":"string"},"userId":{"type":["string","null"]},"projectId":{"type":["string","null"]},"summary":{"type":"object","properties":{"totalJobs":{"type":"integer","minimum":0},"completedJobs":{"type":"integer","minimum":0},"failedJobs":{"type":"integer","minimum":0},"activeJobs":{"type":"integer","minimum":0},"cancelledJobs":{"type":"integer","minimum":0},"otherJobs":{"type":"integer","minimum":0},"tiledJobs":{"type":"integer","minimum":0},"estimatedCostJobs":{"type":"integer","minimum":0},"actualCostJobs":{"type":"integer","minimum":0},"gpuMinutesJobs":{"type":"integer","minimum":0},"totalEstimatedCost":{"type":["number","null"],"description":"Known-value subtotal; null when nonempty activity results have no reported values. Not wallet charges."},"totalActualCost":{"type":["number","null"],"description":"Known-value subtotal; null when nonempty activity results have no reported values. Not wallet charges."},"totalGpuMinutes":{"type":["number","null"],"description":"Known-value subtotal; null when nonempty activity results have no reported values. Not wallet charges."}}},"coverage":{"type":"object","additionalProperties":true},"byQuality":{"type":"object","additionalProperties":true},"byUser":{"type":"object","additionalProperties":true}}}}}},"400":{"description":"Invalid filter or usage_limit_exceeded; no partial totals."},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"forbidden_usage: another user requested with self credential."}},"description":"Opt in with view=activity for the new all-status report. Existing default completed-only response semantics are preserved. Owner-scoped usage using the same application/subject constraints as listJobs. All job states counted, including active/failed/cancelled/other. Actual and estimated costs stay separate; group cost is actual only. Nonempty scopes without reported cost return null, known zero stays zero. Cost coverage counts identify partial known-value subtotals; these are not wallet charges. Reads cap at 5000 jobs per app/user/project scope; larger scopes return INVALID_REQUEST with reason usage_limit_exceeded and no partial totals. Date filters apply after the read cap, inclusively; a date-only endDate means midnight. Undated records excluded by date filtering are counted in coverage.excludedUndatedJobs. Cross-user filters on self credentials return 403. Responses are private/no-store. Legacy queries apply status=complete before the read cap. Security correction: non-delegating credentials receive only their own usage in both views; app-wide reporting requires delegation."}},"/v1/health":{"get":{"tags":["Account"],"summary":"Service health","operationId":"healthCheck","security":[],"responses":{"200":{"description":"Healthy","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"}}}}}}}}},"/v1/jobs/quote":{"post":{"tags":["Jobs"],"summary":"Cost and GPU for a job you have not uploaded yet","operationId":"quoteJob","description":"Answerable from frame count and frame dimensions alone, BEFORE a byte moves.\n\nCheck `feasible` and `limits.maxFrames` BEFORE affordability: a capture over the\ncap cannot run at any price, so \"you cannot afford this\" would send a user to buy\ncredits for a job that still would not start.\n\nNOTE the nesting — cost lives under `estimate`, not at the top level.\nField is `frames`, not `frameCount`.\n\nThis service does not hold the wallet, so `account.balance` and\n`account.affordable` are always null. Null means UNKNOWN, never zero, and never\npermission to proceed.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["frames"],"properties":{"frames":{"type":"integer","description":"keyframes you intend to upload"},"width":{"type":"integer"},"height":{"type":"integer"},"megapixels":{"type":"number","description":"alternative to width/height"},"inputType":{"type":"string","enum":["zip","video","mov","360","colmap","capture"]},"recipe":{"$ref":"#/components/schemas/Recipe"}}},"example":{"frames":191,"width":3840,"height":2160}}}},"responses":{"200":{"description":"Quote. `feasible:false` returns options instead of an error.","content":{"application/json":{"schema":{"type":"object","properties":{"feasible":{"type":"boolean"},"inputs":{"type":"object","properties":{"frames":{"type":"integer"},"megapixels":{"type":"number"},"inputType":{"type":"string"}}},"gpu":{"type":"object","properties":{"id":{"type":"string"},"machineType":{"type":"string"}}},"estimate":{"type":"object","properties":{"currency":{"type":"string"},"cost":{"type":"number","description":"What the job is expected to cost, in `currency`. This is the number to quote: it already includes the service multiple over raw GPU time, so it does NOT equal `minutes` × `gpu.pricePerHour` — it is twice that."},"costLow":{"type":"number"},"costHigh":{"type":"number","description":"The P90 end of the live model's band.\n\n_Not deployed: on the 360 lanes (`inputType: insv` or `360`) this will be the MEASURED high rather than a P90 — the highest of a handful of complete production jobs — with `costLow` null because no low end is measured there. Spec 092, merged, functions lane held. Today a 360 quote carries `costHigh: null`. (This sentence qualifies a key that is live on every other lane, so it carries no `x-status` marker of its own.)_"},"minutes":{"type":"integer","description":"Expected GPU minutes. A duration, not a price input: multiplying it by an hourly rate under-prices the job by half. See `cost`."},"minutesHigh":{"x-status":"planned","x-status-note":"Spec 092 is merged but not deployed yet (the functions lane is held); today's quote carries no `minutesHigh` on any lane.","type":"integer","description":"360 lanes only: the minutes sibling of `costHigh`. Absent on every other lane, where the model's band is already reported as `costLow` / `costHigh`."},"confidence":{"type":"string"},"source":{"type":"string"},"sampleSize":{"type":"integer"},"iterationsMultiplier":{"type":"number","description":"Applied scaling vs the 30k baseline. Present only when != 1."},"note":{"type":"string","description":"Set when the estimate is extrapolated beyond well-sampled inputs."}}},"limits":{"type":"object","properties":{"maxFrames":{"type":"integer"},"gaussianBudget":{"type":"integer"}}},"adjustments":{"type":"array","items":{}},"account":{"type":"object","properties":{"balance":{"type":"string","nullable":true},"affordable":{"type":"string","nullable":true},"note":{"type":"string"}}}}},"example":{"feasible":true,"inputs":{"frames":191,"megapixels":8.2944,"inputType":"zip"},"gpu":{"id":"rtx_4090","machineType":"runpod-secure"},"estimate":{"currency":"USD","cost":4.1,"costLow":4.1,"costHigh":6.38,"minutes":175,"confidence":"high","source":"live","sampleSize":50},"limits":{"maxFrames":1200,"gaussianBudget":2500000},"adjustments":[],"account":{"balance":null,"affordable":null,"note":"This service does not hold the wallet; it cannot say whether you can afford this. Compare `estimate.cost` against a balance from the webapp. A null balance is unknown, not zero."}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/me":{"get":{"tags":["Account"],"summary":"Who this credential resolves to, and what it may do","operationId":"getMe","description":"Lets a client tell apart the three states that all otherwise look like \"no\":\nnot signed in, signed in with unknown balance, signed in and out of funds.\n\n`account.balance` is always null — this service does not hold the wallet.","responses":{"200":{"description":"Resolved identity","content":{"application/json":{"schema":{"type":"object","properties":{"authenticated":{"type":"boolean"},"identity":{"type":"object","properties":{"userId":{"type":"string","nullable":true,"description":"null for a delegating server credential; for a mobile token this is <firebaseProject>:<uid>"},"appId":{"type":"string"},"appName":{"type":"string"},"tenantId":{"type":"string"},"authMethod":{"type":"string","enum":["api_key","firebase_id_token"]},"authProvider":{"type":"string","nullable":true},"emailVerified":{"type":"boolean","nullable":true},"canActForOtherUsers":{"type":"boolean"}}},"limits":{"type":"object","properties":{"requestsPerHour":{"type":"integer"},"scope":{"type":"string","enum":["self","tenant"]}}},"account":{"type":"object","properties":{"balance":{"type":"number","nullable":true},"currency":{"type":"string","nullable":true},"balanceSource":{"type":"string"},"note":{"type":"string"}}},"jobs":{"type":"object","properties":{"visibleRecent":{"type":"integer","nullable":true}}},"webhook":{"x-status":"planned","x-status-note":"Not deployed: the field is absent today.","type":"object","description":"Always `false` for a Firebase ID token — only an API key can hold a webhook secret.\n","properties":{"secretConfigured":{"type":"boolean"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/openapi.json":{"get":{"tags":["Account"],"summary":"This document","operationId":"getOpenApi","security":[],"description":"Unauthenticated so a client can learn every shape without a credential.","responses":{"200":{"description":"This OpenAPI document as JSON","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/v1/jobs/{jobId}/cancel":{"post":{"tags":["Jobs"],"summary":"Stop a running job","operationId":"cancelJob","description":"Deletes the underlying compute job and marks the record cancelled.\n\nIdempotent: cancelling an already-terminal job returns 200 with `cancelled: false` rather than an error — a client retrying a cancel should not be told off for a thing that is already true.\n\n`computeStopped: false` means it was cancelled BEFORE dispatch and nothing was billed. `true` means a running job was stopped; GPU time already consumed remains billable.","parameters":[{"$ref":"#/components/parameters/JobId"}],"responses":{"200":{"description":"Cancelled, or already terminal","content":{"application/json":{"schema":{"type":"object","properties":{"jobId":{"type":"string"},"status":{"$ref":"#/components/schemas/JobStatus"},"cancelled":{"type":"boolean"},"computeStopped":{"type":"boolean","nullable":true},"message":{"type":"string"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"description":"Compute could not be stopped; the job is NOT cancelled. Retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/jobs/{jobId}/convert":{"post":{"tags":["Jobs"],"summary":"Convert a finished job's .ply again","operationId":"convertJob","description":"Re-runs conversion on a **complete** job's existing `.ply` — no retraining, no GPU rental. This is how you apply `tidy` (or different thresholds) after reading the receipt from `/downloads` and finding that the first conversion was content-faithful, or that the cull hit its `maxRemoveFrac` cap.\n\nThe body is the same object as `conversion` on `POST /v1/jobs`, one level up: `{\"tidy\": {\"maxRemoveFrac\": 0.35}}`.\n\n**Every re-conversion is published at a NEW hosted address**, so a `publicUrls` link you already hold is never overwritten and never goes stale. Two consequences:\n\n- The hosted addresses CHANGE on every re-conversion, and the old ones keep working. Read `publicUrls` again from `/downloads` once `receipt.jobId` ends in `-r{N}` for the revision this call booked, and never parse or rebuild one. The address you hold today is never retired: it keeps serving its own conversion for as long as the job exists, so it is safe to embed in a page that will outlive this call. The signed `files[].url` are different: those come from GCS, whose object names are fixed and overwritten in place, so while `conversion.queued` is true a signed URL may serve either conversion's bytes. `receipt.jobId` from `/downloads` is the authority either way: revision *N* writes `sog-job-{jobId}-r{N}`, and the automatic conversion writes the bare `sog-job-{jobId}`.\n- Every revision is kept. The hosted copy of each conversion stays where it was published for as long as the job exists, so the previous asset is still at the previous address — take the URL from the receipt you read before the re-conversion.\n\nRails, because the converter is a single machine and one conversion holds it for 10–15 minutes: **5 re-conversions per rolling 24 hours**, **99 per job** (the lifetime ceiling, which is what the `r{N}` address format can spell), and **one in flight at a time**. Over the daily cap returns 429 `RATE_LIMIT_EXCEEDED` with a `Retry-After` header and `error.details.retryAfter` — seconds until an earlier booking leaves the window — and books nothing, publishes nothing and spends no revision. A second call while the first is still running returns 400 `INVALID_JOB_STATE` with `retryable: true`; wait for the receipt and call again.\n\nNot for `gigascape` jobs (the webapp owns their conversion) or megasplat tiles (the assembly pipeline owns theirs).","parameters":[{"$ref":"#/components/parameters/JobId"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversionRequest"},"example":{"tidy":{"maxRemoveFrac":0.35}}}}},"responses":{"202":{"description":"Queued, and it will be published at a NEW hosted address. The worker picks it up within minutes; poll `/v1/jobs/{jobId}/downloads` until `receipt.jobId` ends in `-r{revision}`, then read `receipt.publicUrls` again. Addresses you already hold are never retired: each keeps serving its own conversion for as long as the job exists.","content":{"application/json":{"schema":{"type":"object","properties":{"jobId":{"type":"string"},"conversion":{"$ref":"#/components/schemas/Conversion"},"note":{"type":"string"}}},"example":{"jobId":"d8e693ed","conversion":{"queued":true,"reason":"reconvert","revision":1,"requestedAt":"2026-09-19T18:04:11.204Z","requestLog":["2026-09-19T18:04:11.204Z"],"request":{"tidy":{"maxRemoveFrac":0.35},"outputs":null},"decidedAt":"2026-09-18T02:11:47.880Z","outputs":["sog","sogs","tileset"],"splatCount":2411008},"note":"This conversion is published at a NEW hosted address. Poll /downloads until receipt.jobId ends with -r<conversion.revision>, then read receipt.publicUrls again. Addresses you already hold are never retired: each keeps serving its own conversion for as long as the job exists."}}}},"400":{"description":"Refused: an invalid threshold, a job that is not complete, a job whose conversion is owned elsewhere, a splat above the converter's ceiling, the 99-per-job lifetime limit, or a conversion still running (`retryable: true`). The 5-per-day cap is a 429, not a 400.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"description":"Over a rate limit: either the daily re-conversion cap (5 per job per rolling 24 hours) or your credential's hourly request limit. `Retry-After` and `error.details.retryAfter` give the seconds to wait. Nothing was booked and no revision was spent.","headers":{"Retry-After":{"schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Nothing was queued; the revision was rolled back. Retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/me/keys":{"get":{"summary":"List your API keys","description":"**Requires a signed-in user (Firebase ID token). An API key cannot manage API keys.** Otherwise a leaked key could mint replacements that outlive revoking it — the thing granting long-lived access has to sit behind something short-lived.\n\nThe secret is never returned — only its SHA-256 is stored, so it cannot be shown again or recovered.\n","responses":{"200":{"description":"Your keys, newest first.","content":{"application/json":{"schema":{"type":"object","properties":{"keys":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"}},"limits":{"type":"object","properties":{"maxKeys":{"type":"integer"},"live":{"type":"integer"}}}}}}}},"403":{"description":"Called with an API key instead of a user token."}}},"post":{"summary":"Create an API key","description":"**Requires a signed-in user (Firebase ID token). An API key cannot manage API keys.** Otherwise a leaked key could mint replacements that outlive revoking it — the thing granting long-lived access has to sit behind something short-lived.\n\n**The secret is returned exactly once, in this response.** Copy it immediately; it cannot be retrieved later.\n\nKeys are free — you do not need a paid plan to hold one. Reading the public catalogue (`/v1/explore`-style endpoints on twinbly.com) needs no key at all; a key is what lets you CREATE splats, and creating spends from your wallet.\n\nA self-serve key speaks only for you: it cannot act on behalf of other users, and it sees only its owner's jobs.\n","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"label":{"type":"string","maxLength":80,"description":"Your own name for it, e.g. \"CI\" or \"laptop\". Shown in the list."}}},"example":{"label":"CI"}}}},"responses":{"201":{"description":"Created. Contains the secret — once.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiKey"},{"type":"object","properties":{"key":{"type":"string","description":"The secret. Shown once."},"warning":{"type":"string"}}}]},"example":{"key":"rgs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","fingerprint":"a1b2c3d4e5f6","label":"CI","revoked":false,"rateLimitPerHour":1000,"warning":"Copy this key now — it is shown once and cannot be retrieved later."}}}},"400":{"description":"`key_limit_reached` — revoke one first."},"403":{"description":"Called with an API key instead of a user token."}}}},"/v1/me/keys/{fingerprint}":{"delete":{"summary":"Revoke an API key","description":"**Requires a signed-in user (Firebase ID token). An API key cannot manage API keys.** Otherwise a leaked key could mint replacements that outlive revoking it — the thing granting long-lived access has to sit behind something short-lived.\n\nImmediate. Matched within your own keys only, so a fingerprint cannot be used to probe for or disable somebody else's credential.\n","parameters":[{"name":"fingerprint","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Revoked (or already was)."},"403":{"description":"Called with an API key instead of a user token."},"404":{"description":"No key of yours matches that fingerprint."}}}},"/v1/me/webhook-secret":{"post":{"x-status":"planned","x-status-note":"Not deployed: the route 404s. Ships with the callback delivery changes.","summary":"Create or rotate your webhook signing secret","description":"**Called with an API key — not a user token.** A webhook secret belongs to a key, so it is the key that manages it (the opposite of `/v1/me/keys`, which refuses keys). Revoking the key deletes its secret too.\n\n**The secret is returned exactly once, in this response.** Copy it now; it cannot be retrieved later.\n\nPOSTing again rotates it, effective immediately for jobs created from that point on. A job already created keeps signing with the secret it was created under, so an in-flight job's callback never changes signature underneath you.\n","responses":{"201":{"description":"Created. Contains the secret — once.","content":{"application/json":{"schema":{"type":"object","properties":{"secret":{"type":"string","description":"`whsec_` + 32 random bytes, base64url."},"createdAt":{"type":"integer","description":"Unix ms."},"note":{"type":"string"}}},"example":{"secret":"whsec_REDACTED_copy_this_value_from_your_own_response","createdAt":1758240000000,"note":"Shown once. Rotating replaces it immediately."}}}},"403":{"description":"Called with a Firebase ID token instead of an API key."}}},"get":{"x-status":"planned","x-status-note":"Not deployed: the route 404s. Ships with the callback delivery changes.","summary":"Is a webhook secret configured?","description":"**Called with an API key — not a user token.** A webhook secret belongs to a key, so it is the key that manages it (the opposite of `/v1/me/keys`, which refuses keys). Revoking the key deletes its secret too.\n\nNever returns the value — only whether one exists.\n","responses":{"200":{"description":"Configuration state.","content":{"application/json":{"schema":{"type":"object","properties":{"configured":{"type":"boolean"},"createdAt":{"type":"integer","nullable":true}}},"example":{"configured":true,"createdAt":1758240000000}}}},"403":{"description":"Called with a Firebase ID token instead of an API key."}}},"delete":{"x-status":"planned","x-status-note":"Not deployed: the route 404s. Ships with the callback delivery changes.","summary":"Delete your webhook signing secret","description":"**Called with an API key — not a user token.** A webhook secret belongs to a key, so it is the key that manages it (the opposite of `/v1/me/keys`, which refuses keys). Revoking the key deletes its secret too.\n\nCallbacks keep arriving; they stop being signed (unless a legacy per-app secret is provisioned for your app).\n","responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"configured":{"type":"boolean"}}},"example":{"configured":false}}}},"403":{"description":"Called with a Firebase ID token instead of an API key."}}}}},"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"For servers, CLIs, and integrations. Issue with\n`node scripts/api-credential.js issue`.\n"},"FirebaseToken":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"A Firebase ID token, for native apps. Identity is derived from the\ntoken — do not send `userId`. Anonymous sign-in is refused by default.\n"}},"parameters":{"JobId":{"name":"jobId","in":"path","required":true,"schema":{"type":"string"}}},"responses":{"BadRequest":{"description":"Invalid request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Unauthorized":{"description":"Missing or invalid credential","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Forbidden":{"description":"Authenticated, but not permitted — e.g. asserting a `userId` this\ncredential may not act for.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"No such job","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"RateLimited":{"description":"Hourly rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"schemas":{"Conversion":{"type":"object","nullable":true,"description":"What the pipeline decided about converting this job's `.ply` into\nviewable assets, recorded when training completed. Null on jobs that\nfinished before this was written. A REFUSAL is reported here rather\nthan left silent: above the converter's splat ceiling the job still\nsucceeds and still produces a `.ply`, and `reason` is the only way to\nlearn that no `.sog` or tileset is coming.\n\n`POST /v1/jobs/{jobId}/convert` updates this same object in place:\n`reason` becomes `reconvert`, `revision` counts up, and `outputs`\nfollows the latest request. `decidedAt` and `splatCount` still record\nthe automatic decision made at completion time.\n","properties":{"queued":{"type":"boolean"},"reason":{"type":"string","enum":["full-set","tileset-only-webapp-owns-sogs","exceeds-worker-ceiling","disabled","no-ply","megasplat-tile","queue-failed","reconvert","publish-failed"]},"outputs":{"type":"array","description":"What the conversion that is queued asked for — sog | sogs | tileset.\nEmpty when nothing was queued. A re-conversion that narrows the set\nrewrites this, so it always describes the latest conversion rather\nthan the automatic one. Always the concrete list that was queued — never\nnull — so it is the field to read; request.outputs only echoes what the\ncaller sent.\n","items":{"type":"string"}},"splatCount":{"type":"integer","nullable":true,"description":"The trained splat count the decision was made on, when known."},"decidedAt":{"type":"string","format":"date-time"},"revision":{"type":"integer","description":"How many times this job has been re-converted. Absent (or 0) means the\nautomatic conversion only. The worker id is `sog-job-{jobId}` at 0 and\n`sog-job-{jobId}-r{revision}` after that, which is what `receipt.jobId`\nfrom `/downloads` reports — the only way to tell the new outputs from\nthe old, since the GCS objects keep fixed names and are overwritten in\nplace. Each revision's HOSTED copy is published at its own path instead.\nCapped at 99 per job, 5 per rolling 24 hours.\n"},"requestLog":{"type":"array","items":{"type":"string","format":"date-time"},"description":"The re-conversions booked in the last 24 hours — what the daily limit counts. Oldest first, pruned on every booking, so its length is how many of the 5 are spent. Absent means none, which is a full allowance.\n"},"requestedAt":{"type":"string","format":"date-time","description":"When the latest re-conversion was asked for. Absent until one is."},"request":{"$ref":"#/components/schemas/ConversionRequest"}}},"ConversionRequest":{"type":"object","description":"What to ask the conversion worker for. Send it as `conversion` on\n`POST /v1/jobs` (it runs when training finishes) or as the whole body of\n`POST /v1/jobs/{jobId}/convert` (it runs now, on the finished `.ply`).\n\nOmit it entirely for a content-faithful conversion — that is the default\nand it does not change.\n","properties":{"tidy":{"description":"The pre-SOG cull: strip the giant and floater splats that poison SOG's\n8-bit scale quantisation (the \"corduroy\"), and that a background shell\nproduces in quantity — one of our own aerials has a tileset root half\nextent of 22,502 around a subject 73 units across.\n\n`true` uses the worker's own thresholds. An object overrides individual\nthresholds and leaves the rest at the worker's defaults; `{}` means the\nsame as `true`. Absent or `false` means no cull.\n\nThe cull refuses to remove more than `maxRemoveFrac` of the splat and\nreports what it did in the conversion receipt (`tidy.applied`,\n`tidy.removedFrac` from `/downloads`) — raise `maxRemoveFrac` and call\n`/convert` again if it capped.\n","oneOf":[{"type":"boolean"},{"type":"object","properties":{"maxScaleMult":{"type":"number","minimum":2,"maximum":1000,"description":"Cull a splat whose scale exceeds this multiple of the median."},"floaterIqrK":{"type":"number","minimum":1,"maximum":50,"description":"Cull isolated splats this many IQRs outside the cloud."},"compFrac":{"type":"number","minimum":0,"maximum":0.5,"description":"Drop connected components smaller than this fraction of the splat."},"compCell":{"type":"number","minimum":0.01,"maximum":1000,"description":"Cell size, in scene units, the component pass groups on."},"speckle":{"type":"boolean","description":"Run the speckle pass."},"maxRemoveFrac":{"type":"number","minimum":0.001,"maximum":0.6,"description":"Hard cap on what the cull may remove. The cull is ABANDONED and the\nraw splat converted if it would exceed this, which the receipt\nreports — it is a safety rail, not a target.\n"}},"additionalProperties":false}]},"outputs":{"type":"array","description":"Narrow what is produced. Absent means all three. null or omitted means the default set: sog, sogs and tileset.","items":{"type":"string","enum":["sog","sogs","tileset"]},"minItems":1}},"additionalProperties":false},"JobStatus":{"type":"string","enum":["pending_upload","preprocessing","queued","dispatched","training","tiling","exporting","complete","failed","preview_ready"]},"Recipe":{"type":"object","description":"What to build. Dispatch is driven by this plus the shape of your input\n— never by a marketing tier.\n","properties":{"engine":{"type":"string","enum":["lichtfeld","nerfstudio"],"default":"lichtfeld"},"iterations":{"type":"integer","default":30000,"minimum":1000,"maximum":100000,"description":"Training iterations. COST SCALES WITH THIS — measured over 463 completed jobs: 30k -> 105 median GPU-min, 38k -> 128 (1.22x), 45k -> 196 (1.87x), 10k -> 27 (0.26x). Well sampled between 25k and 45k; outside that band the quote is extrapolated and returns confidence \"low\" with a note. Above ~45k there is little evidence of quality gain and cost keeps climbing."},"targetMegapixels":{"oneOf":[{"type":"number","minimum":0.5,"maximum":50},{"type":"string","enum":["preserve"]}],"default":"preserve","description":"Downscale frames before training, or \"preserve\" for native. Prefer \"preserve\": measured frame SHARPNESS mattered more than pixel count (a 8.3MP sharp capture beat a 12MP soft one)."},"useGut":{"type":"boolean","description":"Ray-traced reflections and native fisheye/360. Caps around 3M gaussians on 16GB GPUs, so it trades density for reflection fidelity. Needs a ray-tracing-capable viewer to see the benefit.","default":false},"maxGaussians":{"type":"integer","minimum":100000,"maximum":5000000,"description":"Gaussian cap.\n\n**Omit it and one is chosen for you** — the routed GPU's own `gaussianBudget` (l4/rtx_4090 2.5M, l40s 4M, a100_80g 5M), or a lower figure if you sent a `captureTier`: `crisp` takes the full budget, `standard` 1.5M, `blockin` 500K. Omitting is the recommended default; send a value only to ask for LESS than the hardware would give.\n\nWhen you DO send one it is CLAMPED DOWN to the routed GPU's `gaussianBudget` if too large, and the clamp is reported in the response `adjustments` array — read it, or the price you quoted and the work that ran disagree. Note the clamp only applies to a value you actually sent; it is not what fills the gap when you send nothing.\n\nA cap far above what the capture supports does not improve the result: on a 0.5m-extent scene 2M produced visible over-densification.\n"},"rigKeepSeconds":{"type":"array","items":{"type":"number","minimum":0},"maxItems":400,"description":"`inputType:insv` only. The instants to export, in seconds on the summed\ntimeline of the capture, picked from `preflightReport.plan.tSec`. Deduped\nand sorted server-side; malformed values are refused, never dropped. Omit\nto let the backend's fps table choose.\n"}}},"CreateJobRequest":{"type":"object","properties":{"recipe":{"$ref":"#/components/schemas/Recipe"},"quality":{"type":"string","enum":["preview","fast","standard","high","gigasplat"],"description":"Legacy alternative to `recipe`, mapped internally."},"inputType":{"type":"string","enum":["zip","video","mov","360","colmap","capture"],"default":"zip"},"inputSource":{"type":"string","enum":["manifest"],"x-status":"planned","x-status-note":"PARTLY deployed and NOT usable: `createJob` accepts this field (deployed 2026-09-21), but `startJob` and the preprocessor do not yet honour it, so a job created with `inputSource: \"manifest\"` fails when you start it. Do not use it until this marker is gone.","description":"WHERE the images come from. Omit it and you upload a ZIP yourself, which is the default and the only prior behaviour.\n\n`manifest` means your images already live in your own cloud storage and you will hand us a list of signed HTTPS URLs instead of moving the bytes twice. The job is still `inputType: \"zip\"` in every other respect: same routing, same pricing, same training. Only the upload changes.\n\n**What changes.** `uploadUrl` is signed for `manifest.json` with `Content-Type: application/json`. PUT the document described by `InputManifest`, then call `/start`. `/start` parses it, validates every rule, and cross-checks the number of files against the frame count this job was routed and priced for. A disagreement over 10% is refused: create a new job with the right count rather than train something that was priced as something else.\n\n**Sign every file URL for at least 6 hours.** We fetch when the job reaches the front of the queue, not when you call `/start`. A URL signed for 15 minutes on a job that queues for 20 gives a 403 on every file.\n\n**All or nothing.** One file we cannot fetch fails the whole job in `extracting`, before any GPU is booked and before you are charged. The failure names the files, never their URLs. There is no partial capture.\n\nOnly valid with `inputType: \"zip\"`, and not together with `inputFiles`, `reuseColmapFrom`, `reuseInsvFrom` or `flightFrameRange`: the manifest is the complete, ordered list of images this job trains on, and there is no second upload for anything to select from."},"userId":{"type":"string","description":"Only for credentials authorised to act on behalf of users. Omit it\nfor device and third-party credentials — identity comes from the\ncredential, and a mismatch is rejected with 403.\n"},"projectId":{"type":"string"},"callbackUrl":{"type":"string","format":"uri","description":"HTTPS endpoint POSTed when the job reaches a terminal state, signed with HMAC-SHA256. See **callbacks** on this operation for the headers, the verification snippet and the retry schedule.\n\nMust be a public hostname: IP literals, `localhost` and `.internal`/`.local` names are refused at create with `INVALID_URL_FORMAT`.\n"},"analysis":{"type":"object","description":"Optional analyzer-v2 scene/capture analysis; overrides training defaults.","additionalProperties":true},"mode":{"type":"string","enum":["scout","preflight"],"description":"`scout` — cheap COLMAP-only pre-analysis. Not valid with `inputType:colmap`,\nwhich already contains a reconstruction, nor with `inputType:insv`, whose\npanels do not exist until the stitch job has cut them.\n`preflight` — `inputType:insv` only: stitch ~36 preview frames, measure the\ncapture and stop at `preview_ready`. No training, and no charge.\n"},"reuseColmapFrom":{"type":"string","description":"Reuse a prior job's COLMAP instead of solving again. Not valid with\n`inputType:colmap` — the uploaded bundle IS the source.\n"},"reuseInsvFrom":{"type":"string","description":"The `mode:preflight` job this training job takes its upload from\n(`inputType:insv` only, and not together with `mode`). /start copies\nthe preview job's .insv across, so the file is uploaded once. It does\nNOT reuse COLMAP — the panels are cut fresh from the chosen instants.\nThe referenced job must be yours and `preview_ready`.\n"},"captureId":{"type":"string","maxLength":128,"description":"Client-side capture identifier. Echoed back, and doubles as the idempotency key — a retry with the same value returns the existing job."},"splatId":{"type":"string","description":"The library row this capture belongs to, created by your client BEFORE\nthe job so the capture is visible while it trains and a failure leaves a\nrow rather than nothing. Carrying it here is what lets `editorUrl` and\n`viewerUrl` be filled in later; omit it and both stay null forever.\n"},"captureTier":{"type":"string","enum":["crisp","standard","blockin"],"description":"The tier the capture was MADE to; fixes the point-filter bar. crisp gate 5 / filter >=5, standard 3 / >=3, blockin gate 2 / filter >=1. Display text like \"Block in\" is normalised."},"pipelineFilterViews":{"type":"integer","minimum":0,"maximum":50,"description":"Explicit override of the tier filter. 0 disables filtering."},"dryRun":{"type":"boolean","default":false,"description":"Validate and price this request, then create nothing.\n\nEvery gate a real create runs — recipe validation, feasibility, frame and megapixel ceilings, GPU routing, the cost estimate — runs identically. Only the job record, the resumable upload session and the job id are withheld. So the errors you iterate against are the REAL errors, not a simulation.\n\nUse it while you are building. Nothing is created, uploaded, or billed, and the 200 tells you what your recipe RESOLVED to — including fields you did not send, like `maxGaussians` — plus the GPU that would run it, the price, and any `adjustments` we made to what you asked for.\n\nMust be a real boolean. `\"false\"` is a string, and a string is truthy — we reject it rather than silently create nothing when you meant to create a job.\n"},"conversion":{"allOf":[{"$ref":"#/components/schemas/ConversionRequest"}],"description":"What to ask the conversion worker for when training finishes. Omit it\nand the conversion is content-faithful, which is the default and the\nonly behaviour before this field existed.\n\nValidated at create (and on `dryRun`), echoed back as\n`conversionRequest`, and applied once — you can change your mind\nafterwards with `POST /v1/jobs/{jobId}/convert`, which does not retrain.\n\nIgnored for `gigascape` jobs; that app's conversion is the webapp's.\n"}}},"InputManifest":{"type":"object","x-status":"planned","x-status-note":"Spec 078 is merged but not deployed yet; until the preprocessor and API ship, a job created with `inputSource: \"manifest\"` is an ordinary ZIP job and this document is not read.","description":"The document you PUT to `uploadUrl` when you created the job with `inputSource: \"manifest\"`.\n\nIt is not the body of any endpoint. It IS the upload: `PUT {uploadUrl}` with `Content-Type: application/json`, then `POST /v1/jobs/{jobId}/start`.\n\nAt most 2 MiB in total, which holds far more than the 4,000-file ceiling. Unknown fields are refused rather than ignored, top level and per file both: a misspelled `sha256` that we quietly dropped would be an integrity check you believe you have and do not.\n\n**Sign every `url` for at least 6 hours.** We fetch when the job reaches the front of the queue, not when you call `/start`.\n\n`/start` pins the exact object it validated. Replacing manifest.json afterwards has no effect: the job fetches the manifest that was validated, priced and routed. To change the list, create a new job.\n","required":["schema","files"],"additionalProperties":false,"properties":{"schema":{"type":"integer","enum":[1],"description":"The manifest schema version. Only 1 exists."},"files":{"type":"array","minItems":20,"maxItems":4000,"description":"The complete, ordered list of images this job trains on. Nothing is sampled, deduped or truncated server-side: you pick the frames, we train exactly those.\n\nThe floor of 20 is the preprocessor's own minimum image count, below which the job would fail anyway.\n","items":{"type":"object","required":["url","fileName"],"additionalProperties":false,"properties":{"url":{"type":"string","format":"uri","maxLength":4096,"description":"A signed HTTPS URL we can GET without credentials of our own. HTTPS only; no userinfo; port 443 or none.\n\nWe never follow a redirect (a 3xx is a failure) and we never send an Authorization header, so the signature in this URL has to be the whole of the authorisation. The hostname must resolve only to publicly routable addresses: private, loopback, link-local, multicast and reserved addresses are refused, as are `localhost`, `metadata.google.internal`, and any `.internal` or `.local` name.\n\nTreat this URL as a bearer credential. We never write one to a log line, an error message, or your job record.\n"},"fileName":{"type":"string","maxLength":255,"description":"The name this image gets in the job. A bare file name ending in .jpg, .jpeg or .png: no directories, no `..`, no leading dot. Unique within the manifest ignoring case, because every file lands in one directory.\n"},"size":{"type":"integer","minimum":1,"maximum":83886080,"description":"Optional. Bytes, up to 80 MiB. When present we abort a transfer that exceeds it, and fail the file if the finished size disagrees.\n"},"sha256":{"type":"string","pattern":"^[0-9a-fA-F]{64}$","description":"Optional. Verified as we stream; a mismatch fails the job.\n"}}}}},"example":{"schema":1,"files":[{"url":"https://storage.googleapis.com/your-bucket/DJI_0349.JPG?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Expires=21600&X-Goog-Signature=...","fileName":"DJI_0349.JPG","size":11234567,"sha256":"9f2c1d0e4b7a6358c1e5f0a2b3d4c5e6f708192a3b4c5d6e7f80912a3b4c5d6e"},{"url":"https://storage.googleapis.com/your-bucket/DJI_0350.JPG?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Expires=21600&X-Goog-Signature=...","fileName":"DJI_0350.JPG"}]}},"CreateJobResponse":{"type":"object","properties":{"inputSource":{"type":"string","enum":["manifest"],"x-status":"planned","x-status-note":"PARTLY deployed and NOT usable: `createJob` accepts this field (deployed 2026-09-21), but `startJob` and the preprocessor do not yet honour it, so a job created with `inputSource: \"manifest\"` fails when you start it. Do not use it until this marker is gone.","description":"Echoed back only when you asked for `inputSource: \"manifest\"`. Its absence means you are uploading the images yourself, which is the default. `upload.filename` is `manifest.json` on this lane.\n"},"jobId":{"type":"string"},"uploadUrl":{"type":"string","description":"Single-PUT upload URL."},"upload":{"type":"object","properties":{"url":{"type":"string"},"filename":{"type":"string"},"contentType":{"type":"string"},"method":{"type":"string"},"headers":{"type":"object","additionalProperties":{"type":"string"}},"resumable":{"type":"object","description":"Resumable session; prefer this above ~500MB.","properties":{"url":{"type":"string"},"contentType":{"type":"string"}}}}},"status":{"$ref":"#/components/schemas/JobStatus"},"inputType":{"type":"string"},"adjustments":{"type":"array","description":"Reductions the backend applied to your requested recipe. READ THIS. It is non-empty whenever what will run differs from what you asked for — most often maxGaussians clamped to the GPU budget. Ignoring it means the price you quoted and the work performed disagree, silently.","items":{"type":"object","properties":{"field":{"type":"string"},"requested":{},"applied":{},"reason":{"type":"string"}}}},"effective":{"type":"object","description":"The splat budget the trainer will actually read, and which precedence level produced it. `adjustments` says what we CHANGED; this says what the job IS, including when the number is a default you never sent.","properties":{"maxGaussians":{"type":"integer","nullable":true},"maxGaussiansSource":{"type":"string","enum":["analysisResult.lichtfeldConfig","recipe","default:captureTier","default:gpuBudget"]}}},"warnings":{"type":"array","items":{"type":"string"},"description":"Things that are true about this job which you would otherwise discover only after paying for it: a splat budget below the routed GPU's, or one above the conversion ceiling (that job produces a .ply but no .sog/.sogs/tileset). Empty when neither applies."},"createdBy":{"$ref":"#/components/schemas/CreatedBy"},"conversionRequest":{"allOf":[{"$ref":"#/components/schemas/ConversionRequest"}],"description":"The `conversion` block as the server understood it — thresholds\nnormalised, `tidy: {}` shown as the `true` it means. Absent when you did\nnot send one. The `dryRun` report echoes the same field.\n"},"message":{"type":"string"},"nextStep":{"type":"string"}}},"CreatedBy":{"type":"object","properties":{"appId":{"type":"string"},"userId":{"type":"string","nullable":true,"description":"The resolved owner, which for a device credential is its own subject."},"projectId":{"type":"string","nullable":true}}},"JobSummary":{"type":"object","properties":{"jobId":{"type":"string"},"status":{"$ref":"#/components/schemas/JobStatus"},"quality":{"type":"string"},"progress":{"$ref":"#/components/schemas/Progress"},"captureId":{"type":"string","nullable":true,"description":"Your own id for the capture; null when the job was created without one."},"createdBy":{"$ref":"#/components/schemas/CreatedBy"},"billing":{"$ref":"#/components/schemas/Billing"},"createdAt":{"type":"string","format":"date-time","nullable":true}}},"Progress":{"type":"object","properties":{"step":{"type":"string"},"percentage":{"type":"integer"}}},"Billing":{"type":"object","properties":{"actualCost":{"type":"number","nullable":true},"gpuMinutes":{"type":"number","nullable":true}}},"Outputs":{"type":"object","description":"Populated as artifacts land.","properties":{"splatUrl":{"type":"string","nullable":true},"sogsUrl":{"type":"string","nullable":true},"tilesetUrl":{"type":"string","nullable":true},"previewPrefix":{"type":"string","nullable":true,"description":"GCS prefix holding a preflight job's preview frames."}},"additionalProperties":true},"Job":{"type":"object","properties":{"jobId":{"type":"string"},"status":{"$ref":"#/components/schemas/JobStatus"},"quality":{"type":"string"},"inputType":{"type":"string"},"progress":{"$ref":"#/components/schemas/Progress"},"batchJobName":{"type":"string","nullable":true},"outputs":{"$ref":"#/components/schemas/Outputs"},"splatId":{"type":"string","nullable":true,"description":"The library row passed at creation, echoed back."},"editorUrl":{"type":"string","format":"uri","nullable":true,"description":"Where the OWNER opens this capture to look at it, adjust it, and decide\nwhether to publish. **Null until there is something to open** — treat\nnull as \"no button yet\", never as an error, and never construct this\nURL yourself.\n\nThis page requires a signed-in session. Auth is Firebase client-side,\nnot a site cookie, so an embedded webview starts signed OUT and will\nbounce to a login screen. Sign the webview in first with\n`signInWithCustomToken()`; do not ship this behind a button without\nthat step.\n"},"viewerUrl":{"type":"string","format":"uri","nullable":true,"description":"The PUBLIC viewer for this capture — no session required, safe to open\nin a plain webview and safe to share. **Null until the owner\ndeliberately publishes**; publishing is never automatic, so a capture\nthat trained perfectly will still have a null `viewerUrl`. That is not\na failure state. Append `?embed=1` for a chromeless view.\n"},"createdBy":{"$ref":"#/components/schemas/CreatedBy"},"billing":{"$ref":"#/components/schemas/Billing"},"containerImage":{"type":"string","nullable":true},"engine":{"type":"string"},"effectiveConfig":{"type":"object","nullable":true,"additionalProperties":true},"outputStats":{"type":"object","nullable":true,"additionalProperties":true},"trainingStats":{"type":"object","nullable":true,"description":"Includes COLMAP alignment quality — framesRegistered,\nregistrationRate, numSparsePoints, meanTrackLength. A low\nregistration rate is the usual reason a splat looks wrong.\n","additionalProperties":true},"preflightReport":{"type":"object","nullable":true,"additionalProperties":true,"description":"Written by a `mode:preflight` job. `plan.tSec` is the full planned\ninstant list to filter into `recipe.rigKeepSeconds`; `preview` names the\nordinals and times that became thumbnails.\n"},"conversion":{"x-status":"planned","x-status-note":"Not deployed: the fields are absent today.","$ref":"#/components/schemas/Conversion"},"conversionRequest":{"x-status":"planned","x-status-note":"Not deployed: the field is absent today.","allOf":[{"$ref":"#/components/schemas/ConversionRequest"}],"nullable":true,"description":"The `conversion` block you sent at create, as understood. Null when you\nsent none. Read it back before `/start`: it is the only confirmation\nthat a cull setting survived.\n"},"error":{"type":"string","nullable":true},"callback":{"x-status":"planned","x-status-note":"Not deployed: the fields are absent today.","type":"object","nullable":true,"description":"Delivery record for YOUR `callbackUrl`. Null until the first attempt.\n\nNot to be confused with `webhookDelivered`, which is an internal Twinbly notification and says nothing about your callback.\n","properties":{"event":{"type":"string","nullable":true},"deliveryId":{"type":"string","nullable":true},"attempts":{"type":"integer","description":"1–5."},"delivered":{"type":"boolean","description":"The receiver answered 2xx."},"signed":{"type":"boolean","description":"An `X-Splat-Signature` was sent."},"lastStatus":{"type":"integer","nullable":true,"description":"HTTP status of the last attempt; null if the request never completed."},"lastAttemptAtMs":{"type":"integer","nullable":true}}}}},"Error":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["UNAUTHORIZED","FORBIDDEN","RATE_LIMIT_EXCEEDED","INVALID_REQUEST","MISSING_REQUIRED_FIELD","INVALID_QUALITY_PRESET","INVALID_TILING_OPTION","INVALID_URL_FORMAT","INVALID_JOB_STATE","UPLOAD_NOT_FOUND","INSUFFICIENT_CREDITS","BILLING_UNAVAILABLE","JOB_NOT_FOUND","METHOD_NOT_ALLOWED","INTERNAL_ERROR"],"description":"Stable machine-readable code. GUARANTEE: codes are append-only — an existing code will not change meaning or be removed, and messages may change freely. Match on `code`, never on `message`."},"message":{"type":"string"},"details":{"type":"object","additionalProperties":true},"retryable":{"type":"boolean","description":"Whether retrying this EXACT request may succeed. Do not infer it from the status: a 400 meaning \"your request is wrong\" and a 400 meaning \"something transient broke\" are indistinguishable otherwise. Retryable today: INTERNAL_ERROR, RATE_LIMIT_EXCEEDED, UPLOAD_NOT_FOUND, BILLING_UNAVAILABLE. INSUFFICIENT_CREDITS is deliberately NOT retryable — the same request fails identically until the wallet is topped up."}}}}},"ApiKey":{"type":"object","properties":{"fingerprint":{"type":"string","description":"Short non-secret identifier. Use it to revoke."},"label":{"type":"string","nullable":true},"createdAt":{"type":"integer","nullable":true},"lastUsedAt":{"type":"integer","nullable":true},"revoked":{"type":"boolean"},"revokedAt":{"type":"integer","nullable":true},"rateLimitPerHour":{"type":"integer"}}}}}}