# Static sites

If your site is already built —by hand, with a generator like Astro, Vite or Hugo, or with an assistant—, you upload it as is and Meteor hosts it at `https://<slug>.metsite.co`. Each upload is a **version** that does not change; rolling back means putting an earlier version online, without uploading anything again.

There are three steps: **create the site** once → **upload a version** every time it changes → **activate it** (by default it goes live on its own when uploaded).

## Before you start

| Scope | What for | With a test key |
|---|---|---|
| `sites:write` | Create the site, upload versions and activate them. | No: it responds `403` `test_mode_restricted`, because it publishes on the internet. |
| `sites:read` | List the versions. | Yes |

Routes are flat (`/sites/…`): they operate on the key's workspace, and a site from another workspace responds `404`.

## 1. Create the site

```ts
const site = await met.sites.createStatic({ name: 'Neuroliderazgo', slug: 'neuroliderazgo' });
// { site_id: 812, slug: 'neuroliderazgo', url: 'https://neuroliderazgo.metsite.co' }
```
```python
site = met.sites.create_static(name="Neuroliderazgo", slug="neuroliderazgo")
```
```bash
curl -X POST https://api.met.meteor.com.co/api/v1/sites/static \
  -H "Authorization: Bearer $METEOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Neuroliderazgo","slug":"neuroliderazgo"}'
```

- `slug` is the subdomain: 3 to 40 characters, lowercase letters, numbers and single hyphens (no two in a row, none at the start or the end). It is **unique across all Meteor sites**: if it is taken it responds `409` `conflict`, and some are reserved by the platform (`400`).
- The site is **born unpublished**: `url` responds once there is an active version.

## 2. Upload a version

From a zip, the simplest way when you already have the built folder (up to 50 MB):

```ts
import { readFile } from 'node:fs/promises';

const zip = await readFile('site.zip');
const version = await met.sites.deployZip(site.site_id, zip);
// { id: '3f6c1e0a-…', status: 'ready', source: 'zip', file_count: 42, total_bytes: 5242880, is_active: true, url: 'https://neuroliderazgo.metsite.co' }
```
```python
with open("site.zip", "rb") as f:
    version = met.sites.deploy_zip(site["site_id"], f.read())
```
```bash
cd dist && zip -r ../site.zip . && cd ..
curl -X POST https://api.met.meteor.com.co/api/v1/sites/812/deploys \
  -H "Authorization: Bearer $METEOR_API_KEY" \
  -F file=@site.zip
```

Or file by file, as JSON: `content` in `utf8` for text and in `base64` for binaries. The whole body has a 10 MB cap; a bigger site goes by zip.

```ts
import { readdir, readFile } from 'node:fs/promises';
import { join, relative, extname } from 'node:path';

const TEXT = new Set(['.html', '.css', '.js', '.mjs', '.json', '.svg', '.txt', '.xml', '.webmanifest', '.map']);

async function filesIn(folder: string, root = folder) {
  const out: { path: string; content: string; encoding: 'utf8' | 'base64' }[] = [];
  for (const entry of await readdir(folder, { withFileTypes: true })) {
    const path = join(folder, entry.name);
    if (entry.isDirectory()) out.push(...(await filesIn(path, root)));
    else {
      const isText = TEXT.has(extname(entry.name));
      const bytes = await readFile(path);
      out.push({
        path: relative(root, path).split('\\').join('/'),
        content: isText ? bytes.toString('utf8') : bytes.toString('base64'),
        encoding: isText ? 'utf8' : 'base64',
      });
    }
  }
  return out;
}

const version = await met.sites.deployFiles(812, { files: await filesIn('dist') });
```
```python
import base64, pathlib

TEXT = {".html", ".css", ".js", ".mjs", ".json", ".svg", ".txt", ".xml", ".webmanifest", ".map"}
root = pathlib.Path("dist")
files = []
for p in root.rglob("*"):
    if p.is_file():
        is_text = p.suffix in TEXT
        files.append({
            "path": p.relative_to(root).as_posix(),
            "content": p.read_text("utf-8") if is_text else base64.b64encode(p.read_bytes()).decode(),
            "encoding": "utf8" if is_text else "base64",
        })

version = met.sites.deploy_files(812, files=files)
```

Each call is a **complete version**: files are not added to the previous one. With `activate: false` (`activate=False` in Python) the version is uploaded without going online, so you can review it or publish it later.

