Jant API Reference
Jant exposes a compact HTTP API for automations, content migration, dashboard 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 |
| 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 in the dashboard:
- 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 dashboard session for the current site.
Session cookies
Browser requests can use the normal dashboard 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 same site-owner automation surface three ways:
- local
npx jantcommands when the automation runs on the site machine - 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 CLI and MCP flows for posts, media, and settings.
Local CLI
The site-aware CLI maps directly to the HTTP endpoints documented below.
Available command groups:
npx jant postsnpx jant medianpx jant collectionsnpx jant settingsnpx jant search
Resolution rules:
- Pass
--url https://your-site.com, or let the CLI readSITE_ORIGINfrom the environment orwrangler.toml. - Pass
--token jnt_..., or setJANT_API_TOKEN. - On local hosts only,
DEV_API_TOKENis also accepted. npx jant collections list,npx jant collections get, andnpx jant searchcan call public endpoints without a token. Other commands require auth.
Examples:
npx jant posts create --input ./post.json
npx jant media upload ./cover.webp --alt "Cover image"
npx jant collections add-post col_01... pst_01...
npx jant settings update --json '{"SITE_NAME":"Quiet Notes"}'
npx jant search "quiet design"
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 |
deletedAt |
integer | null |
Soft-delete 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 |
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",
"deletedAt": null,
"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 dashboard editing view.
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 |
List excludes latest_hidden; single-post reads 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 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 endpoint used by npx jant media list.
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 dashboard use.
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 dashboard 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 dashboard-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 Zola archive
POST /api/export/zola
Auth: Session or token
Request body: none
Response:
- Content type:
application/zip - Download filename:
jant-export.zip
Archive contents:
config.tomlcontent/<post-slug>/index.mdfor each root post or merged threadcontent/collections/<collection-slug>/_index.mdfor each collectioncontent/_index.mdandcontent/archive/_index.mdtemplates/*andstatic/*README.md
Notes:
- Thread replies are merged into their thread root page in the exported Zola content.
- Collection membership is exported as the
ctaxonomy. - 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/zola \
-H "Authorization: Bearer jnt_YOUR_TOKEN" \
-o jant-export.zip
This export is suitable for:
- static publishing with Zola
- 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/zola, 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
When the automation runs on the same machine as the site, prefer the local CLI first:
npx jant posts create --input ./examples/agent-content-automation/note.json
npx jant media upload ./path/to/photo.webp --alt "Cover image"
npx jant settings update --input ./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/zola \
-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.