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_ID

Unlike 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

FieldTypeDescription
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

SH

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
  }
}
FieldTypeDescription
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

StatusCause
400Missing X-Moshpit-External-User-Id
401Missing or invalid Bearer / public-key combination
403Source scene is private and not owned by this caller, and not a public scene with allowClone: true
403Plan doesn't include cloning (plan_limit, dimension: "cloning")
404Source scene not found, the clone job not found, or the external user hasn't been provisioned
409The source scene's files are missing from storage (SOURCE_FILES_MISSING)
413Not enough storage headroom to physically copy the source's files (STORAGE_QUOTA_EXCEEDED)
429A 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 sets visibility: "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 same plan_limit / STORAGE_QUOTA_EXCEEDED responses a fresh upload would.

What's next