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_here1 · 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
| Method | Path | What it does |
|---|---|---|
| POST | /v1/scans/estimate | Credit cost of a parameter set. Free, creates nothing. |
| POST | /v1/scans | Reserve 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}/start | Validate 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/scans | Your 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}/files | Permanently 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.
| Status | Meaning |
|---|---|
| created | Reserved. Waiting for your upload and the start call. Nothing charged yet. |
| queued | Started and charged. Waiting for a GPU worker. |
| processing | Running on a GPU. stage and progress show how far it got. |
| done | Finished. ply_url is a signed download link valid for 24 hours; poll again for a fresh one. |
| failed | The 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. |
| cancelled | Cancelled 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." }| Code | When |
|---|---|
| 400 | Start was called before the video finished uploading. Finish the PUT, then start again. |
| 401 | Missing, malformed, revoked or unknown API key. Send Authorization: Bearer sk_live_... |
| 402 | Not enough credits to start this scan. The message gives the cost and your balance. Buy credits, then call start again: the scan stays reserved. |
| 404 | No scan with this id on your account. |
| 409 | The 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. |
| 413 | The uploaded video is larger than 500 MB (checked when you call start). |
| 422 | Invalid 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. |
| 429 | Rate limit reached. Wait a moment and retry. |
| 502 | The 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
| Param | Range | Default | Effect |
|---|---|---|---|
| steps | 1,000-30,000 | 15,000 | Training iterations (detail vs cost) |
| downscale | 1, 2, 4 | 2 | 1 = full resolution (pricier), 4 = quarter |
| num_frames | 30-300 | 120 | Frames extracted from your video |
| matching | sequential · vocab_tree · exhaustive | sequential | Camera-pose matching strategy |
| mesh | true · false | false | Also 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:
| Field | Meaning |
|---|---|
| status | pending until the scan finishes, then done or failed. |
| url | Signed download link for the .glb, valid 24 hours. Only when done, and null once the files are deleted. |
| size_mb | Size of the .glb in MB, once done. |
| triangles | Number of triangles in the mesh, once done. |
| error | Why 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
- 10 starts/min, 30 reserves/min, 120 reads/min, 60 file deletions/min per account (429 above that)
- Videos up to 500 MB; upload URLs expire after 1 hour
- Download URLs expire after 24 h, so re-poll the job to get a fresh one
- Input videos are deleted automatically after 7 days; results stay until you delete them
- Failed or cancelled scans are refunded automatically