Pluma
Docs index
docs/api/management.md View markdown →

Management API

Writing: the model, the entries, the files. It needs a management key or an agent token (pluma_agt_, see Agents); with a delivery or preview key you get key_cannot. Conventions in API.

Everything written by API starts as a draft. Publishing is a separate request. Every change goes into the site's activity with the key's name.

Site settings

PATCH /api/v1/spaces/:space_id

{ "production_url": "https://www.example.com", "preview_url": "https://staging.example.com" }

Where the live site and the staging site live. With them and each type's url_path, entries come with sys.url and sys.preview_url. Needs manage_webhooks. An empty string clears a URL. Whatever you don't send doesn't change.

It also takes the site's languages settings (Languages):

  • default_locale: the language the content is written in, like "es". Changing it moves every value stored under the old code to the new one (all entries, all versions) and renames the language; it doesn't translate. If an entry has values in both, you get validation_failed with details.fields.default_locale naming the entries, and nothing changes.
  • prefix_default_locale: true puts the default language in URLs built with {locale} too (/es/menu); false (the default) leaves it out (/menu).

Languages

The site's languages are in GET /api/v1/spaces/:space_id (locales). Changing them needs manage_model.

POST /api/v1/spaces/:space_id/locales

{ "code": "en", "name": "English", "fallback_code": "es" }
  • code: two letters, or two plus a region (en-US). name is optional (the language's own name by default).
  • fallback_code: what to show when a value is missing in this language. Without it, the default language; null for none.
  • It answers 201 with { "code": "en", "name": "English", "default": false, "fallback_code": "es" }.

PATCH /api/v1/spaces/:space_id/locales/:code changes name or fallback_code.

DELETE /api/v1/spaces/:space_id/locales/:code removes a language. Its values are deleted from every entry and version, published ones too, so if it has any, it refuses with validation_failed (details.fields.delete_values says how many entries) unless you add ?delete_values=true. The default language can't be removed. It answers with entries_changed and the languages left.

Reading (list types, get one entry, filter by field) uses the same routes as the Delivery API: GET /api/v1/spaces/:space_id/content_types, GET /api/v1/spaces/:space_id/entries/:id, GET /api/v1/spaces/:space_id/entries with filters like ?content_type=menu_item&fields.slug=morning-bun. With a management key or an agent token they return the latest version, drafts included.

Model

Create a content type

POST /api/v1/spaces/:space_id/content_types

curl -X POST https://pluma.so/api/v1/spaces/acton-estero/content_types?dry_run=true \
  -H "Authorization: Bearer $PLUMA_KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "Event", "api_id": "event", "description": "Calendar events",
    "fields": [
      { "api_id": "title", "name": "Title", "type": "symbol", "required": true },
      { "api_id": "slug", "name": "Slug", "type": "slug", "required": true },
      { "api_id": "startsAt", "name": "Starts", "type": "date" },
      { "api_id": "body", "name": "Description", "type": "rich_text" }
    ]
  }'
  • With dry_run=true nothing is saved: the response says what would be created, or the errors. Always use it first.
  • If it looks right, send the same thing without dry_run. It answers 201 with the content type (same shape as in the Delivery API):
curl -X POST https://pluma.so/api/v1/spaces/acton-estero/content_types \
  -H "Authorization: Bearer $PLUMA_KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "Event", "api_id": "event", "description": "Calendar events",
    "fields": [
      { "api_id": "title", "name": "Title", "type": "symbol", "required": true },
      { "api_id": "slug", "name": "Slug", "type": "slug", "required": true },
      { "api_id": "startsAt", "name": "Starts", "type": "date" },
      { "api_id": "body", "name": "Description", "type": "rich_text" }
    ]
  }'
  • The first symbol or slug field becomes the title field, unless you send display_field.
  • Optional on create too: guidance, previews and url_path (where its entries live on your site, like /blog/{slug}).
  • Agent guidance (optional): guidance (when to use the type), previews (ids of up to 4 images that show what it looks like) and help on each field (one line: format or example). See Agent guidance.
  • For the site's settings (name, phone, menu), send "singleton": true: the type has a single entry.
  • Field types: the ones in Model your content. References accept link_content_types.

