# Cloud
URL: https://synquic.tech/docs/api/cloud.md

Connect installations to Cloud and use its HTTP control endpoints.

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

<Callout title="Hosted-cloud only, switched off in Synq" type="warn">
These routes are for connecting to a hosted cloud. The hosted cloud is switched off in Synq, so the dashboard never
offers them and a Synq install has nothing to connect to or pay for. They are documented here because
the routes still exist in the code.
</Callout>

Cloud endpoints connect a self-hosted installation to Synq 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 provide verified mapping for a fixed SDK scope; 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 "$SYNQ_URL/api/cloud/status" \
  -H "Authorization: Bearer $SYNQ_TOKEN"
```

Connecting itself is a browser flow started from the dashboard (**Settings → Cloud**): a popup runs the
PKCE handshake against Synq Cloud, then posts the result to `connect-finalize` below. There is no
dedicated `synq 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 Synq Cloud, not on your self-hosted box.
All tenant-scoped operations forward to Synquic through the **caller's namespace token**; Synquic enforces the
namespace boundary natively, so there is no SaaS-side ownership ledger. The `public` routes carry no session
— they 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 `synq` 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`.

### 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 Synquic.' },
  }}
/>

### 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: "Synquic 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/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. Synquic returns `409` and Synq
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 */}

| Operation | REST API |
| --- | --- |
| The connected cloud account (name, email, avatar). | `GET /api/cloud/account`<br />`cloud:read` |
| Proxy Synquic analytics for a hostname in the caller's namespace. | `POST /api/cloud/analytics`<br />`cloud:write` |
| 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` |
| 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` |
| GitHub install callback — atomically claim a durable user/workspace nonce after matching user-token and Synq App-JWT verification. | `GET /api/cloud/github/install-callback`<br />`Handler authentication` |
| Build the App install URL with a one-time state token. | `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` |
| Create a Cloud Page (proxy Synquic `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` |
| Synq Cloud connection status for this instance. | `GET /api/cloud/status`<br />`cloud:read` |
| Delete a project's rows on the SaaS (bring-home / reconcile). | `POST /api/cloud/teardown-project`<br />`cloud:admin` |
| Mint a namespace-scoped Synquic token for the caller's org. | `POST /api/cloud/token`<br />`cloud:write` |
| List the org's Synq Cloud (Synquic) workspaces, with drift (orphaned cloud/local). | `GET /api/cloud/workspaces`<br />`cloud:read` |

{/* api-operations:end */}
