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 archive | /api/public/archive |
Public when enabled |
| Posts | /api/posts |
API token or session |
| Upload sessions | /api/uploads |
API token or session |
| One-shot upload | /api/upload |
API token or session |
| Legacy multipart relay | /api/upload/multipart |
API token or session |
| Text attachment content | /api/attachments |
API token or session |
| MCP | /api/mcp |
API token or session |
| Collections | /api/collections |
Mixed |
| Navigation items | /api/nav-items |
Mixed |
| Custom URLs | /api/custom-urls |
API token or session |
| Settings | /api/settings |
API token or session |
| Search | /api/search |
Public or authenticated |
| Export | /api/export |
API token or session |
| 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=falsePublic or authenticated: public by default; requires a session or token whenPUBLIC_API_ENABLED=falseSession or token: browser session cookie orAuthorization: Bearer <token>Internal admin token:Authorization: Bearer <INTERNAL_ADMIN_TOKEN>
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 on local hosts only:
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. - a small set of read endpoints — public posts/archive, Collections, navigation items, and search — work without a token while
PUBLIC_API_ENABLED=true.
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 - requires
MCP-Protocol-Version: 2025-06-18 - 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_get_content,jant_posts_create,jant_posts_update,jant_posts_delete - media:
jant_media_list,jant_media_get,jant_media_upload,jant_media_update_alt,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 - search:
jant_search_posts
Tool calls return normal MCP result envelopes. Successful tool calls include both structuredContent and a JSON string copy in content[0].text. Tool-level validation and domain failures return 200 OK with isError: true.
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.
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 |
| Custom URL / path record | pth_ |
pth_01jpyxb27t6m4v9r2k8s5c1qfh |
| Collection directory item | cdi_ |
cdi_01jpyx8r7s3v8m1q5c9k2f6gth |
| Nav item | nav_ |
nav_01jpyxcv3m7w4b8k2r5s9t1qfh |
Invalid IDs return 400.
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 creation expects a leading slash in the request body, but list/create responses return normalized paths without the leading slash.
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
Domain errors use this shape:
{
"error": "Human-readable message",
"code": "VALIDATION_ERROR",
"details": {}
}
detailsis present for validation errors that carry structured field information.- Unhandled server errors may return only
{ "error": "Something went wrong on our end" }.
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 |
RATE_LIMIT |
429 |
Too many requests |
CONFIGURATION_ERROR |
500 |
Missing or invalid server configuration |
EXTERNAL_SERVICE_ERROR |
500 |
External dependency failed |
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 |
siteId |
string | Owning site |
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 |
body |
string | null |
Raw TipTap JSON string when stored that way |
bodyHtml |
string | null |
Rendered HTML |
bodyText |
string | null |
Plain-text rendering |
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 |
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 |
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",
"siteId": "sit_01jpyx1v6z9k4c7b2m5q8r3nfh",
"format": "note",
"status": "published",
"visibility": "public",
"pinnedAt": null,
"featuredAt": null,
"slug": "hello-world",
"title": "Hello World",
"body": null,
"bodyHtml": "<p>Hello world</p>",
"bodyText": "Hello world",
"quoteText": null,
"summary": null,
"rating": null,
"replyToId": null,
"threadId": "pst_01jpyx3m7gw4w3h7m4bknq0v1d",
"quietReply": false,
"publishedAt": 1706000000,
"lastActivityAt": 1706000000,
"threadUpdatedAt": 1706000000,
"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.GET /api/postsincludes both root posts and replies. There is currently noexcludeRepliesquery parameter.
Public posts
Base path: /api/public/posts
These endpoints expose the public reading view, not the editing view used in Settings.
When PUBLIC_API_ENABLED=false, these dedicated public endpoints return 404
to every caller. Authenticated clients can use /api/posts instead.
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 |
/api/public/posts list excludes latest_hidden; single-post reads and /api/public/archive may return it |
slug |
string | Canonical slug |
permalink |
string | Public post URL |
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 |
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 |
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 |
List public posts
GET /api/public/posts
Auth: Public
Query parameters:
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
format |
note | link | quote |
no | all | Format filter |
collection |
string | no | none | Filter by collection slug(s). Single slug (design) or multiple comma-separated (tech,art) |
sort |
newest | oldest | rating_desc |
no | collection's default | Sort order override. Only effective when collection is set. Without collection, this is ignored |
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 rendered body fields |
Collection filtering notes:
- Single collection:
?collection=design - Multiple collections (union):
?collection=tech,art - When filtering by a single collection, results default to that collection's configured
sortOrder. - When filtering by multiple collections, results default to
newest. - Use
sortto override the default:?collection=design&sort=oldest - If any slug in the
collectionparameter does not resolve, the endpoint returns an empty result set.
Response:
{
"posts": [
{
"id": "pst_01jpyx3m7gw4w3h7m4bknq0v1d",
"format": "note",
"status": "published",
"visibility": "public",
"slug": "hello-world",
"permalink": "/hello-world",
"title": "Hello World",
"bodyHtml": "<p>Hello world</p>",
"bodyText": "Hello world",
"quoteText": null,
"replyToId": null,
"threadId": "pst_01jpyx3m7gw4w3h7m4bknq0v1d",
"publishedAt": 1706000000,
"attachments": [],
"collections": []
}
],
"nextCursor": "pst_01jpyx3m7gw4w3h7m4bknq0v1d"
}
Notes:
- This list returns published public thread roots only.
- Drafts, private posts, replies, and
latest_hiddenposts are excluded. content=markdownreturnsbodyMarkdownand omitsbodyHtml/bodyText.
Get a public post by slug
GET /api/public/posts/:slug
Auth: Public
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.
List archive posts
GET /api/public/archive
Auth: Public
The archive endpoint carries the same filters as the /archive page — year, collection, media kind, presence of media, title, or replies, and visibility — and returns every public thread root, including latest_hidden posts. Use this when you want a complete corpus instead of the curated Latest feed. Ordering is fixed at newest-first; the page's ?sort= switch has no counterpart here, because the cursor is keyed off the post id.
Query parameters:
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
format |
note | link | quote |
no | all | Format filter |
collection |
string | no | none | Filter by collection slug(s). Single slug (design) or several, comma-separated (tech,art). A post in any one of them matches |
lang |
BCP 47 tag | no | all | Restrict to one content language, as /api/public/posts does |
year |
integer | no | none | Only posts whose publishedAt falls in this calendar year (UTC) |
media |
comma-separated MediaKind | any | none |
no | none | Kinds (image, video, audio, text, document): posts with at least one attachment of one of these kinds. any = posts with any attachment, none = posts without attachments |
title |
any | none |
no | none | any = posts with a title, none = posts without |
replies |
any | none |
no | none | any = thread roots with published replies (threads), none = single posts without replies |
visibility |
public | featured | hidden |
no | all | hidden is the URL spelling of latest_hidden, which is also read. private names a set no anonymous caller can see and returns 400, as does any other value |
hasMedia |
0 | 1 |
no | none | Deprecated — use media=any / media=none |
hasTitle |
0 | 1 |
no | none | Deprecated — use title=any / title=none |
hasReplies |
0 | 1 |
no | none | Deprecated — use replies=any / replies=none |
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 rendered body fields |
Response shape matches GET /api/public/posts: { posts: PublicPost[], nextCursor: string | null }.
Notes:
- Returns published public thread roots and
latest_hiddenposts. - Drafts, private posts, and replies are excluded.
- Posts are returned in newest-first order. Cursor pagination is keyed off the post
id. - An invalid value for any filter returns
400, and so does a parameter this endpoint does not know — a typo that silently returned the whole archive would be worse than an error. An unknowncollectionslug returns an empty result set. content=markdownreturnsbodyMarkdownand omitsbodyHtml/bodyText.
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 |
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": "pst_01jpyx3m7gw4w3h7m4bknq0v1d"
}
Notes:
- Each item uses the post response fields above, except list responses omit
collectionIds. nextCursorisnullwhen there are no more results.
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 and ordered attachments.
Example:
{
"id": "pst_01jpyx3m7gw4w3h7m4bknq0v1d",
"format": "note",
"collectionIds": ["col_01jpyx5qds8y79w2dd6sv4rznj"],
"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 path helper; 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 |
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 |
replyToId |
pst_* string |
no | null |
Make this post a thread reply |
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 |
attachments |
attachment[] | no | [] |
Ordered attachments, max 20 |
Important rules:
- Use
bodyorbodyMarkdown, not both. - Use
slugorpath, not both. pathis only available on create. Post updates only supportslug.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.
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 |
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,
"mimeType": "image/jpeg",
"originalName": "photo.jpg",
"size": 1024000,
"summary": null,
"chars": null
}
- Text attachment:
{
"type": "text",
"id": "med_...",
"contentFormat": "markdown",
"contentUrl": "/api/attachments/med_.../content",
"summary": "Attached note Extra context here.",
"chars": 33
}
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 path. 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
Notes:
pathis not supported on update. Useslugfor canonical URL changes andcustom-urlsfor extra aliases.- For quote posts, keep using
sourceNameandsourceUrl. - 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. If the target is a thread root, its replies are deleted as part of the same operation.
Response:
{ "success": true }
Language and translations
These endpoints only matter on a site with multilingual content turned on. Post responses do not carry language; read it through the translation endpoints below, which return the language of every post they list.
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. Same entry shape as the endpoint above, newest first.
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 }.
Uploads
All upload endpoints require auth.
Jant currently exposes three 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/api/upload/multipart: legacy explicit multipart relay API
File size is limited by UPLOAD_MAX_FILE_SIZE_MB and defaults to 1024 MB.
Jant accepts a broad set of image, video, audio, document, text, archive, font, design, and code MIME types. Unsupported types return 400.
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.
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:
{
"id": "med_01jpyx4g9m8b4y50a4gx3t7p1n",
"filename": "med_01jpyx4g9m8b4y50a4gx3t7p1n.webp",
"url": "/media/med_01jpyx4g9m8b4y50a4gx3t7p1n.webp",
"mimeType": "image/webp",
"size": 1024000
}
Abort an upload session
POST /api/uploads/:id/abort
Response:
{ "success": true }
List uploaded media
GET /api/upload
Auth: Session or token
This is the media metadata listing endpoint.
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/ |
Response:
{
"media": [
{
"id": "med_01jpyx4g9m8b4y50a4gx3t7p1n",
"siteId": "sit_01jpyx1v6z9k4c7b2m5q8r3nfh",
"postId": null,
"filename": "photo.webp",
"originalName": "photo.webp",
"mimeType": "image/webp",
"size": 1024000,
"provider": "r2",
"width": 1200,
"height": 800,
"durationSeconds": null,
"alt": "Cover image",
"position": "0",
"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
}
]
}
Notes:
- This list may include ordinary uploaded binaries and stored text attachments.
- Text attachments use
type: "text"and exposecontentFormatpluscontentUrlinstead ofurl,previewUrl, andposterUrl.
Get a media item
GET /api/upload/:id
Auth: Session or token
Returns one media or text attachment record using the same response shape as GET /api/upload.
Update media alt text
PATCH /api/upload/:id
Auth: Session or token
Request body:
{
"alt": "Cover image"
}
Rules:
altis trimmed before storing.- Max length is
500.
Response: 200 OK with the updated media object.
Delete a media item
DELETE /api/upload/:id
Auth: Session or token
Deletes the media record and its stored object.
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 |
poster |
file | no | — | Poster frame for video uploads |
Response:
{
"id": "med_01jpyx4g9m8b4y50a4gx3t7p1n",
"filename": "med_01jpyx4g9m8b4y50a4gx3t7p1n.jpg",
"url": "/media/med_01jpyx4g9m8b4y50a4gx3t7p1n.jpg",
"mimeType": "image/jpeg",
"size": 1024000
}
If the request sends Accept: text/event-stream, the endpoint may return SSE patches instead of JSON for live UI updates.
Legacy explicit multipart relay
Base path: /api/upload/multipart
This is the older chunked-upload API. Prefer /api/uploads unless you already implement this flow.
Initiate a multipart upload
POST /api/upload/multipart
Request body:
{
"filename": "video.mp4",
"contentType": "video/mp4",
"size": 250000000
}
Response:
{
"id": "med_01jpyx4g9m8b4y50a4gx3t7p1n",
"uploadId": "upload-123",
"storageKey": "media/...",
"filename": "med_01jpyx4g9m8b4y50a4gx3t7p1n.mp4",
"originalName": "video.mp4"
}
Upload one part
PUT /api/upload/multipart/:id/part?partNumber=N&storageKey=...&uploadId=...
- Body: raw part bytes
- Response:
{
"partNumber": 1,
"etag": "etag-value"
}
Upload a poster frame
PUT /api/upload/multipart/:id/poster
Content type: multipart/form-data
Form field:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
poster |
file | yes | — | WebP poster frame |
Response:
{
"posterKey": "media/.../posters/..."
}
Complete the multipart upload
POST /api/upload/multipart/:id/complete
Request body:
{
"storageKey": "media/...",
"uploadId": "upload-123",
"parts": [{ "partNumber": 1, "etag": "etag-1" }],
"filename": "med_....mp4",
"originalName": "video.mp4",
"contentType": "video/mp4",
"size": 250000000,
"width": 1920,
"height": 1080,
"blurhash": "optional",
"waveform": "optional",
"posterKey": "media/.../poster.webp"
}
Response:
{
"id": "med_01jpyx4g9m8b4y50a4gx3t7p1n",
"filename": "med_01jpyx4g9m8b4y50a4gx3t7p1n.mp4",
"url": "/media/med_01jpyx4g9m8b4y50a4gx3t7p1n.mp4",
"mimeType": "video/mp4",
"size": 250000000
}
Abort the multipart upload
POST /api/upload/multipart/:id/abort
Request body:
{
"storageKey": "media/...",
"uploadId": "upload-123"
}
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 |
siteId |
string | Owning site |
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 |
siteId |
string | Owning site |
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" |
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: Public
Query parameters:
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
view |
compose |
no | none | Specialized compose view sorted by recent activity |
Default response:
{
"collections": [
{
"id": "col_01jpyx5qds8y79w2dd6sv4rznj",
"siteId": "sit_01jpyx1v6z9k4c7b2m5q8r3nfh",
"slug": "reading",
"title": "Reading",
"description": "Books I've read",
"sortOrder": "newest",
"createdAt": 1706000000,
"updatedAt": 1706000000,
"threadCount": 12,
"recentActivityAt": 1706100000
}
],
"smartCollections": [
{
"id": "smc_01jpyxa2k4d7n6r9s1t3v5w8xz",
"siteId": "sit_01jpyx1v6z9k4c7b2m5q8r3nfh",
"slug": "quotes",
"title": "Quotes",
"description": "Things worth keeping.",
"selection": { "format": "quote" },
"sort": "newest",
"layout": null,
"createdAt": 1706000000,
"updatedAt": 1706000000,
"threadCount": 34,
"recentActivityAt": 1706090000
}
],
"directoryItems": [
{
"id": "cdi_01jpyx8r7s3v8m1q5c9k2f6gth",
"siteId": "sit_01jpyx1v6z9k4c7b2m5q8r3nfh",
"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. 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: Public
Response:
{
"id": "col_01jpyx5qds8y79w2dd6sv4rznj",
"siteId": "sit_01jpyx1v6z9k4c7b2m5q8r3nfh",
"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, including siteId.
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, including siteId.
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 |
Notes:
type: "collection"is not accepted here.- New items are appended to the end of the directory.
Response:
{
"id": "cdi_01jpyx8r7s3v8m1q5c9k2f6gth",
"siteId": "sit_01jpyx1v6z9k4c7b2m5q8r3nfh",
"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 }
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.
Response:
{ "success": true }
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.
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. 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 |
siteId |
string | Owning site |
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 |
sort |
newest | oldest | rating_desc |
Order of the posts the conditions gather |
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 |
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: { "smartCollection": SmartCollection }.
Create a smart collection
POST /api/smart-collections
Body:
{
"slug": "quotes",
"title": "Quotes",
"description": "Things worth keeping.",
"selection": { "format": "quote", "media": "any" },
"sort": "newest",
"layout": null
}
slug and title are required; everything else is optional. Returns 201 with
{ "smartCollection": SmartCollection }. 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. 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 |
siteId |
string | Owning site |
type |
link | system | collection | smart_collection | page |
What the item points at |
systemKey |
rss | settings | collections | archive |
Only present for type: "system" |
collectionId |
col_* string |
Only present for type: "collection" |
smartCollectionId |
smc_* string |
Only present for type: "smart_collection" |
postId |
pst_* string |
Only present for type: "page" |
label |
string | Author's override, or "" to follow the target |
url |
string | Stored URL or path |
position |
string | Fractional ordering key |
createdAt |
integer | Unix seconds |
updatedAt |
integer | Unix seconds |
List nav items
GET /api/nav-items
Auth: Public
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 |
rss | settings | collections | archive |
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 |
System keys:
rsssettingscollectionsarchive
Notes:
- Built-in items get their label and URL automatically.
- 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
labelandurlare 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 | Normalized path without a leading slash |
targetType |
post | collection | redirect |
Target kind |
targetId |
string | null |
Resolved post/collection TypeID for alias records |
toPath |
string | null |
Redirect destination with a leading slash |
redirectType |
301 | 302 | null |
Redirect status for redirect records |
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 |
Internal redirect to another path | toPath, redirectType |
List custom URLs
GET /api/custom-urls
Auth: Session or token
Query parameters:
| Parameter | Type | Required | Default |
|---|---|---|---|
page |
integer | no | 1 |
Response:
{
"customUrls": [
{
"id": "pth_01jpyxb27t6m4v9r2k8s5c1qfh",
"path": "blog/old-post",
"targetType": "redirect",
"targetId": null,
"toPath": "/my-new-slug",
"redirectType": 301,
"createdAt": 1706000000
},
{
"id": "pth_01jpyxbk8v4m2s7r9c5t1g6qdn",
"path": "essays/on-writing",
"targetType": "post",
"targetId": "pst_01jpyx3m7gw4w3h7m4bknq0v1d",
"toPath": null,
"redirectType": null,
"createdAt": 1706000000
}
],
"total": 42,
"page": 1,
"totalPages": 1
}
Notes:
- List and create responses only cover alias and redirect records. Canonical post and collection slugs are not returned here.
- Response
pathvalues are normalized and do not include a leading slash. - Alias responses return the resolved post or collection TypeID in
targetId.
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 | — | Must start with /; max 512; lowercase letters, numbers, -, and / only |
targetType |
post | collection | redirect |
yes | — | Target kind |
targetId |
string | required for post or collection |
— | Send the canonical slug, not the TypeID |
toPath |
string | required for redirect |
— | Internal destination path such as /new-path; normalized before storage |
redirectType |
"301" | "302" |
no | 301 |
Only used for redirect |
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.
- Redirects are for internal paths. External redirect targets are not supported by this API.
- Post and collection targets must already exist by slug or the API returns
404. - Create responses resolve slug targets to TypeIDs.
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 editable site config only.
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" |
CJK_SERIF_FONT |
CJK serif fallback | "off" |
MAIN_RSS_FEED |
Canonical feed kind | "featured" |
ARCHIVE_DEFAULT_LAYOUT |
Archive default layout | "list" |
PAGE_SIZE |
Default page size | "50" |
SEARCH_PAGE_SIZE |
Search page size | "50" |
ARCHIVE_PAGE_SIZE |
Archive page size | "50" |
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".
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": "50",
"SEARCH_PAGE_SIZE": "50",
"ARCHIVE_PAGE_SIZE": "50",
"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"
}
}
Notes:
- The response always returns the full editable settings object, 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".
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 contains the same directly editable settings object
as GET /api/settings plus a safe setting field with the reset key's resolved
Config Editor state:
{
"settings": { "SITE_NAME": "Jant" },
"setting": {
"key": "SITE_NAME",
"mode": "link",
"type": "string",
"value": "Jant",
"fallbackValue": "Jant",
"modified": false,
"locked": false,
"settingsPath": "/settings/general",
"display": "value",
"resettable": true
}
}
Environment-only, secret, unknown, specialized content/file keys, and
demo-locked keys return 400 without changing stored settings.
Mark compose shortcut discovery as seen
POST /api/settings/discovery/compose-open-shortcut
Auth: Session or token
This is a small UI-state endpoint used by the compose UI.
- First call stores the timestamp and returns
201 - Later calls return
200
Response:
{ "learned": true }
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 |
favicon |
file | no | — | Favicon .ico payload |
appleTouch |
file | no | — | Apple touch icon |
Response:
{ "success": true }
Notes:
- File storage must be configured or the endpoint returns
500. - Omitting
filereturns400. - 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 public and only returns published posts.
Search posts
GET /api/search
Auth: Public
Query parameters:
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
q |
string | yes | none | Maximum length 200 |
limit |
integer | no | 20 |
Maximum 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 |
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",
"url": null
}
],
"count": 1
}
Notes:
snippetmay contain<mark>tags.- All search results include
permalink. - Quote results use
sourceNameandsourceUrlinstead oftitleandurl. - Search only returns published posts.
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 |
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",
"checks": {
"startupConfig": { "ok": true },
"database": { "ok": true }
}
}
Notes:
- Returns
200when all checks pass. - Returns
503whenstatusis"error". startupConfig.erroranddatabase.errorappear when a check fails.- 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. - 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.
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" \
-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
Versioning and Stability
The API is currently unversioned.
Practical stability rules:
- The site-owner endpoints documented here are intended to be scriptable.
/api/internal/*is operational rather than public API surface and may change more aggressively.- Breaking changes are announced in release notes rather than through URL-based versioning.