Edit a content type

PATCH /api/v1/spaces/:space_id/content_types/:id

Accepts name, description, display_field, guidance, previews ([] clears them) and url_path (where its entries live on your site, like /blog/{slug}; see Staging). The api_id can't be changed.

Adding or editing a field and editing a content type also take ?dry_run=true: nothing is saved and the answer is {"dry_run": true, "would_update": …} with the content type as it would be, or the same errors as the real call. A field's api_id is never reused, so try new fields with dry_run first.

Add a field

POST /api/v1/spaces/:space_id/content_types/:content_type_id/fields

{ "api_id": "seats", "name": "Seats", "type": "integer" }

Edit or hide a field

PATCH /api/v1/spaces/:space_id/content_types/:content_type_id/fields/:id

Accepts name, required, localized, type, link_content_types, help (one line, up to 300 characters) and hidden (true hides it, false shows it). Whatever you don't send doesn't change. Changing type on a field that has data gives validation_failed.

Entries

Create

POST /api/v1/spaces/:space_id/entries

curl -X POST https://pluma.so/api/v1/spaces/acton-estero/entries \
  -H "Authorization: Bearer $PLUMA_KEY" -H "Content-Type: application/json" \
  -d '{
    "content_type": "event",
    "fields": {
      "title": "Open House",
      "slug": "open-house",
      "startsAt": "2026-10-12T09:00:00-06:00",
      "body": "## Come join us\n\nA **great** day."
    }
  }'
  • It answers 201 with the entry as a draft (Delivery API shape, sys.status: "draft").
  • Rich text: send markdown (a string) or the block document (an object).
  • References: the entry's id ("7"). Files: the asset's id.
  • Dates: stored as you send them. "2026-08-14" stays a date only; "2026-10-12T09:00:00-06:00" keeps its time zone. They are not converted to UTC.
  • Saving validates the types; required fields are validated on publish.

Edit

PATCH /api/v1/spaces/:space_id/entries/:id

{ "version": 3, "fields": { "title": "Open House 2026" } }
  • Only the fields you send change. To empty one, send it as null.
  • Every edit is a new version.
  • version (optional, recommended): the version you read. If someone saved another one in between, you get version_conflict and nothing is overwritten.
  • Another language: add ?locale=en and the fields you send are written in English (only localized fields). With ?locale=*, each field is an object by language, { "title": { "es": "Menú del día", "en": "Daily menu" } }, and errors for a language other than the default come as title.en. Creating takes the same locale. See Languages.

Translate with AI

POST /api/v1/spaces/:space_id/entries/:id/translate

{ "to": "en" }

Fills the English localized fields that are still empty from the default language, with AI, and saves them as a new draft version. It never publishes. Text, rich text (same structure), slugs (translated and unique), tags and the text in lists are translated; references, files, numbers, dates and yes/no stay. "overwrite": true translates every field again. Needs write.

It answers with the entry in that language plus translation: { "from": "es", "to": "en", "fields": ["title", "slug", "body"], "skipped": [] }. skipped lists rich text fields whose translation came back with a different structure; they're left as they were. If the AI provider is down: translation_unavailable, and nothing is saved.

Publish, archive, unarchive

POST /api/v1/spaces/:space_id/entries/:id/publish

POST /api/v1/spaces/:space_id/entries/:id/archive

POST /api/v1/spaces/:space_id/entries/:id/unarchive

Publishing validates the whole entry (required fields, unique slugs, references, files with alt text). If it fails, you get validation_failed with the details per field.

Versions

GET /api/v1/spaces/:space_id/entries/:id/versions

{ "sys": { "type": "Array" }, "total": 3, "items": [
  { "version": 3, "author": { "type": "ApiKey", "name": "Rosa's agent" }, "created_at": "…", "published": false, "fields": { … } }
] }

Files

Upload

POST /api/v1/spaces/:space_id/assets

As multipart, with the file:

