glokki
View .md

Data Model Write API

Create and update data model records — the reference data blocks and collection fields point at (authors, venues, service hours) — from a script or an MCP agent, without a browser session.

This guide covers the record endpoints under /api/v1/admin/data-models/{modelKey}/records, plus the six MCP tools that wrap the same operations. It does not cover creating or editing a data model's definition (its fieldsSchema, displayField, etc.) — that stays dashboard-only, see What is deliberately absent. For the general MCP connection flow (endpoint URL, claude mcp add, rate limits), see the MCP Server Guide.


What it is

A data model is a tenant-defined record type (e.g. "Authors", "Venues", "Service hours") with a field schema, shown in the dashboard under Settings → Data Models. A record is one instance of it (e.g. one author). Blocks and collection fields with a dataModelRef field reference a record by its id (a UUID) — the record's optional key is a separate, stable identifier meant for exactly this API: it lets an external script address (and idempotently upsert) a record by something more legible and re-derivable than a database id.

This API lets a trusted server-side caller — a sync script holding an ek_live_ API key, or an MCP agent — create and update records without going through the dashboard UI. Two callers, one set of rules:

  • REST, authenticated with a bearer API key or a dashboard session: POST, PATCH, and PUT …/by-key/{key} on /api/v1/admin/data-models/{modelKey}/records.
  • MCP, authenticated the same way (the key just arrives as the MCP bearer token): six tools, listed in MCP.

Both callers go through the same service functions, the same field-schema validation, and the same post-write side effects (cache purge, activity log).


Getting a key

  1. Go to Settings → API keys in the dashboard.
  2. Create a new key and tick both admin:data-models:read and admin:data-models:write.
  3. Copy the plain-text key (ek_live_…) — it is shown once, at creation time.

An existing key cannot be given a new scope from the UI. The API keys table only displays a key's permissions as read-only badges; there is no edit-scopes dialog. If a key you already issued doesn't have admin:data-models:read/:write, the only way to add them is to issue a new key with the scopes ticked up front, and swap it into whatever script or agent config used the old one.


Auth

Every request needs:

Authorization: Bearer ek_live_…

X-Tenant-Slug does nothing on these routes — don't send it as a cross-tenant guard. The tenant is always derived from the key itself (a key belongs to exactly one tenant), so there's no header needed for routing to work. If you do send one, it never reaches the code that would check it: the proxy strips X-Tenant-Slug from every request under /api/v1/admin/ before your request is handled (it exists there to stop a client from spoofing another tenant on the session path), so a mismatched value is silently dropped, not validated. There is no way to make this header fail a request on these routes.

If a script is parameterized across multiple tenants and you want a mismatch to fail loudly, pair slug → key in the script's own configuration instead — that's a map you already control — and, if you want the call itself to prove the pairing is right, read back GET /api/v1/admin/data-models first and confirm the model keys you expect are present before writing.

A dashboard session cookie works the same way as a bearer key on every endpoint this guide covers except two: DELETE on a record, and any write to a model's own definition, refuse a bearer key outright (403 API_KEY_NOT_ACCEPTED) — see What is deliberately absent.


Discovering the schema

Before writing a record, find the model's fieldsSchema — record data is validated against it, so guessing field names fails the write.

List the tenant's data models

curl "$CMS_BASE_URL/api/v1/admin/data-models" \
  -H "Authorization: Bearer $CMS_API_KEY"
{
  "data": [
    {
      "id": "8f14e45f-ceea-4b7d-9503-92a1f2b1c5aa",
      "tenantId": "11111111-1111-4111-8111-111111111111",
      "key": "authors",
      "name": "Authors",
      "description": "Blog post authors",
      "icon": "user",
      "displayField": "name",
      "fieldsSchema": [
        {
          "name": "name",
          "label": "Name",
          "fieldType": "text",
          "required": true
        },
        {
          "name": "bio",
          "label": "Bio",
          "fieldType": "textarea",
          "required": false
        },
        {
          "name": "photo",
          "label": "Photo",
          "fieldType": "image",
          "required": false
        }
      ],
      "createdBy": "77777777-7777-4777-8777-777777777777",
      "createdAt": "2026-09-20T10:00:00.000Z",
      "updatedAt": "2026-09-20T10:00:00.000Z"
    }
  ],
  "meta": { "total": 1 }
}

