Features Apograph CMS on GitHub

Writing entries

Content plugin @apograph/content-server@apograph/content-admin

Create, update, publish and delete — including relation deltas and bulk saves that report per item.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

Writes need a full-scope token. Everything on this page is a 403 with a read token.

Example language
curl -X POST 'https://cms.example.com/api/v1/content/article' \
-H 'Authorization: Bearer apograph_…' \
-H 'Content-Type: application/json' \
-d '{"values":{"title":"Hello","slug":"hello"}}'
const response = await fetch(
  'https://cms.example.com/api/v1/content/article',
  {
      method: 'POST',
      headers: {
          Authorization: 'Bearer ' + process.env.APOGRAPH_TOKEN,
          'Content-Type': 'application/json'
      },
      body: JSON.stringify({ values: { title: 'Hello', slug: 'hello' } })
  }
);
const draft = await response.json(); // status: 'draft'
mutation Create {
  createArticle(input: { values: { title: "Hello", slug: "hello" } }) {
      id
      status
  }
}

Creating

POST /api/v1/content/:typeName

{
    "values": { "title": "Hello", "slug": "hello" },
    "locale": "de",
    "localeGroupId": "…",
    "relations": { "tags": { "link": ["9c4b…"] } }
}

A create always produces a draft on a publishable type. There is no status parameter — publishing is its own call.

FieldEffect
valuesThe field values bag.
relationsPer-field link changes. See below.
localeWhich language this row is written in. Create only.
localeGroupIdJoin an existing translation group as a sibling. Create only.

locale on an update is ignored — an update never re-homes a row’s language. Passing a localeGroupId on a create is how a new translation of an existing record is written; the group is verified to exist in the workspace first, so a typo is a 404 rather than a stray one-row group. A duplicate (group, locale) is a 409.

Updating

PATCH /api/v1/content/:typeName/:id

Merge semantics: fields you do not mention are left alone. To clear a field, send it explicitly as null.

Remember that on a publishable type, saving an edit to a live entry moves it back to draft while keeping published_at — the modified state. Publish again to make the edit live.

An unknown field name is dropped in silence

A key in values that is not a declared field is ignored — not stored, not rejected. A misspelt field name writes nothing and returns 200. Read the response back when you are writing against the API by hand.

Relation deltas

Relations are changed with a delta, not a replacement, so a record with thousands of links never has to be sent whole.

{
    "relations": {
        "tags": {
            "link": ["9c4b…"],
            "unlink": ["1f9a…"],
            "order": ["9c4b…", "7d2e…"]
        }
    }
}
KeyEffect
linkAssign these ids.
unlinkUnassign these ids.
orderReorder. Owning side only.
byid (default) or localeGroup.

Ids you do not mention are left alone.

This is for many and inverse relations only. An owning single relation is set through values — naming one here is a 400.

Every id must name an entry in the same workspace, or the save is a 422.

Linking across locales

When both the type and its target are localized, a link may not cross locales — the English article links the English tag, and a target in another locale is a 422.

Setting by: "localeGroup" lets you pass translation group ids instead of entry ids. Each is resolved to that group’s row in this entry’s own locale, so a client that thinks in stories never has to keep a per-locale id map. A group with no row in this locale is a 422 telling you to translate it first.

Publishing and deleting

RouteEffect
POST /api/v1/content/:type/:id/publishRuns the publish gate and goes live.
POST /api/v1/content/:type/:id/unpublishBack to draft.
DELETE /api/v1/content/:type/:idDelete — soft on a paranoid type.

Each has a group-addressed twin — …/group/:groupId/publish and so on — which acts on that group’s row for the requested locale.

Publishing re-validates against the type’s rules. A failure is a 422 naming the fields, and the entry stays as it was. See the publish gate.

Bulk

POST /api/v1/content/:typeName/bulk saves up to 50 entries of one type in one request.

{
    "items": [
        { "values": { "title": "New one" } },
        { "id": "9c4b…", "values": { "title": "Edited" } }
    ]
}

An item with an id updates; one without creates. An item naming only a localeGroupId is genuinely ambiguous — it could be “create this record’s row in another language” or “update the row it already has there” — so that one shape requires an explicit op. Every other item infers it.

Each item means exactly what the equivalent POST or PATCH means, merge semantics and all.

A bulk save is always 200, even when items fail

Items are written one transaction at a time, so a batch cannot roll back as a unit and one bad row does not reject the rest. The response carries a verdict per item in request order, with failures reporting the status and message the single-entry call would have returned — a 422 keeps its per-field issues. Retry the ones you can fix.

The other bulk routes take a list of ids, capped at 100:

RouteEffect
POST /api/v1/content/:type/bulk/publishPublish many
POST /api/v1/content/:type/bulk/unpublishUnpublish many
POST /api/v1/content/:type/bulk/deleteDelete many

A bulk save requires both content:create and content:update, since one request may do either — which a full token has.

Reading your writes back

A read token cannot see the draft it just created. A full token can, with ?status=draft or ?status=any. See API tokens.