HTTP API
The serving router is the instance’s only public HTTP surface. Release and scratch apps expose no public URLs.
Set the base URL and send the shared bearer token on every request:
Authorization: Bearer <token>Missing, malformed, and incorrect tokens return 401 unauthorized. The initial system has no per-user, per-model, or per-session authorization: every valid token can reach every endpoint. FastAPI’s /docs, /redoc, and /openapi.json are disabled because they would bypass that boundary.
Endpoints
Section titled “Endpoints”| Method and path | Purpose |
|---|---|
GET /models |
List models. Query with model_type=<type> and set state_filter to active, deprecated, disabled, or all; default state is active. Served from each router container’s in-memory index, refreshed from the event feed — expect a new publication to appear within about 30 seconds. |
GET /models/{model_id_or_alias} |
Resolve the target and return the manifest (whose serving.spec is the contract), lifecycle state, and readiness. Slashes are supported in the path. Always exact — keyed reads never wait on the listing index. |
POST /models/{model_id_or_alias}/invoke |
Validate and invoke a released model. |
GET /aliases/{name} |
Resolve an alias and return the complete verified chain. |
GET /scratch-sessions?active=true |
List active sessions. Pass active=false to include closed sessions. |
GET /scratch-sessions/{uuid} |
Return the mutable session record, current spec, and revision ID. |
POST /scratch-sessions/{uuid}/invoke |
Renew the lease, validate, and invoke the current verified scratch revision. |
POST /webhooks |
Create a webhook subscription. |
GET /webhooks |
List subscriptions, including disabled history. |
DELETE /webhooks/{uuid} |
Disable a subscription. Optional query parameter: why=<reason>. |
Discover a model contract
Section titled “Discover a model contract”curl --fail-with-body --silent --show-error \ -H "Authorization: Bearer $SERVING_API_TOKEN" \ "$SERVING_ROUTER_URL/models/fmtts/first-light" | jqA successful detail response has this shape:
{ "model_id": "runs/<scope>/<run>/checkpoints/step-000200000.inference-fmtts-r2.json", "manifest": { "model_type": "fmtts", "serving": { "runtime_release": {"name": "fmtts-r2", "source_commit": "<sha>"}, "spec": {"spec_dsl": 2, "interaction": "request_response", "params": [], "results": {}} } }, "state": "active", "servable": true, "resolution_chain": [ {"name": "fmtts/first-light", "level": 0, "target": "<model-id>"} ]}resolution_chain is present when the request used an alias. An unready model returns HTTP 200 from the detail endpoint with servable: false and not_ready: "deployment" or "engine_set(H100)". Invocation returns a structured error instead.
Invoke a released model
Section titled “Invoke a released model”The outer request envelope is platform-owned:
{ "request": { "<model parameter>": "<value>" }, "output_targets": [ {"output_id": "caller-id", "put_url": "https://<presigned-tigris-put>"} ]}request contains only parameters declared by manifest.serving.spec. output_targets is optional and selects upload delivery. Target identity, aliases, upload URLs, and request/result IDs are system fields and can never collide with model parameter names.
The router applies the contract’s defaults and enum-option overrides, validates types and cross-field constraints, and dispatches the resulting effective request. The runtime still validates properties that require decoding, such as audio duration.
An FMTTS inline request might be:
{ "request": { "texts": ["Hello there."], "ref_audio": {"inline_b64": "<base64>"}, "seed": 1234, "solver_method": "heun", "solver_steps": 4 }}The actual model spec decides whether style_audio is required and which optional fields, ranges, and constraints apply. Do not infer a contract from another FMTTS model.
Released response
Section titled “Released response”{ "ok": true, "error": null, "results": [ { "audio_b64": "<base64 WAV>", "sample_rate": 22050, "duration_seconds": 1.2, "resolved": {"seed": 1234, "implied_length_factor": 4.08} } ], "identity": { "model_id": "<resolved-model-id>", "release": "fmtts-r2", "source_commit": "<40-hex-sha>", "attestation": { "kind": "release", "release": "fmtts-r2", "source_commit": "<40-hex-sha>", "descriptor_sha256": "<sha256>", "manifest_sha256": "<sha256>", "engine_set_sha256": null, "gpu": "<optional observed device>" }, "request_id": "<uuid>", "result_ids": ["<uuid>"], "timing_ms": 3037, "requested_alias": "fmtts/first-light", "resolution_chain": [ {"name": "fmtts/first-light", "level": 0, "target": "<model-id>"} ] }}requested_alias and resolution_chain appear only for alias requests. identity.result_ids aligns by index with results; result objects stay exactly the model’s declared contract and do not receive injected IDs.
Any binary result field declared as <name> becomes <name>_b64 in HTTP JSON. This is a mechanical wire rule; for example, audio becomes audio_b64.
Upload delivery
Section titled “Upload delivery”If the spec includes "upload" in results.delivery, pass one output target per requested list item:
{ "request": { "texts": ["One.", "Two."], "ref_audio": {"inline_b64": "<base64>"} }, "output_targets": [ {"output_id": "one", "put_url": "https://<tigris-presigned-put-1>"}, {"output_id": "two", "put_url": "https://<tigris-presigned-put-2>"} ]}The router rejects invalid targets before GPU work. The runtime validates again, PUTs WAVs directly, and returns receipts in request order:
{ "results": [ { "output_id": "one", "sample_rate": 22050, "duration_seconds": 1.0, "resolved": {"seed": 17, "implied_length_factor": 4.0} } ]}Targets must be unique, non-empty IDs and HTTPS presigned URLs on Tigris-owned .storage.dev or .storage.tigris.dev hosts, with port 443, no embedded credentials, and no fragment. Redirects are not followed. Presigned URLs are bearer capabilities and are never echoed in errors.
If a batch upload fails after another target was already written, the response reports no successful results. The caller owns cleanup of partially written destinations.
Scratch sessions
Section titled “Scratch sessions”Fetch the current scratch contract:
curl --fail-with-body --silent --show-error \ -H "Authorization: Bearer $SERVING_API_TOKEN" \ "$SERVING_ROUTER_URL/scratch-sessions/<session-uuid>" | jqThe response contains session, spec, and the current revision. Invoke with the same request and upload envelopes as a released model:
POST /scratch-sessions/<session-uuid>/invokeThe router atomically renews the lease before dispatch. A successful identity block is disjoint from release identity:
{ "provenance": {"kind": "scratch"}, "session_id": "<uuid>", "based_on": {"requested_selection": "<run>/last", "resolved_source": {}}, "attestation": { "kind": "scratch", "session_id": "<uuid>", "revision_id": "<uuid>", "generation": 3, "revision_sha256": "<sha256>", "source_sha256": "<sha256>", "dependency_recipe_sha256": "<sha256>", "spec_sha256": "<sha256>", "draft_manifest_sha256": "<sha256>", "engine_set_sha256": null, "app_version": "<modal-version>" }, "request_id": "<uuid>", "result_ids": ["<uuid>"], "timing_ms": 2500}based_on is descriptive. A scratch result never claims the source model’s ID, release, or commit.
Webhooks and events
Section titled “Webhooks and events”Every durable model, release, and alias change appends a small event to the store’s feed (retained ~90 days). The router delivers that feed to webhook subscriptions in order, at-least-once: each subscription owns a durable cursor that only advances past events the consumer accepted with a 2xx, so a failing endpoint is retried from the same position every ~30 seconds and loses nothing while it is down. A lost event append (the object write and event append are not transactional) is repaired by the router’s hourly rescan, which re-emits the missing event at the head of the feed.
Register an HTTPS endpoint for every event or a selected set:
curl --fail-with-body --silent --show-error \ -X POST \ -H "Authorization: Bearer $SERVING_API_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"url":"https://example.internal/serving-hook","events":["model.published","alias.moved"]}' \ "$SERVING_ROUTER_URL/webhooks" | jqOmit events to subscribe to "*". Pass "backfill": true to start from the beginning of the retained feed instead of the current head — a replay of everything, in order, which doubles as a consumer’s initial sync. Implemented event types are:
model.published,model.unpublished,model.state_changedrelease.minted,release.deployed,release.state_changedalias.moved
Webhook POST bodies have this shape:
{ "schema": 1, "type": "model.published", "subject": "<model-id>", "at": "<RFC3339 time>", "object_sha256": "<sha256>", "operation_id": "<stable-id>", "payload": {"release": "fmtts-r7"}}Verifying posts
Section titled “Verifying posts”Every post carries two headers:
X-Serving-Timestamp: <unix seconds>X-Serving-Signature: ed25519=<hex>The signature is Ed25519 over {timestamp}.{raw request body}. Verification needs only the router’s public key, served (inside the bearer boundary) at:
GET /webhooks/signing-key→ {"algorithm": "ed25519", "public_key": "<hex>", ...}Asymmetric on purpose: onboarding a consumer never hands over a signing secret, one key pair serves every consumer, and nothing a consumer stores can forge posts. Consumers must verify over the exact bytes received, reject timestamps outside a tolerance window (±300 seconds recommended), and apply nothing unsigned — the payload changes what a consumer believes about the registry, so accepting it unauthenticated would let anyone claim the registry changed. Registration itself refuses (503 webhooks_unconfigured) on a router with no signing key.
Consumer obligations
Section titled “Consumer obligations”Delivery is at-least-once, never exactly-once: a router crash between post and cursor advance, or two router containers delivering concurrently, re-posts an event. Deduplicate on operation_id — it is deterministic for the same state change, and it also absorbs signed-post replays inside the timestamp window. Because a healed event is re-emitted at the feed head, its at is the heal time, not the original change time; the subject object carries its own authoritative timestamps. Keep a slow reconciliation poll (hourly is plenty) against GET /models?state_filter=all as the backstop; the pushed payload is a notification, and the store objects remain the authority.
GET /webhooks shows each subscription’s cursor_at and last_delivered_at — a cursor far behind the newest event is a stuck consumer. DELETE /webhooks/{uuid} disables rather than deletes a subscription, retaining its URL and history.
Error envelope
Section titled “Error envelope”Router errors always have code and message; clients must branch on code, never parse the message.
| HTTP | Code | Meaning and useful fields |
|---|---|---|
| 401 | unauthorized |
Missing or invalid bearer token. |
| 404 | model_not_found |
No model at the requested ID, or target is neither a model ID nor valid alias. With reason: "unpublished": the key names a stored training manifest that was never published. |
| 410 | model_removed |
Write-once unpublication marker exists; removed_at. |
| 404 | alias_unresolved |
Missing/corrupt target, removed model, level mismatch, or over-deep chain; chain. |
| 404 | scratch_not_found |
No session with the canonical UUID. |
| 410 | scratch_expired |
Sliding lease elapsed — seen only in the ~30 s window before the reaper closes the session; afterwards the same session answers scratch_closed with reason: "expired". Branch on both. expired_at. |
| 410 | scratch_closed |
Stopped, promoted, or expired-and-reaped; reason says which, optional promoted_to. |
| 503 | scratch_not_ready |
No verified current revision or artifact; missing. |
| 403 | model_disabled |
Model withdrawn intentionally; optional successor_id. |
| 403 | release_disabled |
Bound release withdrawn intentionally; optional successor. |
| 503 | not_ready |
Deployment, engine set, or authoritative state unavailable; missing. |
| 422 | invalid_params |
Contract or transport-envelope violation; param, rule. |
| 429 | over_capacity |
Retryable backpressure; optional body field retry_after. |
| 502 | attestation_mismatch |
Runtime identity disagrees with router expectation; structured mismatches when available. |
| 502 | blob_missing |
Runtime returned a typed store-integrity failure. |
| 502 | upload_failed |
Direct upload failed after synthesis. |
| 502 | upstream_failure |
Runtime app failed before returning a typed envelope. |
| 404 | webhook_not_found |
No active subscription with that UUID. |
No refusal falls back to another model, release, implementation, GPU, or scratch revision.