Skip to content

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.

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>.
Terminal window
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $SERVING_API_TOKEN" \
"$SERVING_ROUTER_URL/models/fmtts/first-light" | jq

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

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.

{
"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.

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.

Fetch the current scratch contract:

Terminal window
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $SERVING_API_TOKEN" \
"$SERVING_ROUTER_URL/scratch-sessions/<session-uuid>" | jq

The 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>/invoke

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

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:

Terminal window
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" | jq

Omit 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_changed
  • release.minted, release.deployed, release.state_changed
  • alias.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"}
}

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.

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.

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.