staxaAPI
Help centerTemplatesPricing
Get an API key
Contents ▾▴

Basics

Getting StartedAuthenticationResponse FormatError HandlingRate LimitsWebhooks (Coming Soon)

API Keys

List API KeysGETCreate API KeyPOSTDelete API KeyDELETE

Tenants

List TenantsGETCreate TenantPOSTGet TenantGETUpdate TenantPATCHDelete TenantDELETE

Services

List ServicesGETAdd ServicePOSTRemove ServiceDELETEDeploy ServicePOSTList Service DeploymentsGETService LogsGETDeploy All ServicesPOSTRollback Service DeploymentPOST

Deployments

List DeploymentsGETCreate DeploymentPOSTGet DeploymentGETRollback DeploymentPOST

Environment Variables

Get Env VarsGETBulk Set Env VarsPUT

Domains

List DomainsGETAdd DomainPOSTVerify DomainPOSTDelete DomainDELETE

Network Rules

List Network RulesGETCreate Network RulePOSTGet Network RuleGETDelete Network RuleDELETE

Real-Time

Events (SSE)GETContainer LogsGET

Templates

List TemplatesGETGet TemplateGETList Runtime VersionsGET

GitHub App

Get GitHub InstallationGETRegister GitHub InstallationPOSTDelete GitHub InstallationDELETEList GitHub ReposGETList GitHub BranchesGETAnalyze RepositoryGET

Registry Credentials

List RegistriesGETCreate Registry CredentialPOSTGet Registry CredentialGETUpdate Registry CredentialPATCHDelete Registry CredentialDELETEVerify Registry CredentialPOSTList Registry ReposGETList Registry TagsGET

Basics

Getting StartedAuthenticationResponse FormatError HandlingRate LimitsWebhooks (Coming Soon)

API Keys

List API KeysGETCreate API KeyPOSTDelete API KeyDELETE

Tenants

List TenantsGETCreate TenantPOSTGet TenantGETUpdate TenantPATCHDelete TenantDELETE

Services

List ServicesGETAdd ServicePOSTRemove ServiceDELETEDeploy ServicePOSTList Service DeploymentsGETService LogsGETDeploy All ServicesPOSTRollback Service DeploymentPOST

Deployments

List DeploymentsGETCreate DeploymentPOSTGet DeploymentGETRollback DeploymentPOST

Environment Variables

Get Env VarsGETBulk Set Env VarsPUT

Domains

List DomainsGETAdd DomainPOSTVerify DomainPOSTDelete DomainDELETE

Network Rules

List Network RulesGETCreate Network RulePOSTGet Network RuleGETDelete Network RuleDELETE

Real-Time

Events (SSE)GETContainer LogsGET

Templates

List TemplatesGETGet TemplateGETList Runtime VersionsGET

GitHub App

Get GitHub InstallationGETRegister GitHub InstallationPOSTDelete GitHub InstallationDELETEList GitHub ReposGETList GitHub BranchesGETAnalyze RepositoryGET

Registry Credentials

List RegistriesGETCreate Registry CredentialPOSTGet Registry CredentialGETUpdate Registry CredentialPATCHDelete Registry CredentialDELETEVerify Registry CredentialPOSTList Registry ReposGETList Registry TagsGET

Reference

API reference

Everything the dashboard does is an HTTP call you can make yourself. Authenticate with an API key as a bearer token.

Base URLhttps://api.staxa.dev/api/v1

Getting Started

The Staxa API creates and manages isolated tenant environments. Everything you need to deploy and run apps on Staxa is here. Account and billing setup lives in the dashboard.

Base URL: https://api.staxa.dev/api/v1

Create a tenant:

curl -X POST https://api.staxa.dev/api/v1/tenants \
  -H "Authorization: Bearer sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "acme-corp",
    "source_type": "github",
    "repo_url": "https://github.com/acme/webapp",
    "branch": "main"
  }'

That single call clones the repo, detects the framework, builds it, and serves the result at https://acme-corp.tenants.staxa.dev. Creation returns 202 straight away and the first deploy starts on its own; subscribe to GET /api/v1/tenants/{id}/events over SSE to watch it progress.

Authentication

The Staxa API accepts two authentication modes.

API Key (Programmatic)

For server-to-server integrations and automation. Create API keys from the dashboard at Settings → API Keys, or via POST /api/v1/api-keys.

curl https://api.staxa.dev/api/v1/tenants \
  -H "Authorization: Bearer sk_live_a1b2c3d4e5f6..."

A key is sk_live_ or sk_test_ followed by 32 hex characters. Both modes carry the same access. The mode is a label, so you can tell a production key from a development one at a glance. Only a bcrypt hash and the first 16 characters are stored, so the raw key exists nowhere but the create response.

Clerk JWT (Dashboard)

The dashboard uses this automatically. For anything you write yourself, use an API key.

curl https://api.staxa.dev/api/v1/tenants \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."

Response Format

Every response takes one of three shapes.

Single resource:

{
  "data": { ... },
  "meta": { "request_id": "..." }
}

List (paginated):

{
  "data": [ ... ],
  "pagination": { "total": 42, "limit": 20, "offset": 0 },
  "meta": { "request_id": "..." }
}

Error:

{
  "error": {
    "code": "...",
    "message": "...",
    "details": { ... }
  }
}

Paginated endpoints accept limit (1–100, default 20) and offset (default 0) as query parameters. Not every list is paginated: shorter collections such as services, domains, env vars, and network rules come back as a plain array under data, with no pagination block.

Error Handling

The API uses standard HTTP status codes:

CodeMeaning
200Success (GET, PATCH, PUT)
201Created (synchronous POST)
202Accepted (async operations: tenant creation, deploys, deletes)
204No Content (DELETE)
400Bad Request (validation)
401Unauthorized
403Forbidden
404Not Found
409Conflict (name already taken, port pool exhausted)
422Unprocessable Entity (tenant limit reached, tenant busy)
429Rate Limited
500Internal Server Error
501Not Implemented (feature not configured on this deployment)

The error.code field carries a machine-readable string to branch on: BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, CONFLICT, UNPROCESSABLE_ENTITY, TOO_MANY_REQUESTS, INTERNAL_SERVER_ERROR, NOT_IMPLEMENTED, SERVICE_UNAVAILABLE.

Network rules and registry credentials add codes of their own: INVALID_CIDR, CIDR_TOO_WIDE, TOO_MANY_CIDRS, RULE_LIMIT_REACHED, EGRESS_MODE_REQUIRED, PORT_POOL_EXHAUSTED, REGISTRY_VERIFICATION_FAILED, REGISTRY_LIMIT_REACHED.

Rate Limits

Authenticated requests share a single limit: 100 requests per minute per provider, counted across every endpoint. The window is fixed and resets at the top of each minute.

The template catalog is public and needs no authentication, so it is limited by IP instead: 60 requests per minute.

Every response carries the rate limit headers:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1708790460

Going over returns 429 with code TOO_MANY_REQUESTS. If the rate limiter itself is unreachable, requests are allowed through rather than rejected.

Webhooks (Coming Soon)

Webhooks will post notifications to a URL you control when events occur in your tenant environments.

Planned event format:

{
  "event": "tenant.ready",
  "tenant_id": "ten_a1b2c3d4e5f6",
  "data": {
    "status": "ready",
    "url": "https://acme.tenants.staxa.dev"
  },
  "timestamp": "2025-02-24T14:31:00Z"
}

Planned events: tenant.ready, tenant.failed, tenant.deleted, deployment.started, deployment.succeeded, deployment.failed.

Not available yet. Get in touch if you need webhook integration before then.

