API
All documented routes come from server/src/routes/api.ts.
Base paths
| Base path | Purpose |
|---|---|
/api | JSON API |
/thumbnails | Protected thumbnail derivatives, served from cache or generated on demand in lazy mode |
/previews | Protected preview derivatives, served from cache or generated on demand in lazy mode |
Authentication and protected routes
Foldergram can optionally require a role-bearing password session.
When password protection is enabled:
- most
/apiroutes return401until the browser logs in, except for safe read routes in public viewer mode GET /api/health,GET /api/auth/status,POST /api/auth/login,POST /api/auth/unlock-admin, andPOST /api/auth/logoutstay reachable without an authenticated sessionPUT /api/auth/passwordis public only when password protection is currently disabled, so the first admin password can be set/thumbnails/...and/previews/...also require the same authenticated session unless public viewer mode is enabled- authenticated sessions carry
adminorviewerrole data - anonymous public reads use
role: "anonymous"andlikesMode: "local" - admin-only mutations return
403for viewer sessions
The frontend sends same-origin credentials automatically and uses a signed cookie-based session.
Mutation requirements
All mutating API routes are protected by requireTrustedMutationRequest.
Required header
x-foldergram-intent: 1Local-origin checks
For mutating requests:
- if
Originis present, it must belocalhost,127.0.0.1, or::1 - in development and test, the origin port must match
DEV_SERVER_PORTor the reserved dev-client range fromDEV_CLIENT_PORTthroughDEV_CLIENT_PORT + 3 - in production, loopback origins are allowed and same-host origins are allowed when they match the host serving the app on
SERVER_PORT - if
Originis absent butRefereris present, the same loopback check is applied to the referer origin
The shipped frontend adds x-foldergram-intent: 1 automatically for POST, PUT, PATCH, and DELETE requests.
Common error behavior
| Status | When it happens |
|---|---|
401 | Password protection is enabled and the request is not authenticated. |
400 | Validation or request-shape errors surfaced through the Express error handler. |
403 | Missing intent header, failed local-origin check, or a viewer session hitting an admin-only route. |
404 | Missing folder, post, moment, or original media. |
409 | A scan or thumbnail rebuild was requested while the library requires a library-index rebuild after a gallery-root change. |
Read endpoints
GET /api/health
Returns process-level health plus storage state.
Example shape:
{
"ok": true,
"timestamp": "2026-03-16T12:34:56.000Z",
"storage": {
"available": true,
"reason": null,
"usingInMemoryDatabase": false
}
}GET /api/auth/status
Returns the current auth state for the browser session.
Example shape:
{
"enabled": true,
"authenticated": false,
"role": "anonymous",
"accessMode": "public",
"likesMode": "local",
"capabilities": {
"canManageLibrary": false,
"canDeleteMedia": false,
"canAccessSettings": false,
"canUseSharedLikes": false,
"canUseLocalFavorites": true
}
}GET /api/feed
Query parameters:
| Param | Type | Default | Notes |
|---|---|---|---|
page | integer | 1 | Minimum 1. |
limit | integer | 24 | Minimum 1, maximum 60. |
mode | `recent | rediscover | random` |
seed | integer | unset | Optional random seed for random mode. |
Response shape:
{
"mode": "recent",
"items": [],
"page": 1,
"limit": 24,
"total": 0,
"hasMore": false
}Notes:
itemscontain both images and videos.thumbnailUrlpoints to/thumbnails/....previewUrlpoints to/previews/....
GET /api/reels
Query parameters:
| Param | Type | Default | Notes |
|---|---|---|---|
page | integer | 1 | Minimum 1. |
limit | integer | 24 | Minimum 1, maximum 60. The current client helper requests 6 per page. |
mode | `recommended | recent | random` |
seed | integer | unset | Optional seed used to keep recommended and random queues stable while paging. |
lastFolder | string | unset | Optional recent-navigation hint used by recommended. |
recentFolders | comma-separated string | unset | Optional recent-navigation hints used by recommended. |
Response shape:
{
"mode": "recommended",
"items": [],
"page": 1,
"limit": 6,
"total": 0,
"hasMore": false
}Notes:
itemscontain videos only.recommendeduseslastFolderandrecentFoldersto bias the queue toward folders you recently opened.recentignores recommendation hints and returns newest indexed videos first.randomandrecommendedstay stable across pages when the sameseedis reused.
GET /api/feed/moments
Returns the current Home Moments or Highlights definition.
The response can be:
momentshighlights
Response shape:
{
"railKind": "moments",
"railTitle": "Moments",
"railDescription": "Memory capsules shaped by real capture dates from your library.",
"railSingularLabel": "Moment",
"items": []
}GET /api/feed/moments/:id
Path parameters:
| Param | Type |
|---|---|
id | string |
Query parameters:
| Param | Type | Default |
|---|---|---|
page | integer | 1 |
limit | integer | 24 |
Returns 404 with {"message":"Feed capsule not found"} when the ID does not exist in the currently selected set.
GET /api/folders
Returns:
{
"items": []
}Each item is a folder summary with:
idslugnamedescriptionfolderPathbreadcrumbimageCountvideoCountlatestImageMtimeMshasAvatarStoryavatarImageIdavatarUrl
GET /api/folders/:slug
Returns one folder summary.
The response uses the same shape as folder items from GET /api/folders, including hasAvatarStory so the client can decide whether the folder avatar should open a story entry point.
Errors:
404with{"message":"Folder not found"}if the slug is missing
GET /api/folders/:slug/images
Query parameters:
| Param | Type | Default | Notes |
|---|---|---|---|
page | integer | 1 | Minimum 1. |
limit | integer | 24 | Minimum 1, maximum 60. |
mediaType | `image | video` | unset |
Response shape:
{
"folder": {
"id": 1,
"slug": "oslo",
"name": "oslo",
"description": null,
"folderPath": "trips/oslo",
"breadcrumb": "trips",
"imageCount": 12,
"videoCount": 3,
"latestImageMtimeMs": 1700000000000,
"hasAvatarStory": false,
"avatarImageId": 42,
"avatarUrl": "/thumbnails/ab/abcd1234ef567890abcd1234ef567890.webp?v=4"
},
"items": [],
"page": 1,
"limit": 24,
"total": 12,
"hasMore": false
}Errors:
404with{"message":"Folder not found"}
GET /api/folders/:slug/stories
Returns the stories payload for one folder.
Response shape:
{
"railKind": "stories",
"railTitle": "Stories",
"railDescription": "Stories and highlights for AnimalPlanet.",
"railSingularLabel": "Story",
"hasAvatarStory": true,
"avatarStoryId": "animalplanet-stories",
"items": [],
"highlights": []
}Notes:
itemscontains the avatar story first when one exists, followed by highlight capsules.highlightscontains only highlight capsules.- each capsule item includes
id,title,subtitle,dateContext,imageCount,coverImage, andpresentation presentationisavatarorhighlightavatarStoryIdcan be a synthetic fallback id when the folder has highlight media but no direct avatar-story files
Errors:
404with{"message":"Folder not found"}
GET /api/folders/:slug/stories/:id
Path parameters:
| Param | Type |
|---|---|
slug | string |
id | string |
Query parameters:
| Param | Type | Default |
|---|---|---|
page | integer | 1 |
limit | integer | 24 |
Response shape:
{
"railKind": "stories",
"railTitle": "Stories",
"railDescription": "Stories and highlights for AnimalPlanet.",
"railSingularLabel": "Story",
"story": {
"id": "animalplanet-stories",
"title": "AnimalPlanet",
"subtitle": "AnimalPlanet story set",
"dateContext": "Today",
"imageCount": 8,
"presentation": "avatar",
"coverImage": {}
},
"items": [],
"page": 1,
"limit": 24,
"total": 8,
"hasMore": false
}Notes:
- the paginated
itemsarray contains normal feed-item payloads - if the requested avatar story is a fallback capsule, the server returns recent highlight media for that synthetic capsule
Errors:
404with{"message":"Story capsule not found"}
GET /api/likes
Returns:
{
"items": []
}Items are ordered by like timestamp descending.
Notes:
- shared likes are available to
adminandviewersessions - anonymous public sessions use browser-local favorites instead of this endpoint
GET /api/images/:id
Query parameters:
| Param | Type | Notes |
|---|---|---|
mediaType | `image | video` |
Returns one post detail payload with:
- feed-item fields
relativePathmimeTypefileSizeoriginalUrlplaybackStrategyfor videos when an original MP4 is browser-compatiblenextImageIdpreviousImageId
nextImageId and previousImageId are resolved within the same folder and the same active mediaType filter when one is supplied.
Detail media rules:
- if
IMAGE_DETAIL_SOURCE=originaland the item is an image,previewUrlpoints to/api/originals/:id - videos keep
previewUrlon/previews/... - compatible original MP4 videos can set
playbackStrategy: "original"so the client can expose an optional original-quality switch
Errors:
404with{"message":"Post not found"}
GET /api/originals/:id
Serves the original file from disk by image ID only.
Query parameters:
| Param | Type | Default | Notes |
|---|---|---|---|
download | 1 | unset | When set to 1, the server responds with an attachment using the indexed original filename. |
Rules:
- the indexed path must still exist
- the resolved path must stay within
GALLERY_ROOT - deleted posts do not resolve
- without
download=1, the route streams the original inline/open behavior - with
download=1, the route serves the same file as a download attachment
Errors:
404with{"message":"Original media not found"}
GET /api/status
Returns viewer-safe operational state for the shell.
When access protection is enabled:
adminandviewersessions can always read it- anonymous visitors can also read it when
viewer_access_mode=public
Notable fields:
| Field | Notes |
|---|---|
folders | Active indexed folders. |
indexedImages | Active indexed posts. The name is historical and still includes videos in the total feed count. |
indexedVideos | Active indexed videos only. |
scan | Viewer-safe live scan progress snapshot. It uses the same shape as GET /api/scan-progress, with currentFolder and currentFile redacted and lastCompletedScan.error_text forced to null. |
storage | Availability with a generic unavailable message only. |
libraryIndex | Rebuild requirement plus ignored root-media count. Gallery-root paths are omitted. |
preferences.defaultHomeFeedMode | Current app-wide default home feed mode. |
preferences.defaultReelsFeedMode | Current app-wide default reels mode used when /reels opens. |
preferences.treatStoriesAsFolders | Whether folders literally named stories are treated as ordinary app folders instead of reserved story sources. |
storiesMigration | Migration hint with hasLegacyStoriesCandidates and decisionPending. |
GET /api/scan-progress
Returns the standalone viewer-safe scan payload used for lightweight polling.
Access rules match GET /api/status:
adminandviewersessions can read it when access protection is enabled- anonymous visitors can also read it when
viewer_access_mode=public
Notable fields:
| Field | Notes |
|---|---|
isScanning | Whether a scan or rebuild is currently running. |
phase | idle, migration, discovery, or derivatives. |
scanReason | Current trigger such as startup, manual rescan, index rebuild, or thumbnail rebuild. |
currentPhaseMessage | Short phase summary for the UI. |
currentOperation | Current action label such as derivative repair, derivative move, metadata discovery, or derivative generation. |
migrationTotalRows / processedMigrationRows | Progress through legacy derivative migration checks. |
migratedDerivativeFiles / repairedDerivativeFiles / missingDerivativeFiles / backfilledAssetKeys | Migration counters for moved, repaired, missing, and backfilled assets. |
discoveredFolders / processedFolders | Folder discovery counters. |
discoveredImages / processedImages | Indexed-post discovery counters. |
queuedDerivativeJobs / processedDerivativeJobs | Derivative job counters once the queue is known. |
generatedThumbnails / generatedPreviews | Completed derivative outputs in the current run. |
currentFolder / currentFile | Always null in this viewer-safe route. |
lastCompletedScan | Latest completed scan summary with error_text forced to null. |
Notes:
- discovery remains open-ended while folders and media are still being found, so clients should treat that phase as indeterminate even though the counters continue to advance
- migration and derivative phases expose determinate totals once the work queue is known
GET /api/admin/scan-progress
Returns the admin-detailed live scan payload.
This route is read-only but admin-only.
It uses the same overall shape as GET /api/scan-progress, with the following differences:
currentFileandcurrentFolderare populated when the active operation can identify a source pathlastCompletedScanis the full scan record, includingerror_text
GET /api/admin/stats
Returns the full admin operational payload.
This route is read-only but admin-only. It extends GET /api/status with the additional fields below and uses the same admin-detailed scan payload as GET /api/admin/scan-progress:
| Field | Notes |
|---|---|
deletedImages | Soft-deleted post count. |
thumbnailCount | Actual generated thumbnail files currently present on disk. |
previewCount | Actual generated preview files currently present on disk. |
storage.usingInMemoryDatabase | Whether SQLite had to fall back to in-memory mode. |
libraryIndex.currentGalleryRoot | Current configured gallery root. |
libraryIndex.previousGalleryRoot | Prior configured gallery root when the root changed. |
libraryIndex.lastSuccessfulGalleryRoot | Gallery root from the last completed successful scan. |
lastScan | Last completed scan run. |
The same preferences.treatStoriesAsFolders and storiesMigration fields from GET /api/status are also present here.
Mutating endpoints
POST /api/auth/login
Body:
{
"password": "your-admin-or-viewer-password"
}Success:
{
"ok": true,
"auth": {
"enabled": true,
"authenticated": true,
"role": "viewer",
"accessMode": "password",
"likesMode": "shared",
"capabilities": {
"canManageLibrary": false,
"canDeleteMedia": false,
"canAccessSettings": false,
"canUseSharedLikes": true,
"canUseLocalFavorites": false
}
}
}Errors:
400if password protection is not enabled401if the password is incorrect403when trust requirements are missing
POST /api/auth/unlock-admin
Elevates the current browser into an admin session by verifying the admin password.
This route stays reachable without an existing authenticated session so it can be used from viewer mode and public mode.
Body:
{
"password": "your-admin-password"
}Success:
{
"ok": true,
"auth": {
"enabled": true,
"authenticated": true,
"role": "admin",
"accessMode": "public",
"likesMode": "shared",
"capabilities": {
"canManageLibrary": true,
"canDeleteMedia": true,
"canAccessSettings": true,
"canUseSharedLikes": true,
"canUseLocalFavorites": false
}
}
}Errors:
400if password protection is not enabled401if the admin password is incorrect403when trust requirements are missing
POST /api/auth/logout
Clears the current browser session cookie.
Success:
{
"ok": true,
"auth": {
"enabled": true,
"authenticated": false
}
}PUT /api/auth/password
Sets or changes the admin password.
Body when protection is disabled:
{
"password": "new-password"
}Body when protection is already enabled:
{
"currentPassword": "old-password",
"password": "new-password"
}Notes:
- password minimum length is
8 viewersessions receive403- changing the password invalidates older sessions
DELETE /api/auth/password
Disables password protection entirely.
Body:
{
"currentPassword": "current-password"
}Errors:
400if protection is already disabled401if the password is incorrect403if the current session is notadmin
PUT /api/auth/viewer-access
Configures viewer access mode.
Body for admin-only access:
{
"mode": "off"
}Body for password-protected viewer access:
{
"mode": "password",
"viewerPassword": "viewer-password"
}Notes:
viewerPasswordis required whenmode=password- viewer and admin passwords must differ
- changing viewer access invalidates older sessions
mode=publicenables anonymous browsing immediately- anonymous public sessions use local browser favorites instead of shared
/api/likes
PUT /api/admin/settings/home-feed-default
Sets the default mode used when Home opens.
Body:
{
"defaultMode": "recent"
}Allowed values:
recentrediscoverrandom
Success:
{
"defaultMode": "recent"
}PUT /api/admin/settings/reels-feed-default
Sets the app-wide default mode used when /reels opens.
Body:
{
"defaultMode": "recommended"
}Allowed values:
recommendedrecentrandom
Success:
{
"defaultMode": "recommended"
}PUT /api/admin/settings/stories-mode
Sets how folders literally named stories are interpreted.
Body:
{
"treatStoriesAsFolders": false
}Success:
{
"treatStoriesAsFolders": false
}Notes:
falseenables the default reserved-stories modetruekeepsstories/folders behaving like ordinary app folders- the Settings UI immediately follows this write with
POST /api/admin/rescan, but this endpoint itself only saves the setting
POST /api/images/:id/like
Marks a post as liked.
Success:
{
"ok": true,
"id": 42,
"liked": true
}Errors:
404with{"message":"Image not found"}when the post does not exist or is deleted403when trust requirements are missing
DELETE /api/images/:id/like
Removes a like.
Success:
{
"ok": true,
"id": 42,
"liked": false
}DELETE /api/images/:id
Permanently deletes:
- the source file from disk
- its thumbnail derivative
- its preview derivative
Then it updates the index and folder avatar state.
Success:
{
"ok": true,
"id": 42,
"folderSlug": "oslo"
}Errors:
404with{"message":"Image not found"}403for viewer sessions403when trust requirements are missing
DELETE /api/folders/:slug
Query parameters:
| Param | Type | Default | Notes |
|---|---|---|---|
deleteSourceFolder | boolean | false | Accepts true or false. |
When deleteSourceFolder=false:
- direct posts in that folder are permanently deleted
- child folders below that path are kept
- Foldergram tries to remove now-empty directories
When deleteSourceFolder=true:
- the source folder subtree is removed from disk
- matching derivative subtrees are removed
- all indexed child folders under that subtree are removed
Success shape:
{
"ok": true,
"slug": "oslo",
"deletedImageCount": 12,
"deletedFolderCount": 1,
"deletedSourceFolder": false
}Errors:
404with{"message":"Folder not found"}403for viewer sessions403when trust requirements are missing
POST /api/admin/rescan
Runs a manual scan against the current library and then starts the development watcher.
Success:
{
"ok": true,
"lastScan": {
"id": 7,
"status": "completed",
"scanned_files": 120
}
}Errors:
409with the library-rebuild-required message when the gallery root changed403for viewer sessions500for scan failures surfaced from the scanner
POST /api/admin/rebuild-index
Stops the watcher, clears the indexed library tables, rescans the current gallery root, and then restarts the watcher.
Matching cached derivatives already on disk are left in place and can be reused. In DERIVATIVE_MODE=lazy, this rebuild does not pre-generate missing thumbnails or previews.
This resets:
likesimagesfoldersfolder_scan_statescan_runs
POST /api/admin/rebuild-thumbnails
Stops the watcher, clears the thumbnail cache, regenerates thumbnails and video poster images from indexed media, and restarts the watcher.
It does not reset:
- previews
- likes
- folder records
- scan history
Errors:
409with the library-rebuild-required message when a library-index rebuild is required403for viewer sessions
Client helpers
The frontend wraps these endpoints in client/src/api/gallery.ts.
Those helpers are the best reference for current client-side usage, including:
- default page sizes
- when
mediaTypeis sent - which routes are expected to return
itemsarrays versus single objects