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 |
The flow
Section titled “The flow”-
Hash every file: lowercase hex SHA-256 of the exact bytes.
-
Build a manifest with one item per file (see Manifest items) and call
POST /push/negotiate. Nothing is written. The response listsmissing_hashes(bytes Prophouse does not have) and, per item, what commit will do. -
Upload each missing hash. If the negotiate response has
upload_mode: "direct",PUTeach file to itsmissing[].upload_urlwith exactly theheadersgiven. OtherwisePUT /blobs/{content_hash}with the raw bytes. -
Call
POST /push/commitwith the same manifest. Everything registers in one transaction, or nothing does. -
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.
Example: push one FBX
Section titled “Example: push one FBX”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/negotiateIf missing_hashes contains the hash and there is no upload_mode, upload through the API:
curl -X PUT -H "Authorization: Bearer $PH_KEY" --data-binary @Barrel.fbx \ "https://api.prophouse.dev/api/v1/blobs/$HASH"Then commit:
curl -X POST -H "Authorization: Bearer $PH_KEY" -H "Content-Type: application/json" \ --data @manifest.json https://api.prophouse.dev/api/v1/push/commitSource 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.
GET /push/limits
Section titled “GET /push/limits”{ "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.
Manifest items
Section titled “Manifest items”{ "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[]. |
Request body
Section titled “Request body”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.
The metadata document
Section titled “The metadata document”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. |
POST /push/negotiate
Section titled “POST /push/negotiate”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.
Direct uploads
Section titled “Direct uploads”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 noAuthorizationheader. The headers are part of the signature; dropping or changing one makes storage answer403. - Storage verifies the bytes against the hash, so a wrong file is rejected.
- A
412 Precondition Failedanswer 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.
PUT /blobs/{content_hash}
Section titled “PUT /blobs/{content_hash}”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. |
POST /push/commit
Section titled “POST /push/commit”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.
Push a new version
Section titled “Push a new version”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’slineage_id.stamped_base_hash: thecontent_hashof the version you started from.expected_face_object_id: the entry’sobject_idwhen you started. Recommended.- An
entryblock withmake_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.
Thumbnails, previews and metadata
Section titled “Thumbnails, previews and metadata”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).
Direct media uploads
Section titled “Direct media uploads”To upload thumbnails and previews straight to storage (recommended for large batches):
POST /entries/media/presignwith{"items": [{"entry_id", "object_id", "kind"}]}(up to 1,000;kindisthumbnail,glb,imageoraudio). 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 thePUTendpoints instead).PUTeach file toupload_urlwith exactly the returnedheaders(aContent-Type), noAuthorization.POST /entries/media/finalizewith{"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 is422.
On this path the server does not inspect the media, so an invalid file results in a preview that does not render.
Processing queues
Section titled “Processing queues”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"}. |
