Static sites
Publish an already built site —HTML, CSS, JS and images— on your metsite.co subdomain, with versions and one-step rollback.
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
const site = await met.sites.createStatic({ name: 'Neuroliderazgo', slug: 'neuroliderazgo' });
// { site_id: 812, slug: 'neuroliderazgo', url: 'https://neuroliderazgo.metsite.co' }site = met.sites.create_static(name="Neuroliderazgo", slug="neuroliderazgo")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"}'slugis 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 responds409conflict, and some are reserved by the platform (400).- The site is born unpublished:
urlresponds 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):
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' }with open("site.zip", "rb") as f:
version = met.sites.deploy_zip(site["site_id"], f.read())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.zipOr 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.
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') });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,pdfandwebmanifest. - 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.htmlbecomesindex.html. .DS_Storeand__MACOSX/are ignored.- A single invalid path is enough for nothing to be uploaded. It responds
400deploy_files_rejectedwith the list of rejected files inerrors, each with itspathand itsreason.
How routes are served
/servesindex.html./contactservescontact.htmlorcontact/index.html.- A route that does not exist serves your
404.html, with status 404. - The
robots.txtandsitemap.xmlyour site brings are served as is.
Versions and rollback
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);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"])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. |