The Content Type API provides programmatic access to polis content operations. It runs alongside the webapp under the /v1/ route prefix.
- Site owner tools: Publish posts, manage comments, handle blessings from scripts, mobile apps, or external tools.
- 3rd party integrations: Any tool that speaks HTTP+JSON can manage content on a polis site.
- Instance-to-instance operations: DMs use signed-request auth for cross-site delivery without discovery service coupling.
This API covers content type operations only. Site settings, theme switching, dashboard aggregations, and setup wizards remain in the webapp/CLI.
# Generate an API key
polis api-key create --name "my-script"
# => polis_abc123...
# List posts (public, no auth needed)
curl https://mysite.example.com/v1/content/post
# Publish a post (auth required)
curl -X POST https://mysite.example.com/v1/content/post \
-H "Authorization: Bearer polis_abc123..." \
-H "Content-Type: application/json" \
-d '{"markdown": "# Hello World\n\nMy first API post."}'
# List installed bundles
curl https://mysite.example.com/v1/bundlesRead operations (GET) on content and bundles are public. All write operations require a Bearer token.
Authorization: Bearer <api-key>
API keys are generated via polis api-key create and stored as SHA-256 hashes in .polis/api-keys.json. Keys are prefixed with polis_.
| Operation | Auth Required |
|---|---|
GET /v1/content/{type} |
No (public types only) |
GET /v1/content/{type}/{id} |
No (public types only) |
GET /v1/bundles |
No |
GET /v1/bundles/{name} |
No |
GET /v1/content/{type}/drafts[/{id}] |
Yes |
GET on private types (dm, feed, follow) |
Yes |
| Everything else | Yes |
Private content types (pub.polis.dm, pub.polis.feed, pub.polis.follow) require Bearer token auth for all operations including reads. Public types (pub.polis.post, pub.polis.comment, pub.polis.tag) allow unauthenticated reads.
For the deliver action on pub.polis.dm, authentication uses signed request headers instead of Bearer tokens. Remote instances authenticate by signing a canonical JSON payload with their Ed25519 private key.
| Header | Purpose |
|---|---|
X-Polis-Domain |
Sender's domain |
X-Polis-Signature |
Ed25519 SSH signature over canonical JSON |
X-Polis-Timestamp |
ISO-8601 timestamp (must be within 5-minute window) |
Missing auth returns 401 Unauthorized. Invalid key returns 403 Forbidden.
GET /v1/content/{type} List content
GET /v1/content/{type}/{id} Get content by ID
POST /v1/content/{type} Create content
PUT /v1/content/{type}/{id} Update content
DELETE /v1/content/{type}/{id} Delete content
POST /v1/content/{type}/actions/{action} Dispatch action
GET /v1/content/{type}/drafts List drafts
GET /v1/content/{type}/drafts/{id} Get draft
POST /v1/content/{type}/drafts Save draft
DELETE /v1/content/{type}/drafts/{id} Delete draft
GET /v1/bundles List installed bundles
GET /v1/bundles/{name} Get bundle details
The {type} parameter accepts short names or fully-qualified names:
| Short Name | Full Name |
|---|---|
post |
pub.polis.post |
comment |
pub.polis.comment |
follow |
pub.polis.follow |
feed |
pub.polis.feed |
dm |
pub.polis.dm |
tag |
pub.polis.tag |
Both forms work: /v1/content/post and /v1/content/pub.polis.post are equivalent.
Resolution: short names try pub.polis.<name> first, then search all bundles for a suffix match. Ambiguous matches fail.
| Action | Method + Path | Status |
|---|---|---|
| List posts | GET /v1/content/post |
Implemented |
| Get post | GET /v1/content/post/{id} |
Routed, not wired |
| Create (publish) | POST /v1/content/post |
Implemented |
| Update (republish) | PUT /v1/content/post/{id} |
Routed, not wired |
| Delete (unpublish) | DELETE /v1/content/post/{id} |
Routed, not wired |
| Render preview | POST /v1/content/post/actions/render |
Routed, not wired |
| Draft CRUD | */v1/content/post/drafts[/{id}] |
Routed, not wired |
Create post request:
{
"markdown": "# Post Title\n\nPost content in markdown.",
"filename": "optional-custom-filename.md"
}Create post response (201):
{
"title": "Post Title",
"path": "posts/20260301/post-title.md",
"version": "1",
"signature": "...",
"url": "https://mysite.example.com/posts/20260301/post-title.html"
}List posts response (200):
{
"posts": [
{
"path": "posts/20260302/second.md",
"title": "Second Post",
"published": "2026-03-02T00:00:00Z",
"current_version": "1"
},
{
"path": "posts/20260301/first.md",
"title": "First Post",
"published": "2026-03-01T00:00:00Z",
"current_version": "1"
}
],
"count": 2
}Posts are returned newest-first. Comment entries are filtered from the index.
| Action | Method + Path | Status |
|---|---|---|
| List comments | GET /v1/content/comment |
Routed, not wired |
| Get comment | GET /v1/content/comment/{id} |
Routed, not wired |
| Create (beseech) | POST /v1/content/comment |
Implemented |
| Bless | POST /v1/content/comment/actions/bless |
Routed, not wired |
| Deny | POST /v1/content/comment/actions/deny |
Routed, not wired |
| Revoke | POST /v1/content/comment/actions/revoke |
Routed, not wired |
| Sync | POST /v1/content/comment/actions/sync |
Routed, not wired |
Create (beseech) request:
{
"comment_id": "abc123"
}Create (beseech) response (200):
{
"success": true,
"status": "pending",
"message": "Comment sent to discovery service",
"auto_blessed": false
}| Action | Method + Path | Status |
|---|---|---|
| List following | GET /v1/content/follow |
Implemented |
| Follow | POST /v1/content/follow |
Routed, not wired |
| Unfollow | DELETE /v1/content/follow/{url} |
Routed, not wired |
List following response (200):
{
"following": [
{
"url": "https://alice.example.com",
"added_at": "2026-01-01T00:00:00Z",
"site_title": "Alice's Blog",
"author_name": "Alice"
}
],
"count": 1
}| Action | Method + Path | Status |
|---|---|---|
| List feed | GET /v1/content/feed |
Routed, not wired |
| Refresh | POST /v1/content/feed/actions/refresh |
Routed, not wired |
Feed list reads from cache, but the cache is populated by the webapp's background sync loop. The refresh action needs sync extraction before it can work.
| Action | Method + Path | Status |
|---|---|---|
| List conversations | GET /v1/content/dm |
Implemented |
| Get conversation | GET /v1/content/dm/{conv_id} |
Implemented |
| Send DM | POST /v1/content/dm |
Implemented |
| Delete conversation | DELETE /v1/content/dm/{conv_id} |
Implemented |
| Deliver (receive) | POST /v1/content/dm/actions/deliver |
Implemented |
| Mark read | POST /v1/content/dm/actions/mark_read |
Implemented |
| Retry unsent | POST /v1/content/dm/actions/retry |
Implemented |
List conversations response (200):
{
"conversations": [
{
"id": "f8e7d6c5b4a3f2e1",
"peer_domain": "bob.example.com",
"peer_url": "https://bob.example.com",
"last_message_at": "2026-03-07T10:00:00Z",
"unread_count": 2,
"last_preview": "Thanks for reaching out..."
}
],
"count": 1
}Get conversation response (200):
{
"conversation_id": "f8e7d6c5b4a3f2e1",
"peer_domain": "bob.example.com",
"peer_url": "https://bob.example.com",
"messages": [
{
"id": "msg1",
"from": "alice.example.com",
"to": "bob.example.com",
"content": "Hello Bob!",
"timestamp": "2026-03-07T10:00:00Z",
"status": "sent",
"read_at": "2026-03-07T10:05:00Z"
}
],
"count": 1
}Send DM request:
{
"recipient_url": "https://bob.example.com",
"content": "Hello Bob!",
"reply_to_id": ""
}Send DM response (201, delivered):
{
"message_id": "a1b2c3d4",
"conversation_id": "f8e7d6c5b4a3f2e1",
"status": "sent"
}Send DM response (201, delivery failed):
{
"message_id": "a1b2c3d4",
"conversation_id": "f8e7d6c5b4a3f2e1",
"status": "unsent",
"error": "recipient unreachable"
}Deliver (receive) request — sent by remote instance with signed headers:
{
"version": 1,
"sender_domain": "alice.example.com",
"recipient_domain": "bob.example.com",
"encrypted_content": "<base64>",
"nonce": "<base64, 24 bytes>",
"timestamp": "2026-03-07T10:00:00Z"
}Deliver response (201):
{
"message_id": "msg1",
"conversation_id": "f8e7d6c5b4a3f2e1",
"status": "received"
}Mark read request:
{
"conversation_id": "f8e7d6c5b4a3f2e1"
}Mark read response (200):
{
"conversation_id": "f8e7d6c5b4a3f2e1"
}Delete conversation response (200):
{
"conversation_id": "f8e7d6c5b4a3f2e1",
"deleted": true
}Retry response (200):
{
"unsent_count": 2,
"unsent": [...]
}Note: The deliver action uses signed-request authentication, not Bearer tokens. All other DM actions require Bearer token auth. The retry action returns the list of unsent messages from the store.
| Action | Method + Path | Status |
|---|---|---|
| List tags | GET /v1/content/tag |
Implemented |
| Apply tag | POST /v1/content/tag/actions/apply |
Implemented |
| Remove target | POST /v1/content/tag/actions/remove |
Implemented |
| Delete tag | POST /v1/content/tag/actions/delete |
Implemented |
List tags response (200, all tags):
{
"tags": [
{
"tag": "favorite",
"count": 3,
"updated": "2026-03-07T10:00:00Z"
}
],
"count": 1
}List tags request (filter by name):
{
"tag": "favorite"
}List tags response (200, single tag with targets):
{
"tag": "favorite",
"targets": [
{
"uri": "https://bob.example.com/posts/20260215/hello.md",
"added": "2026-03-07T10:00:00Z"
}
],
"count": 1
}Apply tag request:
{
"tag": "favorite",
"target_uri": "https://bob.example.com/posts/20260215/hello.md"
}Apply tag response (200):
{
"tag": "favorite",
"target_uri": "https://bob.example.com/posts/20260215/hello.md",
"count": 3
}On success, the tag is synced to the discovery service (non-fatal if DS is unavailable).
Remove target request:
{
"tag": "favorite",
"target_uri": "https://bob.example.com/posts/20260215/hello.md"
}Remove target response (200):
{
"tag": "favorite",
"target_uri": "https://bob.example.com/posts/20260215/hello.md",
"count": 2
}Unregisters the target from the discovery service (non-fatal if DS is unavailable).
Delete tag request:
{
"tag": "favorite"
}Delete tag response (200):
{
"tag": "favorite",
"deleted": true
}Deletes the tag file locally and unregisters all targets from the discovery service.
Read-only access to themes declared by the active bundle (pub.polis.core). The active theme is set via the webapp settings page (which writes .polis/bundles/registry.json) — there is no set/update action on this content type at the API layer today.
Actions: list, get
List response (GET /v1/content/theme):
{
"themes": [
{ "name": "vice", "display_name": "Vice", "type": "dark", "system_only": false },
{ "name": "studio13", "display_name": "Studio 13", "type": "dark", "system_only": false },
{ "name": "especial", "display_name": "Especial", "type": "dark", "system_only": false },
{ "name": "sols", "display_name": "Sols", "type": "dark", "system_only": true }
],
"count": 4,
"active": "vice"
}system_only: true themes (currently just sols) are returned by list but filtered out of the webapp dropdown; they are reserved for the logged-out landing experience.
Get response (GET /v1/content/theme/<name>):
{
"name": "vice",
"display_name": "Vice",
"description": "Warm coral and sunset hues, Miami Vice vibes",
"type": "dark",
"system_only": false,
"shapes": ["pub.polis.shapes.v3", "pub.polis.shapes.v4"],
"overrides": []
}The dispatch engine supports external handlers for custom content types via bundle.json:
| Handler Type | How It Works |
|---|---|
builtin (or empty) |
Go code in builtin_core.go (pub.polis.core only) |
executable |
External binary — receives ActionRequest as JSON on stdin, returns ActionResult on stdout. Env vars: POLIS_SITE_DIR, POLIS_BASE_URL, POLIS_DISCOVERY_URL |
http |
HTTP endpoint — receives ActionRequest as JSON POST, returns ActionResult. 30s timeout. |
List bundles response (200):
{
"bundles": [
{
"name": "pub.polis.core",
"version": "1.0.0",
"description": "Core polis content types",
"types": [
{
"name": "pub.polis.post",
"actions": ["list", "get", "create", "update", "delete", "render",
"draft.list", "draft.get", "draft.save", "draft.delete"]
},
{
"name": "pub.polis.comment",
"actions": ["list", "get", "create", "bless", "deny", "revoke", "sync"]
},
{
"name": "pub.polis.follow",
"actions": ["list", "create", "delete"]
},
{
"name": "pub.polis.feed",
"actions": ["list", "refresh"]
},
{
"name": "pub.polis.dm",
"actions": ["list", "get", "send", "deliver", "mark_read", "delete", "retry"]
},
{
"name": "pub.polis.tag",
"actions": ["list", "apply", "remove", "delete"]
},
{
"name": "pub.polis.theme",
"actions": ["list", "get"]
}
]
}
]
}All content routes dispatch through Engine.Dispatch():
- Resolve short type name → fully-qualified name (e.g.
"post"→"pub.polis.post") - Find the bundle that owns the type
- Look up the handler registered for that bundle
- Check if the action is a write — if yes and no private key is configured, return
503 not_configured - Call
handler.Handle(ctx, request, env) - On write success, trigger
OnContentChanged()callback (re-renders the site)
Write actions (require private key): create, update, delete, bless, deny, revoke, sync, draft.save, draft.delete, refresh, send, deliver, mark_read, retry
All errors follow a consistent format:
{
"status": "error",
"error": {
"code": "not_found",
"message": "unknown content type: pub.unknown"
}
}| HTTP Status | Error Code | When |
|---|---|---|
| 400 | invalid_request |
Bad JSON, missing required fields, invalid payload |
| 400 | unsupported_action |
Action not available for this content type |
| 400 | bad_request |
Malformed JSON body |
| 401 | unauthorized |
Missing Authorization header |
| 403 | forbidden |
Invalid API key, or signed-request verification failed |
| 404 | not_found |
Unknown content type, content not found |
| 405 | method_not_allowed |
Wrong HTTP method for this route |
| 500 | internal_error |
Unexpected server error |
| 503 | not_configured |
Site not configured (e.g., no signing keys) |
All API routes include CORS headers:
Access-Control-Allow-Origin: *Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONSAccess-Control-Allow-Headers: Content-Type, Authorization, X-Polis-Domain, X-Polis-Signature, X-Polis-Timestamp
OPTIONS preflight requests return 200 with these headers.
Request bodies are limited to 1MB.
These operations work end-to-end through the dispatch engine:
pub.polis.post/list— Reads index.jsonl, filters comments, returns newest-firstpub.polis.post/create— Signs content, writes post, updates index, registers with DSpub.polis.comment/create— Sends beseech request for pending commentpub.polis.follow/list— Reads following.json, returns entries with metadatapub.polis.dm/list— Lists conversation summaries with unread countspub.polis.dm/get— Returns conversation with decrypted messagespub.polis.dm/send— Encrypts and delivers DM to remote instancepub.polis.dm/deliver— Receives encrypted DM from remote instance (signed-request auth)pub.polis.dm/mark_read— Marks conversation messages as readpub.polis.dm/delete— Deletes a conversation locallypub.polis.dm/retry— Returns list of unsent messagespub.polis.tag/list— Lists all tags or a single tag's targetspub.polis.tag/apply— Applies a tag to a target URL, syncs to DSpub.polis.tag/remove— Removes a target from a tag, unregisters from DSpub.polis.tag/delete— Deletes a tag and unregisters all targets from DSpub.polis.theme/list— Lists themes declared by the active bundle (withactivefield)pub.polis.theme/get— Returns a single theme's manifest entry
These have REST routes and dispatch correctly through the engine, but the handler returns "unsupported action". Each needs the corresponding cli-go package logic wired into builtin_core.go:
- Post: get, update, delete, render, draft.*
- Comment: list, get, bless, deny, revoke, sync
- Follow: create, delete
- Feed: list, refresh
- Payload validation — No per-action schema validation. Bad inputs produce confusing errors deep in handler code.
- Pagination — List operations return all results. Needs cursor/limit support.
- Feed population — Feed list reads from cache, but the cache is populated by the webapp's background sync loop.
feed/refreshneeds sync extraction.