MakeSplat API quickstart

The MakeSplat Gaussian splatting API turns a video into a .ply splat in four HTTP calls, from any language that speaks HTTPS. Add "mesh": true to also get a textured 3D mesh (.glb) for 0.5 credits more. Full schemas are in the OpenAPI spec and the interactive API reference.

Working in Claude, ChatGPT or Cursor? The MakeSplat MCP server lets your assistant do all of this for you. Connect it to your assistant in a minute.

Authentication

Create an API key in the dashboard. It looks like sk_live_.... Store it like a password: it's shown only once. Send it on every request to https://api.makesplat.com:

Authorization: Bearer sk_live_your_key_here

1 · Reserve a scan

This returns a job_id and a presigned upload_url (valid 1 hour). Nothing is charged yet. thumb_upload_url is optional: PUT a JPEG there if you want a preview image in the dashboard. The body is optional too: a title, such as the video's file name, names the scan in your dashboard and its downloads.

curl -X POST https://api.makesplat.com/v1/scans \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"title": "scene"}'

# → 201 { "job_id": "...", "upload_url": "https://...",
#         "thumb_upload_url": "https://...", "expires_in_seconds": 3600 }

2 · Upload your video

PUT the file straight to the upload URL. No auth header is needed, since the URL is signed. One MP4 or MOV video up to 500 MB. Orbit the subject slowly with good lighting for best results; see how to capture video for Gaussian splatting.

curl -X PUT --upload-file scene.mp4 "<upload_url>"

3 · Start processing

Credits are charged here (1.0 for defaults). Send a JSON body, even if it is just {}. Pass parameters to trade cost for quality, or call the estimate endpoint first to see the price of any combination. Add "mesh": true to also get a 3D mesh (.glb).

curl -X POST https://api.makesplat.com/v1/scans/<job_id>/start \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"params": {"steps": 15000, "downscale": 2, "matching": "sequential"}}'

# → { "job_id": "...", "status": "queued" }
# 402 = not enough credits. Failed jobs refund automatically.

4 · Poll until done, download

A standard splat takes about 10 minutes, longer on High or maximum quality. There are no webhooks: poll every 15-30 s. When status is done, the response includes a signed ply_url (valid 24 h), and mesh.url for a scan with a mesh.

curl https://api.makesplat.com/v1/scans/<job_id> \
  -H "Authorization: Bearer sk_live_..."

# → { "status": "done", "ply_url": "https://...", "ply_size_mb": 64.2,
#     "elapsed_sec": 540, "credits_charged": "1.0", ... }

curl -o scene.ply "<ply_url>"

5 · Delete your files (optional)

Once you have the .ply, you can delete everything we stored for the scan straight away: input video, .ply, .glb mesh, preview image and error log. It's permanent. The job record stays (status, parameters, credits; the error message is cleared) with files_deleted_at set, and ply_url and mesh.url are null from then on. Returns 409 while the scan is still queued, processing or stopping on the GPU. Retry a minute later. Calling it on a scan you reserved but never started cancels that scan. Calling it twice is safe.

curl -X DELETE https://api.makesplat.com/v1/scans/<job_id>/files \
  -H "Authorization: Bearer sk_live_..."

# → { "status": "done", "ply_url": null, "files_deleted_at": "2026-10-04T12:00:00Z", ... }

Endpoints

MethodPathWhat it does
POST/v1/scans/estimateCredit cost of a parameter set. Free, creates nothing.
POST/v1/scansReserve a scan. Returns job_id and a presigned upload_url (valid 1 hour). Nothing charged.
PUT<upload_url>Upload the video bytes (MP4 or MOV, up to 500 MB). No auth header: the URL is signed.
POST/v1/scans/{job_id}/startValidate parameters, charge credits and queue the scan. 402 if your balance is too low.
GET/v1/scans/{job_id}Status, progress and, once done, a signed ply_url (valid 24 hours), plus mesh.url for a mesh.
GET/v1/scansYour scans, newest first. Query: limit (1-100, default 20) and offset.
DELETE/v1/scans/{job_id}Cancel a scan that has not finished. Charged credits are refunded.
DELETE/v1/scans/{job_id}/filesPermanently delete the scan's stored files (video, .ply, .glb mesh, preview, error log).

