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
| Prefix | Auth | Purpose |
|---|---|---|
GET /content/* | No auth — uses X-Content-Domain header | Read-only, for frontends |
POST/PATCH /dashboard/content/* | Bearer token required | Content 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.json → expiresAt. 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:
- Create pages
- Apply theme (
POST /dashboard/templates/apply-theme) - 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
| Filter | Example | Output |
|---|---|---|
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 utilities —
class="text-white px-6"won't work - Write plain CSS inside the component's
cssfield - Use CSS variables for theme integration:
color: var(--primary) - Use
@mediaqueries 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;
});
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 windowX-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.