---
title: "v1 REST API"
description: "The frozen native StackShip dashboard contract, authentication rules, idempotency, and streaming operations."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.stackship.run/llms.txt
> Use this file to discover all available pages before exploring further.

# v1 REST API

The native API base URL is:

```text
https://api.stackship.run/v1
```

Its reproducible OpenAPI 3.1 document is served at
`/v1/openapi.json` and committed at
`apps/api/openapi/stackship-v1.json`. Release verification regenerates the
artifact and fails on drift.

OpenTofu uses the separate native cloud compatibility surface under `/api/v2`.
Do not use `/v1` as an OpenTofu backend.

The compatibility surface returns the same `404` JSON:API error document for
missing resources and tenant resources the active credential cannot access,
including run actions. Workspace documents expose `current-run` as the newest
non-terminal run in that workspace; the relationship is `null` when no run is
active. Every compatibility error returns a canonical `X-Request-Id` header
and repeats that exact value in `errors[0].meta.request_id`; StackShip replaces
a missing or invalid client-supplied request ID. Native v1 workspace records
expose `cloud_name` separately from the human `name` and URL-safe `slug`; use
`cloud_name` as the OpenTofu cloud workspace selector. Workspace creation
accepts lowercase URL slugs up to 90 characters so the initialized
`cloud_name` always satisfies the native cloud contract.

## Authentication and tenancy

Native `/v1` product routes require an authenticated session, a selected
active organization, and a live membership. StackShip derives the tenant from that
membership and repeats the organization constraint in every repository query.
A request body, path, or query parameter cannot select an authorization
tenant.

One bearer credential is additionally accepted: an MCP-audience OAuth access
token, presented in the `Authorization` header, authenticates the
[MCP automation surface](/automation/mcp). The API resolves it against its
hashed token record — so revocation applies immediately — requires the
`mcp:tools` scope, the MCP resource audience, and the token's organization
claim, and then applies the same live-membership tenant derivation as a
session. MCP bearer sessions are confined to the published MCP operation
list, need `mcp:write` for every mutation, and never satisfy step-up
reauthentication. A bearer request never falls back to cookie
authentication, and OpenTofu service-account (`sst_`) and run (`rat_`)
tokens remain rejected on `/v1`.

Missing authentication returns `401`; a stale membership or missing
permission returns `403`. Protected operations fail closed when the identity
database is unavailable.

The dashboard calls `/v1` through its same-origin `/api/control-plane` proxy.
That proxy admits only generated-contract method/path pairs, rewrites only the
host dashboard session cookie upstream, rejects unsafe cross-origin mutations,
and does not expose the API session cookie to browser JavaScript.
The production OpenAPI security scheme names the upstream
`__Secure-better-auth.session_token` cookie accepted by
`api.stackship.run`; dashboard integrations should still use the same-origin
proxy instead of handling that cookie directly.

## Implemented operations

The committed contract is the authority. Its operation groups are:

| Area | Operations |
| --- | --- |
| Projects | list, create, and read |
| Workspaces | list, create, read, update exact execution settings and display name |
| Runs | create, organization/workspace lists, read, cancel |
| Review | sanitized evidence with the recorded decision actor, approve, reject, ordered log stream with a bounded tail read, ordinary artifact stream, reason-bearing sensitive artifact download |
| State | immutable workspace history and restricted, reason-bearing raw-state download |
| Variables | metadata list, create, replace (value optional for metadata-only edits), delete |
| Audit | newest sanitized organization events |
| Policy | sets, streaming source publication, immutable versions, workspace attachment inventory and detach, results, soft override |
| GitHub | App install flow, installation status, repository list, source settings, signed webhook receiver |
| Registry | module list, create, read, and delete; source update; tag sync; version and ingestion lists; version deprecate and restore |
| Credentials | workspace profiles, immutable versions, verification, trust template |
| OpenTofu access | service-account creation, one-time token issuance, revocation |
| Toolchains | exact supported OpenTofu version list |

