On this page

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.

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

GET /v1/jobs

List jobs owned by this key

Body parameters

status query · string

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.

starting_after query · string

Responses

200 OK JobListResponse
401

Errors you might hit

Status
When
401
Request
curl https://bevel.foo/v1/jobs \
  -H "Authorization: Bearer $BEVEL_KEY"
POST /v1/jobs idempotent

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

Idempotency-Key header · string
Prefer header · string

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).

validate query · boolean

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

200 Terminal within the requested wait window, OR a successful ?validate=true/Prefer:dry-run probe (body shape differs: {"valid":true,"warnings":[...]}, no job was created).
202 Accepted, still non-terminal. Job
400 Also returned (F32) when Prefer carries an unparseable wait= value (non-numeric or negative) -- this is a real, deliberate validation error, not the old silent fallback to plain 202 behavior.
401
422
429
503 worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.

Errors you might hit

Status
When
400
Also returned (F32) when Prefer carries an unparseable wait= value (non-numeric or negative) -- this is a real, deliberate validation error, not the old silent fallback to plain 202 behavior.
401
422
429
503
worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.
Request
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
}'
Response 202 Accepted, still non-terminal.
{
  "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": []
}
POST /v1/jobs/from-image

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

Prefer header · string

dry-run: validate only, equivalent to ?validate=true.

validate query · boolean

true: validate-only mode -- see the operation description.

Responses

200 A successful ?validate=true/Prefer:dry-run probe -- {"valid":true,"warnings":[...]}, no job was created and no image was uploaded. DryRunResult
202 Accepted. Job
400
401
422 Also covers a missing/empty/oversized upload; see the 4 distinct messages (F37) -- "missing required multipart field \"image\"", "the uploaded file was empty", "reference images must be under 20 MB", or a field-level validation message, never a single collapsed generic string.
503 worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.

Errors you might hit

Status
When
400
401
422
Also covers a missing/empty/oversized upload; see the 4 distinct messages (F37) -- "missing required multipart field \"image\"", "the uploaded file was empty", "reference images must be under 20 MB", or a field-level validation message, never a single collapsed generic string.
503
worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.
Request
curl https://bevel.foo/v1/jobs/from-image \
  -X POST \
  -H "Authorization: Bearer $BEVEL_KEY"
Response 202 Accepted.
{
  "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": []
}
GET /v1/jobs/{id}

Read a job

Body parameters

id path · string required
wait query · integer

Long-poll up to N seconds for a state change.

Responses

200 OK Job
401
404

Errors you might hit

Status
When
401
404
Request
curl https://bevel.foo/v1/jobs/{id} \
  -H "Authorization: Bearer $BEVEL_KEY"
Response 200 OK
{
  "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": []
}
POST /v1/jobs/{id}/approve-rig

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

id path · string required

Responses

200 OK -- the job resource, state now "queued" and resuming into the animate stage. Job
401
404
409 job is not awaiting_rig_review.
422 invalid-request -- too many bone edits/deletions, or an edit's magnitude exceeds 10.0 per axis. Rejected at this gateway's own edge (never reaches Shapecast, whose equivalent is its own 422 bad_edits, api-reference.md 4.8).

Errors you might hit

Status
When
401
404
409
job is not awaiting_rig_review.
422
invalid-request -- too many bone edits/deletions, or an edit's magnitude exceeds 10.0 per axis. Rejected at this gateway's own edge (never reaches Shapecast, whose equivalent is its own 422 bad_edits, api-reference.md 4.8).
Request
curl https://bevel.foo/v1/jobs/{id}/approve-rig \
  -X POST \
  -H "Authorization: Bearer $BEVEL_KEY"
Response 200 OK -- the job resource, state now "queued" and resuming into the animate stage.
{
  "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": []
}
GET /v1/jobs/{id}/artifacts

List a job's artifacts

Body parameters

id path · string required

Responses

200 OK
401
404

Errors you might hit

Status
When
401
404
Request
curl https://bevel.foo/v1/jobs/{id}/artifacts \
  -H "Authorization: Bearer $BEVEL_KEY"
POST /v1/jobs/{id}/cancel

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

id path · string required

Responses