The list already carries each model's full fieldsSchema (it's the same row shape as the single-model lookup below), so for a tenant with only a handful of models this one call may be all you need. GET /{modelKey} is worth a separate call once you know which model you're targeting — one row, not the whole list — and is what you'll use every time a script re-validates its assumptions before writing.

Get one model's schema

curl "$CMS_BASE_URL/api/v1/admin/data-models/authors" \
  -H "Authorization: Bearer $CMS_API_KEY"
{
  "data": {
    "id": "8f14e45f-ceea-4b7d-9503-92a1f2b1c5aa",
    "tenantId": "11111111-1111-4111-8111-111111111111",
    "key": "authors",
    "name": "Authors",
    "description": "Blog post authors",
    "icon": "user",
    "displayField": "name",
    "fieldsSchema": [
      {
        "name": "name",
        "label": "Name",
        "fieldType": "text",
        "required": true
      },
      {
        "name": "bio",
        "label": "Bio",
        "fieldType": "textarea",
        "required": false
      },
      {
        "name": "photo",
        "label": "Photo",
        "fieldType": "image",
        "required": false
      }
    ],
    "createdBy": "77777777-7777-4777-8777-777777777777",
    "createdAt": "2026-09-20T10:00:00.000Z",
    "updatedAt": "2026-09-20T10:00:00.000Z"
  }
}

Both endpoints accept a key with admin:data-models:read (or a dashboard session).

List a model's records

curl "$CMS_BASE_URL/api/v1/admin/data-models/authors/records?limit=25" \
  -H "Authorization: Bearer $CMS_API_KEY"
{
  "data": [
    {
      "id": "fb48c64f-1234-4abc-9def-0123456789ab",
      "tenantId": "11111111-1111-4111-8111-111111111111",
      "dataModelId": "8f14e45f-ceea-4b7d-9503-92a1f2b1c5aa",
      "key": "jane-doe",
      "data": { "name": "Jane Doe", "bio": "Senior staff writer." },
      "createdBy": "77777777-7777-4777-8777-777777777777",
      "createdAt": "2026-09-27T12:00:00.000Z",
      "updatedAt": "2026-09-27T12:00:00.000Z"
    }
  ],
  "meta": { "total": 1 }
}

Query params: search (matches the model's displayField value or the record's key), sort (updatedAt or displayField, default updatedAt), direction (asc/desc, default desc), limit (1–100, default 25), and offset.

Get one record

curl "$CMS_BASE_URL/api/v1/admin/data-models/authors/records/fb48c64f-1234-4abc-9def-0123456789ab" \
  -H "Authorization: Bearer $CMS_API_KEY"

200 OK with the same record shape as the list above. 404 DATA_MODEL_RECORD_NOT_FOUND if {recordId} doesn't exist, or belongs to a different model than {modelKey} names.

Both record GETs accept a key with admin:data-models:read (or a dashboard session), same as the two model GETs above.


Writing

Three endpoints, all requiring admin:data-models:write (or a dashboard session):

MethodPathUse it to
POST/api/v1/admin/data-models/{modelKey}/recordsCreate a new record, with an optional caller-chosen key.
PATCH/api/v1/admin/data-models/{modelKey}/records/{recordId}Update an existing record by its id.
PUT/api/v1/admin/data-models/{modelKey}/records/by-key/{key}Create-or-replace a record by its stable key — an idempotent upsert.

data is a free-form JSON object validated structurally against the model's fieldsSchema (required fields present, values of a plausible shape for their fieldType). A record's optional key — when set, on create or via PATCH — must match ^[a-z][a-z0-9-]{0,63}$ (lowercase letters, digits, and hyphens, starting with a letter, up to 64 characters) and is unique within the model.

Create — POST …/records

