HTTP API · manage/v1
Federated Management API
Run Justflows as the content and workflow backend for another application, a CI pipeline, or a CLI. One versioned surface — /api/manage/v1 — covers the same operations the administration UI performs, authenticated by revocable API keys whose capabilities and scope you choose per key.
How it differs from the Content API
/api/v1 is read-only and anonymous — published content for headless frontends. The management API is the write surface: it creates content, uploads media, and manages users, settings, menus, plugins, and themes. It is never anonymous, and every route is capability-checked exactly like its cookie-authenticated counterpart in the admin UI. Routes under /api/* that the administration interface uses are session-protected implementation details and are not third-party contracts.
Base URL and enabling
https://your-site.example/api/manage/v1The whole surface is off by default. An administrator turns it on at Admin → Settings → API, which hosts both HTTP-API switches — the read-only public content API and this management API — as separate controls. Toggling the switch, revoking a key, or letting a key expire all take effect on the next request, with no restart.
Authentication
Present a key as a bearer token. There is no cookie and no CSRF token.
GET /api/manage/v1/content HTTP/1.1
Host: your-site.example
Authorization: Bearer jfk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxThe capability model
Each key carries an explicit capability set, and three rules bound what it can do:
- Creation ceiling. A key can never be granted a capability its creator lacked at creation time.
- Live re-check. On every request the effective set is
key capabilities ∩ the owner’s current capabilities. Narrowing the owner’s role or policy immediately neuters their keys. - Scope on every operation. An optional scope (content type, locale, ownership) is enforced on each read and write, not just at route entry. A list is filtered, not blanket-denied.
Failed authorization returns a generic 401 or 403 and never reveals whether a key exists, is expired, or is merely out of scope. Every create / rotate / revoke and every auth failure is written to the audit log by key id — never the secret.
Managing keys
Administrators manage keys in the UI; the same operations are available over the cookie-authenticated admin API for tooling that already holds a session.
POST /api/api-keys # create — returns { key: "jfk_...", record }, once
GET /api/api-keys # list (never returns the secret or its hash)
GET /api/api-keys/capabilities # the capability set you are allowed to grant
PATCH /api/api-keys/:id # rename, re-scope, adjust caps / IPs / origins / expiry
POST /api/api-keys/:id/rotate # new secret, shown once; the old one stops working
POST /api/api-keys/:id/revoke # cut access immediately
DELETE /api/api-keys/:id # remove the key
GET/PUT /api/api-keys/settings # global switch + default rate limit + allowed originsQuick start
# List the ten most recently updated posts
curl -s https://your-site.example/api/manage/v1/content?type=post&limit=10 \
-H "Authorization: Bearer jfk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Create a draft, then publish it
curl -s -X POST https://your-site.example/api/manage/v1/content \
-H "Authorization: Bearer $JF_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"post","title":"Released from CI","blocks":{"version":1,"blocks":[]}}'
curl -s -X POST https://your-site.example/api/manage/v1/content/<id>/publish \
-H "Authorization: Bearer $JF_KEY"const BASE = "https://your-site.example/api/manage/v1";
const headers = { Authorization: `Bearer ${process.env.JF_KEY}` };
async function* allContent(type) {
let cursor = null;
do {
const url = new URL(`${BASE}/content`);
url.searchParams.set("type", type);
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers });
if (res.status === 429) {
await new Promise((r) => setTimeout(r, Number(res.headers.get("Retry-After") ?? 1) * 1000));
continue;
}
if (!res.ok) throw new Error(`Justflows ${res.status}: ${(await res.json()).error}`);
const { data, page } = await res.json();
yield* data;
cursor = page.nextCursor;
} while (cursor);
}
for await (const entry of allContent("post")) {
console.log(entry.slug, entry.status);
}The surface
Every operation reuses the existing service layer and the same capability check as its cookie-authenticated counterpart — no parallel business logic. Paths below are relative to the base URL; each one links to its reference page with parameters, request samples, and example responses.
Content
- GET
/contentList content - POST
/contentCreate content - GET
/content/{id}Get content - PATCH
/content/{id}Update content - DEL
/content/{id}Trash content - PUT
/content/{id}/scheduleSchedule content - POST
/content/{id}/publishPublish content - POST
/content/{id}/unpublishUnpublish content - GET
/content/{id}/revisionsList revisions - GET
/content/{id}/revisions/{revisionId}Get revision - PUT
/content/{id}/header-refSet page header - GET
/content/{id}/termsGet content terms - PUT
/content/{id}/termsSet content terms
Media
Comments
Structure
- GET
/menusList menus - POST
/menusCreate a menu - GET
/menus/{slug}Get a menu - PUT
/menus/{slug}Replace a menu - DEL
/menus/{slug}Trash a menu - GET
/content-typesList content types - POST
/content-typesCreate a content type - GET
/content-types/{slug}Get a content type - PATCH
/content-types/{slug}Update a content type - DEL
/content-types/{slug}Delete a content type - GET
/taxonomiesList taxonomies - POST
/taxonomiesCreate a taxonomy - PATCH
/taxonomies/{slug}Update a taxonomy - DEL
/taxonomies/{slug}Delete a taxonomy - GET
/taxonomies/{slug}/termsList terms - POST
/taxonomies/{slug}/termsCreate a term - PATCH
/taxonomies/{slug}/terms/{id}Update a term - DEL
/taxonomies/{slug}/terms/{id}Delete a term - GET
/languagesList languages - POST
/languagesAdd a language - PATCH
/languages/{id}Update a language - DEL
/languages/{id}Remove a language - GET
/redirectsList redirects - POST
/redirectsCreate a redirect - PUT
/redirects/{id}Update a redirect
Design
- GET
/headersGet the header library - PUT
/headersSave the header library - GET
/headers/optionsList header options - GET
/template-parts/{part}Get a template part - PUT
/template-parts/{part}Save a template part - GET
/widgetsList widget areas - PUT
/widgets/layoutSave the widget layout - POST
/widgets/areasAdd a widget area - GET
/widgets/areas/{key}Get a widget area - PUT
/widgets/areas/{key}Save a widget area - DEL
/widgets/areas/{key}Delete a widget area - POST
/widgets/areas/{key}/discard-draftDiscard a widget area draft - GET
/templatesList page templates - GET
/templates/{slug}Get a page template - PUT
/templates/{slug}Save a page template - DEL
/templates/{slug}Reset a page template - POST
/templates/{slug}/discard-draftDiscard a page template draft - GET
/themes/customizeGet theme appearance - PATCH
/themes/customizeUpdate theme appearance - DEL
/themes/customizeDiscard customizer drafts - GET
/reusable-blocksList reusable blocks - PUT
/reusable-blocksSave a reusable block - DEL
/reusable-blocks/{id}Delete a reusable block - GET
/patternsList block patterns - PUT
/patternsSave a block pattern - GET
/patterns/exportExport patterns - POST
/patterns/importImport patterns - GET
/patterns/{source}/{id}Get a block pattern - DEL
/patterns/{id}Delete a block pattern - GET
/error-pagesGet error pages - PUT
/error-pagesUpdate error pages - GET
/permalinksGet permalinks - PUT
/permalinksUpdate permalinks
Access
Settings
Site
- GET
/trashList trash - DEL
/trashPurge trash items - POST
/trash/restoreRestore trash items - GET
/comment-rulesList comment rules - POST
/comment-rulesAdd a comment rule - DEL
/comment-rules/{id}Remove a comment rule - GET
/comment-spam-termsList spam terms - POST
/comment-spam-termsAdd a spam term - DEL
/comment-spam-terms/{id}Remove a spam term - GET
/cookiesGet the cookie registry - PUT
/cookiesReclassify cookies - GET
/email-templatesList email templates - PUT
/email-templates/designSave the email design - POST
/email-templates/design/restoreRestore the email design - PUT
/email-templates/{key}Save an email template - POST
/email-templates/{key}/restoreRestore an email template - POST
/email-templates/{key}/previewPreview an email template
Operations
- GET
/pluginsList plugins - POST
/plugins/{id}/activateActivate a plugin - POST
/plugins/{id}/deactivateDeactivate a plugin - GET
/themesList themes - POST
/themes/{id}/activateActivate a theme - GET
/cache/statsGet cache statistics - POST
/cache/clearClear the cache - GET
/static-exportGet static export status - POST
/static-export/runRun a static export - POST
/static-export/clearDelete the static export - GET
/diagnosticsGet diagnostics - GET
/healthGet health checks
Platform
- GET
/tenantsList workspaces - POST
/tenantsCreate a workspace - POST
/tenants/{id}/sitesAdd a site - GET
/tenants/{id}/quotasGet workspace quotas - PUT
/tenants/{id}/quotasSet workspace quotas - GET
/sites/{id}/quotasGet site quotas - PUT
/sites/{id}/quotasSet site quotas - POST
/tenants/{id}/suspendSuspend a workspace - POST
/tenants/{id}/reactivateReactivate a workspace - DEL
/tenants/{id}Delete a workspace
Events
Discovery
Response envelope
- Lists return
{ data: [...], page: { limit, cursor, nextCursor, total } }. Pass?limit=and?cursor=. The cursor is opaque — never build or increment it, and keep requesting whilenextCursoris non-null. - Errors are
{ "error": "..." }with a conventional status code. - Conditional reads. GETs return an
ETag; sendIf-None-Matchfor a304. - Rate limiting.
429carriesRetry-After; the IETFRateLimitheader reports the remaining budget.
{
"data": [
{ "id": "01J...", "type": "post", "title": "Hello", "slug": "hello", "status": "published", "version": 3 }
],
"page": { "limit": 50, "cursor": null, "nextCursor": "eyJvIjo1MH0", "total": 50 }
}Rate limits
Every key-authenticated route is limited per key and per IP. The ceiling is the key’s own rateLimitPerMin when set, otherwise the global default (120/min). Routes that touch the filesystem — media upload, plugin/theme activation, static export — carry an additional limiter.
Events and webhooks
GET /events returns the platform event catalog with payload schemas — the same names a webhook endpoint subscribes to. A key with settings:manage registers and manages its own webhook endpoints, so an integration self-subscribes instead of asking an administrator to wire it. Rows a key creates are tagged with the key id; it cannot see or change another key’s subscriptions or any an administrator created.
# Discover the events you can subscribe to
curl -s https://your-site.example/api/manage/v1/events -H "Authorization: Bearer $JF_KEY"
# Register an endpoint this key owns (needs settings:manage)
curl -s -X POST https://your-site.example/api/manage/v1/webhooks \
-H "Authorization: Bearer $JF_KEY" -H "Content-Type: application/json" \
-d '{"name":"CI deploy","url":"https://ci.example/hooks/justflows","events":["content.published","content.unpublished"]}'
# -> 201 { "endpoint": { "id": "..." }, "secret": "whsec_..." } (secret shown once)AI agents over MCP
The same operations are available to AI agents as MCP tools at /api/mcp, switched on under Settings → API next to the management API. Each tool calls the operation’s handler in-process with the same capability and scope check, and a session sees only the tools its key or grant can use. Key-based clients (Cursor, Claude Code, VS Code) send the same jfk_… keys; claude.ai and ChatGPT sign in with OAuth 2.1. An OAuth token can also be bound to /api/manage/v1 (resource=https://your-site.example/api/manage/v1) for agent use of this API. Plugins add tools through the mcp.tools filter. See Bring your own AI.
Discoverability
Each site serves its live schema at /api/manage/v1/openapi.json. It declares the bearerAuth scheme and annotates every operation with x-required-capability. Plugins that register management routes extend the document through the typed openapi.document filter, exactly as for /api/v1. Use it to generate a client, validate responses, or import the API into a testing tool.
curl https://your-site.example/api/manage/v1/openapi.json \
-H "Authorization: Bearer $JF_KEY"Full OpenAPI document
A representative document is below. Your site’s live copy is authoritative and includes any operations added by active plugins.
{
"openapi": "3.1.0",
"info": {
"title": "Justflows Federated Management API",
"version": "manage/v1",
"description": "Operate Justflows headlessly over HTTP, authenticated by a revocable API key. Enable the surface in Admin -> Settings -> API. Each operation reuses the same capability check as its cookie-authenticated counterpart."
},
"servers": [{ "url": "/api/manage/v1" }],
"security": [{ "bearerAuth": [] }],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"description": "A Justflows API key (prefix 'jfk_'). Created in Admin -> Settings -> API; the secret is shown once."
}
},
"parameters": {
"limit": { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } },
"cursor": { "name": "cursor", "in": "query", "schema": { "type": "string" }, "description": "Opaque cursor from a previous response's page.nextCursor." }
},
"headers": {
"RateLimit": { "schema": { "type": "string" }, "description": "IETF draft rate-limit policy and remaining budget." },
"Retry-After": { "schema": { "type": "integer" }, "description": "Seconds to wait before retrying, sent with 429." },
"ETag": { "schema": { "type": "string" } }
},
"responses": {
"Unauthorized": { "description": "Missing, unknown, revoked, expired, or out-of-scope key.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"Forbidden": { "description": "The key lacks the required capability for this resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"TooManyRequests": { "description": "Per-key or per-IP rate limit exceeded.", "headers": { "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"NotModified": { "description": "If-None-Match matched the current ETag." }
},
"schemas": {
"Error": { "type": "object", "required": ["error"], "properties": { "error": { "type": "string" } } },
"Page": {
"type": "object",
"properties": {
"data": { "type": "array", "items": {} },
"page": {
"type": "object",
"properties": {
"limit": { "type": "integer" },
"cursor": { "type": ["string", "null"] },
"nextCursor": { "type": ["string", "null"] },
"total": { "type": "integer", "description": "Items on this page, not the size of the whole collection." }
}
}
}
},
"ContentInput": {
"type": "object",
"required": ["title"],
"properties": {
"type": { "type": "string", "default": "post" },
"title": { "type": "string", "minLength": 1 },
"slug": { "type": "string" },
"excerpt":{ "type": "string" },
"locale": { "type": "string" },
"blocks": { "type": "object", "properties": { "version": { "const": 1 }, "blocks": { "type": "array", "items": {} } } },
"fields": { "type": "object", "additionalProperties": true }
}
},
"ContentEntry": {
"type": "object",
"properties": {
"id": { "type": "string" }, "type": { "type": "string" }, "title": { "type": "string" },
"slug": { "type": "string" }, "locale": { "type": "string" }, "status": { "type": "string" },
"excerpt": { "type": ["string", "null"] }, "blocks": { "type": "object" }, "fields": { "type": "object" },
"publishedAt": { "type": ["string", "null"], "format": "date-time" },
"createdAt": { "type": "string", "format": "date-time" }, "updatedAt": { "type": "string", "format": "date-time" },
"version": { "type": "integer" }
}
},
"EventDescriptor": {
"type": "object",
"properties": {
"event": { "type": "string" },
"description": { "type": "string" },
"data": { "type": "object", "additionalProperties": { "type": "string" } }
}
}
}
},
"paths": {
"/openapi.json": {
"get": { "summary": "This OpenAPI document", "security": [], "x-required-capability": "(none)", "responses": { "200": { "description": "OpenAPI 3.1 document" } } }
},
"/events": {
"get": {
"summary": "Platform event catalog with payload schemas",
"x-required-capability": "(any key)",
"responses": {
"200": { "description": "Event list", "content": { "application/json": { "schema": { "type": "object", "properties": { "events": { "type": "array", "items": { "$ref": "#/components/schemas/EventDescriptor" } } } } } } },
"401": { "$ref": "#/components/responses/Unauthorized" },
"429": { "$ref": "#/components/responses/TooManyRequests" }
}
}
},
"/content": {
"get": {
"summary": "List content",
"x-required-capability": "content:read",
"parameters": [
{ "name": "type", "in": "query", "schema": { "type": "string" } },
{ "name": "status", "in": "query", "schema": { "type": "string", "enum": ["draft", "published", "archived", "scheduled"] } },
{ "name": "locale", "in": "query", "schema": { "type": "string" } },
{ "name": "search", "in": "query", "schema": { "type": "string" }, "description": "Case-insensitive match on title or slug" },
{ "name": "author", "in": "query", "schema": { "type": "string" }, "description": "Author user id" },
{ "$ref": "#/components/parameters/limit" },
{ "$ref": "#/components/parameters/cursor" }
],
"responses": {
"200": {
"description": "Cursor-paginated content, within the key's scope.",
"headers": { "ETag": { "$ref": "#/components/headers/ETag" }, "RateLimit": { "$ref": "#/components/headers/RateLimit" } },
"content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Page" }, { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/ContentEntry" } } } } ] } } }
},
"304": { "$ref": "#/components/responses/NotModified" },
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" },
"429": { "$ref": "#/components/responses/TooManyRequests" }
}
},
"post": {
"summary": "Create a content entry (draft)",
"x-required-capability": "content:create",
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContentInput" } } } },
"responses": {
"201": { "description": "Created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContentEntry" } } } },
"400": { "description": "Validation failed or unknown content type", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "$ref": "#/components/responses/Unauthorized" },
"403": { "$ref": "#/components/responses/Forbidden" },
"409": { "description": "Slug conflict", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"429": { "$ref": "#/components/responses/TooManyRequests" }
}
}
},
"/content/{id}": {
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"get": { "summary": "Get one content entry", "x-required-capability": "content:read", "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContentEntry" } } } }, "304": { "$ref": "#/components/responses/NotModified" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "description": "Not found" } } },
"patch": { "summary": "Update a content entry", "x-required-capability": "content:update", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "title": { "type": "string" }, "slug": { "type": "string" }, "excerpt": { "type": ["string", "null"] }, "blocks": {}, "fields": { "type": "object" }, "status": { "type": "string", "enum": ["draft", "published", "archived", "scheduled"] }, "expectedVersion": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Updated" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "description": "Not found" }, "409": { "description": "Version conflict" } } },
"delete": { "summary": "Move a content entry to trash", "x-required-capability": "content:delete", "responses": { "200": { "description": "Trashed" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "description": "Not found" } } }
},
"/content/{id}/publish": {
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"post": { "summary": "Publish a content entry", "x-required-capability": "content:publish", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "expectedVersion": { "type": "integer" } } } } } }, "responses": { "200": { "description": "Published" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Forbidden" }, "409": { "description": "Version conflict" } } }
},
"/media": {
"get": { "summary": "List media library items", "x-required-capability": "media:read", "parameters": [{ "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/cursor" }], "responses": { "200": { "description": "Cursor-paginated media" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Forbidden" } } },
"post": { "summary": "Upload a file", "x-required-capability": "media:upload", "requestBody": { "required": true, "content": { "multipart/form-data": { "schema": { "type": "object", "properties": { "file": { "type": "string", "format": "binary" } }, "required": ["file"] } } } }, "responses": { "201": { "description": "Stored" }, "400": { "description": "No file / rejected type" }, "413": { "description": "Over the library quota or size limit" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Forbidden" }, "429": { "$ref": "#/components/responses/TooManyRequests" } } }
},
"/media/{id}": {
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"get": { "summary": "Get one media item", "x-required-capability": "media:read", "responses": { "200": { "description": "OK" }, "404": { "description": "Not found" } } },
"patch": { "summary": "Change media details", "x-required-capability": "media:upload", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "altText": { "type": ["string", "null"] }, "caption": { "type": ["string", "null"] }, "focalX": { "type": ["number", "null"], "minimum": 0, "maximum": 1 }, "focalY": { "type": ["number", "null"], "minimum": 0, "maximum": 1 } } } } } }, "responses": { "200": { "description": "Updated" }, "400": { "description": "Validation failed" }, "404": { "description": "Not found" } } },
"delete": { "summary": "Move a media item to trash", "x-required-capability": "media:delete", "responses": { "200": { "description": "Trashed" }, "404": { "description": "Not found" } } }
},
"/settings": {
"get": { "summary": "Read site settings", "x-required-capability": "settings:read", "responses": { "200": { "description": "Settings" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Forbidden" } } },
"patch": { "summary": "Change site settings", "x-required-capability": "settings:manage", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "responses": { "200": { "description": "Saved" }, "400": { "description": "Validation failed" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Forbidden" } } }
},
"/users": {
"get": { "summary": "List users", "x-required-capability": "users:read", "responses": { "200": { "description": "Users" } } },
"post": { "summary": "Create a user", "x-required-capability": "users:manage", "responses": { "201": { "description": "Created" }, "400": { "description": "Validation failed" } } }
},
"/plugins/{id}/activate": {
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"post": { "summary": "Activate a plugin", "x-required-capability": "plugins:activate", "responses": { "200": { "description": "Activated" }, "502": { "description": "The plugin's activate() threw" } } }
},
"/webhooks": {
"get": { "summary": "List the webhook endpoints this key registered", "x-required-capability": "settings:manage", "responses": { "200": { "description": "Endpoints and subscribable event types" } } },
"post": { "summary": "Register a webhook endpoint owned by this key", "x-required-capability": "settings:manage", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["name", "url", "events"], "properties": { "name": { "type": "string" }, "url": { "type": "string" }, "events": { "type": "array", "items": { "type": "string" } }, "active": { "type": "boolean" } } } } } }, "responses": { "201": { "description": "Created - signing secret shown once" }, "400": { "description": "Invalid URL or unknown event" } } }
},
"/health": {
"get": { "summary": "Platform health checks", "x-required-capability": "(any key)", "responses": { "200": { "description": "Aggregate status and individual checks" } } }
}
}
}