Base URL: wherever Folio is running (e.g. http://localhost:8090)
All responses are JSON. All request bodies must be Content-Type: application/json.
Protected endpoints require a valid JWT. The token is issued on login and
stored in an HttpOnly cookie named token. API clients may also pass it
as a Authorization: Bearer <token> header.
Protected mutation endpoints (POST/PUT/DELETE) additionally require a
X-CSRF-Token header. The CSRF token is returned in the login response and
can be refreshed via GET /api/csrf-token.
Returns 200 OK with body ok. Use for uptime checks.
Authenticate and receive a session token.
Request body
{
"email": "admin@example.com",
"password": "yourpassword"
}Response 200
{
"csrf_token": "abc123..."
}Sets an HttpOnly cookie named token (24-hour TTL).
Store the csrf_token and include it as X-CSRF-Token on all subsequent
mutation requests.
Errors: 400 invalid body, 401 invalid credentials
Clear the session cookie.
Response 204 No content.
List all published posts. Does not include post body.
Response 200
[
{
"id": 1,
"slug": "my-first-post",
"title": "My First Post",
"description": "A short description.",
"tags": "go,cms",
"draft": false,
"publish_date": "2026-03-27T00:00:00Z",
"created_at": "2026-03-27T10:00:00Z",
"updated_at": "2026-03-27T10:00:00Z"
}
]Returns [] (empty array) if no posts are published.
Get a single published post including its Markdown body.
Response 200
{
"id": 1,
"slug": "my-first-post",
"title": "My First Post",
"description": "A short description.",
"tags": "go,cms",
"draft": false,
"publish_date": "2026-03-27T00:00:00Z",
"created_at": "2026-03-27T10:00:00Z",
"updated_at": "2026-03-27T10:00:00Z",
"body": "Post body in **Markdown**."
}Errors: 404 post not found or is a draft
Subscribe an email address to the newsletter.
Request body
{ "email": "reader@example.com" }Response 201 No content.
Errors: 400 missing email, 409 already subscribed
Unsubscribe using the token from a newsletter email.
Query params: ?token=<unsubscribe-token>
Response 204 No content.
Errors: 400 missing token, 404 token not found
Requires: valid token cookie or Authorization: Bearer header.
Get the CSRF token for the current session. Use this after a page refresh when the token stored in memory is lost.
Response 200
{
"csrf_token": "abc123..."
}Requires: valid token cookie/header and X-CSRF-Token header.
List all posts including drafts.
Response 200: same shape as GET /api/posts but includes drafts.
Get a single post regardless of draft status, including body.
Response 200: same shape as GET /api/posts/{slug}.
Errors: 404 post not found
Create a new post. The slug is set in the URL path.
Slug rules: lowercase letters, numbers, and hyphens only. Example: my-first-post
Request body
{
"title": "My First Post",
"description": "A short description.",
"tags": ["go", "cms"],
"draft": true,
"publish_date": "2026-03-27",
"body": "Post body in **Markdown**."
}| Field | Required | Notes |
|---|---|---|
title |
Yes | |
description |
No | |
tags |
No | Array of strings |
draft |
No | Defaults to false |
publish_date |
No | Format YYYY-MM-DD. Defaults to today. |
body |
No | Markdown string |
Response 201 No content.
Errors: 400 invalid slug or missing title, 409 post already exists
Update an existing post. Replaces all fields.
Request body: same shape as POST /api/admin/posts/{slug}
Response 204 No content.
Errors: 400 missing title, 404 post not found
Delete a post and its content files from disk.
Response 204 No content.
Errors: 404 post not found
List all newsletter subscribers.
Response 200
[
{
"id": 1,
"email": "reader@example.com",
"token": "uuid-unsubscribe-token",
"subscribed_at": "2026-03-27T10:00:00Z"
}
]Returns [] if no subscribers.
Remove a subscriber by ID.
Response 204 No content.
Errors: 404 subscriber not found
Send an email to all subscribers. Requires SMTP to be configured.
Request body
{
"subject": "New post: My First Post",
"body": "Email body in plain text or HTML."
}Response 200
{ "sent": 42 }Errors: 400 missing subject or body, 503 SMTP not configured
Trigger a theme rebuild via a shared secret. Disabled (404) if
WEBHOOK_SECRET is not set in .env.
Pass the secret via X-Webhook-Secret header or Authorization: Bearer <secret>.
Response 202 Build started.
Response 401 Invalid secret.
Response 409 Rebuild already in progress.
Trigger an async theme rebuild. Returns immediately; poll status to track progress.
Response 202 Build started.
Response 409 A rebuild is already in progress.
Get the current rebuild status.
Response 200
{
"status": "success",
"output": "...",
"started_at": "2026-03-27T10:00:00Z",
"finished_at": "2026-03-27T10:01:05Z",
"error": ""
}status value |
Meaning |
|---|---|
idle |
No build has run yet |
running |
Build in progress |
success |
Last build succeeded |
failed |
Last build failed; see error field |
Get all site settings. Public endpoint — intended for themes to read site metadata.
Response 200
{
"site_name": "My Blog",
"site_description": "A personal blog about Go and systems.",
"social_github": "https://github.com/example",
"social_twitter": "https://twitter.com/example",
"social_linkedin": ""
}Returns a flat key/value object. Keys with no value set return an empty string.
Get all site settings (protected). Same response shape as GET /api/settings.
Update one or more site settings (protected + CSRF).
Request body
{
"site_name": "My Blog",
"site_description": "A personal blog about Go and systems.",
"social_github": "https://github.com/example",
"social_twitter": "",
"social_linkedin": ""
}Only known keys are accepted. Unknown keys return 400.
| Key | Description |
|---|---|
site_name |
Display name of the site |
site_description |
Short description used in meta tags |
social_github |
GitHub profile or repo URL |
social_twitter |
Twitter/X profile URL |
social_linkedin |
LinkedIn profile URL |
Response 204 No content.
Errors: 400 invalid JSON or unknown key
Upload an image file (protected + CSRF). Accepts multipart/form-data.
Form field: file — the image to upload.
Constraints:
- Max file size: 10 MB
- Allowed types:
image/jpeg,image/png,image/gif,image/webp,image/svg+xml - Content type is detected from file bytes, not the
Content-Typeheader
Response 201
{
"key": "550e8400-e29b-41d4-a716-446655440000-photo.jpg",
"filename": "photo.jpg",
"content_type": "image/jpeg",
"size": 204800,
"url": "https://example.com/media/550e8400-e29b-41d4-a716-446655440000-photo.jpg",
"created_at": "2026-04-01T10:00:00Z"
}Errors: 400 missing file or invalid form, 415 unsupported file type, 413 file too large
List all uploaded files (protected + CSRF). Returns newest first.
Response 200
[
{
"key": "550e8400-e29b-41d4-a716-446655440000-photo.jpg",
"filename": "photo.jpg",
"content_type": "image/jpeg",
"size": 204800,
"url": "https://example.com/media/550e8400-e29b-41d4-a716-446655440000-photo.jpg",
"created_at": "2026-04-01T10:00:00Z"
}
]Returns [] if no files have been uploaded.
Delete an uploaded file by key (protected + CSRF). Removes the file from storage and the database record.
Response 204 No content.
Errors: 400 missing key, 500 storage or database error
Serves an uploaded file by key. Only available when MEDIA_STORAGE=local (default).
When using S3-compatible storage, files are served directly from the bucket via the url field
returned by the upload and list endpoints.