curl -X POST "$CMS_BASE_URL/api/v1/admin/data-models/authors/records" \
  -H "Authorization: Bearer $CMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"key": "jane-doe", "data": {"name": "Jane Doe", "bio": "Staff writer."}}'

201 Created:

{
  "data": {
    "id": "fb48c64f-1234-4abc-9def-0123456789ab",
    "tenantId": "11111111-1111-4111-8111-111111111111",
    "dataModelId": "8f14e45f-ceea-4b7d-9503-92a1f2b1c5aa",
    "key": "jane-doe",
    "data": { "name": "Jane Doe", "bio": "Staff writer." },
    "createdBy": "77777777-7777-4777-8777-777777777777",
    "createdAt": "2026-09-27T12:00:00.000Z",
    "updatedAt": "2026-09-27T12:00:00.000Z"
  }
}

key is optional — omit it for a record that's only ever addressed by its id.

Update by id — PATCH …/records/{recordId}

curl -X PATCH "$CMS_BASE_URL/api/v1/admin/data-models/authors/records/fb48c64f-1234-4abc-9def-0123456789ab" \
  -H "Authorization: Bearer $CMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data": {"name": "Jane Doe", "bio": "Senior staff writer."}}'

200 OK with the updated record, same shape as the POST response above. Pass key, data, or both — either can be omitted, but when data is sent, it replaces the whole object; there is no partial/merge update of individual fields inside data.

Upsert by key — PUT …/records/by-key/{key}

This is the endpoint to reach for from an idempotent sync script — one that re-imports the same external dataset on a schedule and doesn't want to track which records it already created.

curl -X PUT "$CMS_BASE_URL/api/v1/admin/data-models/authors/records/by-key/jane-doe" \
  -H "Authorization: Bearer $CMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data": {"name": "Jane Doe", "bio": "Senior staff writer."}}'
  • 201 Created — no record with this key existed for this model; one was created.
  • 200 OK — a record with this key already existed; its data was replaced wholly with the payload you sent (not merged), and its createdBy is left untouched — the original author stays attributed even when someone else's key runs the sync.
  • Re-running the identical request is safe. The write is a single INSERT … ON CONFLICT DO UPDATE, so two calls back-to-back — or two concurrent calls — converge on one row, never a duplicate.

The URL's {key} segment is validated against the same ^[a-z][a-z0-9-]{0,63}$ pattern before any database work; a malformed key is refused with 400 INVALID_RECORD_KEY, not a 404 (a malformed key can never have matched an existing record, so answering "not found" would be misleading).


Error handling

All errors return JSON shaped { "error": { "code", "message", "details"? } }; the codes below are what these endpoints can return. VALIDATION_ERROR and DATA_MODEL_RECORD_VALIDATION_FAILED both carry per-field messages in details, but nested differently — see the two example bodies below.

