All endpoints are under /api/v1/ and require authentication (except /healthz, /readyz, and /metrics).

Authenticate with a Personal Access Token (Authorization: Bearer by_...) or an OIDC session cookie (browser).

curl -H "Authorization: Bearer $TOKEN" ...

Health#

GET /healthz#

Returns 200 OK with body ok. No authentication required.

GET /readyz#

Readiness probe that checks backend dependencies (database, Docker socket, and optionally IdP and OpenBao). No authentication required, but the response detail varies based on the caller.

Response: 200 OK when all checks pass, 503 Service Unavailable otherwise.

Authenticated callers (bearer token or session cookie) see per-component results:

{
  "status": "ready",
  "checks": {
    "database": "pass",
    "docker": "pass"
  }
}

Unauthenticated callers see only the aggregate status:

{
  "status": "ready"
}

When not all checks pass, status is "not_ready" and the HTTP status is 503.

When OIDC and/or OpenBao are configured, their health is included in the checks (as "idp" and "openbao" respectively). When AppRole auth is used (openbao.role_id), a "vault_token" check reports whether the token renewal goroutine is healthy.

When served on the management listener, /readyz always returns full per-component check details regardless of authentication.

GET /metrics#

Prometheus metrics endpoint. Only available when telemetry.metrics_enabled is true. Requires authentication (bearer token or session cookie) when served on the main listener. No authentication when served on the management listener.


Authentication#

These endpoints are available when OIDC is configured.

GET /login#

Redirects the user to the configured OIDC provider for authentication.

GET /callback#

OIDC callback endpoint. Completes the login flow and sets a session cookie.

POST /logout#

Clears the session cookie and logs the user out.

POST /api/v1/bootstrap#

Exchange a one-time bootstrap token for a Personal Access Token. The bootstrap token is configured on the server via server.bootstrap_token (BLOCKYARD_SERVER_BOOTSTRAP_TOKEN). After a single successful exchange the bootstrap token is permanently burned — subsequent calls return 410 Gone.

No API authentication required — the bootstrap token itself is the credential.

Request:

curl -X POST "$BLOCKYARD/api/v1/bootstrap" \
  -H "Authorization: Bearer $BOOTSTRAP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"deploy","expires_in":"1h"}'
FieldTypeRequiredDescription
namestringNoToken name (default: "bootstrap")
expires_instringNoToken TTL, e.g. "1h", "7d" (default: no expiry)

Response: 201 Created with the PAT (same schema as POST /api/v1/users/me/tokens).


Apps#

All {id} path parameters accept either the app’s UUID or its name.

POST /api/v1/apps#

Create a new app.

Request body: JSON with a name field. Names must be URL-safe slugs (lowercase letters, digits, and hyphens; must start with a letter; must not end with a hyphen; 1–63 characters).

{ "name": "my-dashboard" }

Response: 201 Created

{
  "id": "a1b2c3...",
  "name": "my-dashboard",
  "status": "stopped",
  "active_bundle": null,
  ...
}

GET /api/v1/apps#

List apps visible to the caller. Paginated with RBAC filtering.

Query parameters:

ParameterTypeDefaultDescription
searchstringSearch by app name or title
tagstringFilter by tag name
deletedboolfalseShow soft-deleted apps (admin only). When true, returns a bare JSON array instead of the paginated wrapper.
pageinteger1Page number
per_pageinteger25Items per page (clamped to 1–100)

Response: 200 OK

{
  "apps": [
    {
      "id": "a1b2c3...",
      "name": "my-dashboard",
      "owner": "jane",
      "access_type": "acl",
      "active_bundle": "b1234...",
      "title": "My Dashboard",
      "description": "A sales analytics dashboard",
      "enabled": true,
      "status": "running",
      "relation": "owner",
      "tags": ["production"],
      "created_at": "2025-01-15T09:30:00Z",
      "updated_at": "2025-01-15T09:30:00Z"
    }
  ],
  "total": 1,
  "page": 1,
  "per_page": 25
}