202 Accepted -- cancellation requested (or, for an already-queued job, applied immediately). F37: this operation always responds 202, matching every other job-mutation route in this API (POST /v1/jobs, POST /v1/jobs/from-image) -- a prior version of this document incorrectly declared 200 here while the implementation always returned 202; the document was the one that was wrong and has been corrected. Job
401
404
409 job-already-terminal -- the job already reached succeeded/succeeded_degraded/failed/canceled/expired before this call. See this operation's description: a retry-safe client should treat this as expected, not as a failure.

Errors you might hit

Status
When
401
404
409
job-already-terminal -- the job already reached succeeded/succeeded_degraded/failed/canceled/expired before this call. See this operation's description: a retry-safe client should treat this as expected, not as a failure.
Request
curl https://bevel.foo/v1/jobs/{id}/cancel \
  -X POST \
  -H "Authorization: Bearer $BEVEL_KEY"
Response 202 Accepted -- cancellation requested (or, for an already-queued job, applied immediately). F37: this operation always responds 202, matching every other job-mutation route in this API (POST /v1/jobs, POST /v1/jobs/from-image) -- a prior version of this document incorrectly declared 200 here while the implementation always returned 202; the document was the one that was wrong and has been corrected.
{
  "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": []
}
GET /v1/jobs/{id}/deliveries

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

id path · string required

Responses

200 OK JobDeliveryListResponse
401
404

Errors you might hit

Status
When
401
404
Request
curl https://bevel.foo/v1/jobs/{id}/deliveries \
  -H "Authorization: Bearer $BEVEL_KEY"
GET /v1/jobs/{id}/events

Public event log for a job

Body parameters

id path · string required
after query · string
limit query · integer

Responses

200 OK JobEventListResponse
401
404

Errors you might hit

Status
When
401
404
Request
curl https://bevel.foo/v1/jobs/{id}/events \
  -H "Authorization: Bearer $BEVEL_KEY"
POST /v1/jobs/{id}/revise

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

id path · string required

Responses

200 OK -- the job resource, state now "queued" and regenerating its references. Job
400 the body is not valid JSON or carries an unrecognised field.
401
404
409 job is not awaiting_selection -- either it hasn't reached that gate yet, or a prior call already resolved it.
422 note is missing, blank, or longer than 500 characters.

Errors you might hit

Status
When
400
the body is not valid JSON or carries an unrecognised field.
401
404
409
job is not awaiting_selection -- either it hasn't reached that gate yet, or a prior call already resolved it.
422
note is missing, blank, or longer than 500 characters.
Request
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"
}'
Response 200 OK -- the job resource, state now "queued" and regenerating its references.
{
  "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": []
}
POST /v1/jobs/{id}/select-variant

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

id path · string required

Responses

200 OK -- the job resource, state now "queued" and resuming. Job
400 artifact_id missing, or not a ref_variant artifact of this job.
401
404
409 job is not awaiting_selection -- either it hasn't reached that gate yet, or a prior call (from this client or another) already resolved it.

Errors you might hit

Status
When
400
artifact_id missing, or not a ref_variant artifact of this job.
401
404
409
job is not awaiting_selection -- either it hasn't reached that gate yet, or a prior call (from this client or another) already resolved it.
Request
curl https://bevel.foo/v1/jobs/{id}/select-variant \
  -X POST \
  -H "Authorization: Bearer $BEVEL_KEY"
Response 200 OK -- the job resource, state now "queued" and resuming.
{
  "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": []
}
GET /v1/jobs/{id}/variants

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

id path · string required

Responses

200 OK -- same per-item shape as GET /v1/jobs/{id}/artifacts, filtered to kind=="ref_variant".
401
404
409 job is <state>, not awaiting_selection.

Errors you might hit

Status
When
401
404
409
job is <state>, not awaiting_selection.
Request
curl https://bevel.foo/v1/jobs/{id}/variants \
  -H "Authorization: Bearer $BEVEL_KEY"

Batches

POST /v1/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

Prefer header · string

dry-run: validate only, equivalent to ?validate=true.

validate query · boolean

true: validate-only mode -- see the operation description.

Responses

200 A successful ?validate=true/Prefer:dry-run probe -- {"valid":true,"warnings":[...]}, no batch or child job was created. DryRunResult
202 Accepted. BatchSubmitResponse
400
401
503 worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.

Errors you might hit

Status
When
400
401
503
worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.
Request
curl https://bevel.foo/v1/batches \
  -X POST \
  -H "Authorization: Bearer $BEVEL_KEY"
GET /v1/batches/{id}

Read a batch's full-state counts

