Jant API Reference
Jant exposes a compact HTTP API for automations, content migration, settings tooling, and hosted control-plane operations.
- Base URL:
https://your-site.com - Default format: JSON
- Timestamps: Unix seconds
- Auth: session cookies, Bearer API tokens, or an internal admin token for
/api/internal/*
For static export and round-trip import, also see Export and Import. For backup planning, see Backups.
/api/auth/* is handled by better-auth and is primarily intended for browser auth flows, so it is not covered here.
API Surface
| Area | Base path | Auth |
|---|---|---|
| Public posts | /api/public/posts |
Public when enabled |
| Public Threads | /api/public/threads |
Public when enabled |
| Discover post status | /api/discover/posts |
Public |
| Posts | /api/posts |
API token or session |
| Threads | /api/threads |
API token or session |
| Upload sessions | /api/uploads |
API token or session |
| One-shot upload | /api/upload |
API token or session |
| Media | /api/media |
API token or session |
| Text attachment content | /api/attachments |
API token or session |
| MCP | /api/mcp |
API token or session |
| Collections | /api/collections |
API token or session |
| Smart collections | /api/smart-collections |
API token or session |
| Navigation items | /api/nav-items |
API token or session |
| Custom URLs | /api/custom-urls |
API token or session |
| Settings | /api/settings |
API token or session |
| Search | /api/search |
API token or session |
| Export | /api/export |
API token or session |
| GitHub webhooks | /api/github-sync |
GitHub signature |
| Telegram webhook | /api/telegram |
Telegram secret |
| Internal admin | /api/internal/* |
Internal admin token |
Auth labels in this document:
Public: no auth requiredPublic when enabled: public by default; returns404to every caller whenPUBLIC_API_ENABLED=falseSession or token: browser session cookie orAuthorization: Bearer <token>Internal admin token:Authorization: Bearer <INTERNAL_ADMIN_TOKEN>GitHub signature: anX-Hub-Signature-256header GitHub computes with the webhook secret; see GitHub webhooksTelegram secret: anX-Telegram-Bot-Api-Secret-Tokenheader carrying the secret the webhook was registered with; see Telegram webhook
Authentication
API tokens
For scripts and integrations, create an API token from Settings:
- Sign in to Jant.
- Open
Settings -> API Tokens. - Create a token and copy it immediately.
Use it as a Bearer token:
curl https://your-site.com/api/posts \
-H "Authorization: Bearer jnt_YOUR_TOKEN"
API tokens grant the same API access as an authenticated browser session for the current site.
Session cookies
Browser requests can use the normal session cookie after signing in at /signin.
Local development token
When DEV_API_TOKEN is configured, Jant also accepts it as a Bearer token for requests to a local host that come from the same machine. The host must be one of:
localhost127.0.0.1::1*.localtest.me
This is meant for local tooling, not production clients.
Internal admin token
/api/internal/* endpoints only accept the environment-provided INTERNAL_ADMIN_TOKEN.
If that token is not configured, those endpoints behave as if they do not exist and return 404.
Automation Entry Points
Jant exposes the site-owner automation surface two ways:
- HTTP JSON endpoints under
/api/* - an authenticated MCP endpoint at
/api/mcp
Projects created with create-jant also include examples/agent-content-automation/README.md, which shows copy-pasteable HTTP and MCP flows for posts, media, and settings.
Auth resolution for both surfaces:
- pass
Authorization: Bearer jnt_...(issued under Settings → API Tokens), or - on local hosts, send the same value with
DEV_API_TOKENfrom.dev.vars. - the public posts and Threads endpoints under
/api/public/*work without a token whilePUBLIC_API_ENABLED=true. GET /api/discover/postsworks without a token while the site is listed in Discover, independent ofPUBLIC_API_ENABLED.
MCP
Base path: /api/mcp
Auth: Session or token
Jant's MCP endpoint is a minimal HTTP JSON-RPC transport for remote agents and automation systems that already speak MCP.
Current transport behavior:
POSTonly- content type
application/json - takes
MCP-Protocol-Version: 2025-06-18; a request without it is read as that version, and any other version answers400. A later minor release can accept newer protocol versions as well; one is dropped only in a major release - supports
initialize,ping,tools/list,tools/call, andnotifications/initialized - does not support batch requests, SSE streaming, or session negotiation
Current tool groups:
- posts:
jant_posts_list,jant_posts_get,jant_posts_search,jant_posts_create,jant_posts_update,jant_posts_delete - threads:
jant_threads_list,jant_threads_get,jant_threads_list_posts - media:
jant_media_list,jant_media_get,jant_media_upload,jant_media_update,jant_media_delete - attachments:
jant_attachments_get_content - collections:
jant_collections_list,jant_collections_get,jant_collections_create,jant_collections_update,jant_collections_delete,jant_collections_add_thread,jant_collections_remove_thread - settings:
jant_settings_get,jant_settings_update
Tool calls return normal MCP result envelopes. Successful tool calls include both structuredContent and a JSON string copy in content[0].text, and a tool returns the objects its HTTP endpoint does. A failed call returns 200 OK with isError: true; its structuredContent has the HTTP error shape, { error, code } with details for a validation error, and content[0].text repeats the message.
jant_posts_search takes q and limit, searches as GET /api/search does, and returns the same result objects.
jant_posts_list, jant_threads_list, and jant_threads_list_posts take cursor and return nextCursor as GET /api/posts, GET /api/threads, and GET /api/threads/:id/posts do; see Pagination. jant_threads_list takes the same filters as GET /api/threads, and fold: true in place of include=fold. The tools that return posts take content: "markdown" as their endpoints take content=markdown.
Initialize:
curl -X POST https://your-site.com/api/mcp \
-H "Authorization: Bearer jnt_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2025-06-18" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}'
Create a post through tools/call:
curl -X POST https://your-site.com/api/mcp \
-H "Authorization: Bearer jnt_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2025-06-18" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"jant_posts_create","arguments":{"format":"note","bodyMarkdown":"Created through MCP.","status":"published","visibility":"public"}}}'
Conventions
JSON and timestamps
Unless an endpoint explicitly returns a ZIP, XML, or plain text response, it returns JSON.
A request field or query parameter an endpoint doesn't know is ignored, so a client that sends one works against an older Jant too. A value an endpoint can't read, in a field it does know, answers 400. include is the exception: it skips a value it doesn't offer, so a client asking for an extra a later release adds still gets the rest. A list's limit past either end of its range reads as that end: limit=500 on a list of at most 100 returns 100.
A field that takes one of a fixed set of values, such as format, status, visibility, or an error's code, can gain a value in a minor release. Read a format you don't recognize as note, and handle a code you don't recognize by the HTTP status.
All timestamps are Unix seconds:
{
"createdAt": 1706000000
}
IDs
Jant uses TypeIDs everywhere.
| Resource | Prefix | Example |
|---|---|---|
| Post | pst_ |
pst_01jpyx3m7gw4w3h7m4bknq0v1d |
| Media / attachment | med_ |
med_01jpyx4g9m8b4y50a4gx3t7p1n |
| Upload session | upl_ |
upl_01jpyx9h0m8w4g5q1c7d2f3r4s |
| Collection | col_ |
col_01jpyx5qds8y79w2dd6sv4rznj |
| Smart collection | smc_ |
smc_01jpyxd4k2m8w5q9r3t7v1b6nc |
| Custom URL / path record | pth_ |
pth_01jpyxb27t6m4v9r2k8s5c1qfh |
| Collection directory item | cdi_ |
cdi_01jpyx8r7s3v8m1q5c9k2f6gth |
| Nav item | nav_ |
nav_01jpyxcv3m7w4b8k2r5s9t1qfh |
Invalid IDs return 400.
Pagination
The post list (GET /api/posts), the Thread lists (GET /api/threads, GET /api/public/threads), a Thread's posts (GET /api/threads/:id/posts, GET /api/public/threads/:slug/posts), the media list (GET /api/media), the custom URL list (GET /api/custom-urls), and the jant_posts_list, jant_threads_list, jant_threads_list_posts, and jant_media_list MCP tools return one page and a nextCursor. Repeat the request with cursor set to nextCursor for the next page. nextCursor is null on the last page.
nextCursoris opaque: pass it back unchanged. Its format is not part of the API.- A post that exists for the whole walk and keeps its place in the order is returned exactly once, whatever else is published, edited, or deleted between requests. A post that moves during the walk, because its publish date is edited or a reply moves its Thread up, can be skipped or returned twice. To walk everything, use an order a reply doesn't move:
GET /api/posts, orsort=publishedon a Thread list. - A page can hold fewer posts than
limitand still have anextCursor. The walk ends whennextCursorisnull. - A
cursorthat can't be read, or that comes from a list in a different order, returns400.
The other lists, collections (GET /api/collections), smart collections (GET /api/smart-collections), navigation items (GET /api/nav-items), and a post's other versions, return every item in one response. If one of them gains pages in a later release, a request that doesn't ask for a page still gets all of it.
Slugs, paths, and aliases
- Post and collection
slugvalues are lowercasea-z,0-9, and-. - Post
pathis a create-time convenience field, not a general path-management API. - If a post
pathis itself a valid slug, Jant uses it as the canonical slug. - If a post
pathis not a valid slug, Jant slugifies it for the canonical URL and stores the original path as an alias. - Custom URL
pathcarries a leading slash in responses, and so doestoPathwhen it names a path on the site. A request may leave it offpath.
Body formats
Posts accept content in one of two mutually exclusive fields:
bodyMarkdown: recommended for scripts and migrationsbody: a TipTap JSON string, mainly for editor integrations
Jant renders stored content into:
bodyHtmlbodyText
Markdown support includes headings, lists, links, images, tables, fenced code blocks, blockquotes, and <!--more--> excerpt breaks.
Line breaks follow standard Markdown rules:
- A single newline stays within the same paragraph.
- A blank line starts a new paragraph.
- Use two trailing spaces or a backslash before the newline to create a hard line break.
Quote post field mapping
Quote posts use quote-specific names in the API:
- Send
sourceNameandsourceUrlin requests. - Quote responses return
sourceNameandsourceUrl. - Quote responses do not expose
titleorurl.
Error Format
Every error uses this shape:
{
"error": "Human-readable message",
"code": "VALIDATION_ERROR",
"details": {}
}
detailsis present for validation errors that carry structured field information. Its shape can change in any release, and so can the wording oferror: show them to a person or log them, and branch oncode. The one documented part ofdetailsis the settings endpoint'srejectedKeys.codeis always present, an error the server didn't expect included (INTERNAL_ERROR). An unknown/apipath answersNOT_FOUNDin this shape too.
Common error codes:
| Code | HTTP | Meaning |
|---|---|---|
VALIDATION_ERROR |
400 |
Invalid input, invalid ID, unsupported field combination |
UNAUTHORIZED |
401 |
Missing or invalid auth |
FORBIDDEN |
403 |
Authenticated but not allowed |
NOT_FOUND |
404 |
Resource does not exist |
CONFLICT |
409 |
Duplicate slug/path, invalid state transition, hosted-mode conflict |
MEDIA_QUOTA_EXCEEDED |
409 |
Hosted media quota would be exceeded |
LANGUAGE_IN_USE |
409 |
The language still has posts, so it can't be removed |
CONFIGURATION_ERROR |
500 |
Missing or invalid server configuration |
EXTERNAL_SERVICE_ERROR |
500 |
External dependency failed |
INTERNAL_ERROR |
500 |
An error the server didn't expect; the server log has the details |
SITE_UNAVAILABLE |
503 |
The hosted site is suspended |
Example validation error:
{
"error": "Provide either body or bodyMarkdown, not both",
"code": "VALIDATION_ERROR",
"details": {
"formErrors": [],
"fieldErrors": {
"bodyMarkdown": ["Provide either body or bodyMarkdown, not both"]
}
}
}
Posts
Base path: /api/posts
Jant supports three post formats:
| Format | Purpose | Required fields |
|---|---|---|
note |
Original writing | none |
link |
Shared reference | title, url |
quote |
Quoted text | quoteText |
Post responses include these fields:
| Field | Type | Notes |
|---|---|---|
id |
pst_* string |
Post ID |
format |
note | link | quote |
Post format |
status |
draft | published |
Stored post status |
visibility |
public | latest_hidden | private |
Resolved visibility shown to clients |
pinnedAt |
integer | null |
Pin timestamp |
featuredAt |
integer | null |
Feature timestamp |
slug |
string | Canonical slug |
title |
string | null |
Returned for non-quote responses; omitted for quote |
url |
string | null |
Returned for non-quote responses; usually null on notes |
sourceName |
string | null |
Returned instead of title for quote |
sourceUrl |
string | null |
Returned instead of url for quote |
displayTitle |
string | Short plain-text name: the title, or one derived from the content when there is none. Use it where the post is referenced from elsewhere |
body |
string | null |
Raw TipTap JSON string when stored that way; omitted when content=markdown |
bodyHtml |
string | null |
Rendered HTML; omitted when content=markdown |
bodyText |
string | null |
Plain-text rendering; omitted when content=markdown |
bodyMarkdown |
string | null |
Markdown source; only returned when content=markdown |
quoteText |
string | null |
Quote content |
summary |
string | null |
Optional summary |
rating |
integer | null |
1 to 5 when set |
replyToId |
pst_* string | null |
Parent reply/post ID |
threadId |
pst_* string |
Thread root ID |
language |
string | null |
BCP 47 content language, the same for every post in a Thread; null until the site first turns on multilingual content |
quietReply |
boolean | Reply published without announcing its Thread |
publishedAt |
integer | null |
Publish timestamp |
lastActivityAt |
integer | Newest post in the Thread, excluding quiet replies |
threadUpdatedAt |
integer | Newest post in the Thread, including quiet replies |
threadPostCount |
integer | Published posts in the Thread, root included; 1 alone |
createdAt |
integer | Unix seconds |
updatedAt |
integer | Unix seconds — last row write, including edits |
attachments |
array | Ordered media/text attachment objects |
collectionIds |
col_* string[] |
Shared Thread Collections; only in GET /api/posts/:id |
Post response shape
The post list and detail endpoints return the same core fields. GET /api/posts/:id additionally includes the shared Thread-level collectionIds; Root and Child responses return the same set.
Example:
{
"id": "pst_01jpyx3m7gw4w3h7m4bknq0v1d",
"format": "note",
"status": "published",
"visibility": "public",
"pinnedAt": null,
"featuredAt": null,
"slug": "hello-world",
"title": "Hello World",
"displayTitle": "Hello World",
"body": null,
"bodyHtml": "<p>Hello world</p>",
"bodyText": "Hello world",
"quoteText": null,
"summary": null,
"rating": null,
"replyToId": null,
"threadId": "pst_01jpyx3m7gw4w3h7m4bknq0v1d",
"language": "en",
"quietReply": false,
"publishedAt": 1706000000,
"lastActivityAt": 1706000000,
"threadUpdatedAt": 1706000000,
"threadPostCount": 1,
"createdAt": 1706000000,
"updatedAt": 1706000000,
"attachments": []
}
Notes:
- Quote posts replace
titleandurlwithsourceNameandsourceUrl. - Quote responses omit
titleandurlinstead of returning them asnull. replyToId !== nullmeans the post is a thread reply.threadIdpoints at the thread root.threadPostCountabove1means the post belongs to a Thread; Threads returns the rest of it. It is0while nothing in the Thread is published.GET /api/postsincludes both root posts and replies. There is currently noexcludeRepliesquery parameter.
List posts
GET /api/posts
Auth: Session or token
Query parameters:
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
format |
note | link | quote |
no | all | Format filter |
status |
draft | published |
no | published |
Status filter |
cursor |
string | no | none | Pass the previous nextCursor back unchanged |
limit |
integer | no | 100 |
1 to 100 |
content |
markdown |
no | none | Return bodyMarkdown instead of body, bodyHtml, and bodyText |
Response:
{
"posts": [
{
"id": "pst_01jpyx3m7gw4w3h7m4bknq0v1d",
"format": "note",
"status": "published",
"visibility": "public",
"slug": "hello-world",
"title": "Hello World",
"bodyHtml": "<p>Hello world</p>",
"bodyText": "Hello world",
"quoteText": null,
"replyToId": null,
"threadId": "pst_01jpyx3m7gw4w3h7m4bknq0v1d",
"publishedAt": 1706000000,
"createdAt": 1706000000,
"updatedAt": 1706000000,
"attachments": []
}
],
"nextCursor": "eyJ2IjoxLCJzIjoibmV3ZXN0OnB1Ymxpc2hlZDp1bnBpbm5lZCIsImsiOlsxNzA2MDAwMDAwLCJwc3RfMDFqcHl4M203Z3c0dzNoN200YmtucTB2MWQiXX0"
}
Notes:
- Each item uses the post response fields above, except list responses omit
collectionIds. - Roots and replies are listed alike, one post per item. Threads lists them grouped.
- Published posts are ordered by
publishedAt, newest first, withidbreaking ties. Pinned posts are not moved to the top, and a reply does not move its root. Drafts are ordered byupdatedAt, last edited first. - Paging follows Pagination.
Suggest or validate a slug
GET /api/posts/slug
Auth: Session or token
Query parameters:
| Parameter | Type | Required | Notes |
|---|---|---|---|
mode |
suggest |
yes | Suggest a slug from title |
title |
string | suggest | Source title used for slug suggestion |
postId |
pst_* string |
no | Exclude the current post when suggesting or checking |
mode |
check |
yes | Check whether a specific slug is available |
slug |
string | check | Lowercase slug candidate to validate and check |
Modes:
- Suggest from a title:
GET /api/posts/slug?mode=suggest&title=Hello%20World
Response:
{ "slug": "hello-world" }
- Check availability:
GET /api/posts/slug?mode=check&slug=hello-world
Response:
{
"slug": "hello-world",
"available": true
}
When editing an existing post, pass postId so the current slug counts as available:
GET /api/posts/slug?mode=check&slug=hello-world&postId=pst_...
Invalid slug candidates return 400, including reserved slugs and slugs with invalid characters.
Get a single post
GET /api/posts/:id
Auth: Session or token
This returns the full post plus shared Thread-level collectionIds, ordered attachments, and threadPosition: the post's place in its Thread, 1 for the root. jant_posts_get returns the same.
content=markdown returns the body as bodyMarkdown in place of body, bodyHtml, and bodyText. To edit a body, read it this way and send the edited Markdown back as bodyMarkdown.
Example:
{
"id": "pst_01jpyx3m7gw4w3h7m4bknq0v1d",
"format": "note",
"collectionIds": ["col_01jpyx5qds8y79w2dd6sv4rznj"],
"threadPosition": 1,
"attachments": [],
"slug": "hello-world",
"title": "Hello World",
"bodyHtml": "<p>Hello world</p>",
"bodyText": "Hello world"
}
Create a post
POST /api/posts
Auth: Session or token
Request body:
{
"format": "quote",
"quoteText": "What stands in the way becomes the way.",
"sourceName": "Marcus Aurelius",
"sourceUrl": "https://example.com/meditations",
"bodyMarkdown": "Still one of the clearest lines in the book.",
"status": "published",
"visibility": "public",
"publishedAt": 1706000000,
"slug": "from-marcus-aurelius",
"collectionIds": ["col_01jpyx5qds8y79w2dd6sv4rznj"],
"attachments": [
{ "type": "media", "mediaId": "med_01jpyx4g9m8b4y50a4gx3t7p1n" },
{
"type": "text",
"contentFormat": "markdown",
"content": "# Attached note\n\nExtra context here."
}
]
}
Fields:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
format |
note | link | quote |
yes | — | Post format |
title |
string | required for link |
— | Max 300; not allowed for quote |
sourceName |
string | no | null |
Quote attribution name, max 300; only for quote |
body |
string | no | null |
TipTap JSON string; mutually exclusive with bodyMarkdown |
bodyMarkdown |
string | no | null |
Recommended for scripts; mutually exclusive with body |
slug |
string | no | auto-generated | Canonical slug; mutually exclusive with path |
path |
string | no | — | Create-time address; starts with a letter or digit, then lowercase letters, numbers, -, ., and /; mutually exclusive with slug |
status |
draft | published |
no | published |
Post status |
visibility |
public | latest_hidden | private |
no | public |
Post visibility |
pinned |
boolean | no | false |
Pin the post; not allowed on replies |
featured |
boolean | no | false |
Mark as featured |
pinnedAt |
integer | null |
no | — | Pin at this time instead of now; wins over pinned. For restores |
featuredAt |
integer | null |
no | — | Feature at this time instead of now; wins over featured. For restores |
url |
absolute URL | required for link |
— | Allows http:, https:, or mailto:; not allowed for note or quote |
sourceUrl |
absolute URL | no | null |
Quote attribution URL; not allowed for non-quote |
quoteText |
string | required for quote |
— | Not allowed for note or link |
rating |
integer | no | null |
1 to 5; send 0 to clear on update |
collectionIds |
col_* string[] |
no | [] |
Shared Thread Collection TypeIDs; max 20 |
collectionEntries |
object[] | no | — | Collection memberships with their own times; wins over collectionIds. For restores |
replyToId |
pst_* string |
no | null |
Make this post a thread reply |
quietReply |
boolean | no | false |
Publish a reply without announcing its Thread on Latest |
language |
BCP 47 tag | no | detected | Content language, e.g. en, zh-Hans; replies inherit the Thread's |
translationOfId |
pst_* string |
no | null |
Link the new post into that post's translation group |
publishedAt |
integer | no | current time | Unix seconds; only valid when status is published |
createdAt |
integer | no | current time | Unix seconds; restores a moved post's creation time |
updatedAt |
integer | no | createdAt |
Unix seconds; restores a moved post's last edit time |
attachments |
attachment[] | no | [] |
Ordered attachments, max 20 |
Important rules:
- Use
bodyorbodyMarkdown, not both. bodymust be a TipTap document (adocnode) as a JSON string. Anything else is a400.createdAtandupdatedAtare for restores: an import or a migration that keeps a post's own times. Feeds reportupdatedAtas the entry's update time, and a Thread orders its replies bycreatedAt, then ID, after the root, which always comes first. Updates can't change either.- Use
slugorpath, not both. pathis only available on create. Post updates only supportslug.- Each
collectionEntriesitem is{ "collectionId": "col_…", "createdAt": 1706000000, "position": 0, "pinnedAt": null }; all butcollectionIdare optional. It restores when a Thread joined each collection, its place in a hand-ordered one, and whether it's pinned there. linkposts requiretitleandurl.quoteposts requirequoteTextand must usesourceName/sourceUrlinstead oftitle/url.noteposts do not accepturl,quoteText,sourceName, orsourceUrl.- Replies cannot be pinned.
- Replies inherit thread visibility.
- Replies inherit the root status unless you explicitly create the reply as
draft. - Set
collectionIdswhile creating a Thread root. Reply creation rejects non-empty Collection input; use the Thread Collection endpoints afterward, which accept either a Root or Child Post ID.
Path behavior:
path: "hello-world"creates the post at/hello-world.path: "2024/01/hello-world"creates a slugified canonical URL such as/2024-01-hello-worldand stores/2024/01/hello-worldas an alias.
Response: 201 Created with the full post object and ordered attachments.
Attachments
Posts accept an ordered attachments array. Order in the request is the order shown on the post.
An uploaded media item belongs to one post. A mediaId already attached to another post answers 409 with CONFLICT; to show the same file in a second post, such as a translation, upload it again.
Input shapes:
- Media attachment:
{ "type": "media", "mediaId": "med_...", "alt": "Optional alt text" }
- Text attachment:
{
"type": "text",
"contentFormat": "markdown",
"content": "# Heading",
"summary": "Optional summary"
}
Fields:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
type |
"media" |
yes | — | Media attachment |
mediaId |
med_* string |
yes | — | Previously uploaded media ID |
alt |
string | no | null |
Alt text, max 500. On an update, an omitted alt keeps the stored text and "" clears it |
type |
"text" |
yes | — | Text attachment |
contentFormat |
"markdown" |
yes | — | Currently only markdown is supported |
content |
string | yes | — | Non-empty text content |
summary |
string | no | null |
Optional summary, max 300 |
Response shapes:
- Media attachment:
{
"type": "media",
"id": "med_...",
"url": "/media/med_....jpg",
"previewUrl": "/media/med_....jpg",
"posterUrl": null,
"alt": null,
"blurhash": null,
"width": 800,
"height": 600,
"durationSeconds": null,
"mimeType": "image/jpeg",
"originalName": "photo.jpg",
"size": 1024000,
"summary": null,
"chars": null
}
- Text attachment:
{
"type": "text",
"id": "med_...",
"url": "/media/med_....md",
"contentFormat": "markdown",
"contentUrl": "/api/attachments/med_.../content",
"summary": "Attached note Extra context here.",
"chars": 33
}
A text attachment's url is its Markdown source file. contentUrl returns the same source as JSON and needs a session or token, so public posts leave it out.
Get text attachment content
GET /api/attachments/:id/content
Auth: Session or token
This only works for type: "text" attachments.
Response:
{
"id": "med_01jpyx7c0s7y5v2m4b8g1f9qkr",
"type": "text",
"contentFormat": "markdown",
"content": "# Attached note\n\nExtra context here.",
"summary": "Attached note Extra context here.",
"chars": 33
}
Update a post
PUT /api/posts/:id
Auth: Session or token
This is a partial update. Omitted fields stay unchanged.
Example:
{
"sourceName": "Epictetus",
"sourceUrl": "https://example.com/discourses",
"bodyMarkdown": "Updated commentary in **Markdown**."
}
Request body fields:
This endpoint accepts the same JSON fields as POST /api/posts, except the ones a post gets when it's created: path, replyToId, quietReply, translationOfId, createdAt, and updatedAt. It ignores those like any other field it doesn't know. All fields are optional. Additionally, update accepts null to clear title, sourceName, body, bodyMarkdown, url, sourceUrl, quoteText, and rating.
Attachment replacement rules:
- Omit
attachments: keep existing attachments - Send
"attachments": []: remove all attachments - Send a new
attachmentsarray: replace all attachments in that order
An attachment the update removes is deleted, file included.
Notes:
- To change a post's address, send
slug, or add a custom URL through/api/custom-urls. - The fields must suit the post's format, the one in the body or else its current one, with the same rules as create: a note takes no
urlorsourceName, and a quote usessourceNameandsourceUrl, nottitleandurl. A field that doesn't answers400. - To link a post as a translation, use the translation endpoints. A reply's place in its Thread is fixed when it's created.
- Thread replies reject direct
visibilityandpinnedchanges. - Sending
collectionIdsfrom either a Root or Child update replaces the shared Thread Collection set. - Draft updates cannot set
publishedAt. - To clear a rating, send
0.
Response: 200 OK with the updated post.
Delete a post
DELETE /api/posts/:id
Auth: Session or token
Deletes the post and its attachments, files included. If the target is a thread root, its replies are deleted as part of the same operation.
Response:
{ "success": true }
Public posts
Base path: /api/public/posts
GET /api/public/posts/:slug returns one post in the public reading view, not
the editing view used in Settings; the Thread lists under /api/public/threads
carry their posts in the same shape. When PUBLIC_API_ENABLED=false, these
public endpoints return 404 to every caller. Authenticated clients can use
/api/posts instead.
Lists of public posts are Thread lists: GET /api/public/threads.
Public post responses include these fields:
| Field | Type | Notes |
|---|---|---|
id |
pst_* string |
Post ID |
format |
note | link | quote |
Post format |
status |
published |
Public endpoints only return published posts |
visibility |
public | latest_hidden |
latest_hidden comes from single-post reads, and from Thread lists that ask for it |
slug |
string | Canonical slug |
permalink |
string | The post's public path, including any site prefix: its first custom URL, or /{slug} without one |
title |
string | null |
Returned for note and link posts |
url |
string | null |
Returned for link posts |
sourceName |
string | null |
Returned instead of title for quote |
sourceUrl |
string | null |
Returned instead of url for quote |
bodyHtml |
string | null |
Rendered HTML; omitted when content=markdown |
bodyText |
string | null |
Plain-text rendering; omitted when content=markdown |
bodyMarkdown |
string | null |
Markdown source; only returned when content=markdown |
quoteText |
string | null |
Quote content |
summary |
string | null |
Optional summary |
rating |
integer | null |
1 to 5 when set |
previewKind |
string | null |
Link preview kind |
previewProvider |
string | null |
Link preview provider |
previewImageUrl |
string | null |
Public preview image URL |
replyToId |
pst_* string | null |
Parent reply/post ID |
threadId |
pst_* string |
Thread root ID |
language |
string | null |
BCP 47 content language, the same for every post in a Thread; null until the site first turns on multilingual content |
quietReply |
boolean | Reply published without announcing its Thread. Always false on Thread roots |
pinnedAt |
integer | null |
Pin timestamp |
featuredAt |
integer | null |
Feature timestamp |
publishedAt |
integer | null |
Publish timestamp |
lastActivityAt |
integer | Thread root: newest post in the Thread, excluding quiet replies. Editing a post never moves it |
threadUpdatedAt |
integer | Thread root: newest post in the Thread, including quiet replies. Editing a post never moves it |
threadPostCount |
integer | Published posts in the Thread, root included; 1 for a post on its own |
createdAt |
integer | Unix seconds |
updatedAt |
integer | Unix seconds — when this row was last written, including edits |
attachments |
array | Ordered media/text attachment objects |
collections |
object[] | Public collection refs with id, slug, title, and url |
Get a public post by slug
GET /api/public/posts/:slug
Auth: Public when enabled
This returns a single published public post by canonical slug.
Notes:
latest_hiddenposts remain readable by direct slug.- Draft and private posts return
404. content=markdownreturnsbodyMarkdownand omitsbodyHtml/bodyText.
Language and translations
These endpoints only matter on a site with multilingual content turned on. Every post response carries language; the endpoints below list and link a post's other versions.
Set a Thread's language
PUT /api/posts/:id/language
Auth: Session or token
Language is uniform inside a Thread, so this sets it on the root and every reply. Pass any post in the Thread.
{ "language": "zh-Hans" }
Rejected with 409 when another post in the same translation group already holds that language.
Response: 200 OK with { "success": true, "language": "zh-Hans" }.
List a post's other versions
GET /api/posts/:id/translations
Auth: Session or token
The Thread roots linked to this post as versions in other languages, excluding the post itself. Empty when it belongs to no translation group.
{
"translations": [
{
"id": "pst_01jpyx3m7gw4w3h7m4bknq0v1d",
"slug": "hello-world",
"title": "Hello World",
"label": "Hello World",
"language": "en"
}
]
}
label is what to show in a list: the post's display title, falling back to its slug for untitled notes.
Find linkable posts
GET /api/posts/:id/translations/candidates
Auth: Session or token
| Query | Type | Required | Default | Notes |
|---|---|---|---|---|
q |
string | yes | — | Substring of title or body, max 200 |
limit |
integer | no | 8 |
1 to 20 |
Returns published Thread roots this post could actually be linked to: written in a language its group does not already hold, and — when this post already belongs to a group — not in a group of their own. Newest first, as { "candidates": [...] } with the same entries as the endpoint above.
Look up a linkable post by address
GET /api/posts/:id/translations/resolve
Auth: Session or token
| Query | Type | Required | Default | Notes |
|---|---|---|---|---|
url |
string | yes | — | A path or a full URL on this site, max 2048 |
Answers the same question as the search above, about one address instead of a phrase. A full URL on any host the site answers on, a site path prefix, and a language prefix are all accepted — /en/hello, /hello, and https://example.com/hello name one post. Stored redirects are followed, and an address that lands on a reply names its Thread.
{
"resolution": {
"kind": "ok",
"address": "/hello-world",
"candidate": {
"id": "pst_01jpyx3m7gw4w3h7m4bknq0v1d",
"slug": "hello-world",
"label": "Hello World",
"language": "en"
}
}
}
When the post cannot be linked, kind says why instead: external (another site), not_found, not_a_post, same_thread, unpublished, no_language, same_language, language_taken, group_conflict, or group_language_taken. The last two carry the language in the way. Eligibility is decided here, not by the caller — POST /api/posts/:id/translations applies the same rules.
Link a version
POST /api/posts/:id/translations
Auth: Session or token
{ "postId": "pst_01jpyx3m7gw4w3h7m4bknq0v1d" }
Joins the side without a group into the other's group, minting one when neither has any. Rejected with 409 when the two languages clash or when both sides already have a group — merging two groups would silently restructure both.
Response: 200 OK with { "success": true }.
Unlink a version
DELETE /api/posts/:id/translations
Auth: Session or token
Removes this post from its translation group. The other members stay linked to each other.
Response: 200 OK with { "success": true }.
Threads
Base paths: /api/threads and /api/public/threads
A Thread is a root post and its replies. It has no ID of its own: it uses its root's, which every post in it carries as threadId. The endpoints that read one Thread accept any of its posts.
/api/threads is the author's view: every status and visibility, with posts in the editing view. /api/public/threads is the reader's: published Threads that aren't private, with posts in the reading view. When PUBLIC_API_ENABLED=false, /api/public/threads returns 404 to every caller.
Thread responses include these fields:
| Field | Type | Notes |
|---|---|---|
id |
pst_* string |
The root post's ID |
postCount |
integer | Published posts in the Thread, root included |
lastActivityAt |
integer | Newest post in the Thread, excluding quiet replies |
threadUpdatedAt |
integer | Newest post in the Thread, including quiet replies |
root |
object | The root post |
fold |
object | Only with include=fold; see The fold |
Example from /api/public/threads?include=fold:
{
"id": "pst_01jpyx3m7gw4w3h7m4bknq0v1d",
"postCount": 9,
"lastActivityAt": 1706700000,
"threadUpdatedAt": 1706700000,
"root": {
"id": "pst_01jpyx3m7gw4w3h7m4bknq0v1d",
"slug": "dialing-in",
"permalink": "/dialing-in",
"threadPostCount": 9
},
"fold": {
"leading": [{ "id": "pst_01jpz0c7r4e7kqv3m8x2n5b6td" }, { "id": "…" }],
"hidden": 3,
"gap": {
"id": "pst_01jpz2k9w1f8m5c7q3v6x4b2hn",
"slug": "third-cup",
"permalink": "/third-cup",
"cursor": "eyJ2IjoxLCJzIjoidGhyZWFkIiwiayI6WzEsMTcwNjYwMDAwMCwicHN0XzAxanB6MGM3cjRlN2txdjNtOHgybjViNnRkIl19"
},
"trailing": [{ "id": "…" }, { "id": "…" }, { "id": "…" }]
}
}
Posts in the example are cut to a few fields; each is a full post.
The fold
include=fold adds the replies the homepage shows under a Thread: the earliest ones, then the latest ones with the newest last, and a count of the replies left out between them.
| Field | Type | Notes |
|---|---|---|
leading |
post[] | The earliest replies |
hidden |
integer | Replies left out between leading and trailing |
gap |
object | null |
The first reply left out: id, slug, and cursor, plus permalink on /api/public/threads. null when hidden is 0 |
trailing |
post[] | The latest replies, oldest first. The newest reply is the last one |
Notes:
- Which replies the fold keeps is the site's choice and can change. Read
hiddenrather than working it out: for a published Thread,1 + leading.length + trailing.length + hiddenispostCount. - A Thread without replies has an empty fold:
leadingandtrailingare empty andhiddenis0. - To load the replies left out, list the Thread's posts from the gap on:
GET /api/public/threads/:slug/posts?cursor=<gap.cursor>&limit=<hidden>. The cursor is opaque, asnextCursoris.
List Threads
GET /api/public/threads
GET /api/threads
Auth: Public when enabled for /api/public/threads, Session or token for /api/threads
Unfiltered, /api/public/threads lists what the homepage lists: Threads hidden from Latest are left out, the newest activity comes first, and pinned Threads are on top. /api/threads lists every visibility. Both take the archive's filters.
Query parameters:
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
sort |
activity | published | updated | oldest | rating |
no | activity |
See the orders below. A named collection changes the default |
visibility |
public | featured | hidden | any | private |
no | see notes | hidden is the URL spelling of latest_hidden, which is also read. private on /api/threads only |
format |
note | link | quote |
no | all | Format of the root |
collection |
string | no | none | Collection slug, or several comma-separated. A Thread in any one of them matches |
year |
integer | no | none | Roots whose publishedAt falls in this calendar year in the site's time zone, whatever the order |
media |
comma-separated MediaKind | any | none |
no | none | Kinds (image, video, audio, text, document): roots with an attachment of one of them. any = with any attachment, none = without |
title |
any | none |
no | none | Roots with a title, or without |
replies |
any | none |
no | none | Threads with published replies, or single posts |
lang |
BCP 47 tag | no | all | Restrict to one content language |
status |
draft | published |
no | published |
/api/threads only |
include |
fold |
no | none | Add the fold to each Thread. Comma-separated; a value it doesn't offer is skipped |
cursor |
string | no | none | Pass the previous nextCursor back unchanged |
limit |
integer | no | 20 |
1 to 100 |
content |
markdown |
no | none | Return bodyMarkdown instead of the rendered body fields |
Orders:
sort |
Order | Where the site uses it |
|---|---|---|
activity |
Newest activity first, pinned Threads on top. A reply moves its Thread up; a quiet reply doesn't | Homepage, a collection's newest |
published |
Newest root publication first, id breaking ties. A reply doesn't move its Thread |
Archive |
updated |
Like activity, but quiet replies count too, and pinned Threads aren't moved to the top |
Archive's ?sort=updated |
oldest |
Oldest root publication first | A collection's oldest |
rating |
Highest rating first, then newest activity | A collection's rating_desc |
Response:
{
"threads": [
{ "id": "pst_01jpyx3m7gw4w3h7m4bknq0v1d", "postCount": 9, "…": "…" }
],
"nextCursor": null
}
Notes:
visibilityleft out means what the homepage shows on/api/public/threads, and every visibility on/api/threads.visibility=anyon/api/public/threadsadds Threads hidden from Latest.visibility=allreturns400, and so doesvisibility=privateon/api/public/threads.visibility=featuredlists newest-published first. Asortother thanpublishedreturns400.- A single
collectionwithoutsortlists in that collection's ownsortOrder, with its pinned Threads on top; several collections list byactivity.activity,oldest, andratingon a collection keep its pins.publishedandupdatedread it as a plain filter. - To walk every public Thread, pass
visibility=any&sort=published: inactivityorder a new reply moves a Thread, which a walk can skip. - Paging follows Pagination.
- An invalid value returns
400. An unknowncollectionslug returns an empty result set.
Get a Thread
GET /api/public/threads/:slug
GET /api/threads/:id
Auth: Public when enabled for /api/public/threads/:slug, Session or token for /api/threads/:id
Returns the Thread of the post the slug or ID names, root or reply. include=fold adds the fold, and content=markdown works as on the list.
Notes:
/api/public/threads/:slugreturns404when the slug names a draft or a private post. A Thread hidden from Latest is returned./api/threads/:idreturns drafts and private Threads.
List a Thread's posts
GET /api/public/threads/:slug/posts
GET /api/threads/:id/posts
Auth: Public when enabled for /api/public/threads/:slug/posts, Session or token for /api/threads/:id/posts
Returns the posts of the Thread the slug or ID names, in Thread order: the root first, then replies by creation time. The Thread page and the feeds use the same order.
Query parameters:
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
status |
draft | published |
no | published |
/api/threads/:id/posts only |
cursor |
string | no | none | Pass the previous nextCursor or a fold's gap.cursor back unchanged |
limit |
integer | no | 100 |
1 to 100 |
content |
markdown |
no | none | Return bodyMarkdown instead of the rendered body fields |
Response: { "posts": [Post], "nextCursor": string | null }, with each post as the Posts or Public posts endpoints return it.
Notes:
/api/public/threads/:slug/postsreturns published posts only.- A reply published during a walk joins the end of the Thread, so the walk reaches it.
- Paging follows Pagination.
Uploads
All upload endpoints require auth.
Jant exposes two upload APIs:
/api/upload: one-shot upload, preferred for ordinary scripts and migrations/api/uploads: session-based upload, preferred for large files, unreliable connections, and application clients that need resumable transport
File size is limited by UPLOAD_MAX_FILE_SIZE_MB and defaults to 1024 MB.
Images upload as JPEG, PNG, or WebP, video as MP4, and audio as MP4 (audio/mp4); any other image, video, or audio type answers 400, so convert it first, as the editor does in the browser. Documents, text, archives, fonts, design files, and code upload in most formats and are served as downloads, except PDFs, which open in the browser.
The type is the one the request declares for the file. curl -F "[email protected]" declares application/octet-stream for extensions it doesn't know, WebP among them, and the file is then stored as a download; name the type, as in -F "[email protected];type=image/webp".
Session-based upload flow
Base path: /api/uploads
Use this flow for new integrations:
POST /api/uploads/init- Upload the file using the returned transport
- Optionally upload a poster image for video
POST /api/uploads/:id/complete
Upload sessions expire after roughly 15 minutes.
Start an upload session
POST /api/uploads/init
Request body:
{
"filename": "photo.webp",
"contentType": "image/webp",
"size": 1024000,
"checksumSha256": "base64-encoded-sha256"
}
Fields:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
filename |
string | yes | — | Original filename |
contentType |
string | yes | — | MIME type |
size |
integer | yes | — | File size in bytes |
checksumSha256 |
string | no | null |
Base64-encoded SHA-256 checksum |
The response includes an upload session ID (upl_*) and one of three transport kinds. Send the file to transport.url as given: on a site with SITE_PATH_PREFIX, the relay URLs already start with the prefix.
Relay transport
{
"id": "upl_01jpyx9h0m8w4g5q1c7d2f3r4s",
"transport": {
"kind": "relay",
"method": "PUT",
"url": "/api/uploads/upl_01jpyx9h0m8w4g5q1c7d2f3r4s/body"
}
}
Multipart relay transport
{
"id": "upl_01jpyx9h0m8w4g5q1c7d2f3r4s",
"transport": {
"kind": "multipartRelay",
"method": "PUT",
"url": "/api/uploads/upl_01jpyx9h0m8w4g5q1c7d2f3r4s/part",
"partSize": 52428800
}
}
Presigned PUT transport
When the storage driver supports direct uploads, Jant can return a presigned target instead:
{
"id": "upl_01jpyx9h0m8w4g5q1c7d2f3r4s",
"transport": {
"kind": "put",
"url": "https://uploads.example.test/...",
"method": "PUT",
"headers": {
"Content-Type": "image/webp",
"Cache-Control": "public, max-age=31536000, immutable"
},
"expiresAt": 1706000900
}
}
Upload the file body
Use the transport returned by init.
For relay:
PUT /api/uploads/:id/body
- Body: raw file bytes
- Success response:
204 No Content
For multipartRelay:
PUT /api/uploads/:id/part?partNumber=N
- Body: raw part bytes
- Success response:
{
"partNumber": 1,
"etag": "etag-value"
}
For put:
- Upload directly to the returned
transport.url - Use the returned HTTP method and headers unchanged
Upload a poster image
PUT /api/uploads/:id/poster
Use this when uploading a video and you want a WebP poster frame.
- Body: raw WebP bytes
- Success response:
204 No Content
Complete an upload session
POST /api/uploads/:id/complete
Request body:
{
"width": 1200,
"height": 800,
"blurhash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
"waveform": "optional-waveform",
"summary": "Optional summary for text uploads",
"chars": 123,
"parts": [
{ "partNumber": 1, "etag": "etag-1" },
{ "partNumber": 2, "etag": "etag-2" }
]
}
Fields:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
width |
integer | no | null |
Image/video width; positive |
height |
integer | no | null |
Image/video height; positive |
blurhash |
string | no | null |
Blurhash string, max 200 |
waveform |
string | no | null |
Audio waveform, max 2000 |
summary |
string | no | null |
Mainly for text uploads, max 500 |
chars |
integer | no | null |
Mainly for text uploads; non-negative |
parts |
array | required for multipartRelay |
— | [{partNumber, etag}] |
Response: 201 Created with the media object the upload became.
Abort an upload session
POST /api/uploads/:id/abort
Response:
{ "success": true }
One-shot upload
Base path: /api/upload
Use this when a script or one-time migration benefits from sending one file in a single multipart request. Use /api/uploads when files are large or the connection is unreliable.
Upload a file
POST /api/upload
Content type: multipart/form-data
Form fields:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
file |
file | yes | — | Main file |
width |
integer | no | null |
Image/video width |
height |
integer | no | null |
Image/video height |
alt |
string | no | null |
Alt text |
blurhash |
string | no | null |
Blurhash |
waveform |
string | no | null |
Audio waveform |
summary |
string | no | null |
Summary for text uploads |
durationSeconds |
integer | no | null |
Video or audio length |
poster |
file | no | — | Poster frame for video uploads |
Response: 201 Created with the media object the file became.
Media
Base path: /api/media
The files you've uploaded, whether attached to a post or not. Upload them with POST /api/upload or an upload session.
Media responses, from these endpoints and the MCP media tools, include these fields:
| Field | Type | Notes |
|---|---|---|
id |
med_* string |
Media ID |
postId |
pst_* string | null |
The post the file is attached to; null while unattached |
type |
media | text |
text for a text attachment, media for every other file |
mediaKind |
image | video | audio | text | document |
Kind of file |
mimeType |
string | MIME type |
originalName |
string | The file's name as uploaded |
size |
integer | Bytes |
width |
integer | null |
Pixels, for images and video |
height |
integer | null |
Pixels, for images and video |
durationSeconds |
number | null |
For audio and video |
alt |
string | null |
Alt text |
blurhash |
string | null |
Placeholder hash for images |
waveform |
string | null |
Waveform data for audio |
summary |
string | null |
Text attachment summary |
chars |
integer | null |
Text attachment length in characters |
createdAt |
integer | Unix seconds |
updatedAt |
integer | Unix seconds |
url |
string | The file's public URL; for type: "text", its Markdown source |
previewUrl |
string | type: "media" only: a resized image for images, the file's URL otherwise |
posterUrl |
string | null |
type: "media" only: the poster frame for video |
contentFormat |
markdown |
type: "text" only |
contentUrl |
string | type: "text" only: the Markdown source as JSON; see Get text attachment content |
List media
GET /api/media
Auth: Session or token
Query parameters:
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
limit |
integer | no | 50 |
1 to 200 |
mimePrefix |
string | no | none | Prefix filter such as image/ or video/ |
cursor |
string | no | none | Pass the previous nextCursor back unchanged |
Response:
{
"media": [
{
"id": "med_01jpyx4g9m8b4y50a4gx3t7p1n",
"postId": null,
"originalName": "photo.webp",
"mimeType": "image/webp",
"size": 1024000,
"width": 1200,
"height": 800,
"durationSeconds": null,
"alt": "Cover image",
"blurhash": null,
"waveform": null,
"summary": null,
"chars": null,
"mediaKind": "image",
"createdAt": 1706000000,
"updatedAt": 1706000000,
"type": "media",
"url": "/media/med_01jpyx4g9m8b4y50a4gx3t7p1n.webp",
"previewUrl": "/media/med_01jpyx4g9m8b4y50a4gx3t7p1n.webp",
"posterUrl": null
}
],
"nextCursor": null
}
Notes:
- Newest first.
nextCursorisnullon the last page. - This list may include ordinary uploaded binaries and stored text attachments.
- Text attachments use
type: "text"and exposecontentFormatpluscontentUrlinstead ofpreviewUrlandposterUrl.
Get a media item
GET /api/media/:id
Auth: Session or token
Returns one media or text attachment record using the same response shape as GET /api/media.
Update media alt text
PUT /api/media/:id
Auth: Session or token
Request body:
{
"alt": "Cover image"
}
Rules:
altis trimmed before storing.- An empty
altclears it, and the response carriesnull. - Max length is
500.
Response: 200 OK with the updated media object.
Delete a media item
DELETE /api/media/:id
Auth: Session or token
Deletes the media record and its stored object.
Response:
{ "success": true }
Collections
Base path: /api/collections
Collections group complete Threads by topic. A Thread can belong to multiple Collections, and its root and children always share the same memberships.
Collection responses include these fields:
| Field | Type | Notes |
|---|---|---|
id |
col_* string |
Collection ID |
slug |
string | Canonical collection slug |
title |
string | Display title |
description |
string | null |
Optional description |
sortOrder |
newest | oldest | rating_desc |
Per-collection Thread sort order |
createdAt |
integer | Unix seconds |
updatedAt |
integer | Unix seconds |
threadCount |
integer | Only present in list responses |
recentActivityAt |
integer | Only present in list responses |
Directory item responses include these fields:
| Field | Type | Notes |
|---|---|---|
id |
cdi_* string |
Directory item ID |
type |
collection | smart_collection | divider | link |
Item kind |
collectionId |
col_* string | null |
Present for type: "collection" |
smartCollectionId |
smc_* string | null |
Present for type: "smart_collection" |
label |
string | null |
Divider label or link label |
url |
string | null |
Present for type: "link" |
description |
string | null |
Optional description for type: "link" |
position |
string | Fractional ordering key |
createdAt |
integer | Unix seconds |
updatedAt |
integer | Unix seconds |
Notes:
- A directory item is a position, not a membership. Every collection and smart collection appears in the directory whether or not one exists — unplaced entries are appended — so a directory item exists only to interleave an entry with dividers and links and to drag it around.
- Creating a collection automatically creates a
type: "collection"directory item. - Deleting a collection also deletes its
type: "collection"directory item. POST /api/collections/directory-itemsonly acceptsdividerandlink. Collection-backed items are managed through collection CRUD, not this endpoint.
List collections
GET /api/collections
Auth: Session or token
Query parameters:
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
view |
compose |
no | none | Specialized compose view sorted by recent activity |
lang |
BCP 47 tag | no | all | Count only Threads in this content language in threadCount and recentActivityAt |
Default response:
{
"collections": [
{
"id": "col_01jpyx5qds8y79w2dd6sv4rznj",
"slug": "reading",
"title": "Reading",
"description": "Books I've read",
"sortOrder": "newest",
"createdAt": 1706000000,
"updatedAt": 1706000000,
"threadCount": 12,
"recentActivityAt": 1706100000
}
],
"smartCollections": [
{
"id": "smc_01jpyxa2k4d7n6r9s1t3v5w8xz",
"slug": "quotes",
"title": "Quotes",
"description": "Things worth keeping.",
"selection": { "format": "quote" },
"sortOrder": "newest",
"layout": null,
"createdAt": 1706000000,
"updatedAt": 1706000000,
"threadCount": 34,
"recentActivityAt": 1706090000
}
],
"directoryItems": [
{
"id": "cdi_01jpyx8r7s3v8m1q5c9k2f6gth",
"type": "collection",
"collectionId": "col_01jpyx5qds8y79w2dd6sv4rznj",
"smartCollectionId": null,
"label": null,
"url": null,
"position": "a0",
"createdAt": 1706000000,
"updatedAt": 1706000000
}
]
}
Notes:
- The default response returns directory ordering in
directoryItems, and every smart collection insmartCollections. Both kinds carrythreadCountandrecentActivityAt, measured the same way, because the directory prints them side by side. Both count private Threads. view=composereturns collections sorted by recent activity and always returns an emptydirectoryItemsarray. It carries no smart collections: a post cannot be added to one by hand, so offering it in a compose picker would be a control that does nothing.
Get a collection
GET /api/collections/:id
Auth: Session or token
Response:
{
"id": "col_01jpyx5qds8y79w2dd6sv4rznj",
"slug": "reading",
"title": "Reading",
"description": "Books I've read",
"sortOrder": "newest",
"createdAt": 1706000000,
"updatedAt": 1706000000
}
Check an address
GET /api/collections/slug
Auth: Session or token
| Parameter | Type | Required | Notes |
|---|---|---|---|
mode |
check |
yes | Test a typed address |
slug |
string | yes | Address to test |
collectionId |
col_* string |
no | Ignore the address this one already holds |
Returns { "slug": "…", "available": true }. A collection shares the root URL
namespace with posts and smart collections, so anything holding the address
makes it unavailable.
Create a collection
POST /api/collections
Auth: Session or token
Request body:
{
"slug": "reading",
"title": "Reading",
"description": "Books I've read",
"sortOrder": "newest"
}
Fields:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
slug |
string | yes | — | Canonical collection slug, max 200, lowercase letters/numbers/hyphens only |
title |
string | yes | — | Display title, max 120 |
description |
string | no | null |
Optional description, max 500 |
sortOrder |
newest | oldest | rating_desc |
no | newest |
Per-Collection Thread sort order |
Notes:
- Reserved slugs are rejected.
- On success, Jant also creates the collection's
type: "collection"directory item.
Response: 201 Created with the collection object.
Update a collection
PUT /api/collections/:id
Auth: Session or token
This is a partial update.
Request body fields:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
slug |
string | no | unchanged | Same rules as create |
title |
string | no | unchanged | Max 120 |
description |
string | null |
no | unchanged | Send null to clear |
sortOrder |
newest | oldest | rating_desc |
no | unchanged | Replaces the collection sort order |
Response: 200 OK with the updated collection object.
Delete a collection
DELETE /api/collections/:id
Auth: Session or token
This removes the collection itself. Posts remain intact.
Response:
{ "success": true }
Create a directory item
POST /api/collections/directory-items
Auth: Session or token
Creates a manual directory item for the /collections directory page.
Request body:
Divider:
{
"type": "divider",
"label": "Essays"
}
Link:
{
"type": "link",
"label": "Quotes",
"url": "/archive?format=quote"
}
Fields by type:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
type |
divider |
yes | — | Creates a divider item |
label |
string | null |
no | null |
Divider label, max 60; blank values are stored as null |
type |
link |
yes | — | Creates a custom link item |
label |
string | yes (for link) |
— | Link label, 1-60 chars after trim |
url |
string | yes (for link) |
— | Relative path or absolute http:, https:, or mailto: URL |
description |
string | null |
no | null |
Link only: Markdown shown under the link |
Notes:
type: "collection"is not accepted here.- New items are appended to the end of the directory.
Response:
{
"id": "cdi_01jpyx8r7s3v8m1q5c9k2f6gth",
"type": "divider",
"collectionId": null,
"label": "Essays",
"url": null,
"position": "a1",
"createdAt": 1706000000,
"updatedAt": 1706000000
}
Update a directory item
PUT /api/collections/directory-items/:id
Auth: Session or token
Request body:
{ "label": "Essays" }
This is a partial update.
Request body fields:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
label |
string | null |
no | unchanged | For dividers: update label, or send null to clear |
url |
string | no | unchanged | For links: update URL |
Notes:
- Divider items only use
label. - Link items use
labelandurl. - Link labels cannot be cleared with
null. - Collection-backed items should be managed through collection endpoints, not updated directly here.
Response: 200 OK with the updated directory item.
Move a directory item
PUT /api/collections/directory-items/:id/move
Auth: Session or token
Request body:
{
"after": "cdi_01jpyx8r7s3v8m1q5c9k2f6gth",
"before": "cdi_01jpyx9m4h7s2v6b1r8k3t5qc"
}
Fields:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
after |
cdi_* string | null |
no | null |
Place the item after this neighbor |
before |
cdi_* string | null |
no | null |
Place the item before this neighbor |
Notes:
afterandbeforeare both optional and nullable.- Use
before: "<id>"withafter: nullto move to the beginning. - Use
after: "<id>"withbefore: nullto move to the end. - If both are missing or
null, Jant appends the item to the end.
Response: 200 OK with the moved directory item, including its new position.
Delete a directory item
DELETE /api/collections/directory-items/:id
Auth: Session or token
Response:
{ "success": true }
An ID that names no directory item returns 404.
Add a Thread to a collection
POST /api/collections/:id/threads
Auth: Session or token
Request body:
{ "threadId": "pst_01jpyx3m7gw4w3h7m4bknq0v1d" }
Fields:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
threadId |
pst_* string |
yes | — | Thread root or child Post ID |
The ID is normalized to the Thread root. The root and every child share one Collection membership set. Adding a Thread that is already in the Collection changes nothing.
Response:
{ "success": true }
On this endpoint and the three below, a Collection or post that doesn't exist
returns 404.
Remove a Thread from a collection
DELETE /api/collections/:id/threads/:threadId
Auth: Session or token
Removes the whole Thread from the Collection. It does not delete the Thread or the Collection. Removing a Thread that isn't in the Collection changes nothing.
Response:
{ "success": true }
Pin or unpin a Thread in a collection
PUT /api/collections/:id/threads/:threadId/pin
DELETE /api/collections/:id/threads/:threadId/pin
Auth: Session or token
Both endpoints accept a Thread root or child Post ID and operate on the shared
Thread membership. Pinning a Thread that isn't in the Collection returns 409;
unpinning one changes nothing. Response:
{ "success": true }
Smart Collections
Base path: /api/smart-collections
Auth: Session or token on every endpoint. A smart collection's page is
public; managing one is not.
A smart collection is a collection whose members come from conditions rather than from tagging. Nothing is added to one by hand, so there are no membership endpoints here — the conditions are the membership.
Smart collection responses include these fields:
| Field | Type | Notes |
|---|---|---|
id |
smc_* string |
Smart collection ID |
slug |
string | Canonical address, in the same namespace as posts |
title |
string | Display title. Required |
description |
string | null |
Optional description |
selection |
object | The conditions. {} collects every post |
sortOrder |
newest | oldest | rating_desc |
Order of the posts the conditions gather, named and valued as a collection's |
layout |
list | grid | null |
null follows the site's archive layout |
createdAt |
integer | Unix seconds |
updatedAt |
integer | Unix seconds |
threadCount |
integer | Only present in list responses |
recentActivityAt |
integer | Only present in list responses |
The selection object
Each key is one condition, and a key may appear once. Conditions are combined with AND. Omitting a key means that dimension is not part of the conditions — there is no "any" value, because a key that is absent already says that.
| Key | Value |
|---|---|
collection |
Array of exactly one col_* id |
format |
note | link | quote |
title |
boolean — true has a title, false has none |
year |
integer, 1971 or later |
media |
"any" | "none" | array of image video audio text document |
replies |
boolean — true threads with replies, false single posts |
visibility |
public | featured | latest_hidden |
A key this table doesn't list answers 400 rather than being ignored like an
unknown field elsewhere: dropping a condition would publish a wider page than
the one asked for.
visibility never accepts private, and collection never accepts more than
one id. The first is because a smart collection is a published page and can
never name a set only its author can see; the second is because two ids would
be an OR, which the conditions do not express.
List smart collections
GET /api/smart-collections
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
lang |
BCP 47 tag | no | all | Narrows both measures to one language view |
Response: { "smartCollections": SmartCollection[] }, each carrying
threadCount and recentActivityAt.
Both measures answer for the caller. A private thread is counted, and dates the
smart collection, only for a caller who could read it — the same rule the
collection directory follows. recentActivityAt is the newest activity among
the threads the conditions match, where activity means the thread gained a
post; editing one is not activity. A smart collection whose conditions match
nothing reports its own updatedAt.
Get a smart collection
GET /api/smart-collections/:id
Response: the smart collection object.
Create a smart collection
POST /api/smart-collections
Body:
{
"slug": "quotes",
"title": "Quotes",
"description": "Things worth keeping.",
"selection": { "format": "quote", "media": "any" },
"sortOrder": "newest",
"layout": null
}
slug and title are required; everything else is optional. Returns 201 with
the smart collection object. A slug already used by a post, a
collection, or another smart collection returns 409 — they share one address
space.
Update a smart collection
PUT /api/smart-collections/:id
Same body, every field optional. Returns the updated smart collection object. Sending selection replaces the conditions
entirely: a dimension you leave out is cleared, not kept.
Changing slug moves the address immediately and does not leave a redirect
behind. Any navigation item pointing at this smart collection follows.
Delete a smart collection
DELETE /api/smart-collections/:id
Response: { "success": true }. The address stops working at once.
Preview a selection
POST /api/smart-collections/preview
Counts what a set of conditions would gather, without saving anything.
Body: { "selection": { … } } — the same shape create validates. POST rather
than GET because the conditions are a typed body; spelling them into a URL
would mean inventing a second encoding for them.
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
lang |
BCP 47 tag | no | all | Narrows both counts to a language |
Response:
{ "count": 34, "baseline": 1240 }
count is how many threads match; baseline is how many there are in total.
Both are counted for the caller, so a signed-in author may see larger numbers
than the page shows an anonymous reader — the same rule as everywhere else.
Check an address
GET /api/smart-collections/slug
| Parameter | Type | Required | Notes |
|---|---|---|---|
mode |
suggest | check |
yes | Derive one from a title, or test a typed one |
title |
string | suggest |
Title to derive from |
slug |
string | check |
Address to test |
smartCollectionId |
smc_* string |
no | Ignore the address this one already holds |
mode=suggest returns { "slug": "…" }; mode=check returns
{ "slug": "…", "available": true }.
Navigation Items
Base path: /api/nav-items
Navigation items power the header navigation.
Nav item responses include these fields:
| Field | Type | Notes |
|---|---|---|
id |
nav_* string |
Nav item ID |
type |
link | system | collection | smart_collection | page |
What the item points at |
systemKey |
latest | featured | archive | collections | subscribe | rss | settings | null |
null unless type: "system" |
collectionId |
col_* string | null |
null unless type: "collection" |
smartCollectionId |
smc_* string | null |
null unless type: "smart_collection" |
postId |
pst_* string | null |
null unless type: "page" |
label |
string | Author's override, or "" to follow the target |
url |
string | Stored URL or path |
targetTitle |
string | null |
The target's current title, shown when label is "". null for link and system items |
placement |
header | more |
In the header, or in its More menu |
position |
string | Fractional ordering key |
createdAt |
integer | Unix seconds |
updatedAt |
integer | Unix seconds |
List nav items
GET /api/nav-items
Auth: Session or token
Response:
{
"navItems": [
{
"id": "nav_01jpyxcv3m7w4b8k2r5s9t1qfh",
"type": "link",
"label": "GitHub",
"url": "https://github.com/your-username",
"position": "a0",
"createdAt": 1706000000,
"updatedAt": 1706000000
}
]
}
Create a nav item
POST /api/nav-items
Auth: Session or token
Create a custom link:
{
"type": "link",
"label": "GitHub",
"url": "https://github.com/your-username"
}
Create a built-in item:
{
"type": "system",
"systemKey": "archive"
}
Fields by type:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
type |
link |
yes | — | Creates a custom nav link |
label |
string | yes (for link) |
— | Link label, 1-100 chars after trim |
url |
string | yes (for link) |
— | Relative path or absolute http:, https:, or mailto: URL |
type |
system |
yes | — | Creates a built-in nav item |
systemKey |
latest | featured | archive | collections | subscribe | rss | settings |
yes (for system) |
— | Built-in destination key |
type |
collection | smart_collection | page |
yes | — | Points at a collection, a smart collection, or a standalone page |
collectionId |
col_* string |
yes (for collection) |
— | Collection to point at |
smartCollectionId |
smc_* string |
yes (for smart_collection) |
— | Smart collection to point at |
postId |
pst_* string |
yes (for page) |
— | Published, non-private, titled Note to point at |
placement |
header | more |
no | see notes | In the header, or in its More menu |
System keys:
latestfeaturedarchivecollectionssubscribersssettings
Notes:
- Built-in items get their label and URL automatically.
placementdefaults toheader, except for the built-incollections,subscribe,rss, andsettingsitems, which default tomore.- Jant rejects duplicate built-in items.
- A
collection,smart_collection, orpageitem stores itslabelempty unless you send one. It then shows its target's current title and follows it when the target is renamed, and its URL follows when the target's address moves. Only a label you typed is stored, and it then wins in every language view.
Response: 201 Created with the new nav item.
Move a nav item
PUT /api/nav-items/:id/move
Auth: Session or token
Request body:
{
"after": "nav_...",
"before": "nav_..."
}
afterandbeforeare optional and nullable.- If neither is provided, the item moves to the end.
Response: 200 OK with the moved nav item, including its new position.
Update a nav item
PUT /api/nav-items/:id
Auth: Session or token
Request body:
{
"label": "Source",
"url": "https://github.com/your-username"
}
Notes:
- This is a partial update.
- Built-in system items reject manual label and URL edits.
- Only
label,url, andplacementare accepted.
Response: 200 OK with the updated nav item.
Delete a nav item
DELETE /api/nav-items/:id
Auth: Session or token
Response:
{ "success": true }
Custom URLs
Base path: /api/custom-urls
Custom URLs let you attach extra paths to posts or collections, or define internal redirects.
Custom URL responses include these fields:
| Field | Type | Notes |
|---|---|---|
id |
pth_* string |
Custom URL ID |
path |
string | The address, with a leading slash |
targetType |
post | collection | redirect | archive |
Target kind |
targetId |
string | null |
The post's or collection's TypeID; null for other kinds |
toPath |
string | null |
redirect only: a path on the site, with a leading slash, or an http(s) address |
redirectType |
301 | 302 | null |
redirect only: the status it answers with |
archiveQuery |
string | null |
archive only: the archive query the address shows |
createdAt |
integer | Unix seconds |
Target types:
| Type | Meaning | Key fields |
|---|---|---|
post |
Alias that resolves to a post | targetId |
collection |
Alias that resolves to a collection | targetId |
redirect |
Redirect to another path or another site | toPath, redirectType |
archive |
A saved archive view; read and delete only | archiveQuery |
archive addresses can't be created anymore; a smart collection does the same job. Existing ones keep working, and the list returns them.
List custom URLs
GET /api/custom-urls
Auth: Session or token
Query parameters:
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
limit |
integer | no | 100 |
1 to 100 |
cursor |
string | no | none | Pass the previous nextCursor back unchanged |
Response:
{
"customUrls": [
{
"id": "pth_01jpyxb27t6m4v9r2k8s5c1qfh",
"path": "/blog/old-post",
"targetType": "redirect",
"targetId": null,
"toPath": "/my-new-slug",
"redirectType": 301,
"archiveQuery": null,
"createdAt": 1706000000
},
{
"id": "pth_01jpyxbk8v4m2s7r9c5t1g6qdn",
"path": "/essays/on-writing",
"targetType": "post",
"targetId": "pst_01jpyx3m7gw4w3h7m4bknq0v1d",
"toPath": null,
"redirectType": null,
"archiveQuery": null,
"createdAt": 1706000000
}
],
"nextCursor": null
}
Notes:
- Newest first.
nextCursorisnullon the last page; see Pagination. - The list covers aliases, redirects, and archive addresses. Canonical post and collection slugs are not custom URLs and aren't listed.
Create a custom URL
POST /api/custom-urls
Auth: Session or token
Request body:
{
"path": "/blog/old-post",
"targetType": "redirect",
"toPath": "/my-new-slug",
"redirectType": 301
}
Fields:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
path |
string | yes | — | Max 512; starts with a letter or digit, then lowercase letters, numbers, -, ., and /; the leading / may be left off |
targetType |
post | collection | redirect |
yes | — | Target kind |
targetId |
string | required for post or collection |
— | The post's or collection's TypeID, or its slug |
toPath |
string | required for redirect |
— | A path on the site, such as /new-path?format=note, or a full http:// or https:// address. A path is lowercased and keeps its query string; an address is kept as given |
redirectType |
301 | 302 |
no | 301 |
Only used for redirect. The strings "301" and "302" are accepted too |
Examples:
Redirect an old path:
{
"path": "/blog/2024/my-old-post",
"targetType": "redirect",
"toPath": "/my-new-slug",
"redirectType": 301
}
Create an alias for a post:
{
"path": "/essays/on-writing",
"targetType": "post",
"targetId": "on-writing"
}
Important notes:
pathmust not collide with an existing slug or custom URL.- Reserved paths are rejected.
- A redirect can point at a path on the site or at another site. A path is followed within the language view it was reached from.
- A post or collection target that doesn't exist answers
404. - The response names the target by TypeID, whichever you sent.
Response: 201 Created with the new custom URL object.
Delete a custom URL
DELETE /api/custom-urls/:id
Auth: Session or token
This only deletes non-canonical custom URL records. Canonical post and collection slugs are not removable through this endpoint.
Response:
{ "success": true }
Settings
Base path: /api/settings
These endpoints manage user-editable site settings and a small amount of UI state.
All settings endpoints require auth.
Editable setting keys
GET /api/settings and PUT /api/settings operate on these, and on the appearance settings. GET also reports the site's languages.
All values are strings because they map directly to stored config values.
| Key | Meaning | Example value |
|---|---|---|
SITE_NAME |
Site title | "My Blog" |
SITE_DESCRIPTION |
Site description | "Notes and links" |
SITE_LANGUAGE |
BCP 47 content language | "en" |
DASHBOARD_LANGUAGE |
Dashboard catalog language | "zh-Hans" |
MAIN_RSS_FEED |
Canonical feed kind | "featured" |
ARCHIVE_DEFAULT_LAYOUT |
Archive default layout | "list" |
PAGE_SIZE |
Default page size | "25" |
SEARCH_PAGE_SIZE |
Search page size | "25" |
ARCHIVE_PAGE_SIZE |
Archive page size | "25" |
SUMMARY_MAX_PARAGRAPHS |
Summary paragraph limit | "5" |
SUMMARY_MAX_CHARS |
Summary character limit | "500" |
RSS_FEED_LIMIT |
RSS item limit | "50" |
RSS_PUBLISH_DELAY_SECONDS |
Feed publication delay | "300" |
TIME_ZONE |
IANA timezone | "Asia/Shanghai" |
SITE_FOOTER |
Footer HTML/text | "<p>Footer</p>" |
SHOW_JANT_BRANDING_ON_HOME |
Branding toggle | "true" |
NOINDEX |
Search-engine exclusion | "true" |
PUBLIC_API_ENABLED |
Anonymous JSON reads | "true" |
RSS_FEEDS_ENABLED |
Atom feed publishing | "true" |
Notes:
- Editable keys use an explicit allowlist in the config registry; env-only, secret, and internal keys are excluded by default.
- Boolean and numeric settings are still strings in the API.
- Send strings in
PUT /api/settings, not JSON booleans or numbers. TIME_ZONEis normalized to canonical IANA names when possible.GET /api/settingsreturns the effective environment or built-in fallback for editable keys that are not stored yet.- In demo mode,
NOINDEXis always returned as"true".
Appearance setting keys
Each has its own screen under Settings rather than a Config Editor row. PUT /api/settings checks a value the way that screen would.
| Key | Meaning | Example value |
|---|---|---|
THEME |
Color theme ID; "" follows DEFAULT_THEME |
"paper" |
FONT_THEME |
Font theme ID; "" follows DEFAULT_FONT_THEME |
"literary" |
THEME_MODE |
auto, light, or dark |
"dark" |
CUSTOM_CSS |
Custom CSS, trimmed; locked on the demo site | "body { color: red; }" |
SHOW_HEADER_AVATAR |
Avatar in the site header, "true" or "false" |
"true" |
A theme or font theme ID that Jant doesn't have answers 400.
Get editable settings
GET /api/settings
Auth: Session or token
Response:
{
"settings": {
"SITE_NAME": "Jant",
"SITE_DESCRIPTION": "Thoughts, links, and quotes — one post at a time",
"SITE_LANGUAGE": "en",
"MAIN_RSS_FEED": "featured",
"ARCHIVE_DEFAULT_LAYOUT": "list",
"PAGE_SIZE": "25",
"SEARCH_PAGE_SIZE": "25",
"ARCHIVE_PAGE_SIZE": "25",
"SUMMARY_MAX_PARAGRAPHS": "5",
"SUMMARY_MAX_CHARS": "500",
"RSS_FEED_LIMIT": "50",
"RSS_PUBLISH_DELAY_SECONDS": "300",
"TIME_ZONE": "UTC",
"SITE_FOOTER": "",
"SHOW_JANT_BRANDING_ON_HOME": "false",
"NOINDEX": "false",
"PUBLIC_API_ENABLED": "true",
"RSS_FEEDS_ENABLED": "true",
"THEME": "tufte",
"FONT_THEME": "classic",
"THEME_MODE": "auto",
"CUSTOM_CSS": "",
"SHOW_HEADER_AVATAR": "false",
"MULTILINGUAL_ENABLED": "false",
"ADDITIONAL_LANGUAGES": ""
}
}
Notes:
- The response always returns every setting with the value in effect, not only keys stored in the database.
- Environment-only and internal keys never appear in this response.
Update editable settings
PUT /api/settings
Auth: Session or token
Request body:
{
"SITE_NAME": "New Name",
"SITE_DESCRIPTION": "Updated description"
}
Request rules:
- The body must be a JSON object whose values are strings.
SITE_NAMEis trimmed and limited to120characters.SITE_DESCRIPTIONis trimmed and limited to1000characters.SITE_FOOTERis trimmed and limited to5000characters.- Boolean settings accept only
"true"or"false". - Enum settings accept only the options listed above.
SITE_LANGUAGEaccepts and canonicalizes valid BCP 47 language tags.TIME_ZONEaccepts canonical IANA names and normalizes legacy aliases such as"Beijing"to"Asia/Shanghai".PAGE_SIZE,SEARCH_PAGE_SIZE, andARCHIVE_PAGE_SIZEaccept integers from1to100.SUMMARY_MAX_PARAGRAPHSaccepts integers from1to50.SUMMARY_MAX_CHARSaccepts integers from1to1500.RSS_FEED_LIMITaccepts integers from1to200.RSS_PUBLISH_DELAY_SECONDSaccepts integers from0to7200;0disables the delay.- Resetting
SEARCH_PAGE_SIZEorARCHIVE_PAGE_SIZEmakes it inherit the effectivePAGE_SIZEvalue.
Behavior:
- Editable keys are updated.
- Rejected keys are ignored if at least one editable key remains.
- If every provided key is rejected, the endpoint returns
400. - Successful responses return the full current editable settings object, plus optional top-level
rejectedKeys.
Example partial-apply response:
{
"settings": {
"SITE_NAME": "New Name",
"SITE_DESCRIPTION": "Thoughts, links, and quotes — one post at a time",
"SITE_LANGUAGE": "en",
"MAIN_RSS_FEED": "featured",
"ARCHIVE_DEFAULT_LAYOUT": "list",
"TIME_ZONE": "UTC",
"SITE_FOOTER": "",
"SHOW_JANT_BRANDING_ON_HOME": "",
"NOINDEX": ""
},
"rejectedKeys": ["AUTH_SECRET"]
}
Rejected keys are returned:
- in
details.rejectedKeyson400 - in top-level
rejectedKeyson successful partial updates
In demo mode, NOINDEX updates are rejected and the returned value stays "true".
Restore the site's languages
PUT /api/settings/import
Auth: Session or token
Restores the languages a site export recorded. PUT /api/settings doesn't take them: Settings → Languages changes them one at a time with its own checks.
| Key | Notes |
|---|---|
ADDITIONAL_LANGUAGES |
Comma-separated language tags besides SITE_LANGUAGE |
MULTILINGUAL_ENABLED |
"true" or "false"; on only with a second language |
Request body: an object of those keys and string values. A key left out keeps its current value.
The response is what PUT /api/settings returns.
- Other keys are left out and listed in
rejectedKeys. A request with neither key answers400. - Each language passes the checks adding it in Settings would, against the current
SITE_LANGUAGE. A language whose URL prefix collides with an existing address answers409.
Reset a Config Editor setting
DELETE /api/settings/:key
Auth: Session or token
Removes the database override for one resettable Config Editor setting. This
includes directly editable values and the safe scalar linked settings THEME,
FONT_THEME, THEME_MODE, and SHOW_HEADER_AVATAR.
The successful response is what GET /api/settings returns, with the value the
setting falls back to:
{ "settings": { "SITE_NAME": "Jant" } }
Environment-only, secret, unknown, specialized content/file keys, and
demo-locked keys return 400 without changing stored settings.
Upload site avatar and icons
POST /api/settings/avatar
Auth: Session or token
Content type: multipart/form-data
Form fields:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
file |
file | yes | — | Main avatar image: PNG, JPEG, or WebP |
favicon |
file | no | — | Favicon .ico payload |
appleTouch |
file | no | — | Apple touch icon, PNG |
Response:
{ "success": true }
Notes:
- File storage must be configured or the endpoint returns
500. - Omitting
filereturns400, as does a file whose contents aren't the image type it names. An SVG is refused. - On success, this endpoint updates the internal avatar/favicon settings used by site rendering.
Remove site avatar and related icons
DELETE /api/settings/avatar
Auth: Session or token
Removes the stored avatar and related favicon settings.
Response:
{ "success": true }
Search
Base path: /api/search
Search is the author's: it covers every published post, including private posts and replies in a private Thread. Readers search on the /search page, which leaves private posts out for anyone signed out.
Search posts
GET /api/search
Auth: Session or token
Query parameters:
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
q |
string | yes | none | Maximum length 200 |
limit |
integer | no | 20 |
1 to 50 |
Result objects include these fields:
| Field | Type | Notes |
|---|---|---|
id |
pst_* string |
Post ID |
format |
note | link | quote |
Post format |
slug |
string | Canonical slug |
snippet |
string | omitted | Search snippet; may contain <mark> tags |
publishedAt |
integer | null |
Publish timestamp |
permalink |
string | Public path, including any configured site prefix: the post's first custom URL, or /{slug} without one |
visibility |
public | latest_hidden | private |
Resolved visibility, inherited from the Thread's root |
title |
string | null |
Present for note and link results |
url |
string | null |
Present for note and link results |
sourceName |
string | null |
Present instead of title for quote results |
sourceUrl |
string | null |
Present instead of url for quote results |
Response:
{
"query": "hello",
"results": [
{
"id": "pst_01jpyx3m7gw4w3h7m4bknq0v1d",
"format": "note",
"title": "Hello World",
"slug": "hello-world",
"snippet": "...matched <mark>hello</mark> text...",
"publishedAt": 1706000000,
"permalink": "/hello-world",
"visibility": "public",
"url": null
}
],
"count": 1
}
Notes:
snippetmay contain<mark>tags.- All search results include
permalink. - Quote results use
sourceNameandsourceUrlinstead oftitleandurl. - Search covers published posts only. Drafts don't match.
- Search isn't rate-limited. The per-client limit,
RATE_LIMIT_SEARCH_PER_MIN, applies to signed-out readers on the/searchpage.
Export
Base path: /api/export
Export the site as a Hugo archive
POST /api/export/hugo
Auth: Session or token
Request body: none
Response:
- Content type:
application/zip - Download filename:
jant-export.zip
Archive contents:
hugo.tomlcontent/{root-slug}/_index.mdfor each thread root (branch bundle)content/{root-slug}/{reply-slug}/index.mdfor each reply (leaf bundle,build.render = "never")content/{collection-slug}/_index.mdfor each collectioncontent/_index.md,content/archive/_index.md,content/featured/_index.md,content/collections/_index.mddata/jant.toml(nav, branding, collections directory)themes/jant/layouts/*andthemes/jant/static/*README.md
Notes:
- Each thread root is a Hugo branch bundle; its replies live as nested leaf bundles rendered inline by the thread template.
- Collection membership is exported once per Thread as a top-level
collectionsfront-matter array on the root bundle, with per-entrycollected_at/position/pinned_at. Reply bundles do not repeat it. - Navigation items, theme CSS, custom CSS, favicon, and Apple touch icon are included in the scaffold when available.
- Exported post bodies become Markdown. Media references point back to the original Jant media URLs; the ZIP does not bundle original media binaries.
Example:
curl -X POST https://your-site.com/api/export/hugo \
-H "Authorization: Bearer jnt_YOUR_TOKEN" \
-o jant-export.zip
This export is suitable for:
- static publishing with Hugo
- archival
- round-trip import into another Jant instance
For the CLI import/export workflow, see Export and Import.
Internal Admin API
Base path: /api/internal
These endpoints are for hosted control-plane and maintenance workflows, not normal site integrations.
Requirements:
Authorization: Bearer <INTERNAL_ADMIN_TOKEN>- Some site-management endpoints also require host-based site resolution mode
If INTERNAL_ADMIN_TOKEN is not configured, these endpoints return 404.
Notes:
api-tokensand upload-cleanup endpoints operate on the current resolved site.- Managed-site lifecycle and domain endpoints return
409outside host-based mode.
API token maintenance
Health check
GET /api/internal/api-tokens/health
Auth: Internal admin token
Response:
{ "ok": true }
Purge all user API tokens for the current site
POST /api/internal/api-tokens/purge
Auth: Internal admin token
This removes user-created API tokens for the currently resolved site only.
Response:
{ "deleted": 2 }
Upload session maintenance
Clean up expired temporary upload sessions
POST /api/internal/uploads/cleanup
Auth: Internal admin token
Request body:
{ "limit": 10 }
Fields:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
limit |
integer | no | unspecified | Positive integer, maximum 500 |
Notes:
- The JSON body is optional. If the request is not JSON, the endpoint treats it as an empty object.
- File storage must be configured or the endpoint returns
500.
Response:
{
"abortedMultipartUploads": 0,
"deletedSessions": 1
}
Response fields:
| Field | Type | Notes |
|---|---|---|
abortedMultipartUploads |
integer | Number of underlying multipart uploads aborted |
deletedSessions |
integer | Number of expired upload-session rows removed |
Managed site lifecycle
These endpoints are only available in host-based mode.
Create a managed site
POST /api/internal/sites
Auth: Internal admin token
Request body:
{
"key": "demo-cloud",
"primaryHost": "demo-cloud.example.com",
"siteName": "Demo Cloud",
"siteLanguage": "en-US",
"timeZone": "America/New_York"
}
Fields:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
key |
string | yes | — | Lowercase site key, 3-40 chars, letters/numbers/hyphens |
primaryHost |
string | yes | — | Lowercase hostname, max 255 |
siteName |
string | yes | — | Display name, 1-120 chars after trim |
siteLanguage |
string | no | en |
Content locale detected by the control plane |
timeZone |
string | no | UTC |
Runtime-supported IANA name or fixed offset such as +08:00 |
idempotencyKey |
string | no | — | Retry key, max 128 chars |
Response:
{
"primaryHost": "demo-cloud.example.com",
"siteId": "sit_01...",
"status": "active"
}
Notes:
- New managed sites start with
status: "active". - Jant seeds onboarding as completed and stores the provided
SITE_NAME. - Valid browser timezone identifiers retain their own IANA or fixed-offset semantics instead of being collapsed by the curated settings UI list.
- Unknown optional timezone metadata falls back to UTC and does not fail site provisioning. Explicit settings updates still reject invalid timezones.
- Duplicate site keys return
409. - Duplicate primary hosts return
409.
Delete a managed site
DELETE /api/internal/sites/:siteId
Auth: Internal admin token
Response: 204 No Content
This removes the target site and its associated site-scoped records.
Get managed-site media usage
GET /api/internal/sites/:siteId/media-usage
Auth: Internal admin token
Response:
{
"siteId": "sit_01...",
"mediaBytesUsed": 3072
}
Export a managed site
GET /api/internal/sites/:siteId/export
Auth: Internal admin token
Response:
- Content type:
application/zip - Filename resembles
<site-key>-site-export.zip - Export shape matches
POST /api/export/hugo, but for the specified managed site
Suspend a managed site
POST /api/internal/sites/:siteId/suspend
Auth: Internal admin token
Response:
{
"siteId": "sit_01...",
"status": "suspended"
}
Resume a managed site
POST /api/internal/sites/:siteId/resume
Auth: Internal admin token
Response:
{
"siteId": "sit_01...",
"status": "active"
}
Managed site domains
Domain objects include these fields:
| Field | Type | Notes |
|---|---|---|
id |
string | Site domain ID |
host |
string | Lowercase hostname |
kind |
primary | alias |
Domain role for the site |
redirectToPrimary |
boolean | Whether requests redirect |
List domains
GET /api/internal/sites/:siteId/domains
Auth: Internal admin token
Response:
{
"domains": [
{
"host": "example.com",
"id": "sdm_01...",
"kind": "primary",
"redirectToPrimary": true
}
]
}
Add a domain
POST /api/internal/sites/:siteId/domains
Auth: Internal admin token
Request body:
{
"host": "www.example.com",
"makePrimary": false
}
Fields:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
host |
string | yes | — | Lowercase hostname, max 255 |
makePrimary |
boolean | no | false |
When true, demotes the current primary domain |
Notes:
- Hosts are trimmed and normalized to lowercase.
- Adding a host already attached to this site returns
409. - Adding a host already attached to another site returns
409.
Response: 201 Created with the full domains list.
Promote a domain to primary
POST /api/internal/sites/:siteId/domains/:domainId/primary
Auth: Internal admin token
Response: updated domains list.
Notes:
- If the target domain is already primary, the response still returns the current
domainslist. - Missing
domainIdreturns404.
Delete a domain
DELETE /api/internal/sites/:siteId/domains/:domainId
Auth: Internal admin token
Response: updated domains list.
Notes:
- Deleting the current primary domain without promoting another domain first returns
409. - Missing
domainIdreturns404.
Other Public Endpoints
These are not part of the JSON content-management API, but they are often useful in automation or operations.
| Endpoint | Auth | Response | Notes |
|---|---|---|---|
GET /healthz |
Public | JSON | Lightweight liveness probe |
GET /readyz |
Public | JSON | Readiness check for startup config and database |
GET /feed |
Public | RSS | Canonical site feed (latest or featured, based on settings) |
GET /feed/atom.xml |
Public | Atom | Canonical site feed in Atom format |
GET /latest/feed |
Public | RSS | Latest public posts feed (accepts ?format=) |
GET /featured/feed |
Public | RSS | Featured posts feed |
GET /archive/feed |
Public | RSS | Full archive feed incl. latest_hidden (archive filters apply) |
GET /feed/latest |
Public | Redirect | Legacy 308 → /latest/feed (preserves query string) |
GET /feed/latest/atom.xml |
Public | Redirect | Legacy 308 → /latest/feed |
GET /feed/featured |
Public | Redirect | Legacy 308 → /featured/feed |
GET /feed/featured/atom.xml |
Public | Redirect | Legacy 308 → /featured/feed |
GET /feed/all |
Public | Redirect | Legacy alias → /latest/feed |
GET /feed/all/atom.xml |
Public | Redirect | Legacy Atom alias → /latest/feed |
GET /:slug/feed |
Public | RSS | Collection feed for one collection |
GET /collections/:slug/feed |
Public | RSS | Collection feed for a collection selection |
GET /sitemap.xml |
Public | XML | Sitemap for published posts |
GET /robots.txt |
Public | Text | Robots rules and sitemap location |
GET /api/discover/posts |
Public | JSON | Whether posts are still in the feeds a Discover directory reads |
Health and readiness
Liveness
GET /healthz
Auth: Public
Response:
{ "status": "ok" }
This endpoint bypasses site resolution and only answers whether the process is up.
Readiness
GET /readyz
Auth: Public
Response:
{
"status": "ok",
"version": "0.7.0-3f9c2a7b1e4d8c60",
"startedAt": 1789000000,
"checks": {
"startupConfig": { "ok": true },
"database": { "ok": true }
}
}
Notes:
- Returns
200when all checks pass. - Returns
503whenstatusis"error". startupConfig.erroranddatabase.errorappear when a check fails.versionis the package version followed by the first 16 characters of the build's commit id.startedAtis when the process started serving, in Unix seconds. Node only; absent on Workers.- This endpoint is stricter than
/health: it verifies startup configuration and performs a lightweight database query.
Feeds
Feed endpoints are public and return cached XML with Cache-Control: public, max-age=60 while RSS_FEEDS_ENABLED=true. When disabled, canonical and legacy feed URLs return 404; previously cached responses may remain available for up to 60 seconds.
Feed notes:
- Feeds are resource-first: a feed lives at
{page}/feed, the same shape as collection feeds (/{slug}/feed). The site root/is the only special case — its feed is/feed. GET /feedandGET /feed/atom.xmluse the configuredMAIN_RSS_FEEDto chooselatestorfeatured.GET /latest/feedaccepts?format=note|link|quote.- Invalid
formatvalues are ignored rather than rejected. - Every feed accepts
?limit=for one response of a different length, from1to500; a larger value gets500, and a value that is not a positive whole number leaves the site'sRSS_FEED_LIMITin place. The feed'srel="self"link keeps the address without it. - Every entry carries
<jant:id>, the post's ID, beside<id>, which is the permalink. The permalink changes when a slug is renamed or the site moves domain; the ID never does. - Latest feeds include published root posts only, excluding private posts and
latest_hiddenposts. - Featured feeds include published featured root posts and exclude private posts.
GET /archive/feedreturns the complete published record (includinglatest_hidden) and accepts the archive filters?year=,?format=,?collection=,?media=,?title=,?replies=,?visibility=, and?sort=. A?collection=slug that names no collection returns404, as the page does — handing back the unfiltered archive under the collection's name would give a subscriber a set they never asked for.GET /feed/latestandGET /feed/featuredare kept indefinitely as308redirects to the canonical/latest/feedand/featured/feed, so existing subscribers never break.GET /feed/allandGET /feed/all/atom.xmlare legacy aliases that redirect to/latest/feedwith308, preserving the query string.GET /:slug/feedreturns an RSS feed for a single collection.GET /collections/:slug/feedreturns an RSS feed for a collection selection and redirects normalized selections to the canonical path with301.- Pages advertise feeds in the HTML head with
<link rel="alternate" type="application/atom+xml">. Every page carries the main feed and its counterpart; Collection and Archive pages list their own feed first — the archive one carrying the active filters — so a reader subscribing from the page gets the feed it shows. - Disabling feeds also removes HTML autodiscovery, Archive and Collection feed buttons, and the built-in RSS navigation item. Saved navigation configuration is retained for later re-enabling.
Discover post status
GET /api/discover/posts?id={postId}&id={postId}…
Auth: Public
A Discover directory reads a site's feeds, and a feed shows only its newest entries. When a post the directory holds is missing from one, the feed cannot say whether the post was taken out or pushed past the feed's length, and the permalink cannot say it either: a post hidden from Latest or unfeatured is still a live page. This endpoint answers for each post it is asked about. A directory does not build the address: every feed names it in the status attribute of <jant:discover>. See Feeds.
Query parameters:
| Parameter | Type | Required | Notes |
|---|---|---|---|
id |
pst_* string |
yes | Repeat for each post, 1 to 50 of them. The ID from <jant:id> |
lang |
BCP 47 language code | no | Answer for that language view's feeds. The declared address carries it |
Response:
{
"posts": [
{
"id": "pst_01jpyx3m7gw4w3h7m4bknq0v1d",
"latest": true,
"featured": false
},
{
"id": "pst_01jpyx5bq4e0c9t2wq0h6gk3r8",
"latest": false,
"featured": false
}
]
}
Notes:
latestis whether the post is in the Latest feed;featuredis whether its Thread is in the featured feed, which holds a Thread when any post in it is featured. Both are decided by the same rules the feeds use, RSS delay included.- Every ID asked about is answered once, in the order asked. A post that was deleted, made private, moved back to draft, is a reply, or does not exist answers
falsefor both. - Available whenever the site is listed in Discover, whatever
PUBLIC_API_ENABLEDsays: it tells nothing the public feeds do not. A site that is not listed answers404. - No IDs, more than
50, or a value that is not a post ID returns400.
Sitemap and robots
Sitemap
GET /sitemap.xml
Auth: Public
Notes:
- Returns XML with content type
application/xml; charset=utf-8. - Includes up to
1000published root posts. - Excludes private posts.
Robots
GET /robots.txt
Auth: Public
Notes:
- Returns text with content type
text/plain; charset=utf-8. - When
NOINDEXis enabled, the file disallows the entire site withDisallow: /. - Otherwise it allows the public site and disallows the internal utility prefix
/_/. - Always includes an absolute
Sitemap:line that points at/sitemap.xml.
Common Workflows
Publish a post with an uploaded image
- Start an upload session:
curl -X POST https://your-site.com/api/uploads/init \
-H "Authorization: Bearer jnt_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"filename": "photo.jpg",
"contentType": "image/jpeg",
"size": 1024000
}'
- Upload the file using the returned transport.
- Complete the upload and keep the returned
med_*ID. - Create the post:
curl -X POST https://your-site.com/api/posts \
-H "Authorization: Bearer jnt_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"format": "note",
"title": "Hello World",
"bodyMarkdown": "First post.",
"attachments": [
{ "type": "media", "mediaId": "med_01..." }
]
}'
Automate content from a generated site
Projects created with create-jant include examples/agent-content-automation/README.md.
Use that folder when you want ready-made examples for:
- creating note and quote posts from JSON
- updating editable settings from JSON
- uploading media and attaching the returned
med_*ID to a post - calling
/api/mcpfrom an MCP-capable agent
Minimum HTTP equivalents (the README has the full set):
curl -X POST "$JANT_URL/api/posts" \
-H "Authorization: Bearer $JANT_API_TOKEN" \
-H "Content-Type: application/json" \
-d @./examples/agent-content-automation/note.json
curl -X POST "$JANT_URL/api/upload" \
-H "Authorization: Bearer $JANT_API_TOKEN" \
-F "file=@./path/to/photo.webp;type=image/webp" \
-F "alt=Cover image"
curl -X PUT "$JANT_URL/api/settings" \
-H "Authorization: Bearer $JANT_API_TOKEN" \
-H "Content-Type: application/json" \
-d @./examples/agent-content-automation/site-settings.json
Migrate content from another system
Recommended order:
- Create collections first if you want to preserve categories or tags.
- Upload files and keep the returned media IDs.
- Create posts with original
publishedAttimestamps. - Use
pathon post creation orcustom-urlsafter creation to preserve old URLs.
Migration tips:
- Use
bodyMarkdownunless you already have TipTap JSON. - Use
replyToIdto rebuild threads. - Use
status: "draft"for unpublished imports. - The API is not idempotent on its own. If your importer may retry, track created IDs or slugs in your own process.
Export a site
curl -X POST https://your-site.com/api/export/hugo \
-H "Authorization: Bearer jnt_YOUR_TOKEN" \
-o jant-export.zip
GitHub webhooks
GitHub calls these addresses, so they sit in GitHub's settings rather than in any client. Each checks the X-Hub-Signature-256 signature against its secret and answers 401 when it doesn't match.
| Address | GitHub calls it for | Secret |
|---|---|---|
POST /api/github-sync/webhook |
Pushes to the repository GitHub Sync is connected to. Jant registers this webhook on the repository when you connect | GITHUB_APP_WEBHOOK_SECRET when set, otherwise the secret Jant generated when you connected |
POST /api/github-sync/app-webhook |
A GitHub App's installation events: uninstalled, suspended, or repositories removed. You enter it in the App's settings | GITHUB_APP_WEBHOOK_SECRET; the address answers 404 while that isn't set |
Both answer 200 to an event they don't act on, such as an event of another kind or a push that holds only Jant's own sync commits, so GitHub doesn't mark the delivery failed.
Telegram webhook
POST /api/telegram/webhook/:botId
Telegram calls this address with the messages people send the site's Telegram bot. Jant registers it with Telegram: when you save a bot token under Settings → Telegram, or, for a bot pool, with jant telegram register-webhooks or on startup in hosted mode. :botId is the number before the colon in the bot's token.
- An unknown
botIdanswers404, and anX-Telegram-Bot-Api-Secret-Tokenthat doesn't match the registered secret answers401. - Every update it accepts answers
200, even one that fails to publish: the sender gets the error as a chat message, and Telegram doesn't deliver the update again.
Versioning and Stability
The API has no version in its URLs. What this page documents follows Jant's compatibility promise, which says what each kind of release may change. /api/internal/* connects core to the hosted service and can change in any release.