status is one of "running", "stopped", or "stopping" (workers are draining). relation is the caller’s relationship to the app ("owner", "collaborator", "viewer", or "admin").

GET /api/v1/apps/{id}#

Get a single app by ID or name. Returns full app metadata including the caller’s relation to the app. Workers are available via the runtime endpoint.

Response: 200 OK

{
  "id": "a1b2c3...",
  "name": "my-dashboard",
  "owner": "jane",
  "access_type": "acl",
  "active_bundle": "b1234...",
  "max_workers_per_app": null,
  "max_sessions_per_worker": 1,
  "memory_limit": null,
  "cpu_limit": null,
  "title": "My Dashboard",
  "description": "A sales analytics dashboard",
  "pre_warmed_sessions": 0,
  "enabled": true,
  "refresh_schedule": "",
  "created_at": "2025-01-15T09:30:00Z",
  "updated_at": "2025-01-15T09:30:00Z",
  "status": "running",
  "tags": ["production"],
  "relation": "owner"
}

status is "running", "stopped", or "stopping" (workers are draining). relation is the caller’s relationship to the app ("owner", "collaborator", "viewer", or "admin").

PATCH /api/v1/apps/{id}#

Update app configuration. All fields are optional — only provided fields are updated.

{
  "name": "new-slug",
  "max_workers_per_app": 4,
  "max_sessions_per_worker": 1,
  "memory_limit": "512m",
  "cpu_limit": 0.5,
  "access_type": "acl",
  "title": "My Dashboard",
  "description": "A sales analytics dashboard"
}
FieldTypeDefaultDescription
namestringRename the app (new URL-safe slug). Requires owner or admin. The old name is preserved as an alias so existing links continue to work.
max_workers_per_appintegerunlimitedMax concurrent workers (must be >= 1)
max_sessions_per_workerinteger1Sessions per worker (must be >= 1). 1 means single-tenant containers. See Credential Management for how this affects credential injection.
memory_limitstringnoneContainer memory limit (e.g. "512m", "2g")
cpu_limitfloatnoneCPU limit (e.g. 0.5 for half a core)
access_typestring"acl""acl", "logged_in", or "public" (requires owner or admin)
titlestringnoneHuman-readable title for the catalog
descriptionstringnoneDescription for the catalog
pre_warmed_sessionsinteger0Target number of free session slots to keep warm across standby workers (max 10). With max_sessions_per_worker=1 this equals the number of idle workers; with higher values, a single partially-full worker can cover multiple slots.
refresh_schedulestringnoneCron expression for automatic dependency refresh

Response: 200 OK — updated app object.

App response objects include the following additional fields beyond the example above: max_workers_per_app, max_sessions_per_worker, memory_limit, cpu_limit, pre_warmed_sessions, and refresh_schedule.

DELETE /api/v1/apps/{id}#

Delete an app. Stops all running workers.

When soft_delete_retention is configured, the app is soft-deleted — it is hidden from listings but retains its data for the configured retention period. Soft-deleted apps can be restored with POST /api/v1/apps/{id}/restore.

When soft_delete_retention is not set (or 0), the app is permanently deleted along with all bundles, sessions, and access grants.

Response: 204 No Content

DELETE /api/v1/apps/{id}?purge=true#

Admin only. Permanently delete a soft-deleted app. The app must already be soft-deleted — returns 409 Conflict otherwise.

Removes all database rows (bundles, sessions, access grants) and bundle files from disk.

Response: 204 No Content


App Lifecycle#

GET /api/v1/apps/{id}/logs#

Stream logs from a running worker. Returns chunked text/plain.

If the worker has already exited (but is within the log retention window), the buffered logs are returned as a complete response. If the worker is still running, buffered lines are sent immediately followed by live streaming.

Query parameters:

ParameterTypeDefaultDescription
worker_idstringRequired. The worker to stream logs from. Use the runtime endpoint to discover worker IDs.

POST /api/v1/apps/{id}/enable#

