Content (Pages, Posts, Docs)

Endpoints for the core content types. Public reads live under /content/... (no auth); writes live under /dashboard/content/... (bearer token). All write paths also have a /dashboard/projects/{project_id}/content/... form — see the Overview.

Pages

Public reads

  • GET /content/pages — list published pages. Query: page, page_size.

  • GET /content/pages/{slug} — a published page by slug, with its blocks.

Dashboard (bearer)

  • GET /dashboard/content/pages — list all pages. Query: status (draft|pending_review|published|archived), page, page_size, format.

  • POST /dashboard/content/pages — create a page. Gated by dry_run/confirm_token.

  • GET /dashboard/content/pages/{page_id} — a page by id. Query: audit_level (off|errors|warnings|all).

  • PATCH /dashboard/content/pages/{page_id} — update a page (any field except slug).

  • DELETE /dashboard/content/pages/{page_id} — archive a page. Gated.

  • POST /dashboard/content/pages/{page_id}/publish — publish. Gated.

  • POST /dashboard/content/pages/{page_id}/unpublish — revert to draft. Gated.

  • POST /dashboard/content/pages/{page_id}/duplicate — copy a page.

  • POST /dashboard/content/pages/{page_id}/insert-section — insert a marketplace component.

  • GET /dashboard/content/pages/{page_id}/versions — list version snapshots.

  • POST /dashboard/content/pages/{page_id}/restore?version_number=N — restore a version. Gated.

  • POST /dashboard/content/pages/{page_id}/lock / /unlock — edit locking.

  • GET /dashboard/content/pages/{page_id}/export?format=json|md|archive — self-contained export.

POST /dashboard/content/pages body (key fields):

  • title (string, required)

  • slug (string) — URL-safe; auto-derived if omitted

  • blocks (array) — the page's blocks (see Pages)

  • template (string, default "default")

  • seo_title, seo_description, og_image_url, canonical_url, robots, json_ld

Example — create and publish a page:

# Create
curl -X POST "https://spideriq.ai/api/v1/dashboard/content/pages" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "title": "Pricing",
    "slug": "pricing",
    "blocks": [
      { "type": "hero", "data": { "headline": "Simple pricing" } },
      { "type": "pricing_table", "data": { "plans": [] } }
    ]
  }'
# → { "id": "…", "slug": "pricing", "status": "draft", … }

# Publish (preview then confirm)
curl -X POST ".../dashboard/content/pages/PAGE_ID/publish?dry_run=true" -H "Authorization: Bearer $TOKEN"
curl -X POST ".../dashboard/content/pages/PAGE_ID/publish?confirm_token=cft_…" -H "Authorization: Bearer $TOKEN"

Dynamic landing pages

A page created with template: "dynamic_landing" gains two extra public routes on the deployed site:

/lp/{page_slug}/{identifier}
/lp/{page_slug}/{salesperson_slug}/{identifier}

The renderer resolves {identifier} to a business record at request time and exposes it to the template as lead, alongside a flat set of merge tags ({{ company_name }}, {{ city }}, {{ firstname }}, …). {salesperson_slug} is matched against salespersons in the project's template config and exposed as salesperson.

Set the template at create time — the renderer dispatches on this field, and the valid values are default, landing, blank, dynamic_landing, dynamic_list, dynamic_item, and 404.

GET /content/leads/resolve

Public read. No bearer token; the tenant is resolved from the X-Content-Domain header like every other /content/... endpoint. This is the call the Liquid renderer makes for you on a /lp/ request — you rarely call it directly, but it is the contract that decides whether a given identifier will resolve.

Query parameters — supply exactly one identifier, plus an optional include.

Parameter

Type

Resolves a business?

Notes

place_id

string

yes

Google Maps place ID. The default when ?resolve_key= is omitted on a /lp/ URL.

domain

string

yes

The business's primary domain.

pin_name · pin_data_set_id · account_id · pin_subscription_id

string

yes

VayaPin keys; resolve via the pin's linked business.

vat · lei · tax_id · registration_number

string

yes

Company-registry keys; resolve via the registry record's linked business.

email

string

no

Accepted for backward compatibility only. A business record has no email column and no join on one, so this always returns 404 lead_not_found. Use domain.

include

string

Comma-separated related types: emails, phones, domains, contacts.

Ten keys resolve. Supplying zero or more than one identifier is a 400.

Example:

curl "https://yoursite.com/api/v1/content/leads/resolve?place_id=0x47e66fdad6f1cc73:0x341211b3fccd79e1&include=emails,phones" \
  -H "X-Content-Domain: yoursite.com"

Response — 200:

