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.
| Method | Path | Capability | Notes |
|---|---|---|---|
| Content | |||
| GET / POST | /content | content:read · content:create | List (cursor paginated) or create a draft entry. |
| GET / PATCH / DELETE | /content/{id} | content:read · content:update · content:delete | Read, update (draft / working revision / publish / unpublish), or trash. |
| POST | /content/{id}/publish · /unpublish | content:publish | Move an entry between published and draft. |
| GET | /content/{id}/revisions[/{revisionId}] | content:revisions:read | Revision history; single revision includes the block body. |
| Media | |||
| GET / POST | /media | media:read · media:upload | List items, or upload a file as multipart/form-data (field `file`). |
| GET / DELETE | /media/{id} | media:read · media:delete | Read one item or move it to trash. |
| Comments | |||
| GET / PATCH / DELETE | /comments | comments:moderate | List by status, bulk set status, or permanently delete trashed comments. |
| PATCH / POST | /comments/{id} · /{id}/reply | comments:moderate | Edit or re-status one comment, or reply as a moderator. |
| Structure | |||
| GET / POST / PUT / DELETE | /menus · /menus/{slug} | content:read · settings:manage | Resolved menus with layout/design; create, replace, or trash. |
| GET / POST / PATCH / DELETE | /content-types · /content-types/{slug} | content:read · settings:manage | Content type definitions and their field schemas. |
| GET / POST / PATCH / DELETE | /languages · /languages/{id} | settings:read · settings:manage | Configured languages: add, activate, reorder, make default. |
| GET / POST / PUT | /redirects · /redirects/{id} | settings:read · settings:manage | Managed redirect rules. |
| Access | |||
| GET / POST / PATCH / DELETE | /users · /users/{id} | users:read · users:manage | Users with effective access; role, policy, and display-name changes. |
| GET / POST / PATCH / DELETE | /roles · /roles/{id} | users:read · users:manage | Built-in and custom roles. |
| Settings | |||
| GET / PATCH | /settings | settings:read · settings:manage | Read and change site settings (a `settings:manage` key sees the admin view). |
| Operations | |||
| GET / POST | /plugins · /plugins/{id}/activate · /deactivate | plugins:read · plugins:activate | List installed plugins; activation reports a real failure. |
| GET / POST | /themes · /themes/{id}/activate | themes:read · themes:activate | List installed themes; switch the active theme. |
| GET / POST | /cache/stats · /cache/clear | settings:read · settings:manage | Object-cache figures; clear the object cache and page store. |
| GET / POST | /static-export · /static-export/run · /clear | settings:read · settings:manage | Status, run (full or incremental), and delete the export output. |
| GET | /diagnostics · /health | site:admin · (any key) | Version, migrations, runtime diagnostics, and platform health checks. |
| Events | |||
| GET | /events | (any key) | The event catalog with payload schemas — the names a webhook subscribes to. |
| GET / POST / PUT / DELETE | /webhooks · /webhooks/{id} · /{id}/rotate-secret | settings:manage | A key manages only the webhook endpoints it registered itself. |
| Discovery | |||
| GET | /openapi.json | (none) | The OpenAPI 3.1 document for the whole authenticated surface. |
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)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" } },
{ "$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" } } },
"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" } } }
}
}
}