Skip to content

Push (uploads)

Pushing is how files get into Prophouse. The Unreal plugin and the desktop app use exactly this flow, and a script can too: describe the files in a manifest, upload the bytes Prophouse does not already have, then commit. Every upload and commit needs write scope; the GET endpoints on this page need only read.

Method and path Purpose
GET /push/limits Per-push size limits
POST /push/negotiate Dry run: which bytes are missing, and what each item will become
PUT /blobs/{content_hash} Upload bytes through the API
POST /push/commit Register the manifest in one transaction
PUT /entries/{entry_id}/thumbnail Upload a thumbnail
PUT /entries/{entry_id}/preview Upload a GLB, PNG or WAV preview
PUT /entries/{entry_id}/metadata Update an entry’s metadata
POST /entries/media/presign, POST /entries/media/finalize Upload thumbnails and previews directly to storage
  1. Hash every file: lowercase hex SHA-256 of the exact bytes.

  2. Build a manifest with one item per file (see Manifest items) and call POST /push/negotiate. Nothing is written. The response lists missing_hashes (bytes Prophouse does not have) and, per item, what commit will do.

  3. Upload each missing hash. If the negotiate response has upload_mode: "direct", PUT each file to its missing[].upload_url with exactly the headers given. Otherwise PUT /blobs/{content_hash} with the raw bytes.

  4. Call POST /push/commit with the same manifest. Everything registers in one transaction, or nothing does.

  5. Optionally upload a thumbnail for each new entry with PUT /entries/{entry_id}/thumbnail?object_id=<object_id>, using the ids from the commit results.

Uploads are idempotent and order-free. If you stop before commit, nothing appears in the library. Re-running the whole flow is safe: already-known hashes come back as dedup.

Terminal window
HASH=$(sha256sum Barrel.fbx | cut -d' ' -f1)
SIZE=$(stat -c%s Barrel.fbx)
cat > manifest.json <<EOF
{
"packs": [{ "ref": "p1", "name": "Industrial Yard", "source_type": "fbx" }],
"items": [{
"content_hash": "$HASH",
"asset_class": "SourceMesh",
"asset_name": "Barrel",
"blob_size": $SIZE,
"source_ext": "fbx",
"pack_ref": "p1",
"entry": { "entry_type": "source_mesh", "name": "Barrel" }
}]
}
EOF
curl -X POST -H "Authorization: Bearer $PH_KEY" -H "Content-Type: application/json" \
--data @manifest.json https://api.prophouse.dev/api/v1/push/negotiate

If missing_hashes contains the hash and there is no upload_mode, upload through the API:

Terminal window
curl -X PUT -H "Authorization: Bearer $PH_KEY" --data-binary @Barrel.fbx \
"https://api.prophouse.dev/api/v1/blobs/$HASH"

Then commit:

Terminal window
curl -X POST -H "Authorization: Bearer $PH_KEY" -H "Content-Type: application/json" \
--data @manifest.json https://api.prophouse.dev/api/v1/push/commit

Source files like this one can be searched and downloaded but not pulled into Unreal (they have no /Game path). Unreal packages pushed with an origin_path can be pulled.

{ "max_items": 5000, "max_packs": 5000, "max_dependencies_per_item": 10000 }

Negotiate and commit both reject larger manifests with 422 VALIDATION and details such as {"items": 6200, "max_items": 5000}. Keep batches to about 500 items even though the limit is higher: smaller commits finish faster and hold the library’s write lock for less time.

