# Projects API
URL: https://openship.io/docs/api/projects.md

Create and configure projects, link a git repo, manage env vars and resources, read logs, deploy an uploaded folder, and transfer projects to and from Openship Cloud.

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

A **project** is the unit Openship builds and runs: a source (a git repo, a local path, or an uploaded
folder), its build configuration, environment variables, resource limits, domains, and deployment history.
In the dashboard this is everything under a project's pages; from the terminal it's the
[`openship projects`](/docs/cli/projects) command.

<Callout title="Base path & auth">
All paths are relative to your instance, under **`/api`** — e.g. `https://your-host/api/projects`.
Send a personal access token as a bearer header (`Authorization: Bearer <token>`), created with
[`openship token create`](/docs/cli/access). The dashboard uses your session cookie instead. See the
[API overview](/docs/api) and [auth model](/docs/security/auth) for details.
</Callout>

<Callout title="Both modes, with a few self-hosted-only routes" type="info">
The Projects API is served on both self-hosted instances and Openship Cloud. On a self-hosted instance,
per-project routes transparently proxy to the SaaS for any project that lives in the cloud. Rows marked
**†** below are **self-hosted only** (local filesystem, edge route rules, folder-upload relay, and
cloud transfer) and return `404` on the SaaS.
</Callout>

## Endpoints

| Method & path | Permission | What it does |
|---|---|---|
| `GET /api/projects` | `project:list` | List projects in the org. |
| `POST /api/projects` | `project:write` | Create a project from a git or local source (build config baked in). |
| `GET /api/projects/home` | `project:list` | Home feed — local + cloud projects merged server-side. |
| `POST /api/projects/ensure` | `project:write` | Create or update the project carrying build config (folder-upload step 3). |
| `GET /api/projects/local` † | `project:list` | List local-filesystem projects. |
| `POST /api/projects/scan` † | `project:write` | Scan a local folder to detect its build config. |
| `POST /api/projects/import` † | `project:write` | Import a local folder as a project. |
| `GET /api/projects/:id` | `project:read` | Get a project — config, source, routes, status. |
| `PATCH /api/projects/:id` | `project:write` | Update a project's configuration. |
| `DELETE /api/projects/:id` | `project:admin` | Delete a project. |
| `GET /api/projects/:id/info` | `project:read` | Detailed info (runtime, build, source). |
| `GET /api/projects/:id/deletion-preview` | `project:read` | Preview what deleting the project would remove. |
| `GET /api/projects/:id/environments` | `project:read` | List environments (production / previews). |
| `POST /api/projects/:id/environments` | `project:write` | Create an environment (e.g. a preview). |
| `POST /api/projects/:id/options` | `project:write` | Set build/deploy options. |
| `POST /api/projects/:id/enable` | `project:write` | Enable the project (allow deploys / bring online). |
| `POST /api/projects/:id/disable` | `project:write` | Disable the project (pause deploys / take offline). |
| `POST /api/projects/:id/routing/retry` | `project:write` | Retry syncing the free `.opsh.io` edge route (no rebuild). |
| `GET /api/projects/:id/env` | `project:read` | List env vars (secret values masked). |
| `PATCH /api/projects/:id/env` | `project:write` | Merge env var changes (upserts + deletes). |
| `GET /api/projects/:id/clone-token` | `project:read` | Get the per-project git clone credential. |
| `PATCH /api/projects/:id/clone-token` | `project:admin` | Set/replace the per-project clone credential. |
| `GET /api/projects/:id/git` | `project:read` | Get the linked git repository info. |
| `GET /api/projects/:id/commit-status` | `project:read` | Compare the deployed commit against remote HEAD. |
| `POST /api/projects/:id/git/link` | `project:write` | Link a git repository to the project. |
| `GET /api/projects/:id/branches` | `project:read` | List the linked repository's branches. |
| `POST /api/projects/:id/auto-deploy` | `project:write` | Enable/disable auto-deploy on push. |
| `POST /api/projects/:id/webhook-domain` | `project:write` | Set the domain used for git webhook delivery. |
| `POST /api/projects/:id/branch` | `project:write` | Set the deploy branch. |
| `GET /api/projects/:id/resources` | `project:read` | Get the CPU/RAM/disk resource config. |
| `PATCH /api/projects/:id/resources` | `project:write` | Update CPU/RAM/disk, sleep mode, or port. |
| `POST /api/projects/:id/resources` | `project:write` | Update resources (POST alias of the PATCH). |
| `POST /api/projects/:id/sleep-mode` | `project:write` | Set sleep mode (`auto_sleep` / `always_on`). |
| `GET /api/projects/:id/deployments` | `project:deployment:list` | List the project's deployments. |
| `POST /api/projects/:id/deployment-session` | `project:read` | Open a read-only deployment/build session. |
| `POST /api/projects/:id/connect` | `project:write` | Connect a custom domain as the primary domain. |
| `GET /api/projects/:id/logs` | `project:read` | Fetch runtime logs (non-streaming). |
| `GET /api/projects/:id/logs/stream` | `project:read` | Stream runtime logs. |
| `GET /api/projects/:id/server-logs/recent` | `project:read` | Recent HTTP request logs. |
| `GET /api/projects/:id/server-logs/stream-token` | `project:read` | Mint a token for the server-log stream. |
| `GET /api/projects/:id/server-logs/stream` | `project:read` | Stream HTTP request logs. |
| `GET /api/projects/:id/route-rules` † | `project:read` | List edge route rules (rate-limit / ban / allow-deny). |
| `POST /api/projects/:id/route-rules` † | `project:write` | Create an edge route rule. |
| `PATCH /api/projects/:id/route-rules/:ruleId` † | `project:write` | Update a route rule. |
| `DELETE /api/projects/:id/route-rules/:ruleId` † | `project:write` | Delete a route rule. |
| `POST /api/projects/folder/session` | `project:write` | Open a folder-upload session (step 1). |
| `POST /api/projects/folder/scan/:sessionId` | `project:write` | Detect the uploaded source's build config (step 2). |
| `POST /api/projects/folder/upload/:sessionId` † | `project:write` | Upload the gzipped tarball (self-hosted relay, 300 MB limit). |
| `POST /api/projects/:id/transfer/to-cloud` † | `project:admin` | Promote a local project to Openship Cloud. |
| `POST /api/projects/:id/transfer/to-self-hosted` † | `project:admin` | Bring a cloud project back to this instance. |