API Keys

Create and manage API keys for programmatic access.

API Keys

List API Keys

GET/api/v1/api-keys

List all API keys for the authenticated provider. Only the key prefix comes back, never the full key.

`prefix` is the first 16 characters of the key: enough to identify it in a list, not enough to use it. Revoked keys stay in the list with `status: "revoked"`.

Response
{
  "data": [
    {
      "id": "key_a1b2c3d4",
      "provider_id": "prov_a1b2c3d4",
      "name": "Production",
      "prefix": "sk_live_a1b2c3d4",
      "scopes": ["*"],
      "mode": "live",
      "last_used": "2026-01-16T08:12:00Z",
      "expires_at": null,
      "status": "active",
      "created_at": "2026-01-15T10:30:00Z"
    }
  ]
}

API Keys

Create API Key

POST/api/v1/api-keys

Create a new API key. The response contains the raw key, and it is the only time you will see it.

Parameters

namestringrequired
A name for the key, for your own reference
scopesstring[]default ["*"]
Permission scopes
modestringdefault live
Key mode: "live" or "test"
expires_atstring
ISO 8601 expiration date

Errors

  • 400 Missing required field "name"

Only `name` is required. `mode` defaults to `live`, `scopes` defaults to `["*"]`, and `expires_at` must be RFC 3339 if you send it. The `key` field holds the raw token. Nothing else in the API returns it again, so copy it before you move on.

Request · curl
curl -X POST https://api.staxa.dev/api/v1/api-keys \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"name": "Production", "mode": "live"}'
Request body
{
  "name": "Production",
  "scopes": ["*"],
  "mode": "live",
  "expires_at": "2027-01-01T00:00:00Z"
}
Response
{
  "data": {
    "id": "key_a1b2c3d4",
    "name": "Production",
    "prefix": "sk_live_a1b2c3d4",
    "key": "sk_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
    "mode": "live",
    "note": "save this key — it will not be shown again",
    "scopes": ["*"],
    "expires_at": "2027-01-01T00:00:00Z",
    "created_at": "2026-01-15T10:30:00Z"
  }
}

API Keys

Delete API Key

DELETE/api/v1/api-keys/{id}

Revoke an API key. Takes effect immediately.

Errors

  • 404 API key not found

The key is marked revoked rather than erased, so it keeps showing up in `GET /api-keys` with `status: "revoked"`. Deleting a key that belongs to another provider returns 404.

Response
No response body (204 No Content).

Tenants

Create, list, update, and delete tenant environments.

Tenants

List Tenants

GET/api/v1/tenants

List all tenants for the authenticated provider. Paginated, with an optional status filter.

Parameters

statusstring
Filter by status (e.g., ready, deploying, failed)
limitintdefault 20
Results per page
offsetintdefault 0
Pagination offset
sortstring
Sort field (e.g., created_at, name)
searchstring
Search by tenant name

Takes `limit` (1–100, default 20), `offset`, and `status` as query parameters. The live URL is `deployment_url`, and it stays empty until the first deploy routes successfully. Deleted tenants are excluded.

Response
{
  "data": [
    {
      "id": "ten_a1b2c3d4",
      "provider_id": "prov_a1b2c3d4",
      "name": "acme-corp",
      "display_name": "Acme Corp",
      "status": "ready",
      "source_type": "github",
      "repo_url": "https://github.com/acme/webapp",
      "branch": "main",
      "app_port": 3000,
      "resource_size": "small",
      "egress_mode": "allow_all",
      "deployment_url": "https://acme-corp.tenants.staxa.dev",
      "created_at": "2026-01-15T10:30:00Z",
      "updated_at": "2026-01-15T10:35:00Z"
    }
  ],
  "pagination": { "total": 1, "limit": 20, "offset": 0 }
}

Tenants

Create Tenant

POST/api/v1/tenants

Create a new tenant environment. Supports a single-service (legacy) or multi-service request body.

Parameters

namestringrequired
Unique tenant name (used as subdomain)
display_namestring
Display name for the tenant
source_typestring
Source type: "github" or "image" (single-service mode)
repo_urlstring
Git repository URL (single-service mode)
branchstringdefault main
Git branch
servicesobject[]
Array of service definitions (multi-service mode)
envobject
Environment variables as key-value pairs

Errors

  • 400 Validation error (missing name, invalid source_type, duplicate service names)
  • 409 Tenant name already taken
  • 422 Tenant limit reached for this provider

Creation is asynchronous, so the call returns 202 with the tenant at `status: "pending"`. Supply either `source_type` or `services[]`. A legacy body is wrapped into one primary service for you. The first deploy is queued automatically. `auto_deploy_started` tells you whether that worked; if it did not, `auto_deploy_error` explains why and the tenant is marked failed rather than left pending. Pass `"deploy_on_create": false` to stage the configuration without deploying. Watch progress at `GET /api/v1/tenants/{id}/events`. A duplicate name returns 409, and exceeding your plan's tenant limit returns 422.

Request · curl
curl -X POST https://api.staxa.dev/api/v1/tenants \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "acme-corp",
    "source_type": "github",
    "repo_url": "https://github.com/acme/webapp",
    "branch": "main"
  }'
Request body
**Single-service (legacy):**
```json
{
  "name": "acme-corp",
  "display_name": "Acme Corp",
  "source_type": "github",
  "repo_url": "https://github.com/acme/webapp",
  "branch": "main",
  "dockerfile_path": "Dockerfile",
  "build_context": ".",
  "framework": "nextjs",
  "app_port": 3000,
  "resource_size": "small",
  "health_check_type": "http",
  "health_check_path": "/",
  "env": { "NODE_ENV": "production" }
}
```

**Multi-service:**
```json
{
  "name": "acme-platform",
  "display_name": "Acme Platform",
  "services": [
    {
      "name": "web",
      "source_type": "github",
      "repo_url": "https://github.com/acme/frontend",
      "branch": "main",
      "port": 3000,
      "is_primary": true,
      "is_routable": true
    },
    {
      "name": "api",
      "source_type": "github",
      "repo_url": "https://github.com/acme/backend",
      "branch": "main",
      "port": 8080,
      "is_routable": true,
      "deploy_priority": 1
    }
  ],
  "env": { "NODE_ENV": "production" },
  "deploy_on_create": true
}
```
Response
{
  "data": {
    "id": "ten_a1b2c3d4",
    "provider_id": "prov_a1b2c3d4",
    "name": "acme-corp",
    "status": "pending",
    "source_type": "github",
    "repo_url": "https://github.com/acme/webapp",
    "branch": "main",
    "app_port": 3000,
    "resource_size": "small",
    "services": [
      {
        "id": "svc_9f8e7d6c",
        "tenant_id": "ten_a1b2c3d4",
        "name": "web",
        "source_type": "github",
        "port": 3000,
        "is_primary": true,
        "is_routable": true,
        "status": "pending"
      }
    ],
    "auto_deploy_started": true,
    "created_at": "2026-01-15T10:30:00Z",
    "updated_at": "2026-01-15T10:30:00Z"
  }
}

Tenants

Get Tenant

GET/api/v1/tenants/{id}

Get full details for a tenant, including its services.

Errors

  • 404 Tenant not found

Domains are not included here. Fetch them from `GET /tenants/{id}/domains`. When a deploy fails, `last_error` and `error_stage` carry the reason; `diagnostic_message` may appear mid-deploy with a non-fatal hint, such as a readiness probe that is not passing yet.

