Skip to content

Terraducktel API Reference

Complete endpoint catalog. Everything is HTTP/JSON over /api/v1.

Conventions

  • Base URL: /api/v1 for user-facing endpoints, plus a handful of state-token-authenticated /api/v1/state/*, /api/v1/internal/* and /api/v1/webhooks/* paths used by the executor / drift detector / webhook senders.
  • Auth: Every user-facing endpoint requires a JWT Bearer token (Authorization: Bearer …) or an API key (Authorization: Bearer tdt_…, see API Keys below). State + internal + webhook endpoints authenticate via the X-Terraducktel-State-Token header or a webhook HMAC signature instead.
  • Business Unit scoping: Most user-facing list/write endpoints honor X-Business-Unit: <slug>. Rules (see app/auth/bu_context.py):
  • Superadmin, no header (or header all) → all BUs, no filter.
  • Superadmin, header <slug> → that BU (404 if it doesn't exist).
  • Member, no header → caller's first BU membership (alphabetical by slug).
  • Member, header <slug> → that BU if the caller is a member (403 otherwise).
  • Member, header all → 403 (only superadmins can view "all").
  • An API key always forces its own bound BU — any X-Business-Unit header sent alongside a key is ignored.
  • Endpoints noted as BU-scoped filter by the resolved scope; endpoints noted as cross-BU ignore it entirely.
  • Roles: viewer < operator < admin. Endpoints document the minimum role required. A handful of routers (Users, API Keys, Business Units) are interactive-only — they reject API-key callers outright regardless of the key's capability tier, because they touch identity/tenancy rather than workspace operations.
  • Secrets: GET endpoints for integrations and credentialed resources (AWS accounts, Azure subscriptions, clusters, variables) never return plaintext — only {configured, masked_tail} shapes and (for some) a cached identity (Slack workspace name, GitHub login, etc.).
  • Pagination: Most list endpoints return the full result set (BU-scoped collections are small by design). Two exceptions currently paginate explicitly: GET /inventory/assets (limit/offset query params, default limit=200, max 1000) and API-key-authenticated automation is expected to filter client-side otherwise.

JWT format: - Access token: HS256, default 480min/8h expiry (tunable via auth.access_token_expire_minutes in Runtime Config), claims: sub (user id), email, role, is_superadmin, name, type: "access". - Refresh token: HS256, default 24h expiry (tunable via auth.refresh_token_expire_hours), claims: sub, type: "refresh".


Auth — /api/v1/auth

Local email+password login is always available as a break-glass path, even when OIDC SSO is the primary provider. OIDC is gated by the auth.provider config key (local / oidc / both).

Method Path Description Auth
POST /auth/token Local email+password login. Returns access + refresh tokens. public
POST /auth/refresh Redeem a refresh token for a fresh access+refresh pair. Rejects access tokens, run-scoped executor tokens and tdt_ API keys. public (bearer not required — the body carries the token)
GET /auth/config Public auth config: {mode, oidc_enabled, oidc_issuer, cli_loopback}. public
GET /auth/oidc/login Begin OIDC login redirect for the configured provider. 404 if OIDC isn't enabled; 503 if enabled but not configured. Optional cli_port + cli_nonce query params switch the callback to the CLI loopback hand-off (see below). public
GET /auth/oidc/callback OIDC callback target — exchanges the auth code for tokens, upserts the local user, redirects the browser to /auth/oidc-finish?access_token=…&refresh_token=…. public

POST /auth/token body {"email": "...", "password": "..."} → {"access_token", "refresh_token", "token_type": "bearer"}. 401 on bad credentials.

OIDC role mapping (tested against a generic OIDC-compliant IdP) reads a configurable claim (default group/role claim) and maps it to a TDT role + is_superadmin; see services/api/app/auth/oidc.py for the config-key contract.


POST /auth/refresh body {"refresh_token": "..."} → a new {access_token, refresh_token, token_type}.

Both /auth/token and the OIDC callback have always minted a refresh token; this is what redeems one. A new refresh token comes back each time, so an actively-used client slides forward indefinitely and never re-authenticates.

Tokens are stateless — there is no server-side revocation list — so a leaked refresh token stays usable until it expires (auth.refresh_token_expire_hours, default 24). Keep that TTL short, and revoke access by deleting the user. Claims are re-read from the user row on every refresh, so a demotion takes effect on the next one rather than persisting for the life of the token.

CLI loopback sign-in. GET /auth/oidc/login?cli_port=<1024-65535>&cli_nonce=<16-128 url-safe chars> makes the callback hand the token pair to a listener on http://127.0.0.1:<cli_port>/callback instead of redirecting to the SPA. The IdP's registered redirect_uri is unchanged — it still points at this API's own callback — and cli_port travels in the signed session cookie rather than the callback URL, so a crafted callback cannot retarget the hand-off. The redirect host is hardcoded to 127.0.0.1. GET /auth/config advertises cli_loopback: true so a client can tell whether a deployment supports this before trying.

Users — /api/v1/users

Lists users and edits superadmin status / per-Business-Unit memberships. Interactive-only — the whole router rejects API-key callers, even admin-tier ones, since identity management must never be automatable.

Method Path Description Min role
GET /users List all users with their per-BU memberships. admin
GET /users/eligible-reviewers Deprecated — 4-eyes was removed. Always returns []. operator
PATCH /users/{user_id} Update a user's is_superadmin flag and/or BU memberships. superadmin

PATCH /users/{user_id} body:

{
  "is_superadmin": true,                 // optional
  "add_memberships": [                    // optional
    {"business_unit_id": "<bu-id>", "role": "operator"}   // role: operator | viewer
  ],
  "remove_memberships": ["<bu-id>"]        // optional, list of business_unit_id
}
Requires the caller to already be a superadmin (checked against current_user.is_superadmin, independent of the admin role on /users GET). Demoting the last remaining superadmin is rejected with 409. Adding a membership for a BU that doesn't exist is a 400; adding a membership that already exists just updates its role.

Response (UserResponse): {id, email, role, auth_provider, external_id, is_superadmin, memberships: [{business_unit_id, business_unit_slug, business_unit_name, role}]}.


API Keys — /api/v1/api-keys

Long-lived, scoped credentials for automation. Admin-only to manage; the key itself authenticates as its owner but is capped at its capability tier and (optionally) a workspace allowlist, and is pinned to one BU. Interactive-only router — an API key can never mint, rotate, or revoke API keys, even at the admin tier (privilege-escalation guard).

Method Path Description Min role
GET /api-keys List keys in the current BU (masked — token_prefix, never the token). admin
POST /api-keys Mint a key. Returns the plaintext token once. admin
POST /api-keys/{id}/regenerate Rotate an active key's secret in place, keeping name/capability/allowlist/expiry. Returns the new plaintext token once. admin
POST /api-keys/{id}/rotate Mint a successor key and keep the old secret usable for an overlap window. Returns the new plaintext token once, plus predecessor_id and predecessor_expires_at. admin
DELETE /api-keys/{id} Soft-revoke a key (immediate 401 for further use). Idempotent. admin

POST /api-keys body:

{
  "name": "ci-deploy-bot",
  "capability": "plan",            // read | plan | apply | admin
  "workspace_ids": ["<ws-id>"],    // optional allowlist; omit/null = whole BU
  "expires_at": "2026-12-31T00:00:00Z"  // optional; omit = never
}
→ { ...key, "token": "tdt_…" } (the only response that carries the plaintext). Requires a concrete X-Business-Unit — 400 if scoped to "all". A non-empty workspace_ids is validated against the current BU; any id not in this BU → 400. expires_at must be in the future or 400.

POST /api-keys/{id}/regenerate — mints a fresh secret for an existing key row, clears last_used_at, and keeps everything else (name, capability, workspace allowlist, expiry) unchanged. The old token stops working immediately. Rejects with 409 if the key is already revoked or has already expired — rotation only ever refreshes a live secret; create a new key instead of trying to revive a dead one.

POST /api-keys/{id}/rotate body {"overlap_hours": 24} (0–168, default 24) — the option to reach for when more than one consumer holds the key.

regenerate swaps a secret in place and the old one dies that instant, which is fine for a single consumer and unusable when the same key sits in a CI secret store, a laptop keychain and a cron job: those cannot be updated atomically. Rotation creates a second key so both secrets work at once, and you move consumers over one at a time.

Two rows are involved, because an overlap needs two usable secrets and token_hash is unique per row:

old key expires_at pulled in to now + overlap_hours, rotated_at set, superseded_by_id → the successor. Nothing else changes, so it keeps authenticating until the window closes.
new key same name, capability, workspace allowlist, owner and BU; fresh secret, clean last_used_at.
{ ...successorKey,
  "token": "tdt_…",                                  // once, as on create
  "predecessor_id": "…",
  "predecessor_expires_at": "2026-09-02T14:45:01Z" } // deadline for the swap

The window rides the ordinary expires_at check, so the old key stops on its own — there is nothing to call afterwards and no cleanup job. overlap_hours: 0 is an immediate cutover.

Constraints:

  • A rotation never extends a credential's life. If the old key already expires sooner than the requested window, the earlier deadline wins.
  • A key can be rotated once. Chaining would leave several live secrets behind one name; 409 names the successor to rotate instead.
  • Revoked or expired keys are refused with 409, as with regenerate.

rotated_at and superseded_by_id appear on every key in GET /api-keys, so a client can tell "expiring because an admin said so" from "retiring because it was rotated".

DELETE /api-keys/{id} — sets revoked_at; calling it again on an already-revoked key is a no-op 200 (idempotent), not a 404/409.

Using a key: send Authorization: Bearer tdt_…. The key forces its own BU (any X-Business-Unit header is ignored). Capability gates: - read — viewer reads. - plan — trigger plan-only runs (+cancel). - apply — also trigger apply/destroy + approve/reject. - admin — full admin within the BU: everything apply can do plus workspace create / discover / import / update / delete, AWS accounts, Azure subscriptions, clusters, policies, drift, integrations, variables. Use this for CLI-driven workspace discovery and onboarding. ⚠️ Powerful — treat the token like an admin password.

Always interactive-only, even for an admin key: minting/rotating/revoking API keys, user management, and Business-Unit create/update. A key is bound to one BU and can never act across BUs. Requests outside the workspace allowlist → 403; revoked/expired keys → 401.


Policies — /api/v1/policies

BU-scoped OPA/conftest rego rules. The executor enforces them (see the OPA policy gate in /integrations/opa); these endpoints author + test them. Every write snapshots into policy_versions and is audit-logged.

Method Path Description Min role
GET /policies List the BU's policies. viewer
POST /policies Create (snapshots v1). Body: {name, rego, description?, tests_rego?, severity?, enabled?}. admin
POST /policies/test Dry-run vs a plan. Body: {run_id\|plan_json, rego?, rego_name?, rego_severity?, policy_ids?}. → {ok, violations[], warnings[], engine_error?}. operator
POST /policies/verify Run rego unit tests. Body: {rego, tests_rego}. → {ok, passed, failures[], engine_error?}. operator
GET /policies/{id} Policy detail (full rego). viewer
PUT /policies/{id} Edit (bumps version). Body: any subset of the create fields. admin
DELETE /policies/{id} Delete the policy + its history. admin
GET /policies/{id}/versions Revision list (newest first). viewer
POST /policies/{id}/versions/{v}/restore Restore a revision into a new current version. admin

severity is block (fails the run under enforce mode) | warn | info. POST /policies/test requires exactly one plan source (run_id or plan_json); the policy set is the candidate rego and/or policy_ids, else all enabled policies in the BU. Every write requires a concrete X-Business-Unit — 400 if scoped to "all".

Executor-facing: GET /api/v1/runs/{run_id}/policies (operator) returns the merged gate config + enabled policies for the run's BU — see Runs.


Business Units — /api/v1/business-units

Multi-tenancy root. Interactive-only router (API keys are bound to a single BU and can never touch this one, regardless of capability tier).

Method Path Description Min role
GET /business-units List BUs the caller is a member of (superadmin sees all; an API-key caller sees only its own bound BU). any authenticated caller
POST /business-units Create a BU. {slug, name}. 409 if the slug already exists. superadmin
PUT /business-units/{bu_id} Update a BU's name (slug is immutable post-create). superadmin

slug must match ^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$ (lowercase letters/digits/hyphens, 3-64 chars, no leading/trailing hyphen).


AWS Accounts — /api/v1/aws-accounts

Encrypted-at-rest AWS credentials, one row per account per BU.

Method Path Description Min role BU
GET /aws-accounts List configured AWS accounts (masked). viewer BU-scoped
POST /aws-accounts Add an AWS account (creds stored encrypted). admin BU-scoped
PUT /aws-accounts/{account_pk} Update AWS account creds / display name. admin —
DELETE /aws-accounts/{account_pk} Delete an AWS account row. admin —
POST /aws-accounts/{account_pk}/test Probe creds via STS GetCallerIdentity + s3:HeadBucket. admin —
POST /aws-accounts/{account_pk}/bucket Create the account's S3 state bucket if missing (idempotent). admin —

POST /aws-accounts body: {account_id (12-digit), name, description?, state_bucket, state_bucket_region?, default_region?, aws_profile_name?, access_key_id, secret_access_key}. Requires a concrete X-Business-Unit — 400 if scoped to "all". account_id is unique per BU (the same AWS account number may legitimately be registered in two different BUs) — a duplicate within the same BU is 409.

POST .../bucket hardens a freshly created bucket with versioning, AES256 default encryption, and a public-access block; response carries already_existed so callers can tell a no-op from a real creation. {account_pk} in the path is the TDT row id (not the 12-digit AWS account number).


Azure Subscriptions — /api/v1/azure-subscriptions

Encrypted-at-rest Azure Service Principal credentials, mirroring AWS Accounts. Workspaces that target azurerm link one of these; the executor exports ARM_* env vars from the decrypted SP secret at run time.

Method Path Description Min role BU
GET /azure-subscriptions List configured subscriptions (masked). viewer BU-scoped
POST /azure-subscriptions Add a subscription (SP secret stored encrypted). admin BU-scoped
PUT /azure-subscriptions/{sub_pk} Update name/description/location or rotate the client secret. admin —
DELETE /azure-subscriptions/{sub_pk} Delete a subscription row. admin —
POST /azure-subscriptions/{sub_pk}/test Validate the SP creds via an ARM OAuth2 token; also probes the Blob state container when configured. admin —
POST /azure-subscriptions/{sub_pk}/container Create (or verify) the Blob state container using the SP. admin —

POST /azure-subscriptions body: {subscription_id, tenant_id, client_id (all UUIDs), client_secret, name, description?, default_location?, state_storage_account?, state_container?}. Requires a concrete X-Business-Unit. subscription_id is unique per BU — duplicate → 409.

{sub_pk} in the path is the TDT row id (not the Azure subscription GUID). Workspaces under a Git-synced repo at azure/subscription-<guid>/<region>/<stack> auto-link to the matching subscription by GUID on import — no manual picker needed if the subscription is already registered.

Set state_storage_account + state_container to enable Azure Blob as a Terraform state backend for workspaces flagged state_backend=azureblob (state is written via the same SP over AAD — grant it Storage Blob Data Contributor).


GCP Projects — /api/v1/gcp-projects

Encrypted-at-rest GCP service-account keys, mirroring AWS Accounts / Azure Subscriptions. Workspaces that target the google provider link one of these; the executor writes the SA-key JSON to a 0600 file and exports GOOGLE_APPLICATION_CREDENTIALS / GOOGLE_PROJECT / GOOGLE_REGION at run time.

Method Path Description Min role BU
GET /gcp-projects List configured projects (SA key never returned; email shown). viewer BU-scoped
POST /gcp-projects Add a project (SA-key JSON stored encrypted). admin BU-scoped
PUT /gcp-projects/{project_pk} Update name/description/region/state bucket or rotate the SA key. admin —
DELETE /gcp-projects/{project_pk} Delete a project row. admin —
POST /gcp-projects/{project_pk}/test Validate the SA key by minting an access token (google-auth). admin —
POST /gcp-projects/{project_pk}/bucket Create (or verify) the GCS state bucket using the SA key. admin —

POST /gcp-projects body: {project_id, name, description?, default_region?, state_bucket?, state_prefix?, service_account_json}. The JSON is validated structurally (must be a service_account key) and its embedded project_id must match — mismatch → 422. project_id is unique per BU — duplicate → 409. Set state_bucket to enable GCS as a Terraform state backend for workspaces flagged state_backend=gcs. Workspaces at gcp/project-<id>/<region>/<stack> auto-link to the matching project on import. Proxmox workspaces similarly auto-link when at proxmox/cluster-<slug>/<node>/<stack>; discovery stamps aws_account_id="global" and region="global" like other non-AWS paths, and the UI reads the node from the path; state_backend stays s3.


Proxmox VE Clusters — /api/v1/proxmox-clusters

Encrypted-at-rest Proxmox VE API tokens, mirroring the other providers. Workspaces that target bpg/proxmox or Telmate/proxmox link one of these; the executor exports both providers' env-var vocabularies from the one token.

Method Path Description Min role BU
GET /proxmox-clusters List clusters (secret + SSH key never returned; masked tail shown). viewer BU-scoped
POST /proxmox-clusters Add a cluster (API token + optional SSH private key stored encrypted). admin BU-scoped
PUT /proxmox-clusters/{cluster_pk} Update fields, rotate the token secret, set/clear SSH key, TLS flag, CA PEM. Changing endpoint requires api_token_secret in the same request (422 otherwise) and is audited. admin —
DELETE /proxmox-clusters/{cluster_pk} Delete; linked workspaces are unlinked (FK SET NULL). admin —
POST /proxmox-clusters/{cluster_pk}/test Test connection: GET the Proxmox /api2/json/version endpoint with the stored token; honours tls_insecure / ca_cert_pem. Returns {ok, detail, version?}. admin —

POST /proxmox-clusters body: {slug, name, description?, endpoint, api_token_id, api_token_secret, ssh_username?, ssh_private_key?, tls_insecure?, ca_cert_pem?, color?}. slug matches ^[a-z][a-z0-9-]{1,38}[a-z0-9]$ and is unique per BU (409 on duplicate). endpoint must be https:// (trailing /api2/json is stripped). api_token_id is user@realm!tokenid; an = in it is rejected (422) so a pasted id=secret pair never lands in a plaintext column. ssh_private_key requires ssh_username. Responses carry token_secret_masked_tail and has_ssh_key in place of the secrets. Workspaces at proxmox/cluster-<slug>/<node>/<stack> auto-link to the matching cluster on import; state_backend stays s3.

PUT /proxmox-clusters/{cluster_pk}: all fields optional; an omitted or null api_token_secret keeps the stored one. Changing endpoint requires api_token_secret in the same body (422 otherwise) — without that, an admin who never knew the secret could point the cluster at a host they control and have /test or the next run deliver the stored token there. The comparison is made after the same normalisation the schema applies (scheme/host lower-cased, trailing / and /api2/json stripped), so re-submitting the unchanged endpoint is a no-op and needs no secret. An accepted endpoint change writes a proxmox_cluster.endpoint_change audit row carrying slug, business_unit_id, old_endpoint, new_endpoint and the acting user — never the secret.

POST .../test decrypts the stored token in memory and probes the Proxmox version endpoint, and always returns {ok, detail?, version?} — never raises even on network/auth failures. The response includes the version string on success.

Secrets: Responses never include api_token_secret or ssh_private_key — only token_secret_masked_tail (e.g. …5555) and has_ssh_key: true|false.


Kubernetes Clusters — /api/v1/clusters

Encrypted-at-rest kubeconfigs for Helm-kind workspaces (workspace.kind=helm). Mirrors AWS Accounts: BU-scoped CRUD + connectivity test. The kubeconfig is never returned or logged — responses carry only a masked tail.

Method Path Description Min role BU
GET /clusters List clusters (masked). viewer BU-scoped
POST /clusters Register a cluster (kubeconfig stored encrypted). admin BU-scoped
PUT /clusters/{cluster_id} Update name/description/namespace, relink AWS account, or rotate the kubeconfig. admin BU-scoped
DELETE /clusters/{cluster_id} Delete a cluster row. admin BU-scoped
POST /clusters/{cluster_id}/test Validate connectivity via kubectl version. admin BU-scoped

POST /clusters body: {name, description?, server_url?, default_namespace?, aws_account_id?, kubeconfig}. aws_account_id is optional — set it for EKS clusters whose kubeconfig authenticates via the aws eks get-token exec plugin; the test/executor paths then export that account's decrypted creds (+ its default_region) into the subprocess environment. Leave null for non-EKS clusters.

POST .../test writes the decrypted kubeconfig to a private 0600 temp file, runs kubectl version --request-timeout=8s, and always deletes the temp file afterward — the kubeconfig itself is never echoed back in the result, only {ok, detail?, context?} (the resolved kubectl config current-context).


Workspaces — /api/v1/workspaces

A workspace is one Terraform state (or one Helm release) plus its Git-tracked source path. kind is terraform (default) or helm — Helm workspaces target a cluster_id instead of an aws_account_id and skip the S3/HTTP state backend entirely (Helm release state lives in-cluster).

state_backend (s3 default | azureblob | gcs) selects where Terraform state is stored. azureblob requires a linked azure_subscription_id whose state_storage_account/state_container are set; gcs requires a linked gcp_project_id whose state_bucket is set — create/update 422 otherwise. gcp_project_id links the workspace to a GCP project (google provider), the mirror of azure_subscription_id. proxmox_cluster_id links the workspace to a registered Proxmox cluster (same-BU required); state stays s3 for these.

tags is a free-form key/value map ({"team": "payments", "tier": "prod"}). Keys are lowercased on write — Team and team are the same tag — while values keep their case. A workspace carries at most 32; keys are 1–64 chars matching [a-z0-9][a-z0-9._-]*[a-z0-9], values up to 255. Responses always return an object, never null.

Method Path Description Min role BU
GET /workspaces List workspaces. ?tag=team=payments filters by tag — repeatable and AND-ed; a bare ?tag=owner matches any value. viewer BU-scoped
GET /workspaces/tags Every tag key in the BU with its values and usage counts. viewer BU-scoped
POST /workspaces/tags Bulk set/unset tags across many workspaces. operator BU-scoped
GET /workspaces/{id} Get a single workspace. viewer BU-scoped
POST /workspaces Create a workspace (manual). admin BU-scoped
PUT /workspaces/{id} Update workspace (branch override, drift settings, state_aws_account_id, azure_subscription_id, gcp_project_id, proxmox_cluster_id, state_backend, …). admin-tier key or interactive operator+ BU-scoped
POST /workspaces/discover Enumerate importable paths in a Git repo or local mount. admin BU-scoped
POST /workspaces/import Bulk-import workspaces from a discovery result. admin BU-scoped
GET /workspaces/{id}/branches List GitHub branches for the workspace's repo (falls back to free text if no token / non-GitHub remote). viewer BU-scoped
POST /workspaces/{id}/sync Re-check a single workspace's path against its tracked ref now. admin BU-scoped
POST /workspaces/sync Re-check every workspace's path in the current BU. admin BU-scoped
GET /workspaces/{id}/state-lock Report whether a terraform state lock is currently held, and by which run. viewer BU-scoped
DELETE /workspaces/{id}/state-lock Force-release a stuck state lock. Audited (workspace.force_unlock). admin-tier key or interactive operator+ BU-scoped
DELETE /workspaces/{id} Delete a workspace. Rejects git-synced ones with 409 unless ?force=true. With ?delete_state=true, also removes the S3 tfstate (default: retain). admin BU-scoped

Note on PUT and the state-lock DELETE: the route itself only requires operator+ for interactive JWT callers, but both are additionally gated via api_key_service.enforce(..., need="admin", ...), which is a no-op for JWT callers and requires the admin capability tier for API-key callers. In practice: any interactive operator/admin can call them; an automation token needs the admin tier.

POST /workspaces — body

{
  "name": "network-vpc",
  "environment": "prod",
  "aws_account_id": "111111111111",   // required unless kind="helm"
  "region": "us-east-1",
  "repo_url": "https://github.com/example-org/infra.git",  // optional; local:// or omit for manual
  "tf_working_dir": ".",
  "repo_ref": "main",
  "webhook_enabled": false,
  "kind": "terraform",                // "terraform" | "helm"
  "cluster_id": null,                 // required for kind="helm"
  "azure_subscription_id": null       // optional Azure link
}
For kind="helm", cluster_id must reference a cluster in the same BU (400 otherwise) and aws_account_id defaults to the "global" sentinel if omitted. For kind="terraform", aws_account_id is required and must reference an AWS account already registered in the same BU (400 otherwise). An optional azure_subscription_id must belong to the same BU if set (400 otherwise).

A workspace is unique per BU on (aws_account_id, region, environment, tf_working_dir) — violating that identity tuple on create or update returns a clean 409, not a raw 500.

POST /workspaces/discover — body

{
  "repo_url": "https://github.com/example-org/infra.git",  // or local_path (dev-only)
  "ref": "main",
  "username": "x-access-token",   // optional; falls back to configured GitHub PAT
  "token": "ghp_..."               // optional; falls back to Settings → GitHub
}
Returns {repo_url, ref, accounts: [{aws_account_id, regions: {<region>: [{path, name, aws_account_id, region, suggested_environment, has_tf, kind, already_imported}]}}], stack_count, errors}. Each leaf folder containing *.tf (or a Helm Chart.yaml) becomes a candidate. already_imported flags paths that are already workspaces in the current BU so the UI can gray them out.

POST /workspaces/import — body

{
  "repo_url": "https://github.com/example-org/infra.git",
  "ref": "main",
  "entries": [
    {"path": "account-111111111111/us-east-1/network/prod", "name": "network",
     "aws_account_id": "111111111111", "region": "us-east-1", "environment": "prod",
     "kind": "terraform", "cluster_id": null}
  ]
}
→ {created: [WorkspaceResponse, ...], skipped: [{path, reason}, ...]}. Duplicates (same path within the BU) are skipped, not errored. Azure leaves at azure/subscription-<guid>/… auto-link to a matching subscription in the BU by GUID; an unmatched GUID just leaves the workspace unlinked.

Orphan handling — WorkspaceResponse.path_status is ok / orphaned / unknown. A background loop in the API process (default 10 min, configurable via repo_sync.poll_seconds in Runtime Config) shallow-clones each workspace's tracked ref and flips the status to orphaned when tf_working_dir is missing. The UI shows an amber badge and replaces Run/Destroy with a "Force delete" button on those rows. ?force=true plus optional ?delete_state=true on the DELETE is the path for cleaning up the TDT row when the source path was renamed/removed; the actual cloud resources are presumed to have been migrated to the new workspace (or destroyed by hand) — there is no fallback that runs a real terraform destroy on an orphan. A force-delete of a git-synced workspace is audited as workspace.force_delete.

State-backend cred override (state_aws_account_id) — optional column on Workspace. aws_account_id always means "the AWS account whose resources this workspace manages" and is what the dashboard groups by; it's also what the executor uses by default to look up per-account creds for AWS_ACCESS_KEY_ID. For non-AWS workspaces (aws_account_id="global") whose terraform state nonetheless lives in an AWS S3 bucket owned by a different account, set state_aws_account_id to that account's id — the executor uses THAT account's per-account creds for the state backend while the workspace stays grouped under "global" in the tree. PUT /workspaces/{id} accepts the field; the new value (when non-empty) must be a registered AWS account in the workspace's BU or the call 422s. Pass empty string to clear back to "same as aws_account_id".

PUT /workspaces/{id} — passing {"repo_ref": "feat/x"} changes the tracked branch atomically. Useful for the branch_only flow. azure_subscription_id follows the same empty-string-clears / value-relinks convention as state_aws_account_id.

State lock inspection/release — GET .../state-lock → {held, run_id?, acquired_at?}. DELETE .../state-lock force-releases a Postgres advisory lock stuck from a crashed executor; use only when certain no executor is actually running against the workspace, since releasing a live lock could let a second concurrent run race state.


POST /workspaces/tags — body

{
  "workspace_ids": ["<ws-id>", "<ws-id>"],
  "set":   { "team": "payments", "tier": "prod" },  // merged per key
  "unset": ["deprecated"]                           // removed if present
}

→ { "updated": 2, "workspaces": [ …full workspace objects… ] }

set merges rather than replacing the map, so retagging twenty workspaces' team cannot wipe the owner tag one of them happens to carry. To replace a single workspace's tags wholesale, PUT /workspaces/{id} with a tags object.

The batch is all-or-nothing: any id outside the current BU fails the whole request with 404 rather than tagging a subset, because a partially-applied bulk edit leaves you unable to tell which half landed. 400 if neither set nor unset is given, or if a key or value fails validation.

Each affected workspace gets its own AuditLog row (workspace.tags_bulk_edit), so "what happened to workspace X?" stays answerable.

GET /workspaces/tags

[ { "key": "team", "count": 3,
    "values": [ {"value": "payments", "count": 2}, {"value": "ops", "count": 1} ] } ]

Keys sorted alphabetically, values by descending usage. Feeds filter autocomplete, and makes it obvious when two keys have drifted apart.

Workspace variables — /api/v1/workspaces/{id}/variables

Method Path Description Min role
GET …/variables List workspace-scoped variables (secrets masked). viewer
POST …/variables Create a workspace variable. 409 if the key already exists on this workspace. operator
PATCH …/variables/{var_id} Update a workspace variable. operator
DELETE …/variables/{var_id} Delete a workspace variable. operator

Global variables — /api/v1/variables

Method Path Description Min role
GET /variables List global variables (BU-scoped; secrets masked). viewer
POST /variables Create a global variable. 409 if the key already exists. admin
PATCH /variables/{var_id} Update. admin
DELETE /variables/{var_id} Delete. admin

Both routers share the same request/response shape (VariableCreate / VariableUpdate / VariableResponse):

// create
{"key": "instance_type", "value": "t3.medium", "is_secret": false, "is_hcl": false, "description": "..."}
key must match ^[A-Za-z_][A-Za-z0-9_]*$ (Terraform identifier rules) — a mismatch is a 422 before anything is encrypted or written. key is immutable after creation (delete + recreate to rename). Response: non-secret rows return the real value; secret rows always return value: null plus masked_tail (e.g. …ab12) so the UI can confirm which secret is configured without ever re-exposing it. PATCH with value omitted leaves the stored ciphertext untouched — use it to rotate description/is_secret without re-supplying the value.

Variable precedence at run time: global ← workspace ← run (last wins per key).


Runs — /api/v1/runs (+ /api/v1/workspaces/{id}/runs)

Method Path Description Min role
POST /workspaces/{id}/runs Trigger a run (plan/apply/destroy). operator (+ plan or apply API-key tier)
GET /runs List runs, newest first (BU-scoped; API-key callers additionally narrowed to their workspace allowlist). Filters: ?workspace_id=, ?status= (comma-separated, e.g. failed,cancelled; an unknown value is a 400 listing the valid ones), ?limit= (1–1000). No default cap; the Runs page uses /runs/page instead. viewer
GET /runs/page Paged list, newest first: offset (default 0), limit (default 25, max 200), plus workspace_id, status, q (case-insensitive search over run id/command/status/branch, workspace name/env/region/account/path, account name, triggering user's email). Returns {items, total, offset, limit, status_counts}; items omit plan_output, and status_counts ignores the status filter. Same scoping as /runs. viewer
GET /runs/{run_id} Get a run. viewer
PATCH /runs/{run_id} Executor callbacks — status, plan output, plan_json, tfplan_b64, policy_status. Triggers notifications + auto-approve gate on awaiting_approval. operator
POST /runs/{run_id}/heartbeat Executor liveness ping (run_jobs.heartbeat_at). 204, or 404 if there's no picked job for this run. operator
POST /runs/{run_id}/cancel Cancel a run that hasn't reached applying yet. operator (+ plan API-key tier)
GET /runs/{run_id}/plan Raw plan output: {plan_output}. viewer (+ read API-key tier)
GET /runs/{run_id}/tfplan Base64-encoded tfplan binary (executor uses this on apply): {tfplan_b64}. operator
GET /runs/{run_id}/graph Structured {nodes, edges, summary} parsed from plan_json. viewer (+ read API-key tier)
GET /runs/{run_id}/steps Run timeline (Init / Checkov / Plan / etc). ?since=<position> returns only steps at or after that index, and ?include_output=false omits each step's log blob — together they make polling a long apply cheap, since the finished prefix carries the bulk of the bytes. viewer (+ read API-key tier)
PATCH /runs/{run_id}/steps/{step_id} Executor callback to update a single step. operator
GET /runs/{run_id}/policies OPA policy bundle + gate config the executor should enforce for this run. BU is derived from the run's workspace. operator

POST /workspaces/{id}/runs — body

{
  "command": "plan" | "apply" | "destroy",   // default "plan"
  "variables": [{"key": "...", "value": "...", "is_secret": false, "is_hcl": false}],  // per-run additions, optional
  "branch": "feat/x",                         // optional override, persisted to workspace.repo_ref
  "auto_approve_if_no_changes": false,        // see auto-approve section
  "auto_approve_skip_apply": false
}
Response is a RunResponse: {id, workspace_id, triggered_by, reviewer_id, command, status, branch, plan_output, error_output, policy_status, created_at, started_at, completed_at, auto_approve_if_no_changes, auto_approve_skip_apply}.

Run status machine

pending → planning → planned → awaiting_approval → applying → applied, with failed / cancelled reachable from most non-terminal states. Notable guardrails enforced by PATCH /runs/{run_id}: - A PATCH cannot move a run directly into applying — only POST /runs/{id}/approve may do that. - applied is only accepted immediately after applying. - A PATCH that repeats the run's current status is a no-op (idempotent — lets a reconnecting executor retry a heartbeat-adjacent status write safely). - Cancellable source states: pending, running, planning, planned, awaiting_approval. Once applying, a run must complete or fail — it can no longer be cancelled (409 on POST /runs/{run_id}/cancel).

Auto-approve flow

When auto_approve_if_no_changes is true on an apply/destroy run AND the plan succeeds with Checkov + Cost gates green AND plan_json.resource_changes summary is 0/0/0, the API:

  1. Posts a synthetic system approval (audit action="auto_approve", user_id=null, details include the plan summary).
  2. If auto_approve_skip_apply is true, also writes action="auto_apply_skipped" and transitions awaiting_approval → applying → applied without spawning an executor.
  3. Otherwise enqueues an apply job exactly like a human approval would.
  4. Posts a Slack and/or Telegram notification, to whichever of the two the BU has configured. Both fire independently; one channel's outage never suppresses the other.

The flag is silently ignored when command="plan" (no apply phase to approve). All four bot notification kinds (auto-approved, awaiting approval, run failed, drift detected) require the target workspace's BU to have a Slack and/or Telegram integration configured — failures are best-effort and never roll back the run state machine.


Approvals — /api/v1/runs/{run_id} (approve/reject)

Method Path Description Min role
POST /runs/{run_id}/approve Approve a run awaiting approval; queues the apply phase. operator (+ apply API-key tier)
POST /runs/{run_id}/reject Reject a run awaiting approval; transitions to cancelled. operator (+ apply API-key tier)

Body (optional): {"comment": "...optional..."}. 4-eyes approval was revoked — the triggering user may also approve their own run. Any operator+ in the run's BU can approve or reject any run in that BU.


Drift — /api/v1/drift

Per-workspace and per-BU drift summaries, plus report ingestion from the drift-detector service.

Method Path Description Min role
GET /drift/summary Per-BU drift breakdown: latest report per workspace, aggregated. viewer
GET /drift/{workspace_id} Latest drift report for one workspace, with per-resource detail. viewer
POST /drift/{workspace_id}/scan Queue an on-demand drift scan; returns a placeholder report id immediately (the real detector runs async). admin
POST /drift/{workspace_id}/report Submit drift scan results (detector or manual/test). admin

Reports are also accepted at the internal, state-token-authenticated path POST /api/v1/internal/drift/{workspace_id}/report (used by the real drift-detector service); that path fires the per-BU Slack and Telegram bot alerts, but only on a clean→drifted transition (see below). This user-facing endpoint instead fires the legacy global Slack webhook alert (slack.webhook_url) plus email on the same transition — an older mechanism that predates the bot integrations and was not extended to Telegram, so a manual/test report never reaches Telegram. Either path additionally refreshes the cloud-asset Inventory when the report includes an assets[] payload.

POST /drift/{workspace_id}/report body (DriftReportIn):

{
  "workspace_id": "<id>",
  "has_drift": true,
  "summary": "3 resources modified out of band",
  "plan_output": "...",
  "modified_count": 3, "untracked_count": 0, "deleted_count": 0, "mismatch_count": 0,
  "resources": [{"address": "aws_s3_bucket.logs", "type": "aws_s3_bucket", "provider": "aws", "drift_type": "modified", "summary": "..."}],
  "assets": []   // optional; see Inventory
}
drift_type is one of modified | untracked | deleted | mismatch. Posting a report also updates workspace.drift_status to drifted or clean.


Inventory — /api/v1/inventory

Firefly-style cloud asset inventory: a read-side view of every discovered cloud resource and how well Infrastructure-as-Code tracks it. Backed by the cloud_assets table, which the drift-detector refreshes on every scan via the assets[] field of a drift report.

Method Path Description Min role
GET /inventory/summary Headline KPIs: codification %, per-iac_status counts, filter facets. viewer
GET /inventory/assets Filterable, paginated asset list. viewer
GET /inventory/ignore-rules List the current BU's ignore rules. viewer
POST /inventory/ignore-rules Add an ignore rule; immediately reclassifies matching assets to ignored. admin
DELETE /inventory/ignore-rules/{rule_id} Delete an ignore rule (suppressed assets revert on the next scan). admin

iac_status is one of codified | drifted | ghost | unmanaged | ignored | undetermined. Codification % = tracked-by-IaC ÷ (total minus ignored/service_managed), rounded to the nearest integer.

GET /inventory/summary query params (all optional, apply to the counts/codification but not to the facets, which stay BU-global so filter dropdowns never empty out): provider, region, account_id, search (substring match on asset id/address).

GET /inventory/assets — same scope filters plus iac_status, asset_type, and pagination: limit (default 200, max 1000), offset (default 0). Returns {total, items: [...]}.

POST /inventory/ignore-rules body: {match_type: "arn_glob" | "asset_type", pattern, note?}. Requires a concrete X-Business-Unit — 400 if scoped to "all".


Audit — /api/v1/audit

Tamper-evident hash-chained log of every write in the system.

Method Path Description Min role
GET /audit/verify Re-walk the audit hash chain and report integrity. admin
GET /audit List audit entries, optionally filtered by run_id and/or workspace_id. admin

GET /audit/verify query: limit (optional, 1-100000). Returns {ok: true, total: N, broken_at: []} on a clean chain, or the first few offending row ids if a stored hash fails to reproduce — that's the breakpoint for forensic review.

GET /audit — cross-BU (no X-Business-Unit filtering is applied); the only supported filters today are run_id and workspace_id query params. Response: {items: [{id, user_id, action, resource_type, resource_id, workspace_id, details, created_at}, ...]}.


Presence — /api/v1/presence

Cross-BU "who's online" indicator for the top bar. The UI pings every ~30s with the BU slug it currently has selected; the read side returns everyone seen in the last 60 seconds, deliberately across every BU — the entire point is letting an operator in one BU notice someone deploying in another and avoid a concurrent-change collision. Only identity (email, display name, selected BU slug) is exposed — never page contents or run state.

Method Path Description Auth
POST /presence Upsert the caller's presence row. Body: {bu_slug?}. 204. any authenticated user
GET /presence List users active in the last 60s, across all BUs. any authenticated user

Presence rows older than ~10× the window are lazily garbage-collected on each GET.


Governance › Environments. A link pairs the stacks under two repo-tree nodes (account / region / folder / stack, same level, same BU, terraform only) so their differences can be compared and promoted.

Method Path Description Min role
GET /env-links Links in the current BU (all BUs for a superadmin on all), each with its pair_summary counts. Pairs are reconciled against the live workspace set on every read. viewer
POST /env-links Create. Body {name, source_node:{level,path}, target_node:{level,path}, rewrite_rules?, pair_overrides?, protected_rules?}. Omitted protected_rules are seeded with the defaults (env-specific keys + both account ids / ARNs). Requires a specific BU (400 on all); 409 on a duplicate name in the BU; 400 on level mismatch, overlapping or empty nodes, Helm-only nodes, or an invalid regex. BU admin
POST /env-links/preview-pairs Dry-run pairing for candidate nodes/rules (the link builder's live preview). Persists nothing; returns pairs, counts, warnings and the default protected rules for those nodes. viewer
GET /env-links/{id} One link. 404 outside the caller's BU. viewer
PUT /env-links/{id} Partial update. Any change to nodes / rewrite rules / overrides / protected rules bumps rules_version; a rename does not. BU admin
DELETE /env-links/{id} Delete the link and its pairs. Touches no stacks, runs or state. BU admin
GET /env-links/{id}/pairs Pairs with stack names; ?status=a,b filters (unknown → 400). viewer

BU admin = require_bu_admin (app/auth/rbac.py): a superadmin, interactive only — API keys are rejected at every tier. There is no per-BU admin role yet; that function is the single seam to change when one lands.

Pair status: not_compared (matched, no compare yet) · in_sync · diverged · missing_in_target (carries proposed_target_rel) · missing_in_source · excluded (by rule, or an ambiguous path). Link writes are audited as env_link.create|update|delete (resource_type=env_link, rules diff in details.changes) and show in the per-BU audit view.

Environment compare & promotion

Method Path Description Min role
POST /env-links/{id}/compare Queue (re)compares. Body {pair_ids?, direction?: forward\|reverse, force?}. 202 {queued}. viewer
GET /env-pairs/{id}/compare ?direction=. Config + state diff, side refs (branch, commit, state serial, drift), create_preview for missing pairs. 202 (with the stale result, if any) while computing. viewer
POST /env-pairs/{id}/refresh-state {side} — re-read the state TDT holds and recompare (no cloud call). viewer
POST /env-pairs/{id}/refresh-state/run {side} — queue a refresh run (plan -refresh-only → approval → apply). 201 {run_id}. operator
GET /env-pairs/{id}/history Promotions + recent runs on either stack. viewer
POST /env-links/{id}/promotions/preview Body = selection {direction, confirm_reverse?, pairs:[{pair_id, hunk_ids, create_in_target?, target_branch?}], protected_overrides:[{hunk_id, reason}], commit_message?, reason?}. Returns exact file changes per target stack, commits, blockers, warnings, default commit message. BU admin
POST /env-links/{id}/promotions Commit + push + create ordinary apply runs. 201; 200 replayed for an identical selection within 15 min; 409 {detail:{blockers}} if blocked. status=commit_failed + error when the push is refused. BU admin
GET /promotions List (BU-scoped); ?link_id=&limit=. viewer
GET /promotions/{id} Detail: status (derived from runs), commits (+ web_url), per-stack stages (committed/checkov/plan/opa/cost/approval/apply/verified), residual after verify. viewer
POST /promotions/{id}/revert Revert commit(s) on the same branch(es) + ordinary runs. 409 while runs are in flight, if already reverted, or if the promotion created stacks. BU admin
GET /env-links-stack-index {stack_id: [{link_id, link_name, pair_id, side, status}]} for the Dashboard "linked" glyph. viewer
GET /integrations/git-write Per-BU git write access: {enabled, token_configured, token_source, token_tail, username, bot_name, bot_email}. Token never returned. admin
PUT /integrations/git-write Body {enabled?, token?, clear_token?, username?, bot_name?, bot_email?}. BU admin
POST /integrations/git-write/test Push-permission check per repo the BU's stacks use. admin

Promotion runs are plain apply runs (runs.promotion_id set, echoed on RunResponse); they are never auto-approved. RunCreate.command also accepts refresh (terraform only).

The pre-2026 /api/v1/environments endpoints and their per-workspace promote action (fixed dev→staging→prod chain by the environment tag) was removed: it was unused and its runs were never enqueued.


Integrations — /api/v1/integrations

Per-BU third-party credentials and pipeline-gate configuration, stored in the encrypted config table. All admin-write / viewer-or-admin-read, BU-scoped via X-Business-Unit (every route 400s without a concrete BU). Most sub-resources fall back to a legacy global (non-BU) config key for one release if the BU hasn't saved its own value yet — the response's inherited flag tells the UI which case it's in.

GitHub

Method Path Description
GET /integrations/github {configured, token_tail, overridden_by_env, inherited}
PUT /integrations/github Set GitHub PAT. Body: {"token": "ghp_…"}.
DELETE /integrations/github Remove the BU's saved token.
POST /integrations/github/test Probe the token against /user; returns {ok, login, scopes}.

GitHub webhook (per-BU secret + org binding)

Method Path Description
GET /integrations/webhook {bu_slug, configured, secret_tail, github_org, webhook_path}
PUT /integrations/webhook Body: {secret?, github_org?}.

Infracost (cost estimation)

Method Path Description
GET /integrations/infracost {configured, api_key_tail, currency, overridden_by_env, inherited}
PUT /integrations/infracost Body: {api_key?, currency?}. Empty-string api_key removes it.
POST /integrations/infracost/test Pings the Infracost pricing GraphQL endpoint; returns {ok, detail?, organization?}.

Checkov gate

Method Path Description
GET /integrations/checkov {mode: "fail"\|"warn", inherited}
PUT /integrations/checkov Body: {mode}.

OPA policy gate

Method Path Description
GET /integrations/opa {mode: "enforce"\|"warn"\|"off", use_bundled, bundled_severity, git_severity, repo_url, repo_ref, repo_dir, inherited}
PUT /integrations/opa Body: same shape (admin). mode defaults to off.

Terraform modules registry

Method Path Description
GET /integrations/modules {mode: "github"\|"local", upstream_url, local_host_dir, inherited}
PUT /integrations/modules Body: same shape. local mode requires non-empty local_host_dir (422 otherwise).

Default infra repo (Git-import prefill)

Method Path Description
GET /integrations/infra-repo {repo_url, inherited} — not a secret, just a UI convenience default for the Discover form.
PUT /integrations/infra-repo Body: {repo_url}.

Changelog (GitHub Releases / merged PRs feed)

Method Path Description Min role
GET /integrations/changelog {repo, configured, inherited} — the owner/repo the Changelog tab reads from. viewer
PUT /integrations/changelog Body: {repo} in owner/repo form (422 on a malformed value). admin
GET /integrations/changelog/entries Stored changelog entries for the current BU, newest first. Reads only TDT's own DB — never hits GitHub. viewer
POST /integrations/changelog/sync Pull merged PRs from the configured repo, upserting by PR number. 404 if no repo configured; 502 on a GitHub-side failure. admin
POST /integrations/changelog/entries Add a manual changelog entry. Body: {title, body?, url?, entry_date?}. admin
DELETE /integrations/changelog/entries/{entry_id} Delete one entry (manual or synced). admin

Synced entries have source="github" and ref=<PR number>; manual entries have source="manual" and ref=null.

Slack (bot token + channel)

Method Path Description
GET /integrations/slack {configured, token_tail, team_name, channel_id, channel_name, drift_channel_id, drift_channel_name, drift_alerts_enabled}
PUT /integrations/slack Body: {token?, channel_id?, channel_name?}. Verifies the token via Slack auth.test before persisting; 422 if no token is saved yet and none is supplied.
PUT /integrations/slack/drift Drift-alert destination. Body: {drift_channel_id?, drift_channel_name?, drift_alerts_enabled}. Empty drift_channel_id = the default channel; drift_alerts_enabled=false = no drift alerts. Doesn't touch or re-verify the token; 400 if Slack isn't configured.
DELETE /integrations/slack Remove the bot token + channel (and drift routing).
POST /integrations/slack/test Re-verify the saved bot token. Returns {ok, team, bot_user_id, url}.
GET /integrations/slack/channels List the channels the bot can see (conversations.list). Each row has an is_private flag so the Settings UI can render a lock badge.

The bot needs chat:write, plus channels:read to surface public channels and groups:read to surface private ones. The bot must be invited to the target channel before posts will land.

Notifications post on:

  • Auto-approve fired (run was auto-approved on a 0/0/0 plan).
  • Run awaiting approval (plan paused for human review).
  • Run failed (plan / checkov / apply terminal failure).
  • Drift detected (drift report transitioned a workspace to drifted).

Telegram (bot token + chat)

Per-BU, admin-only, same storage and masking rules as Slack. The bot token is encrypted at rest and never returned.

Method Path Notes
GET /integrations/telegram {configured, token_tail, bot_username, chat_id, chat_title, drift_chat_id, drift_chat_title, drift_alerts_enabled}
PUT /integrations/telegram Body: {token?, chat_id?}. Verifies the token via getMe before persisting; 422 if no token is saved yet and none is supplied, if a supplied token (after trimming whitespace) is not BotFather-shaped (<digits>:<30+ of [A-Za-z0-9_-]> — rejected before any Bot API call, since the token is part of the request URL; the rejected value is never echoed), or if chat_id is neither numeric nor a public @username. When chat_id is given it is resolved with getChat and the title cached.
PUT /integrations/telegram/drift Drift-alert destination. Body: {drift_chat_id?, drift_alerts_enabled}. Empty drift_chat_id = the main chat; drift_alerts_enabled=false = no drift alerts. Doesn't touch or re-verify the token; 400 if Telegram isn't configured. Unlike the Slack route (picked from a fetched channel list) the id is typed by hand, so a non-empty one is format-checked (422) and resolved with getChat — 400 if the bot can't see it, 502 if Telegram is unreachable — and its title cached as drift_chat_title.
DELETE /integrations/telegram Remove the bot token, chat, and drift routing.
POST /integrations/telegram/test Re-verify the saved token and re-read the chat. Returns {ok, bot_username, chat_title, detail}.
POST /integrations/telegram/test-message Post a confirmation message to the configured chat. Returns {ok, chat_title, detail}.

There is no channel-listing endpoint: the Telegram Bot API gives a bot no way to enumerate the chats it belongs to. getChat proves the bot can see a chat but not that it may post there — in a channel the bot must be an administrator with the "Post messages" right — which is why test-message exists.

Telegram carries the same four run-lifecycle notification kinds as the Slack bot path (auto-approve fired, run awaiting approval, run failed, drift detected) — see the list above. It parallels the Slack bot path only; the legacy slack.webhook_url mechanism used for the manual/test drift endpoint (see the Drift section) has no Telegram equivalent.


Webhooks — /api/v1/webhooks (public; HMAC-authenticated)

Method Path Description
POST /webhooks/forgejo Forgejo push hook. HMAC-SHA256 via X-Gitea-Signature, validated against the legacy global webhook.secret. Maps branch → environment (main/master/production→prod, staging/stage→staging, else dev) to disambiguate same-named workspaces across environments, then triggers a plan run if webhook_enabled and the branch matches the workspace's tracked ref.
POST /webhooks/github Legacy global GitHub push hook (back-compat). HMAC-SHA256 via X-Hub-Signature-256. Matches every workspace whose repo_url references the pushed repo, filters by webhook_enabled + branch match + (if the payload lists changed files) whether the push touched that workspace's tf_working_dir, and triggers one plan run per remaining match.
POST /webhooks/github/{bu_slug} Per-BU GitHub push hook. Preferred over the legacy path — validates against bu.<slug>.webhook.secret (falling back to the legacy global secret for one release) and only matches workspaces belonging to that BU, so a repo-name collision across BUs can't cross-trigger.

All three always return 202 with a JSON body describing what happened ({status: "accepted"|"ignored", ...}) — a webhook sender never sees a 4xx for "no matching workspace", only for a bad/missing signature (403) or malformed JSON (400). Webhooks only ever create plan runs; applying still requires a human (or auto-approve) approval.


Internal — /api/v1/internal (state-token-auth)

Used by background services (drift-detector, liveness-detector) that run on the same private network as the API and authenticate with the X-Terraducktel-State-Token header instead of a JWT.

Method Path Description
GET /internal/workspaces List every workspace, cross-BU.
POST /internal/drift/{workspace_id}/report Drift report submission (the collector's path). Same body as the user-facing POST /drift/{workspace_id}/report plus drift_checked (default true; false = scan couldn't look, so drift_status is left alone). Posts the per-BU Slack and Telegram bot drift alerts (not the legacy Slack webhook) only on a clean→drifted transition — each to the BU's drift channel/chat (main one if unset), and skipped for a channel whose drift alerts are switched off. Refreshes the Inventory.
GET /internal/workspaces/{workspace_id}/aws-credentials Decrypted AWS creds for a workspace: {access_key_id, secret_access_key, account_id, region}. Honors the state_aws_account_id override, falling back to aws_account_id. Empty strings if the account has no stored credentials.
GET /internal/github-token Plaintext GitHub token for in-network crons that can't decrypt the config table themselves: {token, source: "env"\|"config"\|"none"}.
POST /internal/workspaces/{workspace_id}/auto-delete Cleanup hook used by the liveness detector when a workspace's repo path disappears upstream. Body: {reason}. Deletes the workspace + its runs/drift reports/state locks and audits as auto_delete_orphan. Idempotent (204) on an already-missing workspace.

Runtime config — /api/v1/runtime-config

Admin-tunable worker timings and poll intervals (JWT auth = a superadmin concept in practice, since it's admin+ everywhere and cross-cutting rather than BU-scoped).

Method Path Description Min role
GET /runtime-config Every tunable with its {value, default}. admin
PUT /runtime-config/{key} Update one tunable. Body: {"value": <number>}. 404 for an unknown key, 422 if the value fails validation (e.g. not > 0). admin

Workers pick up a new value within ~60s (the same ConfigService TTL cache used everywhere else) without a container restart. Known tunables include repo_sync.poll_seconds, auth.access_token_expire_minutes, auth.refresh_token_expire_hours — see app/services/runtime_settings.py for the authoritative catalog and defaults.


Terraform state — /api/v1/state/{workspace_id} (state-token-auth)

Implements Terraform's HTTP state backend protocol. Not meant to be called directly — Terraform itself talks to this via the executor's generated backend config.

Method Path Description
GET /state/{workspace_id} Fetch the tfstate body for a workspace. 404 (not 200 + empty JSON) when no state has ever been written — matches the HTTP backend spec's "create on first write" semantics and avoids Terraform misreading an empty body as a corrupted state file. 503 on a real S3/connectivity failure (never silently treated as "no state", which would let apply recreate every existing resource).
POST /state/{workspace_id} Upload an updated tfstate body. Runs a secret scanner over the JSON first; a suspected leaked secret is rejected with 422.
POST /state/{workspace_id}/lock Acquire a Postgres advisory lock for this workspace (TF HTTP backend lock protocol). 409 with Terraform-shaped lock-info JSON if already held.
DELETE /state/{workspace_id}/lock Release the advisory lock. Idempotent — releasing an already-unlocked workspace is 200, not an error; only a genuine holder-id mismatch is 409.

State lives in S3 (LocalStack in dev) at {tf_working_dir}/terraform.tfstate inside the bucket owned by the workspace's (or its state_aws_account_id override's) AWS account. proxmox/… workspaces are additionally prefixed bu-{business_unit_id}/ so two BUs with the same cluster slug never share state. Locking is pg_try_advisory_lock-based, not DynamoDB.


Operational endpoints (unversioned, no auth)

These sit outside /api/v1 and are for probes and scrapers, not clients.

Method Path Description
GET /health Liveness/readiness probe. Used by the compose healthcheck and the ECS target group.
GET /metrics Prometheus text-format counters (request counts and latency sums).
GET /server-info Build/version identification.

They are deliberately unauthenticated so a load balancer can reach them before any credential exists. Do not expose /metrics publicly without a proxy in front of it.

Status codes used everywhere

  • 200 / 201 / 204 — success.
  • 202 — accepted for async processing (webhooks, drift scan trigger).
  • 400 — bad input (e.g. invalid Slack token, invalid Telegram token or chat, channel id missing, BU scoped to "all" where a concrete BU is required).
  • 401 — JWT or API key missing/expired/revoked.
  • 403 — role or API-key capability insufficient, or an API key stepping outside its workspace allowlist, or an identity endpoint rejecting an API key outright.
  • 404 — resource not found (including cross-BU access, which 404s rather than 403s to avoid leaking another tenant's resource existence).
  • 409 — state-machine conflict (e.g. approving/cancelling a run in the wrong state, deleting a git-synced workspace, duplicate account/subscription/workspace identity tuple, regenerating a revoked/expired API key).
  • 422 — schema validation failed (including business-rule validation like an out-of-BU account reference).
  • 502 — upstream integration unreachable (GitHub, Slack, Telegram, Infracost).
  • 503 — state backend (S3) unavailable.

Audit actions you'll see

login, workspace.create, workspace.update, workspace.delete, workspace.force_delete, workspace.force_unlock, workspace.tags_bulk_edit, auto_delete_orphan, run.trigger, approve, auto_approve, auto_apply_skipped, reject, aws_account.create, aws_account.update, integration.github.set, integration.slack.set, api_key.create, api_key.regenerate, api_key.rotate, api_key.revoke, proxmox_cluster.endpoint_change, …

workspace.tags_bulk_edit writes one row per affected workspace rather than one per batch, so a workspace's own audit trail shows every tag change that ever touched it.