Skip to content

Content as code

Everything you can do to a content model in the editor, you can also do over HTTP: create Contracts and Templates, write Components, build the Experience tree, upload media and publish. This page covers the Content Management API, which is how a script, a CI job or an AI agent does that work.

It needs an ebitex organization on the Pro plan or above, which a trial counts towards. Set up your organization covers signing up.

All routes on this page are under one prefix:

https://api.ebitex.io/content/management/v1

Every request carries a management key as a bearer token:

Authorization: Bearer frm_live_…

Create one in Content under Settings → API Keys, in the Management keys section. Management keys need the Pro plan or above. That is checked when the key is created and again on every request, so a key stops working if the organization moves to a lower plan. An organization on a Pro trial can create them straight away.

A key is defined by four choices.

The environment it is bound to. A key works in exactly one authoring environment: whichever one is selected in the environment switcher at the top of Content when you create it. There is no header for picking an environment per request, so which environment you write to depends only on which key you use. To run the same pipeline against staging and production, mint two keys. A staging key cannot write to production, even by mistake.

The role it acts as. A key has the permissions of an organization role, chosen under Acts as role. Folder and page access rules and approval workflows apply to it exactly as they would to a person with that role. A key whose role cannot edit a folder cannot edit that folder, and content that a workflow blocks cannot be published by a script any more than by a person. You cannot give a key a role that has permissions you do not hold yourself.

Its permissions, or scopes. These decide which kinds of request the key may make:

Permission Scope Lets the key
Read content.management.read List and read content, export bundles, plan imports, look up blobs, and check job status
Write content.management.write Import bundles and upload blobs
Publish content.management.publish Plan and run publishes
Authoring content.management.authoring Create, update and delete individual items: Contracts, Templates, Components, folders, pages, Adapters, streams, audiences and categories

Scopes and the role are separate checks, and a request has to pass both. The scope decides which routes the key may call, and the role decides which content it may touch. A key with Authoring whose role grants no Content permissions authenticates successfully, and every write it makes is then refused.

Authoring is the only permission that can delete anything. An import only adds or updates items and never removes one.

A key with only Read cannot change anything, which makes it safe for a pull-request check that reports what an import would change. A publishing key usually needs Read as well, because checking on a publish job (GET /operations/{id}) is a read.

IP ranges. Turn on Restrict to IP ranges and list one range per line, as CIDR blocks or single addresses. Requests from anywhere else are refused with 403 ip_denied. Do this whenever your build server has a fixed address.

Expiry. Choose how long the key should last: 30 days, 90 days, a year, or never. It defaults to 90 days. An expired key stops working the same way a revoked one does, answering 401 invalid_api_key — so if a pipeline that has been running for months suddenly cannot authenticate, check the key’s expiry before anything else. There is no way to extend a key: mint a new one, which also gives you a fresh secret, and revoke the old.

The key is shown once, when you create it. Store it in your CI system’s secret store. Revoke one from the same section when you no longer need it, and it stops working on the next request. Deleting the role a key acts as also stops the key working.

A key is an opaque string. Before you act on a key you did not mint yourself, ask the API what it is. GET /whoami works with any valid key, whatever its scopes:

Terminal window
curl -s -H "Authorization: Bearer $EBITEX_CONTENT_MANAGEMENT_KEY" \
https://api.ebitex.io/content/management/v1/whoami
{
"organizationId": "", "organizationName": "Acme", "organizationSlug": "acme",
"environmentId": "", "environmentName": "Staging",
"roleId": "", "roleName": "Editor",
"keyId": "", "keyName": "Marketing site CI",
"scopes": ["content.management.publish", "content.management.read"]
}

If environmentName is not the environment you expected, stop: nothing else about a request will tell you where it is about to write. GET /topology (Read) goes one step further. It lists the delivery environments a publish from this environment lands in, the environments this one promotes to and is promoted from, and the environments it inherits from and is inherited by. Content does not record which environment is production, so judge that from the names.

The API offers two ways to change content, and both are fully supported. Choose per task.

Per-entity requests create, update or delete one item at a time. They are the same operations the editor itself uses, so every rule the editor enforces applies. Examples: create a Contract, create a Component, update a Component, create a page node and set its payload. These need the Authoring permission to write, and Read to read.