{
"content_hash": "9f2c...e41a",
"asset_class": "StaticMesh",
"asset_name": "SM_Barrel_Rusty",
"origin_path": "/Game/Props/Industrial/SM_Barrel_Rusty",
"blob_size": 1048576,
"engine_version": "5.8.0",
"package_guid": "6F9619FF8B86D011B42D00C04FC964FF",
"stamped_lineage_id": null,
"stamped_base_hash": null,
"expected_face_object_id": null,
"source_ext": null,
"metadata_doc": null,
"dependencies": [{ "content_hash": "41be...07c2", "hard": true }],
"pack_ref": "p1",
"entry": {
"entry_type": "mesh",
"name": "SM_Barrel_Rusty",
"variant_label": null,
"make_face": true,
"metadata": { "tri_count": 1840, "material_count": 2 }
}
}
Field Type Description
content_hash string, required SHA-256 of the file. Must be unique within the manifest.
asset_class string, required Class name, for example StaticMesh, Texture2D, SourceMesh. Up to 200 characters.
blob_size integer, required Size in bytes.
asset_name string Asset name: ASCII letters, digits, _ and - only, up to 200 characters.
origin_path string Unreal long package path under /Game (letters, digits, _, - per segment). Required for anything you want to pull into Unreal.
engine_version string major.minor.patch of the engine that saved the package.
package_guid string The package GUID. Gives the file a stable identity across pushes.
stamped_lineage_id, stamped_base_hash string Declare this file a new version of an existing lineage, based on the version with that hash. See New versions.
expected_face_object_id integer The lineage’s current version as you last saw it. Commit fails with 409 STALE_FACE if someone published since.
source_ext string For non-Unreal source files: bare lowercase extension (fbx, wav), matching ^[a-z0-9]{1,12}$.
metadata_doc object Structured metadata. See The metadata document.
dependencies array [{content_hash, hard}] (up to 10,000). Each must be in this manifest or already in the library. hard defaults to true.
pack_ref string Ties the item to an entry in the request’s packs[].
entry object Present when the file should appear as a browsable entry. Omit (or null) for dependency-only files such as textures used by a mesh.
attach_to_lineage, force_new_lineage string, boolean Answers for items negotiate held as probable_lost_stamp.

List dependencies before the items that use them. For Unreal packages, Prophouse reads the package header itself: a package_guid or origin_path that contradicts the file is 422 VALIDATION, and imports the header names that are also in the manifest are added as dependencies even if you left them out.

The entry object:

Field Type Description
entry_type string, required Open set: mesh, skeletal_mesh, texture, material, blueprint, vfx, audio, animation, sequence, source_mesh, source_texture, source_audio, and so on.
name string, required Display name. Ignored if the entry already exists (names chosen in the library are kept).
variant_label string Marks the version as a variant.
make_face boolean Default true: this file becomes the entry’s current version.
metadata object Flat metadata: tri_count, vert_count, material_count, lod_count, has_collision, nanite_enabled, bounds_x, bounds_y, bounds_z, texture_count, max_texture_res, pbr_channels (comma-separated), blend_mode, two_sided, source_app, source_authored_at, audio_duration, sample_rate, channel_count, texture_width, texture_height, texture_format, bone_count, anim_length, emitter_count.
variant_group_ref string Ties the entry to a group in the request’s variant_groups[].

Negotiate and commit take the same body:

Field Type Description
items array, required Manifest items (at least one).
pack_id integer Existing pack for items without a pack_ref.
packs array Inline packs: [{ref, name, source_type?, product_url?, source_path?, engine_version?}]. Commit creates each pack by name or links the existing one (filling only its empty fields). ref and name must be unique in the manifest.
variant_groups array Up to 500 [{ref, label?, id?}]. Groups sibling entries such as SM_Crate_A, _B, _C. Pass id from an earlier batch’s response to add to the same group.

Items with no pack attribution are assigned a pack named after their /Game folder.

metadata_doc stores structured metadata on the exact version, and fills the flat entry fields from its core section.

{
"v": 1,
"core": {
"bounds": { "x": 62.5, "y": 62.5, "z": 91.0 },
"counts": { "tris": 1840, "verts": 1022, "materials": 2, "lods": 3 },
"materials": [{ "name": "M_RustyMetal", "blend": "opaque", "two_sided": false }],
"source": { "app": "Blender 4.2", "authored_at": "2026-05-02", "ext": "fbx" }
},
"ext": { "mystudio": { "lod_policy": "hero" } }
}
Rule Detail
Version v must be 1. Top-level keys are only v, core, ext.
Size At most 64 KiB serialized.
core Closed schema; unknown keys are 422. Sections: bounds {x, y, z}, counts {tris, verts, materials, lods, bones, emitters}, materials [{name, blend, two_sided}] (up to 64), preview {kind}, source {app, authored_at, ext}, audio {duration, sample_rate, channels}, image {width, height, format}, anim {length}.
ext Open. Namespaces match ^[a-z][a-z0-9_]{1,15}$, each an object nested at most 8 levels, stored as sent.