Body parameters

id path · string required

Responses

200 OK Batch
401
404

Errors you might hit

Status
When
401
404
Request
curl https://bevel.foo/v1/batches/{id} \
  -H "Authorization: Bearer $BEVEL_KEY"

Assets

GET /v1/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

tag query · string

Exact match (case-insensitive) against one of the asset's own gateway-bookkeeping tags (see PUT .../tags).

kind query · string

Exact match against prop/character/creature; an asset whose producing job omitted kind at submission is treated as "prop" (JobCreate's own default).

q query · string

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).

starting_after query · string

An asset id from a previous page's next_cursor.

limit query · integer

Default 20, maximum 100 -- same ceiling as GET /v1/jobs.

Responses

200 OK AssetListResponse
400 An unrecognised query parameter.
401

Errors you might hit

Status
When
400
An unrecognised query parameter.
401
Request
curl https://bevel.foo/v1/assets \
  -H "Authorization: Bearer $BEVEL_KEY"
GET /v1/assets/tags

Distinct tags in use across this key's own assets

SCGW-90. Aggregated from the gateway's own bookkeeping tags (see the Asset schema's own description) -- not a live read of Shapecast's own tag store, which the gateway has no synchronous path to reach.

Responses

200 OK AssetTagsResponse
401

Errors you might hit

Status
When
401
Request
curl https://bevel.foo/v1/assets/tags \
  -H "Authorization: Bearer $BEVEL_KEY"
Response 200 OK
{
  "tags": [
    {
      "count": 4,
      "name": "brass"
    },
    {
      "count": 12,
      "name": "catalog-import"
    }
  ]
}
DELETE /v1/assets/{id}

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

id path · string required
purge query · boolean

true: hard-delete (see operation description). Requires the assets:purge scope. Any other value, or omitted, is treated as false.

Responses

204 Deleted (soft, or hard with ?purge=true).
401
403 insufficient-scope -- either the key lacks jobs:write, or ?purge=true was requested without the assets:purge scope.
404

Errors you might hit

Status
When
401
403
insufficient-scope -- either the key lacks jobs:write, or ?purge=true was requested without the assets:purge scope.
404
Request
curl https://bevel.foo/v1/assets/{id} \
  -X DELETE \
  -H "Authorization: Bearer $BEVEL_KEY"
GET /v1/assets/{id}

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

id path · string required

Shapecast's own asset id (job.shapecast_asset_id).

Responses

200 OK AssetDetail
401
404

Errors you might hit

Status
When
401
404
Request
curl https://bevel.foo/v1/assets/{id} \
  -H "Authorization: Bearer $BEVEL_KEY"
Response 200 OK
{
  "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"
  }
}
PATCH /v1/assets/{id}

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

id path · string required

Shapecast's own asset id (job.shapecast_asset_id).

Responses

200 OK -- the renamed asset, plus `rename_job_id`. Asset
400 name is missing, blank or over 200 characters, the body is not valid JSON, or it carries an unrecognised field.
401
403 insufficient-scope -- the key does not carry jobs:write.
404 no such asset -- unknown, deleted or not owned by this key, indistinguishable.
429 quota-exhausted -- max_queued_jobs reached for this key.
503 worker-offline -- see this operation's description. `Retry-After` is 60.

Errors you might hit

Status
When
400
name is missing, blank or over 200 characters, the body is not valid JSON, or it carries an unrecognised field.
401
403
insufficient-scope -- the key does not carry jobs:write.
404
no such asset -- unknown, deleted or not owned by this key, indistinguishable.
429
quota-exhausted -- max_queued_jobs reached for this key.
503
worker-offline -- see this operation's description. `Retry-After` is 60.
Request
curl https://bevel.foo/v1/assets/{id} \
  -X PATCH \
  -H "Authorization: Bearer $BEVEL_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Barrel v2"
}'
Response 200 OK -- the renamed asset, plus `rename_job_id`.
{
  "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"
  }
}
DELETE /v1/assets/{id}/clips/{clip_name}

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

id path · string required

The target asset's id.

clip_name path · string required

The clip's name, matched after strip().lower() on Shapecast's own side.

Responses

202 Accepted -- a job was created for this operation. Job
401
403 insufficient-scope -- the key does not carry jobs:write.
404 no such asset -- unknown or not owned by this key, indistinguishable.
429 quota-exhausted -- max_queued_jobs reached for this key.
503 worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.

