Docs
Dashboard

API reference

Content

Reading and writing your own content types with the /v1/content and /v1/schema endpoints.

The endpoints for the types you define. For the blog, knowledge base and authors, see Built-in modules.

Fetch a type

GET /v1/content/{type}

{type} is the type slug, exactly as content_types[].slug reports it in introspection.

A singleton answers with its values object. A collection answers with a paginated array.

Parameter
limitCollections only. 1–100, default 20. Refused outside that, not clamped.
cursorCollections in their default order. Opaque.
offsetCollections when sort is used.
where[…]Filter by the type’s own fields. See filtering.
sortA field key, or created_at / published_at / updated_at. - prefixes descending.
idsComma-separated ent_… ids, at most 50. Returns exactly those, in the sort order.
releaseA rel_… id. Omit for the default release.
media_urlspermanent to refuse expiring image URLs.
curl "https://api.zevcontent.net/v1/content/team-member\
?where[role]=engineer&sort=-joined&limit=10" \
  -H "Authorization: Bearer $ZEVCONTENT_KEY"
{
  "data": [
    {
      "id": "ent_2c8RkXqGn4wVtYbMhJpDs",
      "values": { "name": "Ada Lovelace", "role": "engineer", "joined": "2024-03-01" },
      "published_at": "2026-08-14T09:00:00.000Z",
      "updated_at": "2026-08-20T11:12:00.000Z"
    }
  ],
  "meta": {
    "type": "team-member",
    "kind": "collection",
    "release": { "id": "rel_…", "name": "Launch", "default": true },
    "limit": 10,
    "has_more": false,
    "next_cursor": null,
    "next_offset": null
  }
}

Fetch one entry

GET /v1/content/{type}/{entryId}

By its ent_… id. Use this for a detail page when you already hold the id; filter the listing instead when you hold a slug-like field value.

Takes release and media_urls.

reference and reference_list fields come back as the linked entries, resolved one level deep. See Linking content for the shape and the rules.

What links to an entry

GET /v1/content/{type}/{entryId}/referenced-by

Every published entry with a reference or reference_list field pointing at this one, and the field it points through. Any key kind.

{
  "data": [
    { "id": "ent_Dxb7…", "type": "product", "field": "category",
      "url": "/v1/content/product/ent_Dxb7…" }
  ],
  "meta": { "type": "category", "entry": "ent_QYa7…", "count": 1, "release": { … } }
}

The entry itself need not be published: an unpublished target is exactly the case where you want to know which live pages now render it as null. This is also how a cache learns what to invalidate when a linked entry changes, since webhooks do not cascade.

?where[goes_to]=cat_Ch8Hg…          entries whose menu points at one category
?where[featured]=art_6cRpC…          lists that CONTAIN this article
?where[goes_to][is_null]=true        no link set

Same operators and the same indexed containment as a reference. See Linking to your blog and knowledge base.

?where[category]=ent_QYa7…            products in one category
?where[category][in]=ent_a,ent_b       in any of these
?where[related]=ent_Dxb7…             lists that CONTAIN this entry
?where[category][is_null]=true         no category set

Indexed, like every other filter. is_null asks whether the field is set, not whether its target is published.

Create an entry

POST /v1/content/{type}/entries

Secret key only. Collections only.

{
  "values": { "name": "Ada Lovelace", "role": "engineer" },
  "publish": true
}

values is keyed by field key and validated against the type: a missing required field is missing_required_field, a wrong type is invalid_field_value, an unknown key is unknown_field naming the ones that exist.

Omitting publish creates a draft. That is the right default for a migration script or an agent, because content generated by a machine should usually be reviewed before it is public.

Update an entry

PATCH /v1/content/{type}/{entryId}

Secret key only. Change field values, the publish state, or both.

{
  "values": { "role": "lead" },
  "publish": true
}

values is merged, which is the difference between this and POST: a key you omit is left alone, and an explicit null clears that one field. So adding a field to a type that already has entries can be backfilled from a script, one field at a time, without touching anything else and without changing any entry id.

Omit publish and the publish state is left as it is. Send {"publish": false} alone to take an entry off the public API while keeping its draft, which is the reversible way to undo a write experiment.

Sending neither is refused (empty_patch) rather than quietly doing nothing.

