Skip to main content

Gotchas & Best Practices

Hard-won lessons from building on SpiderIQ's Content Platform. Read this before you start — it'll save you hours.


Authentication

Bearer token format is three parts, colon-separated

Authorization: Bearer cli_abc123:sk_def456:secret_ghi789

Not just a token string — it's client_id:api_key:api_secret. All three are required.

Public vs Dashboard endpoints use different auth

PrefixAuthPurpose
GET /content/*No auth — uses X-Content-Domain headerRead-only, for frontends
POST/PATCH /dashboard/content/*Bearer token requiredContent management

If you get 404 on public endpoints, you're probably missing the X-Content-Domain header. If you get 401 on dashboard endpoints, check your Bearer token format.

PAT tokens expire

PAT tokens have an expiry date. Check ~/.spideriq/credentials.jsonexpiresAt. Request a new one before it expires.


Content Creation

Pages must be published before they appear

Creating a page puts it in draft status. You must call POST /dashboard/content/pages/{id}/publish before it's visible on the public API or rendered on the site.

The "home" slug is special

The homepage of every site is the page with slug: "home". If you create a page with a different slug, it won't appear at the root URL /.

Block id must be unique within a page

Every block in the blocks array needs a unique id string. Use any format — "hero-1", "block-abc", a UUID. Duplicate IDs cause rendering issues.

Custom fields are your friend

custom_fields is a JSONB catch-all on pages. Use it for:

  • Dynamic landing page placeholders (headline: "Welcome to {business}")
  • Feature flags (show_testimonials: true)
  • Template-specific config
  • Anything that doesn't fit the standard schema

Templates & Themes

Always read /content/help first

GET /api/v1/content/help?format=yaml

This 2,867-token YAML file has every block type, Liquid filter, template variable, and API endpoint. It's the single source of truth for what your agent can do.

Theme must be applied before deploy

Pages without a theme applied will render with no styling. Always:

  1. Create pages
  2. Apply theme (POST /dashboard/templates/apply-theme)
  3. Then deploy

Liquid templates use {{ }} for output, {% %} for logic

{{ page.title }}                              {# Output a value #}
{% if page.custom_fields.show_cta %} {# Conditional logic #}
{% for block in page.blocks %} {# Loop #}
{{ page.title | truncate_words: 10 }} {# Filter #}

Available Liquid filters

FilterExampleOutput
tiptap_html{{ post.body | tiptap_html }}Renders Tiptap JSON to HTML
date_relative{{ post.published_at | date_relative }}"2h ago"
reading_time{{ post.body | reading_time }}"5 min read"
money{{ 1234.5 | money: "USD" }}"$1,234.50"
img_url{{ url | img_url: "400x300" }}Cloudflare resized URL
md{{ text | md }}Markdown to HTML
slugify{{ "Hello World" | slugify }}"hello-world"

Components (Shadow DOM)

CSS is completely isolated — no Tailwind

Components render inside Declarative Shadow DOM. Parent page styles don't reach in, component styles don't leak out. This means:

  • No Tailwind utilitiesclass="text-white px-6" won't work
  • Write plain CSS inside the component's css field
  • Use CSS variables for theme integration: color: var(--primary)
  • Use @media queries for responsive behavior

Theme variables available in components

:host {
--primary: #eebf01; /* From site settings */
--font-body: system-ui; /* From site settings */
}

h1 { color: var(--primary); }

These are injected automatically — you don't need to set them.

Props are passed as Liquid variables

<!-- html_template field -->
<section>
<h1>{{ props.headline }}</h1>
<p>{{ props.description }}</p>
{% if props.show_cta %}
<a href="{{ props.cta_url }}">{{ props.cta_text }}</a>
{% endif %}
</section>

Tier 2: Interactive components with JS

Set the js field for scoped JavaScript. It runs inside the shadow root:

// js field — receives 'root' (shadowRoot) and 'props'
const btn = root.querySelector('.counter-btn');
let count = props.start_value || 0;
btn.textContent = count;
btn.addEventListener('click', () => {
count++;
btn.textContent = count;
});
warning

Use root.querySelector() not document.querySelector(). Your JS has no access to the parent page DOM.


Dynamic Landing Pages

The identifier must be a Google Place ID (for now)

The /lp/ route resolves leads by Google Place ID from the google_place_id column in your normalized data. Other identifier types (domain, email) are supported but Place ID is most common.

Salesperson slug must match config exactly

URL: /lp/wifi-proposal/ajay/0x47e...

The ajay part must match a key in your salespersons config:

{ "salespersons": { "ajay": { "name": "Ajay Verma", ... } } }

Case-sensitive. If it doesn't match, salesperson will be null in the template.

Use replace filter for placeholder substitution

{{ page.custom_fields.headline | replace: '{business}', lead.name | replace: '{city}', lead.city }}

This turns "Stop Losing Guests at {business} in {city}" into "Stop Losing Guests at Mario's Pizzeria in Paris".

Lead data requires normalized schemas

The IDAP resolve endpoint reads from norm_cli_* schemas, which are populated by the CRM sync cron after campaign jobs complete. If you just submitted a job, the lead data may not be available yet — wait for the job to complete and sync.


Deployment

Deploy takes 2-5 seconds

POST /dashboard/content/deploy uploads templates + config to Cloudflare KV and deploys the Liquid renderer Worker. It's fast.

Changes aren't live until you deploy

Creating/publishing pages updates the database. But the rendered site reads from Cloudflare KV. You must deploy for changes to appear on the live site.

Check deploy status after deploying

GET /api/v1/dashboard/content/deploy/status

Look for status: "completed". If "failed", check the error message.


IDAP Data Access

Always use include= to avoid N+1 queries

# Bad: 21 API calls for 20 businesses + their emails
GET /idap/businesses?limit=20
GET /idap/emails?business_id=1
GET /idap/emails?business_id=2
...

# Good: 1 API call
GET /idap/businesses?limit=20&include=emails

rejected leads are hidden by default

Leads flagged as rejected don't appear in list responses. To see them explicitly: ?flags=rejected.

Use since= for incremental sync

# First call: get everything
GET /idap/businesses?limit=500

# Later: only get changes since last check
GET /idap/businesses?since=2026-04-13T00:00:00Z&limit=500

Rate Limiting

100 requests/minute per client

If you hit the limit, you'll get HTTP 429. Check these headers:

  • X-RateLimit-Remaining — requests left in current window
  • X-RateLimit-Reset — Unix timestamp when the window resets

Use YAML format to reduce token usage

# JSON: ~800 tokens
GET /api/v1/idap/businesses/abc123

# YAML: ~400 tokens (50% savings)
GET /api/v1/idap/businesses/abc123?format=yaml

This matters when you're an AI agent processing hundreds of responses.