Transfer bundles move a whole set of items in one request. You export a bundle, edit it or generate it, plan the import to see what would change, then import it. An import is one transaction. The bundle is the same document the editor’s Transfer tool exports.

Per-entity requests Bundles
The model is being discovered already known
Runs are exploratory, one change at a time repeated, converging on the same result
You get fast feedback, one refused document at a time, partial progress atomicity, a dry run, detection of edits made in the editor
Typical caller an interactive tool, an AI agent a build pipeline that keeps the model in source control
Deletes yes, with Authoring never

Both validate documents against their Contracts the same way the editor does. They report failures differently. A per-entity write refuses one document and names its failing fields. An import that contains invalid documents writes nothing, and its error lists every invalid item with its failing fields, so you can fix them all and resubmit once.

To move content between two environments, use two keys: export with the source environment’s key and import with the target environment’s key. A single key can only ever act on its own environment.

Before creating anything, ask the server what exists. The shape of your requests comes from these responses, so read them rather than guessing:

  • GET /field-types lists every field type with a JSON Schema for its settings.
  • GET /contracts and GET /templates show what this environment already has. Check them first, because a duplicate is harder to undo than to avoid.

Build in dependency order:

  1. Contracts, parents before children.
  2. Templates.
  3. Components.
  4. A site if the environment has none, then nodes under it.
  5. Node payloads.

A refusal names the failing field and what is wrong with it. Correct that one field and send the request again.

A few routes do not follow the usual create/update pattern:

  • An Adapter is updated with PATCH, not PUT.
  • A page node and its payload are separate: PUT /experience/nodes/{id} changes a node’s name, slug and location, and PUT /experience/nodes/{id}/payload changes what the page renders.
  • A site is created with POST /experience/sites, not as a node.

A delete is refused while anything still uses the item, and the refusal names what depends on it. A published Component or node cannot be deleted through the API. Take it down in the editor first, because the API has no unpublish operation.

This is the loop for a content model kept in source control.

First, adopt the environment’s current state:

Terminal window
KEY="$EBITEX_CONTENT_MANAGEMENT_KEY"
API=https://api.ebitex.io/content/management/v1
curl -s -X POST -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"wholeEnvironment": true}' "$API/export" > bundle.json

To export only part of an environment, send roots instead: a list of { "kind", "id" }. Each root brings its dependencies with it, but a page node does not bring its child pages. Name every node, stream and standalone Component you want as a root of its own.

Then, on every run, plan and import. Planning needs only Read and writes nothing:

Terminal window
curl -s -X POST -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d "{\"identityMode\":\"externalId\",\"bundle\":$(cat bundle.json),\"baseTokens\":$(cat state.json)}" \
"$API/import-plan"

identityMode says how the bundle’s items are matched to what the environment already holds:

Mode Matches on Use it when
externalId (the default) Each item’s own natural key: its external id, or for a category, folder or node, its key or name within its parent The model lives in source control, and must converge onto whatever the target already has under the same names, including items someone created by hand in the editor
preserve The bundle’s ids Moving content between environments you control
fresh Nothing; every item is new Copying content into a different organization

Each plan item looks like this:

{
"kind": "contract",
"id": "",
"externalId": "article",
"naturalKey": "contract:article",
"name": "Article",
"implicit": false,
"state": "update",
"stateToken": ""
}

The plan also carries blockers, which would make the import refuse.

state is new, identical, update or conflict. When the bundle and the environment differ, the difference has one of two causes: your bundle changed, or somebody edited the environment. The server cannot tell these apart by comparing the two, so you supply the evidence.

Every plan item carries a stateToken, a hash of that item’s current state in the environment. After a successful import, record each item’s stateToken under its naturalKey, and send the map back as baseTokens next time:

{
"contract:article": "",
"category:topics/coffee": "",
"node:Main site/about": ""
}
Bundle compared with the environment baseTokens entry state
equal any identical
differs matches the item’s current token update: only your bundle moved
differs does not match conflict: the environment changed too
differs absent conflict: no evidence, so the server assumes the worse case

That last row is why the token file matters. Without base tokens every difference is reported as a conflict on every run, and you end up confirming overwrites by habit, until the check no longer protects anything. Commit the token file alongside the bundle, so every machine that runs the import works from the same evidence.

Use the naturalKey strings exactly as the server reports them; do not construct them yourself. Categories, folders and nodes have no external id, so their natural keys are paths, and the server controls how those paths are spelled and escaped. GET /items also reports every item’s naturalKey, without exporting anything.