{
  "id": "8f2c…",
  "ref": "idap://businesses/8f2c…",
  "created_at": "2026-04-02T10:11:12Z",
  "modified_at": "2026-04-09T08:00:00Z",
  "flags": {},
  "name": "Acme Plumbing",
  "city": "Berlin",
  "country_code": "DE",
  "rating": 4.7,
  "reviews_count": 212,
  "domain": "acme-plumbing.de",
  "related": { "emails": [], "phones": [] }
}

The business columns are spread at the top level; related is present only when include was supplied.

Errors:

Status

Detail

Cause

400

Provide exactly one identifier: …

Zero, or two or more, identifier parameters were supplied.

404

client_not_found

The X-Content-Domain host did not resolve to a client.

404

no_lead_data

The client has no normalised data schema — nothing has been ingested for this tenant yet.

404

lead_not_found

The identifier did not match a business. Also what email always returns.

503

The database was unavailable.

A 404 here is not an error on the page. The renderer degrades a missing lead to null, and because it runs with strict variables off, {{ lead.name }} renders empty rather than throwing. A /lp/ page that 500s is a custom template dereferencing nested lead.* without an {% if lead %} guard — see Dynamic Landing Pages.

Posts

Public reads

  • GET /content/posts — list published posts. Query: page, page_size, tag, category, author_id, created_after, format.

  • GET /content/posts/featured — featured posts.

  • GET /content/posts/search?q=... — full-text search.

  • GET /content/posts/{slug} — a published post, body as Tiptap JSON.

  • GET /content/authors, GET /content/authors/{slug} — author profiles.

  • GET /content/categories, GET /content/tags — taxonomy.

Dashboard (bearer)

  • GET|POST /dashboard/content/posts — list / create.

  • GET|PATCH|DELETE /dashboard/content/posts/{post_id} — read / update / delete.

  • POST /dashboard/content/posts/{post_id}/publish · /unpublish · /duplicate.

  • POST /dashboard/content/posts/{post_id}/status — set draft|pending_review|published|archived.

  • PUT /dashboard/content/posts/{post_id}/related — set related posts.

  • Authors: GET|POST /dashboard/content/authors, GET|PATCH|DELETE /authors/{id}.

  • Tags: GET|POST /dashboard/content/tags, PATCH|DELETE /tags/{id}.

  • Categories: GET|POST /dashboard/content/categories, PATCH|DELETE /categories/{id}.

POST /dashboard/content/posts body (key fields): title (required), slug, body (Tiptap JSON), excerpt, cover_image_url, author_id or author_name, tags[], category_ids[], is_featured, seo_title, seo_description, vayapin_pins[].

Custom collections

A collection defines a content type. Each of its records is a first-class page with its own slug, SEO fields and URL at /<route_base>/<record-slug>.

Dashboard (bearer) — all routes are project-scoped.

  • GET|POST /dashboard/content/{project_id}/collections — list / create. Create enforces the max_collections cap.

  • GET|PATCH /dashboard/content/{project_id}/collections/{slug} — read / update a definition.

  • DELETE /dashboard/content/{project_id}/collections/{slug} — delete the collection and cascade to its records. Gated.

  • GET|POST /dashboard/content/{project_id}/collections/{slug}/records — list / create. Create enforces max_records.

  • POST /dashboard/content/{project_id}/collections/{slug}/records/bulk — create 1–100 records in one transaction. Any validation failure rejects the entire batch.

  • GET /dashboard/content/{project_id}/collections/{slug}/records/{record_slug} — one record, by slug.

  • PATCH|DELETE /dashboard/content/{project_id}/collections/{slug}/records/{record_id} — update / delete, by id. A status transition (publish, archive, unpublish) is gated; editing a draft's fields is not.

Gated routes use the standard two-step: call with dry_run to get a preview plus a single-use confirm_token (7-day TTL) and a snapshot_hash, then call again with the token to commit.

Collection body (key fields): slug (required), label (required), route_base (required — the URL base for detail pages), schema_json, is_public.

Record body (key fields): slug (required), data, seo_title, seo_description, og_image_url, sort, publish_at.

Field types

schema_json.fields[] accepts nine types: text, number, bool, select, date, richtext, media, relationship, blocks.

  • select takes an options[] list.

  • relationship 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.

  • richtext accepts markdown, HTML, a block list, or raw Tiptap, and caps at 262,144 bytes (256 KB).

Reading records efficiently

GET .../records returns every field of every record, which is expensive on a wide schema. Pass fields to narrow the data object — the record envelope (slug, status, dates, SEO) is always returned. limit clamps to 1–500 and defaults to 50.

Relationships hydrate on a fixed query budget, never one query per record: one lookup to resolve the URL segment, one joined query for the definition and the page of records, then exactly one batched fetch per many-to-one field — two for a one-to-many reverse walk. depth=0 returns raw foreign keys with no include queries at all. depth caps at 1.

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

project_id

string (path)

yes

The project the collection belongs to.

