Writing data
Define models, create and import records, and change fields from a script through the admin GraphQL API.
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/graphqlThe 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 3A 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
importand changed ones topatch. - 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.