JustFlows

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

text
https://your-site.example/api/manage/v1

The 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.

http
GET /api/manage/v1/content HTTP/1.1
Host: your-site.example
Authorization: Bearer jfk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

The 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.

Key lifecycle (cookie-authenticated admin API)
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 origins

Quick start

curl
# 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"
Paginated client with back-off
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.

MethodPathCapabilityNotes
Content
GET / POST/contentcontent:read · content:createList (cursor paginated) or create a draft entry.
GET / PATCH / DELETE/content/{id}content:read · content:update · content:deleteRead, update (draft / working revision / publish / unpublish), or trash.
POST/content/{id}/publish · /unpublishcontent:publishMove an entry between published and draft.
GET/content/{id}/revisions[/{revisionId}]content:revisions:readRevision history; single revision includes the block body.
Media
GET / POST/mediamedia:read · media:uploadList items, or upload a file as multipart/form-data (field `file`).
GET / DELETE/media/{id}media:read · media:deleteRead one item or move it to trash.
Comments
GET / PATCH / DELETE/commentscomments:moderateList by status, bulk set status, or permanently delete trashed comments.
PATCH / POST/comments/{id} · /{id}/replycomments:moderateEdit or re-status one comment, or reply as a moderator.
Structure
GET / POST / PUT / DELETE/menus · /menus/{slug}content:read · settings:manageResolved menus with layout/design; create, replace, or trash.
GET / POST / PATCH / DELETE/content-types · /content-types/{slug}content:read · settings:manageContent type definitions and their field schemas.
GET / POST / PATCH / DELETE/languages · /languages/{id}settings:read · settings:manageConfigured languages: add, activate, reorder, make default.
GET / POST / PUT/redirects · /redirects/{id}settings:read · settings:manageManaged redirect rules.
Access
GET / POST / PATCH / DELETE/users · /users/{id}users:read · users:manageUsers with effective access; role, policy, and display-name changes.
GET / POST / PATCH / DELETE/roles · /roles/{id}users:read · users:manageBuilt-in and custom roles.
Settings
GET / PATCH/settingssettings:read · settings:manageRead and change site settings (a `settings:manage` key sees the admin view).
Operations
GET / POST/plugins · /plugins/{id}/activate · /deactivateplugins:read · plugins:activateList installed plugins; activation reports a real failure.
GET / POST/themes · /themes/{id}/activatethemes:read · themes:activateList installed themes; switch the active theme.
GET / POST/cache/stats · /cache/clearsettings:read · settings:manageObject-cache figures; clear the object cache and page store.
GET / POST/static-export · /static-export/run · /clearsettings:read · settings:manageStatus, run (full or incremental), and delete the export output.
GET/diagnostics · /healthsite: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-secretsettings:manageA 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 while nextCursor is non-null.
  • Errors are { "error": "..." } with a conventional status code.
  • Conditional reads. GETs return an ETag; send If-None-Match for a 304.
  • Rate limiting. 429 carries Retry-After; the IETF RateLimit header reports the remaining budget.
GET /content — 200
{
  "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.

Self-subscribe to platform events
# 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.

shell
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.

/api/manage/v1/openapi.json
{
  "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" } } }
    }
  }
}