### What is accepted

| Rule | Cap |
|---|---|
| Zip | 50 MB |
| Files per version | 2000 |
| One file, uncompressed | 25 MB |
| The whole version, uncompressed | 200 MB |
| `deployFiles` JSON | 10 MB |
| Length of a path | 512 characters |

- Extensions: `html`, `htm`, `css`, `js`, `mjs`, `map`, `json`, `txt`, `xml`, `svg`, `png`, `jpg`, `jpeg`, `gif`, `webp`, `avif`, `ico`, `woff`, `woff2`, `ttf`, `otf`, `eot`, `mp4`, `webm`, `mp3`, `pdf` and `webmanifest`.
- Paths go without a leading `/`, without `..`, without `\` and without hidden folders (except `.well-known/`). Each part of a path accepts unaccented letters, numbers, spaces and `. _ - @ + ( ) ! , = & $ '`; an accented letter or an `ñ` rejects it. The safest choice: lowercase letters, numbers, `-`, `_` and `.`.
- If everything comes inside a single folder (`dist/`), it is stripped: `dist/index.html` becomes `index.html`.
- `.DS_Store` and `__MACOSX/` are ignored.
- **A single invalid path is enough for nothing to be uploaded.** It responds `400` `deploy_files_rejected` with the list of rejected files in `errors`, each with its `path` and its `reason`.

### How routes are served

- `/` serves `index.html`.
- `/contact` serves `contact.html` or `contact/index.html`.
- A route that does not exist serves your `404.html`, with status 404.
- The `robots.txt` and `sitemap.xml` your site brings are served as is.

## Versions and rollback

```ts
const versions = await met.sites.listDeploys(812, { limit: 10 });
// newest to oldest; is_active: true on the one that is online

const previous = versions.find((v) => !v.is_active && v.status === 'ready');
if (previous) await met.sites.activate(812, previous.id);
```
```python
versions = met.sites.list_deploys(812, limit=10)
previous = next((v for v in versions if not v["is_active"] and v["status"] == "ready"), None)
if previous:
    met.sites.activate(812, previous["id"])
```
```bash
curl -X POST https://api.met.meteor.com.co/api/v1/sites/812/deploys/3f6c1e0a-5b7d-4c2e-9a1f-0d8b7e6a5c4b/activate \
  -H "Authorization: Bearer $METEOR_API_KEY"
```

| `status` | What it is |
|---|---|
| `ready` | Upload complete: it can be activated. |
| `uploading` | Still uploading. |
| `failed` | The upload did not finish; `error` says why. Upload another version. |

`listDeploys` returns 20 by default (from 1 to 100). `activate` only puts online a `ready` version of **that** site; the first activation publishes the site. The change is immediate and nothing is uploaded again.

## Your domain

The site lives at `https://<slug>.metsite.co`. Connecting a custom domain (`www.yourbusiness.com`) is not done through the API.

## From an assistant (MCP)

These operations are reachable with the MCP server's generic tool (`met_call_operation`), in their file-by-file form: the zip does not travel over MCP, because an assistant does not attach binaries in the conversation.

## Common errors

| Code | What happened |
|---|---|
| `400` `validation_error` | The `slug` does not match the format, or it is reserved. |
| `400` `deploy_files_rejected` | Some path or extension is not accepted; check `errors`. Nothing was uploaded. |
| `400` `deploy_zip_missing` | The zip did not arrive in the multipart `file` field. |
| `400` `zip_invalid` | The file is not a valid zip. |
| `400` `deploy_empty` · `deploy_too_many_files` · `deploy_too_large` · `deploy_zip_too_large` | The version has no files or exceeds one of the caps. |
| `413` `FILE_TOO_LARGE` | The zip uploaded via multipart exceeds 50 MB. |
| `403` `test_mode_restricted` | A `met_test_` key does not publish sites. |
| `404` `not_found` · `deploy_not_found` | The site does not belong to this workspace, or the version does not belong to this site. |
| `409` `conflict` | The `slug` is already in use. |
| `409` `site_not_static` | The site is built with blocks in the panel: versions are only for sites created with `createStatic`. |
| `409` `deploy_not_ready` | Tried to activate a version that is not `ready`. |
| `503` `deploy_upload_failed` | The upload failed on our side. The version stays `failed` and the one that was online stays online: upload it again. |
