API reference
A small HTTP API authenticated with a bearer key. The CLI uses exactly these endpoints and nothing more.
Authentication and base URL
Send your key as Authorization: Bearer colo_live_…. A CLI session fromcolophon login (colo_sess_…) is accepted in the same header; it carries the person’s role, so an owner’s session has every scope and a member’s has publish and stats. The API lives on the content origin, so any of these work as a base:
https://api.usercontent.colophon.fyi— the defaulthttps://<workspace>.usercontent.colophon.fyi— your own subdomain
Once a workspace has attached its own domain, every url below is on that hostname instead; the subdomain form keeps working. The key identifies the workspace; the hostname does not. Responses are JSON. Errors are{ "error": "…" } with the status: 401 for a missing or revoked key, 403 when the key lacks the scope, 400 for validation, 404 when the thing is not yours — RLS makes another workspace’s id indistinguishable from a nonexistent one — and 429with Retry-After for quotas and rate limits.
Rate limits: 120 requests per minute per key, 300 per minute per IP.
/api/cli/* endpoints on app.colophon.fyi implementcolophon login, create-token and switch. They are the CLI’s own and may change with it; build against the endpoints below.Publish a site
POST /api/v1/sites · scope publish · multipart form
| Field | Meaning |
|---|---|
archive | Required. A .tar.gz of the directory. |
name | Display name. Defaults to the archive filename. |
slug | URL path. Defaults to a slug of the name. |
visibility | public · unlisted (the default for a new site) · restricted · private. Omit it on an existing slug and the level it has is kept. |
domain | Optional. A hostname you control, for this one site — see a site’s own domain. The response carries the two DNS records until they resolve; url moves to the hostname once they do. |
expires_at | Optional, an ISO 8601 moment. After it the site answers 410 on every path, previews included, and nothing is deleted. Omitted, an existing site keeps its expiry; none clears it. A moment already past is refused here — take a live site down with PATCH instead. See Expiry. |
draft | Optional, 1. Store the version without making it live. url becomes its preview at …/_v/<version_id>/, site_url is the live address, and an existing site’s name and visibility are left alone. Someone makes it live from the dashboard. See Drafts. |
COPYFILE_DISABLE=1 tar -czf - -C ./site . |
curl -sS -X POST https://api.usercontent.colophon.fyi/api/v1/sites \
-H "Authorization: Bearer $COLOPHON_TOKEN" \
-F "archive=@-;filename=site.tar.gz" \
-F name="Q3 report" -F slug=q3-report -F visibility=unlisted201 when live, or stored as a draft with draft: true; 202 with held: true when the scanner held it for review.
{
"site_id": "…", "version_id": "…",
"url": "https://acme.usercontent.colophon.fyi/q3-report/",
"files": 18, "bytes": 421888, "new_blobs": 2,
"changes": { "added": 1, "removed": 0, "changed": 3 }
}changes counts the files this version added, removed and changed against the one published just before it — null on a site’s first version, all zeros when nothing moved. new_blobs is a different number: how many files nobody had stored before.
With domain, one more field. A hostname another site or workspace holds is reported as domain: { "error": "…" } beside a publish that still went through.
"domain": {
"hostname": "docs.example.com", "verified": false,
"records": [
{ "type": "TXT", "name": "_colophon.docs.example.com", "value": "<token>", "ok": false },
{ "type": "CNAME", "name": "docs.example.com", "value": "acme.usercontent.colophon.fyi", "ok": false }
]
}COPYFILE_DISABLE=1 stops macOS tar from adding ._* resource forks. The server drops them anyway, but not sending them is faster.List sites
GET /api/v1/sites · scope publish · ?limit (default 50, max 200) and ?cursor
{
"sites": [{
"id": "…", "slug": "q3-report", "name": "Q3 report", "visibility": "unlisted",
"url": "https://acme.usercontent.colophon.fyi/q3-report/",
"versions": 3, "shared_with": 0, "updated_at": "2026-09-04T19:22:54.842Z"
}],
"next_cursor": "MjAyNi0wOS0wNFQxOToyMjo1NC44NDJaIDdmM2E…"
}One page, newest first. When next_cursor is not null there are more sites: ask again with ?cursor= set to it, verbatim, until it comes back null. It is opaque — do not parse it. A cursor this API did not issue is a 400.
One site
GET /api/v1/sites/:id · scope publish
{
"id": "…", "slug": "q3-report", "name": "Q3 report", "visibility": "unlisted",
"url": "https://acme.usercontent.colophon.fyi/q3-report/",
"live": true, "live_version_id": "1c4f…", "shared_with": 0, "expires_at": null,
"versions": [
{ "id": "1c4f…", "status": "active", "created_at": "…", "files": 18, "bytes": 421888, "via": "ci-key",
"changes": { "since": "a91e…", "added": 1, "removed": 0, "changed": 2 } },
{ "id": "a91e…", "status": "archived", "created_at": "…", "files": 17, "bytes": 407552, "via": "dashboard",
"changes": null }
]
}Versions come newest first, so the last one is v1. Each carries changes: the files it added, removed and changed against the one published just before it (since), drafts included. null on the first version; three zeros mean a republish that changed nothing. via names the API key that published it, or dashboard.
What changed between two versions
GET /api/v1/sites/:id/diff?from=<version id>&to=<version id> · scope publish
{
"from": "a91e…", "to": "1c4f…",
"summary": { "added": 1, "removed": 0, "changed": 2 },
"files": [
{ "path": "charts/revenue.svg", "kind": "added", "content_type": "image/svg+xml", "size_before": null, "size_after": 18841 },
{ "path": "index.html", "kind": "changed", "content_type": "text/html; charset=utf-8", "size_before": 42188, "size_after": 44032 },
{ "path": "styles.css", "kind": "changed", "content_type": "text/css; charset=utf-8", "size_before": 6247, "size_after": 6451 }
]
}Every path whose bytes differ; a file that is the same on both sides is not listed. Both ids must be versions of this site — one from another site is 404. To see what making an older version live would change, pass the live version as from.
GET /api/v1/sites/:id/diff?from=…&to=…&path=index.html
{
"path": "index.html", "kind": "changed", "content_type": "text/html; charset=utf-8",
"size_before": 42188, "size_after": 44032,
"patch": "diff --git a/index.html b/index.html\n--- index.html\n+++ index.html\n@@ -2,3 +2,4 @@\n …"
}With path, one file’s unified diff in the shape git diff writes, three lines of context, so git apply and any diff viewer read it. An added or removed file is a whole-file patch. A file that is not text, or over 1 MB a side, comes back as binary: true with a reason and its sizes, and its bytes are never read.
Change who can open a site, or when it expires
PATCH /api/v1/sites/:id · scope publish · JSON body with visibility, expires_at, or both
curl -sS -X PATCH https://api.usercontent.colophon.fyi/api/v1/sites/<id> \
-H "Authorization: Bearer $COLOPHON_TOKEN" -H "content-type: application/json" \
-d '{ "visibility": "private" }'{ "id": "…", "slug": "q3-report", "visibility": "private", "before": "unlisted", "changed": true,
"url": "https://acme.usercontent.colophon.fyi/q3-report/" }One of public, unlisted, restricted, private; the next request to the site is answered under the new level, with nothing republished.changed: false means it was that already. This grants nothing a publish key did not have — republishing with visibility set always changed it — but it does change who can open a site the moment it is called, so confirm before widening one or taking a link away from people who hold it. The footer a free public site carries is added at publish time, so a site made public here shows it from its next publish. See Access & sharing.
{ "expires_at": "2026-10-01T00:00:00Z" } // or null to clearexpires_at moves or clears the moment the site stops answering; the reply carries the new value and expires_at_before. A moment already past takes the site down on the next request and deletes nothing, which is the takedown that keeps the versions to bring back. See Expiry.
Delete a site
DELETE /api/v1/sites/:id · scope publish
{ "deleted": true, "slug": "q3-report", "versions": 3 }Removes every version and grant. 404 if the id is not a site in your workspace.
Who am I
GET /api/v1/whoami · any scope
{
"tenant": { "id": "…", "name": "Acme Research", "subdomain": "acme" },
"key": { "id": "…", "scopes": ["publish", "links", "stats"] }
}Short links
POST /api/v1/links · scope links · JSON body
curl -sS -X POST https://api.usercontent.colophon.fyi/api/v1/links \
-H "Authorization: Bearer $COLOPHON_TOKEN" -H "content-type: application/json" \
-d '{ "url": "https://example.com/report", "code": "q3", "title": "Q3 report" }'code, title and expires_at (ISO date) are optional. Returns 201:
{ "id": "…", "code": "q3", "url": "https://acme.usercontent.colophon.fyi/l/q3", "target_url": "https://example.com/report" }GET /api/v1/links returns { "links": [ … ] } with state (ok, disabled, expired, flagged), expires_at, clicks_7d and created_at on each.
Traffic
GET /api/v1/stats/sites/:id?range=30d · scope stats · range 7d, 30d or 90d
{
"views": 1284, "uniques": 402,
"topPages": [{ "path": "/", "views": 712 }],
"topReferrers": [{ "host": "news.ycombinator.com", "views": 88 }],
"countries": [], "devices": [{ "device": "desktop", "views": 1010 }],
"daily": [{ "day": "2026-09-04", "views": 61, "uniques": 30 }]
}daily lists only days that had traffic. See Analytics for how uniques are counted.