Querying

API Reference

Complete REST API reference for CellCMS. All endpoints are under /api/v1/. Authentication is via Authorization: Bearer <token> (JWT or API token) unless noted otherwise.

Base URL

https://api.cellcms.com/api/v1

Rate Limiting

ScopeLimit
Auth endpoints (/auth/*)10 requests / minute
All other endpoints100 requests / minute

Rate limit headers are included in every response:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 97
X-RateLimit-Reset: 1700000060

When exceeded, the API returns 429 Too Many Requests with a Retry-After header.

Error Responses

All errors follow this format:

{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "Detailed error description"
}

Common status codes:

CodeMeaning
400Invalid request body or parameters
401Missing or invalid authentication
403Insufficient permissions
404Resource not found
409Conflict (e.g., duplicate slug)
429Rate limit exceeded
500Internal server error

Health

GET /health

Check API server health. No authentication required.

Response:

{
  "status": "ok",
  "timestamp": "2025-01-15T10:00:00.000Z",
  "connections": 3
}

connections is the number of active WebSocket connections.


Authentication

POST /auth/login

Authenticate with email and password. Returns JWT tokens.

Rate limit: 10/minute

Request body:

{
  "email": "admin@cellcms.com",
  "password": "admin"
}

Response (200):

{
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "a1b2c3d4e5f6...",
  "user": {
    "id": "uuid",
    "email": "admin@cellcms.com",
    "name": "Admin",
    "role": "admin",
    "created_at": "2025-01-01T00:00:00Z",
    "updated_at": "2025-01-01T00:00:00Z"
  }
}

Errors: 400 (missing fields), 401 (invalid credentials)


POST /auth/refresh

Exchange a refresh token for a new token pair. The old refresh token is invalidated.

Rate limit: 10/minute

Request body:

{
  "refreshToken": "a1b2c3d4e5f6..."
}

Response (200):

{
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "new-refresh-token...",
  "user": { ... }
}

Errors: 400 (missing token), 401 (invalid or expired token)


POST /auth/logout

Revoke a refresh token.

Rate limit: 10/minute

Request body:

{
  "refreshToken": "a1b2c3d4e5f6..."
}

Response (200):

{ "ok": true }

Projects

GET /projects

List all projects. Non-admin users only see projects they have access to.

Auth: Any authenticated user

Response (200):

[
  {
    "id": "uuid",
    "name": "My Website",
    "slug": "my-website",
    "datasets": ["production", "staging"],
    "locales": null,
    "created_at": "2025-01-01T00:00:00Z",
    "updated_at": "2025-01-01T00:00:00Z"
  }
]

POST /projects

Create a new project.

Auth: Admin only

Request body:

{
  "name": "My Website",
  "slug": "my-website",
  "datasets": ["production", "staging"]
}
FieldTypeRequiredDefault
namestringYes
slugstringYes
datasetsstring[]No["production"]

Response (201):

{
  "id": "uuid",
  "name": "My Website",
  "slug": "my-website",
  "datasets": ["production", "staging"],
  "created_at": "2025-01-01T00:00:00Z",
  "updated_at": "2025-01-01T00:00:00Z"
}

Errors: 400 (missing fields), 409 (slug already exists)


GET /projects/:slug

Get a single project by slug.

Auth: Any authenticated user

Response (200): Project object

Errors: 404 (not found)


PUT /projects/:slug

Update a project.

Auth: Admin only

Request body (all fields optional):

{
  "name": "Updated Name",
  "datasets": ["production", "staging", "development"],
  "locales": {
    "default": "en",
    "languages": [
      { "id": "en", "title": "English" },
      { "id": "nl", "title": "Dutch" }
    ]
  }
}

Response (200): Updated project object

Errors: 404 (not found)


Users

GET /users

List all users.

Auth: Admin only

Response (200):

[
  {
    "id": "uuid",
    "email": "admin@cellcms.com",
    "name": "Admin",
    "role": "admin",
    "project_access": null,
    "created_at": "2025-01-01T00:00:00Z",
    "updated_at": "2025-01-01T00:00:00Z"
  }
]

Password hashes are never returned.


POST /users

Create a new user.

Auth: Admin only

Request body:

{
  "email": "editor@example.com",
  "name": "Jane Editor",
  "password": "secure-password",
  "role": "editor"
}
FieldTypeRequiredDefault
emailstringYes
namestringYes
passwordstringYes
role"admin" | "editor" | "viewer"No"editor"

Response (201): SafeUser object

Errors: 400 (missing fields), 409 (email already exists)


GET /users/me

Get the currently authenticated user.

Auth: Any authenticated user

Response (200): SafeUser object


PUT /users/:id

Update a user. Non-admin users can only update their own profile (name, email, password).

Auth: Self or admin

Request body (all fields optional):

{
  "name": "Updated Name",
  "email": "new@example.com",
  "password": "new-password",
  "role": "admin",
  "project_access": [
    { "projectId": "proj-uuid", "role": "editor" }
  ]
}

Only admins can change role and project_access.

Response (200): Updated SafeUser object

Errors: 403 (not admin and not self), 404 (not found)


Documents

POST /data/query/:dataset

Execute a GROQ query against a dataset.

Auth: Any authenticated user (viewer+)

Request body:

{
  "query": "*[_type == \"post\" && publishedAt < now()] | order(publishedAt desc)[0...10]{title, slug, publishedAt}",
  "params": {
    "type": "post"
  }
}
FieldTypeRequiredDescription
querystringYesGROQ query string
paramsobjectNoNamed parameters ($param in query)

Query parameter: ?language=en — resolve localized fields to the specified language.

Response (200):

{
  "query": "*[_type == \"post\"]...",
  "ms": 12,
  "result": [
    { "title": "Hello World", "slug": { "current": "hello-world" } }
  ]
}

Errors: 400 (parse error, missing params), 500 (execution error)


GET /data/counts/:dataset

Get document counts per type.

Auth: Any authenticated user (viewer+)

Response (200):

{
  "counts": {
    "post": 42,
    "author": 5,
    "category": 12
  }
}

Only includes published (non-draft) documents.


GET /data/doc/:dataset/:documentId

Get a single document by ID.

Auth: Any authenticated user (viewer+)

Query parameter: ?language=en — resolve localized fields.

Response (200):

{
  "_id": "doc-uuid",
  "_type": "post",
  "_rev": "abc123",
  "_createdAt": "2025-01-15T10:00:00Z",
  "_updatedAt": "2025-01-15T12:00:00Z",
  "title": "Hello World",
  "slug": { "current": "hello-world" }
}

Errors: 404 (not found)


POST /data/mutate/:dataset

Create, update, or delete documents in a single atomic transaction.

Auth: Editor or above

Request body:

{
  "mutations": [
    {
      "create": {
        "_type": "post",
        "title": "New Post",
        "slug": { "current": "new-post" }
      }
    },
    {
      "patch": {
        "id": "existing-doc-id",
        "set": { "title": "Updated Title" }
      }
    },
    {
      "delete": {
        "id": "doc-to-delete"
      }
    }
  ]
}

Mutation Types

create — Create a new document:

{
  "create": {
    "_type": "post",
    "_id": "optional-custom-id",
    "title": "Hello"
  }
}

createOrReplace — Create or fully replace an existing document:

{
  "createOrReplace": {
    "_id": "my-doc-id",
    "_type": "post",
    "title": "Replaced content"
  }
}

createIfNotExists — Create only if the ID doesn't exist:

{
  "createIfNotExists": {
    "_id": "my-doc-id",
    "_type": "post",
    "title": "Only created if new"
  }
}

patch — Atomically modify fields on an existing document:

{
  "patch": {
    "id": "doc-id",
    "ifRevisionID": "abc123",
    "set": { "title": "Updated" },
    "setIfMissing": { "views": 0 },
    "unset": ["deprecated_field"],
    "inc": { "views": 1 },
    "dec": { "stock": 1 }
  }
}
OperationDescription
setSet fields to specific values
setIfMissingSet only if the field doesn't exist
unsetRemove fields (array of dot-notation paths)
incIncrement numeric fields
decDecrement numeric fields
ifRevisionIDOptimistic concurrency — fail if revision doesn't match

delete — Delete a document:

{
  "delete": {
    "id": "doc-id"
  }
}

Response (200):

{
  "transactionId": "txn-uuid",
  "results": [
    { "id": "new-doc-id", "operation": "create" },
    { "id": "existing-doc-id", "operation": "update" },
    { "id": "doc-to-delete", "operation": "delete" }
  ]
}

Errors: 400 (validation errors), 404 (project not found)


POST /data/publish/:dataset/:documentId

Publish a draft document. Moves the draft (prefixed with drafts.) to the published version.

Auth: Editor or above

Response (200): Published document content

Errors: 404 (draft not found)


POST /data/unpublish/:dataset/:documentId

Unpublish a document. Converts the published version back to a draft.

Auth: Editor or above

Response (200): Draft document content

Errors: 404 (published document not found)


POST /data/schedule/:dataset/:documentId

Schedule a draft for automatic publishing at a future date.

Auth: Editor or above

Request body:

{
  "scheduledAt": "2025-02-01T10:00:00Z"
}

Pass null to cancel a scheduled publish:

{
  "scheduledAt": null
}

Response (200):

{
  "documentId": "doc-id",
  "scheduledAt": "2025-02-01T10:00:00Z"
}

Errors: 400 (invalid date), 404 (draft not found)


GET /data/history/:dataset/:documentId

Get revision history for a document.

Auth: Any authenticated user (viewer+)

Query parameters:

ParamTypeDefaultMax
limitnumber50100
offsetnumber0

Response (200):

{
  "documentId": "doc-id",
  "revisions": [
    {
      "id": "rev-uuid",
      "revision": 5,
      "changed_by": "user-uuid",
      "created_at": "2025-01-15T12:00:00Z"
    }
  ]
}

GET /data/history/:dataset/:documentId/:revision

Get a specific revision's content.

Auth: Any authenticated user (viewer+)

Response (200): Revision object with full document content

Errors: 404 (document or revision not found)


POST /data/history/:dataset/:documentId/:revision/restore

Restore a document to a previous revision. Creates a new revision snapshot.

Auth: Editor or above

Response (200): Restored document content

Errors: 404 (document or revision not found)


Assets

POST /assets/:dataset

Upload an image or file.

Auth: Editor or above

Content-Type: multipart/form-data

Body: Form field file with the file data. Max size: 50MB.

curl -X POST https://api.cellcms.com/api/v1/assets/production \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -F "file=@photo.jpg"

Response (201):

{
  "_id": "asset-uuid",
  "_type": "image",
  "assetId": "asset-uuid",
  "path": "project-id/production/asset-uuid.jpg",
  "url": "/api/v1/assets/asset-uuid",
  "metadata": {
    "width": 1920,
    "height": 1080,
    "format": "jpeg",
    "hasAlpha": false,
    "palette": { "dominant": "#2b5797", "vibrant": "#ff6600" }
  }
}

Errors: 400 (no file), 404 (project not found)


GET /assets/:id

Get an asset by ID. Supports on-the-fly image transforms.

Auth: Any authenticated user

Query parameters (images only):

ParamTypeDescription
wnumberWidth in pixels
hnumberHeight in pixels
fitstringcover, contain, or fill
formatstringOutput format (webp, avif, png, jpeg)
qnumberQuality (1–100)
blurnumberBlur radius
dprnumberDevice pixel ratio multiplier

Examples:

GET /api/v1/assets/abc123?w=400&h=300&fit=cover&format=webp&q=80
GET /api/v1/assets/abc123?w=100&blur=10
GET /api/v1/assets/abc123?w=800&dpr=2

Response: Binary file data with appropriate Content-Type header.

Caching: Transformed images are cached. Response includes ETag and Cache-Control: public, max-age=31536000, immutable headers. Returns 304 Not Modified when appropriate.

Errors: 404 (asset not found)


GET /assets/:dataset/list

List assets in a dataset.

Auth: Any authenticated user

Query parameters:

ParamTypeDefaultDescription
typestringFilter by type (image or file)
limitnumber50Max items to return
offsetnumber0Pagination offset

Response (200):

{
  "items": [
    {
      "id": "asset-uuid",
      "type": "image",
      "filename": "photo.jpg",
      "mime_type": "image/jpeg",
      "size": 245760,
      "url": "/api/v1/assets/asset-uuid",
      "metadata": { "width": 1920, "height": 1080 },
      "created_at": "2025-01-15T10:00:00Z"
    }
  ],
  "total": 128
}

DELETE /assets/:dataset/:id

Delete an asset.

Auth: Editor or above

Response (200):

{ "deleted": true, "id": "asset-uuid" }

Errors: 404 (not found)


Webhooks

GET /webhooks/:project

List all webhooks for a project.

Auth: Admin only

Response (200):

[
  {
    "id": "webhook-uuid",
    "name": "Deploy trigger",
    "url": "https://api.vercel.com/deploy/hook/...",
    "events": ["document.publish"],
    "dataset": "production",
    "active": true
  }
]

Secrets are not included in the response.


POST /webhooks/:project

Create a webhook.

Auth: Admin only

Request body:

{
  "name": "Deploy trigger",
  "url": "https://api.vercel.com/deploy/hook/...",
  "events": ["document.publish", "document.unpublish"],
  "dataset": "production",
  "secret": "optional-custom-secret",
  "active": true
}
FieldTypeRequiredDefault
namestringYes
urlstringYes
eventsstring[]Yes
datasetstringYes
secretstringNoAuto-generated (32 bytes hex)
activebooleanNotrue

Valid events: document.create, document.update, document.delete, document.publish, document.unpublish

Response (201): Webhook object

Errors: 400 (missing fields, invalid events), 409 (project not found)


PUT /webhooks/:project/:id

Update a webhook.

Auth: Admin only

Request body: Any of the creation fields (all optional).

Response (200): Updated webhook object

Errors: 400 (invalid events), 404 (not found)


DELETE /webhooks/:project/:id

Delete a webhook and all its delivery logs.

Auth: Admin only

Response (200):

{ "deleted": true, "id": "webhook-uuid" }

Errors: 404 (not found)


GET /webhooks/:project/:id/deliveries

View delivery history for a webhook.

Auth: Admin only

Query parameters:

ParamTypeDefaultMax
limitnumber50100

Response (200):

[
  {
    "id": "delivery-uuid",
    "event": "document.publish",
    "payload": { "documentId": "...", "type": "post" },
    "status_code": 200,
    "response_body": "OK",
    "attempts": 1,
    "success": true,
    "created_at": "2025-01-15T10:00:00Z"
  }
]

API Tokens

GET /tokens/:project

List all API tokens for a project (without raw token values).

Auth: Admin only

Response (200):

[
  {
    "id": "token-uuid",
    "name": "Frontend read-only",
    "permissions": ["read"],
    "dataset": "production",
    "last_used_at": "2025-01-15T12:00:00Z",
    "created_at": "2025-01-15T10:00:00Z"
  }
]

POST /tokens/:project

Create an API token. The raw token is only returned once.

Auth: Admin only

Request body:

{
  "name": "Frontend read-only",
  "permissions": ["read"],
  "dataset": "production"
}
FieldTypeRequiredDefault
namestringYes
permissionsstring[]No["read"]
datasetstringNonull (all datasets)

Valid permissions: read, write

Response (201):

{
  "id": "token-uuid",
  "name": "Frontend read-only",
  "permissions": ["read"],
  "dataset": "production",
  "created_at": "2025-01-15T10:00:00Z",
  "token": "cell_a1b2c3d4e5f6..."
}

Errors: 400 (invalid name or permissions), 404 (project not found)


DELETE /tokens/:project/:id

Revoke an API token.

Auth: Admin only

Response (200):

{ "deleted": true, "id": "token-uuid" }

Errors: 404 (not found)


Data Migration

GET /data/export/:dataset

Export all documents as NDJSON (newline-delimited JSON).

Auth: Viewer or above

Query parameters:

ParamTypeDescription
typesstringComma-separated list of types to export

Response: Streaming NDJSON. Each line is a complete JSON document.

Content-Type: application/x-ndjson
Content-Disposition: attachment; filename="production-export.ndjson"
{"_id":"doc1","_type":"post","title":"Hello World","_createdAt":"2025-01-15T10:00:00Z"}
{"_id":"doc2","_type":"author","name":"Jane","_createdAt":"2025-01-14T09:00:00Z"}

POST /data/import/:dataset

Import documents from NDJSON format. Compatible with Sanity export format.

Auth: Admin only

Content-Type: application/x-ndjson or application/json

Query parameters:

ParamTypeDefaultDescription
replacestringSet to "true" to replace existing documents

Request body: NDJSON text (one document per line).

Response (200):

{
  "total": 150,
  "created": 142,
  "replaced": 0,
  "skipped": 8,
  "errors": [
    { "line": 45, "error": "Invalid document format" }
  ]
}

Behavior:

  • Sanity system documents (starting with system. or _.) are skipped
  • Without replace=true: existing documents are skipped
  • With replace=true: existing documents are overwritten
  • Preserves _createdAt if present in the source
  • Runs in a single atomic transaction

Schema

GET /schema/:project

Get the compiled schema for a project.

Auth: Any authenticated user

Response (200): Serialized schema with all document types and field definitions.

Note: This endpoint is only available if the server was started with schema types configured.