`PATCH /workspaces/{workspaceId}` accepts an optional `name` alongside the
required execution settings. A rename never changes the workspace `slug` or
`cloud_name`, and a display name already used by another workspace in the
organization returns `409 WORKSPACE_NAME_TAKEN` — the same code creation
uses.

`PUT /workspace-variables/{variableId}` accepts an optional `hcl` flag and
makes `value` optional. Omitting `value` keeps the stored value, version,
and digest untouched, so a sensitive value is never re-encrypted or
re-exposed by a description- or hcl-only edit. When `value` is omitted,
`sensitive` must equal the variable's current sensitivity; changing
sensitivity without a new value returns
`400 WORKSPACE_VARIABLE_VALUE_REQUIRED`.

`GET /runs/{runId}/logs` additionally accepts `tail=<n>` (1–100) to return
only the newest `n` sanitized log chunks, still in ascending sequence
order. `tail` is mutually exclusive with `after` and `limit`; the
`X-StackShip-Log-Last-Sequence` header still names the newest returned
sequence so a client can continue forward polling with `after`.

The run review carries a `decision` object once a human decision exists:
`action` (`approved`, `rejected`, or `canceled`), the acting principal's
`actor_id`, a display-safe `actor_name` resolved through the identity
user table (`null` when the principal no longer resolves), and
`decided_at`. An approved run names the approval record's author; a
rejected or canceled run names the user-action signal's actor. Reviews
without a human decision, including approval-deadline expiries, carry
`decision: null`.

`GET /github/installation` reports the active organization's newest
GitHub App installation as `connected`, `suspended`, or `not_installed`,
with its `installation_id`, `account_login`, and `connected_at` when one
exists. Revoked installations read as `not_installed`.

The committed OpenAPI contract includes the `/v1/registry` management
operations for the [private module registry](/registry): modules, repository
sources, tag sync, versions, ingestion history, and version deprecation and
restoration. Module consumption itself uses the separate `modules.v1`
protocol app, not `/v1`.

The following are not available today through native v1: billing,
drift detection, customer-operated runners, general notifications, and
auto-apply. Run creation rejects an `auto_apply` field rather than silently
ignoring it.

## Idempotency

Every state-changing `POST`, `PUT`, `PATCH`, and `DELETE` operation in the
committed native v1 contract requires `Idempotency-Key`, except the signed
GitHub webhook receiver. Sensitive download routes also use `POST` so their
operator reason stays out of the URL, but are read operations and do not use
an idempotency key. They reject every non-empty query string before reading
artifact or state storage; the reason belongs only in the bounded form body. A
key contains 1–255 visible ASCII characters. StackShip hashes the canonical
parsed request and stores the fingerprint with the exact response status and
serialized domain body. An exact retry receives that original result inside a
fresh request envelope; `meta.request_id` identifies the current HTTP attempt
and therefore differs between an initial response and its replay. Reusing the
key with different input returns `409 IDEMPOTENCY_KEY_REUSED`.

A stored key expires 24 hours after it is first reserved. Once it expires the
key no longer replays, and the next request that presents it executes as a new
mutation. Presenting a key that another in-flight request already reserved,
but has not yet completed, returns `409 IDEMPOTENCY_IN_PROGRESS`; retry after
the first request settles.

For generic-secret version creation, the secret environment map is first
reduced to a domain-separated HMAC under the API-only
`IDEMPOTENCY_HMAC_KEY`. The ordinary request fingerprint contains that keyed
value and public metadata, never plaintext or an offline-guessable plaintext
hash. The endpoint returns `503` before sealing or persistence when the key is
missing or weak.

The idempotency reservation, domain mutation, audit evidence, durable outbox
entry when needed, and serialized response commit in one PlanetScale
transaction. A rollback leaves none of them committed. Replaying a completed
public request never calls a credential broker again, mints a second token, or
directly dispatches another Workflow.

The webhook receiver instead uses the signed `X-GitHub-Delivery` value,
provider event identity, and payload digest as its natural retry identity. An
exact delivery replay returns the recorded safe ingestion IDs and does not
dispatch a Workflow from the request path. A changed identity or digest
conflicts.