DELETE /v1/content/{type}/{entryId}

Removes the entry and its draft. Irreversible, with no trash. Prefer {"publish": false} when you only want it off the site.

Update a singleton

PATCH /v1/content/{type}

Secret key only. Singletons only. This is the one to reach for.

values is MERGED, exactly as it is for an entry: a key you omit is left alone, and an explicit null clears that one field. publish works the same way, and omitting it leaves the publish state as it is.

curl -X PATCH "https://api.zevcontent.net/v1/content/site-settings" \
  -H "Authorization: Bearer $ZEVCONTENT_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"values": {"tagline": "New tagline"}, "publish": true}'

Patching a singleton nobody has saved yet creates it, so you do not have to know whether it exists.

Replace a singleton

PUT /v1/content/{type}

Secret key only. Singletons only.

Replaces the singleton’s draft with values, creating its one entry on first save, and publishes it in the same call with "publish": true.

It is a replace, not a merge: values is the whole object. A key you omit is cleared, not left alone.

The schema itself

GET /v1/schema/content-types

Secret key only. Every type definition with its fields. These are the same definitions introspection embeds, on their own endpoint for a client that wants the schema without the rest of the map.

PUT /v1/schema/content-types/{type}

Secret key only. Create or update a type and its fields. This is how a project is modelled from code rather than in the dashboard, and it is what lets an agent set one up end to end. Idempotent: applying the same body twice lands in the same state.

{
  "name": "Team member",
  "kind": "collection",
  "fields": [
    { "key": "name", "label": "Name", "type": "string", "required": true },
    {
      "key": "role",
      "label": "Role",
      "type": "select",
      "options": {
        "choices": [
          { "value": "engineer", "label": "Engineer" },
          { "value": "designer", "label": "Designer" }
        ]
      },
      "help_text": "Shown as a filter on the team page."
    },
    { "key": "photo", "label": "Photo", "type": "image" }
  ]
}
Field
nameThe human name, shown in the dashboard.
kindsingleton or collection. Immutable once the type exists.
fields[].keyThe key in the values object. The permanent name.
fields[].labelWhat a writer sees.
fields[].typeOne of the twelve field types. Immutable once the field exists.
fields[].requiredOptional, default false.
fields[].optionsselect: { "choices": [{ "value", "label" }] }, in order. Not a string array. value is stored, label is what a writer sees. reference and reference_list: { "content_type": "<collection slug>" }. link and link_list: { "target": "blog_article" | "blog_category" | "author" | "kb_article" | "kb_collection" | "kb_section" }. Every target is immutable once set.
fields[].help_textOptional, shown under the field in the dashboard.
display_fieldOptional. Which field names an entry in the dashboard list. A key from fields, or null for the first field.
subtitle_fieldOptional. A second field shown under it, for what tells similar rows apart.

Naming entries in a list

By default the dashboard lists an entry under its first field, which is right for almost every structure and wrong for the ones that need it most.

A navigation collection is the clearest case: one entry per link, with a label, a location and an order. The same label legitimately belongs in a header, a footer column and a set of tiles, so the list comes back as seventeen rows reading “About”, “Journal”, “Covers” over and over, and the first question anybody asks is why everything looks duplicated.

display_field fixes it without inventing a field to hold a label:

{
  "display_field": "label",
  "subtitle_field": "location"
}

Rows then read Covers with header underneath, Covers with footer-left, and so on. A key that names no field is refused rather than ignored, because a nomination that silently falls back looks exactly like one you never made.

Two things are immutable once set, because changing them breaks every frontend already reading:

  • A type’s kind (content_type_kind_immutable). A singleton becoming a collection changes the response from an object to an array.
  • A field’s type (field_type_immutable). A string becoming a number changes what every reader receives.
  • A reference field’s target (reference_target_immutable). Every entry already stores ids of that collection under it. Remove the field and add another to point somewhere else.
  • A link field’s target (link_target_immutable), for the same reason.

Everything else is editable: labels, options, order, and adding or removing fields.

Only published content is served

/v1/content serves published entries. A draft is invisible here and visible in the dashboard, which is what lets somebody work on a page for a week without it appearing on your site.

An entry that exists but is not published answers entry_not_published rather than pretending it does not exist.

Updated at, Sunday, September 20, 2026