The native API base URL is:
https://api.stackship.run/v1Its 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. 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: 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:
{
"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.
GET /v1/runs?limit=50&cursor=v1.opaque-tokenThese list responses keep the array in data and add page metadata:
{
"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_000bytes. - Configuration archive uploads use the same 100 MB launch ceiling.
- Policy source publication accepts an
application/gzip.tar.gzbody and uses the same 100 MB ceiling. The dashboard passes the browserFilethrough unchanged; neither Worker aggregates it in memory. - Ordinary JSON control documents are limited to
65_536bytes. Variable mutation envelopes are limited to131_072bytes so one supported65_536-byte value still fits with its bounded metadata. Credential-profile mutation documents are limited to100_000bytes so a supported 64 KiB canonical generic-secret environment map fits inside its bounded version envelope. Oversized documents receive413 PAYLOAD_TOO_LARGE; malformed JSON receives400 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 requiresartifact: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:
{
"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:
{
"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.