# Cloud
URL: https://openship.io/docs/api/cloud.md

Connect installations to Cloud and use its HTTP control endpoints.

import { TypeTable } from 'fumadocs-ui/components/type-table';

Cloud endpoints connect a self-hosted installation to Openship Cloud and support browser handoffs,
managed routing, and transfers. The self-hosted connection routes and Cloud control-plane routes share
`/api/cloud`; the process mode determines which are available.

There is no named native `cloud` group. To manage Cloud projects through the SDK, use the
[remote client](/docs/api/sdk/client) with the canonical Cloud API and Cloud organization ID.
A local owner's Cloud link does not authorize fixed requests or bound/scoped credentials to manage
Cloud-stored records; see
[Cloud compatibility](/docs/api/sdk/compatibility#cloud).

## Self-hosted endpoints

Present only on a self-hosted instance (not in `CLOUD_MODE`). The cloud connection is **org-owned**: it
belongs to the organization owner, so every member sees the same verdict. Disconnect and connect-finalize
require the `owner` role, not merely a `cloud:admin` grant.


### Check connection status

```
GET /api/cloud/status
```

```bash
curl "$OPENSHIP_URL/api/cloud/status" \
  -H "Authorization: Bearer $OPENSHIP_TOKEN"
```

Connecting itself is a browser flow started from the dashboard (**Settings → Cloud**): a popup runs the
PKCE handshake against Openship Cloud, then posts the result to `connect-finalize` below. There is no
dedicated `openship cloud connect` CLI verb.

### Finalize a connection

The dashboard popup reads its PKCE verifier from `localStorage` and posts the returned code here to store
the org's cloud bearer.

```
POST /api/cloud/connect-finalize
```

<TypeTable
  type={{
    code: { type: 'string', description: 'The one-time handoff code returned by the cloud connect flow.', required: true },
    codeVerifier: { type: 'string', description: 'The PKCE verifier matching the code_challenge sent when the flow started.' },
  }}
/>

<Callout title="Owner only" type="warn">
`connect-finalize` and `disconnect` take over the org's cloud bearer, so they are gated by `requireRole("owner")`
on top of the `cloud:admin` tag. A non-owner with a `cloud:admin` grant still gets `403`.
</Callout>

## Cloud control-plane endpoints

Present only in cloud mode (`CLOUD_MODE`) — i.e. these run on Openship Cloud, not on your self-hosted box.
All tenant-scoped operations forward to Oblien through the **caller's namespace token**; Oblien enforces the
namespace boundary natively, so there is no SaaS-side ownership ledger. The public handoff routes are
reached by browser redirects and authenticated by a signed, single-use token in the URL.


<Callout title="These are mostly internal">
Your self-hosted instance calls the control-plane endpoints for you via its cloud client — you don't wire
them up by hand, and there is no `openship` CLI verb that maps to them one-to-one. The request shapes below
are documented so integrators can reason about the traffic.
</Callout>

### Handoff & code exchange

The connect and desktop-login flows never hand the local instance a browser cookie. Instead a short-lived,
PKCE-bound **handoff code** is minted (`connect-authorize`), then swapped for a dedicated session
(`exchange-code`).

```
POST /api/cloud/connect-authorize
```

<TypeTable
  type={{
    redirect: { type: 'string', description: 'The callback URL to return to. Must be HTTPS (localhost exempt).', required: true },
    state: { type: 'string', description: 'Opaque CSRF value (1–256 chars) echoed back on the callback.', required: true },
    codeChallenge: { type: 'string', description: 'PKCE S256 challenge (40–128 url-safe chars) bound to the minted code.', required: true },
  }}
/>

```
POST /api/cloud/exchange-code
```

<TypeTable
  type={{
    code: { type: 'string', description: 'The one-time handoff code from the connect/desktop flow.', required: true },
    code_verifier: { type: 'string', description: 'PKCE verifier matching the code_challenge (required when the code was PKCE-bound).' },
  }}
/>

The public `GET` handoff routes take query params, not a body — `desktop-handoff` and `connect-handoff`
expect `redirect`, `state`, and `code_challenge`; `github/install-callback` expects `installation_id`,
`setup_action`, and `state`; `github/oauth-bridge` expects `token`.

GitHub installation links first open Openship's account selection using `flow=select` and
`state`. The user can reconnect an existing installation or continue to GitHub for a new
one. This also works in the external browser used by self-hosted and desktop instances:
the temporary state binds the choice to its initiating user and workspace without requiring
a Cloud browser session. Discovery and selection recheck access, and the installation claim
uses the same verification and transaction as the dashboard picker.

### Deploy proxies (preflight, edge proxy, analytics)

```
POST /api/cloud/preflight
```

<TypeTable
  type={{
    slug: { type: 'string', description: 'Proposed cloud subdomain slug to check for availability.' },
    customDomain: { type: 'string', description: 'Proposed custom domain to check.' },
  }}
/>

```
POST /api/cloud/edge-proxy
```

<TypeTable
  type={{
    slug: { type: 'string', description: 'The project slug to bind on the shared cloud zone.', required: true },
    target: { type: 'string', description: 'The origin target (IP/host) the proxy should forward to.', required: true },
  }}
/>

```
POST /api/cloud/analytics
```

<TypeTable
  type={{
    operation: { type: '"timeseries" | "requests" | "streamToken"', description: 'Which analytics read to perform.', required: true },
    domain: { type: 'string', description: 'The hostname to read analytics for (must be in the caller\'s namespace).', required: true },
    params: { type: 'Record<string, unknown>', description: 'Operation-specific query parameters passed through to Oblien.' },
  }}
/>

### Dashboard product telemetry

```
POST /api/cloud/telemetry
```

This optional, first-party endpoint accepts only allowlisted dashboard page and checkout-navigation
events. It is separate from the customer traffic analytics relay above and is not an SDK or MCP
operation. It returns `404` unless production Cloud telemetry is explicitly configured.

Requests must come from the configured dashboard Origin, use the browser's cookie session when
signed in, and omit bearer authorization. The server verifies the current session before associating
events with a user or organization. Browser events cannot report payments or other business outcomes;
unrecognized properties and bodies larger than 4 KiB are rejected.

### Pages

`pages` creates a managed static page; the enable/disable/delete actions each take just a slug.

```
POST /api/cloud/pages
```

<TypeTable
  type={{
    workspace_id: { type: 'string', description: "Oblien workspace id (must belong to the caller's namespace).", required: true },
    path: { type: 'string', description: 'Source path/artifact to publish.', required: true },
    name: { type: 'string', description: 'Display name for the page.', required: true },
    slug: { type: 'string', description: 'Subdomain slug; must be free on the shared zone.', required: true },
    domain: { type: 'string', description: 'Optional custom domain to attach.' },
  }}
/>

```
POST /api/cloud/pages/disable   (also /enable, /delete)
```

<TypeTable
  type={{
    slug: { type: 'string', description: 'The page slug to act on.', required: true },
  }}
/>

### Invitation relay

Used when a self-hosted operator sets `invitationMailSource = "cloud"` — the SaaS sends the email from its
own infrastructure. Capped at 20/hour per org inside the service.

```
POST /api/cloud/send-invitation
```

<TypeTable
  type={{
    to: { type: 'string', description: 'Recipient email address.', required: true },
    subject: { type: 'string', description: 'Email subject line.', required: true },
    html: { type: 'string', description: 'HTML body.', required: true },
    text: { type: 'string', description: 'Plain-text body.', required: true },
  }}
/>

### Subgraph transfer & teardown

The ingest/export pair moves an org's or a single project's rows between a self-hosted instance and the SaaS
(team-mode migration and project transfer). Instance-scope dumps are refused.

```
POST /api/cloud/ingest-subgraph
```

<TypeTable
  type={{
    dump: { type: 'DatabaseDump', description: 'The subgraph dump. Its formatVersion must match the SaaS (else 412).', required: true },
    allowNonEmptyTarget: { type: 'boolean', description: 'Permit ingest into a target that already has rows.', default: 'false' },
  }}
/>

```
POST /api/cloud/promote-project
```

Explicit project promotion uses this receipt-aware variant with `{ dump, promotionId }`. The source
persists the transfer ID and original Cloud identity before making the request. Cloud commits the
receipt in the same transaction as the project data. A retry with that receipt returns the original
import counts, allowing the source to retry local cleanup without importing or overwriting again.
Matching project IDs alone do not authorize cleanup; older duplicates without a receipt require
review. Both instances must support this endpoint. An older Cloud API rejects it before importing.

Before local cleanup starts, Openship blocks configuration writes and checks the source snapshot
again. An edit committed before this check preserves the local copy with `TRANSFER_SOURCE_CHANGED`;
edits attempted during cleanup return `PROJECT_TRANSFER_IN_PROGRESS`. Failed cleanup releases this
guard so the project remains editable. New builds admitted after import are preserved, not cancelled
as part of promotion.

Promotion transfers configuration; it does not migrate running workloads or persistent application
data. Deploying a local project on a connected Cloud server uses `serverId` and does not require
promotion. See [the desktop MCP workflow](/docs/api/mcp#deploy-a-desktop-folder-on-cloud).

```
POST /api/cloud/export-subgraph
```

<TypeTable
  type={{
    scope: { type: 'SubgraphScope', description: 'Discriminated scope — { kind: "organization", organizationId } or { kind: "project", projectId }. Defaults to the caller\'s org. "instance" is rejected.' },
  }}
/>

```
POST /api/cloud/teardown-project
```

<TypeTable
  type={{
    projectId: { type: 'string', description: "The project whose SaaS rows to delete (must belong to the caller's org).", required: true },
  }}
/>

### GitHub App proxy

Cloud holds the GitHub App private key; self-hosted instances never do. Local hands off `(userId, request)`
and gets resolved data back — it never signs a JWT or sees the App credentials.

```
POST /api/cloud/github/installation-token
```

<TypeTable
  type={{
    owner: { type: 'string', description: 'The GitHub account/org the installation belongs to.', required: true },
    repos: { type: 'string[]', description: 'Optional repo names to scope the token to.' },
  }}
/>

<Callout title="installationId is never accepted from the body" type="warn">
`installation-token` resolves the installation from the caller's org, not from a client-supplied id — passing
one has no effect. This prevents a caller from minting a token for an installation it doesn't own.
</Callout>

## Errors

<Callout title="403 — owner required (self-hosted connect/disconnect)" type="error">
`connect-finalize` and `disconnect` require the org `owner` role. A member with only a `cloud:admin` grant is
rejected.
</Callout>

<Callout title="409 — slug already taken (edge proxy / pages)" type="warn">
The requested slug is owned by another tenant on the shared cloud zone. Oblien returns `409` and Openship
forwards it. Choose a different slug.
</Callout>

<Callout title="412 — dump format mismatch (ingest)" type="warn">
`ingest-subgraph` rejects dumps whose `formatVersion` doesn't match the SaaS (`code: INGEST_FORMAT_MISMATCH`).
Update your local instance and re-dump.
</Callout>

<Callout title="413 — dump too large (ingest)" type="error">
`ingest-subgraph` caps the request body at 50MB (`code: PAYLOAD_TOO_LARGE`) before auth even runs.
</Callout>

<Callout title="401 — invalid or expired code / session" type="error">
Handoff codes are single-use and time-bound; `exchange-code` returns `401` once consumed or expired. The
control-plane `cloud:*` endpoints return `401` when the cloud session bearer is missing or revoked — reconnect
from the dashboard.
</Callout>

## Operations

{/* api-operations:start */}

### Managed server connections

| Operation | REST API |
| --- | --- |
| Verify the connected Cloud user's administration of a server in the exact requested organization before linking an installation. | `POST /api/cloud/servers/:id/authorize`<br />`server:admin` |
| Issue namespace-scoped server credentials after ownership checks; work admission also verifies the subscription. Credentials stay on the installation's server and must not be exposed to a browser. | `POST /api/cloud/servers/:id/connection`<br />`server:admin` |
| Claim durable server activity for a linked installation and synchronize its project registry before execution; a disconnected claim does not expire. | `POST /api/cloud/servers/:id/activity`<br />`server:admin` |
| Release the same user's exact controller and activity claim after work finishes, and update its registered projects. | `POST /api/cloud/servers/:id/activity/release`<br />`server:admin` |
| Read a durable deletion receipt using billing administration, the original organization and exact operationId after the server row has been removed. | `GET /api/cloud/server-deletions/:id`<br />`billing:admin` |

### HTTP endpoints

| Operation | REST API |
| --- | --- |
| The connected cloud account (name, email, avatar). | `GET /api/cloud/account`<br />`cloud:read` |
| Proxy Oblien analytics for a hostname in the caller's namespace. | `POST /api/cloud/analytics`<br />`cloud:write` |
| Capture optional hosted dashboard product events with exact-origin and verified cookie identity checks. | `POST /api/cloud/telemetry`<br />`Handler authentication` |
| Consent page mints a one-time handoff code (cookie session checked inline). | `POST /api/cloud/connect-authorize`<br />`Handler authentication` |
| Complete the connect popup: exchange the PKCE code and store the cloud bearer. | `POST /api/cloud/connect-finalize`<br />`cloud:admin` · Self-hosted |
| Validate connect params, then 302 to the dashboard consent page. | `GET /api/cloud/connect-handoff`<br />`Handler authentication` |
| Poll the local Cloud connection handoff. | `GET /api/cloud/connect-poll`<br />`Handler authentication` |
| OAuth → one-time code → redirect to the desktop app callback. | `GET /api/cloud/desktop-handoff`<br />`Handler authentication` |
| Revoke the current cloud session server-side (idempotent). | `POST /api/cloud/disconnect`<br />`cloud:admin` |
| Sync the managed edge proxy (bind a slug to a target). | `POST /api/cloud/edge-proxy`<br />`cloud:write` |
| Remove an authorized Cloud edge-proxy route. | `POST /api/cloud/edge-proxy/delete`<br />`cloud:write` |
| Begin verification for an edge-proxy domain. | `POST /api/cloud/edge-proxy/verify`<br />`cloud:write` |
| Check edge-proxy domain verification. | `POST /api/cloud/edge-proxy/verify-check`<br />`cloud:write` |
| Exchange a one-time code for user + session (rate-limited). | `POST /api/cloud/exchange-code`<br />`Handler authentication` |
| Export an org- or project-scope subgraph dump (rate-limited). | `POST /api/cloud/export-subgraph`<br />`cloud:admin` |
| Select an existing GitHub installation with flow=select, or atomically claim the workspace-bound attempt after GitHub user-token and App-JWT verification. | `GET /api/cloud/github/install-callback`<br />`Handler authentication` |
| Build an Openship installation-selection URL with a one-time workspace state; existing installations can reconnect without another GitHub setup callback. | `POST /api/cloud/github/install-url`<br />`cloud:write` |
| Mint a short-lived (~60min) installation access token. | `POST /api/cloud/github/installation-token`<br />`cloud:write` |
| List the org owner's GitHub App installations. | `GET /api/cloud/github/installations`<br />`cloud:read` |
| Consume the bridge token and redirect the browser into GitHub OAuth. | `GET /api/cloud/github/oauth-bridge`<br />`Handler authentication` |
| Start GitHub OAuth: return a one-time bridge URL. | `POST /api/cloud/github/oauth-handoff`<br />`cloud:write` |
| Friendly "GitHub connected" close-window page. | `GET /api/cloud/github/oauth-success`<br />`Handler authentication` |
| Cloud-resolved GitHub OAuth identity (login, avatar) for the caller. | `GET /api/cloud/github/user-status`<br />`cloud:read` |
| Ingest an org- or project-scope subgraph dump (rate-limited, 50MB cap). | `POST /api/cloud/ingest-subgraph`<br />`cloud:admin` |
| Import a project and its promotion receipt atomically, or acknowledge the same original import on retry (rate-limited, 50MB cap). | `POST /api/cloud/promote-project`<br />`cloud:admin` |
| Create a Cloud Page (proxy Oblien `pages.create`). | `POST /api/cloud/pages`<br />`cloud:write` |
| Delete a Cloud Page by slug. | `POST /api/cloud/pages/delete`<br />`cloud:write` |
| Disable a Cloud Page by slug. | `POST /api/cloud/pages/disable`<br />`cloud:write` |
| Enable a Cloud Page by slug. | `POST /api/cloud/pages/enable`<br />`cloud:write` |
| Cloud deployment preflight — slug / custom-domain availability. | `POST /api/cloud/preflight`<br />`cloud:write` |
| Run an allowed Pages or routing action after verifying customer namespace ownership. | `POST /api/cloud/resource-proxy`<br />`cloud:write` |
| Read routes or certificates belonging to the caller's Cloud namespace. | `GET /api/cloud/route-registry`<br />`cloud:read` |
| Relay an invitation email via the SaaS mail infrastructure. | `POST /api/cloud/send-invitation`<br />`cloud:write` |
| Openship Cloud connection status for this instance. | `GET /api/cloud/status`<br />`cloud:read` · Self-hosted |
| Delete a project's rows on the SaaS (bring-home / reconcile). | `POST /api/cloud/teardown-project`<br />`cloud:admin` |
| Mint a namespace-scoped Oblien token for the caller's org. | `POST /api/cloud/token`<br />`cloud:write` |

{/* api-operations:end */}