| Mutation | Retry identity |
| --- | --- |
| `POST /projects` | `Idempotency-Key` |
| `POST /workspaces` | `Idempotency-Key` |
| `PATCH /workspaces/{workspaceId}` | `Idempotency-Key` |
| `POST /runs` | `Idempotency-Key` |
| `POST /runs/{runId}/actions/approve` | `Idempotency-Key` plus exact review binding |
| `POST /runs/{runId}/actions/reject` | `Idempotency-Key` plus exact review binding |
| `POST /runs/{runId}/actions/cancel` | `Idempotency-Key` |
| `POST /runs/{runId}/actions/recover` | `Idempotency-Key`, recent step-up, exact state and emergency evidence |
| `POST /workspaces/{workspaceId}/variables` | `Idempotency-Key` |
| `PUT /workspace-variables/{variableId}` | `Idempotency-Key` |
| `DELETE /workspace-variables/{variableId}` | `Idempotency-Key` |
| `POST /policy-sets` | `Idempotency-Key` |
| `POST /policy-sets/{policySetId}/versions` | `Idempotency-Key` |
| `POST /policy-sets/{policySetId}/attachments` | `Idempotency-Key` |
| `POST /workspaces/{workspaceId}/policy-sets/{policySetId}/versions` | `Idempotency-Key` plus the exact source stream |
| `POST /workspaces/{workspaceId}/policy-attachments` | `Idempotency-Key` |
| `DELETE /workspaces/{workspaceId}/policy-attachments/{attachmentId}` | `Idempotency-Key` |
| `POST /runs/{runId}/policy-overrides` | `Idempotency-Key` |
| `POST /github/installations/start` | `Idempotency-Key` |
| `PUT /workspaces/{workspaceId}/source` | `Idempotency-Key` |
| `POST /registry/modules` | `Idempotency-Key` |
| `DELETE /registry/modules/{moduleId}` | `Idempotency-Key` |
| `POST /registry/modules/{moduleId}/actions/sync` | `Idempotency-Key` |
| `PUT /registry/modules/{moduleId}/source` | `Idempotency-Key` |
| `POST /registry/module-versions/{versionId}/actions/deprecate` | `Idempotency-Key` |
| `POST /registry/module-versions/{versionId}/actions/restore` | `Idempotency-Key` |
| `POST /workspaces/{workspaceId}/credential-profiles` | `Idempotency-Key` |
| `POST /credential-profiles/{profileId}/actions/archive` | `Idempotency-Key` plus an exact empty object |
| `POST /credential-profiles/{profileId}/versions` | `Idempotency-Key` |
| `POST /credential-profiles/{profileId}/versions/{versionId}/actions/revoke` | `Idempotency-Key` plus an exact empty object |
| `POST /credential-profiles/{profileId}/versions/{versionId}/actions/verify` | `Idempotency-Key` |
| `POST /service-accounts` | `Idempotency-Key` |
| `POST /service-accounts/{serviceAccountId}/tokens` | `Idempotency-Key` |
| `DELETE /service-accounts/{serviceAccountId}/tokens/{tokenId}` | `Idempotency-Key` |
| `POST /github/webhooks` | Signed `X-GitHub-Delivery` and payload identity |

Generic-secret profile versions reject any environment value containing a NUL
code point with `CREDENTIAL_SECRET_VALUE_INVALID` before the plaintext reaches
the sealing service. Broker and runner boundaries independently reject the
same invalid value if it crosses an internal boundary.

The GitHub installation callback is a provider-mandated state-changing `GET`.
Its signed, expiring state digest is single use and bound to the organization
and initiating principal.

Approval and rejection additionally carry the current plan, dependency,
predecessor-state, and policy-result binding. A stale binding is rejected
instead of authorizing a changed plan. The review advertises neither action
until that exact binding exists. A closed policy gate may advertise rejection
but never approval; unexpected gate-check failures surface instead of being
reported as ordinary policy blocks.

