Dynamic Content

One generic set of endpoints serves every content model in your CMS. The model slug becomes the route segment.

Endpoints

GET /api/v1/{modelSlug}
GET /api/v1/{modelSlug}/{idOrSlug}
POST /api/v1/{modelSlug} (scope: write)
PUT /api/v1/{modelSlug}/{id} (scope: write)
DELETE /api/v1/{modelSlug}/{id} (scope: delete)

The controller (DynamicContentController) resolves {modelSlug} to a content_models row and applies its api_config JSON to filter visible fields, default sort, and per-page caps.

List entries

GET /api/v1/blog-posts?per_page=10&sort=-published_at&filter[locale]=en

200 OK
{
  "data": [
    {
      "id":   "01H…",
      "slug": "hello-world",
      "title": "Hello world",
      "status": "published",
      "locale": "en",
      "data": {
        "title":   "Hello world",
        "excerpt": "First post.",
        "body":    "<p>…</p>",
        "tags":    ["intro"],
        "hero_image": { "id": "…", "url": "/media/…", "alt": "…" }
      },
      "seo": { "title": "…", "description": "…" },
      "published_at": "2026-05-29T10:00:00Z"
    }
  ],
  "meta": { "page": 1, "per_page": 10, "total": 137 },
  "error": null
}

Query parameters

ParamExampleNotes
page21-based
per_page20Capped by model's api_config.max_per_page
sort-published_at,titleComma-separated; prefix - for descending
filter[status]publishedEquality filter on top-level columns
filter[locale]enDefaults to the site's default locale if model is localized
filter[data.tags]launchFilter on a JSON path in the data column
qhelloFull-text on the model's title field + any text fields flagged searchable
includeauthor,hero_imageResolve relations and media inline
fieldstitle,slug,excerptLimit returned top-level keys

By default, only entries with status = published are returned. Drafts are visible only with API keys that have an admin role attached.

Get a single entry

You can address an entry by its UUID or by its slug. When a slug is used, the response defaults to the requested locale.

GET /api/v1/blog-posts/hello-world?include=author,related_posts
GET /api/v1/blog-posts/01H4Z…              // by UUID

Single-type models

Single-type models always return one entry — no listing semantics. Address them as GET /api/v1/{modelSlug} (returns one object, not an array) or GET /api/v1/{modelSlug}/single as a verbose alias.

Create an entry

POST /api/v1/blog-posts
X-API-Key: …
X-API-Secret: …
Content-Type: application/json

{
  "title":  "New post",
  "slug":   "new-post",
  "status": "published",
  "locale": "en",
  "data": {
    "title":   "New post",
    "excerpt": "Quick hello.",
    "body":    "<p>Hello!</p>"
  },
  "seo": { "title": "New post", "description": "Quick hello." }
}

201 Created
{ "data": { "id": "01H…", … }, "error": null }

Required and validated fields come from the model's content_fields definitions. If you omit a required field, you get a 422 with details.

Update an entry

PUT /api/v1/blog-posts/01H4Z…
Content-Type: application/json

{
  "status": "published",
  "data":   { "excerpt": "Updated excerpt." }
}

200 OK
{ "data": { "id": "01H…", … }, "error": null }

Partial updates are accepted: any keys you omit are left untouched. Each save creates a revision.

Delete an entry

DELETE /api/v1/blog-posts/01H4Z…

204 No Content

Soft delete only. The entry is hidden but recoverable from the admin trash.

Errors specific to this surface

StatusCodeWhen
404MODEL_NOT_FOUND{modelSlug} doesn't match any active model
404ENTRY_NOT_FOUNDUUID / slug not found in this model + locale
422VALIDATION_FAILEDReturns per-field error map
409SLUG_CONFLICTSlug already exists for this model + locale
Webhooks fire hereCreates and updates emit entry.created / entry.updated; transitions to published emit entry.published. See Admin: Webhooks.