Writing data

Define models, create and import records, and change fields from a script through the admin GraphQL API.

September 16, 2026

Everything you can do to records in the admin - define a model, create a record, import a spreadsheet, change a field - you can also do from a script. This page is the path an integration takes: an ERP export, a PIM feed, a one-off migration. It ends with a complete script you can run as is.

Where writes go

Writes go to the admin GraphQL API, not to the delivery route your frontend reads from:

https://api.cmssy.io/graphql

The delivery route (/public/{org}/{workspace}/graphql) serves published content and has no record mutations at all - a write sent there fails validation. Every request to the admin API carries two headers:

  • Authorization: Bearer cs_... - an API token. The token acts as the user who created it, so that user's role in the workspace decides what the script may write.
  • x-workspace-id - the id of the workspace to write to, from Settings → Workspace. A token scoped to one workspace refuses any other.

Check both before writing anything:

curl https://api.cmssy.io/graphql \
  -H "Authorization: Bearer $CMSSY_TOKEN" \
  -H "x-workspace-id: $CMSSY_WORKSPACE_ID" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ model { list { id slug name } } }"}'

A list of models (possibly empty) means you are in. Not authorized means the token or the workspace id is wrong, or the token belongs to another workspace.

Define a model

Records belong to a model. Create it once, from the admin or from the script. Turning on product makes the model a product catalog: skuField names the field that must be unique, and priceField the price, in major units (149.99, not cents).

mutation ($input: CreateModelDefinitionInput!) {
  model {
    create(input: $input) { id }
  }
}
{
  "input": {
    "name": "Catalog product",
    "slug": "catalog-product",
    "displayField": "name",
    "product": { "enabled": true, "skuField": "sku", "priceField": "price" },
    "fields": [
      { "key": "sku", "label": "SKU", "type": "text", "required": true },
      { "key": "name", "label": "Name", "type": "text", "required": true },
      { "key": "price", "label": "Price", "type": "number" },
      { "key": "color", "label": "Color", "type": "text" }
    ]
  }
}

The slug is unique within the workspace; a second create with the same slug is refused. A relation field points at another model with "type": "relation", "relationTo": "model:<slug>".

Create one record

data is a JSON object keyed by field key. It is validated against the model: a missing required field or a duplicate SKU is refused with a message naming the field.

mutation ($input: CreateModelRecordInput!) {
  record {
    create(input: $input) { id data }
  }
}
{ "input": { "modelId": "<model id>", "data": { "sku": "CHAIR-1", "name": "Oak chair", "price": 149.99 } } }

Import in bulk

record.import takes up to 1000 rows per call. The 5 MB limit you may know from the admin's CSV import applies only to the browser. Send larger files in batches of 1000.

mutation ($input: ImportModelRecordsInput!) {
  record {
    import(input: $input) {
      importedCount
      errors { row message }
    }
  }
}

A row that fails validation does not stop the others. It comes back in errors with its 1-based position in the batch:

{ "importedCount": 2, "errors": [{ "row": 3, "message": "sku: A record with this SKU already exists" }] }

Treat a non-empty errors as a failed run, or a partial catalog goes unnoticed.

Change a record

Use record.patch. It is a JSON merge patch: fields you leave out keep their stored value, null removes a field, and a translatable or object field given an object is merged key by key. Two scripts patching different fields of the same record do not overwrite each other.

mutation ($input: PatchModelRecordInput!) {
  record {
    patch(input: $input) { id data }
  }
}
{ "input": { "id": "<record id>", "data": { "price": 129.99, "color": "natural" } } }

record.update takes the same input but replaces the whole data object - any field you do not send is gone. Use it only when the script owns every field of the record. record.delete(id) removes a record.

Read back

record.list returns up to 100 records per call (20 when limit is left out); page with offset until hasMore is false.

query ($modelId: ID!, $offset: Int) {
  record {
    list(modelId: $modelId, limit: 100, offset: $offset, sort: "createdAt_asc") {
      total
      hasMore
      items { id data }
    }
  }
}

Limits and retries

A workspace accepts a fixed number of record writes per minute from API tokens - the current number is on Rate limits. An import costs one per row; every other write costs one. Past the budget the API answers HTTP 429 with a Retry-After header: wait that many seconds and send the same request again. A single call larger than the whole budget is refused outright - split it. Writes made in the admin app are not counted.