Errors you might hit

Status
When
401
403
insufficient-scope -- the key does not carry jobs:write.
404
no such asset -- unknown or not owned by this key, indistinguishable.
429
quota-exhausted -- max_queued_jobs reached for this key.
503
worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.
Request
curl https://bevel.foo/v1/assets/{id}/clips/{clip_name} \
  -X DELETE \
  -H "Authorization: Bearer $BEVEL_KEY"
Response 202 Accepted -- a job was created for this operation.
{
  "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": []
}
POST /v1/assets/{id}/export

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

id path · string required

The target asset's id.

Responses

202 Accepted -- a job was created for this operation. Job
400 Neither preset nor formats is given, a value is outside the vocabulary, print_scale_mm is not positive or no print format is requested, the body is not valid JSON, or it carries an unrecognised field.
401
403 insufficient-scope -- the key does not carry jobs:write.
404 no such asset -- unknown or not owned by this key, indistinguishable.
429 quota-exhausted -- max_queued_jobs reached for this key.
503 worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60.

Errors you might hit

Status
When
400
Neither preset nor formats is given, a value is outside the vocabulary, print_scale_mm is not positive or no print format is requested, the body is not valid JSON, or it carries an unrecognised field.
401
403
insufficient-scope -- the key does not carry jobs:write.
404
no such asset -- unknown or not owned by this key, indistinguishable.
429
quota-exhausted -- max_queued_jobs reached for this key.
503
worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60.
Request
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
}'
Response 202 Accepted -- a job was created for this operation.
{
  "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": []
}
POST /v1/assets/{id}/fix-face

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

id path · string required

The target asset's id (a prior job's shapecast_asset_id).

Responses

202 Accepted -- a job was created for this operation. Job
400 only one of cx and cy was given, either is outside 0-1, the body is not valid JSON, or it carries an unrecognised field.
401
403 insufficient-scope -- the key does not carry jobs:write.
404 no such asset -- unknown or not owned by this key, indistinguishable (see this operation's description).
429 quota-exhausted -- max_queued_jobs reached for this key, the same admission check every job-creating route applies.
503 worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.

Errors you might hit

Status
When
400
only one of cx and cy was given, either is outside 0-1, the body is not valid JSON, or it carries an unrecognised field.
401
403
insufficient-scope -- the key does not carry jobs:write.
404
no such asset -- unknown or not owned by this key, indistinguishable (see this operation's description).
429
quota-exhausted -- max_queued_jobs reached for this key, the same admission check every job-creating route applies.
503
worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.
Request
curl https://bevel.foo/v1/assets/{id}/fix-face \
  -H "Authorization: Bearer $BEVEL_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
Response 202 Accepted -- a job was created for this operation.
{
  "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": []
}
POST /v1/assets/{id}/fix-hand

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

id path · string required

The target asset's id.

Responses

202 Accepted -- a job was created for this operation. Job
400 cx or cy is missing or outside 0-1, size_frac is outside 0.05-0.5, the body is not valid JSON, or it carries an unrecognised field.
401
403 insufficient-scope -- the key does not carry jobs:write.
404 no such asset -- unknown or not owned by this key, indistinguishable.
429 quota-exhausted -- max_queued_jobs reached for this key.
503 worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.

Errors you might hit

Status
When
400
cx or cy is missing or outside 0-1, size_frac is outside 0.05-0.5, the body is not valid JSON, or it carries an unrecognised field.
401
403
insufficient-scope -- the key does not carry jobs:write.
404
no such asset -- unknown or not owned by this key, indistinguishable.
429
quota-exhausted -- max_queued_jobs reached for this key.
503
worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.
Request
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
}'
Response 202 Accepted -- a job was created for this operation.
{
  "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": []
}
POST /v1/assets/{id}/generate-clip

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

id path · string required

The target asset's id.

Responses

202 Accepted -- a job was created for this operation. Job
400 A field failed the checks this gateway can run without asking Shapecast -- see this operation's description.
401
403 insufficient-scope -- the key does not carry jobs:write.
404 no such asset -- unknown or not owned by this key, indistinguishable.
429 quota-exhausted -- max_queued_jobs reached for this key.
503 worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.

Errors you might hit

Status
When
400
A field failed the checks this gateway can run without asking Shapecast -- see this operation's description.
401
403
insufficient-scope -- the key does not carry jobs:write.
404
no such asset -- unknown or not owned by this key, indistinguishable.
429
quota-exhausted -- max_queued_jobs reached for this key.
503
worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.
Request
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"
}'
Response 202 Accepted -- a job was created for this operation.
{
  "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": []
}
POST /v1/assets/{id}/repaint

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