slug

string (path)

yes

Collection slug.

records

array

yes

1–100 record objects.

records[].slug

string

yes

Record slug, unique within the collection.

records[].data

object

no

Field values, validated against the collection schema.

records[].seo_title

string

no

SEO title for the record's page.

records[].sort

integer

no

Sort weight, ascending. Default 0.

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. Each carries id, slug, data, status ("draft"), sort, created_at and a _warnings array.

Errors

Status

When

Resolution

400

no project scope resolved

use the project-scoped path, or send an X-Project-Id header

403

the batch would exceed max_records

the body reports current/limit; shrink the batch

404

no collection with that slug in this project

verify the slug and the project

422

a record failed validation

body is {"errors": [...], "warnings": [...]}. Causes: a data key not in the schema, a duplicate slug within the batch, or a slug already in the collection

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

Update a record. Editing draft fields applies immediately; changing status 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.

status

string

no

draft, published or 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 preview = await client.contentUpdateCollectionRecord(
  "case-studies",
  "eef28faa-69ee-4b19-949b-ab8f3bc0789d",
  { status: "published" },
  { dryRun: true },
);

const done = await client.contentUpdateCollectionRecord(
  "case-studies",
  "eef28faa-69ee-4b19-949b-ab8f3bc0789d",
  { 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:

# 1. preview — returns confirm_token + snapshot_hash, mutates nothing
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}'

# 2. commit with the token it returned
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 instead 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

tokens are single-use with a 7-day TTL; re-run the dry run

422

data failed schema validation

the error names every declared field; unknown keys are rejected, not dropped

Errors across the collections surface

Status

When

Resolution

400

project scope missing

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

403

max_collections / max_records reached

body reports current/limit

404

collection or record not found in this project

verify slug and project

409

confirm-token expired or rejected

re-run the dry run

422

schema validation failed

read detail.errors. A slug collision is 422 here, not 409

503

database unavailable

transient; retry

Two asymmetries worth knowing

Read by slug, write by id. GET a record with its slug; PATCH and DELETE it with its id. A slug can be renamed and an id cannot, so writes take the stable key.

Unknown fields are rejected. A data key the schema does not declare fails the write with an error naming every declared field. Posts drop unrecognised keys silently; collections do not.

Slugs for collections and records match ^[a-z0-9][a-z0-9-]*$ (hyphens, no underscores). Field ids match ^[a-z][a-z0-9_]*$ (snake_case).

Docs

Public reads

  • GET /content/docs/tree — the full nested docs tree.

  • GET /content/docs/{path} — a doc by its full path (e.g. getting-started/quickstart).

  • GET /content/docs/search?q=... — search docs.

Dashboard (bearer)

  • GET /dashboard/content/docs/tree — full tree (incl. drafts).

  • POST /dashboard/content/docs — create a doc or section.

  • GET /dashboard/content/docs/{doc_id} — a doc by id.

  • POST /dashboard/content/docs/{doc_id}/duplicate — copy a doc.

  • PUT /dashboard/content/docs/reorder — reorder the tree.

POST /dashboard/content/docs body: slug (required), title (required), body (Tiptap JSON), parent_id (a section id, or null for top-level), is_section (bool, default false), sort_order (int), seo_title, seo_description.

Tree-build note: a section's sort_order must be smaller than its child pages' sort_order, because the tree is assembled in one pass over sort_order. Sections first, then pages.

Navigation

  • GET /content/navigation/{location} — public read; location is header, footer, or docs_sidebar.

  • GET|PUT /dashboard/content/navigation/{location} — read / replace a whole menu.

A menu is { "items": [NavItem] } where each NavItem is { label, url?, icon?, badge?, is_external?, children? }. PUT replaces the entire menu, so read it first, edit, then write back.

Media

  • GET /dashboard/content/media — list media. Query: page, page_size.

  • POST /dashboard/content/media/upload — multipart upload (file, optional folder, alt_text, caption).

  • POST /dashboard/content/media/upload-batch — multiple files.

  • PATCH /dashboard/content/media/{media_id} — update alt_text / caption / folder.

  • DELETE /dashboard/content/media/{media_id} — delete.

Uploads return a permanent CDN URL on media.cdn.spideriq.ai plus detected width/height.

Settings

  • GET /content/settings — public read of site-level settings.

  • GET /dashboard/content/settings — full settings.

  • PATCH /dashboard/content/settings — update. Gated by dry_run/confirm_token.

Settings include site_name, site_tagline, favicon_url, default_og_image, social_links, and an extensions object that controls the sitemap, robots, and llms.txt surfaces.

Redirects & Domains

  • GET|POST /dashboard/content/redirects, DELETE /redirects/{id} — manage 301/302 redirects.

  • Domains are covered in Design & Deploy.

Next steps

Publish