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_PAT

Omit 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

text

plain string

number

numeric

bool

true / false

select

requires an options[] list

date

ISO 8601

richtext

markdown, HTML, a block list or raw Tiptap. Caps at 262,144 bytes (256 KB)

media

a URL

relationship

requires target, on, rel — see below

blocks

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 relmany_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"]

Response200 OK, { collections: [...], total: number }. Each collection carries id, slug, label, route_base, schema_json, is_public, created_at.

Errors

Status

When

Resolution

400

no project scope resolved

use the project-scoped path or send X-Project-Id

503

database unavailable

transient; retry

POST /dashboard/content/{project_id}/collections

Create a collection definition. Non-destructive — no confirm gate.

Parameters

Name

Type

Required

Description

slug

string

yes

Unique in the project. Hyphens, no underscores.

label

string

yes

Human-readable name.

route_base

string

no

URL base for detail pages. Defaults to the slug.

schema_json

object

no

{ "fields": [...] }. Empty means no declared fields.

is_public

boolean

no

Expose records on the anonymous public door. Default false.

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"

Response201 Created, the collection. Note the server normalises schema_json, adding an access block and a version.

Errors

Status

When

Resolution

403

max_collections cap reached

body reports current/limit

422

slug already exists in this project, invalid slug, unknown field type, or a duplicate field id

read detail.errors — a slug collision is 422, not 409

GET /dashboard/content/{project_id}/collections/{slug}

Get one collection definition by slug.

Parametersslug (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); // → 3

Response200 OK, the collection definition.

Errors

Status

When

Resolution

404

no collection with that slug in this project

contentListCollections is not project-scoped, so it can return a slug this route cannot resolve. Check the project.

PATCH /dashboard/content/{project_id}/collections/{slug}

Update a definition — label, route_base, schema or visibility. Non-destructive.

Parametersslug (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); // → true

Response200 OK, the updated collection.

Errors

Status

When

Resolution

404

no collection with that slug in this project

verify slug and project

422

invalid schema, or a route_base already used by another collection

read detail.errors

DELETE /dashboard/content/{project_id}/collections/{slug}

Delete a collection and cascade to all of its records. Gated.

Parametersslug (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 it

Response — 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

404

no collection with that slug in this project

verify slug and project

409

confirm_token expired or rejected

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

slug

string (path)

yes

Collection slug.

fields

string[]

no

Field ids to keep in each record's data. Unknown ids are ignored.

limit

integer

no

1–500. Default 50.

offset

integer

no

Pagination offset.

sort

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"

Response200 OK, { records: [...], total, limit, offset }.

Errors

Status

When

Resolution

404

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.

Parametersslug (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"

Response200 OK, the record with its full data object.

Errors

Status

When

Resolution

404

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

slug

string

yes

Record slug, unique within the collection.

data

object

no

Field values, validated against the schema.

seo_title

string

no

SEO title for the record's page.

seo_description

string

no

SEO meta description.

og_image_url

string

no

Open Graph image.

sort

integer

no

Sort weight, ascending. Default 0.

publish_at

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"

Response201 Created, the record. Always status: "draft" — creating never publishes. Carries a _warnings array.

Errors

Status

When

Resolution

403

max_records cap reached

body reports current/limit

404

no collection with that slug in this project

verify slug and project

422

a data key is not declared in the schema, the slug is invalid, or it already exists in this collection

read detail.errors; it names every declared field

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

records

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 created

The 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"}}
      ]}'

Response201 Created, an array of created records.

Errors

Status

When

Resolution

403

the batch would exceed max_records

the cap is checked for the full batch size, so a 100-record push cannot slip past one record at a time

404

no collection with that slug in this project

verify slug and project

422

any record failed validation

causes: an undeclared data key, a duplicate slug within the batch, or a slug already in the collection. Nothing was written.

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

record_id

string (path)

yes

The record's id, not its slug.

data

object

no

Replacement field values.

slug

string

no

New record slug.

seo_title / seo_description / og_image_url

string

no

SEO fields.

sort

integer

no

Sort weight.

status

string

no

draft | published | archived. Gated.

dry_run

boolean

no

For a status change: preview and receive a confirm_token.

confirm_token

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 run

The 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"}'

Response200 OK, the updated record. A dry run returns {dry_run, action, preview, confirm_token, expires_at, snapshot_hash}.

Errors

Status

When

Resolution

404

no record with that id in this collection

you probably passed a slug — reads take a slug, writes take an id

409

confirm_token expired or rejected

single-use, 7-day TTL; re-run the dry run

422

data failed schema validation

the error names every declared field

DELETE /dashboard/content/{project_id}/collections/{slug}/records/{record_id}

Delete one record by its id. Gated.

Parametersrecord_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); // → true

Response — a dry run returns the preview envelope; the commit returns {deleted: true}.

Errors

Status

When

Resolution

404

no record with that id in this collection

reads take a slug, writes take an id

409

confirm_token expired or rejected

re-run the dry run


Errors across the whole surface

Status

When

Resolution

400

project scope missing

use the project-scoped path or send X-Project-Id

403

max_collections / max_records reached

body reports current/limit. These caps are enforced but are not yet configurable per plan.

404

collection or record not found in this project

verify the slug and the project

409

confirm-token expired or rejected

single-use, 7-day TTL; re-run the dry run

422

schema validation failed

read detail.errors and detail.warnings. A slug collision arrives here, not as a 409.

503

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_collections and max_records are 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

Publish