HTTPcodeCause
400VALIDATION_ERRORRequest body fails the endpoint's own Zod schema — missing data, or an invalid key format on POST/PATCH.
400INVALID_RECORD_KEYThe by-key URL segment doesn't match ^[a-z][a-z0-9-]{0,63}$.
400DATA_MODEL_RECORD_VALIDATION_FAILEDdata fails structural validation against the model's fieldsSchema — check details.fieldErrors.
401UNAUTHORIZEDThe Authorization header is missing or malformed, or the key is unknown, revoked, or expired. (A key whose creating user account no longer exists is refused too, but with 401 USER_NOT_FOUND instead — same status, different code.)
403INSUFFICIENT_SCOPEThe key is valid but lacks the scope the endpoint requires (admin:data-models:read or :write).
403API_KEY_NOT_ACCEPTEDThe endpoint is session-only. This covers record DELETE and any write to a model's own definition — a bearer key gets this instead of a login redirect.
404DATA_MODEL_NOT_FOUND{modelKey} doesn't name a data model in this tenant.
404DATA_MODEL_RECORD_NOT_FOUND{recordId} — on GET or PATCH — doesn't exist (or belongs to a different model).
409DATA_MODEL_RECORD_KEY_TAKENPOST with a key already used by another record in this model. (PUT …/by-key/{key} can't hit this — it upserts on the same key instead of colliding with it.)
429RATE_LIMITEDA rate-limit budget is exhausted. Reads and writes are metered separately, and each budget has two ceilings: a tenant-wide one, shared across every bearer-authenticated call in that category this tenant makes (not just data model records), and a per-key one at half the tenant figure, rounded up, so one key alone can never spend the whole tenant budget.<br><br>Writes (POST, PATCH, PUT) are metered on admin:write — 100 requests / 60s tenant-wide, 50 requests / 60s per key.<br>Reads (all four GETs) are metered on admin:read — 200 requests / 60s tenant-wide, 100 requests / 60s per key.<br><br>Whichever ceiling is hit first refuses the call, and the message names the figure it enforced, e.g. Write limit reached (50/min). That message says "Write" even when it was a read budget that refused you — the label is not per category, so read the number, not the word.
503RATE_LIMIT_UNAVAILABLEThe rate limiter itself is unreachable (e.g. Redis is down). Writes fail closed here rather than let an unmetered burst through. Back off and retry on your own schedule — there is no Retry-After header on this response, and the numeric retry hint in details is only present outside production, so don't rely on reading one back from a deployed instance.

Two example bodies, since the two "invalid data" codes nest their field errors differently:

// VALIDATION_ERROR — details IS the fieldErrors map. The message text below
// is Zod's own generated wording (e.g. "Invalid input: expected record,
// received undefined") and can shift between Zod versions — the shape
// (an array of strings per field) is what's load-bearing, not this exact string.
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": {
      "data": ["Invalid input: expected record, received undefined"]
    }
  }
}
// DATA_MODEL_RECORD_VALIDATION_FAILED — details wraps it under "fieldErrors"
{
  "error": {
    "code": "DATA_MODEL_RECORD_VALIDATION_FAILED",
    "message": "Record data does not match the data model's fields schema",
    "details": { "fieldErrors": { "name": ["Name is required"] } }
  }
}

What is deliberately absent

  • No delete. DELETE …/records/{recordId} exists, but only for a dashboard session — a bearer key gets 403 API_KEY_NOT_ACCEPTED. There is no way to delete a record over this API.
  • No bulk payloads. Every write endpoint takes exactly one record per request. Importing many records means one request per record (the upsert-by-key endpoint is what makes that safe to re-run).
  • No writing model definitions. The admin:data-models:* scopes only ever gate the endpoints in this guide — the four record/model read GETs and the three record writes. Creating a data model, changing its fieldsSchema, renaming it, or deleting it goes through separate POST/PATCH/DELETE endpoints under /api/v1/admin/data-models (and /{modelKey}) that are session-only, same as record delete, regardless of what scopes a bearer key carries. This guide only covers records.

MCP

The same read/write operations are available as six MCP tools, gated by the same two scopes (plus mcp:connect, required to open any MCP connection — see the MCP Server Guide):

ToolScope
list_data_modelsadmin:data-models:read
get_data_modeladmin:data-models:read
list_data_model_recordsadmin:data-models:read
get_data_model_recordadmin:data-models:read
create_data_model_recordadmin:data-models:write
update_data_model_recordadmin:data-models:write

Call get_data_model before either write tool. Both create_data_model_record and update_data_model_record validate data against the model's field schema, exactly like the REST endpoints do — guessing field names fails the write the same way it would over HTTP.

update_data_model_record needs expectedUpdatedAt. get_data_model_record (and every record row from list_data_model_records) returns an updatedAt token; pass it back as expectedUpdatedAt on the update call. If the record changed since you read it — someone else's edit landed in between — the call is refused with 409 STALE_RECORD instead of silently overwriting that change; re-read with get_data_model_record and retry with the fresh token. Every successful write also returns the record's new updatedAt, so a chain of edits against the same record doesn't need a re-read between each one.

There is no MCP upsert-by-key tool and no MCP delete tool — those two gaps mirror What is deliberately absent above.