Estimate the cost

POST /v1/scans/estimate returns the credit cost of a parameter set without creating a scan. Send the parameters directly, not wrapped in params; omitted ones use their defaults. credits is a decimal string.

curl -X POST https://api.makesplat.com/v1/scans/estimate \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"steps": 30000, "downscale": 1, "matching": "exhaustive"}'

# → { "credits": "3.4",
#     "params": { "steps": 30000, "num_frames": 120, "downscale": 1, "matching": "exhaustive" } }

Credit packs are on the pricing page.

Scan statuses

GET /v1/scans/{job_id} returns the scan's status. done, failed and cancelled are final; keep polling while it is queued or processing.

StatusMeaning
createdReserved. Waiting for your upload and the start call. Nothing charged yet.
queuedStarted and charged. Waiting for a GPU worker.
processingRunning on a GPU. stage and progress show how far it got.
doneFinished. ply_url is a signed download link valid for 24 hours; poll again for a fresh one.
failedThe pipeline could not build a splat. error_headline says why in one plain sentence (error_kind as a code, e.g. long_video), error_msg has the log, and the credits are refunded automatically.
cancelledCancelled by you (or its files were deleted before it started). Charged credits are refunded.

While a scan is processing, stage is download, frames, cameras, training or export (in that order, then mesh for a scan with a mesh) and progress is a rough percent from 0 to 99. Both are null until the GPU worker first reports.

{
  "job_id": "...",
  "status": "processing",
  "stage": "training",
  "progress": 62,
  "ply_url": null,
  "thumb_url": "https://...",
  "params": { "steps": 15000, "num_frames": 120, "downscale": 2, "matching": "sequential" },
  "error_msg": null,
  "error_kind": null,
  "error_headline": null,
  "elapsed_sec": null,
  "ply_size_mb": null,
  "credits_charged": "1.0",
  "files_deleted_at": null,
  "mesh": null,
  "created_at": "2026-10-07T10:00:00Z",
  "updated_at": "2026-10-07T10:05:30Z"
}

Errors

Errors return JSON with a detail field: a readable message, or a list of validation errors for a malformed request.

# → 402 { "detail": "This scan costs 1.6 credits but your balance is 0.4. Buy credits to continue." }
CodeWhen
400Start was called before the video finished uploading. Finish the PUT, then start again.
401Missing, malformed, revoked or unknown API key. Send Authorization: Bearer sk_live_...
402Not enough credits to start this scan. The message gives the cost and your balance. Buy credits, then call start again: the scan stays reserved.
404No scan with this id on your account.
409The scan is in the wrong state for this call: starting a scan twice, cancelling a finished scan, or deleting files while it is queued, processing or still stopping on the GPU.
413The uploaded video is larger than 500 MB (checked when you call start).
422Invalid request: a parameter out of range (for example steps outside 1,000-30,000), a missing or non-JSON body, or a malformed job id.
429Rate limit reached. Wait a moment and retry.
502The scan could not be submitted to the GPU queue, or file deletion failed partway. You were not charged; retry. If a start's detail says the GPU queue did not confirm the scan, don't retry: it is marked failed and refunded within 15 minutes.

Parameters

ParamRangeDefaultEffect
steps1,000-30,00015,000Training iterations (detail vs cost)
downscale1, 2, 421 = full resolution (pricier), 4 = quarter
num_frames30-300120Frames extracted from your video
matchingsequential · vocab_tree · exhaustivesequentialCamera-pose matching strategy
meshtrue · falsefalseAlso make a textured 3D mesh (.glb), +0.5 credits

The web studio presets map to these parameters: Standard uses the defaults (1 credit), High {"steps": 30000} (1.6 credits). Maximum quality is {"steps": 30000, "downscale": 1, "matching": "exhaustive"} (3.4 credits).

