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; each one links to its reference page with parameters, request samples, and example responses.

Content

Media

Comments

Structure

Design

Access

Settings

Site

Operations

Platform

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 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)

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.

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" } },
          { "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" } } }
    }
  }
}