id path · string required

The target asset's id.

Responses

202 Accepted -- a job was created for this operation. Job
400 A field failed the checks this gateway can run without asking Shapecast -- see this operation's description.
401
403 insufficient-scope -- the key does not carry jobs:write.
404 no such asset -- unknown or not owned by this key, indistinguishable.
429 quota-exhausted -- max_queued_jobs reached for this key.
503 worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.

Errors you might hit

Status
When
400
A field failed the checks this gateway can run without asking Shapecast -- see this operation's description.
401
403
insufficient-scope -- the key does not carry jobs:write.
404
no such asset -- unknown or not owned by this key, indistinguishable.
429
quota-exhausted -- max_queued_jobs reached for this key.
503
worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.
Request
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"
}'
Response 202 Accepted -- a job was created for this operation.
{
  "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": []
}
GET /v1/assets/{id}/repaint-view

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

id path · string required

The target asset's id.

view query · string

Defaults to three_quarter when omitted.

Responses

202 Accepted -- a job was created for this operation. Job
400 view is not one of front, three_quarter, back.
401
403 insufficient-scope -- the key does not carry jobs:write.
404 no such asset -- unknown or not owned by this key, indistinguishable.
429 quota-exhausted -- max_queued_jobs reached for this key.
503 worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.

Errors you might hit

Status
When
400
view is not one of front, three_quarter, back.
401
403
insufficient-scope -- the key does not carry jobs:write.
404
no such asset -- unknown or not owned by this key, indistinguishable.
429
quota-exhausted -- max_queued_jobs reached for this key.
503
worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.
Request
curl https://bevel.foo/v1/assets/{id}/repaint-view \
  -H "Authorization: Bearer $BEVEL_KEY"
Response 202 Accepted -- a job was created for this operation.
{
  "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": []
}
POST /v1/assets/{id}/retarget

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

id path · string required

The target asset's id.

Responses

202 Accepted -- a job was created for this operation. Job
401
403 insufficient-scope -- the key does not carry jobs:write.
404 no such asset -- unknown or not owned by this key, indistinguishable.
422 missing/empty/oversized clip, or an unsupported extension -- see this operation's description.
429 quota-exhausted -- max_queued_jobs reached for this key.
503 worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.

Errors you might hit

Status
When
401
403
insufficient-scope -- the key does not carry jobs:write.
404
no such asset -- unknown or not owned by this key, indistinguishable.
422
missing/empty/oversized clip, or an unsupported extension -- see this operation's description.
429
quota-exhausted -- max_queued_jobs reached for this key.
503
worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.
Request
curl https://bevel.foo/v1/assets/{id}/retarget \
  -X POST \
  -H "Authorization: Bearer $BEVEL_KEY"
Response 202 Accepted -- a job was created for this operation.
{
  "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": []
}
POST /v1/assets/{id}/retopo

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

id path · string required

The target asset's id.

Responses

202 Accepted -- a job was created for this operation. Job
400 faces is not an integer in 500-200000, the body is not valid JSON, or it carries an unrecognised field.
401
403 insufficient-scope -- the key does not carry jobs:write.
404 no such asset -- unknown or not owned by this key, indistinguishable.
429 quota-exhausted -- max_queued_jobs reached for this key.
503 worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.

Errors you might hit

Status
When
400
faces is not an integer in 500-200000, the body is not valid JSON, or it carries an unrecognised field.
401
403
insufficient-scope -- the key does not carry jobs:write.
404
no such asset -- unknown or not owned by this key, indistinguishable.
429
quota-exhausted -- max_queued_jobs reached for this key.
503
worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.
Request
curl https://bevel.foo/v1/assets/{id}/retopo \
  -X POST \
  -H "Authorization: Bearer $BEVEL_KEY"
Response 202 Accepted -- a job was created for this operation.
{
  "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": []
}
POST /v1/assets/{id}/rig

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

id path · string required

The target asset's id.

Responses

202 Accepted -- a job was created for this operation. Job
401
403 insufficient-scope -- the key does not carry jobs:write.
404 no such asset -- unknown or not owned by this key, indistinguishable.
429 quota-exhausted -- max_queued_jobs reached for this key.
503 worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.