Response
{
  "data": {
    "id": "ten_a1b2c3d4",
    "provider_id": "prov_a1b2c3d4",
    "name": "acme-corp",
    "display_name": "Acme Corp",
    "status": "ready",
    "current_stage": "ready",
    "source_type": "github",
    "repo_url": "https://github.com/acme/webapp",
    "branch": "main",
    "app_port": 3000,
    "resource_size": "small",
    "cpu_request": "25m",
    "cpu_limit": "500m",
    "memory_request": "64Mi",
    "memory_limit": "512Mi",
    "storage_limit": "5Gi",
    "current_image": "registry.staxa.dev/acme-corp:v3",
    "deployment_url": "https://acme-corp.tenants.staxa.dev",
    "egress_mode": "allow_all",
    "health_check_type": "http",
    "health_check_path": "/",
    "k8s_namespace": "ten-acme-corp",
    "services": [],
    "metadata": {},
    "created_at": "2026-01-15T10:30:00Z",
    "updated_at": "2026-01-15T10:35:00Z"
  }
}

Tenants

Update Tenant

PATCH/api/v1/tenants/{id}

Partial update of a tenant. Only provided fields are changed.

Parameters

display_namestring
Display name for the tenant
branchstring
Default git branch
resource_sizestring
Resource tier: small, medium, large
app_portint
Application port
egress_modestring
Network egress mode: open or restricted

Errors

  • 404 Tenant not found

`resource_size` must be `small`, `medium`, or `large`; `egress_mode` must be `allow_all` or `restricted`; `app_port` must be between 1 and 65535. Changes to build or runtime settings take effect on the next deploy. Trigger one with `POST /tenants/{id}/deployments`.

Request body
{
  "display_name": "Acme Corp Updated",
  "repo_url": "https://github.com/acme/webapp",
  "branch": "release",
  "dockerfile_path": "docker/Dockerfile",
  "build_context": ".",
  "resource_size": "medium",
  "app_port": 3000,
  "egress_mode": "restricted",
  "health_check_type": "http",
  "health_check_path": "/healthz",
  "health_check_port": 3000
}
Response
{
  "data": {
    "id": "ten_a1b2c3d4",
    "name": "acme-corp",
    "display_name": "Acme Corp Updated",
    "resource_size": "medium",
    "egress_mode": "restricted",
    "status": "ready",
    "updated_at": "2026-01-15T12:00:00Z"
  }
}

Tenants

Delete Tenant

DELETE/api/v1/tenants/{id}

Delete a tenant and every resource it owns.

Errors

  • 404 Tenant not found

Returns 202. The tenant is soft-deleted right away (the name is freed and it drops out of `GET /tenants`) while containers, volumes, and routes are torn down in the background.

Response
{
  "data": {
    "status": "accepted",
    "message": "tenant deletion queued"
  }
}

Services

Manage individual services inside a multi-service tenant.

Services

List Services

GET/api/v1/tenants/{id}/services

List all services for a tenant.

A plain array, not paginated. Services deploy in `deploy_priority` order, lowest first.

Response
{
  "data": [
    {
      "id": "svc_9f8e7d6c",
      "tenant_id": "ten_a1b2c3d4",
      "name": "web",
      "display_name": "Web",
      "source_type": "github",
      "repo_url": "https://github.com/acme/webapp",
      "branch": "main",
      "dockerfile_path": "Dockerfile",
      "build_context": ".",
      "framework": "nextjs",
      "port": 3000,
      "is_routable": true,
      "is_primary": true,
      "status": "running",
      "image_tag": "v3",
      "deploy_priority": 0,
      "resource_size": "small",
      "health_check_type": "http",
      "health_check_path": "/",
      "created_at": "2026-01-15T10:30:00Z",
      "updated_at": "2026-01-15T10:35:00Z"
    }
  ]
}

Services

Add Service

POST/api/v1/tenants/{id}/services

Add a new service to an existing tenant.

Parameters

namestringrequired
Unique service name within the tenant
source_typestringrequired
Source type: "github" or "image"
repo_urlstring
Git repository URL
branchstringdefault main
Git branch
image_urlstring
Container image URL (for source_type "image")
portint
Service port
is_routablebooleandefault false
Whether the service receives HTTP traffic
resource_sizestringdefault small
Resource tier: small, medium, large

Errors

  • 400 Missing required field (name or source_type)
  • 409 Service name already exists in this tenant
  • 422 Tenant is not in a deployable state

Returns 202. Creating the service also queues a deploy of it. `name` and `source_type` are required; `port` defaults to 8080 and `is_routable` to true. A service added this way is never primary. A duplicate name returns 409, and a tenant that is mid-deploy returns 422.

Request body
{
  "name": "api",
  "display_name": "API",
  "source_type": "github",
  "repo_url": "https://github.com/acme/backend",
  "branch": "main",
  "dockerfile_path": "Dockerfile",
  "build_context": ".",
  "port": 8080,
  "is_routable": true,
  "deploy_priority": 1,
  "resource_size": "small",
  "health_check_type": "http",
  "health_check_path": "/healthz"
}
Response
{
  "data": {
    "id": "svc_1a2b3c4d",
    "tenant_id": "ten_a1b2c3d4",
    "name": "api",
    "source_type": "github",
    "repo_url": "https://github.com/acme/backend",
    "branch": "main",
    "port": 8080,
    "is_routable": true,
    "is_primary": false,
    "status": "pending",
    "deploy_priority": 1,
    "resource_size": "small",
    "created_at": "2026-01-15T10:30:00Z",
    "updated_at": "2026-01-15T10:30:00Z"
  }
}

Services

Remove Service

DELETE/api/v1/tenants/{id}/services/{serviceName}

Remove a service from a tenant. The primary service cannot be removed.

Errors

  • 400 Cannot remove the primary service
  • 404 Service not found

Addressed by service name, not ID. Removing the primary service returns 400. The tenant's aggregate status is recalculated from whatever services remain.

Response
No response body (204 No Content).

Services

Deploy Service

POST/api/v1/tenants/{id}/services/{serviceName}/deploy

Trigger a deployment for one service.

Returns 202 with the tracking deployment record. `version` counts up per service. Subscribe to the tenant events stream to follow progress; a tenant that is already deploying returns 422.

Response
{
  "data": {
    "id": "dep_x1y2z3",
    "tenant_id": "ten_a1b2c3d4",
    "service_id": "svc_9f8e7d6c",
    "provider_id": "prov_a1b2c3d4",
    "image_tag": "pending",
    "source_type": "github",
    "deploy_type": "full",
    "trigger": "api",
    "status": "pending",
    "version": 4,
    "is_current": false,
    "metadata": {},
    "created_at": "2026-01-15T10:30:00Z"
  }
}

Services

List Service Deployments

GET/api/v1/tenants/{id}/services/{serviceName}/deployments

List deployment history for one service.

Paginated with `limit` and `offset`. `is_current` is scoped to this service: it marks the deployment currently serving traffic for THIS service, and every service in a tenant has its own current deployment. `has_config_snapshot` reports whether the deployment's configuration was captured, and `has_retained_secrets` whether its secret values are still held. A deployment that captured no secrets reports `has_retained_secrets: true`, because a rollback restores it in full. Together they tell you what a rollback to that deployment would restore before you request it. Roll one of these back with `POST /api/v1/tenants/{id}/services/{serviceName}/deployments/{depId}/rollback`.