Enable an app, allowing it to accept traffic and cold-start workers. Requires collaborator or higher permissions.

Response: 200 OK — updated app object.

POST /api/v1/apps/{id}/disable#

Disable an app. Active sessions are ended and running workers are drained and stopped. Disabled apps return 503 Service Unavailable for all proxy requests. Requires collaborator or higher permissions.

Response: 200 OK — updated app object.

POST /api/v1/apps/{id}/rollback#

Roll back to a previous bundle. The request body specifies the target bundle ID.

Request body:

{ "bundle_id": "b1234..." }

Response: 200 OK — updated app object with the new active bundle.

POST /api/v1/apps/{id}/restore#

Restore a soft-deleted app. The app must be soft-deleted — returns 409 Conflict otherwise.

Response: 200 OK — restored app object.

POST /api/v1/apps/{id}/refresh#

Start a dependency refresh for the active bundle. Re-resolves packages from configured repositories without uploading a new bundle.

Response: 202 Accepted

{
  "task_id": "t5678...",
  "message": "refresh started"
}

POST /api/v1/apps/{id}/refresh/rollback#

Roll back a dependency refresh, restoring the previous package set.

Response: 202 Accepted

{
  "task_id": "t5678...",
  "message": "rollback started"
}

Runtime & Sessions#

GET /api/v1/apps/{id}/runtime#

Get live operational data for an app, including running workers, container resource usage, active sessions, and activity metrics. Requires collaborator or higher permissions.

Response: 200 OK

{
  "workers": [
    {
      "id": "w-a3f2...",
      "bundle_id": "01ABC...",
      "status": "active",
      "started_at": "2026-03-26T11:00:00Z",
      "idle_since": null,
      "stats": {
        "cpu_percent": 12.5,
        "memory_usage_bytes": 268435456,
        "memory_limit_bytes": 536870912
      },
      "sessions": [
        {
          "id": "s-9e1b...",
          "user_sub": "alice@company.com",
          "user_display_name": "Alice",
          "started_at": "2026-03-26T11:00:00Z"
        }
      ]
    }
  ],
  "active_sessions": 3,
  "total_views": 1247,
  "recent_views": 89,
  "unique_visitors": 42,
  "last_deployed_at": "2026-03-26T10:00:00Z"
}

workers[].status is "active", "draining", or "ended" (recently exited). stats may be null if container metrics are unavailable. Ended workers include an ended_at timestamp and remain visible for the log_retention window.

GET /api/v1/apps/{id}/sessions#

List sessions for an app. Requires collaborator or higher permissions.

Query parameters:

ParameterTypeDefaultDescription
userstringFilter by user sub
statusstringFilter by status (active, ended)
limitinteger50Max results (clamped to 1–200)

Response: 200 OK

{
  "sessions": [
    {
      "id": "s-9e1b...",
      "app_id": "a1b2c3...",
      "worker_id": "w-a3f2...",
      "user_sub": "alice@company.com",
      "started_at": "2026-03-26T11:00:00Z",
      "ended_at": null,
      "status": "active"
    }
  ]
}

Bundles#

POST /api/v1/apps/{id}/bundles#

Upload a new bundle. The app must already exist.

Request body: raw .tar.gz bytes (Content-Type: application/octet-stream).

Uploads larger than max_bundle_size (default 100 MB) are rejected with 413 Payload Too Large.

Response: 202 Accepted

{
  "bundle_id": "b1234...",
  "task_id": "t5678..."
}

The build (dependency restore) runs asynchronously. Use the task endpoint to follow progress.

GET /api/v1/apps/{id}/bundles#

List all bundles for an app.

Response: 200 OK — array of bundle objects.


Deployments#

GET /api/v1/deployments#

List bundle deployments across all apps. Results are RBAC-filtered to apps where the caller is collaborator or higher. Supports search, status filter, and pagination.

Query parameters:

