API Reference
/
POST /v1/splats/{id}/clone
POST /api/v1/splats/{splatId}/clone
Copy a scene into the calling external user's library. Copying a scene you
already own is a Duplicate. Copying a public scene that its owner made
cloneable is a Remix — the owner opts in with the Allow remixing
setting (allowClone in the API).
POST
https://moshpit.studio/api/v1/splats/{splatId}/clone
Auth
All three headers are required:
Authorization: Bearer msk_YOUR_SECRET_KEY
X-Moshpit-Public-Key: mpk_YOUR_PUBLIC_KEY
X-Moshpit-External-User-Id: YOUR_HOST_USER_IDUnlike GET /v1/splats and
GET /v1/splats/{splatId},
X-Moshpit-External-User-Id is required on this endpoint — a clone always
belongs to a specific external user, so there's no "integration-level" owner
slot for it to land in otherwise. Provision the external user first with
PUT /api/v1/external-user if you haven't already.
REST API access
REST API access is included on Free, Pro, and Enterprise. Storage, Scene counts, and integration limits still apply. Duplicate and Remix require Pro or Enterprise.
Path parameters
| Field | Type | Description |
|---|---|---|
splatId Required | string | 24-character MongoDB ObjectId of the scene to copy. Use the `id` returned from `GET /api/v1/splats`. |
There is no request body — this endpoint takes no parameters beyond the path and headers above.
What gets copied
The clone is a full deep copy, not just a new database row: a new splat
document plus physical copies of every R2 object the source references — the
splat file or LOD folder, thumbnails, and any audio referenced inside
levelData. It always lands under the calling integration + the
X-Moshpit-External-User-Id you send — there is no way to clone into
another integration or another customer.
The new scene is always created private and unpublished, regardless of the
source scene's visibility. It doesn't inherit allowClone from the source
either — Remixing something doesn't make your copy remixable by default.
Example request
Bash
curl -X POST "https://moshpit.studio/api/v1/splats/65f1a2b3c4d5e6f7a8b9c0d1/clone" \
-H "Authorization: Bearer msk_YOUR_SECRET_KEY" \
-H "X-Moshpit-Public-Key: mpk_YOUR_PUBLIC_KEY" \
-H "X-Moshpit-External-User-Id: host_user_123"Success response — 201 Created
Small scenes finish inline and return 201 directly. Larger scenes (LOD
folders with hundreds of files) return 202 Accepted with a jobId
instead — see Async copies below.
Same item shape as list-splats, plus
splatId / sourceSplatId convenience fields and a refreshed usage
snapshot (same shape as create-splat's):
JSON
{
"status": "success",
"splat": {
"id": "7af0b1c2d3e4f5061728394a",
"title": "Living room scan",
"visibility": "private",
"allowClone": false,
"remixedFrom": {
"splatId": "65f1a2b3c4d5e6f7a8b9c0d1",
"ownerName": "app.example.com · host_user_123",
"clonedAt": "2026-07-16T18:30:00.000Z"
},
"externalUserId": "host_user_123",
"isOwner": true,
"createdAt": "2026-07-16T18:30:00.000Z",
"updatedAt": "2026-07-16T18:30:00.000Z"
},
"splatId": "7af0b1c2d3e4f5061728394a",
"sourceSplatId": "65f1a2b3c4d5e6f7a8b9c0d1",
"usage": {
"usedBytes": 123456,
"limitBytes": 10737418240,
"remainingBytes": 10737294784,
"percentUsed": 0.001
}
}| Field | Type | Description |
|---|---|---|
splat | object | The new scene, in the same shape as a list-splats item. `id` is also the clone's `splatId`. |
splatId | string | Convenience alias for `splat.id` — the new scene. |
sourceSplatId | string | The scene that was copied — same value as the `{splatId}` path parameter. |
usage | object | The integration owner's Studio account storage meter after the copy. Same shape as create-splat's `usage`. |
remixedFrom is only present — { splatId, ownerName, clonedAt } — when the
source scene belonged to a different owner (a Remix). It's null for a
same-owner Duplicate; in that case the clone's title gets a " (Copy)" suffix
instead. See list-splats response fields
for the full field reference shared with this endpoint.
Async copies — 202 Accepted + polling
The physical copy runs as a background job. When it can't finish within the
initial request's time budget, the endpoint responds 202:
JSON
{
"status": "accepted",
"jobId": "7af0b1c2d3e4f50617283950",
"sourceSplatId": "65f1a2b3c4d5e6f7a8b9c0d1",
"copiedObjects": 120,
"totalObjects": 260
}Poll the job with the same three auth headers until it turns terminal. Polling is what advances the copy — each poll copies another bounded slice of files, so keep polling every 1–2 seconds until completion:
GET
https://moshpit.studio/api/v1/splats/{splatId}/clone/{jobId}
- 200
{ "status": "running", "jobId", "copiedObjects", "totalObjects" }— copy still in progress; poll again. - 200 with
{ "status": "success", ... }— done; the body is exactly the 201 payload documented above (splat,splatId,sourceSplatId,usage). - Any error status — the job failed and was fully rolled back (every copied file deleted, reserved storage released). The body uses the same error shapes listed below.
A job abandoned mid-copy (no polls for a few minutes) is rolled back
automatically, and the next POST /clone for the same external user starts
fresh.
Error responses
| Status | Cause |
|---|---|
| 400 | Missing X-Moshpit-External-User-Id |
| 401 | Missing or invalid Bearer / public-key combination |
| 403 | Source scene is private and not owned by this caller, and not a public scene with allowClone: true |
| 403 | Plan doesn't include cloning (plan_limit, dimension: "cloning") |
| 404 | Source scene not found, the clone job not found, or the external user hasn't been provisioned |
| 409 | The source scene's files are missing from storage (SOURCE_FILES_MISSING) |
| 413 | Not enough storage headroom to physically copy the source's files (STORAGE_QUOTA_EXCEEDED) |
| 429 | A clone is already in progress for this integration + external user (CLONE_IN_PROGRESS) |
404 is intentional for both "doesn't exist" and "not visible to you" — a
private scene you don't own returns the same 404 as one that was deleted.
The plan-limit response uses the shared envelope documented on External Users:
JSON
{
"status": "error",
"code": "plan_limit",
"dimension": "cloning",
"limit": null,
"message": "Your plan doesn't include cloning scenes.",
"upgradeUrl": "/account/billing?upgrade=plan"
}The storage response mirrors the shape used across the REST API — scope is
"customer" (this external user's limitBytes allocation) or "account"
(the reseller's whole-account plan storage):
JSON
{
"status": "error",
"code": "STORAGE_QUOTA_EXCEEDED",
"message": "Not enough storage to clone this scene.",
"scope": "customer",
"usedBytes": 5368709120,
"limitBytes": 5368709120,
"remainingBytes": 0,
"requestedBytes": 17825792
}Clones are never storage-exempt
Unlike scene-count quota (below), storage is always charged at clone time — files are physically copied, so there's no unpublished grace period for storage the way there is for scene count.
The in-flight response names the running job so a client that lost track of it (a retried request, a reloaded page) can resume polling instead of failing:
JSON
{
"status": "error",
"code": "CLONE_IN_PROGRESS",
"message": "A clone is already in progress for this account.",
"jobId": "7af0b1c2d3e4f50617283950",
"sourceSplatId": "65f1a2b3c4d5e6f7a8b9c0d1"
}Only resume polling jobId when sourceSplatId matches the scene you asked
to clone — otherwise the running job is a copy of a different scene and
adopting it would hand your user the wrong result.
Unpublished clones and quota
A clone does not count against the account's maxSplats or the external
user's per-customer scene cap at creation time — it counts from its first
publish, permanently. Unpublishing it later never frees the slot back up;
archiving or deleting the scene does.
- You can clone freely up to your storage limit even while at your scene-count cap — the clones just stay private and unpublished.
- Storage is not exempt. A clone reserves and charges storage at clone time like any upload, regardless of publish state.
- The first
PATCH /v1/splats/{splatId}that setsvisibility: "public"on a clone re-checks both the account's scene-count limit and the external user's per-customer cap, and can return the sameplan_limit/STORAGE_QUOTA_EXCEEDEDresponses a fresh upload would.
What's next
- GET /v1/splats —
allowCloneandremixedFromon every list item. - PATCH /v1/splats/{splatId} — publish a clone (quota re-checked on first publish).
- PostMessage Protocol — the
clonedevent emitted by the embedded viewer's Remix button and the embedded editor's Duplicate action.