Apply recovery accepts `current_state_verified` or
`import_emergency_state`, the displayed current state id and digest, the
displayed emergency-state digest when importing, a reason, and
`confirm_fence_advance: true`. It requires both recovery permissions and
recent step-up. A prepared operation is durable and resumes the same recovery
and target-state identities after response loss; changed evidence conflicts.
It leases its principal for 15 minutes. Before the lease expires, no caller can
take it over. After expiry, a separately authorized caller must resubmit the
same resolution and state/emergency evidence with
`confirm_takeover: true`; StackShip reuses the recovery and target-state
identities, atomically fences the previous principal, records bounded takeover
audit evidence, and never retries the apply.
The run review represents an active prepared lease with
`lease_expires_at` and `lease_remaining_seconds`. The remaining value is
derived from the PostgreSQL clock and is always an integer from 0 through 900;
both fields are `null` when no prepared lease is active.
The global apply switch does not block this containment path.

Service-account creation and issuance are separate native operations. The
dashboard retains the non-secret service-account id after a failed issuance so
a retry does not create another account. Issuance requires an explicit
permission subset, optional project/workspace boundaries, and an expiry no more
than 365 days away. The response is the only operation that returns token
plaintext; no service-account or token list operation is exposed in the v1
contract.

Token issuance is safe across response loss without storing bearer plaintext.
The exact authenticated principal, service account, request, and idempotency
key deterministically derive the same token; PlanetScale stores only its
digest and a metadata-only replay body. An exact retry re-derives the same
disclosure. A changed request with the same key conflicts.

### Deferred Workflow delivery

Run, source-ingestion, registry-ingestion, and policy-publication mutations
commit a bounded Workflow-dispatch outbox record before returning. The API attempts immediate
delivery after commit. Failure leaves the public result intact and the outbox
record pending.

Every five minutes, the internal orchestrator calls an authenticated API
reconciler. One tick claims at most 25 due records for 60 seconds. Claims,
completion, and retry release are constrained by organization, dispatch key,
and claim owner. Failures use exponential backoff from five seconds to five
minutes. Workflow IDs are deterministic, and create-response loss is
reconciled through `get` before completion, so a retry cannot create a second
logical Workflow.

## Response boundary

The generated dashboard client sends credentials, propagates the request id,
refuses redirects for JSON operations, and validates exact response schemas.
An `ApiError` retains only status, code, and request id; response bodies,
headers, URLs, and credential material are not retained.

Most successful JSON operations use:

```json
{
  "data": {
"id": "run_example",
"object": "run"
  },
  "meta": {
"request_id": "req_example"
  }
}
```

Errors carry a bounded `error` object and the same request metadata.

### Cursor-backed lists

Organization runs, audit events, and workspace state versions use one opaque
forward-pagination contract. Supply the endpoint's returned `next_cursor` as
the next request's `cursor`; do not inspect it or reuse it with another
endpoint. The optional `limit` is an integer from 1 through 100.

```text
GET /v1/runs?limit=50&cursor=v1.opaque-token
```

These list responses keep the array in `data` and add page metadata:

```json
{
  "data": [],
  "meta": {
"request_id": "req_example",
"page": {
  "next_cursor": null
}
  }
}
```

A non-null `next_cursor` is the only continuation signal. There is no public
`has_more` flag and records do not carry their own cursor. StackShip orders
each list by creation time and a stable identity tie-breaker, so records with
the same timestamp cross page boundaries exactly once. Those ordering columns
are stored at millisecond precision, matching the timestamp carried by the
cursor, so PostgreSQL cannot retain a finer instant that the next-page query
cannot represent. A malformed,
over-sized, outdated, or endpoint-mismatched token returns the endpoint's
`*_LIST_CURSOR_INVALID` error with HTTP 400.

## Streaming limits

Run logs, state files, and run artifacts are not parsed into Worker-sized
buffers. The API and dashboard proxy forward `ReadableStream` bodies with
backpressure.

- State and artifact plaintext is limited to `100_000_000` bytes.
- Configuration archive uploads use the same 100 MB launch ceiling.
- Policy source publication accepts an `application/gzip` `.tar.gz` body and
  uses the same 100 MB ceiling. The dashboard passes the browser `File`
  through unchanged; neither Worker aggregates it in memory.