ParameterTypeDefaultDescription
searchstringSearch by app name
statusstringFilter by bundle status (ready, pending, failed)
pageinteger1Page number
per_pageinteger25Items per page (clamped to 1–100)

Response: 200 OK

{
  "deployments": [
    {
      "app_id": "a1b2c3...",
      "app_name": "my-dashboard",
      "bundle_id": "b1234...",
      "deployed_by": "jane-sub",
      "deployed_by_name": "Jane",
      "deployed_at": "2026-03-26T10:00:00Z",
      "status": "ready"
    }
  ],
  "total": 1,
  "page": 1,
  "per_page": 25
}

Tasks#

GET /api/v1/tasks/{task_id}#

Get the current status of a background task.

Response: 200 OK

{
  "id": "t5678...",
  "status": "running",
  "created_at": "2024-01-15T09:30:00Z"
}

status is one of "running", "completed", or "failed".

GET /api/v1/tasks/{task_id}/logs#

Stream logs for a background task (e.g. dependency restoration).

If the task is still running, the response streams buffered output followed by live lines. If the task is complete, the full log is returned.

Response: 200 OK — chunked text/plain.


Access Control (ACL)#

Manage per-app access grants. Requires owner or admin permissions on the app.

POST /api/v1/apps/{id}/access#

Grant a user access to an app.

Request body:

{
  "principal": "user-sub-123",
  "kind": "user",
  "role": "viewer"
}
  • kind must be "user"
  • role must be "viewer" or "collaborator"
  • You cannot grant access to yourself

Response: 204 No Content

GET /api/v1/apps/{id}/access#

List all access grants for an app.

Response: 200 OK — array of grant objects.

[
  {
    "principal": "jane",
    "kind": "user",
    "role": "viewer",
    "granted_by": "admin-sub",
    "granted_at": "2025-01-15T09:30:00Z"
  }
]

DELETE /api/v1/apps/{id}/access/{kind}/{principal}#

Revoke a specific access grant.

Response: 204 No Content


Users#

Admin-only endpoints for managing user roles and status (except GET /api/v1/users/me which is available to any authenticated user). Users are created automatically on first OIDC login.

GET /api/v1/users/me#

Get the current authenticated user’s profile. Available to any authenticated user (not admin-only).

Response: 200 OK — user object.

GET /api/v1/users#

List all users.

Response: 200 OK

[
  {
    "sub": "google-oauth2|abc123",
    "email": "alice@example.com",
    "name": "Alice",
    "role": "publisher",
    "active": true,
    "last_login": "2026-03-10T14:00:00Z"
  }
]

GET /api/v1/users/{sub}#

Get a single user by OIDC sub.

Response: 200 OK — user object.

PATCH /api/v1/users/{sub}#

Update a user’s role or active status. Admin only.

{
  "role": "publisher",
  "active": true
}

Both fields are optional. An admin cannot demote or deactivate themselves.

Response: 200 OK — updated user object.


Personal Access Tokens#

Manage personal access tokens for API access. See the Authorization guide for usage details.

POST /api/v1/users/me/tokens#

Create a new PAT. Must be authenticated via OIDC session cookie — you cannot use a PAT to create another PAT.

Request body:

{ "name": "deploy-ci", "expires_in": "90d" }

Response: 201 Created

{
  "id": "tok-abc123",
  "name": "deploy-ci",
  "token": "by_7kJx9mQ2vR...",
  "created_at": "2026-03-14T10:00:00Z",
  "expires_at": "2026-06-12T10:00:00Z"
}

The plaintext token is returned only once. Save it immediately.

GET /api/v1/users/me/tokens#

List your PATs (without the plaintext token values).

Response: 200 OK — array of token objects.

DELETE /api/v1/users/me/tokens/{tokenID}#

Revoke a single PAT.

Response: 204 No Content

DELETE /api/v1/users/me/tokens#

Revoke all your PATs.

Response: 204 No Content


Tags#

GET /api/v1/tags#

List all tags.

Response: 200 OK — array of tag objects.

POST /api/v1/tags#

