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.

GrantWhat a key can do
schema:readRead models, blocks, taxonomy schemes, field types, the export bundle, automation context, and generated types
content:readList and read content, versions, archived entries, and schedules
content:writeCreate and update drafts, patch fields, bulk import drafts, validate, and set locale status markers
content:publishPublish, bulk publish, unpublish, schedule either action, and drive the whole release surface
content:archiveArchive an entry and restore it. Archiving is recoverable
content:deletePermanently delete an already archived entry. This is not recoverable, so it must be granted explicitly
assets:readList and read asset metadata
assets:writeUpload, 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.

bash
code
API_URL="https://api.blobify.io"
ORG_ID="org_abc"
SPACE_ID="main"
API_KEY="YOUR_API_KEY"

Every example below uses this header:

http
code
Authorization: Bearer YOUR_API_KEY

Read 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.

bash
code
curl -H "Authorization: Bearer $API_KEY" \
  "$API_URL/v1/orgs/$ORG_ID/automation/context"
json
code
{
  "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:

bash
code
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:

bash
code
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

text
code
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}/publish

The 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:

bash
code
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.

bash
code
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:

bash
code
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

bash
code
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:

bash
code
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:

bash
code
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.

bash
code
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.

bash
code
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.

bash
code
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

bash
code
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.

bash
code
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" }'
text
code
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-publishes

action 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.

text
code
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}/schedule

Every 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.

text
code
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:

bash
code
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.

bash
code
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

  1. Read automation context and full schemas.
  2. List or filter existing content by a stable key.
  3. Validate schema and content payloads locally before writing.
  4. Upsert restartable items by a stable indexed field.
  5. Inspect every bulk result.
  6. Publish only the locales that should go live.
  7. Rebuild derived output after schema or index configuration changes, then wait for the asynchronous job to converge.