Values are compared as JSON, so object key order does not matter, and an omitted optional member equals an explicit null. The constraint lists allowedTemplateIds, allowedContractIds, allowedModes and allowedCategoryGroupIds, and a stream’s sourceContractIds, are compared as sets. Order matters in every other array, including a Contract’s field list and an allowedValues list.

A Presentation value records a templateVersion, and an inline value records a contractVersion. A bundle written by hand cannot know either number, so write 0. It means “the latest version this import produces”, and the plan resolves it the same way the import does.

Send the same bundle, identityMode and baseTokens, plus items, which selects what is written:

{
"identityMode": "externalId",
"bundle": { "formatVersion": "" },
"baseTokens": { "contract:article": "" },
"items": [
{ "kind": "contract", "id": "" },
{ "kind": "component", "id": "", "confirmOverwrite": true }
]
}
  • Name every item the plan did not call identical. An item the plan calls new must be named, because nothing else would create it; leaving one out is 400 required_item_omitted.
  • An item planned as conflict is written only with confirmOverwrite: true, which means “overwrite what is there”. Read the conflict list before you confirm anything: it is how the API tells you somebody has been editing in the environment.
  • The server plans again itself. It never trusts the plan you saw earlier.

A plan describes the environment at the moment it ran. Plan immediately before each import, and plan again after any other write to the same environment, including your own per-entity writes. An item you changed per-entity reports conflict against tokens recorded before that change.

A clean plan does not guarantee a valid import. The plan does not validate documents; the import does.

Importing or writing content changes the draft. Publishing makes it live, and takes two jobs.

1. Plan. POST /publish-plan takes the Components and page nodes you want to publish:

Terminal window
curl -s -X POST -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"roots":[{"kind":"node","id":"…"}]}' "$API/publish-plan"
{ "id": "", "status": "queued" }

If this environment publishes to more than one delivery environment, add deliveryEnvironmentId. GET /topology lists them.

2. Poll. GET /operations/{id} with the returned id, until status is succeeded or failed:

Terminal window
curl -s -H "Authorization: Bearer $KEY" "$API/operations/<id>"

A succeeded plan job’s result holds the plan. The plan covers everything your roots depend on, not only the roots, and reports each item’s publish status, validation errors and workflow state, including whether a workflow blocks it. Read it before publishing: it is the only place the API tells you what will change beyond what you named.

3. Publish. POST /publish takes the items to publish, built from the plan:

{
"items": [
{ "kind": "node", "id": "", "versionNumber": 7, "include": true },
{ "kind": "component", "id": "", "versionNumber": 3, "include": true }
]
}
  • Set versionNumber to the plan item’s activeRunPinnedVersionNumber when it has one, and to its headVersionNumber otherwise.
  • Set include: true on every item. An item without it is skipped, and a job that skips every item still reports succeeded after publishing nothing.
  • Send only these four properties. Passing plan items through unchanged is refused with 400 unknown_property, because plan items carry extra members.
  • Send the same deliveryEnvironmentId the plan used.

This returns another job id to poll. The job publishes items in order and stops at the first one it cannot publish. Items before it stay published, and the failed job’s error names the item and the reason.

A bundle carries blob ids, never file bytes, so upload media separately. Ask first which files the organization already holds. GET /blobs takes up to 100 SHA-256 digests and needs only Read:

Terminal window
HASH=$(sha256sum hero.png | cut -d' ' -f1)
curl -s -H "Authorization: Bearer $KEY" "$API/blobs?hash=$HASH"
{ "blobs": [{ "hash": "", "blobId": "", "contentType": "image/png", "sizeBytes": 88213 }] }

A digest that is missing from blobs has no stored file. That is the answer, not an error. Upload the missing files with POST /blobs, sending the raw bytes as the body with the file’s media type (Write):

Terminal window
curl -s -X POST -H "Authorization: Bearer $KEY" -H 'Content-Type: image/png' \
--data-binary @hero.png "$API/blobs"

The response, { "blobId", "contentType", "sizeBytes" }, is what a media field stores. Storage is content-addressed, so uploading bytes the organization already holds stores nothing new and returns the existing id. Looking files up first is what lets a Read-only key plan a model that carries media.