Read-only. Returns what commit would do:

{
"missing_hashes": ["9f2c...e41a"],
"upload_mode": "direct",
"missing": [{
"hash": "9f2c...e41a",
"upload_url": "https://<presigned-host>/...",
"headers": { "x-amz-checksum-sha256": "<base64 digest>", "If-None-Match": "*" }
}],
"items": [{
"content_hash": "9f2c...e41a",
"action": "register",
"lineage_id": null,
"lineage_anchor": "minted",
"parent_object_id": null,
"existing_object_id": null,
"probable_lost_stamp": false,
"candidate_lineage_id": null
}]
}
action Meaning
register A new version will be created. lineage_id is null when a new lineage will be minted at commit.
dedup These exact bytes are already in the library (existing_object_id).
hold Held back. reason: "probable_lost_stamp" means the file looks like a new version of candidate_lineage_id but carries no version stamp: resend at commit with attach_to_lineage or force_new_lineage: true. reason: "depends_on_held" lists held_dependencies.
denied Blocked by the workspace denylist (reason: "denylisted", denylist_kind) or depends on denied content (reason: "depends_on_denied", denied_dependencies). Denied hashes never appear in missing_hashes.

lineage_anchor is guid, stamp, minted or user_attached.

When upload_mode is "direct", missing[] gives a presigned PUT target per missing hash:

  • Send exactly the listed headers, with the same names and values, and no Authorization header. The headers are part of the signature; dropping or changing one makes storage answer 403.
  • Storage verifies the bytes against the hash, so a wrong file is rejected.
  • A 412 Precondition Failed answer means the bytes are already stored. Treat it as success and move on to commit; do not retry.

When upload_mode and missing are absent, use PUT /blobs/{content_hash} for every hash in missing_hashes.

Negotiate returns 403 with details.reason ingest_blocked or read_only when new bytes cannot be accepted (storage limit reached, or the workspace is read-only). A push whose bytes are all already stored still negotiates.

Raw file bytes as the body. The hash is checked while the upload streams.

Status Body or code
201 {"hash", "size", "created": true}: newly stored.
200 {"hash", "size", "created": false}: already stored.
409 HASH_MISMATCH The bytes do not hash to content_hash; nothing is stored.
403 DENYLISTED The hash is on the denylist.

Same body as negotiate. Registers every item, dependency and entry in one transaction.

{
"pack_id": null,
"packs": { "p1": 3 },
"results": [
{
"content_hash": "9f2c...e41a",
"status": "new",
"object_id": 1880,
"lineage_id": "ph:0b6e4c1f9a2d4f0e8c7b5a3d2e1f0a9b",
"lineage_anchor": "minted",
"parent_object_id": null,
"variant": false,
"entry_id": 412,
"entry_created": true
}
],
"summary": { "new": 1, "dedup": 0, "held": 0, "denied": 0, "locked": 0, "entries_created": 1 }
}

packs maps each inline pack ref to its pack id; variant_groups (when used) maps each group ref to its id, and grouped results carry variant_group_id.

Result status Meaning
new Registered. Carries object_id, lineage_id, entry_id when an entry was created or updated.
dedup Already in the library. May carry enriched when this push filled fields the stored version was missing (asset_name, origin_path, engine_version).
held Held as in negotiate. Does not fail the batch.
denied Blocked by the denylist. Does not fail the batch.
locked Would create a new version of a lineage someone else has checked out (reason: "lineage_locked", lock), or depends on such an item (reason: "depends_on_locked"). Does not fail the batch.

Errors that fail the whole commit:

