Custom Collections
Custom collections let you define your own content types. A collection is a schema; each of its records is a first-class page served at /<route_base>/<record-slug> with its own slug, SEO fields and Open Graph image.
Every route below is project-scoped and takes a bearer PAT:
https://spideriq.ai/api/v1/dashboard/content/{project_id}/collections/...
Authorization: Bearer $SPIDERIQ_PATOmit the project scope and you get a 400 telling you to use the project-scoped path or send an X-Project-Id header.
Two asymmetries to know before you start
Records are read by slug and written by id. GET .../records/{record_slug} takes the human-readable key; PATCH and DELETE take the record's id. A slug can be renamed and an id cannot, so writes take the stable one. List first, keep the id, then write.
Unknown fields are rejected, not ignored. A data key your schema does not declare fails the write with an error naming every field that is declared. Blog posts drop unrecognised keys silently; collections deliberately do not.
Slugs for collections and records match ^[a-z0-9][a-z0-9-]*$ — hyphens, never underscores. Field ids inside schema_json match ^[a-z][a-z0-9_]*$ — snake_case, underscores allowed. So case-studies is a valid collection slug and case_studies is not.
Field types
schema_json.fields[] accepts nine types.
Type | Notes |
|---|---|
| plain string |
| numeric |
| true / false |
| requires an |
| ISO 8601 |
| markdown, HTML, a block list or raw Tiptap. Caps at 262,144 bytes (256 KB) |
| a URL |
| requires |
| a page-builder block array |
A relationship field takes target (a collection slug, or post / author), on (the foreign-key key carried in the record's data) and rel — many_to_one or one_to_many. Many-to-many is not supported. Business data is never a relationship target, which keeps the public renderer away from anything private.
Adding a tenth field type is a registry entry, not a migration — the schema lives in a JSON column, so a new type never touches existing rows.
Collection definitions
GET /dashboard/content/{project_id}/collections
List the project's collections.
Parameters — none beyond the path.
Example
import { SpiderIQClient } from "@spideriq/core";
const client = new SpiderIQClient({ token: process.env.SPIDERIQ_PAT });
const res = await client.contentListCollections();
console.log(res.collections.map((c) => c.slug)); // → ["case-studies", "guides"]Response — 200 OK, { collections: [...], total: number }. Each collection carries id, slug, label, route_base, schema_json, is_public, created_at.
Errors
Status | When | Resolution |
|---|---|---|
| no project scope resolved | use the project-scoped path or send |
| database unavailable | transient; retry |
POST /dashboard/content/{project_id}/collections
Create a collection definition. Non-destructive — no confirm gate.
Parameters
Name | Type | Required | Description |
|---|---|---|---|
| string | yes | Unique in the project. Hyphens, no underscores. |
| string | yes | Human-readable name. |
| string | no | URL base for detail pages. Defaults to the slug. |
| object | no |
|
| boolean | no | Expose records on the anonymous public door. Default |
Example
import { SpiderIQClient } from "@spideriq/core";
const client = new SpiderIQClient({ token: process.env.SPIDERIQ_PAT });
const res = await client.contentCreateCollection({
slug: "case-studies",
label: "Case Studies",
route_base: "case-studies",
is_public: true,
schema_json: {
fields: [
{ id: "client_name", type: "text", required: true },
{ id: "industry", type: "select", options: ["SaaS", "Retail", "Finance"] },
{ id: "body", type: "richtext" },
],
},
});
console.log(res.slug); // → "case-studies"Response — 201 Created, the collection. Note the server normalises schema_json, adding an access block and a version.
Errors
Status | When | Resolution |
|---|---|---|
|
| body reports |
| slug already exists in this project, invalid slug, unknown field type, or a duplicate field id | read |
GET /dashboard/content/{project_id}/collections/{slug}
Get one collection definition by slug.
Parameters — slug (path, required).
Example
import { SpiderIQClient } from "@spideriq/core";
const client = new SpiderIQClient({ token: process.env.SPIDERIQ_PAT });
const res = await client.contentGetCollection("case-studies");
console.log(res.schema_json.fields.length); // → 3Response — 200 OK, the collection definition.
Errors
Status | When | Resolution |
|---|---|---|
| no collection with that slug in this project |
|
PATCH /dashboard/content/{project_id}/collections/{slug}
Update a definition — label, route_base, schema or visibility. Non-destructive.
Parameters — slug (path, required), plus any of label, route_base, schema_json, is_public.
Example
import { SpiderIQClient } from "@spideriq/core";
const client = new SpiderIQClient({ token: process.env.SPIDERIQ_PAT });
const res = await client.contentUpdateCollection("case-studies", { is_public: true });
console.log(res.is_public); // → trueResponse — 200 OK, the updated collection.
Errors
Status | When | Resolution |
|---|---|---|
| no collection with that slug in this project | verify slug and project |
| invalid schema, or a | read |
DELETE /dashboard/content/{project_id}/collections/{slug}
Delete a collection and cascade to all of its records. Gated.
Parameters — slug (path, required); dry_run / confirm_token for the gate.
Example — two calls. The preview tells you how many records would go with it:
import { SpiderIQClient } from "@spideriq/core";
const client = new SpiderIQClient({ token: process.env.SPIDERIQ_PAT });
const preview = await client.contentDeleteCollection("case-studies", { dryRun: true });
console.log(preview.preview.warning); // → "Deletes the collection AND all of its records (cascade)."
const done = await client.contentDeleteCollection("case-studies", {
confirmToken: preview.confirm_token,
});
console.log(done.record_count); // → 12 records removed with itResponse — a dry run returns {dry_run, action, preview, confirm_token, expires_at, snapshot_hash}. The commit returns {deleted: true, slug, record_count}.
Errors
Status | When | Resolution |
|---|---|---|
| no collection with that slug in this project | verify slug and project |
|
| tokens are single-use with a 7-day TTL; re-run the dry run |
Records
GET /dashboard/content/{project_id}/collections/{slug}/records
List a page of records — drafts included. This is the author view.
Parameters
Name | Type | Required | Description |
|---|---|---|---|
| string (path) | yes | Collection slug. |
| string[] | no | Field ids to keep in each record's |
| integer | no | 1–500. Default 50. |
| integer | no | Pagination offset. |
| string | no | Sort expression. |
Every field of every record is returned unless you pass fields. On a wide schema that is expensive — a 54-field collection returns roughly 73,000 characters for 60 records. The record envelope (slug, status, dates, SEO) is always returned; fields narrows the data object only.
Example
import { SpiderIQClient } from "@spideriq/core";
const client = new SpiderIQClient({ token: process.env.SPIDERIQ_PAT });
const res = await client.contentListCollectionRecords("case-studies", {
fields: ["client_name", "industry"],
limit: 20,
});
console.log(res.total, res.records[0].slug); // → 12 "acme-migration"Response — 200 OK, { records: [...], total, limit, offset }.
Errors
Status | When | Resolution |
|---|---|---|
| no collection with that slug in this project | verify slug and project |
Relationship hydration and the query budget
Relationships resolve on a fixed budget, never one query per record:
one lookup to resolve the URL segment to a collection
one joined query for the definition and the page of records, with the count windowed onto the same query
exactly one batched
fk = ANY($1)fetch per many-to-one relationship field — two for a one-to-many reverse walk
A page of 50 records with two many-to-one relationships costs four queries, not 104. depth=0 returns raw foreign keys with no include queries at all. depth caps at 1 in this release.
GET /dashboard/content/{project_id}/collections/{slug}/records/{record_slug}
Get one record by its slug.
Parameters — slug (path, required), record_slug (path, required).
Example
import { SpiderIQClient } from "@spideriq/core";
const client = new SpiderIQClient({ token: process.env.SPIDERIQ_PAT });
const res = await client.contentGetCollectionRecord("case-studies", "acme-migration");
console.log(res.data.client_name, res.status); // → "Acme" "published"Response — 200 OK, the record with its full data object.
Errors
Status | When | Resolution |
|---|---|---|
| no record with that slug | this route takes a slug; if you passed an id, that is the bug |
POST /dashboard/content/{project_id}/collections/{slug}/records
Create one draft record. Non-destructive. Enforces max_records.
Parameters
Name | Type | Required | Description |
|---|---|---|---|
| string | yes | Record slug, unique within the collection. |
| object | no | Field values, validated against the schema. |
| string | no | SEO title for the record's page. |
| string | no | SEO meta description. |
| string | no | Open Graph image. |
| integer | no | Sort weight, ascending. Default |
| string | no | Scheduled publish timestamp (ISO 8601). |
Example
import { SpiderIQClient } from "@spideriq/core";
const client = new SpiderIQClient({ token: process.env.SPIDERIQ_PAT });
const res = await client.contentCreateCollectionRecord("case-studies", {
slug: "acme-migration",
seo_title: "How Acme cut deploy time to under a minute",
data: { client_name: "Acme", industry: "SaaS" },
});
console.log(res.status); // → "draft" — creating never publishes
console.log(res.id); // → "eef28faa-69ee-4b19-949b-ab8f3bc0789d"Response — 201 Created, the record. Always status: "draft" — creating never publishes. Carries a _warnings array.
Errors
Status | When | Resolution |
|---|---|---|
|
| body reports |
| no collection with that slug in this project | verify slug and project |
| a | read |
POST /dashboard/content/{project_id}/collections/{slug}/records/bulk
Create 1–100 draft records in one transaction. Any record that fails validation rejects the entire batch — there is no partial import.
Parameters
Name | Type | Required | Description |
|---|---|---|---|
| array | yes | 1–100 record objects, each shaped like the single-create body. |
Example
import { SpiderIQClient } from "@spideriq/core";
const client = new SpiderIQClient({ token: process.env.SPIDERIQ_PAT });
const res = await client.contentBulkCreateCollectionRecords("case-studies", [
{ slug: "acme-migration", data: { client_name: "Acme", industry: "SaaS" } },
{ slug: "globex-rollout", data: { client_name: "Globex", industry: "Retail" } },
]);
console.log(res.records.length); // → 2, both status "draft"
// a 422 here rejects the WHOLE batch — nothing is createdThe same call with curl:
curl -X POST "https://spideriq.ai/api/v1/dashboard/content/$PROJECT_ID/collections/case-studies/records/bulk" \
-H "Authorization: Bearer $SPIDERIQ_PAT" \
-H "Content-Type: application/json" \
-d '{"records": [
{"slug": "acme-migration", "data": {"client_name": "Acme", "industry": "SaaS"}},
{"slug": "globex-rollout", "data": {"client_name": "Globex", "industry": "Retail"}}
]}'Response — 201 Created, an array of created records.
Errors
Status | When | Resolution |
|---|---|---|
| the batch would exceed | the cap is checked for the full batch size, so a 100-record push cannot slip past one record at a time |
| no collection with that slug in this project | verify slug and project |
| any record failed validation | causes: an undeclared |
PATCH /dashboard/content/{project_id}/collections/{slug}/records/{record_id}
Update a record by its id. Editing draft fields applies immediately; a status change is gated.
Parameters
Name | Type | Required | Description |
|---|---|---|---|
| string (path) | yes | The record's id, not its slug. |
| object | no | Replacement field values. |
| string | no | New record slug. |
| string | no | SEO fields. |
| integer | no | Sort weight. |
| string | no |
|
| boolean | no | For a status change: preview and receive a |
| string | no | Consume a prior dry-run token and apply. |
Example — publishing is two calls:
import { SpiderIQClient } from "@spideriq/core";
const client = new SpiderIQClient({ token: process.env.SPIDERIQ_PAT });
const recordId = "eef28faa-69ee-4b19-949b-ab8f3bc0789d";
const preview = await client.contentUpdateCollectionRecord(
"case-studies",
recordId,
{ status: "published" },
{ dryRun: true },
);
const done = await client.contentUpdateCollectionRecord(
"case-studies",
recordId,
{ status: "published" },
{ confirmToken: preview.confirm_token },
);
console.log(done.status); // → "published"
// a 409 on the second call means the token expired — re-run the dry runThe same two calls with curl:
curl -X PATCH "https://spideriq.ai/api/v1/dashboard/content/$PROJECT_ID/collections/case-studies/records/$RECORD_ID" \
-H "Authorization: Bearer $SPIDERIQ_PAT" -H "Content-Type: application/json" \
-d '{"status": "published", "dry_run": true}'
curl -X PATCH "https://spideriq.ai/api/v1/dashboard/content/$PROJECT_ID/collections/case-studies/records/$RECORD_ID" \
-H "Authorization: Bearer $SPIDERIQ_PAT" -H "Content-Type: application/json" \
-d '{"status": "published", "confirm_token": "cft_05de7a5e7f699a9e55454e1f97dc8e48"}'Response — 200 OK, the updated record. A dry run returns {dry_run, action, preview, confirm_token, expires_at, snapshot_hash}.
Errors
Status | When | Resolution |
|---|---|---|
| no record with that id in this collection | you probably passed a slug — reads take a slug, writes take an id |
|
| single-use, 7-day TTL; re-run the dry run |
|
| the error names every declared field |
DELETE /dashboard/content/{project_id}/collections/{slug}/records/{record_id}
Delete one record by its id. Gated.
Parameters — record_id (path, required); dry_run / confirm_token.
Example
import { SpiderIQClient } from "@spideriq/core";
const client = new SpiderIQClient({ token: process.env.SPIDERIQ_PAT });
const recordId = "eef28faa-69ee-4b19-949b-ab8f3bc0789d";
const preview = await client.contentDeleteCollectionRecord("case-studies", recordId, {
dryRun: true,
});
const done = await client.contentDeleteCollectionRecord("case-studies", recordId, {
confirmToken: preview.confirm_token,
});
console.log(done.deleted); // → trueResponse — a dry run returns the preview envelope; the commit returns {deleted: true}.
Errors
Status | When | Resolution |
|---|---|---|
| no record with that id in this collection | reads take a slug, writes take an id |
|
| re-run the dry run |
Errors across the whole surface
Status | When | Resolution |
|---|---|---|
| project scope missing | use the project-scoped path or send |
|
| body reports |
| collection or record not found in this project | verify the slug and the project |
| confirm-token expired or rejected | single-use, 7-day TTL; re-run the dry run |
| schema validation failed | read |
| database unavailable | transient; retry |
Gated operations — every delete, and any record status transition — use the same two-step: call with dry_run to get a preview, a single-use confirm_token and a snapshot_hash, then call again with the token to commit.
Not in this release
So you do not discover these on day one:
Many-to-many and nested collections. Cardinality is many-to-one or one-to-many only.
Configurable quotas.
max_collectionsandmax_recordsare enforced, but there is no admin write-path to set them per plan yet.An automatic index page. Record detail pages are automatic once you set a
route_base; a page that lists a collection still needs a page with a dynamic component on it.Localization and record versioning.
Next steps
Custom Collections for agents — when to reach for a collection, and the agent workflow end to end.
MCP Content Tools — the same operations as
collection_*tools.CLI Content Commands — the same operations as
spideriq content collections ....Content API — pages, posts, docs and the rest of the content surface.