Response
{
  "data": [
    {
      "id": "dep_x1y2z3",
      "tenant_id": "ten_a1b2c3d4",
      "service_id": "svc_9f8e7d6c",
      "image_tag": "registry.staxa.dev/acme-corp-web:v3",
      "source_ref": "abc123def456",
      "source_type": "github",
      "deploy_type": "full",
      "trigger": "api",
      "status": "succeeded",
      "started_at": "2026-01-15T10:30:05Z",
      "completed_at": "2026-01-15T10:32:00Z",
      "duration_ms": 115000,
      "version": 3,
      "is_current": true,
      "has_config_snapshot": true,
      "has_retained_secrets": true,
      "created_at": "2026-01-15T10:30:00Z"
    }
  ],
  "pagination": { "total": 3, "limit": 20, "offset": 0 }
}

Services

Service Logs

GET/api/v1/tenants/{id}/services/{serviceName}/logs

Stream logs for one service.

Parameters

linesintdefault 100
Number of log lines to return
followbooleandefault false
Stream logs in real-time (SSE)

`lines` defaults to 100 and caps at 5000. `follow=true` holds the connection open and streams new output, up to ten minutes. With no running pods you get 404; on a deployment without log streaming configured, 501.

Request · curl
curl "https://api.staxa.dev/api/v1/tenants/ten_a1b2c3d4/services/api/logs?lines=200&follow=true" \
  -H "Authorization: Bearer sk_live_..."
Response
Raw text log output (Content-Type: text/plain).

Services

Deploy All Services

POST/api/v1/tenants/{id}/deploy

Deploy every service in a tenant at once.

Returns 202 with one tenant-level deployment record covering the whole run. Services build and deploy in `deploy_priority` order, and one failing service does not stop the others. The tenant's final status is derived from all of them together.

Response
{
  "data": {
    "id": "dep_x1y2z3",
    "tenant_id": "ten_a1b2c3d4",
    "provider_id": "prov_a1b2c3d4",
    "image_tag": "pending",
    "source_type": "github",
    "deploy_type": "full",
    "trigger": "api",
    "status": "pending",
    "version": 5,
    "is_current": false,
    "metadata": {},
    "created_at": "2026-01-15T10:30:00Z"
  }
}

Services

Rollback Service Deployment

POST/api/v1/tenants/{id}/services/{serviceName}/deployments/{depId}/rollback

Roll one service back to an earlier deployment of that same service.

Returns 202. Restores one service to an earlier deployment of that same service, leaving every other service alone. Use this rather than the tenant-level rollback for anything in a multi-service tenant. The tenant-level endpoint redeploys the primary workload, so restoring a service's image through it would deploy that image tenant-wide, and it rejects service-scoped targets for that reason. No rebuild happens: the target's image already exists in the registry and is redeployed as-is. Configuration comes from the target's snapshot, with database credentials and inter-service discovery variables re-derived live on top, because those describe current topology. `config_source` reports which configuration was applied (`snapshot`, `partial` or `current`) with the same meaning as the tenant-level rollback, and `{"refresh_config": true}` opts into current configuration with the old image. Returns 404 when the deployment does not exist, belongs to another tenant, or belongs to a DIFFERENT service of this tenant. A sibling service's deployment is not visible on this route, so it reads as not found rather than as a validation error. Returns 422 when the target did not complete successfully, when it has no image to restore, or when the tenant is not in a deployable state (`pending`, `ready` or `failed`). The new deployment is versioned in the service's own sequence, not the tenant's, so its `version` continues that service's history. Configuration retention: a deployment's settings and non-secret variables are kept for the life of the deployment row, so `has_config_snapshot` stays true. Only secret VALUES are bounded, to the most recent 3 deployments per service by default. Operators can change this with the `config_snapshots.secret_retention` platform config key, which is read with a 30 second cache and needs no restart.

Request · curl
curl -X POST \
  https://api.staxa.dev/api/v1/tenants/ten_a1b2c3d4/services/web/deployments/dep_x1y2z3/rollback \
  -H "Authorization: Bearer $STAXA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"refresh_config": false}'
Request body
{
  "refresh_config": false
}
Response
{
  "data": {
    "id": "dep_new456",
    "tenant_id": "ten_a1b2c3d4",
    "service_id": "svc_9f8e7d6c",
    "provider_id": "prov_a1b2c3d4",
    "image_tag": "registry.staxa.dev/acme-corp-web:v3",
    "source_ref": "abc123def456",
    "source_type": "github",
    "deploy_type": "rollback",
    "trigger": "api",
    "status": "pending",
    "version": 8,
    "is_current": false,
    "config_source": "partial",
    "metadata": {
      "rollback_to": "dep_x1y2z3",
      "rollback_to_version": 3,
      "refresh_config": false,
      "config_source": "partial",
      "service": "web"
    },
    "created_at": "2026-01-15T11:00:00Z"
  }
}

Deployments

Trigger, inspect, and roll back deployments.

Deployments

List Deployments

GET/api/v1/tenants/{id}/deployments

List all deployments for a tenant, newest first.

Paginated with `limit` and `offset`. Status moves through `pending` → `building` → `deploying` → `succeeded` or `failed`. Deployments scoped to a single service also carry a `service_id`. `has_config_snapshot` reports whether the deployment's configuration was captured, and `has_retained_secrets` whether its secret values are still held. A deployment that captured no secrets reports `has_retained_secrets: true`, because a rollback restores it in full. Together they tell you what a rollback to that deployment would restore before you request it.

Response
{
  "data": [
    {
      "id": "dep_x1y2z3",
      "tenant_id": "ten_a1b2c3d4",
      "provider_id": "prov_a1b2c3d4",
      "image_tag": "registry.staxa.dev/acme-corp:v3",
      "source_ref": "abc123def456",
      "source_type": "github",
      "deploy_type": "full",
      "trigger": "api",
      "status": "succeeded",
      "started_at": "2026-01-15T10:30:05Z",
      "completed_at": "2026-01-15T10:32:00Z",
      "duration_ms": 115000,
      "version": 3,
      "is_current": true,
      "has_config_snapshot": true,
      "has_retained_secrets": true,
      "metadata": {},
      "created_at": "2026-01-15T10:30:00Z"
    }
  ],
  "pagination": { "total": 5, "limit": 20, "offset": 0 }
}

Deployments

Create Deployment

POST/api/v1/tenants/{id}/deployments

Trigger a new deployment for a tenant.

Parameters

branchstring
Git branch to deploy
commit_shastring
Specific commit to deploy

Returns 202. Every field is optional. An empty body builds the configured branch at its latest commit, with `source_type` falling back to `github`, `deploy_type` to `full`, `trigger` to `api`, and `image_tag` to `pending` until the build assigns a real tag. Use `source_ref` to pin a commit SHA or tag. Only tenants in `pending`, `ready`, or `failed` accept new deployments; anything else returns 422.

Request body
{
  "image_tag": "registry.staxa.dev/acme-corp:v4",
  "source_ref": "abc123def456",
  "source_type": "github",
  "deploy_type": "full",
  "trigger": "api"
}
Response
{
  "data": {
    "id": "dep_x1y2z3",
    "tenant_id": "ten_a1b2c3d4",
    "provider_id": "prov_a1b2c3d4",
    "image_tag": "pending",
    "source_ref": "abc123def456",
    "source_type": "github",
    "deploy_type": "full",
    "trigger": "api",
    "status": "pending",
    "version": 4,
    "is_current": false,
    "metadata": {},
    "created_at": "2026-01-15T10:30:00Z"
  }
}

Deployments

Get Deployment

GET/api/v1/tenants/{id}/deployments/{depId}

Get details for one deployment.

On a failure, `error_stage` names the pipeline stage that broke and `build_logs` holds the captured build output. Both are omitted on a clean deploy. `has_config_snapshot` reports whether the deployment's configuration was captured, and `has_retained_secrets` whether its secret values are still held. A deployment that captured no secrets reports `has_retained_secrets: true`, because a rollback restores it in full. Together they tell you what a rollback to that deployment would restore before you request it.

