Skip to content

REST API

Plumix can serve your published content as JSON under /_plumix/api/v1, alongside a generated OpenAPI 3.1 document that describes every endpoint. The API is off by default. One config slot turns it on, and a second opens it to browsers on other origins.

The API is read-only for core content. Every content endpoint core mounts answers GET. Each public entry type and each public taxonomy gets a collection, named by pluralising the type or taxonomy name, so post becomes /posts and category becomes /categories. A type or taxonomy registered with isPublic: false has no collection, and asking for one returns 404.

Endpoint What it returns
GET /_plumix/api/v1/{collection} A page of entries or terms from one collection
GET /_plumix/api/v1/{collection}/{id} One entry or term, by numeric id
GET /_plumix/api/v1/openapi.json The OpenAPI document for every endpoint, core and plugin alike
POST /_plumix/api/v1/system/mail/test Sends core’s mailTest mail to { to } through the site’s mailer

A request without a token reads as an anonymous visitor and sees published content only. Content it may not see is a 404, never a 403, so a caller cannot probe for a draft’s existence. A request carrying an API token reads as that token’s user instead, as Authenticating with an API token describes.

The mail test route is what plumix mail test --remote calls. It needs a token whose user holds settings:manage, answers 204 once the mailer accepts the mail, and answers 502 with { error: { code: "mail_send_failed", message } }, the transport’s own message, when it refuses.

Plugins add their own endpoints to the same namespace, and those appear in openapi.json automatically. @plumix/plugin-comments is the one that ships, with GET /_plumix/api/v1/{collection}/{entry}/comments.

  1. Enable the API in your config:

    import type { PlumixConfigInput } from "plumix";
    export const api: PlumixConfigInput["api"] = { enabled: true };

    Pass it as the api slot of plumix({ … }).

  2. Read a collection:

    Terminal window
    curl https://example.com/_plumix/api/v1/posts
  3. Fetch the spec and point any OpenAPI tool at it:

    Terminal window
    curl https://example.com/_plumix/api/v1/openapi.json

A list response is an envelope around one page of results:

{
"data": [
{ "id": 42, "slug": "sicilian-caponata", "title": "Sicilian caponata" }
],
"meta": { "page": 2, "per_page": 20 },
"links": {
"self": "/_plumix/api/v1/posts?page=2",
"next": "/_plumix/api/v1/posts?page=3",
"prev": "/_plumix/api/v1/posts?page=1"
}
}

?page starts at 1 and ?per_page defaults to 20, capped at 100. The API clamps a value outside the range rather than rejecting it. links.next is present only when another page exists, and links.prev only past the first page. The links are paths without an origin, so resolve them against the URL you called.

Entries come newest first by publish date. An entry collection also filters by any public taxonomy, named as a query parameter and given one or more term slugs separated by commas:

Terminal window
curl "https://example.com/_plumix/api/v1/posts?category=baking,bread&tag=sourdough"

The API ignores a query key that names no public taxonomy. A taxonomy called page or per_page cannot act as a filter, because pagination owns those keys.

The response schemas are an allowlist. Nothing leaves the API unless the schema names it. An entry carries id, type, slug, title, excerpt, content, contentGated, status, publishedAt, createdAt, updatedAt, author (id, name and avatarUrl), terms keyed by taxonomy, and two default-deny maps, meta and images. contentGated is true for an entry an access policy lists as a teaser, whose content is then null. Dates are ISO 8601 strings. A term carries id, name and slug.

meta holds only the fields declared with .showInApi(). images holds one key for each image role a field of that entry type declares, again only when that field opted in:

{
"id": 42,
"slug": "sicilian-caponata",
"meta": { "servings": 4 },
"images": {
"featured": {
"url": "https://media.example.com/caponata.jpg",
"alt": "Caponata in a shallow bowl",
"width": 1600,
"height": 1067
}
}
}

The API addresses a role by name, not by meta key. It reads the role’s gate from whichever field carries the role, including a field nested in a group. That field opts its own image in and does not inherit the group’s answer. A role whose field did not opt in is absent from images. A role present with null means the field opted in but resolved to no image, because the reference is orphaned or the media adapter refused it. width and height travel together, and both are absent when the adapter does not know the size. Field Builders covers .showInApi(), and Field Types covers image roles.

A user mints API tokens from the API tokens card on their own profile in the admin. The client sends a token as a bearer credential:

Terminal window
curl -H "Authorization: Bearer $PLUMIX_TOKEN" https://example.com/_plumix/api/v1/posts

The request then reads as that user. A token minted with scopes can do only what both its scopes and its user’s role allow. A token with no scopes carries the role’s full capabilities. The mint form offers “Same as me” (no scopes), “Read only” and “Draft, can’t publish”; the last two store an explicit scope list when the token is minted, so a capability the role gains later never widens them. A token that is malformed, expired or revoked, or whose user is disabled, gets a 401. The API never quietly downgrades it to an anonymous read, so a client learns that its credential failed.