curl -X POST https://pluma.so/api/v1/spaces/acton-estero/assets \
  -H "Authorization: Bearer $PLUMA_KEY" \
  -F "file=@playground.jpg" -F "title=Playground" -F "alt=Recess in the playground"

Or as JSON, with the file's URL: Pluma downloads it. Useful for migrating from another CMS without downloading anything to your machine.

{ "url": "https://images.ctfassets.net/…/playground.jpg", "alt": "Recess in the playground" }
  • The URL must be https and on the public internet. Up to 3 redirects are followed, and each one is checked again.
  • filename (optional) changes the name; otherwise the one from the URL is used. The title comes from the name if you don't send title.
  • Types and sizes: JPG, PNG, WebP, GIF, SVG and PDF up to 20 MB; MP4 and WebM up to 50 MB. An SVG can't contain scripts or load things from outside. See Images and files.

Edit or replace a file

PATCH /api/v1/spaces/:space_id/assets/:id

Accepts title, alt and description. To replace the file itself, also send file (multipart) or url (JSON, like when uploading). The id stays the same, so every entry that uses it shows the new file without being edited. Same types and sizes as an upload.

# 4 is the id of the file you're replacing
curl -X PATCH https://pluma.so/api/v1/spaces/acton-estero/assets/4 \
  -H "Authorization: Bearer $PLUMA_KEY" -F "file=@patio-2027.jpg"

If something published uses the file, the deploy hooks get asset.replaced so the site rebuilds with it.

Batch

POST /api/v1/spaces/:space_id/batch

Several operations in a single request, all or nothing: if one fails, nothing is saved. To migrate a whole site in a few calls instead of hundreds.

curl -X POST https://pluma.so/api/v1/spaces/acton-estero/batch \
  -H "Authorization: Bearer $PLUMA_KEY" -H "Content-Type: application/json" \
  -d '{
    "operations": [
      { "op": "create_entry", "ref": "fair", "content_type": "event",
        "fields": { "title": "Science fair", "slug": "science-fair", "startsAt": "2026-11-05T14:00:00-06:00" } },
      { "op": "create_entry", "ref": "graduation", "content_type": "event",
        "fields": { "title": "Graduation", "slug": "graduation", "startsAt": "2026-11-28" } },
      { "op": "publish_entry", "id": "$fair" },
      { "op": "publish_entry", "id": "$graduation" }
    ]
  }'

It answers 200 with one result per operation, in the same order:

{ "dry_run": false, "total": 4, "results": [
  { "op": "create_entry", "ref": "fair", "sys": { "id": "12", "type": "Entry", "content_type": "event", "version": 1, "status": "draft" } },
  …
  { "op": "publish_entry", "sys": { "id": "13", "type": "Entry", "content_type": "event", "version": 1, "status": "published" } }
] }
op Fields Permission
create_entry content_type, fields, locale (optional) write
update_entry id, fields, version (optional), locale (optional) write
create_asset url, or data (base64) + filename for a local file; title, alt, description write
publish_entry id publish
archive_entry id publish
unarchive_entry id publish
  • ref: give an operation a name and use it later as "$name": in id, and in reference fields (reference, references) or file fields (asset, assets). That way you create an author and a post that references it in the same batch, without knowing the ids. They are not resolved inside rich text: use the real id there.
  • locale: like ?locale= on a single entry: "en" writes English, "*" takes each field as an object by language. See Languages.
  • Limits: up to 100 operations and 20 files (create_asset) per batch. It counts as one request for the per-token limit.
  • dry_run=true: runs everything, validates everything and saves nothing. Files are still downloaded, to validate them. The ids in a dry run are not reserved: the real run can get different ones, so always use "$ref".
  • Links: with the site's URLs and the type's url_path set, each entry result also carries url and preview_url (see Staging), so you can hand the owner the link without reading the entry again.
  • If something fails, the error is that operation's (validation_failed, not_found, version_conflict, key_cannot…) and details.operation says which one (starting at 0), with its op and its ref. Fix that one and send the whole batch again.
  • Deploy hooks fire only if the batch was saved: a batch that fails doesn't trigger builds.

