On this page
Core concepts
Jobs
Assets
Webhooks
Queue
API Reference
Every real endpoint this API exposes, generated directly from its own openapi.json -- the same document is the single source of truth for both. New here? Start with the Quickstart.
Authentication
Authenticate every request with Authorization: Bearer <API key>. Each key also carries a scope list beyond bearer authentication (for example jobs:read, jobs:write, jobs:read:all, webhooks:write, assets:purge, forgehub:write) -- a request whose key lacks the scope a route requires gets a real, reachable 403 with problem type insufficient-scope, never a silent allow and never a 401.
Errors
Every error response is application/problem+json; see /problems/ for the closed slug catalogue.
Pagination
List endpoints return {data, has_more[, total_count]} and take a real limit query parameter -- default 20, maximum 100 on GET /v1/assets, the same ceiling GET /v1/jobs uses.
Idempotency
POST /v1/jobs and POST /v1/batches both take a real Idempotency-Key header. On batches specifically, per-item idempotency is keyed on (api_key_id, name-or-submit-level-Idempotency-Key, row_key); a submission with neither a name nor an Idempotency-Key header is rejected with 400.
Versioning
This spec is version 2026-08-25. Deprecation (RFC 9745) and Sunset (RFC 8594) headers are reserved as the retirement mechanism for a future breaking change; neither is emitted today, no version-transform layer exists or is planned until that first breaking change happens, and no Shapecast-Version request header exists yet.
Prompt checks
Prompt checks are switched on for text prompts. Before any GPU time is spent, the gateway asks TypeSafe's Jev model whether a prompt tends to build badly (a whole scene, thin or twiggy parts, photo or render wording, an awkward pose or camera angle, or too little detail) and, if so, adds a short tip on how to fix it. A check never blocks or delays a job: if TypeSafe is slow, unsure or unavailable, the job goes ahead with no check, exactly as before. No request is refused with prompt-rejected today.
A successful response from POST /v1/jobs or POST /v1/batches (and their dry runs) may carry an optional checks array; image uploads (POST /v1/jobs/from-image) are not checked: on the Job, on the batch's 202 body, or on a dry run's DryRunResult. Each entry is {code, message, severity, source, item}. code is one of the closed set below, so a client can branch on it. message is plain-English advice for a person. severity is info or warn. source is jev (TypeSafe's Jev model) or code (the gateway's own code). item is the batch item index, and is absent on a single job. checks itself is absent when no check ran.
Checks are advisory: a check never blocks a job.
warnings is unchanged for existing callers. It stays an array of plain strings, and each check's message is added there too, prefixed "item N: " for a batch item, the same way every other per-item batch warning is. A client that reads only warnings needs no change.
To see a prompt's checks without creating anything, send the same request with ?validate=true or a Prefer: dry-run header. The 200 body is a DryRunResult, {valid, warnings, checks}; no job or batch is created and nothing is uploaded.
A prompt refused under bevel's acceptable-use policy is not a check. The request fails with 422 and an application/problem+json body whose type ends in /problems/prompt-rejected. Its detail links to the acceptable-use policy and, for a batch, starts "item N: " to name the refused item.
- thin_sparse
- thin or twiggy parts that tend not to rebuild
- extra_surroundings
- a scene or background, not one object
- orientation_trait
- a pose or camera angle that fights the reference view
- vague_prompt
- too little detail
- photo_or_render
- photo or render wording
- kind_defaulted
- kind was omitted and defaulted to prop
- checks_unavailable
- the prompt could not be checked this time; the job goes ahead unchecked
- held_for_review
- the job is held for the owner to review before it runs
Jobs
List jobs owned by this key
Body parameters
One real state (exact match), or a comma-separated list of real states (an OR-of-states match) -- e.g. status=queued,leased,running,canceling. Every value must be one of the job lifecycle's own 11 real states.
Responses
Errors you might hit
curl https://bevel.foo/v1/jobs \ -H "Authorization: Bearer $BEVEL_KEY"
Submit a job
?validate=true (or Prefer: dry-run) runs the exact same validation as a real submission -- including the rig/kind/pose/target_tris checks -- but never creates a job, consumes quota, or spends GPU time: a 400/422 on failure is identical to what a real submission would return, and a 200 {"valid":true,"warnings":[...]} on success.
Body parameters
wait=N (seconds, capped at 50): hold the response open and return 200 if the job reaches terminal within N seconds, else 202. dry-run: validate only, see validate=true below (equivalent to it; either works).
true: validate-only mode -- see the operation description. Never combine with Prefer: wait, since a validate-only request never creates a job to wait on.
Responses
Errors you might hit
curl https://bevel.foo/v1/jobs \
-H "Authorization: Bearer $BEVEL_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "prop",
"metadata": {
"order_id": "ord_9f2a"
},
"name": "brass-compass-01",
"pose": null,
"prompt": "a weathered brass compass with an engraved lid",
"rig": false,
"scale_m": 0.12,
"seed": null,
"style": null,
"tags": [
"catalog-import"
],
"target_tris": 20000,
"variants": null
}'
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
Submit a job from an uploaded reference image
?validate=true (or Prefer: dry-run) validates the request (including that an image part was provided) without uploading it to storage or creating a job -- 200 {"valid":true,"warnings":[...]} on success, same 400/422 as a real submission on failure.
Form fields (SCGW-382/383): `image` (the file part, required), `name`, `kind`, `rig`, `target_tris`, `pose`, plus the Shapecast 0.3.12 options `texture_quality` (standard, 4k, 8k), `detail` (standard, high) and `geometry_only` (true or false), with the same vocabularies and the same geometry_only-versus-4k/8k conflict as POST /v1/jobs. Export (SCGW-393): `export_preset`, `export_formats` (repeat the part once per format) and `print_scale_mm`, with the same vocabularies and rules as on POST /v1/jobs; the files are delivered as `export` and `export_manifest` artifacts once the job finishes. Any other part name (including a misspelt option) is refused with 422 listing it, never silently dropped. A bad option value is 422 naming the field.
F37: only POST is registered on this exact path; GET/PUT/etc. correctly 404/405 rather than falling through to GET /v1/jobs/{id} with id="from-image" (a previous route-collision bug -- see server.go).
Body parameters
dry-run: validate only, equivalent to ?validate=true.
true: validate-only mode -- see the operation description.
Responses
Errors you might hit
curl https://bevel.foo/v1/jobs/from-image \ -X POST \ -H "Authorization: Bearer $BEVEL_KEY"
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
Read a job
Body parameters
Long-poll up to N seconds for a state change.
Responses
Errors you might hit
curl https://bevel.foo/v1/jobs/{id} \
-H "Authorization: Bearer $BEVEL_KEY"
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
Resolve Gate 2: approve (optionally edit) the predicted rig
SCGW-92. Requires jobs:write. Only meaningful while state=="awaiting_rig_review" (a rig:true job that has finished postprocessing and rigging). Mirrors Shapecast's own ApproveRig exactly (api-reference.md 4.8): send `{}` to accept the predicted skeleton as-is, or bone_edits/bone_deletes to adjust it first. The rig preview itself is the `rigged_glb` artifact, already visible via GET /v1/jobs/{id}/artifacts as soon as the job reaches this state -- there is no separate bone-list endpoint; Shapecast has none, so inspect the GLB's own skeleton in a viewer/tool that reads it. SCGW-99, found live (measured 2026-09-02, BANNER-556): inspecting that skeleton does NOT get you usable bone names today -- Shapecast's `rigged_glb` preview names every joint positionally (`bone_0`...`bone_N`, root `bone_0`); the real, semantic names (`Hips`, `LeftUpperLeg`, ...) only appear in the `animated_glb` delivered AFTER this call, once the animate stage has run. bone_edits/bone_deletes are keyed on joint name, so in practice they cannot be authored from what this endpoint hands you to review -- an engine-side limitation (Shapecast assigns names later in its own pipeline than this gate fires), not something fixable at this layer. A caller with no real edit to make should send `{}`; that path works correctly. Moves the job back to "queued" with a resume marker, the same mechanism select-variant uses -- the next worker to lease it applies these edits against the EXISTING Shapecast job and continues into the animate stage, it does not re-rig from scratch. There is currently NO timeout on how long a job may sit at awaiting_rig_review waiting for this call.
Body parameters
Responses
Errors you might hit
curl https://bevel.foo/v1/jobs/{id}/approve-rig \
-X POST \
-H "Authorization: Bearer $BEVEL_KEY"
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
List a job's artifacts
Body parameters
Responses
Errors you might hit
curl https://bevel.foo/v1/jobs/{id}/artifacts \
-H "Authorization: Bearer $BEVEL_KEY"
Request cancellation
No progress-level webhooks exist in v1 -- cancellation outcome is only ever observed via the job resource or job.canceled/job.failed/job.succeeded* webhooks, never a dedicated cancel-progress event.
F37: cancelling a job already in queued/leased/running/canceling is idempotent (a repeat call while still canceling returns 202 again with the unchanged resource). A job paused at awaiting_selection or awaiting_rig_review (SCGW-92) cancels straight to canceled too, the same as queued -- it holds no lease, so no agent needs to confirm (fixed 2026-09-03: this used to 409 job-already-terminal, wrongly, since these two states didn't exist yet when the cancel filter was written; a caller had no way to abandon a stuck gated job at all). Once the job has actually reached a terminal state (succeeded, succeeded_degraded, failed, canceled, expired), a further cancel call is a real, deliberate 409 job-already-terminal, not a bug -- there is nothing left to cancel. A retry-safe client should treat 409 job-already-terminal on this operation as an expected, safe-to-ignore outcome (the job is done, one way or another) rather than an error to surface to a user.
Body parameters
Responses
Errors you might hit
curl https://bevel.foo/v1/jobs/{id}/cancel \
-X POST \
-H "Authorization: Bearer $BEVEL_KEY"
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
Real webhook delivery history for a job
Every shapecast.outbox row ever recorded for this job (aggregate_id match), oldest first -- not further filtered by endpoint owner. New deliveries only ever go to the job owner's own subscribed endpoints (SCGW-133); older rows from before that fix may include other keys' endpoints (see JobDelivery's own description).
Body parameters
Responses
Errors you might hit
curl https://bevel.foo/v1/jobs/{id}/deliveries \
-H "Authorization: Bearer $BEVEL_KEY"
Public event log for a job
Body parameters
Responses
Errors you might hit
curl https://bevel.foo/v1/jobs/{id}/events \
-H "Authorization: Bearer $BEVEL_KEY"
Revise the reference images at Gate 1
SCGW-391. Requires jobs:write. The other decision at awaiting_selection, beside `select-variant`: instead of choosing one of the reference images, say what should change. The engine (Shapecast 0.3.12's `POST /v1/jobs/{job_id}/revise`) deletes the current references, adds your `note` to the job's prompt and generates new ones. The job goes back to "queued", a worker continues the existing cast, and once the new references are ready the job parks at `awaiting_selection` again, with a fresh list from `GET /v1/jobs/{id}/variants`. The old variant ids no longer exist. A job can be revised any number of times.
Only a job at `awaiting_selection` can be revised. The note is a plain sentence appended to the prompt; the engine does not take other keys.
Body parameters
Responses
Errors you might hit
curl https://bevel.foo/v1/jobs/{id}/revise \
-H "Authorization: Bearer $BEVEL_KEY" \
-H "Content-Type: application/json" \
-d '{
"note": "make the armor dark green, no cape"
}'
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
Resolve Gate 1: pick a reference image
SCGW-92. Requires jobs:write. Moves the job from awaiting_selection back to "queued" with an internal resume marker -- the NEXT worker to lease it (not necessarily the one that originally reported it blocked) continues the existing Shapecast cast from the chosen reference image, it does not start over. Poll GET /v1/jobs/{id} (or use this call's own 200 response) to see the resumed state progress from queued through generating_shape onward. There is currently NO timeout on how long a job may sit at awaiting_selection waiting for this call -- see Job.state's own description.
Body parameters
Responses
Errors you might hit
curl https://bevel.foo/v1/jobs/{id}/select-variant \
-X POST \
-H "Authorization: Bearer $BEVEL_KEY"
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
Gate 1 review material: the generated reference images
SCGW-92. Only meaningful while state=="awaiting_selection" -- returns every ref_variant artifact Shapecast generated for this job (uploaded the moment the job first reported that state; see the Job.state description). Pick one and POST its `id` to /v1/jobs/{id}/select-variant. Calling this before the job reaches awaiting_selection, or after a caller has already resolved the gate (the job has since moved on to generating_shape or further), returns 409 -- the same "job is <state>, not awaiting selection" shape Shapecast's own engine uses (api-reference.md 4.7), not a 404: the job exists, it just isn't at this gate right now.
Body parameters
Responses
Errors you might hit
curl https://bevel.foo/v1/jobs/{id}/variants \
-H "Authorization: Bearer $BEVEL_KEY"
Batches
Submit a batch
SCGW-382: strict, like POST /v1/jobs. `defaults` and every item may carry only the fields of the JobCreate schema (including texture_quality, detail and geometry_only, SCGW-383, and side_views and prompt_mode, SCGW-384); any other key is a 400 naming the item (or `defaults`) and the key, and no job is created. Each item is validated exactly as POST /v1/jobs would validate it.
Accepts either the canonical object form {name, defaults, items} or a legacy bare array of items; both produce identical children given the same effective idempotency identity. Per-item idempotency is keyed on (api_key_id, name-or-submit-level-Idempotency-Key, row_key) where row_key hashes {prompt,kind,style,pose,rig,variants,target_tris,seed,scale_m,export_preset,export_formats, and texture_quality/detail/geometry_only/side_views/prompt_mode when set} -- editing one item's prompt and resubmitting replays every unchanged sibling and only re-casts the edited one. A submission with neither a name nor an Idempotency-Key header is rejected with 400, since it would otherwise have no way to scope its children's idempotency identity apart from other callers on the same API key (this matters most for the bare-array form, which has no name field at all).
?validate=true (or Prefer: dry-run) validates every item (after merging defaults) without creating the batch or any child job -- 200 {"valid":true,"warnings":[...]} once every item passes, or the same 400 the real submission would give naming the first invalid item, whichever comes first. SCGW-85: on that 200, warnings[] is the union of every item's own warnings (e.g. an omitted target_tris/variants, a style registered before but not currently offered), each entry prefixed with its item index the same way the 400 case already names the offending item -- e.g. "item 3: target_tris was null or omitted; the engine's default of 40000 applies and is not range-checked".
Body parameters
dry-run: validate only, equivalent to ?validate=true.
true: validate-only mode -- see the operation description.
Responses
Errors you might hit
curl https://bevel.foo/v1/batches \ -X POST \ -H "Authorization: Bearer $BEVEL_KEY"
Read a batch's full-state counts
Body parameters
Responses
Errors you might hit
curl https://bevel.foo/v1/batches/{id} \
-H "Authorization: Bearer $BEVEL_KEY"
Assets
List/search this key's own assets
SCGW-90. An asset is the job that produced it -- see the Asset schema's own description. Ownership-scoped the same way GET /v1/jobs is: owner-only unless the key carries jobs:read:all. Only jobs that produced a real, still-live Shapecast asset (shapecast_asset_id set, not soft- or hard-deleted) are ever listed here.
Body parameters
Exact match (case-insensitive) against one of the asset's own gateway-bookkeeping tags (see PUT .../tags).
Exact match against prop/character/creature; an asset whose producing job omitted kind at submission is treated as "prop" (JobCreate's own default).
Case-insensitive substring match against the asset's name (the producing job's prompt) -- mirrors Shapecast's own `q` param (api-reference.md 5.1).
An asset id from a previous page's next_cursor.
Default 20, maximum 100 -- same ceiling as GET /v1/jobs.
Responses
Errors you might hit
curl https://bevel.foo/v1/assets \ -H "Authorization: Bearer $BEVEL_KEY"
Delete an asset (soft by default; hard with ?purge=true)
SCGW-90. Requires jobs:write (matching cancel and every other job mutation -- never jobs:read:all, which is a read-only bypass). Ownership-scoped: unknown and not-yours both 404. `purge=true` additionally requires the assets:purge scope -- a deliberately separate, non-default grant, since it is the first genuinely irreversible-data-loss capability this API exposes; a key with only jobs:write gets 403 and the asset is provably untouched by the rejected attempt.
Default (soft delete): idempotent -- sets a deleted-at marker excluding this asset from GET /v1/assets and GET /v1/assets/{id}, matching Shapecast's own soft-delete semantics (api-reference.md 5.5: "files stay on disk, the row is hidden from GET /v1/assets"). The producing job resource itself (GET /v1/jobs/{id}) is completely untouched -- the asset-library view and the job-resource view are separate surfaces over the same row by design.
`?purge=true`: soft-deletes (if not already) AND additionally, once, hard-deletes this job's own artifact rows -- the gateway's real, irreversible downloadable copies. This does NOT reach Shapecast's own host to delete the library folder the way Shapecast's own purge does (api-reference.md 5.5: an unrecoverable rmtree) -- the gateway has no synchronous path to Shapecast's local API to do that; this is a known, documented architecture gap, not a silent no-op dressed up as a real delete.
Body parameters
true: hard-delete (see operation description). Requires the assets:purge scope. Any other value, or omitted, is treated as false.
Responses
Errors you might hit
curl https://bevel.foo/v1/assets/{id} \
-X DELETE \
-H "Authorization: Bearer $BEVEL_KEY"
Read an asset's full detail
SCGW-90. Ownership-scoped exactly like GET /v1/jobs/{id}: unknown and not-yours both 404, with the same jobs:read:all bypass.
Body parameters
Shapecast's own asset id (job.shapecast_asset_id).
Responses
Errors you might hit
curl https://bevel.foo/v1/assets/{id} \
-H "Authorization: Bearer $BEVEL_KEY"
{
"clips": [],
"created_at": "2026-08-27T06:18:27.21Z",
"files": [
{
"bytes": 7271620,
"content_type": "model/gltf-binary",
"expires_at": "2026-09-26T06:18:27.21Z",
"filename": "final.glb",
"format": "glb",
"id": "8d2f9a3e-1b4c-4e2a-9f3a-2c1d4e5f6a7b",
"kind": "final_glb",
"lod_level": null,
"scale_factor": 0.06,
"tri_count": 39482,
"tri_count_source": "manifest",
"url": "/v1/artifacts/8d2f9a3e-1b4c-4e2a-9f3a-2c1d4e5f6a7b/content"
}
],
"folder": "a-weathered-brass-compass-b9d5cdf7",
"id": "a3f9c21b-9d4f-4a1b-8c2e-5f6a7b8c9d0e",
"job_id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"kind": "prop",
"name": "a weathered brass compass with an engraved lid",
"tags": [
"catalog-import",
"brass"
],
"urls": {
"job": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a",
"self": "/v1/assets/a3f9c21b-9d4f-4a1b-8c2e-5f6a7b8c9d0e",
"tags": "/v1/assets/a3f9c21b-9d4f-4a1b-8c2e-5f6a7b8c9d0e/tags"
}
}
Rename an asset
SCGW-395. Requires jobs:write. Ownership-scoped: unknown, deleted and not-yours all 404.
Two things happen together, in one transaction. The new name is stored on the asset at once, so GET /v1/assets/{id}, the asset list and `GET /v1/assets?q=` use it as soon as this returns, and the 200 body is the renamed asset. And a job with `operation_kind: "rename"` is queued, which the agent runs against Shapecast 0.3.12's `PATCH /v1/assets/{asset_id}` so the engine's own library entry carries the name too. The 200 body carries that job's id as `rename_job_id`; poll GET /v1/jobs/{id} to see whether the engine side finished. If the engine refuses the name the job fails with the engine's error and the gateway keeps the new name.
The worker must be online, like every operation on an existing asset: a worker the gateway knows is offline is a 503 and nothing is stored, so the two names cannot drift apart because nobody was there to rename the engine's.
Body: `{"name": "..."}`, trimmed, 1-200 characters, `extra: forbid`.
Body parameters
Shapecast's own asset id (job.shapecast_asset_id).
Responses
Errors you might hit
curl https://bevel.foo/v1/assets/{id} \
-X PATCH \
-H "Authorization: Bearer $BEVEL_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Barrel v2"
}'
{
"created_at": "2026-08-27T06:18:27.21Z",
"degraded": false,
"final_glb_artifact_id": "b1a2c3d4-0004-4a5b-9c6d-7e8f9a0b1c2d",
"folder": "a-weathered-brass-compass-b9d5cdf7",
"id": "a3f9c21b-9d4f-4a1b-8c2e-5f6a7b8c9d0e",
"job_id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"kind": "prop",
"name": "a weathered brass compass with an engraved lid",
"pose": "",
"rig": false,
"tags": [
"catalog-import",
"brass"
],
"thumbnail_artifact_id": "b1a2c3d4-0003-4a5b-9c6d-7e8f9a0b1c2d",
"tri_count": 20000,
"urls": {
"job": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a",
"self": "/v1/assets/a3f9c21b-9d4f-4a1b-8c2e-5f6a7b8c9d0e",
"tags": "/v1/assets/a3f9c21b-9d4f-4a1b-8c2e-5f6a7b8c9d0e/tags"
}
}
Remove an animation clip from an existing rigged asset
SCGW-94. **DESTRUCTIVE**: once the created job actually runs, it rewrites `animated.glb` with the named clip stripped out and removes it from the asset's own clip list (api-reference.md 5.15) -- there is no undo. Requires `jobs:write`.
Same async resource model as the operations above: this creates a job (`operation_kind: "delete_clip"`, `target_asset_id` set to `id`). `clip_name` is matched Shapecast-side after `strip().lower()`.
Errors Shapecast itself can still report once the job runs (409 no_animations/clip_not_found/export_busy; 500 glb_invalid/clip_failed) surface on the created job's own `error` field, never as a synchronous response from this route.
Body parameters
The target asset's id.
The clip's name, matched after strip().lower() on Shapecast's own side.
Responses
Errors you might hit
curl https://bevel.foo/v1/assets/{id}/clips/{clip_name} \
-X DELETE \
-H "Authorization: Bearer $BEVEL_KEY"
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
Export an existing asset (export)
SCGW-394. Exports an already-built asset with Shapecast 0.3.12's export operation (`POST /v1/assets/{asset_id}/export`), so an owner who did not ask for stl or any other format at submit time does not have to recast. Requires `jobs:write`. Not destructive: the engine writes into the asset folder's exports/ subfolder.
Resource model: this does NOT run synchronously. It creates a job resource -- the exact same shape POST /v1/jobs returns, with `operation_kind: "export_asset"` and `target_asset_id` set to `id` -- on the shared GPU queue, which the caller polls via GET /v1/jobs/{id}, then reads the files from GET /v1/jobs/{id}/artifacts once terminal: one `export` artifact per file and an `export_manifest`.
Body: `{"preset": ..., "formats": [...], "print_scale_mm": ...}`, validated like the submit-time export fields. At least one of `preset` and `formats` is required (an empty body is 400, mirroring the engine's empty_export). The body is `extra: forbid`.
Ownership: same unknown-and-not-yours-both-404 rule as fix-face/repaint.
Every engine-side outcome (409 export_busy while another export runs, 409 no_source, 403 license_required/setup_required, and any engine failure) surfaces on the created job's own `error` field, never as a synchronous response from this route.
Body parameters
The target asset's id.
Responses
Errors you might hit
curl https://bevel.foo/v1/assets/{id}/export \
-H "Authorization: Bearer $BEVEL_KEY" \
-H "Content-Type: application/json" \
-d '{
"preset": "printing",
"print_scale_mm": 80
}'
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
Repair a known face-texture defect on an existing asset
SCGW-91. **DESTRUCTIVE**: once the created job actually runs, it re-bakes the face region from the asset's saved reference image and overwrites `final.glb` and `thumbnail.png` in place (api-reference.md 5.9). The previous model is kept for one step back: `POST /v1/assets/{id}/touchup-undo` restores it (SCGW-392). The request body is optional (SCGW-390): with none, or `{}`, the engine finds the face itself; with `cx` and `cy` it aims the bake at that click on the asset's saved reference image. Requires `jobs:write`.
Resource model: this does NOT run synchronously. It creates a job resource -- the exact same shape POST /v1/jobs returns, with `operation_kind: "fix_face"` and `target_asset_id` set to `id` -- which the caller polls via GET /v1/jobs/{id} exactly like a generation job, then reads the updated GLB/thumbnail back via GET /v1/jobs/{id}/artifacts once terminal.
Ownership: `id` must be an asset a prior job under THIS key produced (or already operated on) -- an asset id no job of this key's has ever reported, and one owned by a different key, both 404 identically (gw-tenancy: never leak that an asset exists to a non-owner).
Every Shapecast-side outcome (409 export_busy -- the engine's global export/fix-face/repaint lock is held by another long operation, retried automatically by the agent before it ever gives up; 409 no_source/no_reference; 500 facefix_failed; 403 license_required/setup_required) happens inside the agent's own call to Shapecast, well after this endpoint has already returned 202 -- it surfaces on the created job's own `error` field (GET /v1/jobs/{id}), never as a synchronous response from this route.
Body parameters
The target asset's id (a prior job's shapecast_asset_id).
Responses
Errors you might hit
curl https://bevel.foo/v1/assets/{id}/fix-face \
-H "Authorization: Bearer $BEVEL_KEY" \
-H "Content-Type: application/json" \
-d '{}'
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
Re-cast one hand of an existing asset (fix-hand)
SCGW-390. **DESTRUCTIVE**: once the created job actually runs, it re-casts one hand from a crop of the asset's saved reference image (Shapecast 0.3.12's `POST /v1/assets/{asset_id}/fix-hand`) and overwrites `final.glb` and the thumbnail in place. The previous model is kept for one step back: `POST /v1/assets/{id}/touchup-undo` restores it. Requires `jobs:write`.
`cx` and `cy` are where the hand is on the saved reference image, as fractions of its width and height. The engine runs its shape and texture steps on the crop, so the job takes a few minutes and needs the GPU. It fails with the engine's own error when the asset has no saved reference image (`no_reference`, assets made before fix-face existed) or the engine is not set up.
Body parameters
The target asset's id.
Responses
Errors you might hit
curl https://bevel.foo/v1/assets/{id}/fix-hand \
-H "Authorization: Bearer $BEVEL_KEY" \
-H "Content-Type: application/json" \
-d '{
"cx": 0.5,
"cy": 0.7
}'
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
Generate a new animation clip for an existing rigged asset from a text prompt
SCGW-94. **DESTRUCTIVE**: once the created job actually runs, it parses the prompt into a motion family and appends the generated clip into `animated.glb` in place (api-reference.md 5.14) -- there is no undo. Requires `jobs:write`.
Same async resource model as retarget/fix-face/repaint: this creates a job (`operation_kind: "generate_clip"`, `target_asset_id` set to `id`).
Field errors this gateway can check without asking Shapecast (blank prompt, or over 200 bytes after trimming) return 400 before any job is created. The motion-family vocabulary itself (idle/walk/run/jump/attack/death) and skeleton-capability gate are entirely Shapecast's own -- an unsupported motion (409 unsupported_motion) or an incompatible skeleton (409 clip_unsupported) surface on the created job's own `error` field, along with 409 no_animations/clip_exists/export_busy, 500 clip_failed, and 403 license_required/setup_required.
Body parameters
The target asset's id.
Responses
Errors you might hit
curl https://bevel.foo/v1/assets/{id}/generate-clip \
-H "Authorization: Bearer $BEVEL_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "menace_walk",
"prompt": "a menacing stalking walk"
}'
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
Repaint (rebake) part of an existing asset's texture from a masked prompt
SCGW-91. **DESTRUCTIVE**: once the created job actually runs, it renders the requested view, runs the image-host repaint model, bakes the result back onto the mesh, re-renders the thumbnail, and overwrites the asset's GLB and thumbnail in place (api-reference.md 5.11) -- there is no undo. Requires `jobs:write`.
Same async resource model as POST .../fix-face: this creates a job (`operation_kind: "repaint"`, `target_asset_id` set to `id`) rather than running synchronously -- poll GET /v1/jobs/{id}, then GET /v1/jobs/{id}/artifacts once terminal for the updated GLB/thumbnail. `mask_png` should line up with whatever GET /v1/assets/{id}/repaint-view rendered for the same `view`.
Ownership: same unknown-and-not-yours-both-404 rule as fix-face.
Field errors (missing/blank prompt, missing mask_png, an oversized mask, strength or view outside the documented range/enum, or an unrecognised top-level field -- this body is `extra: forbid`, matching Shapecast's own RepaintRequest) are caught here and return 400 before any job is created. Everything Shapecast itself can still reject once the job actually runs (422 bad_mask/mask_too_large/bad_prompt/bad_strength/bad_view -- the engine's own deeper image-content checks this gateway does not duplicate; 409 export_busy/image_not_available/export_unavailable; 500 repaint_failed; 403 license_required/setup_required) surfaces on the created job's own `error` field, never as a synchronous response from this route.
Body parameters
The target asset's id.
Responses
Errors you might hit
curl https://bevel.foo/v1/assets/{id}/repaint \
-H "Authorization: Bearer $BEVEL_KEY" \
-H "Content-Type: application/json" \
-d '{
"mask_png": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"prompt": "change the tunic from red to deep forest green",
"strength": 0.75,
"view": "front"
}'
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
Render (or re-fetch) the reference view a repaint mask must line up with
SCGW-91. Not destructive -- but unlike a normal GET, this route creates a job resource, exactly like the two POST operations above (`operation_kind: "repaint_view"`, `target_asset_id` set to `id`): this gateway has no synchronous path to the remote, poll-driven Shapecast agent (see assetops.go's own package doc comment), so a GET here cannot simply block and hand back PNG bytes the way Shapecast's own local route does. Poll GET /v1/jobs/{id}, then GET /v1/jobs/{id}/artifacts once terminal to fetch the rendered PNG (1024x1024, api-reference.md 5.10) via the normal artifact-content redirect. Requires `jobs:write` for the same reason as repaint-view's POST siblings: it creates a job. Shapecast itself caches this render per (asset, view) on its own side ("cached-on-hit"), so a repeat call for the same view after the first is fast on the agent's end even though this gateway still creates a fresh job row for it.
Body parameters
The target asset's id.
Defaults to three_quarter when omitted.
Responses
Errors you might hit
curl https://bevel.foo/v1/assets/{id}/repaint-view \
-H "Authorization: Bearer $BEVEL_KEY"
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
Fit an uploaded animation clip onto an existing rigged asset
SCGW-94. **DESTRUCTIVE**: once the created job actually runs, it retargets the uploaded clip onto the asset's skeleton and overwrites `animated.glb` in place (api-reference.md 5.13) -- there is no undo. `multipart/form-data`, a single required `clip` file part (`.fbx` or `.bvh`, non-empty, at most 25 MB -- Shapecast's own MAX_CLIP_UPLOAD_BYTES). Requires `jobs:write`.
Same async resource model as fix-face/repaint: this creates a job (`operation_kind: "retarget"`, `target_asset_id` set to `id`) rather than running synchronously. The uploaded clip is stored and relayed to the agent out of band -- it never travels through the job's own request/response bodies.
Field errors this gateway can check without asking Shapecast (missing `clip` part, empty file, over 25 MB, or an unsupported extension) return 422 before any job is created -- the same status Shapecast's own equivalent checks (`bad_format`/`empty_clip`/`clip_too_large`) would report for the identical mistake. Everything Shapecast can still reject once the job runs (409 clip_unsupported -- only two-legged characters accept animation files; 409 no_animations/clip_exists/export_busy; 500 clip_failed; 403 license_required/setup_required) surfaces on the created job's own `error` field.
Body parameters
The target asset's id.
Responses
Errors you might hit
curl https://bevel.foo/v1/assets/{id}/retarget \
-X POST \
-H "Authorization: Bearer $BEVEL_KEY"
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
Re-topologise an existing asset (retopo)
SCGW-388. Re-topologises an existing asset with Shapecast 0.3.12's retopo operation (`POST /v1/assets/{asset_id}/retopo`). Requires `jobs:write`. **DESTRUCTIVE** once the created job runs: the engine rewrites the asset's current `final.glb` and thumbnail IN PLACE, replacing the previous mesh, and writes no separate retopo file (live-verified on 0.3.12). The job's artifacts are the new `final_glb` and `thumbnail`; the engine's `retopo_glb` kind is not produced by this operation. Download the asset's current files first if you want to keep the old mesh. Other asset files (LOD files, animated.glb) are not regenerated and may be stale afterward.
Resource model: this does NOT run synchronously. It creates a job resource -- the exact same shape POST /v1/jobs returns, with `operation_kind: "retopo"` and `target_asset_id` set to `id` -- on the shared GPU queue, which the caller polls via GET /v1/jobs/{id}, then reads the result back via GET /v1/jobs/{id}/artifacts once terminal.
Body: `{"faces": <integer>}`. `faces` is optional and defaults to 12000, the engine's own default; the gateway stores the resolved value on the job. `faces` is the engine's quad-dominant face count, not a triangle target: the triangle count of the result is roughly 1.8 times larger and not exact (live-verified: faces 2000 on a 3000-triangle prop gave 3608 triangles). The engine's schema gives `faces` no bounds, so this gateway accepts 500-200000 (the range the engine documents for `target_tris`) and the engine stays the authority: a value it still refuses surfaces on the job's own `error` field. The body is `extra: forbid`.
Ownership: same unknown-and-not-yours-both-404 rule as fix-face/repaint.
Every engine-side outcome (409 export_busy/engine_busy while the engine's global lock or a cast holds the GPU, 403 license_required/setup_required, and any engine failure) happens inside the agent's own call to Shapecast, well after this endpoint has already returned 202, and surfaces on the created job's own `error` field.
Body parameters
The target asset's id.
Responses
Errors you might hit
curl https://bevel.foo/v1/assets/{id}/retopo \
-X POST \
-H "Authorization: Bearer $BEVEL_KEY"
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
Rig an already-finished asset
SCGW-94. **DESTRUCTIVE**: reopens the asset's job row and enqueues a real rig work item through Shapecast's OWN job queue (api-reference.md 5.12) -- there is no undo. No request body. Requires `jobs:write`.
Unlike every other operation on this page, this is genuinely async through Shapecast's own queue rather than one blocking call: the created job (`operation_kind: "rig"`, `target_asset_id` set to `id`) is gate-eligible exactly like a fresh `rig:true` generation -- it can park on `awaiting_rig_review` (POST /v1/jobs/{id}/approve-rig resolves it, same as any other rig gate) before eventually reaching a terminal state. Poll GET /v1/jobs/{id}, then GET /v1/jobs/{id}/artifacts once terminal for the updated GLB.
Ownership: same unknown-and-not-yours-both-404 rule as fix-face/repaint.
Errors Shapecast itself can still report once the job actually runs (409 not_riggable -- only characters/creatures can be rigged; 409 no_source/no_job; 409 job_active -- this asset's own job is still running, retry once it finishes) surface on the created job's own `error` field, never as a synchronous response from this route.
Body parameters
The target asset's id.
Responses
Errors you might hit
curl https://bevel.foo/v1/assets/{id}/rig \
-X POST \
-H "Authorization: Bearer $BEVEL_KEY"
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
Upscale an existing asset's texture (sharpen)
SCGW-389. **DESTRUCTIVE**: once the created job actually runs, it upscales the asset's texture with Shapecast 0.3.12's sharpen operation (`POST /v1/assets/{asset_id}/sharpen`) and overwrites `final.glb` and the thumbnail in place -- there is no undo. Requires `jobs:write`.
Resource model: same as retopo and fix-face -- this creates a job (`operation_kind: "sharpen"`, `target_asset_id` set to `id`) on the shared GPU queue rather than running synchronously. Poll GET /v1/jobs/{id}, then GET /v1/jobs/{id}/artifacts once terminal for the updated `final_glb` and thumbnail.
Body: `{"quality": "4k"|"8k"}`, required. The engine accepts only 4k or 8k for sharpen, so this gateway rejects anything else, `standard` included, with 400 naming the `quality` field and listing the valid values (SCGW-404). The engine stays the authority for everything else: a failure it reports surfaces on the job's own `error` field. The body is `extra: forbid`.
Ownership: same unknown-and-not-yours-both-404 rule as fix-face/repaint.
Every engine-side outcome (409 export_busy/engine_busy, 403 license_required/setup_required, and any engine failure) surfaces on the created job's own `error` field, never as a synchronous response from this route.
Body parameters
The target asset's id.
Responses
Errors you might hit
curl https://bevel.foo/v1/assets/{id}/sharpen \
-X POST \
-H "Authorization: Bearer $BEVEL_KEY"
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
Undo the last touch-up on an existing asset
SCGW-392. **DESTRUCTIVE**: once the created job actually runs, it restores the asset's model and thumbnail from just before the last `fix-face`, `fix-hand` or `repaint` (Shapecast 0.3.12's `POST /v1/assets/{asset_id}/touchup-undo`). Only one step back is kept per asset. Requires `jobs:write`. No request body.
Whether there is anything to undo is only known to the engine, so this call is accepted regardless and the job fails with the engine's own message (`nothing_to_undo`: "There is no touch-up to undo for this asset.") when there is not. The restored `final_glb` and `thumbnail` are delivered as the job's artifacts, like any other touch-up.
Body parameters
The target asset's id.
Responses
Errors you might hit
curl https://bevel.foo/v1/assets/{id}/touchup-undo \
-X POST \
-H "Authorization: Bearer $BEVEL_KEY"
{
"attempt": 1,
"bbox_native": {
"max": [
0.9938958287239075,
0.07364699989557266,
0.9947947263717651
],
"min": [
-0.9997259974479675,
-0.07583899796009064,
-0.9999741315841675
]
},
"blocked": false,
"blocked_reason": null,
"cancel_requested_at": null,
"client_ref": "brass-compass-01",
"created_at": "2026-08-27T06:10:21.699Z",
"degraded": false,
"error": null,
"estimated_completion_at": null,
"estimated_gpu_seconds": null,
"finished_at": "2026-08-27T06:18:27.21Z",
"id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"last_progress_at": "2026-08-27T06:18:20.5Z",
"max_attempts": 3,
"operation_kind": "generate",
"outcome": "succeeded",
"progress": null,
"queue_position": null,
"scale_factor": null,
"scale_m": 0.12,
"stage": null,
"stage_started_at": null,
"started_at": "2026-08-27T06:10:22.1Z",
"state": "succeeded",
"target_asset_id": null,
"terminal": true,
"urls": {
"artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
"cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel",
"events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
"self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
},
"usage": {
"gpu_seconds": 483.2
},
"warnings": []
}
Artifacts
Fetch (or redirect to) an artifact's bytes
Mints a fresh Supabase Storage signed URL and 302s to it -- the artifact's own object_key never appears in any response body. That signed URL itself carries no Shapecast auth: possession of the URL is sufficient to download, exactly like any presigned storage URL, for as long as it remains valid. Three things about that redirect that a caller should know:
1. The signed URL is short-lived -- valid for 300 seconds (5 minutes) from the moment this endpoint mints it, not from when the caller first requested it. A URL saved/logged/cached past that window returns an expired-token error from storage, not from this gateway; re-GET this endpoint to mint a new one. 2. The storage response the URL points at is served with `Access-Control-Allow-Origin: *`, so for that same 5-minute window the URL is fetchable from browser JS on any origin, not just the caller's own. 3. The `Location` header (and so the redirect itself) discloses the storage host, the bucket name, and the owning job's UUID in the URL path. This is exposed only to a caller who already holds a valid Shapecast API key authorized for that artifact -- i.e. disclosure is scoped to an already-authorized caller, not to the public -- but it is real disclosure, so avoid forwarding this endpoint's raw Location header to an untrusted party.
Artifacts also carry their own, separate retention window (see 410 below) -- distinct from the above per-download URL TTL.
Body parameters
Responses
Errors you might hit
curl https://bevel.foo/v1/artifacts/{id}/content \
-H "Authorization: Bearer $BEVEL_KEY"
Styles
Currently-available styles, with never-drop semantics
A style Shapecast has ever reported stays listed even if temporarily unavailable, marked accordingly -- callers building content pipelines never see a style silently vanish from the catalogue.
Responses
Errors you might hit
curl https://bevel.foo/v1/styles \ -H "Authorization: Bearer $BEVEL_KEY"
Webhooks
List webhooks owned by this key
Requires the `webhooks:write` scope (there is no separate webhooks:read scope in this system); a key without it gets 403 insufficient-scope. F9: added alongside GET/DELETE .../{id} -- previously the only way to interact with a registered webhook was POST, with no way to list, read, or remove one afterward.
Responses
Errors you might hit
curl https://bevel.foo/v1/webhooks \ -H "Authorization: Bearer $BEVEL_KEY"
Register a webhook endpoint
Requires the `webhooks:write` scope; a key without it gets 403 insufficient-scope. An endpoint receives only the event types listed in `events`, and only for jobs and batches owned by the same API key that registered it (SCGW-133). `events` must be non-empty and every entry must be one of the event names under `webhooks` in this document.
Responses
Errors you might hit
curl https://bevel.foo/v1/webhooks \ -X POST \ -H "Authorization: Bearer $BEVEL_KEY"
{
"consecutive_failures": 0,
"created_at": "2026-08-27T06:00:00Z",
"disabled_at": null,
"events": [
"job.succeeded",
"job.failed"
],
"id": "3e7b1a2c-9d4f-4a1b-8c2e-5f6a7b8c9d0e",
"secret": "whsec_3f9a2b7c1e8d4f5a6b7c8d9e0f1a2b3c",
"url": "https://example.com/hooks/shapecast"
}
Delete a webhook endpoint
Requires the `webhooks:write` scope; a key without it gets 403 insufficient-scope. Ownership-scoped: unknown and not-yours both 404. A real delete, including any recorded delivery history for the endpoint -- not a disable.
Body parameters
Responses
Errors you might hit
curl https://bevel.foo/v1/webhooks/{id} \
-X DELETE \
-H "Authorization: Bearer $BEVEL_KEY"
Read a webhook endpoint
Requires the `webhooks:write` scope; a key without it gets 403 insufficient-scope. Ownership-scoped: unknown and not-yours both 404.
Body parameters
Responses
Errors you might hit
curl https://bevel.foo/v1/webhooks/{id} \
-H "Authorization: Bearer $BEVEL_KEY"
Send a synthetic test delivery to a registered endpoint
Requires the `webhooks:write` scope; a key without it gets 403 insufficient-scope. Ownership-scoped: unknown and not-yours both 404.
Body parameters
Responses
Errors you might hit
curl https://bevel.foo/v1/webhooks/{id}/test \
-X POST \
-H "Authorization: Bearer $BEVEL_KEY"
{
"delivered": true
}
Usage
This key's current quota usage
Responses
Errors you might hit
curl https://bevel.foo/v1/usage \ -H "Authorization: Bearer $BEVEL_KEY"
Queue
The queue: the running job and the queued jobs in claim order
SCGW-396. An owner key sees every job (`scope: "all"`); any other key sees only the jobs it submitted (`scope: "own"`), each at its real place in the whole line. `position` is 1-based in claim order, the order the worker will take jobs; it can differ from a job's own `queue_position`, which is a 0-based count of the jobs ahead that ignores the per-key round robin. `running` is null when the slot is free or the running job belongs to another key. Pause and clear are not offered: pause already exists as worker draining, and a job is canceled with POST /v1/jobs/{id}/cancel.
Responses
Errors you might hit
curl https://bevel.foo/v1/queue \ -H "Authorization: Bearer $BEVEL_KEY"
{
"queued": [
{
"created_at": "2026-10-11T11:35:00Z",
"effective_priority": 0,
"id": "8f1c2f3a-0f6b-4a64-9b53-2f0b2f6d1c11",
"kind": "prop",
"lane": "api",
"not_yet_due": false,
"position": 2,
"priority": 0,
"state": "queued"
}
],
"running": null,
"scope": "own"
}
Move a queued job to the front of the line (owner keys only)
SCGW-396. Raises the job's priority just above every other queued job (never lowers it, and capped), so it is claimed next. Owner keys only: any other key gets 403 before anything about the job is looked at.
Body parameters
A queued job's id.
Responses
Errors you might hit
curl https://bevel.foo/v1/queue/{id}/move-to-front \
-X POST \
-H "Authorization: Bearer $BEVEL_KEY"
{
"id": "8f1c2f3a-0f6b-4a64-9b53-2f0b2f6d1c11",
"position": 1,
"state": "queued"
}
ForgeHub
Publish a packaged piece to ForgeHub
SCGW-328. Requires the `forgehub:write` scope (never bundled into `jobs:write`); a key without it gets 403 insufficient-scope. The gateway commits the piece's packaged GLB and its piece.json into the key's ForgeHub repo, under `<pack>/<piece>/package/`, beside the generation of the same piece (a name like `cave_bear` becomes the folder `cave-bear`). Only `package/model.glb` and `package/piece.json` are ever written; the client names no repo, path or file name, and the filenames on the multipart parts are ignored.
The first package commit of a piece lands straight on main. A later change goes on a `pkg/<piece>-<first 6 of plan_key>` branch with a pull request that is merged, so ForgeHub shows a diff of `package/model.glb`. Identical bytes make no commit and no pull request (`no_change: true`). The commit date is `built_at`, so a retried push is the same commit.
`multipart/form-data`: file part `meta` (the piece.json, JSON, at most 256 KB), file part `model` (a binary glTF, at most 8 MB -- ForgeHub's glTF diff skips larger files), and fields `built_at` (required, RFC 3339), `model_job_id`, `packager` and `plan_key` (recorded as commit trailers). The whole request is at most 16 MB. `pack` and `piece` must match `^[a-z][a-z0-9_]{0,63}$`.
Pushes and the ForgeHub sync share one lock. If it stays busy for 60 seconds, or ForgeHub is failing in a way a retry can fix, the answer is 503 forgehub-unavailable with Retry-After. A ForgeHub 4xx (revoked token, refused merge) is 502 forgehub-rejected. A gateway with no ForgeHub connection answers 503 forgehub-disabled.
Body parameters
Responses
Errors you might hit
curl https://bevel.foo/v1/forgehub/packs/{pack}/pieces/{piece}/package \
-X PUT \
-H "Authorization: Bearer $BEVEL_KEY"
Publish a pack's SPECS.csv to ForgeHub
SCGW-328. Requires the `forgehub:write` scope. The body is the pack's SPECS.csv as `text/csv` (non-empty, at most 1 MB), committed straight to main as `<pack>/SPECS.csv` in the key's ForgeHub repo; identical bytes are `no_change: true`. Optional query `built_at` (RFC 3339) sets the commit date, default now. Error statuses are the same as for the piece route.
Body parameters
Responses
Errors you might hit
curl https://bevel.foo/v1/forgehub/packs/{pack}/specs \
-X PUT \
-H "Authorization: Bearer $BEVEL_KEY"
Meta
Dependency health probe
Responses
curl https://bevel.foo/v1/health
This document
Responses
curl https://bevel.foo/v1/openapi.json
Webhook events
Every real event a registered webhook endpoint can receive. Delivered with webhook-id/webhook-timestamp/webhook-signature headers -- see each event's own description.
Every child job in a batch reached a terminal state. Fires once, regardless of the batch's final state (completed, partially_failed, or canceled) -- there is no separate event per final state; check GET /v1/batches/{id}'s state field to distinguish them.
SCGW-92. A job parked at Gate 2 -- the rigged_glb preview is on GET /v1/jobs/{id}/artifacts, POST .../approve-rig is waiting. NOT terminal, same as job.awaiting_selection above. The payload also carries rig_audit ({passed, joint_count, rules}) when the agent reported one (SCGW-419).
{
"gate": "awaiting_rig_review",
"job_id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"rig_audit": {
"joint_count": 24,
"passed": true,
"rules": [
{
"detail": "24 joints",
"name": "joint_count",
"passed": true
}
]
}
}
SCGW-92. A job parked at Gate 1 -- GET /v1/jobs/{id}/variants now has reference images to review, POST .../select-variant is waiting. NOT terminal -- unlike every other event in this list, the job's own `terminal` field is still false when this fires.
{
"gate": "awaiting_selection",
"job_id": "1164d478-da11-4d9a-aabc-b411e3cc658a"
}
A queued job was canceled before any GPU work started.
A job left queued past queued_expires_at, or a lease expired at max_attempts.
A job failed terminally (non-retryable, or retries exhausted). The payload carries job_id and reason, plus problem_code and failed_rules ([{rule, detail}]) when the agent reported them; a rig that fails the rig audit arrives with problem_code rig_quality (SCGW-419).
{
"failed_rules": [
{
"detail": "1 joint, expected at least 8",
"rule": "joint_count"
}
],
"job_id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
"problem_code": "rig_quality",
"reason": "rig failed the rig audit"
}
A job finished successfully with a clean verdict.
Delivered with headers webhook-id (the event id), webhook-timestamp, and webhook-signature (HMAC over id+timestamp+body, verifiable against the secret(s) returned once at registration) -- see internal/outbox and internal/webhook.
{
"asset_id": "a3f9c21b-brass-compass",
"degraded": false,
"job_id": "1164d478-da11-4d9a-aabc-b411e3cc658a"
}
A job finished but the verdict was degraded.
{
"asset_id": "a3f9c21b-brass-compass",
"degraded": true,
"job_id": "1164d478-da11-4d9a-aabc-b411e3cc658a"
}