- Ordinary JSON control documents are limited to `65_536` bytes. Variable
  mutation envelopes are limited to `131_072` bytes so one supported
  `65_536`-byte value still fits with its bounded metadata. Credential-profile
  mutation documents are limited to `100_000` bytes so a supported 64 KiB
  canonical generic-secret environment map fits inside its bounded version
  envelope. Oversized documents receive `413 PAYLOAD_TOO_LARGE`; malformed
  JSON receives `400 VALIDATION_FAILED`.
- Restricted download reasons use a form body bounded to 4 KiB and a
  trimmed reason of 1–500 characters. Raw state additionally requires
  `state:read`; every restricted download requires
  `artifact:read_sensitive`, step-up reauthentication no older than 15
  minutes, and a pre-stream audit record.
- The API rejects over-limit declared sizes and aborts on an over-limit stream.
- R2 plaintext URLs are never returned to the browser.
- A state-download audit record is committed before the response body is
  released.

## Credential profiles

Credential profile versions are immutable and phase scoped. The current
generated contract covers AWS, Azure, and Google Cloud federation profiles,
plus write-only generic environment secrets.

Create the profile with `provider: "generic_secret"`. Create a version with:

```json
{
  "audience": "stackship:generic-secret",
  "phase": "plan_and_apply",
  "configuration": {
"environment": {
  "VENDOR_TOKEN": "write-only-value"
}
  }
}
```

New generic-secret versions require `phase: "plan_and_apply"`. New cloud
federation versions may use `plan_and_apply` or `speculative_plan`. The API
rejects standalone `plan` and `apply` creation with
`400 CREDENTIAL_PROFILE_PHASE_UNSUPPORTED`; generic secrets also reject
`speculative_plan` with that code. The environment map accepts 1–32 safe,
unique names and at most 64 KiB of canonical UTF-8 JSON. Profile creation and
version writes require credential-write authority. Credential-profile
mutation documents are capped at 100,000 bytes before parsing; the difference
between that envelope limit and the 64 KiB map limit is reserved for bounded
profile metadata and JSON framing.

A newly created profile remains a detached draft until a version is verified
and activated as current. Detached drafts are omitted from run snapshots. If a
current version exists but is not verified or does not permit the requested
run, snapshotting fails closed.

Credential writers can archive a profile or revoke an exact version without
deleting its history. Archive sets `archived_at` and detaches the complete
profile. Revoke sets the selected version to `disabled`, records
`disabled_at`, and clears its current marker. The revoke API accepts current
and historical versions for incident response; the dashboard initially
exposes the control only for the current verified version.

Both actions require `credential:write`, an `Idempotency-Key`, and an exact
empty JSON object. A cross-tenant or profile/version-mismatched identifier
returns `404 NOT_FOUND`. A new request against an already transitioned
resource returns `409 CREDENTIAL_PROFILE_ARCHIVED` or
`409 CREDENTIAL_PROFILE_VERSION_DISABLED`. Successful transitions append
`credential_profile.archived` or `credential_profile_version.revoked` audit
events with non-secret identifiers and provider metadata.

Run launch and runner redemption re-read persisted credential lifecycle state.
Consequently, an archive or revoke blocks new snapshots and also makes an
already queued snapshot fail credential issuance or redemption. StackShip does
not delete the immutable version, encrypted envelope, or historical run
evidence.

The create, list, and verify response never echoes the submitted map or its
envelope. A generic version instead has:

```json
{
  "provider": "generic_secret",
  "audience": "stackship:generic-secret",
  "configuration": null,
  "environment_names": ["VENDOR_TOKEN"]
}
```

Verification asks the internal broker to decrypt and validate the stored
envelope before activation. The trust-template operation is not available for
generic profiles. The public API has no secret-read or handle-redemption
operation; redemption is an internal, authenticated runner operation with an
exact-bound, one-time capability.

## Related pages

- [Architecture](/architecture)
- [OpenTofu state and migration](/workspaces/state)
- [GitHub source connections](/workspaces/vcs)
- [Policy sets](/governance/policies)

Source: https://docs.stackship.run/automation/api/index.mdx
