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 getvalidation_failedwithdetails.fields.default_localenaming the entries, and nothing changes.prefix_default_locale:trueputs 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).nameis 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;nullfor none.- It answers
201with{ "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=truenothing 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 answers201with 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
symbolorslugfield becomes the title field, unless you senddisplay_field. - Optional on create too:
guidance,previewsandurl_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) andhelpon 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
201with 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'sid. - 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 getversion_conflictand nothing is overwritten.- Another language: add
?locale=enand 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 astitle.en. Creating takes the samelocale. 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
httpsand 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 sendtitle.- 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": inid, 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_pathset, each entry result also carriesurlandpreview_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…) anddetails.operationsays which one (starting at 0), with itsopand itsref. 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 itssite_url(the property),synced_atandlast_error(nullwhen the last sync worked).totals: per engine,clicks,impressions,ctr(0 to 1) andposition(average, 1 is the top).queriesandpages: the top 50 of each, most clicks first, withengine, thequeryorpage, 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.