API tokens are for server-to-server use. A token-authenticated response never carries CORS headers, so a token embedded in browser JavaScript cannot be used from another origin.

Cross-origin access is closed by default, even with the API enabled. With no cors block, the API never sends an Access-Control-Allow-Origin header and refuses a preflight with 403. List the origins allowed to make anonymous requests, or pass "*" for any origin:

import type { PlumixConfigInput } from "plumix";
export const api: PlumixConfigInput["api"] = {
enabled: true,
cors: { origins: ["https://app.example.com"] },
};

With a list, the API echoes the allowed origin back and every response carries Vary: origin, so a shared cache never serves one origin’s answer to another. The API never sets Access-Control-Allow-Credentials.

A preflight allows GET and OPTIONS, plus the method of every auth: "public" plugin resource whose path matches the requested one, so an allowed origin can call a public POST resource with a JSON body. The method of an "authenticated" or capability resource is never listed, because those take an API token and tokens stay server-to-server.

Core ships no rate limiter. The API sits under one stable prefix, /_plumix/api/, so throttle it in front of the app. On Cloudflare, add a WAF rate-limiting rule scoped to http.request.uri.path starts_with "/_plumix/api/". On another host, apply the same prefix match in your reverse proxy.

A plugin contributes an endpoint to the API with registerRestResource. Unlike registerRoute, which mounts a raw handler under the plugin’s own prefix, a resource joins the shared /_plumix/api/v1 namespace and appears in openapi.json. path is relative to that prefix, method defaults to GET, and the output schema is both the response allowlist and what the spec documents:

import { definePlugin, v } from "plumix/plugin";
const statusSchema = v.object({ ok: v.boolean() });
export const status = definePlugin("status", {
setup: (ctx) => {
ctx.registerRestResource({
path: "/status",
auth: "public",
output: statusSchema,
handler: () => ({ ok: true }),
});
},
});

Core enforces auth before the handler runs. "public" is open, "authenticated" requires an API token, and a capability gate checks the token’s grants. Core rejects "development", because a resource is part of the published API. A path that collides with core’s or another plugin’s fails at boot.

The handler receives { input, context, errors }. input is what the resource’s input schema parses from its params, query and body. errors is core’s REST error set, so throw errors.NOT_FOUND({ data: { kind: "entry" } }) answers in the same shape core’s own routes do.

Two path segment names are reserved. Core binds them before the handler runs, and the handler receives what they name instead of the raw strings:

  • {collection} becomes entryType, the public entry type the collection names, resolved the same way core’s own /{collection} routes resolve it. An unknown collection, a non-public type or a taxonomy’s collection answers core’s 404 and the handler never runs.
  • {entry} becomes entry, the entry with that id, if the requesting principal may read it. When the path also has {collection}, the entry must be of that type. A missing, unreadable or mismatched entry, or an id that isn’t a positive integer, all answer the same 404, as GET /{collection}/{id} does.
import { definePlugin, v } from "plumix/plugin";
export const likes = definePlugin("likes", {
setup: (ctx) => {
ctx.registerRestResource({
path: "/{collection}/{entry}/likes",
auth: "public",
output: v.object({ type: v.string(), id: v.number() }),
handler: ({ entryType, entry }) => ({
type: entryType.name,
id: entry.id,
}),
});
},
});

entryType and entry are typed as present only when the path has the matching segment, and neither appears on input. Binding decides whether the entry exists for this requester. Whether the plugin will act on it, such as whether the entry takes comments, is still the handler’s call.

Core serves the spec and nothing else. To browse it, serve a page that loads a reference viewer such as Scalar from a plugin route:

import { definePlugin } from "plumix/plugin";
export const apiDocs = definePlugin("api-docs", {
setup: (ctx) => {
ctx.registerRoute({
method: "GET",
path: "/docs",
auth: "public",
handler: () =>
new Response(
`<!doctype html><html><head><meta charset="utf-8" /></head><body>
<script id="api-reference" data-url="/_plumix/api/v1/openapi.json"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body></html>`,
{ headers: { "content-type": "text/html; charset=utf-8" } },
),
});
},
});

A plugin route mounts under /_plumix/<pluginId>, so this one answers at /_plumix/api-docs/docs. Swagger UI works the same way.

The api slot sits with the rest of the site’s settings in Configuration. Field Builders covers the per-field choice of what reaches meta. Entry Types and Taxonomies define what makes an entry type or taxonomy public. The comments endpoint belongs to Comments.

Enable the API and open openapi.json to see the collections your plugins registered. For a plugin that needs its own endpoint, start from Plugins.