Create a new tag. Admin only. Tag names follow the same rules as app names (lowercase slugs, 1–63 characters).

Request body:

{ "name": "production" }

Response: 201 Created

PATCH /api/v1/tags/{tagID}#

Rename a tag. Requires admin or publisher role.

Request body:

{ "name": "new-tag-name" }

Response: 200 OK — updated tag object.

DELETE /api/v1/tags/{tagID}#

Delete a tag. Admin only. Cascades to all app–tag associations.

Response: 204 No Content

GET /api/v1/apps/{id}/tags#

List tags attached to an app.

Response: 200 OK — array of tag objects.

POST /api/v1/apps/{id}/tags#

Attach a tag to an app. Requires deploy permissions (owner, collaborator, or admin).

Request body:

{ "tag_id": "tag-uuid" }

Response: 204 No Content

DELETE /api/v1/apps/{id}/tags/{tagID}#

Remove a tag from an app. Requires deploy permissions.

Response: 204 No Content


Catalog#

GET /api/v1/catalog#

Deprecated — use GET /api/v1/apps with search and tag query parameters instead.

Paginated, RBAC-filtered listing of apps with metadata and tags.

Query parameters:

ParameterTypeDefaultDescription
tagstringFilter by tag name
searchstringSearch by app name, title, or description
pageinteger1Page number
per_pageinteger20Items per page (max 100)

Response: 200 OK

{
  "items": [
    {
      "id": "a1b2c3...",
      "name": "my-dashboard",
      "title": "My Dashboard",
      "description": "A Shiny dashboard",
      "owner": "jane",
      "tags": ["production"],
      "status": "running",
      "url": "/app/my-dashboard/",
      "updated_at": "2025-01-15T09:30:00Z"
    }
  ],
  "total": 1,
  "page": 1,
  "per_page": 20
}

Credentials#

POST /api/v1/credentials/vault#

Exchange a session reference token for a scoped OpenBao token. This endpoint uses session token authentication (not the API bearer token). Only available when OpenBao is configured.

Response: 200 OK

{
  "token": "hvs.CAESIxyz...",
  "ttl": 3600
}

POST /api/v1/users/me/credentials/{service}#

Store a user credential in OpenBao’s KV store. Authenticated via session cookie or JWT bearer token. Only available when OpenBao is configured.

Request body:

{ "api_key": "sk-..." }

Response: 204 No Content


Proxy (Data Plane)#

When OIDC is configured, proxy routes enforce authentication — users must be logged in to access apps. Without OIDC, proxy routes are unauthenticated. Session affinity is managed via cookies.

GET /app/{name}/#

Reverse-proxy to the Shiny app. On the first request, Blockyard spawns a worker container (cold start), waits for it to become healthy, and forwards the request. A blockyard_session cookie is set to pin subsequent requests to the same worker.

If the app is disabled, all proxy requests return 503 Service Unavailable.

WebSocket upgrade requests are also supported at any path under /app/{name}/.

GET /app/{name}/{path}#

Same as above, for any sub-path within the app.


Rate limiting#

All endpoints are rate-limited per client IP. When a limit is exceeded the server returns 429 Too Many Requests.

Route groupLimit
Authentication (/login, /callback, /logout)10 req/min
Credential exchange (/api/v1/credentials/vault)20 req/min
User profile (/api/v1/users/me)20 req/min
General API (/api/v1/*)120 req/min
Proxy (/app/*)200 req/min

Errors#

All error responses use a consistent JSON shape:

{
  "error": "not_found",
  "message": "app a3f2c1... not found"
}
StatusMeaning
400Bad request (e.g. empty bundle body, invalid app name)
401Missing or invalid bearer token
403Insufficient permissions for the requested action
404Resource not found
409Conflict (e.g. duplicate app name)
413Bundle exceeds max_bundle_size
429Rate limit exceeded
500Internal server error
502Upstream service error (e.g. OpenBao login failure)
503Service unavailable (e.g. max workers reached, worker start timeout, app disabled)