Response
{
  "data": {
    "id": "dep_x1y2z3",
    "tenant_id": "ten_a1b2c3d4",
    "provider_id": "prov_a1b2c3d4",
    "image_tag": "registry.staxa.dev/acme-corp:v3",
    "source_ref": "abc123def456",
    "source_type": "github",
    "deploy_type": "full",
    "trigger": "api",
    "status": "failed",
    "started_at": "2026-01-15T10:30:05Z",
    "completed_at": "2026-01-15T10:31:10Z",
    "duration_ms": 65000,
    "error_stage": "build",
    "error_message": "build failed: exit status 1",
    "build_logs": "...",
    "version": 3,
    "is_current": false,
    "has_config_snapshot": true,
    "has_retained_secrets": true,
    "metadata": {},
    "created_at": "2026-01-15T10:30:00Z"
  }
}

Deployments

Rollback Deployment

POST/api/v1/tenants/{id}/deployments/{depId}/rollback

Roll back to a specific earlier deployment, restoring its image and the configuration it ran with.

Returns 202. `{depId}` is the deployment to RESTORE: the rollback runs as a new deployment that redeploys that deployment's image. By default it also restores the configuration that deployment ran with, rather than applying today's configuration to an old image. The response reports which was applied in `config_source`: - `snapshot`: image and configuration both restored, secrets included. - `partial`: settings and non-secret variables restored; the target's secret values are past the retention window, so current values are used for those. This is the usual result for an older deployment, and is normally what you want: a rotated credential should be current. - `current`: the deployment predates configuration snapshots, so current configuration is used. Send `{"refresh_config": true}` to keep the old image but deliberately use current configuration. A bare POST with no body keeps the default. Fails with 422 when the target did not complete successfully, when it has no image to restore, or when it is service-scoped. A service's deployment cannot be restored here, because this endpoint redeploys the tenant's primary workload and would push that image tenant-wide. Use `POST /api/v1/tenants/{id}/services/{serviceName}/deployments/{depId}/rollback` instead. Returns 404 when the deployment does not exist or belongs to another tenant. A rollback fails rather than deploying if the target named secret variables that no longer exist and whose values are past the retention window; the error names the keys. Recreate them, or retry with `refresh_config`. Configuration retention: a deployment's settings and non-secret variables are kept for the life of the deployment row, so `has_config_snapshot` stays true. Only secret VALUES are bounded, to the most recent 3 deployments per service by default. Operators can change this with the `config_snapshots.secret_retention` platform config key, which is read with a 30 second cache and needs no restart.

Request body
{
  "refresh_config": false
}
Response
{
  "data": {
    "id": "dep_new123",
    "tenant_id": "ten_a1b2c3d4",
    "provider_id": "prov_a1b2c3d4",
    "image_tag": "registry.staxa.dev/acme-corp:v3",
    "source_ref": "abc123def456",
    "source_type": "github",
    "deploy_type": "rollback",
    "trigger": "api",
    "status": "pending",
    "version": 5,
    "is_current": false,
    "config_source": "snapshot",
    "metadata": {
      "rollback_to": "dep_x1y2z3",
      "rollback_to_version": 3,
      "refresh_config": false,
      "config_source": "snapshot"
    },
    "created_at": "2026-01-15T11:00:00Z"
  }
}

Environment Variables

Manage environment variables for tenant workloads.

Environment Variables

Get Env Vars

GET/api/v1/tenants/{id}/env

List environment variables for a tenant.

Variables flagged `is_secret` come back with their value replaced by `[redacted]`, and nothing in the API reveals them again. `source` is `user` for anything you set and `system` for values Staxa injects, such as database credentials.

Response
{
  "data": [
    {
      "id": "6f1c9a2e-8b34-4d51-9f77-2a0c5e8b1d43",
      "key": "NODE_ENV",
      "value": "production",
      "is_secret": false,
      "source": "user"
    },
    {
      "id": "b28d7f04-15ce-4a9b-8e60-7c3f9a1d6e52",
      "key": "DATABASE_URL",
      "value": "[redacted]",
      "is_secret": true,
      "source": "system"
    }
  ]
}

Environment Variables

Bulk Set Env Vars

PUT/api/v1/tenants/{id}/env

Create or update environment variables in bulk.

The request body is a flat `Record<string, string>`, not a nested object. Keys are merged, not replaced: anything you send is created or overwritten, and anything you leave out is kept as it was. `updated` counts the keys in your request. Variables set this way are always non-secret and owned by you (`source: "user"`). Values are encrypted at rest and reach your containers on the next deploy. There is no endpoint for deleting a single variable. Set it to an empty string instead.

Request body
{
  "NODE_ENV": "production",
  "DATABASE_URL": "postgres://...",
  "API_KEY": "secret123"
}
Response
{
  "data": { "updated": 3 }
}

Domains

Custom domain management with DNS verification.

Domains

List Domains

GET/api/v1/tenants/{id}/domains

List custom domains for a tenant.

Point your A record at the `server_ip` field, which is only present when the platform has one configured. Domain `status` runs `pending` → `verified` → `active`; `ssl_status` tracks certificate issuance separately.

Response
{
  "data": {
    "domains": [
      {
        "id": "dom_a1b2c3",
        "tenant_id": "ten_a1b2c3d4",
        "domain": "app.acme.com",
        "type": "custom",
        "status": "verified",
        "verification_record": "staxa-verify=ver_a1b2c3d4",
        "verified_at": "2026-01-15T11:00:00Z",
        "ssl_status": "active",
        "is_primary": true,
        "service_id": "svc_9f8e7d6c",
        "created_at": "2026-01-15T10:30:00Z",
        "updated_at": "2026-01-15T11:00:00Z"
      }
    ],
    "server_ip": "65.108.x.x"
  }
}

Domains

Add Domain

POST/api/v1/tenants/{id}/domains

Add a custom domain to a tenant.

Parameters

domainstringrequired
Custom domain name
is_primarybooleandefault false
Set as primary domain
service_idstring
Route to a specific service (multi-service tenants)

Returns 201. `type` defaults to `custom`, which is what generates the `verification_record` you need for the TXT check. `service_id` is optional and must be a service ID (`svc_...`) belonging to this tenant. Leave it out to route the domain at the primary service. Domains must be valid hostnames of 253 characters or fewer.

Request body
{
  "domain": "app.acme.com",
  "type": "custom",
  "is_primary": true,
  "service_id": "svc_9f8e7d6c"
}
Response
{
  "data": {
    "id": "dom_a1b2c3",
    "tenant_id": "ten_a1b2c3d4",
    "domain": "app.acme.com",
    "type": "custom",
    "status": "pending",
    "verification_record": "staxa-verify=ver_a1b2c3d4",
    "ssl_status": "pending",
    "is_primary": true,
    "service_id": "svc_9f8e7d6c",
    "server_ip": "65.108.x.x",
    "created_at": "2026-01-15T10:30:00Z",
    "updated_at": "2026-01-15T10:30:00Z"
  }
}

Domains

Verify Domain

POST/api/v1/tenants/{id}/domains/{domId}/verify

Run the DNS check for a custom domain.

Always returns 200. The shape tells you the outcome. The check looks for a TXT record at `_staxa-verify.<your-domain>` matching `verification_record`; until that resolves you get the pending shape back, and you can call this as often as you need. Once it passes, the domain object is returned and the SSL certificate is issued automatically. A domain that is already verified or active is returned unchanged.