## Create a project

```
POST /api/projects
```

Create a project from a git repo or a local path, baking the build config into the project row. For an
uploaded folder, use the [folder-upload flow](#folder-upload-deploy) instead. Only `name` is required —
everything else is auto-detected or defaulted.

<TypeTable
  type={{
    name: { type: 'string', description: 'Display name (1–100 chars).', required: true },
    slug: { type: 'string', description: 'Override the auto-generated slug (used as the free subdomain slug.opsh.io).' },
    localPath: { type: 'string', description: 'Absolute path to a local source folder (local source).' },
    gitProvider: { type: 'string', description: 'Git provider for a git source.', default: 'github' },
    gitOwner: { type: 'string', description: 'Repo owner / org.' },
    gitRepo: { type: 'string', description: 'Repo name.' },
    gitBranch: { type: 'string', description: 'Branch to deploy.', default: 'main' },
    installationId: { type: 'number', description: 'GitHub App installation id (private repos).' },
    framework: { type: 'string (stack id)', description: 'Stack from the registry, e.g. nextjs, vite, go, django.' },
    packageManager: { type: 'string (enum)', description: 'npm, pnpm, yarn, bun, pip, cargo, …' },
    installCommand: { type: 'string', description: 'Override the install command.' },
    buildCommand: { type: 'string', description: 'Override the build command.' },
    outputDirectory: { type: 'string', description: 'Build output directory (static/SSG).' },
    productionPaths: { type: 'string', description: 'Paths served in production.' },
    rootDirectory: { type: 'string', description: 'Subdirectory to build from.' },
    startCommand: { type: 'string', description: 'Override the runtime start command.' },
    buildImage: { type: 'string', description: 'Build image override.' },
    productionMode: { type: '"host" | "static" | "standalone"', description: 'How the app is served in production.' },
    port: { type: 'number', description: 'Listen port (1–65535).' },
    publicEndpoints: { type: 'PublicEndpoint[]', description: 'Public routes/domains to expose (1–20).' },
    hasServer: { type: 'boolean', description: 'Project runs a long-lived server.', default: 'true' },
    hasBuild: { type: 'boolean', description: 'Project has a build step.', default: 'true' },
    rollbackWindow: { type: 'number', description: 'Deployments kept for instant rollback (0–20).' },
    cloudArchiveStrategy: { type: '"inplace" | "offload"', description: 'Cloud snapshot strategy (only "inplace" is implemented).' },
    projectType: { type: '"app" | "docker" | "services" | "monorepo"', description: 'Project flavor; "monorepo" enables multi-app fields below.' },
    monorepoApps: { type: 'MonorepoApp[]', description: 'Sub-apps discovered inside a monorepo (1–50).' },
    monorepoWorkspace: { type: 'object', description: 'Shared workspace install run once at repo root.' },
    monorepoSharedPaths: { type: 'string[] | null', description: 'Prefixes that force all sub-apps to rebuild; [] clears, null disables.' },
    routingConfig: { type: 'object | null', description: 'Routing parsed from the repo vercel.json.' },
    defaultRollbackStrategy: { type: '"git" | "snapshot"', description: 'Rollback strategy for new deployments.' },
  }}
/>

```bash
curl -X POST https://your-host/api/projects \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"marketing-site","gitOwner":"acme","gitRepo":"site","gitBranch":"main","framework":"nextjs"}'
```

```bash
# Same thing from the CLI:
openship projects create
```

<Callout title="Creating a project does not deploy it" type="info">
`POST /api/projects` only records the project and its config. Trigger the first build with the
[Deployments API](/docs/api/deployments) (`POST /api/deployments`) or `openship deploy`.
</Callout>

## Update a project

```
PATCH /api/projects/:id
```

The update body is a **partial** of the [create body](#create-a-project) — send only the fields you want
to change. Every field above is accepted and optional here.

```bash
curl -X PATCH https://your-host/api/projects/proj_123 \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"buildCommand":"pnpm build","gitBranch":"release"}'
```

```bash
openship projects update proj_123 --build-command "pnpm build"
```

## Link a git repo

```
POST /api/projects/:id/git/link
```

Attach (or re-point) a GitHub repository to an existing project. When `branch` is omitted, the repo's
default branch is used.

<TypeTable
  type={{
    owner: { type: 'string', description: 'Repo owner / org.', required: true },
    repo: { type: 'string', description: 'Repo name.', required: true },
    branch: { type: 'string', description: 'Branch to deploy (defaults to the repo default branch).' },
    installationId: { type: 'number', description: 'GitHub App installation id for a private repo.' },
  }}
/>

```bash
curl -X POST https://your-host/api/projects/proj_123/git/link \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"owner":"acme","repo":"site","branch":"main"}'
```

Related git routes: `POST /api/projects/:id/branch` (change the deploy branch),
`POST /api/projects/:id/auto-deploy` (toggle deploy-on-push), `GET /api/projects/:id/branches`, and
`GET /api/projects/:id/commit-status` (is the deployed commit behind remote HEAD?). See the
[GitHub API](/docs/api/github) for connecting the GitHub App itself.

## Connect a custom domain

```
POST /api/projects/:id/connect
```

A shortcut that adds a domain to the project and marks it primary in one call. For the full domain
lifecycle (DNS preview, verification, SSL), use the [Domains API](/docs/api/domains).

<TypeTable
  type={{
    domain: { type: 'string', description: 'The fully-qualified hostname, e.g. app.example.com.', required: true },
    includeWww: { type: 'boolean', description: 'Also attach the www. variant.' },
    externalIngress: { type: 'boolean', description: 'TLS is terminated upstream; verify by TXT only, issue no certificate.', default: 'false' },
  }}
/>

```bash
curl -X POST https://your-host/api/projects/proj_123/connect \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"domain":"app.example.com"}'
```

## Environment variables

```
GET   /api/projects/:id/env
PATCH /api/projects/:id/env
```

`GET` lists variables for the project with secret values masked. Writes go through a **merge**: only the
keys you name are touched — `upserts` are inserted/updated, `deletes` are removed, and every other
variable (including untouched masked secrets) is left intact.

<TypeTable
  type={{
    environment: { type: '"production" | "preview" | "development"', description: 'Which environment to edit.', required: true },
    upserts: { type: '{ key, value, isSecret? }[]', description: 'Variables to insert or update (0–100). isSecret defaults to false.', required: true },
    deletes: { type: 'string[]', description: 'Variable keys to remove (0–100).', required: true },
  }}
/>

```bash
curl -X PATCH https://your-host/api/projects/proj_123/env \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "environment": "production",
    "upserts": [{"key":"DATABASE_URL","value":"postgres://…","isSecret":true}],
    "deletes": ["OLD_FLAG"]
  }'
```

```bash
openship env set DATABASE_URL="postgres://…" --project proj_123 --secret
```

<Callout title="There is no full-replace endpoint" type="warn">
The old destructive `PUT /:id/env` was removed — it could wipe or corrupt masked secrets. Always send a
diff via the merge `PATCH`.
</Callout>

## Resources

```
GET   /api/projects/:id/resources
PATCH /api/projects/:id/resources
POST  /api/projects/:id/resources
```

Read or update the CPU/RAM/disk allotment (separately for production and build), sleep behavior, and listen
port. `POST` is an alias of `PATCH`. All fields are optional; send only what you want to change.

<TypeTable
  type={{
    production: { type: '{ cpuCores?, memoryMb?, diskMb? }', description: 'Runtime limits. cpuCores 0.25–4, memoryMb 128–8192, diskMb 64–204800.' },
    build: { type: '{ cpuCores?, memoryMb?, diskMb? }', description: 'Build-time limits (same ranges as production).' },
    sleepMode: { type: '"auto_sleep" | "always_on"', description: 'Whether the app sleeps when idle.' },
    port: { type: 'number', description: 'Listen port (1–65535).' },
  }}
/>

```bash
curl -X PATCH https://your-host/api/projects/proj_123/resources \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"production":{"cpuCores":1,"memoryMb":1024},"sleepMode":"always_on"}'
```

Sleep mode also has its own shortcut route: `POST /api/projects/:id/sleep-mode`.

## Create an environment

```
POST /api/projects/:id/environments
```

<TypeTable
  type={{
    environmentName: { type: 'string', description: 'Display name (1–80 chars).', required: true },
    environmentSlug: { type: 'string', description: 'URL-safe slug (lowercase alphanumeric + dashes).' },
    environmentType: { type: '"production" | "preview" | "development"', description: 'Environment kind.' },
    gitBranch: { type: 'string', description: 'Branch this environment tracks.' },
    sourceMode: { type: '"branch" | "manual"', description: 'Deploy from a tracked branch or manually.' },
  }}
/>

## Folder-upload deploy

Deploy a local folder from a browser or client that shares no filesystem with the API. The flow is four
steps; the binary tarball upload happens out-of-band (raw bytes, not JSON).

<Cards>
  <Card title="1. Open a session" href="#folder-upload-deploy">
    `POST /api/projects/folder/session` → returns an `upload` target `{ url, method, headers }`.
  </Card>
  <Card title="2. Upload the tarball">
    POST the gzipped tarball to that `upload.url` with `Content-Type: application/gzip`. On self-hosted this
    relays through `POST /api/projects/folder/upload/:sessionId` (300 MB limit).
  </Card>
  <Card title="3. Scan">
    `POST /api/projects/folder/scan/:sessionId` detects stack, package manager, and build/start commands.
  </Card>
  <Card title="4. Ensure + deploy">
    Feed the scan into `POST /api/projects/ensure` (create/update the project), then deploy via
    `POST /api/deployments/build/access`.
  </Card>
</Cards>

The session body (`POST /api/projects/folder/session`) is a lightweight hint — all fields optional:

<TypeTable
  type={{
    stack: { type: 'string', description: "Stack hint (e.g. 'vite', 'nextjs') to pick the build image." },
    packageManager: { type: 'string', description: 'npm | pnpm | yarn | bun.' },
    name: { type: 'string', description: 'Project name.' },
  }}
/>

`POST /api/projects/ensure` takes the full [create body](#create-a-project) plus an optional `projectId` to
update an existing project in place instead of creating a new one. Set `gitProvider: "upload"` for a
folder-upload project.

## Transfer to / from cloud

```
POST /api/projects/:id/transfer/to-cloud         † self-hosted only
POST /api/projects/:id/transfer/to-self-hosted   † self-hosted only
```

Promote a local project to Openship Cloud, or pull a cloud project back to this instance. These are
meaningless on the SaaS (it *is* the cloud) and return `404` there. Both require `project:admin`.

```bash
curl -X POST https://your-host/api/projects/proj_123/transfer/to-cloud \
  -H "Authorization: Bearer $OPENSHIP_TOKEN"
```

## Errors you might see

<Callout title="400 — validation failed" type="warn">
A field is missing or out of range (e.g. `name` empty, a `cpuCores` above 4, or `git/link` without
`owner`/`repo`). The response names the offending field.
</Callout>

<Callout title="403 — insufficient permission" type="error">
Your token or role lacks the route's tag — deleting needs `project:admin`, writes need `project:write`.
See the [permission model](/docs/security/auth).
</Callout>

<Callout title="404 on a per-project route" type="error">
The `:id` doesn't belong to your organization (or was deleted). A self-hosted-only route (marked **†**)
also returns `404` when called on Openship Cloud. List your projects with `GET /api/projects` to get valid
IDs.
</Callout>