Errors you might hit

Status
When
401
403
insufficient-scope -- the key does not carry jobs:write.
404
no such asset -- unknown or not owned by this key, indistinguishable.
429
quota-exhausted -- max_queued_jobs reached for this key.
503
worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.
Request
curl https://bevel.foo/v1/assets/{id}/rig \
  -X POST \
  -H "Authorization: Bearer $BEVEL_KEY"
Response 202 Accepted -- a job was created for this operation.
{
  "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": []
}
POST /v1/assets/{id}/sharpen

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

id path · string required

The target asset's id.

Responses

202 Accepted -- a job was created for this operation. Job
400 quality is missing or not one of standard, 4k, 8k (the message lists them), the body is not valid JSON, or it carries an unrecognised field.
401
403 insufficient-scope -- the key does not carry jobs:write.
404 no such asset -- unknown or not owned by this key, indistinguishable.
429 quota-exhausted -- max_queued_jobs reached for this key.
503 worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.

Errors you might hit

Status
When
400
quality is missing or not one of standard, 4k, 8k (the message lists them), the body is not valid JSON, or it carries an unrecognised field.
401
403
insufficient-scope -- the key does not carry jobs:write.
404
no such asset -- unknown or not owned by this key, indistinguishable.
429
quota-exhausted -- max_queued_jobs reached for this key.
503
worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.
Request
curl https://bevel.foo/v1/assets/{id}/sharpen \
  -X POST \
  -H "Authorization: Bearer $BEVEL_KEY"
Response 202 Accepted -- a job was created for this operation.
{
  "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": []
}
PUT /v1/assets/{id}/tags

Overwrite an asset's tag set (full replace, not merge)

SCGW-90. Requires jobs:write. Ownership-scoped: unknown and not-yours both 404. Matches Shapecast's own TagsUpdate semantics exactly (api-reference.md 5.3): each tag is whitespace-collapsed and lower-cased, duplicates removed, at most 40 characters each, at most 24 distinct tags -- but writes the gateway's OWN bookkeeping copy of this asset's tags (see the Asset schema's own description for why this is not a live write to Shapecast's provenance.json).

Body parameters

id path · string required

Responses

200 OK -- the asset's new tag set. Asset
400 Malformed body, or `tags` omitted entirely (it is required -- send tags: [] to clear).
401
404
422 tag_too_long ("Tags are 40 characters at most.") or too_many_tags ("24 tags per asset at most.") -- the exact messages Shapecast's own API uses for the same limits (api-reference.md 5.3).

Errors you might hit

Status
When
400
Malformed body, or `tags` omitted entirely (it is required -- send tags: [] to clear).
401
404
422
tag_too_long ("Tags are 40 characters at most.") or too_many_tags ("24 tags per asset at most.") -- the exact messages Shapecast's own API uses for the same limits (api-reference.md 5.3).
Request
curl https://bevel.foo/v1/assets/{id}/tags \
  -X PUT \
  -H "Authorization: Bearer $BEVEL_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "tags": [
    "brass",
    "catalog-import"
  ]
}'
Response 200 OK -- the asset's new tag set.
{
  "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"
  }
}
POST /v1/assets/{id}/touchup-undo

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

id path · string required

The target asset's id.

Responses

202 Accepted -- a job was created for this operation. Job
401
403 insufficient-scope -- the key does not carry jobs:write.
404 no such asset -- unknown or not owned by this key, indistinguishable.
429 quota-exhausted -- max_queued_jobs reached for this key.
503 worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.

Errors you might hit

