REST API Reference
All endpoints are under /v1/. Authenticate with X-Platform-API-Key header.
Authentication
Every request must include your platform API key:
X-Platform-API-Key: th_live_your_key_here
Every endpoint also accepts the key as Authorization: Bearer th_live_…, and accepts an OAuth access token issued by /oauth/token (the hosted MCP endpoint's sign-in flow) in the same header — both resolve to the same account.
Error Format
All errors return a structured JSON body:
{
"error": {
"code": "domain_taken",
"message": "The domain mysite.com is already provisioned",
"next_action": "Choose a different domain or check if you own this domain",
"retryable": false
}
}
Rate Limits
Rate limits are per-platform and vary by plan tier. Headers included on every response:
| Header | Description |
|---|---|
X-Rate-Limit-Limit | Max requests per day |
X-Rate-Limit-Remaining | Requests remaining today |
X-Rate-Limit-Reset | Seconds until limit resets |
POST /v1/domains
Provision a new custom domain.
Request
| Field | Type | Required | Description |
|---|---|---|---|
domain | string | yes | Custom domain to provision (e.g. "mysite.com") |
origin_url | string | yes | Origin URL to proxy to |
end_user_email | string | yes | End user's email for billing |
idempotency_key | string | no | Prevent duplicate provisions on retry |
Response (201)
{
"id": "uuid",
"domain": "mysite.com",
"status": "pending_payment",
"dns_instructions": {
"type": "CNAME",
"name": "mysite.com",
"value": "cname.thin.host",
"human_readable": "Set a CNAME record for mysite.com pointing to cname.thin.host"
},
"claim_url": "https://thin.host/claim/abc123...",
"next_action": "Direct the user to the claim_url to complete payment and DNS setup",
"expected_completion_seconds": 300
}
GET /v1/domains/:id
Get full domain status.
Response (200)
{
"id": "uuid",
"hostname": "mysite.com",
"origin_url": "https://myapp.vercel.app",
"status": "active",
"dns_status": "resolved",
"ssl_status": "active",
"payment_status": "paid",
"ready": true,
"created_at": "2026-04-25T00:00:00Z",
"updated_at": "2026-04-25T01:00:00Z"
}
PATCH /v1/domains/:id
Update the origin URL for an existing domain.
Request
{ "origin_url": "https://new-deploy.vercel.app" }
Response (200)
{
"id": "uuid",
"domain": "mysite.com",
"origin_url": "https://new-deploy.vercel.app",
"status": "active",
"updated_at": "2026-04-25T02:00:00Z"
}
DELETE /v1/domains/:id
Release a domain. Removes proxy config and marks as released. Not reversible.
Response (200)
{
"id": "uuid",
"domain": "mysite.com",
"status": "released",
"released_at": "2026-04-25T03:00:00Z"
}
GET /v1/domains
List domains with pagination and filtering.
Query Parameters
| Param | Default | Description |
|---|---|---|
status | all | Filter by status enum |
page | 1 | Page number |
limit | 50 | Results per page (max 100) |
Response (200)
{
"domains": [ ... ],
"total": 42,
"page": 1,
"per_page": 50,
"has_more": false
}
POST /v1/websites
Publish a new hosted website from JSON — the endpoint behind the MCP publish_website tool. Requires an API key created from the dashboard (linked to your account); unlinked platform keys get account_not_linked. The site is live at the returned url immediately.
Request
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | Site title |
slug | string | no | URL slug (site lives at /s/<slug>/); auto-generated if omitted |
pages | array | yes | [{path, html}] — e.g. {"path": "/", "html": "<html>...</html>"} |
assets | array | no | [{path, content_base64, content_type?}] — css/js/images/fonts, base64-encoded |
Limits: 20MB per request, 200 pages, 500 assets — a larger site is published as one POST followed by PATCH batches (the MCP tools do this automatically). Asset types: css, js, mjs, map, png, jpg, jpeg, gif, svg, ico, webp, avif, mp4, webm, mp3, woff, woff2, ttf, otf, eot, wasm, json, xml, txt, csv, md, yaml, yml, pdf, webmanifest. A site with no / page gets a placeholder root. A page at /404 is served, with a 404 status, for any path the site does not have.
Response (201)
{
"id": "uuid",
"slug": "my-site",
"title": "My Site",
"url": "https://thin.host/s/my-site/",
"pages": ["/", "/about"],
"assets": ["/css/style.css"],
"page_count": 2,
"asset_count": 1,
"custom_domains": [],
"next_action": "The site is live at ... — share that URL."
}
POST /v1/websites/archive
Publish a new site from one multipart upload instead of JSON batches — the shell-friendly path (and the hosted MCP endpoint's alternative to a public zip URL). Form fields: file (a .zip of the site/build folder, or a single .html), title, optional slug. Same page/asset mapping as the MCP tools; limit 30MB per upload.
cd build && zip -qr ../site.zip . && cd ..
curl -X POST https://thin.host/v1/websites/archive \
-H "Authorization: Bearer th_live_your_key" \
-F file=@site.zip -F title="My Docs" -F slug=my-docs
Response (201)
As POST /v1/websites, plus skipped_files.
POST /v1/websites/:id_or_slug/archive
Redeploy from one multipart upload. Form fields: file, optional title, sync (default true: everything the archive does not contain is removed afterwards, exactly like /sync with the archive's manifest).
curl -X POST https://thin.host/v1/websites/my-docs/archive \
-H "Authorization: Bearer th_live_your_key" -F file=@site.zip
Response (200)
{ "url": "https://thin.host/s/my-docs/", "updated_pages": ["/", "/docs/intro"], "updated_assets": ["…"],
"removed_pages": [], "removed_assets": ["/assets/js/main.0a1b2c3d.js"], "kept_redirects": [] }
PATCH /v1/websites/:id_or_slug
Update a website — upserts pages/assets by path and can retitle. Paths not included are left untouched.
Request
{
"title": "My Site v2",
"pages": [{ "path": "/", "html": "<h1>Updated</h1>" }],
"assets": [{ "path": "/css/style.css", "content_base64": "..." }]
}
Response (200)
{ "url": "https://thin.host/s/my-site/", "updated_pages": ["/"], "updated_assets": ["/css/style.css"] }
POST /v1/websites/:id_or_slug/sync
Finalize a build-output deploy: delete every page and asset the site has that the manifest does not list. Static-site generators (Docusaurus, Astro, Vite, Next export) rename their hashed chunks on every build, and PATCH only upserts — upload the new build in batches (assets first, then pages), then post the full manifest here and the leftovers are removed. Pages with a dashboard-authored redirect_to are kept and reported in kept_redirects. See Docusaurus & static builds.
Request
{
"keep": {
"pages": ["/", "/docs/intro", "/404"],
"assets": ["/assets/js/main.e7f12ca1.js", "/assets/css/styles.be7ce4c6.css", "/img/logo.svg"]
}
}
keep.pages must be non-empty (empty_manifest otherwise) — to remove a whole site use DELETE.
Response (200)
{
"url": "https://thin.host/s/my-docs/",
"page_count": 3,
"asset_count": 3,
"removed_pages": ["/docs/old-page"],
"removed_assets": ["/assets/js/main.0a1b2c3d.js"],
"kept_redirects": ["/guide/moved"]
}
GET /v1/websites
List your hosted websites, newest first. Supports page and limit (max 100) query parameters.
Response (200)
{
"websites": [ { "id": "...", "slug": "my-site", "url": "https://thin.host/s/my-site/", "page_count": 2 } ],
"total": 1,
"page": 1,
"per_page": 50,
"has_more": false
}
GET /v1/websites/:id_or_slug
Get one website, including its page paths and asset paths.
DELETE /v1/websites/:id_or_slug
Delete a website and all its pages and assets. Not reversible.
Response (200)
{ "deleted": true, "slug": "my-site" }
Domain Status Enum
| Status | Meaning |
|---|---|
pending_payment | Waiting for end user to pay via claim page |
pending_dns | Paid, waiting for DNS records to propagate |
pending_ssl | DNS verified, SSL certificate provisioning |
active | Domain is live with SSL |
failed | Provisioning failed or expired |
released | Domain was released/deleted |