Requests are limited per organization per minute, and writes per day. Imports, publishes and blob uploads count against one daily limit, and per-entity writes against a second, larger one: one import of a whole model is one write, while building the same model item by item is one write per item. Reads are limited only per minute. A request over a limit is refused with 429 quota_exceeded, and the response names the limit.

Every change a key makes is recorded in the organization’s activity log and in each item’s version history under the key’s name, never a person’s.

@ebitex/content-mcp is an MCP server that gives an AI agent the per-entity API as a small set of tools. It runs on your own machine over stdio, so your key is never sent anywhere except the Content Management API.

Add it to your MCP client’s configuration. For Claude Desktop that is claude_desktop_config.json. For Claude Code, use a project .mcp.json or claude mcp add. Other clients take the same command, args and env values:

{
"mcpServers": {
"ebitex-content": {
"command": "npx",
"args": ["-y", "@ebitex/content-mcp"],
"env": { "EBITEX_CONTENT_MANAGEMENT_KEY": "frm_live_…" }
}
}
}
Variable
EBITEX_CONTENT_MANAGEMENT_KEY Required. The management key the agent acts as
EBITEX_CONTENT_API_BASE The API origin. Defaults to https://api.ebitex.io
EBITEX_CONTENT_MCP_READ_ONLY Set to 1 to hide every write tool, whatever the key’s scopes allow
EBITEX_CONTENT_TARGET_MANAGEMENT_KEY Optional. A second key, bound to the environment content_transfer imports into

Keep the key out of any configuration file you commit.

At startup the server calls GET /whoami and prints the organization, environment, role and scopes it resolved to stderr. Every write result also names where it landed. If the environment is not the one you expected, stop the agent.

The tools the agent sees depend on the key’s scopes. A tool the key cannot use is not offered at all, rather than offered and then refused:

Tool Offered with Does
content_describe Read Lists every field type with its settings schema, plus the environment’s Contracts and Templates
content_find Read Finds items by kind, or Components by search text or external id
content_get Read Reads one item in full
content_topology Read Reports where the environment sits: where a publish lands, and its promotion and inheritance neighbours
content_operation_status Read Polls a publish job
content_write Authoring Creates or updates one item
content_delete Authoring Deletes one item; the agent must pass confirm: true
content_publish Publish Plans a publish, then publishes the plan’s items
content_transfer Write Exports a bundle, and with a target key configured, imports it into the target environment. identityMode takes the same three values the HTTP API does: externalId (the default), preserve, fresh

content_write passes the agent’s payload to the API as-is; the tool itself does not describe what belongs in it. The agent learns each Contract’s shape from content_describe, and the API validates what arrives. A refusal reaches the agent whole, with every failing field path, so it can correct the one field named and try again.

The server also offers the Content documentation as MCP resources. Point the agent at ebitex-content://help/agent-authoring before it builds a model: it explains what Contracts, Templates, Presentations, Adapters and the Experience tree are for, and the order to create them in.

Mint the agent’s key against a non-production authoring environment, give it a role with no more access than the job needs, and leave Authoring off unless the agent has to write. The API has no unpublish, so an agent can publish content that it cannot then take down.

The ebitex-content-mcp executable is the usual way to run the server. If your host already owns an MCP transport, build the server yourself and connect it:

embed.ts
// Embedding the server in a host that owns its own transport. This file is typechecked against the
// published package, so the imports it shows are the ones the package's `exports` map actually
// serves.
//
// The ordinary way to use this package is the `ebitex-content-mcp` executable configured in an MCP
// client; this entry exists for a host that already owns a transport.
import { buildServer, ContentApiError, readConfig, WRITABLE_KINDS } from '@ebitex/content-mcp'
export async function start(): Promise<void> {
// Throws ConfigError naming the missing variable rather than failing later on a 401.
const config = readConfig()
const { server, session, banner } = await buildServer({ config })
// Never stdout: on a stdio transport that stream is the protocol itself.
console.error(banner)
console.error(`writing into ${session.who.environmentName ?? session.who.environmentId}`)
// Connect `server` to whatever transport the host owns.
void server
}
export function kinds(): readonly string[] {
return WRITABLE_KINDS
}
export function describe(error: unknown): string {
// A refusal carries the API's own body whole — the field paths a corrected retry needs are in
// `message`, unmodified.
return error instanceof ContentApiError ? `${error.status} ${error.code ?? ''}: ${error.message}` : String(error)
}