Error Cause
409 BLOB_MISSING Bytes for a new item were never uploaded (details.missing).
409 HASH_MISMATCH Stored bytes do not match their hash (details.contradicted).
409 STALE_FACE expected_face_object_id no longer matches the lineage’s current version. Re-sync; do not retry as-is.
409 CONFLICT Entries in one variant group already belong to two different groups (details.existing_group_ids).
422 VALIDATION Malformed manifest, duplicate hashes, a dependency that is neither in the manifest nor the library, unknown pack_ref or variant_group_ref, too many items, or a package header that contradicts the manifest.
404 PACK_NOT_FOUND pack_id does not exist.

Pushes never overwrite curation: names, tags, comments and collections chosen in the library survive re-pushes.

To publish a new version of a prop you already have (for example after a check-out), push the new file with:

  • stamped_lineage_id: the entry’s lineage_id.
  • stamped_base_hash: the content_hash of the version you started from.
  • expected_face_object_id: the entry’s object_id when you started. Recommended.
  • An entry block with make_face: true.

The commit creates a child version in the same lineage and makes it current. Unreal packages that keep their package GUID are matched to their lineage automatically.

If another person holds the check-out lock, the item comes back locked. Your own lock does not block you.

These attach media to an entry’s current version. Pass the version as object_id; if the entry’s current version changed since, the upload is refused with 409 CONFLICT (details.current_object_id).

Endpoint Body Limits Response
PUT /entries/{entry_id}/thumbnail?object_id= PNG bytes At most 512 x 512 and 8 MiB {entry_id, object_id, artifact_id, width, height, size}
PUT /entries/{entry_id}/preview?object_id=&kind= GLB, PNG or WAV bytes kind is glb (default), image or audio. GLB up to 64 MiB; PNG up to 8192 x 8192; WAV up to 32 MiB, PCM or float, at most 8 channels {entry_id, object_id, artifact_id, size, kind}
PUT /entries/{entry_id}/metadata?object_id= JSON: any flat metadata fields plus optional metadata_doc Fields you send overwrite; null keeps the stored value {entry_id, object_id, updated: [field names]}
POST /entries/{entry_id}/preview/unavailable?object_id= none Marks the entry as having no possible preview {entry_id, preview_status: "unavailable"}

Malformed media is 422 VALIDATION. Replacing a thumbnail or preview keeps the old file available by its artifact_id (for annotations) and refreshes visual search for that entry.

Thumbnail and preview uploads accept optional query parameters describing what produced the media: producer_tier, producer_tool, producer_tool_version, generator, generator_version (lowercase identifiers such as producer_tool=my-pipeline).

To upload thumbnails and previews straight to storage (recommended for large batches):

  1. POST /entries/media/presign with {"items": [{"entry_id", "object_id", "kind"}]} (up to 1,000; kind is thumbnail, glb, image or audio). The response is {"upload_mode": "direct", "targets": [{entry_id, object_id, kind, artifact_id, upload_url, headers}]}, or {} if direct upload is not offered (use the PUT endpoints instead).
  2. PUT each file to upload_url with exactly the returned headers (a Content-Type), no Authorization.
  3. POST /entries/media/finalize with {"items": [{"artifact_id", "entry_id", "object_id", "kind"}]}. Returns {"results": [{entry_id, object_id, kind, artifact_id, size}]}. A file that never arrived or is over the size limit is 422.

On this path the server does not inspect the media, so an invalid file results in a preview that does not render.

The Unreal plugin uses these to find entries that still need media. They are available to scripts that generate previews.

Endpoint Returns
GET /push/preview-queue?limit=&entry_type= Up to limit (default 500, max 2000) entries still needing a preview: {"items": [{entry_id, object_id, name, entry_type, content_hash, origin_path, asset_name}]}. entry_type is a comma-separated subset of mesh, skeletal_mesh, texture, audio.
GET /push/preview-stats {pending, ready, unavailable, total_pending}, each split by entry type.
GET /push/thumbnail-refresh-queue?limit=&after= Entries whose thumbnail can be recaptured, in id order after after: {"items": [{entry_id, object_id, name, entry_type, has_thumbnail, origin_path, asset_name}], "total"}.