Response
**Still pending:**
```json
{
  "data": {
    "status": "pending",
    "verification_record": "staxa-verify=ver_a1b2c3d4",
    "instructions": "Add a TXT record for _staxa-verify.app.acme.com with value: staxa-verify=ver_a1b2c3d4",
    "server_ip": "65.108.x.x"
  }
}
```

**Verified:**
```json
{
  "data": {
    "id": "dom_a1b2c3",
    "tenant_id": "ten_a1b2c3d4",
    "domain": "app.acme.com",
    "type": "custom",
    "status": "verified",
    "verification_record": "staxa-verify=ver_a1b2c3d4",
    "verified_at": "2026-01-15T11:00:00Z",
    "ssl_status": "pending",
    "is_primary": true,
    "created_at": "2026-01-15T10:30:00Z",
    "updated_at": "2026-01-15T11:00:00Z"
  }
}
```

Domains

Delete Domain

DELETE/api/v1/tenants/{id}/domains/{domId}

Remove a custom domain from a tenant.

For a domain that reached `verified` or `active`, the route, certificate, and any managed CNAME record are cleaned up first.

Response
No response body (204 No Content).

Network Rules

Inbound and outbound network access control.

Network Rules

List Network Rules

GET/api/v1/tenants/{id}/network-rules

List network rules for a tenant, with optional filtering.

Parameters

directionstring
Filter: "inbound" or "outbound"
statusstringdefault active
Filter by status

A plain array, not paginated. Filter with `direction` (`inbound` or `outbound`) and `status`, which defaults to `active`. `connection_string` is computed on create and read, not stored, so it is absent from this list.

Response
{
  "data": [
    {
      "id": "nr_a1b2c3",
      "tenant_id": "ten_a1b2c3d4",
      "provider_id": "prov_a1b2c3d4",
      "direction": "inbound",
      "protocol": "tcp",
      "description": "Database access from office",
      "target_service": "database",
      "target_port": 5432,
      "source_cidrs": ["203.0.113.0/24"],
      "allocated_port": 30100,
      "expires_at": "2026-01-16T10:30:00Z",
      "status": "active",
      "created_at": "2026-01-15T10:30:00Z",
      "updated_at": "2026-01-15T10:30:00Z"
    }
  ]
}

Network Rules

Create Network Rule

POST/api/v1/tenants/{id}/network-rules

Create a network rule. Inbound TCP rules allocate a port; outbound rules require restricted egress mode.

Parameters

directionstringrequired
"inbound" or "outbound"
protocolstringrequired
Protocol: "tcp" or "udp"
target_servicestring
Target service name (inbound)
target_portint
Target port (inbound)
source_cidrsstring[]
Allowed source IPs (inbound)
destination_cidrsstring[]
Allowed destination IPs (outbound)
destination_portint
Destination port (outbound)
ttl_hoursint
Auto-expire after N hours
descriptionstring
Free-text description of the rule

Errors

  • 400 Invalid CIDR format, CIDR too wide, or too many CIDRs
  • 409 NodePort pool exhausted (inbound)
  • 422 Rule limit reached, or egress_mode must be "restricted" for outbound rules

Returns 201. `direction` and `protocol` are required: `inbound` or `outbound`, `tcp` or `udp`. Inbound TCP rules get a port from the platform pool in `allocated_port`; when the pool is empty you get 409 `PORT_POOL_EXHAUSTED`. Outbound rules only work on a tenant with `egress_mode: "restricted"`, otherwise 422 `EGRESS_MODE_REQUIRED`. Each one is folded into a single network policy covering every active outbound rule. `ttl_hours` sets an expiry, after which the rule is cleaned up automatically; leave it out to use the platform default. CIDRs are validated on the way in: 400 with `INVALID_CIDR`, `CIDR_TOO_WIDE` (`/0` and `/1` are blocked), or `TOO_MANY_CIDRS`. Past your rule limit you get 422 `RULE_LIMIT_REACHED`.

Request body
**Inbound rule:**
```json
{
  "direction": "inbound",
  "protocol": "tcp",
  "target_service": "database",
  "target_port": 5432,
  "source_cidrs": ["203.0.113.0/24"],
  "description": "Database access from office",
  "ttl_hours": 24
}
```

**Outbound rule:**
```json
{
  "direction": "outbound",
  "protocol": "tcp",
  "destination_cidrs": ["10.0.0.0/8"],
  "destination_port": 443,
  "description": "Allow HTTPS to internal network"
}
```
Response
{
  "data": {
    "id": "nr_a1b2c3",
    "tenant_id": "ten_a1b2c3d4",
    "provider_id": "prov_a1b2c3d4",
    "direction": "inbound",
    "protocol": "tcp",
    "description": "Database access from office",
    "target_service": "database",
    "target_port": 5432,
    "source_cidrs": ["203.0.113.0/24"],
    "allocated_port": 30100,
    "expires_at": "2026-01-16T10:30:00Z",
    "status": "active",
    "connection_string": "65.108.x.x:30100 (service: database)",
    "created_at": "2026-01-15T10:30:00Z",
    "updated_at": "2026-01-15T10:30:00Z"
  }
}

Network Rules

Get Network Rule

GET/api/v1/tenants/{id}/network-rules/{ruleId}

Get details for one network rule.

A rule belonging to another tenant returns 404 rather than 403.

Response
{
  "data": {
    "id": "nr_a1b2c3",
    "tenant_id": "ten_a1b2c3d4",
    "provider_id": "prov_a1b2c3d4",
    "direction": "inbound",
    "protocol": "tcp",
    "description": "Database access from office",
    "target_service": "database",
    "target_port": 5432,
    "source_cidrs": ["203.0.113.0/24"],
    "allocated_port": 30100,
    "expires_at": "2026-01-16T10:30:00Z",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z",
    "updated_at": "2026-01-15T10:30:00Z"
  }
}

Network Rules

Delete Network Rule

DELETE/api/v1/tenants/{id}/network-rules/{ruleId}

Delete a network rule. Inbound rules release the allocated port.

Deleting an outbound rule rebuilds the egress policy from the rules that remain; removing the last one drops the policy entirely and the tenant falls back to whatever its `egress_mode` allows.

Response
No response body (204 No Content).

Real-Time

SSE event streams and live container logs.

Real-Time

Events (SSE)

GET/api/v1/tenants/{id}/events

Subscribe to deployment events over Server-Sent Events (SSE).

Three event names come down the wire: `connected` once on open, `deployment` for every pipeline update, and `ping` every 15 seconds to hold the connection alive. On connect, the events already recorded for the tenant's latest deployment are replayed first, so a client that joins late still sees the full picture. Deployment stages run `validate` → `provision` → `database` → `build` → `arch_check` → `deploy` → `route` → `health_check` → `ready`, with `progress` climbing from 0 to 100. Deletions emit `deleting` and `deleted`. A failure ends the stream on the stage that broke. Read `error_stage` from the deployment for the detail.

Request · curl
curl -N https://api.staxa.dev/api/v1/tenants/ten_a1b2c3d4/events \
  -H "Authorization: Bearer sk_live_..." \
  -H "Accept: text/event-stream"
Response
event: connected
data: {"type":"connected","tenant_id":"ten_a1b2c3d4"}

event: deployment
data: {"type":"deployment","deployment_id":"dep_x1y2z3","tenant_id":"ten_a1b2c3d4","stage":"build","message":"Building image...","progress":40,"timestamp":"2026-01-15T10:30:20Z"}

event: deployment
data: {"type":"deployment","deployment_id":"dep_x1y2z3","tenant_id":"ten_a1b2c3d4","stage":"ready","message":"Tenant is live","progress":100,"timestamp":"2026-01-15T10:32:00Z"}

