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 |
| Public archive | /api/public/archive |
Public |
| Posts | /api/posts |
API token or session |
| Uploads (recommended) | /api/uploads |
API token or session |
| Uploads (legacy) | /api/upload, /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 |
| Export | /api/export |
API token or session |
| Internal admin | /api/internal/* |
Internal admin token |
Auth labels in this document:
Public: no auth requiredSession 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 —
GET /api/collections,GET /api/collections/:slug,GET /api/search— work without a token.
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_post,jant_collections_remove_post - 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 |
publishedAt |
integer | null |
Publish timestamp |
lastActivityAt |
integer | Last activity timestamp |
createdAt |
integer | Unix seconds |
updatedAt |
integer | Unix seconds |
attachments |
array | Ordered media/text attachment objects |
collectionIds |
col_* string[] |
Only included by GET /api/posts/:id |
Post response shape
The post list and detail endpoints return the same core fields. GET /api/posts/:id additionally includes collectionIds.
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",
"publishedAt": 1706000000,
"lastActivityAt": 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.
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 |
pinnedAt |
integer | null |
Pin timestamp |
featuredAt |
integer | null |
Feature timestamp |
publishedAt |
integer | null |
Publish timestamp |
lastActivityAt |
integer | Last activity timestamp |
createdAt |
integer | Unix seconds |
updatedAt |
integer | Unix seconds |
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 mirrors the /archive page: it returns every public thread root, including latest_hidden posts, with archive-style filters (year, media kind, presence of media or title). Use this when you want a complete corpus instead of the curated Latest feed.
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) |
year |
integer | no | none | Only posts whose publishedAt falls in this calendar year (UTC) |
media |
comma-separated MediaKind |
no | none | Only posts that have at least one attachment with one of these kinds: image, video, audio, text, document |
hasMedia |
0 | 1 |
no | none | 1 = posts with attachments, 0 = posts without |
hasTitle |
0 | 1 |
no | none | 1 = posts with a title, 0 = posts without |
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
mediavalue returns400. 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 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 | [] |
Collection TypeIDs; max 20 |
replyToId |
pst_* string |
no | null |
Make this post a thread reply |
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.
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. - 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 }
Uploads
All upload endpoints require auth.
Jant currently exposes three upload APIs:
/api/uploads: recommended session-based upload API for new clients/api/upload: legacy single-request upload API/api/upload/multipart: legacy explicit multipart relay API
File size is limited by UPLOAD_MAX_FILE_SIZE_MB and defaults to 500 MB.
Jant accepts a broad set of image, video, audio, document, text, archive, font, design, and code MIME types. Unsupported types return 400.
Recommended 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 }
Legacy one-shot upload
Base path: /api/upload
Use this only if you want the older multipart form upload behavior in a single request. New clients should prefer /api/uploads.
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 posts by topic. A post can belong to multiple collections.
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 post sort order |
createdAt |
integer | Unix seconds |
updatedAt |
integer | Unix seconds |
postCount |
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 | divider | link |
Item kind |
collectionId |
col_* string | null |
Present for type: "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:
- 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,
"postCount": 12,
"recentActivityAt": 1706100000
}
],
"directoryItems": [
{
"id": "cdi_01jpyx8r7s3v8m1q5c9k2f6gth",
"siteId": "sit_01jpyx1v6z9k4c7b2m5q8r3nfh",
"type": "collection",
"collectionId": "col_01jpyx5qds8y79w2dd6sv4rznj",
"label": null,
"url": null,
"position": "a0",
"createdAt": 1706000000,
"updatedAt": 1706000000
}
]
}
Notes:
- The default response returns directory ordering in
directoryItems. view=composereturns collections sorted by recent activity and always returns an emptydirectoryItemsarray.
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
}
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 post 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 post to a collection
POST /api/collections/:id/posts
Auth: Session or token
Request body:
{ "postId": "pst_01jpyx3m7gw4w3h7m4bknq0v1d" }
Fields:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
postId |
pst_* string |
yes | — | Post ID |
Response:
{ "success": true }
Remove a post from a collection
DELETE /api/collections/:id/posts/:postId
Auth: Session or token
Removes the post-to-collection association. It does not delete the post or the collection.
Response:
{ "success": 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 |
Custom link or built-in item |
systemKey |
rss | settings | collections | archive |
Only present for type: "system" |
label |
string | Display label |
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 |
System keys:
rsssettingscollectionsarchive
Notes:
- Built-in items get their label and URL automatically.
- Jant rejects duplicate built-in items.
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 |
Language code | "en" |
HOME_DEFAULT_VIEW |
Home feed mode | "latest" |
MAIN_RSS_FEED |
Canonical feed kind | "featured" |
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" |
Notes:
- Editable keys are derived from the config registry; env-only and internal keys are excluded.
- 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/settingsfills in defaults 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",
"HOME_DEFAULT_VIEW": "latest",
"MAIN_RSS_FEED": "featured",
"TIME_ZONE": "UTC",
"SITE_FOOTER": "",
"SHOW_JANT_BRANDING_ON_HOME": "",
"NOINDEX": ""
}
}
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 to300characters.SITE_FOOTERis trimmed and limited to5000characters.TIME_ZONEaccepts canonical IANA names and normalizes legacy aliases such as"Beijing"to"Asia/Shanghai".
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",
"HOME_DEFAULT_VIEW": "latest",
"MAIN_RSS_FEED": "featured",
"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".
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 as a top-level
collectionsfront-matter array on each post, with per-entrycollected_at/position/pinned_at. - 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"
}
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 |
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. - 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 /feed/latest |
Public | RSS | Latest public posts feed |
GET /feed/latest/atom.xml |
Public | Atom | Latest public posts feed |
GET /feed/featured |
Public | RSS | Featured posts feed |
GET /feed/featured/atom.xml |
Public | Atom | Featured posts feed |
GET /feed/all |
Public | Redirect | Legacy alias to /feed/latest |
GET /feed/all/atom.xml |
Public | Redirect | Legacy Atom alias to /feed/latest/atom.xml |
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
All feed endpoints are public and return cached XML with Cache-Control: public, max-age=180.
Feed notes:
GET /feedandGET /feed/atom.xmluse the configuredMAIN_RSS_FEEDto chooselatestorfeatured.GET /feed/latestandGET /feed/latest/atom.xmlaccept?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 /feed/allandGET /feed/all/atom.xmlare legacy aliases that redirect to thelatestfeed with308, 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.
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.