Export everything

GET /api/v1/spaces/:space_id/export

Returns the whole site as JSON: content types with all their fields (hidden ones too), each entry with its latest version and its published version, and the files with their URL. It's yours: if you leave, you take it with you.

AI answers

GET /api/v1/spaces/:space_id/answers

PATCH /api/v1/spaces/:space_id/questions

GET returns the site's questions, the place they're asked from (market: country, city) and the last run: how many answers, in how many the site is mentioned and cited, its average_position, the competitors named in the answers (each with how many answers named it), the issues (statements that contradict the site's published content, each with claim and correct), and items with each question's answer and sources. history has the last 12 runs. Any key that reads drafts.

PATCH replaces the questions: {"questions": ["…", "…"], "market": {"country": "GT", "city": "Guatemala City"}}, 1 to 30 questions of up to 200 characters. Needs manage_model. See AI answers.

Analytics

GET /api/v1/spaces/:space_id/analytics

The site's first-party analytics for the last days (1 to 365, default 30): visitors, page_views, from_ai (visitors and page views that came from an AI assistant), and top-10 lists of sources, ai_engines, referrers, pages, countries, cities, campaigns (UTM) and devices, each item with its count of visitors. crawlers has the AI crawler visits, by_bot (with operator and purpose: search, user or training) and the pages read for answers. Any key that reads drafts. See Analytics.

Search queries

GET /api/v1/spaces/:space_id/search_queries

What people searched on Google and Bing before they saw or clicked the site, from Google Search Console and Bing Webmaster Tools, for the last days (1 to 90, default 28). engine is google or bing; without it, both. Any key that reads drafts. Empty until an owner connects the engines in Settings → Integrations (it needs their Google or Bing account, so there's no API to connect). See Search Console and Bing.

  • connections: each connected engine with its site_url (the property), synced_at and last_error (null when the last sync worked).
  • totals: per engine, clicks, impressions, ctr (0 to 1) and position (average, 1 is the top).
  • queries and pages: the top 50 of each, most clicks first, with engine, the query or page, and the same four numbers.

Classic search only. Neither engine shares its AI answers data (Google's AI Overviews and AI Mode, Bing's Copilot) through its API.

{
  "days": 28,
  "since": "2026-09-03",
  "connections": [{ "engine": "google", "site_url": "sc-domain:harborbakery.example", "synced_at": "2026-10-01T05:10:00Z", "last_error": null }],
  "totals": { "google": { "clicks": 412, "impressions": 9870, "ctr": 0.0417, "position": 11.3 } },
  "queries": [{ "engine": "google", "query": "sourdough near me", "clicks": 96, "impressions": 1240, "ctr": 0.0774, "position": 3.2 }],
  "pages": [{ "engine": "google", "page": "https://harborbakery.example/menu", "clicks": 180, "impressions": 3100, "ctr": 0.0581, "position": 6.4 }]
}

AI readiness

GET /api/v1/spaces/:space_id/readiness

POST /api/v1/spaces/:space_id/readiness

POST reads the site's live URL like an AI crawler and returns each check; GET returns the last one. Any key that reads drafts (read_drafts). Once a minute at most. Each check has key, title, status (pass, warn or fail), detail, fix_by (content or site), ask_agent (the instruction to fix it, or null) and doc_url. See AI visibility.

{
  "url": "https://harborbakery.example",
  "checked_at": "2026-10-01T12:00:00Z",
  "passed": 6,
  "total": 9,
  "checks": [
    { "key": "summary-for-ai", "title": "There's a summary for AI (llms.txt)", "status": "warn",
      "detail": "No llms.txt. …", "fix_by": "site", "ask_agent": "Add https://harborbakery.example/llms.txt: …",
      "doc_url": "https://pluma.so/docs/guides/ai-visibility#summary-for-ai" }
  ]
}

If the site has no live URL, or the last check was less than a minute ago: check_unavailable.

Possible errors

key_cannot, check_unavailable, validation_failed, version_conflict, invalid_body, plus the read ones.