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 bydry_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 exceptslug).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 omittedblocks(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 |
|---|---|---|---|
| string | yes | Google Maps place ID. The default when |
| string | yes | The business's primary domain. |
| string | yes | VayaPin keys; resolve via the pin's linked business. |
| string | yes | Company-registry keys; resolve via the registry record's linked business. |
| string | no | Accepted for backward compatibility only. A business record has no email column and no join on one, so this always returns |
| string | — | Comma-separated related types: |
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 |
|---|---|---|
|
| Zero, or two or more, identifier parameters were supplied. |
|
| The |
|
| The client has no normalised data schema — nothing has been ingested for this tenant yet. |
|
| The identifier did not match a business. Also what |
| — | The database was unavailable. |
A
404here is not an error on the page. The renderer degrades a missing lead tonull, 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 nestedlead.*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— setdraft|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 themax_collectionscap.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 enforcesmax_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. Astatustransition (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.
selecttakes anoptions[]list.relationshiptakestarget(a collection slug, orpost/author),on(the foreign-key key carried in the record'sdata), andrel—many_to_oneorone_to_many. Many-to-many is not supported.richtextaccepts 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 |
|---|---|---|---|
| string (path) | yes | The project the collection belongs to. |
| string (path) | yes | Collection slug. |
| array | yes | 1–100 record objects. |
| string | yes | Record slug, unique within the collection. |
| object | no | Field values, validated against the collection schema. |
| string | no | SEO title for the record's page. |
| integer | no | Sort weight, ascending. Default |
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. Each carries id, slug, data, status ("draft"), sort, created_at and a _warnings array.
Errors
Status | When | Resolution |
|---|---|---|
| no project scope resolved | use the project-scoped path, or send an |
| the batch would exceed | the body reports |
| no collection with that slug in this project | verify the slug and the project |
| a record failed validation | body is |
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 |
|---|---|---|---|
| string (path) | yes | The record's id, not its slug. |
| object | no | Replacement field values. |
| string | no | New record slug. |
| 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 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 runThe 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"}'Response — 200 OK, the updated record. A dry run instead 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 |
|
| tokens are single-use with a 7-day TTL; re-run the dry run |
|
| the error names every declared field; unknown keys are rejected, not dropped |
Errors across the collections surface
Status | When | Resolution |
|---|---|---|
| project scope missing | use the project-scoped path or |
|
| body reports |
| collection or record not found in this project | verify slug and project |
| confirm-token expired or rejected | re-run the dry run |
| schema validation failed | read |
| 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_ordermust be smaller than its child pages'sort_order, because the tree is assembled in one pass oversort_order. Sections first, then pages.
Navigation
GET /content/navigation/{location}— public read;locationisheader,footer, ordocs_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, optionalfolder,alt_text,caption).POST /dashboard/content/media/upload-batch— multiple files.PATCH /dashboard/content/media/{media_id}— updatealt_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 bydry_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
API Overview — base URL, auth, and the dry-run / confirm flow.
Design & Deploy — components, templates, themes, marketplace, deploy.
Public & SEO Surfaces — public reads, sitemap, llms.txt, feeds, help.
Pages — the block model these endpoints write and read.