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

POST/api/public/agent-upload

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

GET/api/public/scan/:scan_id/status

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

GET/api/public/scan/:scan_id/splat
GET/api/public/scan/:scan_id/ply

.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:

StatusMeaningWhen
200OKRead succeeded, or job status returned
202AcceptedUpload queued, poll poll_url
400Bad requestMissing asset, unknown target_format
401UnauthorizedMissing/invalid agent bearer token
404Not foundUnknown scan_id or asset not ready
413Payload too largeCapture exceeds 512 MB
429Rate limitedMore than 60 uploads/min per token
5xxServer errorRetry 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
done

Download the .splat binary:

curl -L "https://www.mattermesh.ai/api/public/scan/$SCAN_ID/splat" \
  -o valve.splat

Download the Stanford .ply:

curl -L "https://www.mattermesh.ai/api/public/scan/$SCAN_ID/ply" \
  -o valve.ply

List 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 classLimitWindowScope
Public reads (GET /api/public/*)UnmeteredCached at the edge
Splat status polling120 req1 minPer IP
Agent uploads (POST /agent-upload)60 req1 minPer bearer token
Agent uploads (daily)2,000 req24 hPer bearer token
Bounty agentsCustomSee /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.

EventFires whenPayload highlights
splat.ready.splat + .ply outputs are downloadablesplat_url, ply_url, splat_count, bytes, duration_ms
splat.failedTraining failed and will not be retriederror
scan.processedFull 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:

/postman-collection.json

# 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_ID

The 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.

Questions or a splat pipeline we should support? Open an issue via the API reference or hit the MCP endpoint at /mcp.