Documentation
Automation API
Use Blobify's HTTP API from migration scripts, CLIs, CI jobs, and services. Create a bearer API key in Settings > Developer > API Keys and limit it to the smallest role and set of spaces the job needs.
The role hierarchy is viewer, editor, developer, and admin. Content writes require editor or higher. Schema writes and rebuilds require developer or admin.
Roles or grants
A key can be created with a role, or with a scope that lists capability grants plus the spaces, models, and locales it may touch. A scoped key is the safer choice for an integration, because each capability is granted independently and none of them implies another.
| Grant | What a key can do |
|---|---|
schema:read | Read models, blocks, taxonomy schemes, field types, the export bundle, automation context, and generated types |
content:read | List and read content, versions, archived entries, and schedules |
content:write | Create and update drafts, patch fields, bulk import drafts, validate, and set locale status markers |
content:publish | Publish, bulk publish, unpublish, schedule either action, and drive the whole release surface |
content:archive | Archive an entry and restore it. Archiving is recoverable |
content:delete | Permanently delete an already archived entry. This is not recoverable, so it must be granted explicitly |
assets:read | List and read asset metadata |
assets:write | Upload, confirm, update, and delete assets |
content:write never implies publish, archive, or delete. The single delete route accepts either content:archive or content:delete and then re-checks the exact grant once it knows whether it is archiving a live entry or permanently removing an archived one, so a key holding only content:archive is refused the permanent step.
Some routes carry no grant at all and are therefore refused for scoped keys whatever their grants say: every schema write (including taxonomy writes and schema import), all rebuild and repair routes, routing, webhooks, organization, space, member and API key administration, and the danger routes. Use an unscoped developer or admin key for those. The same rules apply over MCP. See MCP server.
API_URL="https://api.blobify.io"
ORG_ID="org_abc"
SPACE_ID="main"
API_KEY="YOUR_API_KEY"Every example below uses this header:
Authorization: Bearer YOUR_API_KEYRead automation context
Start an import by reading the organization context. It returns the visible spaces, locales, model summaries, block summaries, public bucket URL, and per-space contentPrefix settings without exposing bucket credentials.
curl -H "Authorization: Bearer $API_KEY" \
"$API_URL/v1/orgs/$ORG_ID/automation/context"{
"org": {
"id": "org_abc",
"name": "Example",
"defaultLocale": "en",
"locales": [{ "code": "en", "label": "English" }],
"rootPrefix": "blobify",
"publicBucketUrl": "https://cdn.example.com",
"spaceSettings": {
"main": { "contentPrefix": "site_a1" }
}
},
"spaces": ["main"],
"models": [
{
"model": "article",
"name": "Article",
"version": 3,
"singleton": false,
"displayField": "title",
"summaryFields": ["title", "slug"],
"lookupFields": ["slug"],
"listIndexIds": ["latest"]
}
],
"blocks": []
}lookupFields in this response is a compact list derived from the model's current fieldIndexes configuration.
Read and validate schemas
Read full schema definitions when your script needs field shapes:
curl -H "Authorization: Bearer $API_KEY" \
"$API_URL/v1/orgs/$ORG_ID/schemas/models"
curl -H "Authorization: Bearer $API_KEY" \
"$API_URL/v1/orgs/$ORG_ID/schemas/blocks"Validate a model and block bundle without writing it:
curl -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
"$API_URL/v1/orgs/$ORG_ID/schemas/validate-import" \
-d '{
"models": [
{
"model": "article",
"name": "Article",
"displayField": "title",
"summaryFields": ["title", "slug"],
"fields": {
"title": { "type": "text", "required": true, "translatable": true },
"slug": { "type": "slug", "required": true, "sourceField": "title", "unique": true },
"body": { "type": "richtext", "translatable": true }
}
}
],
"blocks": []
}'The response includes conflicts, normalized schemas, rebuild implications, and validation errors. Apply the same bundle with POST /v1/orgs/{orgId}/schemas/import. Add "override": true only when replacing conflicting schemas is intentional.
A bundle may carry models, blocks, taxonomies, or any mix, and must contain at least one of the three. Bundle taxonomies are applied before models, so a model field can name a scheme that arrives in the same bundle. Imported schemes stay private until you publish them.
Replacing an existing taxonomy scheme through an override bundle is rejected when the new concept map omits stored concept IDs, because those IDs would become permanent tombstones. Add "allowConceptRemoval": true to acknowledge that and let the apply through. Both validate-import and import run the check, so the dry run surfaces it first.
Taxonomy schemes
GET /v1/orgs/{orgId}/schemas/taxonomies
GET /v1/orgs/{orgId}/schemas/taxonomies/{schemeId}
PUT /v1/orgs/{orgId}/schemas/taxonomies/{schemeId}
DELETE /v1/orgs/{orgId}/schemas/taxonomies/{schemeId}
POST /v1/orgs/{orgId}/schemas/taxonomies/{schemeId}/publishThe two reads answer to schema:read. The three writes carry no grant, so they need an unscoped developer or admin key.
A PUT replaces the scheme's whole concept map, and every stored concept ID the new map omits becomes a permanent tombstone. Because that is irreversible, replacing a scheme that already exists requires the version token from the read, sent either as an If-Match header or as expectedEtag in the body:
curl -X PUT \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-H 'If-Match: "3"' \
"$API_URL/v1/orgs/$ORG_ID/schemas/taxonomies/topics" \
-d '{
"label": { "en": "Topics" },
"concepts": {
"travel": { "prefLabel": { "en": "Travel" }, "broader": [], "order": 0 },
"iceland": { "prefLabel": { "en": "Iceland" }, "broader": ["travel"], "order": 0 }
}
}'Omitting both returns 428 Precondition Required with an if_match_required body carrying the current version and document, so a script can read, merge, and retry. A token that no longer matches returns 412 Precondition Failed. Creating a scheme that does not exist yet needs no precondition.
Dropping a concept ID is allowed, not blocked: the write succeeds and returns one warning per removed ID. Existing content keeps the ID and warns until an editor replaces it, and the ID may never be reused.
Deleting a scheme returns 409 Conflict while any model field still names it, listing the offending model.field pairs. ?force=true deletes anyway and returns the same list as warnings. POST .../publish writes the sanitized public projection and refreshes the schema manifest. See Taxonomies.
List or find content
The authenticated list endpoint reads summary shards. It is intended for the dashboard, migration tools, and automation, not as a website runtime API.
curl -H "Authorization: Bearer $API_KEY" \
"$API_URL/v1/orgs/$ORG_ID/content/$SPACE_ID/article?state=draft&locale=en&page=1&perPage=100"Filter by an exact field and value by providing both query parameters:
curl -H "Authorization: Bearer $API_KEY" \
"$API_URL/v1/orgs/$ORG_ID/content/$SPACE_ID/article?state=draft&locale=en&field=slug&value=hello-world"The default state is draft, the default page is 1, and the default perPage is 100. perPage is capped at 500. The response includes items, page, perPage, total, and a numeric nextPage or null.
Validate content before saving
curl -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
"$API_URL/v1/orgs/$ORG_ID/content/$SPACE_ID/article/validate" \
-d '{
"fields": {
"title": { "en": "Hello world" },
"slug": "hello-world",
"body": {
"en": {
"type": "root",
"children": [
{
"type": "paragraph",
"children": [{ "type": "text", "value": "Created locally" }]
}
]
}
}
}
}'The response is { "valid": true, "errors": [] } when validation succeeds.
Create and update drafts
Create a draft and let Blobify generate its content ID:
curl -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
"$API_URL/v1/orgs/$ORG_ID/content/$SPACE_ID/article" \
-d '{
"fields": {
"title": { "en": "Hello world" },
"slug": "hello-world"
}
}'Update a draft by posting fields to its ID. Fields omitted from the request remain unchanged. Each field you do send replaces that field's stored value, so send the complete locale map for a translatable field:
CONTENT_ID="cnt_123"
curl -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
"$API_URL/v1/orgs/$ORG_ID/content/$SPACE_ID/article/$CONTENT_ID" \
-d '{
"fields": {
"title": { "en": "Hello world, updated" },
"slug": "hello-world"
}
}'Upsert by a stable field
Use upsert for restartable imports. Blobify resolves the key through a field index or summaries, then falls back to scanning source documents if derived output is still catching up.
curl -X PUT \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
"$API_URL/v1/orgs/$ORG_ID/content/$SPACE_ID/article/upsert" \
-d '{
"key": {
"field": "slug",
"value": "hello-world",
"locale": "en",
"state": "draft"
},
"fields": {
"title": { "en": "Hello world, updated" },
"slug": "hello-world"
},
"publish": { "locales": ["en"] }
}'The response has action: "created" or action: "updated" plus the saved content document. If locale is omitted, the organization default locale is used.
Patch draft fields
PATCH accepts an RFC 6902 operation array against the draft's fields object. Paths therefore start at a field name, not at /fields.
curl -X PATCH \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
"$API_URL/v1/orgs/$ORG_ID/content/$SPACE_ID/article/$CONTENT_ID" \
-d '[
{ "op": "replace", "path": "/title/en", "value": "A corrected title" },
{ "op": "add", "path": "/excerpt", "value": { "en": "Short summary" } }
]'The patched fields pass through the normal validation and save pipeline. An optional If-Match: <etag> header enables optimistic concurrency. A stale ETag returns 409 Conflict instead of overwriting another writer.
Bulk-save drafts
The bulk endpoint accepts 1 to 100 items. Items with an id update that draft. Items without an id create a draft. Each item is processed independently.
curl -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
"$API_URL/v1/orgs/$ORG_ID/content/$SPACE_ID/article/bulk" \
-d '{
"items": [
{ "fields": { "slug": "page-a", "title": { "en": "A" } } },
{ "id": "cnt_existing", "fields": { "slug": "page-b", "title": { "en": "B" } } }
]
}'A well-formed batch returns HTTP 200 even when an individual item fails. Inspect every result's status, which is created, updated, or failed. A malformed or empty batch, or a batch above 100 items, returns 400.
Publish and unpublish
curl -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
"$API_URL/v1/orgs/$ORG_ID/content/$SPACE_ID/article/$CONTENT_ID/publish" \
-d '{ "locales": ["en"] }'Unpublish selected locales with the matching /unpublish endpoint. Omitting locales unpublishes all currently published locales.
Schedule a publish or unpublish
One route schedules both lifecycle actions. Times are stored in UTC at minute precision and must be in the future.
curl -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
"$API_URL/v1/orgs/$ORG_ID/content/$SPACE_ID/article/$CONTENT_ID/schedule-publish" \
-d '{ "at": "2026-09-10T08:00:00Z", "locales": ["en"], "action": "publish" }'GET /v1/orgs/{orgId}/content/{spaceId}/{model}/{contentId}/schedule-publish
DELETE /v1/orgs/{orgId}/content/{spaceId}/{model}/{contentId}/schedule-publish?action=unpublish
GET /v1/orgs/{orgId}/content/{spaceId}/scheduled-publishesaction defaults to publish. Each entry can hold one pending schedule per action, and re-posting the same action reschedules it. locales is optional, and for an unpublish it is resolved when the schedule fires. Scheduling returns 400 when the floored time is not in the future or when a scheduled unpublish would land at or before a pending scheduled publish for the same locales, 404 when the entry has no draft, and 409 while a record for that action is already firing. Cancelling returns 404 when there is no record and 409 once the record is no longer cancellable. Reads answer to content:read, and scheduling and cancelling to content:publish.
Releases
A release groups documents that must go live together. Adding a document copies its current draft into a working copy owned by the release, so the everyday draft keeps shipping in the meantime.
POST /v1/orgs/{orgId}/releases/{spaceId}
GET /v1/orgs/{orgId}/releases/{spaceId}
GET /v1/orgs/{orgId}/releases/{spaceId}/{releaseId}
DELETE /v1/orgs/{orgId}/releases/{spaceId}/{releaseId}
POST /v1/orgs/{orgId}/releases/{spaceId}/{releaseId}/items
DELETE /v1/orgs/{orgId}/releases/{spaceId}/{releaseId}/items/{model}/{contentId}
GET /v1/orgs/{orgId}/releases/{spaceId}/{releaseId}/items/{model}/{contentId}/copy
POST /v1/orgs/{orgId}/releases/{spaceId}/{releaseId}/items/{model}/{contentId}/copy
POST /v1/orgs/{orgId}/releases/{spaceId}/{releaseId}/publish
POST /v1/orgs/{orgId}/releases/{spaceId}/{releaseId}/schedule
DELETE /v1/orgs/{orgId}/releases/{spaceId}/{releaseId}/scheduleEvery release route needs content:publish. Adding documents returns per-item results, and a document already claimed by another open release fails that item only. Publishing returns per-item results too, so inspect each one. Release publish does not accept a locale list, so a key restricted to specific locales cannot publish a release. Use per-entry publish calls instead. See Publishing and releases.
Locale status markers
Mark which locales a sync is working on so editors can see what is in flight.
PUT /v1/orgs/{orgId}/content/{spaceId}/{model}/{contentId}/locale-status/{locale}
DELETE /v1/orgs/{orgId}/content/{spaceId}/{model}/{contentId}/locale-status/{locale}PUT takes { "status": "in_progress" }, { "status": "needs_review" }, or { "status": "not_required" }, and DELETE clears the locale. Both require an If-Match header carrying the draft's current version: a missing header returns 428 and a stale one returns 412 with the current document. Both return the updated localeStatus map and a new ETag, so several locales can be set in a row without re-reading.
The marker is editorial metadata. It changes no timestamps, creates no version, emits no webhook, and never blocks a save or a publish. It does move the draft's ETag, so a following field patch must use the ETag the marker response returned. Set in_progress when you export a locale and needs_review when you import the translation back as a draft. Publishing that locale clears the marker for you. Both calls need content:write plus the model and the locale in the key's scope. There is no bulk marker route: send one conditional request per entry. See Localization workflow.
Archive, restore, and permanently delete
The first delete of a live entry archives it:
curl -X DELETE \
-H "Authorization: Bearer $API_KEY" \
"$API_URL/v1/orgs/$ORG_ID/content/$SPACE_ID/article/$CONTENT_ID"Restore an archived entry with POST .../$CONTENT_ID/restore. Permanently delete by issuing DELETE again while the entry is already in the archive. Keep this archive-first sequence in automation so accidental deletion remains recoverable.
Rebuild summaries and indexes
Rebuild routes are asynchronous. They return 202 Accepted after placing a model-level job on the queue worker.
curl -X POST \
-H "Authorization: Bearer $API_KEY" \
"$API_URL/v1/orgs/$ORG_ID/content/$SPACE_ID/article/rebuild-all?state=published"Replace rebuild-all with rebuild-summaries, rebuild-field-indexes, or rebuild-list-indexes for a narrower job. Omit state to rebuild both draft and published output. The equivalent routes under /schemas/models/{model}/ enqueue work for every space.
Recommended migration loop
- Read automation context and full schemas.
- List or filter existing content by a stable key.
- Validate schema and content payloads locally before writing.
- Upsert restartable items by a stable indexed field.
- Inspect every bulk result.
- Publish only the locales that should go live.
- Rebuild derived output after schema or index configuration changes, then wait for the asynchronous job to converge.