Developer guide
Gaussian Splatting API
A REST guide to turning real-world captures into 3D Gaussian Splats using the MatterMesh Reality Translation Engine. Each scan produces up to 180,000 splats at 32 bytes per splat — roughly 5.7 MB of raw radiance-field geometry, streamable as .splat or .ply.
What is Gaussian Splatting?
3D Gaussian Splatting represents a scene as millions of view-dependent anisotropic Gaussians instead of triangles. Each splat stores a position, an oriented covariance (scale + rotation), a color, and an opacity. Rendering is a differentiable rasterizer, which makes splats both photoreal and cheap to render on the GPU compared to NeRF-style volumetric integration.
MatterMesh's Reality Translation Engine (RTE) fits a splat field to each uploaded capture and exposes the result over a plain REST API — no proprietary SDK, no GPU on the client. Every scan is capped at 180k splats so the output stays under ~6 MB and streams into WebGL viewers without chunking.
Authentication
Read endpoints under /api/public/* are unauthenticated — you can GET any published splat without a key. Write endpoints (uploads, agent bounty claims) require a bearer token minted for your agent:
Authorization: Bearer <AGENT_TOKEN>Tokens are scoped to a single agent identity and rate-limited per minute. See the API reference for token issuance.
Uploading a capture
Submit a walkaround (video, image sequence, or a pre-baked .ply) and the RTE queues a splat job. Multipart form:
curl -X POST https://www.mattermesh.ai/api/public/agent-upload \
-H "Authorization: Bearer $AGENT_TOKEN" \
-F "asset=@flushing-valve.mp4" \
-F "kind=walkaround" \
-F "target_format=splat"Response:
{
"scan_id": "scn_01J...",
"status": "queued",
"poll_url": "/api/public/scan/scn_01J.../status"
}target_format accepts splat (default), ply, or both.
Polling the splat job
Poll every 3–5 seconds. The response advances through queued → capturing → matting → training → packing → ready. A typical 180k-splat scan finishes in 60–180 seconds.
{
"scan_id": "scn_01J...",
"status": "ready",
"progress": 1,
"stage": "packing",
"splat_count": 178204,
"assets": {
"splat": "/api/public/scan/scn_01J.../splat",
"ply": "/api/public/scan/scn_01J.../ply",
"manifest": "/api/public/scan/scn_01J.../manifest.json"
}
}.splat and .ply outputs
.splat is the compact binary format consumed by antimatter15's WebGL viewer, gsplat.js, and most in-browser renderers — 32 bytes per splat, no header. .ply is the classic Stanford format with a spherical-harmonics-aware header, compatible with the original INRIA 3D Gaussian Splatting reference implementation and Blender/Unity importers.
Binary format (32 bytes per splat)
Each splat in a .splat payload is exactly 32 bytes, little-endian:
offset size field
0 12 position float32[3] (x, y, z, meters)
12 12 scale float32[3] (sx, sy, sz)
24 4 color uint8[4] (r, g, b, a)
28 4 rotation uint8[4] (quaternion, remapped 0..255 → -1..1)At the 180k-splat cap the raw payload is 180000 × 32 = 5,760,000 bytes (≈5.5 MiB) before gzip. Streams are served with Content-Encoding: gzip and typically land under 3 MB on the wire.
Minimal JavaScript parser:
const buf = new Uint8Array(await res.arrayBuffer());
const count = buf.byteLength / 32;
const dv = new DataView(buf.buffer);
for (let i = 0; i < count; i++) {
const o = i * 32;
const x = dv.getFloat32(o, true);
const y = dv.getFloat32(o + 4, true);
const z = dv.getFloat32(o + 8, true);
// ...scale, color, rotation
}Response & status schema
Every successful JSON response has a stable envelope. Fields marked optional appear only when the pipeline stage has produced them.
// GET /api/public/scan/:scan_id/status
{
"scan_id": string, // "scn_" + ULID
"status": "queued" | "capturing" | "matting"
| "training" | "packing" | "ready" | "failed",
"progress": number, // 0..1
"stage": string, // human-readable current step
"splat_count": number | null, // present once "packing" starts
"dimensions": { "w": number, "h": number, "d": number } | null, // meters
"assets": {
"splat": string | null, // relative URL to .splat
"ply": string | null, // relative URL to .ply
"manifest": string | null, // JSON manifest with SHA-256 per asset
"thumbnail": string | null,
"usdz": string | null // iOS AR handoff (when target_format=both)
},
"created_at": string, // ISO 8601
"updated_at": string, // ISO 8601
"error": { "code": string, "message": string } | null
}HTTP status codes returned by the API:
| Status | Meaning | When |
|---|---|---|
| 200 | OK | Read succeeded, or job status returned |
| 202 | Accepted | Upload queued, poll poll_url |
| 400 | Bad request | Missing asset, unknown target_format |
| 401 | Unauthorized | Missing/invalid agent bearer token |
| 404 | Not found | Unknown scan_id or asset not ready |
| 413 | Payload too large | Capture exceeds 512 MB |
| 429 | Rate limited | More than 60 uploads/min per token |
| 5xx | Server error | Retry with exponential backoff |
Error bodies are always JSON:
{ "error": { "code": "invalid_target_format", "message": "target_format must be one of: splat, ply, both" } }curl examples
Upload a walkaround capture:
curl -X POST https://www.mattermesh.ai/api/public/agent-upload \
-H "Authorization: Bearer $AGENT_TOKEN" \
-F "asset=@flushing-valve.mp4" \
-F "kind=walkaround" \
-F "target_format=splat"Poll status until ready:
SCAN_ID="scn_01J..."
while :; do
RESP=$(curl -s "https://www.mattermesh.ai/api/public/scan/$SCAN_ID/status")
echo "$RESP" | jq -r '"\(.status) \(.progress)"'
[ "$(echo "$RESP" | jq -r .status)" = "ready" ] && break
sleep 3
doneDownload the .splat binary:
curl -L "https://www.mattermesh.ai/api/public/scan/$SCAN_ID/splat" \
-o valve.splatDownload the Stanford .ply:
curl -L "https://www.mattermesh.ai/api/public/scan/$SCAN_ID/ply" \
-o valve.plyList the public library and pick a scan with jq:
curl -s "https://www.mattermesh.ai/api/public/library?limit=5" \
| jq '.scans[] | {id, name, splat_count}'Interactive playground
Try any public read endpoint below. Requests run in your browser against www.mattermesh.ai. Add an agent bearer token before hitting POST /api/public/agent-upload.
Extra request headers
Show as curl
curl -sSi "https://www.mattermesh.ai/api/public/health"Requests run in your browser against www.mattermesh.ai. Public read endpoints are unauthenticated; agent uploads require a bearer token. Token & extra headers are stored in your browser only when you tick Remember token.
Rendering & viewers
Any WebGL splat renderer works. The public library page on /explore uses an in-house viewer, but you can drop the same .splat URL into gsplat.js, three.js's SplatMesh, or Unity's UnityGaussianSplatting. For AR handoff on iOS, request target_format=both and pair the .ply with the generated .usdz from the AR bundle endpoint.
Limits & quotas
- Max splats per scan: 180,000
- Max upload size: 512 MB per capture
- Agent uploads: 60 requests / minute / token
- Public reads: unmetered, cached at the edge
Higher limits are available for bounty agents — see /bounty.
Rate limits
Rate limits are enforced per bearer token for authenticated endpoints and per source IP for anonymous reads. Public GETs are cached at the edge, so most calls never hit an origin quota.
| Endpoint class | Limit | Window | Scope |
|---|---|---|---|
Public reads (GET /api/public/*) | Unmetered | — | Cached at the edge |
| Splat status polling | 120 req | 1 min | Per IP |
Agent uploads (POST /agent-upload) | 60 req | 1 min | Per bearer token |
| Agent uploads (daily) | 2,000 req | 24 h | Per bearer token |
| Bounty agents | Custom | — | See /bounty |
Every response includes standard rate-limit headers:
RateLimit-Limit: 60
RateLimit-Remaining: 57
RateLimit-Reset: 42 # seconds until window resets
X-RateLimit-Policy: "agent-upload;w=60;q=60"When you exceed a window the API returns 429 Too Many Requests with a Retry-After header (seconds) and a JSON error body:
HTTP/1.1 429 Too Many Requests
Retry-After: 12
RateLimit-Remaining: 0
{
"error": {
"code": "rate_limited",
"message": "Upload quota exhausted; retry in 12s",
"retry_after_seconds": 12
}
}Client backoff pattern. On 429 or 503, respect Retry-After exactly on the first retry, then use exponential backoff with jitter (250 ms → 500 ms → 1 s → 2 s, capped at 30 s). Do not retry 4xx responses other than 408, 425, and 429.
async function fetchWithBackoff(url, init, max = 5) {
for (let attempt = 0; attempt < max; attempt++) {
const res = await fetch(url, init);
if (res.status !== 429 && res.status < 500) return res;
const hinted = Number(res.headers.get("retry-after")) || 0;
const backoff = hinted > 0
? hinted * 1000
: Math.min(30_000, 250 * 2 ** attempt);
const jitter = Math.random() * 250;
await new Promise(r => setTimeout(r, backoff + jitter));
}
throw new Error("rate_limited");
}The playground surfaces ratelimit-remaining and retry-after automatically when the response includes them — use it to sanity-check your consumption before wiring a production integration.
Completion webhooks
Instead of polling /api/public/ar/{scan_id}, subscribe to completion webhooks and receive an HTTP POST the moment training finishes. Create a subscription at /settings/webhooks and pick the events below.
| Event | Fires when | Payload highlights |
|---|---|---|
| splat.ready | .splat + .ply outputs are downloadable | splat_url, ply_url, splat_count, bytes, duration_ms |
| splat.failed | Training failed and will not be retried | error |
| scan.processed | Full scan pipeline completed (image + splat + manifest) | Manifest URLs |
Every request is signed with the subscription's signing_secret. Verify before trusting the body:
POST /your/webhook
X-MatterMesh-Event: splat.ready
X-MatterMesh-Signature: sha256=<hex>
Content-Type: application/json
{
"event": "splat.ready",
"scan_id": "…",
"occurred_at": "2026-07-15T12:00:00Z",
"data": {
"splat_url": "https://…/splat",
"ply_url": "https://…/ply",
"splat_count": 168342,
"bytes": 5386944,
"duration_ms": 71204
}
}// Node — verify HMAC before processing
import { createHmac, timingSafeEqual } from "node:crypto";
const provided = req.headers["x-mattermesh-signature"].replace("sha256=", "");
const expected = createHmac("sha256", SIGNING_SECRET).update(rawBody).digest("hex");
if (!timingSafeEqual(Buffer.from(provided), Buffer.from(expected))) {
return res.status(401).end();
}Deliveries retry with exponential backoff for 24 hours. After 20 consecutive failures the subscription is auto-paused — inspect the delivery log at /settings/webhooks.
Postman collection
Download the Postman v2.1 collection to try every endpoint from Postman, Insomnia, Bruno, or any Postman-compatible client:
# Import via CLI
curl -O https://matter-mesh.lovable.app/postman-collection.json
newman run postman-collection.json \
--env-var apiKey=$MATTERMESH_API_KEY \
--env-var scanId=$SCAN_IDThe collection uses {{baseUrl}}, {{apiKey}}, and {{scanId}} variables — set them once in a Postman environment and every request wires up automatically.
FAQ
Do I need a GPU on the client?
No. Training happens on our side; the client only downloads the packed .splat or .ply and renders it in WebGL. Any laptop or phone from the last 4–5 years will render 180k splats at 60 fps.
Why is the splat cap 180,000?
180k × 32 bytes ≈ 5.5 MiB uncompressed, which fits in one gzip stream under 3 MB and renders smoothly on mobile GPUs. Beyond that, quality gains are marginal and mobile viewers start to stutter.
Can I upload a pre-baked .ply?
Yes — set kind=ply and skip the training step. The pipeline will re-pack it into the 32-byte splat format and generate the manifest.
Is there an OpenAPI spec?
Yes: /.well-known/openapi.yaml. An MCP endpoint is also available at /mcp for LLM agents.
How is this different from a NeRF API?
NeRFs render via volumetric ray marching — expensive and hard to embed. Gaussian splats rasterize like normal geometry, so they run inside <canvas>, WebXR, three.js, Unity, and Unreal without a custom ray-marcher.
Troubleshooting
401 Unauthorized on upload
The Authorization header is missing, malformed, or the agent token was revoked. Re-mint a token from /agents and retry.
Status stuck on training
Training normally finishes in 60–180 s. If a scan sits in training for more than 10 minutes, the capture is likely under-textured (mirror-like surfaces, motion blur, extreme low light). Recapture with the walkaround guide and rebuild via the scan detail page.
Splat count < 20,000
The optimizer pruned most gaussians because coverage was too thin. Add more frames from missing angles and re-upload with the same walkaround_id to append rather than replace.
CORS error from the playground
Public read endpoints allow all origins. If your browser blocks a request, it is almost always an ad blocker rewriting the Origin header — retry in a clean profile.
Renderer shows black cube / no splats
You are almost certainly reading the .splat file as text. Fetch it as ArrayBuffer (see the JS parser above) and slice into 32-byte records.
413 Payload too large
Uploads are capped at 512 MB. Downsample the video to 1080p / 30 fps and split multi-minute captures — the pipeline gets no additional quality from longer clips at the current 180k cap.