---
title: "Data Model Write API"
url: "https://glokki.com/docs/data-model-write-api"
locale: "en"
updated: "2026-10-14T21:05:55.448Z"
---

# 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](#what-is-deliberately-absent). For the general MCP connection flow (endpoint URL, `claude mcp add`, rate limits), see the [MCP Server Guide](/docs/mcp-server).

---

## Table of Contents

1. [What it is](#what-it-is)
2. [Getting a key](#getting-a-key)
3. [Auth](#auth)
4. [Discovering the schema](#discovering-the-schema)
5. [Writing](#writing)
6. [Error handling](#error-handling)
7. [What is deliberately absent](#what-is-deliberately-absent)
8. [MCP](#mcp)

---

## 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](#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](#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

```bash
curl "$CMS_BASE_URL/api/v1/admin/data-models" \
  -H "Authorization: Bearer $CMS_API_KEY"
```

```json
{
  "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

```bash
curl "$CMS_BASE_URL/api/v1/admin/data-models/authors" \
  -H "Authorization: Bearer $CMS_API_KEY"
```

```json
{
  "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

```bash
curl "$CMS_BASE_URL/api/v1/admin/data-models/authors/records?limit=25" \
  -H "Authorization: Bearer $CMS_API_KEY"
```

```json
{
  "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

```bash
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):

| Method  | Path                                                        | Use it to                                                              |
| ------- | ----------------------------------------------------------- | ---------------------------------------------------------------------- |
| `POST`  | `/api/v1/admin/data-models/{modelKey}/records`              | Create 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`

```bash
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`:

```json
{
  "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}`

```bash
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.

```bash
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.

| HTTP | `code`                                | Cause                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| ---- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 400  | `VALIDATION_ERROR`                    | Request body fails the endpoint's own Zod schema — missing `data`, or an invalid `key` format on `POST`/`PATCH`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| 400  | `INVALID_RECORD_KEY`                  | The `by-key` URL segment doesn't match `^[a-z][a-z0-9-]{0,63}$`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| 400  | `DATA_MODEL_RECORD_VALIDATION_FAILED` | `data` fails structural validation against the model's `fieldsSchema` — check `details.fieldErrors`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| 401  | `UNAUTHORIZED`                        | The `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.)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| 403  | `INSUFFICIENT_SCOPE`                  | The key is valid but lacks the scope the endpoint requires (`admin:data-models:read` or `:write`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| 403  | `API_KEY_NOT_ACCEPTED`                | The 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.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| 404  | `DATA_MODEL_NOT_FOUND`                | `{modelKey}` doesn't name a data model in this tenant.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| 404  | `DATA_MODEL_RECORD_NOT_FOUND`         | `{recordId}` — on `GET` or `PATCH` — doesn't exist (or belongs to a different model).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| 409  | `DATA_MODEL_RECORD_KEY_TAKEN`         | `POST` 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.)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| 429  | `RATE_LIMITED`                        | A 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 `GET`s) 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. |
| 503  | `RATE_LIMIT_UNAVAILABLE`              | The 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:

```json
// 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"]
    }
  }
}
```

```json
// 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](/docs/mcp-server)):

| Tool                       | Scope                     |
| -------------------------- | ------------------------- |
| `list_data_models`         | `admin:data-models:read`  |
| `get_data_model`           | `admin:data-models:read`  |
| `list_data_model_records`  | `admin:data-models:read`  |
| `get_data_model_record`    | `admin:data-models:read`  |
| `create_data_model_record` | `admin:data-models:write` |
| `update_data_model_record` | `admin: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](#what-is-deliberately-absent) above.
