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:

HeaderDescription
X-Rate-Limit-LimitMax requests per day
X-Rate-Limit-RemainingRequests remaining today
X-Rate-Limit-ResetSeconds until limit resets

POST /v1/domains

Provision a new custom domain.

Request

FieldTypeRequiredDescription
domainstringyesCustom domain to provision (e.g. "mysite.com")
origin_urlstringyesOrigin URL to proxy to
end_user_emailstringyesEnd user's email for billing
idempotency_keystringnoPrevent 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

ParamDefaultDescription
statusallFilter by status enum
page1Page number
limit50Results 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

FieldTypeRequiredDescription
titlestringyesSite title
slugstringnoURL slug (site lives at /s/<slug>/); auto-generated if omitted
pagesarrayyes[{path, html}] — e.g. {"path": "/", "html": "<html>...</html>"}
assetsarrayno[{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

StatusMeaning
pending_paymentWaiting for end user to pay via claim page
pending_dnsPaid, waiting for DNS records to propagate
pending_sslDNS verified, SSL certificate provisioning
activeDomain is live with SSL
failedProvisioning failed or expired
releasedDomain was released/deleted