event: ping
data: {"type":"ping","time":1768473120}

Real-Time

Container Logs

GET/api/v1/tenants/{id}/logs

Stream container logs for a tenant.

Parameters

linesintdefault 200
Number of log lines to return
followbooleandefault false
Stream logs as they arrive

`lines` defaults to 100 and caps at 5000. `follow=true` holds the connection open and streams new output, up to ten minutes. This reads the tenant's primary workload. For a specific service in a multi-service tenant, use `GET /tenants/{id}/services/{serviceName}/logs`. With no running pods you get 404.

Request · curl
curl "https://api.staxa.dev/api/v1/tenants/ten_a1b2c3d4/logs?lines=50&follow=true" \
  -H "Authorization: Bearer sk_live_..."
Response
Raw text log output (Content-Type: text/plain).

Templates

Pre-built application templates and the runtime versions you can build against.

Templates

List Templates

GET/api/v1/templates

List available application templates for quick-start tenant creation.

Public: no authentication needed, limited to 60 requests per minute per IP. Narrow the list with a `category` query parameter.

Response
{
  "data": [
    {
      "id": "tmpl_nextjs_postgres",
      "slug": "nextjs-postgres",
      "name": "Next.js + PostgreSQL",
      "description": "Full-stack Next.js app with PostgreSQL database",
      "framework": "nextjs",
      "runtime": "node",
      "runtime_version": "20",
      "db_engine": "postgres",
      "db_version": "16",
      "default_env": {},
      "default_size": "small",
      "default_port": 3000,
      "category": "fullstack",
      "sort_order": 1,
      "is_featured": true,
      "is_active": true,
      "created_at": "2026-01-15T10:30:00Z",
      "updated_at": "2026-01-15T10:30:00Z"
    }
  ]
}

Templates

Get Template

GET/api/v1/templates/{id}

Get details for one template, by ID or slug.

Public, same as the list endpoint. The path segment is matched against the template ID first and then its slug, so `/templates/nextjs-postgres` works as well as `/templates/tmpl_nextjs_postgres`. Image-based templates carry an `image` instead of a `starter_repo`.

Response
{
  "data": {
    "id": "tmpl_nextjs_postgres",
    "slug": "nextjs-postgres",
    "name": "Next.js + PostgreSQL",
    "description": "Full-stack Next.js app with PostgreSQL database",
    "framework": "nextjs",
    "runtime": "node",
    "runtime_version": "20",
    "db_engine": "postgres",
    "db_version": "16",
    "default_env": {},
    "default_size": "small",
    "default_port": 3000,
    "category": "fullstack",
    "sort_order": 1,
    "is_featured": true,
    "is_active": true,
    "created_at": "2026-01-15T10:30:00Z",
    "updated_at": "2026-01-15T10:30:00Z"
  }
}

Templates

List Runtime Versions

GET/api/v1/config/runtimes

List the runtime versions the platform can build against.

The set is platform configuration, so it changes as runtimes are added or retired, so read it rather than hardcoding a list. Use these values for `runtime_version` when creating a tenant or a service.

Response
{
  "data": {
    "node": ["22", "20", "18"],
    "python": ["3.13", "3.12", "3.11", "3.10"],
    "go": ["1.24", "1.23", "1.22"],
    "ruby": ["3.4", "3.3", "3.2"],
    "java": ["23", "21", "17", "11"],
    "php": ["8.4", "8.3", "8.2"],
    "rust": ["1.83", "1.80", "1.77"],
    "dotnet": ["9", "8"],
    "elixir": ["1.17", "1.16", "1.15"],
    "deno": ["2.1", "1.46"],
    "bun": ["1.1", "1.0"]
  }
}

GitHub App

GitHub App installation and repository access.

GitHub App

Get GitHub Installation

GET/api/v1/github/installation

Get the GitHub App installation status for the authenticated provider.

Always 200. Check `connected` rather than the status code. When nothing is installed, `install_url` is where you send the user to install the app. `repository_selection` is `all` or `selected`, and a suspended installation carries `suspended_at`.

Response
**Not connected:**
```json
{
  "data": {
    "connected": false,
    "install_url": "https://github.com/apps/staxa/installations/new"
  }
}
```

**Connected:**
```json
{
  "data": {
    "connected": true,
    "installation": {
      "id": "ghi_a1b2c3d4",
      "provider_id": "prov_a1b2c3d4",
      "installation_id": 12345678,
      "account_type": "Organization",
      "account_login": "acme-org",
      "account_id": 87654321,
      "app_slug": "staxa",
      "repository_selection": "selected",
      "status": "active",
      "created_at": "2026-01-15T10:30:00Z",
      "updated_at": "2026-01-15T10:30:00Z"
    }
  }
}
```

GitHub App

Register GitHub Installation

POST/api/v1/github/installation

Register a GitHub App installation for the provider. Called after GitHub redirects back from the install flow.

Parameters

installation_idintrequired
GitHub App installation ID from the OAuth callback

Returns 200. The installation is verified against GitHub before anything is written, and the account metadata in the response comes from GitHub rather than the request, and an ID GitHub does not recognise returns 400. Calling it again for the same provider updates the existing record instead of creating a second one.

Request body
{
  "installation_id": 12345678
}
Response
{
  "data": {
    "id": "ghi_a1b2c3d4",
    "provider_id": "prov_a1b2c3d4",
    "installation_id": 12345678,
    "account_type": "Organization",
    "account_login": "acme-org",
    "account_id": 87654321,
    "app_slug": "staxa",
    "repository_selection": "selected",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z",
    "updated_at": "2026-01-15T10:30:00Z"
  }
}

GitHub App

Delete GitHub Installation

DELETE/api/v1/github/installation

Remove the GitHub App installation link from the provider account.

This only unlinks the installation on the Staxa side. The app stays installed on GitHub until it is removed there too, so a later `POST /github/installation` can re-link it.

Response
No response body (204 No Content).

GitHub App

List GitHub Repos

GET/api/v1/github/repos

List repositories accessible through the GitHub App installation.

Which repositories appear depends on what the installation was granted: everything, or just the ones the user picked. With no installation registered you get 400.

Response
{
  "data": [
    {
      "id": 123456789,
      "full_name": "acme-org/webapp",
      "private": true,
      "default_branch": "main",
      "html_url": "https://github.com/acme-org/webapp"
    }
  ]
}

GitHub App

List GitHub Branches

GET/api/v1/github/repos/{owner}/{repo}/branches

List branches for one repository.

The repository has to be one the installation can reach, otherwise the call fails. With no installation registered you get 400.

Response
{
  "data": [
    { "name": "main" },
    { "name": "develop" },
    { "name": "feature/auth" }
  ]
}

GitHub App

Analyze Repository

GET/api/v1/github/repos/{owner}/{repo}/analyze

Inspect a repository and detect its runtime, framework, and how to run it.

Takes an optional `branch`, defaulting to `main`. The repository tree and its dependency manifests are read through the GitHub App installation; nothing is cloned and nothing is written. When a Dockerfile is present, its base image decides `detected_runtime` and `detected_version`; otherwise both are inferred from the framework. `has_dockerfile: false` means Staxa will generate one for you at build time. Use the result to prefill a create-tenant call: `framework`, `runtime_version`, and `detected_port` map onto `framework`, `runtime_version`, and `app_port`. With no installation registered you get 400.

Request · curl
curl "https://api.staxa.dev/api/v1/github/repos/acme-org/webapp/analyze?branch=main" \
  -H "Authorization: Bearer sk_live_..."
