# REST API reference

The Page Lemon REST API lives at `https://api.pagelemon.com/v1`. Code agents with a shell use it to publish a folder in one command; AI agents should start with the [publishing guide for AIs](/agents) (Markdown).

## Quick start

Publish a folder without an account (it stays online for 72 hours unless you keep it):

```bash
tar -czf - -C dist . | curl -sS --data-binary @- https://api.pagelemon.com/v1/deploy
```

With an [API token](/docs/api-tokens), the site goes straight into your account:

```bash
tar -czf - -C dist . | curl -sS -H "Authorization: Bearer $PAGELEMON_TOKEN" --data-binary @- https://api.pagelemon.com/v1/deploy
```

## Hosts

| Host | Serves |
|---|---|
| `https://pagelemon.com` | This site, `/agents`, `/llms.txt`, `/docs/*` |
| `https://api.pagelemon.com` | REST API under `/v1` |
| `https://mcp.pagelemon.com` | Remote MCP server (streamable HTTP) at `/` (alias `/mcp`) |
| `https://app.pagelemon.com` | Your account: sites, people, tokens, billing |
| `https://<name>.pagelemon.dev` | Your sites |

## Auth

Send credentials as `Authorization: Bearer <token>`.

| Credential | Looks like | Where it comes from | What it can do |
|---|---|---|---|
| Site token | `st_…` | Returned once by an anonymous deploy | Update, read back, roll back and view that one site, until it's claimed. After the claim it only reports the claim (`GET /v1/sites/:name`) |
| Claim link | `https://app.pagelemon.com/claim/ct_…` | Returned once by an anonymous deploy | A signed-in person claims the site |
| API token | `pl_…` | [app.pagelemon.com/account/tokens](https://app.pagelemon.com/account/tokens) (**Account settings** → **API tokens**; see [API tokens](/docs/api-tokens)) | Everything you can do in your account, over REST and MCP |
| OAuth access token | `pla_…` | Issued to an MCP app (Claude.ai, ChatGPT, Claude Code…) after you sign in and approve it | The same as an API token, but **MCP only** (REST answers `401`). Lasts 1 hour and renews itself |

We store only hashes of tokens, so a lost token can't be shown again. Create a new one instead.

## Errors

```json
{ "error": { "code": "conflict", "message": "…" }, "agent_message": "…" }
```

`agent_message` is present when the AI should tell the user something. Pass it on in the user's language, with every link, code, number, date and domain exact; times are UTC, and `*_at` fields have them in ISO.

| Code | Status | Meaning |
|---|---|---|
| `bad_request` | 400 | Bad archive, body or parameter |
| `unauthorized` | 401 | Missing or invalid token |
| `forbidden` | 403 | The token or role can't do this |
| `not_found` | 404 | No such site or file |
| `conflict` | 409 | Stale `X-Base-Revision` / `base_revision` |
| `gone` | 410 | The site expired |
| `too_large` | 413 | Over a size limit |
| `blocked_secret` | 422 | The leak guard found a secret key |
| `limit` | 429 | Plan or rate limit |

## Deploy

### `POST /v1/deploy`: one archive

The request body is the archive: `.tar.gz`, `.tgz`, `.tar` or `.zip`. The server detects the format from the bytes, so `Content-Type` is optional.

```bash
tar -czf - -C <dir> . | curl -sS --data-binary @- https://api.pagelemon.com/v1/deploy
```

| Request | Result |
|---|---|
| No `?site`, no token | **Anonymous create.** A random name such as `brave-otter-4821`, expires after 72 hours unless claimed |
| No `?site`, API token | Create in your account. No expiry |
| `?site=<name>` + `X-Base-Revision: <revision id>` + site token or API token | **Update.** If the live revision isn't `X-Base-Revision`, you get `409 conflict` and nothing is published |

Every deploy replaces the whole site: files you leave out are removed from the live site. If every file sits under one top-level folder (`dist/index.html`, …), that folder is stripped.

Response `201`:

```json
{
  "site": { "name": "brave-otter-4821", "url": "https://brave-otter-4821.pagelemon.dev", "claimed": false, "expires_at": "2026-10-14T14:40:00Z" },
  "revision": { "id": "rev_…", "number": 1, "files": 12, "bytes": 48213 },
  "site_token": "st_…",
  "claim_url": "https://app.pagelemon.com/claim/ct_…",
  "skipped": [".env", "node_modules/…"],
  "agent_message": "Your site is live at …"
}
```

`site_token` and `claim_url` appear only on an anonymous create.

### `POST /v1/deploy` with JSON: inline files

For MCP clients and small sites: send `Content-Type: application/json` to the same endpoint.

```json
{ "site": "brave-otter-4821", "base_revision": "rev_…", "files": [ { "path": "index.html", "content": "<h1>Hi</h1>", "encoding": "utf8" } ] }
```

`encoding` is `utf8` or `base64`. Leave out `site` and `base_revision` to create a new site. The response is the same as above.

**Patch:** add `"patch": true` to send only the changed `files` plus `"deleted": ["old.html"]` on top of `base_revision`; everything else is kept. Same limits and checks; a stale `base_revision` is a `409 conflict`.

### Upload rules

| | Files | Per file | Per site | Expiry |
|---|---|---|---|---|
| No account | 100 | 1 MB | 1 MB | 72 hours unless claimed |
| Free | 500 | 5 MB | 5 MB | none |
| Prompter | 1,000 | 50 MB | 5 GB (all its storage) | none |
| Builder | 2,000 | 50 MB | 25 GB (all its storage) | none |
| Team | 3,000 | 50 MB | 50 GB (all its storage) | none |
| Company | 5,000 | 50 MB | 150 GB (all its storage) | none |

Per site means the whole live site after a deploy; a patch counts the whole resulting site. On paid plans one site can use all of the plan's storage, but one request carries at most 90 MB (40 MB through MCP): send a bigger site as a first deploy, then the rest as patch deploys. Over a limit: `413 too_large` with an `agent_message` to show.

- **Rejected:** symlinks, hard links, devices, absolute paths, `..`, and duplicate paths that differ only in case.
- **Skipped** and listed in `skipped`: `.git/`, `node_modules/`, `.env*`, `*.map`, `*.pem`, `*.key`; dotfiles except `.well-known/`; `.DS_Store`, `Thumbs.db`.
- **Leak guard:** text files are scanned for well-known kinds of secret keys and private keys. A hit returns `422 blocked_secret` with `{file, line, kind}`. The key itself is never returned.
- **Required:** `index.html` at the root.
- **Anonymous only:** executables and installers are blocked, and each network gets a small number of anonymous sites and deploys. Claim or sign in for more.

## Sites

| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | `/v1/sites` | API token | Sites you're a member of |
| GET | `/v1/sites/:name` | site token, member | `{name, url, claimed, expires_at, plan, role, live_revision: {id, number, created_at, created_by}, badge}`. With the site token, poll it after the user opens the claim link: `{claimed: false, …}` or `{claimed: true, account_token?}`; the one call a site token can still make once its site is claimed |
| GET | `/v1/sites/:name/files` | site token, member | `{revision, files: [{path, size, sha256}]}` for the live version |
| GET | `/v1/sites/:name/files/<path>` | site token, member | Raw file content (the read-back), with an `X-Revision` header |
| GET | `/v1/sites/:name/revisions` | site token, member | Newest first. Anonymous: current only. Free: 7 days. Prompter: 90 days. Builder and Team: 1 year. Company: 2 years |
| POST | `/v1/sites/:name/rollback` | site token, editor or owner | `{revision}` makes that version live again, as a new version number |
| POST | `/v1/sites/:name/rename` | editor or owner (account only) | `{name}`: 4–63 characters of `a-z`, `0-9` and `-`. Can't look like a random name (`word-word-1234`), be reserved, or have been used before |
| DELETE | `/v1/sites/:name` | owner | Permanent. With an API token it returns `202` with an `approve_url`: a page where the person types the site address and clicks Delete permanently. `GET /v1/approvals/<token>` then says whether it was deleted |
| GET | `/v1/sites/:name/members` | member | `{members: [{email, role}], invites: [{email, role}]}` |
| POST | `/v1/sites/:name/members` | owner | `{email, role: "editor" or "viewer"}` sends an invite email |
| DELETE | `/v1/sites/:name/members/:email` | owner | Two-step: the first call returns `{confirm_token}`; repeat with `?confirm=<token>` within 5 minutes |

**Roles:** viewers can read the site and its history. Editors can also deploy and roll back. Owners can also invite, rename and delete.

## Account

| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | `/v1/me` | API token | `{email, plan, sites_used, sites_limit}` |

Claiming sites, accepting invites, tokens and billing are handled at [app.pagelemon.com](https://app.pagelemon.com).

## Serving on `pagelemon.dev`

- `/` serves `index.html`. `/about` serves `about.html` or `about/index.html`.
- Missing files get the site's `404.html` if present, otherwise ours.
- Single-page app fallback: if the site has no `404.html` and the path has no file extension, `index.html` is served.
- Headers: `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Cache-Control: public, max-age=60` for HTML and `max-age=300` for other files. Content types come from an allowlist; unknown types are served as `application/octet-stream`.
- Anonymous and Free sites get a small "Live on Page Lemon" badge before `</body>`. It's plain HTML and CSS, with no script, and includes a link to report the site.
- Expired or deleted sites return `410`. Sites taken down for abuse return `451`.

## `.pagelemon.json`

After deploying, agents write this file in the project folder and add it to `.gitignore`, so the next session can update the same site:

```json
{ "site": "brave-otter-4821", "url": "https://brave-otter-4821.pagelemon.dev", "revision": "rev_…", "site_token": "st_…" }
```