A complete script

Node 22 or later, no dependencies. Set CMSSY_TOKEN and CMSSY_WORKSPACE_ID, save the script as import-products.mjs and run node import-products.mjs. It creates the model and one record, imports three rows (the third is refused as a duplicate SKU), patches the first record and counts what is there.

const API = "https://api.cmssy.io/graphql";
const headers = {
  "content-type": "application/json",
  authorization: `Bearer ${process.env.CMSSY_TOKEN}`,
  "x-workspace-id": process.env.CMSSY_WORKSPACE_ID,
};

async function gql(query, variables) {
  const res = await fetch(API, { method: "POST", headers, body: JSON.stringify({ query, variables }) });
  if (res.status === 429) {
    const wait = Number(res.headers.get("retry-after") ?? 1);
    await new Promise((r) => setTimeout(r, wait * 1000));
    return gql(query, variables);
  }
  const body = await res.json();
  if (body.errors) throw new Error(body.errors.map((e) => e.message).join("; "));
  return body.data;
}

const { model } = await gql(
  `mutation ($input: CreateModelDefinitionInput!) { model { create(input: $input) { id } } }`,
  {
    input: {
      name: "Catalog product",
      slug: "catalog-product",
      displayField: "name",
      product: { enabled: true, skuField: "sku", priceField: "price" },
      fields: [
        { key: "sku", label: "SKU", type: "text", required: true },
        { key: "name", label: "Name", type: "text", required: true },
        { key: "price", label: "Price", type: "number" },
        { key: "color", label: "Color", type: "text" },
      ],
    },
  },
);
const modelId = model.create.id;

const { record } = await gql(
  `mutation ($input: CreateModelRecordInput!) { record { create(input: $input) { id data } } }`,
  { input: { modelId, data: { sku: "CHAIR-1", name: "Oak chair", price: 149.99 } } },
);
console.log("created", record.create.id);

const rows = [
  { sku: "TABLE-1", name: "Oak table", price: 499 },
  { sku: "LAMP-1", name: "Desk lamp", price: 39.5, color: "black" },
  { sku: "CHAIR-1", name: "Duplicate chair", price: 1 },
];
const imported = await gql(
  `mutation ($input: ImportModelRecordsInput!) { record { import(input: $input) { importedCount errors { row message } } } }`,
  { input: { modelId, rows } },
);
console.log("imported", imported.record.import);

const patched = await gql(
  `mutation ($input: PatchModelRecordInput!) { record { patch(input: $input) { data } } }`,
  { input: { id: record.create.id, data: { price: 129.99, color: "natural" } } },
);
console.log("patched", patched.record.patch.data);

const list = await gql(
  `query ($modelId: ID!) { record { list(modelId: $modelId, limit: 100) { total items { id data } } } }`,
  { modelId },
);
console.log("total", list.record.list.total);

Expected output:

created 6aaa...
imported {
  importedCount: 2,
  errors: [ { row: 3, message: 'sku: A record with this SKU already exists' } ]
}
patched { sku: 'CHAIR-1', name: 'Oak chair', price: 129.99, color: 'natural' }
total 3

A second run stops at the first step, because the model slug is already taken. Delete the model in the admin or change the slug.

A full sync, worked end to end

examples/catalog-import syncs a public wholesale catalog (Microsoft's Wide World Importers) into cmssy: suppliers and stock groups as models of their own, products related to both, a second run that writes only what changed, and a replay of the catalog's change history as patches. Fork it as the starting point for a real integration.

What it does not do yet

  • Import only inserts. Running the same file twice creates every row again. Product models refuse a repeated SKU row by row; any other model accepts the duplicate. Until import can update by key, read what is there first, then send new rows to import and changed ones to patch.
  • Import does not return the ids it created. To match your rows to records afterwards, list the model and look them up by your own key.
  • Only the SKU can be unique. No other field can be declared unique, so your own reference numbers are not protected against duplicates.
  • No queue, buffer or retry on our side. The API writes what it receives, when it receives it. Your integration owns ordering, retries and idempotency; when two writers change the same field, the last write wins.