# HTTP API

> Deploy any project with a token and curl, without the CLI: the ready-made script, each request, the archive format and every error code.

With a token, everything `sitesolide deploy` does is plain HTTPS to the dashboard. This page is for an agent, or a person, who holds a token but not the CLI: a ready-made script deploys any project, a static site, an app, services or workers, and each request it makes is described below.

## What you need

| Variable | Value |
|---|---|
| `SITESOLIDE_API` | The dashboard's address, `https://dashboard.<zone>`. There is no default; `http://` is refused, but for `localhost`, `127.0.0.1` and `[::1]`. |
| `SITESOLIDE_TOKEN` | A token, `sst_` and 43 more characters, from the owner or minted on the dashboard's **Tokens** page: see [Teammates and tokens](https://sitesolide.fr/docs/tokens.md#create-a-token). |

```bash
export SITESOLIDE_API=https://dashboard.<zone>
```

```bash
read -rs SITESOLIDE_TOKEN && export SITESOLIDE_TOKEN
```

> [!WARNING]
> The token is a password: whoever holds it deploys what it allows. Keep it out of command arguments, logs, the repository's files and the conversation. An agent gets it in its environment from whoever runs it. A token that leaked is revoked on **Tokens**.

## Deploy with the script

[sitesolide.fr/deploy.sh](https://sitesolide.fr/deploy.sh) does exactly what `sitesolide deploy` does with a token. It needs bash, curl, tar (GNU or bsdtar), gzip, find, mktemp and one JSON reader, the first found of jq, node, bun or python3: neither rsync nor the CLI. Download it outside the project's folder, since an app's archive would carry it, and read it before you run it:

```bash
curl -fsSL https://sitesolide.fr/deploy.sh -o ~/deploy.sh
```

GitHub releases carry the same script beside `install.sh`, from the first release that ships it: `https://github.com/cthiriet/sitesolide/releases/latest/download/deploy.sh`. With both variables exported, run it from the project's folder, the one holding `sitesolide.json`:

```bash
bash ~/deploy.sh
```

| Command | Does |
|---|---|
| `bash deploy.sh [folder]` | Deploys the folder holding `sitesolide.json`, the current one by default. |
| `bash deploy.sh --archive <file> [folder]` | Runs the build and writes the archive. Sends nothing, needs no token, runs the project's code all the same. |
| `bash deploy.sh --help` | Prints its usage. |

A deployment exits 0 only once the machine says `succeeded`, and non-zero otherwise; `--archive` and `--help` exit 0 once done. The rest of this page is what the script does, request by request.

## Send the token

Every request carries `Authorization: Bearer <token>`. Hand the header to curl through a file descriptor: `printf` is a bash builtin, so the token is in no process's arguments and no history. `-q`, first, leaves `~/.curlrc` unread, `-g` reads brackets in an address as brackets, and `--proto =https` refuses anything else. The examples below use this function:

```bash
api() { curl -q -sS -g --proto =https -H @<(printf 'Authorization: Bearer %s\n' "$SITESOLIDE_TOKEN") "$@"; }
```

```bash
api "$SITESOLIDE_API/api/v1/whoami"
```

```text
{"identity":{"email":"alice@example.com","scope":{"slugs":["notes"],"create":true,"public":false,"outbound":false,"domain":false,"secrets":true},"owned":[],...}}
```

curl prints the body alone; the statuses below are given in the text, and `-w '%{http_code}'` prints them. Send the token to `SITESOLIDE_API` alone: build every address from it, never from one an answer names, and never follow a redirect.

## Deploy, step by step

1. **Check the folder.** The CLI and the script refuse an app's code folder that holds `node_modules`, `.venv`, `venv` or `__pycache__` at its top, unless `exclude` lists that exact name (`node_modules`, not `/node_modules` or `node_modules/`): the machine installs dependencies itself, with `install`. `source` and `publicDir` stay inside the git repository holding `sitesolide.json`, links resolved. Every name under `secrets.build` must be set, in your environment, or for the CLI in `~/.config/sitesolide/secrets/<slug>.env`: one missing stops here, with nothing sent.
2. **Send the manifest.** It is judged before anything is built.
3. **Run the build**, then check that `publicDir` exists and is not empty.
4. **Build the archive**, then **upload** it.
5. **Follow the log** until the deployment ends.

## Send the manifest

The body is `{"manifest": ...}`, the text of `sitesolide.json` or its object. Every reason it is refused comes back at once, in `details`:

```bash
jq -Rs '{manifest: .}' sitesolide.json | api -H 'Content-Type: application/json' --data-binary @- "$SITESOLIDE_API/api/v1/deployments"
```

It answers 201, with the deployment's `id`:

```text
{"deployment":{"id":"<id>","slug":"<slug>","state":"awaiting-bundle","creating":true,"log":[],"next":0,"error":null,"url":null,"allocated":[],...}}
```

The deployment waits 15 minutes for its archive. A token holds three waiting at most: a fourth replaces the oldest.

## Run the build

The manifest's `build` runs in the code folder, the manifest's `source` or its own folder, in a clean environment: from yours, only what locates your tools, `PATH`, `HOME`, the locale and the like; then the manifest's `env`, `{zone}` and `{contact}` replaced from the deployment's `placeholders`; then each name under `secrets.build`. Never a `SITESOLIDE_` variable, the token among them, and never a value on a command line. It is the project's code and its dependencies', run as you all the same, with what you may read: run only a build you would run yourself. By hand, from the folder holding `sitesolide.json`, keep at least the token out of it:

```bash
build="$(jq -r '.build // empty' sitesolide.json)"
```

```bash
(cd <code folder> && env -u SITESOLIDE_TOKEN sh -c "$build")
```

## Build the archive

A gzip-compressed tar whose only top-level entries are `app/` and `public/`, those the project has:

| Entry | Sent for | Holds |
|---|---|---|
| `app/` | An app: `start` or `services`. | The code folder, minus `exclude`, `.git`, `.env*`, `sitesolide.json` and the `publicDir`, each read as an exclusion pattern. |
| `public/` | A project with `publicDir`. | The `publicDir` folder, minus `.git` and `.env*`. |

Exclusion patterns follow rsync's rules:

- a name without a slash matches at any depth: `node_modules`, `.env*`;
- a pattern with a slash matches the end of the path, on a folder boundary: `cache/tmp`;
- a leading slash anchors it at the root of the folder sent: `/dist`;
- `*`, `?` and `[...]` match within a name, `**` across folders;
- a trailing slash matches folders only: `logs/`.

Copy the trees into a stage outside the project's folder: inside it, the next deployment would carry the stage and the archive. For an app whose `exclude` is `["node_modules"]` and whose `publicDir` is `public`, from its code folder, with one `--exclude` per pattern:

```bash
stage="$(mktemp -d)"
```

```bash
rsync -a --exclude node_modules --exclude .git --exclude '.env*' --exclude sitesolide.json --exclude public ./ "$stage/app/"
```

```bash
rsync -a --exclude .git --exclude '.env*' public/ "$stage/public/"
```

Stage by copying, rsync or `cp -p`, which keep the executable bit and modification times: Caddy derives a file's ETag from its size and date. Never stage by extracting a tar on macOS, which writes non-ASCII names decomposed: `café.txt` would reach the machine as another name than the one the site links to.

Then write the archive in pax, without the workstation's extended attributes, flags or owners. With macOS's tar, bsdtar:

```bash
COPYFILE_DISABLE=1 tar -czf "$stage/bundle.tar.gz" --format=paxr --no-xattrs --no-acls --no-fflags --uid 0 --gid 0 --uname root --gname root -C "$stage" app public
```

With GNU tar:

```bash
tar -czf "$stage/bundle.tar.gz" --format=pax --pax-option=delete=atime,delete=ctime,delete=mtime --owner=0 --group=0 --numeric-owner -C "$stage" app public
```

Name only the trees the project has: `public` alone for a static site, `app` alone without `publicDir`. Treat any warning tar prints as a failure, even when it exits 0: it may have left a file out. Not `--format=ustar`: macOS's tar drops a name over 100 bytes or a path over 255 with a warning, and exits 0. Not a bare `--format=pax` either: macOS's tar writes extended attributes in records that are not UTF-8, which the machine refuses, and both tars give every entry an extended header, which takes the machine's room for 20,000 entries. The forms above write one only where a long or non-ASCII name needs it.

The machine extracts the archive strictly:

- pax, ustar or GNU tar, every pax value in UTF-8;
- regular files and folders only: no symbolic or hard link, device or pipe, so replace a link with the file it points to;
- every path under `app/` or `public/`, a leading `./` dropped: no absolute path, no `..`, no empty or `.` component, no backslash, no file at the top level, each path once;
- a path of 1,024 bytes at most, each component 255;
- 100 MiB compressed, 512 MiB extracted, 20,000 entries, files and folders together.

`bash deploy.sh --archive bundle.tar.gz` writes the archive the script would send, and sends nothing: `tar -tzvf bundle.tar.gz` lists it.

## Upload and follow

```bash
api -X PUT -H 'Content-Type: application/gzip' --data-binary @"$stage/bundle.tar.gz" "$SITESOLIDE_API/api/v1/deployments/<id>/bundle"
```

It answers 202, the deployment `running`. A refused upload may have ended the deployment: read it before sending the archive again. Still `awaiting-bundle`, the dashboard's `busy` with three deployments running on the machine, send it again in a minute, within the 15 minutes. `failed`, a refusal from the machine itself, its own `busy` among them, start again from the manifest: the archive sent again would get 409 `invalid`.

```bash
api "$SITESOLIDE_API/api/v1/deployments/<id>?after=0"
```

```text
{"deployment":{"id":"<id>","state":"succeeded","log":["..."],"next":42,"error":null,"url":"https://<slug>.<zone>/","allocated":[{"service":null,"port":3002}],...}}
```

Print `log`, pass `next` as the next `after`, and ask again about every second, until `state` is `succeeded`, `failed` or `expired`. A dropped connection ends nothing: ask again. Then remove the stage, `rm -rf "$stage"`.

| `state` | Means |
|---|---|
| `awaiting-bundle` | The archive has not arrived. |
| `running` | The machine installs it: keep reading. |
| `succeeded` | Live at `url`. `allocated` lists the ports the machine chose, `service` null for a single `start`: write them into `sitesolide.json`. `missingSecrets` lists the declared names and files the store lacked: a missing secret never fails a deployment. |
| `failed` | `error.code` names the step that stopped it, `error.message` what to do. |
| `expired` | The archive never came within 15 minutes, or a newer deployment replaced this one: start again. |

## Routes

| Route | What it does |
|---|---|
| `GET /api/v1/whoami` | The token's holder, scope and projects. |
| `POST /api/v1/deployments` `{ manifest }` | Judges the manifest, opens a deployment for 15 minutes. |
| `PUT /api/v1/deployments/<id>/bundle` | The archive; starts the installer. |
| `GET /api/v1/deployments/<id>?after=<n>` | `state`, `log` from line `n`, `next`, `error`, `url`, `allocated`. |
| `GET /api/v1/projects` | The projects the token reaches, with their status. |
| `GET /api/v1/projects/<slug>` | One of them. |
| `GET /api/v1/projects/<slug>/logs?lines=<n>&cursor=<c>` | The journal, 100 lines by default and 500 at most, and a `cursor` for the next call. |
| `POST /api/v1/projects/<slug>/restart` | Restarts the service and watches it eight seconds: `verdict.kind` is `active`, `looping` or `failure`. |
| `GET /api/v1/projects/<slug>/store` | The project's secret names, where each value comes from, what is missing, its files and its build's names, never a value. Needs *Manage secrets*. |
| `PATCH /api/v1/projects/<slug>/store` `{ set, remove, restart }` | Sets and removes variables, all or nothing; 201 when it created the store. Needs *Manage secrets*. |
| `POST /api/v1/projects/<slug>/store/generate` `{ name, format, bytes, replace, restart }` | Draws a random value on the server, never returned. Needs *Manage secrets*. |
| `PUT /api/v1/projects/<slug>/store/files/<name>` `{ content, restart }` | Sets a file read whole, its bytes in base64. Needs *Manage secrets*. |
| `DELETE /api/v1/projects/<slug>/store/files/<name>` `{ restart }` | Removes that file. Needs *Manage secrets*. |
| `GET /api/v1/projects/<slug>/access` | General access, people with access, and the domains the token may give. |
| `PUT /api/v1/projects/<slug>/access` `{ who }` | Gives Can open to a company email, or to one of the company's `@domain`s. |
| `DELETE /api/v1/projects/<slug>/access` `{ who }` | Takes a Can open entry off. |

## Set a secret

A value never goes on a command line, and no route reads one back. The token needs *Manage secrets*, and may set any name of the project's store, declared in `sitesolide.json` or not. Read the value without echo, then hand it to `jq` on its standard input:

```bash
IFS= read -rs VALUE
```

```bash
printf '%s' "$VALUE" | jq -Rs '{set: {API_KEY: .}, restart: true}' | \
  api -X PATCH -H 'Content-Type: application/json' --data-binary @- "$SITESOLIDE_API/api/v1/projects/<slug>/store"
```

```bash
unset VALUE
```

It answers 200, or 201 when the change created the store:

```text
{"store":{"slug":"<slug>","variables":[{"name":"API_KEY","source":"store",...}],"missing":[],...},"created":false,"set":["API_KEY"],"generated":[],"removed":[],"absent":[],"files":{"set":[],"removed":[]},"restart":{"verdict":{"kind":"active",...}}}
```

A random key the app checks itself is better drawn on the server, where nobody sees it; a name already set is refused unless `replace`:

```bash
api -H 'Content-Type: application/json' -d '{"name": "SESSION_SECRET"}' "$SITESOLIDE_API/api/v1/projects/<slug>/store/generate"
```

A file goes whole, its bytes in base64, and each service reads it at `$CREDENTIALS_DIRECTORY/files_<name>`:

```bash
base64 < gcp.json | tr -d '\n' | jq -Rs '{content: .}' | \
  api -X PUT -H 'Content-Type: application/json' --data-binary @- "$SITESOLIDE_API/api/v1/projects/<slug>/store/files/gcp.json"
```

Never a value read from the conversation. Names alone carry no secret: `{"remove": ["OLD_KEY"]}` goes on the line. A value holds on one line, 8 KiB at most; a file 64 KiB, and a project 32 files. Removing a name that a secret file from before still holds is refused, since that file's value would come back: the owner moves those files to the store. The route of before, `PATCH /api/v1/projects/<slug>/secrets/<slug>.env`, serves only a project whose manifest still declares `<slug>.env`.

## Errors

Every refusal is `{"error": "<code>", "message": "<what to do>"}`, with `details` listing every reason a manifest is refused, and `wait`, in seconds, when the answer knows how long.

| Code | Status | What to do |
|---|---|---|
| `unauthenticated` | 401 | The token is missing, unknown, expired or revoked: ask the owner, or mint another if it is your own. |
| `out-of-scope` | 403 | The token may not do this, and the message says whom to ask. Never try another token. |
| `reserved` | 403 | The slug belongs to the platform, starts like one of its projects (`dashboard-*`, `portal-*`), or is a removed project's whose backups or connector grants the machine still keeps: pick another. |
| `invalid-manifest` | 422 | Fix every point of `details`, then send the manifest again. |
| `invalid` | 400, 409 | The request is malformed, an access `who` that is neither an email nor a `@domain` or a secret the rules above refuse among them, or the archive went to a deployment that no longer waits for one: the message says which. |
| `not-found` | 404 | No such deployment or project for this token, or a project not deployed yet. |
| `busy` | 409 | A deployment of the project runs, or the machine runs three: try again in a minute, after reading the deployment on an upload. |
| `unmanaged` | 409 | The project's store, one of its files or a secret file from before is not in a form the server rewrites: the owner fixes it on the server. |
| `already-present` | 409 | Generating a name already set: `replace` draws a new one, and the old value stops working at the next restart. |
| `migrate-first` | 409 | The manifest's units would stop reading a secret file from before whose values the store lacks: the owner merges it into the store first, `sitesolide secrets migrate --restart`, then deploy again. |
| `expired` | 410 | The archive came after 15 minutes: start again from the manifest. |
| `too-large` | 413 | A manifest over 64 KiB, or an archive over 100 MiB: exclude dependencies and caches. A change of secrets over 512 KiB: split it. |
| `too-many-attempts` | 429 | Wait `wait` seconds. Without `wait`, the message says what to wait for, the hour's changes of access among them. Never retry in a loop. |
| `not-available` | 503 | The machine does not carry this route yet: tell the owner, who runs `sitesolide upgrade`. |
| `failure` | 500, 502 | Something broke on the machine: tell the owner, with the message. |

A failed deployment's `error.code` names the step that stopped it, with a message that says what to do:

| `error.code` | What to do |
|---|---|
| `invalid-manifest`, `out-of-scope` | Judged again on the machine, against its state and the token's scope at that moment: fix what the message names, or ask the owner. |
| `secret-taken` | The project, in the form of before, would read a secret file not its own: pick another slug, or tell the owner. |
| `store-refused` | The project's store on the server is not a folder root owns: tell the owner, nothing was changed. |
| `store-failed` | The project's store could not be laid on the server, so nothing was served: tell the owner, who runs `sitesolide upgrade`. |
| `store-not-yours` | A first deployment found the project's store made by another's write, never this token's: nothing was served, tell the owner. |
| `taken-meanwhile` | The project was deployed on the server after this token's creation of it was accepted: pick another slug, or ask the owner. |
| `migrate-first` | Judged again on the machine before any unit is laid, as above: nothing was changed. |
| `bundle-refused` | The archive broke a rule above: the message names the entry. |
| `public-empty` | `publicDir` arrived empty: make the build fill it. |
| `install-failed` | `install` failed on the machine: run it in the project's folder and fix what it reports. |
| `port-taken`, `domain-taken` | Drop `port` to let the machine choose one; a domain belongs to one project. |
| `service-failed` | The service did not start, or fell within ten seconds: read its logs. A service that could not load its secret files says so: the owner runs `sitesolide refresh <slug>`. |
| `verify-failed` | `/` must answer 200, or the portal's 401 on a Restricted site. |
| `system-unit` | The slug names a service of the machine: pick another. |
| `caddy-busy`, `access-changed`, `machine-unreadable` | Something changed on the machine meanwhile: deploy again in a minute. |
| `no-port`, `edited-by-hand`, `portal-not-ready`, `caddy-unknown`, `caddy-refused`, `loopback-outdated`, `loopback-failed`, `misconfigured`, `no-result` | Tell the owner, with the message: only they can fix it. |
| Any other | Tell the owner, with the message and the deployment's `id`. |

## What a token may not deploy

| In `sitesolide.json`, or on the machine | Refused unless |
|---|---|
| `portalExempt` | The token may deploy public sites. |
| A static site, with neither `start` nor `services` | The token may deploy public sites. |
| A project public on the machine | The token may deploy public sites. A person's own token deploys it as it stands. |
| `"network": "outbound"`, or `egress` | The token may use outbound network. |
| `domain` | The token may declare a domain. |
| `secrets` in the form of before naming a file other than `<slug>.env`, `lock` | Never. |
| `memory` above `1G`, more than 6 services | Never. |

A manifest without `portal` is not refused: a new project is Restricted unless the token may deploy public sites. A project that already exists keeps the general access the machine carries: it changes from the dashboard, never from a deployment.

## Next steps

- [Teammates and tokens](https://sitesolide.fr/docs/tokens.md): mint a token, and pick its permissions.
- [Manifest reference](https://sitesolide.fr/docs/manifest.md): every key of `sitesolide.json`.
- [Agents](https://sitesolide.fr/docs/agents.md): the CLI's JSON output and its MCP server, when the CLI is there.

Full reference: https://sitesolide.fr/reference/docs/access.md#the-api-for-an-agent-without-the-cli
