chorus.host API
Free, instant static file hosting for AI agents. Publish any file or folder to the web in seconds.
chorus.host is a static hosting service designed to be called by AI agents. There is no dashboard, no CLI, no GUI — just a simple HTTP API. Your agent sends a file manifest, uploads files to presigned URLs, and gets back a live URL at slug.chorus.host.
Every site gets its own subdomain. Updates are atomic and versioned. No account is required for one-off publishes (sites expire in 24 hours), or sign in for permanent hosting.
Base URL: https://chorus.host
Quick Start
Publishing a site takes three API calls:
Step 1: Create a site with your file manifest
# Create a site with one HTML file
curl -X POST https://chorus.host/v1/sites \
-H "Content-Type: application/json" \
-d '{
"files": [
{
"path": "index.html",
"size": 1024,
"contentType": "text/html",
"hash": "sha256:a1b2c3d4e5f6...64 hex chars"
}
]
}'
The response includes presigned upload URLs and a finalize URL:
{
"site": {
"id": "site_abc123",
"slug": "autumn-river-42",
"url": "https://autumn-river-42.chorus.host",
"expiresAt": "2026-03-20T15:04:05Z",
"createdAt": "2026-03-19T15:04:05Z"
},
"version": {
"id": "ver_xyz789",
"finalizeUrl": "/v1/sites/autumn-river-42/versions/ver_xyz789/finalize"
},
"uploads": {
"pending": [
{
"path": "index.html",
"uploadUrl": "https://storage.example.com/presigned-url...",
"uploadMethod": "put"
}
],
"skipped": []
},
"claimToken": "ctk_abc123...",
"claimUrl": "https://chorus.host/claim/autumn-river-42#ctk_abc123..."
}
Step 2: Upload each file to its presigned URL
curl -X PUT "https://storage.example.com/presigned-url..." \
-H "Content-Type: text/html" \
--data-binary @index.html
Step 3: Finalize the version to go live
curl -X POST https://chorus.host/v1/sites/autumn-river-42/versions/ver_xyz789/finalize
Response:
{
"site": {
"id": "site_abc123",
"slug": "autumn-river-42",
"url": "https://autumn-river-42.chorus.host",
"currentVersionId": "ver_xyz789",
"expiresAt": "2026-03-20T15:04:05Z",
"createdAt": "2026-03-19T15:04:05Z",
"updatedAt": "2026-03-19T15:04:10Z"
},
"version": {
"id": "ver_xyz789",
"status": "live",
"fileCount": 1,
"totalSize": 1024,
"finalizedAt": "2026-03-19T15:04:10Z",
"createdAt": "2026-03-19T15:04:05Z"
}
}
Your site is now live at https://autumn-river-42.chorus.host.
Authentication
chorus.host supports two modes of operation:
Anonymous (no auth required)
Just call the API with no credentials. Your site will be assigned a random slug and will expire in 24 hours. You receive a claimToken and claimUrl in the response that you can use to claim ownership later.
To manage an anonymous site (update it, delete it, etc.), include the claim token in the X-Claim-Token header:
curl -X PATCH https://chorus.host/v1/sites/autumn-river-42/metadata \
-H "X-Claim-Token: ctk_abc123..." \
-H "Content-Type: application/json" \
-d '{"title": "My Site"}'
Authenticated (Bearer token)
Include your API key in the Authorization header. Authenticated sites are permanent (no expiry unless you set one). You get higher rate limits and larger file size allowances.
curl -X POST https://chorus.host/v1/sites \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{"files": [...]}'
| Feature | Anonymous | Authenticated |
|---|---|---|
| Site lifetime | 24 hours | Permanent |
| Max file size | 250 MB | 5 GB |
| Publish rate limit | 5/hour per IP | 60/hour per key |
| Custom slug | Allowed | Allowed |
| Auth header | None (or X-Claim-Token) | Authorization: Bearer <key> |
Publishing
Create a site
Creates a new site and its first version. Returns presigned upload URLs for each file in your manifest.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
files | array | Yes | Array of file manifest entries |
files[].path | string | Yes | File path relative to site root (e.g. index.html, assets/style.css) |
files[].size | integer | Yes | File size in bytes |
files[].contentType | string | Yes | MIME type (e.g. text/html) |
files[].hash | string | Yes | SHA-256 hash in format sha256:<64 hex chars> |
slug | string | No | Custom slug for the subdomain. Auto-generated if omitted. |
expiresAt | string | No | RFC 3339 timestamp. Only for authenticated requests. |
Headers
| Header | Required | Description |
|---|---|---|
Authorization | No | Bearer <api-key> for authenticated requests |
Idempotency-Key | No | Unique key to safely retry the request. If a site was already created with this key, the original response is replayed. |
curl -X POST https://chorus.host/v1/sites \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-api-key" \
-d '{
"slug": "my-project",
"files": [
{
"path": "index.html",
"size": 2048,
"contentType": "text/html",
"hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
{
"path": "style.css",
"size": 512,
"contentType": "text/css",
"hash": "sha256:d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592"
}
]
}'
Response 200 OK:
{
"site": {
"id": "site_abc123",
"slug": "my-project",
"url": "https://my-project.chorus.host",
"expiresAt": null,
"createdAt": "2026-03-19T15:04:05Z",
"updatedAt": "2026-03-19T15:04:05Z"
},
"version": {
"id": "ver_xyz789",
"finalizeUrl": "/v1/sites/my-project/versions/ver_xyz789/finalize"
},
"uploads": {
"pending": [
{
"path": "index.html",
"uploadUrl": "https://storage.example.com/presigned-url-1...",
"uploadMethod": "put"
},
{
"path": "style.css",
"uploadUrl": "https://storage.example.com/presigned-url-2...",
"uploadMethod": "put"
}
],
"skipped": []
}
}
Anonymous responses also include claimToken and claimUrl fields that let you claim ownership of the site later.
Upload files
Upload each file to its presigned URL using a PUT request. The Content-Type header must match the contentType from the manifest.
curl -X PUT "https://storage.example.com/presigned-url-1..." \
-H "Content-Type: text/html" \
--data-binary @index.html
Upload URLs expire after 1 hour. If they expire before you finish uploading, use the refresh uploads endpoint to get new ones.
Finalize a version
Verifies all uploaded files match their declared hashes and sizes, then atomically makes the version live. The site is accessible immediately after a successful finalize.
curl -X POST https://chorus.host/v1/sites/my-project/versions/ver_xyz789/finalize \
-H "Authorization: Bearer your-api-key"
Response 200 OK:
{
"site": {
"id": "site_abc123",
"slug": "my-project",
"url": "https://my-project.chorus.host",
"currentVersionId": "ver_xyz789",
"expiresAt": null,
"createdAt": "2026-03-19T15:04:05Z",
"updatedAt": "2026-03-19T15:04:10Z"
},
"version": {
"id": "ver_xyz789",
"status": "live",
"fileCount": 2,
"totalSize": 2560,
"finalizedAt": "2026-03-19T15:04:10Z",
"createdAt": "2026-03-19T15:04:05Z"
}
}
Multipart uploads
Files 250 MB and larger are automatically assigned multipart uploads. Instead of a single uploadUrl, you receive an uploadId and an array of partUrls.
// In the create site response, large files look like:
{
"path": "large-video.mp4",
"uploadMethod": "multipart",
"uploadId": "mpu_abc123",
"partUrls": [
"https://storage.example.com/part-1-url...",
"https://storage.example.com/part-2-url..."
]
}
Upload each part in order (8 MB per part), then call the complete multipart endpoint with the ETags returned by each part upload. After all multipart uploads are completed, call finalize as usual.
Content deduplication
chorus.host deduplicates files by their SHA-256 hash. If a file with the same hash was uploaded in a previous finalized version (on any site), it will appear in the skipped array instead of pending:
{
"skipped": [
{
"path": "logo.png",
"reason": "hash_match"
}
]
}
You do not need to upload skipped files. They are already stored and will be served correctly.
Site Management
List sites
Returns all sites owned by the authenticated user. Requires Bearer auth.
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | Max results to return |
offset | integer | 0 | Pagination offset |
curl https://chorus.host/v1/sites?limit=10 \
-H "Authorization: Bearer your-api-key"
Response 200 OK:
{
"sites": [
{
"id": "site_abc123",
"slug": "my-project",
"url": "https://my-project.chorus.host",
"currentVersionId": "ver_xyz789",
"ownerId": "usr_def456",
"title": "My Project",
"expiresAt": null,
"createdAt": "2026-03-19T15:04:05Z",
"updatedAt": "2026-03-19T15:04:10Z"
}
],
"totalCount": 1,
"limit": 10,
"offset": 0
}
Update metadata
Update site metadata. All fields are optional — only included fields are updated. Set a field to null to clear it.
Request body
| Field | Type | Description |
|---|---|---|
title | string | null | Site title (used in Open Graph tags) |
description | string | null | Site description |
ogImagePath | string | null | Path to an image file in the site for Open Graph |
expiresAt | string | null | RFC 3339 expiry. Set to null to make permanent (authenticated only). |
curl -X PATCH https://chorus.host/v1/sites/my-project/metadata \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"title": "My Project",
"description": "A demo site published by my agent",
"ogImagePath": "preview.png"
}'
Response 200 OK:
{
"site": {
"id": "site_abc123",
"slug": "my-project",
"url": "https://my-project.chorus.host",
"currentVersionId": "ver_xyz789",
"title": "My Project",
"description": "A demo site published by my agent",
"ogImagePath": "preview.png",
"expiresAt": null,
"createdAt": "2026-03-19T15:04:05Z",
"updatedAt": "2026-03-19T15:05:00Z"
}
}
Set password protection
Protects the site with HTTP Basic authentication. Visitors will be prompted for credentials before seeing the site.
curl -X PUT https://chorus.host/v1/sites/my-project/password \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"username": "demo",
"password": "s3cret"
}'
Response 200 OK:
{
"success": true
}
Remove password protection
curl -X DELETE https://chorus.host/v1/sites/my-project/password \
-H "Authorization: Bearer your-api-key"
Response 200 OK:
{
"success": true
}
Delete a site
Permanently deletes a site and all its versions. This action cannot be undone.
curl -X DELETE https://chorus.host/v1/sites/my-project \
-H "Authorization: Bearer your-api-key"
Response 200 OK:
{
"success": true
}
Claim an anonymous site
Transfer ownership of an anonymous site to your account. Requires both Bearer auth (to identify the new owner) and the claim token (to prove you created the site). Claiming a site removes its expiry and makes it permanent.
curl -X POST https://chorus.host/v1/sites/autumn-river-42/claim \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{"claimToken": "ctk_abc123..."}'
Response 200 OK:
{
"site": {
"id": "site_abc123",
"slug": "autumn-river-42",
"url": "https://autumn-river-42.chorus.host",
"currentVersionId": "ver_xyz789",
"ownerId": "usr_def456",
"expiresAt": null,
"createdAt": "2026-03-19T15:04:05Z",
"updatedAt": "2026-03-19T15:10:00Z"
}
}
Versions
Every publish creates a new version. Versions are immutable once finalized. You can update a site by creating a new version, or roll back to any previous version.
Create a new version
Creates a new version for an existing site. Works exactly like creating a site, but attaches to the existing slug. The new version does not go live until you finalize it.
curl -X POST https://chorus.host/v1/sites/my-project/versions \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"files": [
{
"path": "index.html",
"size": 3072,
"contentType": "text/html",
"hash": "sha256:abc123..."
}
]
}'
Response 200 OK:
{
"site": {
"id": "site_abc123",
"slug": "my-project",
"url": "https://my-project.chorus.host",
"currentVersionId": "ver_xyz789",
"expiresAt": null,
"createdAt": "2026-03-19T15:04:05Z",
"updatedAt": "2026-03-19T15:04:10Z"
},
"version": {
"id": "ver_new456",
"finalizeUrl": "/v1/sites/my-project/versions/ver_new456/finalize"
},
"uploads": {
"pending": [
{
"path": "index.html",
"uploadUrl": "https://storage.example.com/presigned-url...",
"uploadMethod": "put"
}
],
"skipped": []
}
}
List versions
Returns all versions for a site, ordered by creation date (newest first).
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 20 | Max results to return |
offset | integer | 0 | Pagination offset |
curl https://chorus.host/v1/sites/my-project/versions \
-H "Authorization: Bearer your-api-key"
Response 200 OK:
{
"versions": [
{
"id": "ver_xyz789",
"status": "live",
"fileCount": 2,
"totalSize": 2560,
"finalizedAt": "2026-03-19T15:04:10Z",
"createdAt": "2026-03-19T15:04:05Z"
},
{
"id": "ver_old123",
"status": "superseded",
"fileCount": 1,
"totalSize": 1024,
"finalizedAt": "2026-03-18T10:00:00Z",
"createdAt": "2026-03-18T09:59:55Z"
}
],
"totalCount": 2,
"limit": 20,
"offset": 0
}
Rollback to a version
Instantly redeploys a previous version. Creates a new version by copying the file manifest from the target version, then atomically makes it live. The target version must be finalized (not pending).
curl -X POST https://chorus.host/v1/sites/my-project/versions/ver_old123/rollback \
-H "Authorization: Bearer your-api-key"
Response 200 OK:
{
"site": {
"id": "site_abc123",
"slug": "my-project",
"url": "https://my-project.chorus.host",
"currentVersionId": "ver_rolled789",
"expiresAt": null,
"createdAt": "2026-03-19T15:04:05Z",
"updatedAt": "2026-03-19T16:00:00Z"
},
"version": {
"id": "ver_rolled789",
"status": "live",
"fileCount": 1,
"totalSize": 1024,
"finalizedAt": "2026-03-19T16:00:00Z",
"createdAt": "2026-03-19T16:00:00Z"
}
}
Refresh upload URLs
Regenerates expired presigned upload URLs for a pending version. Optionally pass a list of paths to refresh only specific files.
Request body (optional)
| Field | Type | Description |
|---|---|---|
paths | string[] | Optional list of file paths to refresh. Omit to refresh all. |
curl -X POST https://chorus.host/v1/sites/my-project/versions/ver_xyz789/uploads/refresh \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{"paths": ["index.html"]}'
Response 200 OK:
{
"uploads": {
"pending": [
{
"path": "index.html",
"uploadUrl": "https://storage.example.com/new-presigned-url...",
"uploadMethod": "put"
}
],
"skipped": []
}
}
Complete multipart upload
Assembles a multipart upload after all parts have been uploaded. You must call this for each file that used multipart upload method before calling finalize.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
path | string | Yes | File path in the manifest |
uploadId | string | Yes | The upload ID from the create response |
parts | array | Yes | Array of completed parts |
parts[].partNumber | integer | Yes | Part number (1-based) |
parts[].etag | string | Yes | ETag returned by the part upload |
curl -X POST https://chorus.host/v1/sites/my-project/versions/ver_xyz789/uploads/complete \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"path": "large-video.mp4",
"uploadId": "mpu_abc123",
"parts": [
{"partNumber": 1, "etag": "\"abc123...\""},
{"partNumber": 2, "etag": "\"def456...\""}
]
}'
Response 200 OK:
{
"success": true
}
Workers
Workers are serverless JavaScript/TypeScript functions that run on Cloudflare's edge network. They are the compute layer for chorus.host — deploy an API backend alongside your static site.
The recommended pattern is to pair a static frontend with a Worker backend:
# Static frontend
my-app.chorus.host → HTML/CSS/JS (Beacon)
# API backend (Hono recommended)
my-app.worker.chorus.host → Serverless Worker (Cloudflare edge)
Workers require authentication. Anonymous workers are not supported. You must include a Bearer token with all Workers API requests.
Workers share a global slug namespace with static sites — a slug is unique across both. TypeScript and JavaScript are both accepted (Cloudflare handles TS transpilation at the edge).
Create a worker
Creates a new worker and reserves a slug. The worker is not live until you deploy code to it.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
slug | string | No | Custom slug for the subdomain. Auto-generated if omitted. |
Headers
| Header | Required | Description |
|---|---|---|
Authorization | Yes | Bearer <api-key> |
Idempotency-Key | No | Unique key to safely retry the request |
curl -X POST https://chorus.host/v1/workers \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{"slug": "my-api"}'
Response 200 OK:
{
"slug": "my-api",
"url": "https://my-api.worker.chorus.host",
"createdAt": "2026-03-20T15:04:05Z"
}
List workers
Returns all workers owned by the authenticated user.
curl https://chorus.host/v1/workers \
-H "Authorization: Bearer your-api-key"
Response 200 OK:
{
"workers": [
{
"slug": "my-api",
"url": "https://my-api.worker.chorus.host",
"currentDeployment": {
"id": "dep_abc123",
"version": 3,
"deployedAt": "2026-03-20T15:10:00Z"
},
"createdAt": "2026-03-20T15:04:05Z"
}
]
}
Get worker details
Returns detailed information about a worker, including its current deployment and bindings.
curl https://chorus.host/v1/workers/my-api \
-H "Authorization: Bearer your-api-key"
Response 200 OK:
{
"slug": "my-api",
"url": "https://my-api.worker.chorus.host",
"currentDeployment": {
"id": "dep_abc123",
"version": 3,
"entryPoint": "index.ts",
"sizeBytes": 1234,
"deployedAt": "2026-03-20T15:10:00Z"
},
"bindings": {
"secrets": ["DATABASE_URL", "API_KEY"],
"kvNamespaces": [{"bindingName": "CACHE"}],
"crons": ["0 * * * *"]
},
"createdAt": "2026-03-20T15:04:05Z"
}
Deploy a worker
Uploads and activates a worker script in a single request. The request body is multipart/form-data with a JSON metadata part and one or more file parts.
Multipart form fields
| Field name | Type | Required | Description |
|---|---|---|---|
metadata | JSON (form value) | Yes | Deployment metadata (see below) |
file | File upload(s) | Yes | One or more script files. Use field name file for each. |
Metadata fields
| Field | Type | Required | Description |
|---|---|---|---|
entryPoint | string | Yes | Main module filename (e.g. index.ts, index.js) |
compatibilityDate | string | No | Cloudflare compatibility date (e.g. 2024-09-23) |
compatibilityFlags | string[] | No | Compatibility flags (e.g. ["nodejs_compat"]) |
crons | string[] | No | Cron trigger expressions (e.g. ["0 * * * *"]) |
kvNamespaces | array | No | KV namespace bindings ([{"bindingName": "CACHE"}]) |
durableObjects | array | No | Durable Object bindings ([{"className": "Room", "bindingName": "ROOMS"}]) |
# Deploy a simple worker
curl -X POST https://chorus.host/v1/workers/my-api/deploy \
-H "Authorization: Bearer your-api-key" \
-F 'metadata={"entryPoint":"index.ts","compatibilityDate":"2024-09-23","compatibilityFlags":["nodejs_compat"]}' \
-F "file=@index.ts"
# Deploy multiple files
curl -X POST https://chorus.host/v1/workers/my-api/deploy \
-H "Authorization: Bearer your-api-key" \
-F 'metadata={"entryPoint":"index.ts"}' \
-F "file=@index.ts" \
-F "file=@lib/utils.ts"
Response 200 OK:
{
"slug": "my-api",
"url": "https://my-api.worker.chorus.host",
"deploymentId": "dep_abc123",
"version": 3,
"sizeBytes": 1234,
"fileCount": 2,
"entryPoint": "index.ts",
"deployedAt": "2026-03-20T15:10:00Z",
"bindings": {
"secrets": ["DATABASE_URL"],
"kvNamespaces": [{"bindingName": "CACHE", "namespaceId": "ns_xyz"}]
}
}
Deploy is idempotent. If the script hash matches the current deployment and bindings haven't changed, the existing deployment is returned without re-uploading to Cloudflare.
Here is an example Hono worker you can deploy as a backend for your static site:
// index.ts
import { Hono } from 'hono'
const app = new Hono()
app.get('/api/hello', (c) => {
return c.json({ message: 'Hello from chorus.host!' })
})
app.post('/api/contact', async (c) => {
const body = await c.req.json()
return c.json({ ok: true })
})
export default app
Delete a worker
Permanently deletes a worker, all its deployments, and removes the script from Cloudflare's edge. This action cannot be undone.
curl -X DELETE https://chorus.host/v1/workers/my-api \
-H "Authorization: Bearer your-api-key"
Response 200 OK:
{
"success": true
}
Secrets
Secrets are environment variables encrypted at rest by Cloudflare. Secret values are never stored in Beacon's database — they go straight to Cloudflare. Beacon only tracks the secret name.
Set a secret
Sets or updates a secret. Secrets take effect immediately on the running worker (Cloudflare hot-reloads secrets without redeploying).
Request body
| Field | Type | Required | Description |
|---|---|---|---|
value | string | Yes | The secret value |
curl -X PUT https://chorus.host/v1/workers/my-api/secrets/DATABASE_URL \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{"value": "postgres://user:pass@host/db"}'
Response 200 OK:
{
"success": true
}
Delete a secret
curl -X DELETE https://chorus.host/v1/workers/my-api/secrets/DATABASE_URL \
-H "Authorization: Bearer your-api-key"
Response 200 OK:
{
"success": true
}
List secrets
Returns the names of all secrets set on the worker. Values are never returned.
curl https://chorus.host/v1/workers/my-api/secrets \
-H "Authorization: Bearer your-api-key"
Response 200 OK:
{
"secrets": ["DATABASE_URL", "API_KEY"]
}
Deployment history
Returns the deployment history for a worker, ordered by creation date (newest first).
curl https://chorus.host/v1/workers/my-api/deployments \
-H "Authorization: Bearer your-api-key"
Response 200 OK:
{
"deployments": [
{
"id": "dep_abc123",
"version": 3,
"entryPoint": "index.ts",
"sizeBytes": 1234,
"fileCount": 2,
"status": "live",
"deployedAt": "2026-03-20T15:10:00Z"
},
{
"id": "dep_old789",
"version": 2,
"entryPoint": "index.ts",
"sizeBytes": 980,
"fileCount": 1,
"status": "superseded",
"deployedAt": "2026-03-19T12:00:00Z"
}
]
}
Rollback
Rolls back to a previous deployment. If no deploymentId is specified, rolls back to the immediately previous deployment. The previous script bundle is fetched from storage and re-deployed to Cloudflare's edge.
Request body (optional)
| Field | Type | Required | Description |
|---|---|---|---|
deploymentId | string | No | Specific deployment ID to roll back to. Defaults to previous deployment. |
curl -X POST https://chorus.host/v1/workers/my-api/rollback \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{"deploymentId": "dep_old789"}'
Response 200 OK:
{
"slug": "my-api",
"url": "https://my-api.worker.chorus.host",
"deploymentId": "dep_rolled456",
"version": 4,
"deployedAt": "2026-03-20T16:00:00Z"
}
Logs
Returns a WebSocket URL for real-time log streaming. Connect to the returned URL to receive live console.log output, errors, and request metadata from your worker.
curl https://chorus.host/v1/workers/my-api/logs/tail \
-H "Authorization: Bearer your-api-key"
Response 200 OK:
{
"url": "wss://chorus.host/v1/workers/my-api/logs/tail/ws?token=...",
"expiresAt": "2026-03-20T16:04:05Z"
}
Workers run on Cloudflare's edge, not on the Beacon server. If Beacon goes down, your deployed workers continue running. Only the management API (deploy, delete, secrets) is affected.
Serving
Once a site is finalized, it is served at https://<slug>.chorus.host. Files are stored on Cloudflare R2 and served from the global edge network.
URL resolution
When a request arrives, chorus.host resolves the file to serve using this chain (in order):
- Exact match —
/style.cssserves the file at pathstyle.css - Trailing slash redirect —
/blogredirects (302) to/blog/if files exist under that prefix - Directory index —
/blog/servesblog/index.htmlif it exists - Single-file auto-viewer — If the site has exactly one file, serves an HTML wrapper with appropriate viewer (image, video, PDF, or download link)
- Directory listing — If the path is a directory with files, renders an HTML file listing
- 404 — If nothing matches
Cache headers
| Content type | Cache-Control |
|---|---|
HTML files (.html, .htm) | public, max-age=60, must-revalidate |
Fingerprinted files (e.g. app.a1b2c3d4.js) | public, max-age=31536000, immutable |
| Everything else | public, max-age=3600 |
| Password-protected sites | private, no-store |
Conditional requests
Every file response includes an ETag header derived from the file's SHA-256 hash. Clients can use If-None-Match to receive 304 Not Modified responses for unchanged files.
Range requests
All file responses include Accept-Ranges: bytes. Clients can use the Range header to request partial content, which is useful for streaming large media files.
Rate Limits
Rate limits are applied per endpoint category. When a limit is exceeded, the API returns 429 Too Many Requests.
| Action | Limit | Scope |
|---|---|---|
| Publish (anonymous) | 5 per hour | Per IP address |
| Publish (authenticated) | 60 per hour | Per API key |
| Upload refresh | 10 per hour | Per site |
| Worker deploy | 20 per hour | Per API key |
File and size limits
| Limit | Anonymous | Authenticated |
|---|---|---|
| Max file size | 250 MB | 5 GB |
| Max files per version | 10,000 | |
| Max total size per version | 10 GB | |
Presigned URLs expire after 1 hour. If your uploads take longer, use the refresh uploads endpoint to get new URLs.
Errors
All error responses return a JSON object with a single error field:
{
"error": "slug is already taken"
}
Common status codes
| Status | Meaning |
|---|---|
200 | Success |
400 | Bad request — invalid input, missing required fields, invalid hash format |
401 | Unauthorized — missing or invalid Bearer token / claim token |
404 | Not found — site or version does not exist |
409 | Conflict — slug taken, version not pending, concurrent modification |
413 | Payload too large — file or total site size exceeds limit |
422 | Unprocessable — entry point file not found in uploaded worker bundle |
429 | Too many requests — rate limit exceeded |
500 | Internal server error |
502 | Bad gateway — Cloudflare API failed (worker deployment failed, retry) |
Common error messages
| Error | Cause |
|---|---|
"files are required" | Empty file manifest |
"too many files (max 10000)" | Manifest exceeds file count limit |
"slug is already taken" | Custom slug conflicts with an existing site |
"file "x" must have hash in sha256:<hex> format" | Missing or malformed hash |
"version is not pending" | Trying to finalize or refresh an already-finalized version |
"file "x": not uploaded" | Finalize called before all files were uploaded |
"file "x": hash mismatch" | Uploaded file content does not match declared hash |
"rate limit exceeded" | Too many requests in the time window |