Response
{
  "data": {
    "has_dockerfile": true,
    "dockerfile_path": "Dockerfile",
    "detected_runtime": "node",
    "detected_version": "20",
    "from_line": "FROM node:20-alpine",
    "framework": "nextjs",
    "build_command": "npm run build",
    "start_command": "npm start",
    "detected_port": 3000,
    "confidence": "high"
  }
}

Registry Credentials

Private container registry authentication.

Registry Credentials

List Registries

GET/api/v1/registries

List saved container registry credentials.

Credentials themselves are never returned by any endpoint. They are encrypted at rest and only decrypted when Staxa talks to the registry. `status` is `active` or `error`; when it is `error`, `last_error` carries the reason from the last attempt.

Response
{
  "data": [
    {
      "id": "rcr_a1b2c3d4",
      "provider_id": "prov_a1b2c3d4",
      "name": "Docker Hub",
      "registry_type": "dockerhub",
      "registry_url": "https://index.docker.io/v1/",
      "namespace": "acme",
      "status": "active",
      "last_verified": "2026-01-15T10:30:00Z",
      "created_at": "2026-01-15T10:30:00Z",
      "updated_at": "2026-01-15T10:30:00Z"
    }
  ]
}

Registry Credentials

Create Registry Credential

POST/api/v1/registries

Save credentials for a private container registry.

Returns 201. `registry_type` must be `dockerhub`, `ghcr`, `ecr`, or `gcr`, and the shape of `credentials` follows from it. `registry_url` defaults per type (`https://index.docker.io/v1/`, `https://ghcr.io`, `https://gcr.io`) but is required for ECR, since the URL carries your account and region. Credentials are checked against the registry before they are stored, so a bad secret returns 422 `REGISTRY_VERIFICATION_FAILED` and nothing is saved. That is also why the credential comes back already `active`. Past your credential limit you get 422 `REGISTRY_LIMIT_REACHED`. `namespace` scopes repository listings to one organisation or project.

Request body
**Docker Hub or GHCR:**
```json
{
  "name": "Docker Hub",
  "registry_type": "dockerhub",
  "registry_url": "https://index.docker.io/v1/",
  "namespace": "acme",
  "credentials": {
    "username": "acme",
    "password": "dckr_pat_..."
  }
}
```

**ECR:**
```json
{
  "name": "Production ECR",
  "registry_type": "ecr",
  "registry_url": "https://123456789012.dkr.ecr.us-east-1.amazonaws.com",
  "credentials": {
    "aws_access_key_id": "AKIA...",
    "aws_secret_access_key": "...",
    "aws_region": "us-east-1"
  }
}
```

**GCR or Artifact Registry:**
```json
{
  "name": "GCR",
  "registry_type": "gcr",
  "registry_url": "https://gcr.io",
  "credentials": {
    "service_account_json": "{\"type\":\"service_account\",...}"
  }
}
```
Response
{
  "data": {
    "id": "rcr_a1b2c3d4",
    "provider_id": "prov_a1b2c3d4",
    "name": "Docker Hub",
    "registry_type": "dockerhub",
    "registry_url": "https://index.docker.io/v1/",
    "namespace": "acme",
    "status": "active",
    "last_verified": "2026-01-15T10:30:00Z",
    "created_at": "2026-01-15T10:30:00Z",
    "updated_at": "2026-01-15T10:30:00Z"
  }
}

Registry Credentials

Get Registry Credential

GET/api/v1/registries/{credId}

Get details for a saved registry credential. The credentials themselves are never returned.

A credential belonging to another provider returns 404 rather than 403.

Response
{
  "data": {
    "id": "rcr_a1b2c3d4",
    "provider_id": "prov_a1b2c3d4",
    "name": "Docker Hub",
    "registry_type": "dockerhub",
    "registry_url": "https://index.docker.io/v1/",
    "namespace": "acme",
    "status": "active",
    "last_verified": "2026-01-15T10:30:00Z",
    "created_at": "2026-01-15T10:30:00Z",
    "updated_at": "2026-01-15T10:30:00Z"
  }
}

Registry Credentials

Update Registry Credential

PATCH/api/v1/registries/{credId}

Rename a registry credential or rotate its secret.

Both fields are optional. New credentials are verified before they replace the old ones. If verification fails you get 422 and the stored credential is left alone, so a rotation can never lock you out by half-applying. `registry_type` and `registry_url` are fixed at creation; to change either, create a new credential.

Request body
{
  "name": "Docker Hub (rotated)",
  "credentials": {
    "username": "acme",
    "password": "dckr_pat_new..."
  }
}
Response
{
  "data": {
    "id": "rcr_a1b2c3d4",
    "provider_id": "prov_a1b2c3d4",
    "name": "Docker Hub (rotated)",
    "registry_type": "dockerhub",
    "registry_url": "https://index.docker.io/v1/",
    "namespace": "acme",
    "status": "active",
    "last_verified": "2026-01-15T10:30:00Z",
    "created_at": "2026-01-15T10:30:00Z",
    "updated_at": "2026-01-16T09:00:00Z"
  }
}

Registry Credentials

Delete Registry Credential

DELETE/api/v1/registries/{credId}

Delete a saved registry credential.

Deployments that pull images with this credential will start failing on their next build, so repoint them first.

Response
No response body (204 No Content).

Registry Credentials

Verify Registry Credential

POST/api/v1/registries/{credId}/verify

Re-test that the saved credentials can still authenticate with the registry.

On success the credential comes back with `status: "active"` and a fresh `last_verified`. On failure you get 422 `REGISTRY_VERIFICATION_FAILED`, and the credential is marked `error` with the reason stored in `last_error`. It's worth calling after a token rotation on the registry side.

Response
{
  "data": {
    "id": "rcr_a1b2c3d4",
    "provider_id": "prov_a1b2c3d4",
    "name": "Docker Hub",
    "registry_type": "dockerhub",
    "registry_url": "https://index.docker.io/v1/",
    "namespace": "acme",
    "status": "active",
    "last_verified": "2026-01-16T09:00:00Z",
    "created_at": "2026-01-15T10:30:00Z",
    "updated_at": "2026-01-16T09:00:00Z"
  }
}

Registry Credentials

List Registry Repos

GET/api/v1/registries/{credId}/repos

List repositories available in the registry.

Parameters

qstring
Search query to filter repos
limitintdefault 25
Maximum results to return

Takes `q` to filter by name and `limit` (1–100, default 25). Results are scoped to the credential's `namespace` where the registry supports it.

Response
{
  "data": [
    {
      "name": "acme/webapp",
      "full_url": "index.docker.io/acme/webapp",
      "description": "Frontend application",
      "is_private": true,
      "updated_at": "2026-01-14T18:02:00Z"
    }
  ]
}

Registry Credentials

List Registry Tags

GET/api/v1/registries/{credId}/repos/{repo}/tags

List tags for one repository in the registry.

Repository paths may contain slashes, as in `library/nginx`. The whole path goes between `/repos/` and `/tags`, and wildcard matching handles it. Takes `limit` (1–100, default 25). Which fields are populated depends on the registry; some return a digest and size, others only the tag name.

Response
{
  "data": [
    {
      "name": "latest",
      "digest": "sha256:abc123...",
      "size_bytes": 52428800,
      "pushed_at": "2026-01-14T18:02:00Z"
    },
    {
      "name": "v1.2.3",
      "digest": "sha256:def456...",
      "size_bytes": 52428800,
      "pushed_at": "2026-01-10T11:40:00Z"
    }
  ]
}
© 2026 Stackforge Labs Limited
DocsTemplatesSupportPrivacyTerms