3D mesh (.glb)

Add "mesh": true to the start parameters (or the estimate body) to also get a textured 3D mesh as a .glb file, made from the same video. It adds 0.5 credits to any parameter set, Maximum included, and a few minutes of processing.

curl -X POST https://api.makesplat.com/v1/scans/<job_id>/start \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"params": {"mesh": true}}'

# Standard + mesh: 1.5 credits. Once done, GET /v1/scans/<job_id> returns
# → { "status": "done", "ply_url": "https://...", "credits_charged": "1.5",
#     "mesh": { "status": "done", "url": "https://...", "size_mb": 6.3,
#               "triangles": 150000, "error": null }, ... }

mesh is null for scans without one. Otherwise it has these fields:

FieldMeaning
statuspending until the scan finishes, then done or failed.
urlSigned download link for the .glb, valid 24 hours. Only when done, and null once the files are deleted.
size_mbSize of the .glb in MB, once done.
trianglesNumber of triangles in the mesh, once done.
errorWhy the mesh failed, when it did.

If the mesh can't be made, the scan still finishes with its .ply, mesh.status is failed and the 0.5 credits are refunded automatically.

Batch processing

There's no special batch endpoint. Just loop. Reserve and start N jobs, then poll them together. Jobs run in parallel on our GPU fleet.

Python (requests)

import time
import requests

API = "https://api.makesplat.com"
H = {"Authorization": "Bearer sk_live_..."}

jobs = []
for video in ["a.mp4", "b.mp4", "c.mp4"]:
    res = requests.post(f"{API}/v1/scans", headers=H)
    res.raise_for_status()
    r = res.json()
    with open(video, "rb") as f:
        requests.put(r["upload_url"], data=f).raise_for_status()
    requests.post(f"{API}/v1/scans/{r['job_id']}/start", headers=H, json={}).raise_for_status()
    jobs.append(r["job_id"])

# Poll all jobs together until each one is done, failed or cancelled
pending = set(jobs)
while pending:
    time.sleep(30)
    for job_id in list(pending):
        job = requests.get(f"{API}/v1/scans/{job_id}", headers=H).json()
        if job["status"] == "done":
            with open(f"{job_id}.ply", "wb") as out:
                out.write(requests.get(job["ply_url"]).content)
            pending.discard(job_id)
        elif job["status"] in ("failed", "cancelled"):
            print(job_id, job["status"], job["error_headline"] or "")
            pending.discard(job_id)

JavaScript (fetch)

// Node 18+ (built-in fetch). Save as splat.mjs and run: node splat.mjs
import { readFile, writeFile } from "node:fs/promises";

const API = "https://api.makesplat.com";
const KEY = "sk_live_...";

async function call(method, path, body) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: "Bearer " + KEY, "Content-Type": "application/json" },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  if (!res.ok) throw new Error(res.status + " " + (await res.text()));
  return res.json();
}

async function makeSplat(video) {
  // 1. Reserve, 2. upload, 3. start
  const { job_id, upload_url } = await call("POST", "/v1/scans");
  const put = await fetch(upload_url, { method: "PUT", body: await readFile(video) });
  if (!put.ok) throw new Error("upload failed: " + put.status);
  await call("POST", "/v1/scans/" + job_id + "/start", { params: { steps: 15000 } });

  // 4. Poll every 30 s until the scan finishes
  let job;
  do {
    await new Promise((r) => setTimeout(r, 30000));
    job = await call("GET", "/v1/scans/" + job_id);
    console.log(video, job.status, job.stage ?? "", job.progress ?? "");
  } while (job.status === "queued" || job.status === "processing");

  if (job.status !== "done") throw new Error(video + ": " + (job.error_headline ?? job.status));
  const ply = await fetch(job.ply_url);
  await writeFile(video.replace(/\.\w+$/, ".ply"), Buffer.from(await ply.arrayBuffer()));
}

// Process several videos in parallel
await Promise.all(["scene.mp4", "room.mp4"].map(makeSplat));

Rate limits & rules