Status
When
401
403
insufficient-scope -- the key does not carry jobs:write.
404
no such asset -- unknown or not owned by this key, indistinguishable.
429
quota-exhausted -- max_queued_jobs reached for this key.
503
worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: "worker status unavailable"`.
Request
curl https://bevel.foo/v1/assets/{id}/touchup-undo \
  -X POST \
  -H "Authorization: Bearer $BEVEL_KEY"
Response 202 Accepted -- a job was created for this operation.
{
  "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

GET /v1/artifacts/{id}/content

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

id path · string required

Responses

302 Redirect to a Supabase Storage URL signed for 300 seconds (5 minutes) from mint time. See the operation description for what that URL discloses and how long it stays valid.
401
404
410 gone -- the artifact's retention window has elapsed. Artifacts are retained for 90 days after the job completes (shapecast.artifact.expires_at, set at job-completion time); once that window passes the artifact row is still readable via GET /v1/jobs/{id}/artifacts but this content route answers 410 instead of minting a download URL.

Errors you might hit

Status
When
302
Redirect to a Supabase Storage URL signed for 300 seconds (5 minutes) from mint time. See the operation description for what that URL discloses and how long it stays valid.
401
404
410
gone -- the artifact's retention window has elapsed. Artifacts are retained for 90 days after the job completes (shapecast.artifact.expires_at, set at job-completion time); once that window passes the artifact row is still readable via GET /v1/jobs/{id}/artifacts but this content route answers 410 instead of minting a download URL.
Request
curl https://bevel.foo/v1/artifacts/{id}/content \
  -H "Authorization: Bearer $BEVEL_KEY"

Styles

GET /v1/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

200 OK StyleListResponse
401

Errors you might hit

Status
When
401
Request
curl https://bevel.foo/v1/styles \
  -H "Authorization: Bearer $BEVEL_KEY"

Webhooks

GET /v1/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

200 OK
401
403 insufficient-scope -- the key does not carry webhooks:write.

Errors you might hit

Status
When
401
403
insufficient-scope -- the key does not carry webhooks:write.
Request
curl https://bevel.foo/v1/webhooks \
  -H "Authorization: Bearer $BEVEL_KEY"
POST /v1/webhooks

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

201 Created. Body includes the plaintext secret exactly once -- it cannot be read back afterward. WebhookCreated
400
401
403 insufficient-scope -- the key does not carry webhooks:write.

Errors you might hit

Status
When
400
401
403
insufficient-scope -- the key does not carry webhooks:write.
Request
curl https://bevel.foo/v1/webhooks \
  -X POST \
  -H "Authorization: Bearer $BEVEL_KEY"
Response 201 Created. Body includes the plaintext secret exactly once -- it cannot be read back afterward.
{
  "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 /v1/webhooks/{id}

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

id path · string required

Responses

204 Deleted.
401
403 insufficient-scope -- the key does not carry webhooks:write.
404

Errors you might hit

Status
When
401
403
insufficient-scope -- the key does not carry webhooks:write.
404
Request
curl https://bevel.foo/v1/webhooks/{id} \
  -X DELETE \
  -H "Authorization: Bearer $BEVEL_KEY"
GET /v1/webhooks/{id}

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

id path · string required

Responses

200 OK Webhook
401
403 insufficient-scope -- the key does not carry webhooks:write.
404

Errors you might hit

Status
When
401
403
insufficient-scope -- the key does not carry webhooks:write.
404
Request
curl https://bevel.foo/v1/webhooks/{id} \
  -H "Authorization: Bearer $BEVEL_KEY"
POST /v1/webhooks/{id}/test

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

id path · string required

Responses

200 Delivery attempted synchronously; body reports the outcome. WebhookTestResult
401
403 insufficient-scope -- the key does not carry webhooks:write.
404

Errors you might hit

Status
When
401
403
insufficient-scope -- the key does not carry webhooks:write.
404
Request
curl https://bevel.foo/v1/webhooks/{id}/test \
  -X POST \
  -H "Authorization: Bearer $BEVEL_KEY"
Response 200 Delivery attempted synchronously; body reports the outcome.
{
  "delivered": true
}

Usage

GET /v1/usage

This key's current quota usage

Responses

200 OK Usage
401

Errors you might hit

Status
When
401
Request
curl https://bevel.foo/v1/usage \
  -H "Authorization: Bearer $BEVEL_KEY"

Queue

GET /v1/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

200 OK Queue
401
403 insufficient-scope: the key lacks jobs:read.
500

Errors you might hit

Status
When
401
403
insufficient-scope: the key lacks jobs:read.
500
Request
curl https://bevel.foo/v1/queue \
  -H "Authorization: Bearer $BEVEL_KEY"
Response 200 OK
{
  "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"
}
POST /v1/queue/{id}/move-to-front

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

id path · string required

A queued job's id.

Responses

200 OK QueueMoved
401
403 insufficient-scope: the key is not an owner key, or lacks jobs:write.
404 No job with that id is queued (unknown, running or finished).
409 The job left the queue between the move and the read-back.
500

Errors you might hit

Status
When
401
403
insufficient-scope: the key is not an owner key, or lacks jobs:write.
404
No job with that id is queued (unknown, running or finished).
409
The job left the queue between the move and the read-back.
500
Request
curl https://bevel.foo/v1/queue/{id}/move-to-front \
  -X POST \
  -H "Authorization: Bearer $BEVEL_KEY"
Response 200 OK
{
  "id": "8f1c2f3a-0f6b-4a64-9b53-2f0b2f6d1c11",
  "position": 1,
  "state": "queued"
}

ForgeHub

PUT /v1/forgehub/packs/{pack}/pieces/{piece}/package

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

pack path · string required
piece path · string required

Responses

200 Pushed, or already there. ForgeHubPush
401
403 insufficient-scope -- the key does not carry forgehub:write.
413 body-too-large -- the model is over 8 MB or the request over 16 MB.
415 unsupported-media-type -- not multipart/form-data.
422 invalid-request -- a bad pack or piece name, a missing part, meta that is not JSON, a model that is not a binary glTF, or a missing or malformed built_at.
502 forgehub-rejected -- ForgeHub refused the push (a 4xx that retrying will not fix).
503 forgehub-disabled (no ForgeHub connection configured), or forgehub-unavailable (lock busy for 60 s, or ForgeHub failing) with a Retry-After header.

Errors you might hit

Status
When
401
403
insufficient-scope -- the key does not carry forgehub:write.
413
body-too-large -- the model is over 8 MB or the request over 16 MB.
415
unsupported-media-type -- not multipart/form-data.
422
invalid-request -- a bad pack or piece name, a missing part, meta that is not JSON, a model that is not a binary glTF, or a missing or malformed built_at.
502
forgehub-rejected -- ForgeHub refused the push (a 4xx that retrying will not fix).
503
forgehub-disabled (no ForgeHub connection configured), or forgehub-unavailable (lock busy for 60 s, or ForgeHub failing) with a Retry-After header.
Request
curl https://bevel.foo/v1/forgehub/packs/{pack}/pieces/{piece}/package \
  -X PUT \
  -H "Authorization: Bearer $BEVEL_KEY"
PUT /v1/forgehub/packs/{pack}/specs

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

pack path · string required
built_at query · string

Responses

200 Pushed, or already there. ForgeHubPush
401
403 insufficient-scope -- the key does not carry forgehub:write.
413 body-too-large -- over 1 MB.
415 unsupported-media-type -- not text/csv.
422 invalid-request -- a bad pack name, an empty body or a malformed built_at.
502 forgehub-rejected.
503 forgehub-disabled or forgehub-unavailable (with Retry-After).

Errors you might hit

Status
When
401
403
insufficient-scope -- the key does not carry forgehub:write.
413
body-too-large -- over 1 MB.
415
unsupported-media-type -- not text/csv.
422
invalid-request -- a bad pack name, an empty body or a malformed built_at.
502
forgehub-rejected.
503
forgehub-disabled or forgehub-unavailable (with Retry-After).
Request
curl https://bevel.foo/v1/forgehub/packs/{pack}/specs \
  -X PUT \
  -H "Authorization: Bearer $BEVEL_KEY"

Meta

GET /v1/health

Dependency health probe

Responses

200 Healthy or degraded; see body for per-dependency detail. Health
Request
curl https://bevel.foo/v1/health
GET /v1/openapi.json

This document

Responses

200 This OpenAPI 3.1 document.
Request
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.

batch.completed

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.

job.awaiting_rig_review

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).

Payload
{
  "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
      }
    ]
  }
}
job.awaiting_selection

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.

Payload
{
  "gate": "awaiting_selection",
  "job_id": "1164d478-da11-4d9a-aabc-b411e3cc658a"
}
job.canceled

A queued job was canceled before any GPU work started.

job.expired

A job left queued past queued_expires_at, or a lease expired at max_attempts.

job.failed

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).

Payload
{
  "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"
}
job.succeeded

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.

Payload
{
  "asset_id": "a3f9c21b-brass-compass",
  "degraded": false,
  "job_id": "1164d478-da11-4d9a-aabc-b411e3cc658a"
}
job.succeeded_degraded

A job finished but the verdict was degraded.

Payload
{
  "asset_id": "a3f9c21b-brass-compass",
  "degraded": true,
  "job_id": "1164d478-da11-4d9a-aabc-b411e3cc658a"
}