Merge Tags
Type {{ firstname }}, we fill it in per visitor.
SpiderPublish gives you a Mailchimp-style merge-tag vocabulary for dynamic landing pages. Instead of reaching into the raw lead object (lead.related.domains[0].company_vitals.industry — the old way), you write the same flat tokens you'd use in Mailchimp, HubSpot, ActiveCampaign, or SendGrid emails. {{ firstname }}, {{ company_name }}, {{ city }}, {{ industry }}, {{ logo }}, {{ email }} — about 40 singulars plus six arrays for {% for %} loops.
The data comes from each visitor's record in your client's CRM (the norm_cli_* schema), built up by your SpiderMaps + SpiderSite + SpiderVerify + SpiderCompanyData pipeline. You write one template; every visitor sees their own business.
When to use them
- Dynamic landing pages —
/lp/{page_slug}/{google_place_id}or/lp/{page_slug}/{salesperson}/{place_id}. One URL per lead, fully personalized. - Personalized outreach — drop a link into a cold email; every recipient lands on a page that says their business name back to them.
- Sales one-pagers — a page the AE shares in a live demo that's pre-populated with the prospect's data.
Merge tags only work on pages with template: "dynamic_landing". For normal marketing pages (template: "default" / "landing" / "blank"), the tags render as empty strings — they're null-safe, nothing breaks, they just don't bind.
Quickstart
# 1. Bind your CLI to the project (writes spideriq.json)
spideriq use my-client
# 2. Fetch the vocabulary once (MCP: content_get_variables)
curl https://spideriq.ai/api/v1/content/variables > variables.yaml
# Or via MCP in Claude Code / Cursor: just call content_get_variables — it's
# tagged "START HERE for personalized landing pages" in the tool catalog.
# 3. Create a page with template=dynamic_landing and merge tags in the body
spideriq content pages create \
--slug proposal \
--template dynamic_landing \
--title "Proposal for {{ company_name }}" \
--html-file ./proposal.html
# 4. Publish + deploy
spideriq content pages publish proposal
spideriq content deploy
# 5. Preview with the built-in demo fixture (Mario's Pizzeria, Miami Beach)
# — every tag populated, no real data needed
open https://your-client-domain.com/lp/proposal/demo
# 6. Real URL per lead — Google Place ID from any SpiderMaps run
open https://your-client-domain.com/lp/proposal/ChIJd8BlQ2BZwokRAFUEcm_qrcA
Where the data comes from
SpiderMaps (scrape) ─┐
SpiderSite (crawl + AI) ─┤
SpiderVerify (email check) ─┼──► norm_cli_* (per-client CRM)
SpiderCompanyData (registry) ─┘ │
▼
IDAP (GET /content/leads/resolve)
│
▼
buildMergeTags() — selection rules
│
▼
LiquidJS render — per visitor
│
▼
HTML response
Your scraping pipeline builds up a per-client CRM schema. When a visitor hits /lp/{slug}/{place_id}, the Liquid renderer resolves that Place ID to a business record via IDAP, runs it through a deterministic selection layer (buildMergeTags), and gives your template a flat dictionary of tags ready to bind.
Each merge tag has a deterministic selection rule — e.g. {{ email }} picks the best verified email by ranking deliverable > unknown > risky > catch_all, then highest score, then most recent verification. {{ firstname }} picks the top contact, preferring job titles that match owner/founder/CEO/director. Fallbacks are null-safe: {{ revenue }} on a business with no company-registry row renders as an empty string, not undefined. Full rules in the reference below.
The full reference
Spec version: 1.0.0 · Auto-generated from apps/liquid-renderer/merge-tags.spec.json — the same file the Liquid renderer imports. Drift is structurally impossible.
Naming rules (v1, committed):
- snake_case only (matches Mailchimp / HubSpot / ActiveCampaign / SendGrid convention).
- Singular = the one best value. Plural = the full list for
{% for %}loops. - Null-safe — every singular returns '' (empty string), every array returns [], when source data is missing.
- Additive — the raw IDAP shape is still exposed as
lead.*alongside these flat tags for power users.
Company
The business itself — name, branding, web presence.
| Tag | What it is | Example | Always present? |
|---|---|---|---|
{{ company_name }} | Business name (from Google Maps). | Mario's Pizzeria | ✅ |
{{ legal_name }} | Registered legal name (from company registry). Falls back to company_name. | Mario's Pizzeria LLC | ✅ |
{{ vat_number }} | VAT / tax-registration number (EU / UK companies). | — | — |
{{ registration_number }} | Company registration number (Companies House / SEC EDGAR / SunBiz / etc.). | L98000004231 | — |
{{ industry }} | Industry classification for the business. | Restaurants & Food Service | — |
{{ description }} | Short marketing description of the business. | Family-owned Neapolitan pizzeria serving wood-fired pies and fresh pasta since 1998. Featured in Miami New Times Top 10 for three consecutive years. | — |
{{ website }} | Primary website URL (includes the scheme). | https://mariospizzeria.com | — |
{{ domain }} | Website domain (no scheme, no path). | mariospizzeria.com | — |
{{ logo }} | Company logo image URL (transparent PNG/SVG when available). | https://mariospizzeria.com/wp-content/uploads/2020/06/logo.png | — |
{{ photo }} | Business hero photo URL (storefront, food, product shot). | https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=1200&q=80 | — |
{{ rating }} | Google Maps average rating (0.0-5.0). | 4.6 | — |
{{ reviews_count }} | Total number of Google Maps reviews. | 234 | — |
{{ lead_score }} | SpiderSite lead-quality score (0.0-1.0, higher = better-qualified prospect). | 0.87 | — |
{{ place_id }} | Google Maps Place ID (stable external identifier). | ChIJd8BlQ2BZwokRAFUEcm_qrcA | — |
How company tags pick a value when multiple exist
| Tag | Rule |
|---|---|
{{ legal_name }} | Use company_registry.name if available; else fall back to businesses.name. |
{{ industry }} | Prefer domains[0].company_vitals.industry; else businesses.categories[0]. |
{{ logo }} | Prefer domains[0].logo_url (found during SpiderSite crawl); else businesses.image_url. |
Contact
The top person at the business (owner / founder / exec) and how to reach them.
| Tag | What it is | Example | Always present? |
|---|---|---|---|
{{ firstname }} | First name of the top contact at the business. | Alessandro | — |
{{ lastname }} | Last name of the top contact at the business. | Romano | — |
{{ full_name }} | Full name of the top contact at the business. | Alessandro Romano | — |
{{ job_title }} | Job title of the top contact. | Owner & Head Chef | — |
{{ email }} | Best verified email for this business. | info@mariospizzeria.com | — |
{{ phone }} | Main phone number in E.164 format. | +13055551234 | — |
{{ mobile }} | Mobile phone number in E.164 format (for SMS, WhatsApp). | +13055559876 | — |
{{ linkedin_url }} | LinkedIn profile URL of the top contact. | https://linkedin.com/in/alessandro-romano-mariospizzeria | — |
How contact tags pick a value when multiple exist
| Tag | Rule |
|---|---|
{{ firstname }} | Prefer contacts whose position matches /ceo|founder|owner|president|director|head|chief|principal/i; else first contact. |
{{ lastname }} | Same picker as firstname. |
{{ full_name }} | Same picker as firstname. |
{{ job_title }} | Same picker as firstname. |
{{ email }} | Sort: status rank (deliverable > unknown > risky > catch_all), then highest score, then most recent last_verified_at. First result wins. |
{{ phone }} | Prefer businesses.phone_e164; else first phone in related.phones where valid=true. |
{{ mobile }} | First phone in related.phones with phone_type='mobile'; empty if none. |
{{ linkedin_url }} | Prefer top contact's linkedin_url; else first linkedin_profiles[0].linkedin_url. |
Location
Where the business is physically.
| Tag | What it is | Example | Always present? |
|---|---|---|---|
{{ address }} | Street address. | 2341 Collins Ave | — |
{{ city }} | City. | Miami Beach | — |
{{ region }} | State / region / province. | Florida | — |
{{ state }} | Alias of region (US-centric naming). | Florida | — |
{{ country }} | Country name. | United States | — |
{{ country_code }} | ISO 2-letter country code. | US | — |
{{ postal_code }} | Postal / ZIP code. | 33139 | — |
{{ zip }} | Alias of postal_code (US-centric naming). | 33139 | — |
How location tags pick a value when multiple exist
| Tag | Rule |
|---|---|
{{ city }} | Prefer businesses.city; else company_registry.city. |
{{ state }} | Identical to region. |
{{ zip }} | Identical to postal_code. |
Vitals
Size, age, revenue — signals for qualification.
| Tag | What it is | Example | Always present? |
|---|---|---|---|
{{ team_size }} | Number of employees. | 24 | — |
{{ founded }} | Year the business was founded. | 1998 | — |
{{ revenue }} | Annual revenue in the registry's reported currency (usually USD / EUR / GBP). | 2840000 | — |
How vitals tags pick a value when multiple exist
| Tag | Rule |
|---|---|
{{ team_size }} | Prefer domains[0].company_vitals.team_size; else company_registry.financials.employees. |
{{ founded }} | Prefer domains[0].company_vitals.founded; else extract year from company_registry.incorporation_date. |
Arrays (for {% for %} loops)
Full collections when you want to render multiple emails, phones, contacts, etc. Each iteration variable has the projected keys listed.
| Tag | What it is | Projection | Example use |
|---|---|---|---|
{{ emails }} | All discovered email addresses with verification status. Iterate for multi-email templates. | { address, status, score, deliverable } | {% for email in emails %}{{ email.address }} — {{ email.status }}{% endfor %} |
{{ phones }} | All discovered phones. Iterate for multi-phone templates (e.g. 'Call us or WhatsApp'). | { number, type, carrier, valid } | {% for phone in phones %}<a href="tel:{{ phone.number }}">{{ phone.number }} ({{ phone.type }})</a>{% endfor %} |
{{ contacts }} | All discovered contacts at the business. | { firstname, lastname, full_name, email, position, linkedin_url, photo } | {% for contact in contacts %}<div><img src="{{ contact.photo }}"> {{ contact.full_name }} — {{ contact.position }}</div>{% endfor %} |
{{ officers }} | Company officers from the registry (directors, members, managers). | raw JSONB: { name, role, appointed, resigned, nationality } | {% for officer in officers %}{{ officer.name }} — {{ officer.role }}{% endfor %} |
{{ categories }} | Business categories from Google Maps. | string[] | {{ categories | join: ' · ' }} |
{{ pain_points }} | Business pain points identified by SpiderSite AI analysis. | string[] | {% for pain in pain_points %}<li>{{ pain }}</li>{% endfor %} |
Example items (what each iteration variable looks like)
{
"emails": {
"address": "info@mariospizzeria.com",
"status": "deliverable",
"score": 0.97,
"deliverable": true
},
"phones": {
"number": "+13055551234",
"type": "fixed_line",
"carrier": "AT&T Mobility",
"valid": true
},
"contacts": {
"firstname": "Alessandro",
"lastname": "Romano",
"full_name": "Alessandro Romano",
"email": "mario@mariospizzeria.com",
"position": "Owner & Head Chef",
"linkedin_url": "https://linkedin.com/in/alessandro-romano-mariospizzeria",
"photo": "https://images.unsplash.com/photo-1566492031773-4f4e44671857?w=400&q=80"
},
"officers": {
"name": "Alessandro Romano",
"role": "Manager / Member",
"appointed": "1998-04-21",
"resigned": null,
"nationality": "Italian"
},
"categories": "Pizzeria",
"pain_points": "Manual catering quote process (currently phone + email back-and-forth)"
}
Example gallery
All four templates assume template: "dynamic_landing" on the page and real Google Place IDs in the URL. Preview any of them with /lp/{your-slug}/demo — the built-in Mario's Pizzeria fixture populates every field listed above.
1. Minimalist personal CTA
Keep it short, let the personalization do the work.
<section class="hero">
<h1>Hey {{ firstname }} at {{ company_name }},</h1>
<p>We noticed your {{ rating }}★ rating in {{ city }}. We think we can help.</p>
<a href="mailto:{{ email }}" class="cta">Get in touch</a>
</section>
2. Sales one-pager
Logo + industry + pain points identified by the SpiderSite AI crawl + a direct line to the decision maker.
<section class="header">
<img src="{{ logo }}" alt="{{ company_name }} logo" />
<div>
<h1>{{ company_name }}</h1>
<p>{{ industry }} · {{ team_size }} people · founded {{ founded }}</p>
</div>
</section>
<section class="pains">
<h2>We noticed three things about how {{ company_name }} operates:</h2>
<ul>
{% for pain in pain_points %}
<li>{{ pain }}</li>
{% endfor %}
</ul>
</section>
<section class="contact">
<p>Let's talk, {{ firstname }}.</p>
<p>Email: <a href="mailto:{{ email }}">{{ email }}</a></p>
<p>LinkedIn: <a href="{{ linkedin_url }}">{{ full_name }}</a></p>
</section>
3. Review-social-proof hero
Show the prospect their own rating back to them — classic "you're doing great, and we can help you do better."
<section class="hero">
<div class="stars">
{% if rating >= 4.5 %}★★★★★
{% elsif rating >= 3.5 %}★★★★
{% elsif rating >= 2.5 %}★★★
{% else %}★★{% endif %}
</div>
<h1>{{ rating }}★ across {{ reviews_count }} reviews</h1>
<p><strong>{{ company_name }}</strong> is already the top {{ industry | downcase }} in {{ city }}.</p>
<p>Here's how we help you keep it that way.</p>
</section>
4. Multi-email contact block
Sometimes you want to show every contact option, not just the "best" one. Use the {{ emails }} array.
<section class="contacts">
<h2>Here's every way to reach {{ company_name }}:</h2>
<ul>
{% for email in emails %}
<li>
<a href="mailto:{{ email.address }}">{{ email.address }}</a>
{% if email.deliverable %}✓ verified{% endif %}
<small>({{ email.status }})</small>
</li>
{% endfor %}
</ul>
<h3>Team at {{ company_name }}:</h3>
<ul>
{% for contact in contacts %}
<li>
{% if contact.photo %}<img src="{{ contact.photo }}" width="40" />{% endif %}
<strong>{{ contact.full_name }}</strong> — {{ contact.position }}
{% if contact.linkedin_url %}· <a href="{{ contact.linkedin_url }}">LinkedIn</a>{% endif %}
</li>
{% endfor %}
</ul>
</section>
Testing with the demo record
Every tenant's Liquid renderer ships with a built-in "Mario's Pizzeria" fixture. When the identifier in the URL is literally demo — or any URL adds ?preview=sample-lead — the renderer serves that fixture instead of doing an IDAP lookup. Every merge tag has a realistic populated value, so you can build and iterate on a template before any actual scraping has happened.
https://your-client-domain.com/lp/{your-page-slug}/demo
https://your-client-domain.com/lp/{your-page-slug}/?preview=sample-lead
The demo dataset:
- Business: Mario's Pizzeria, 2341 Collins Ave, Miami Beach, FL. 4.6★ / 234 reviews. Italian restaurant / pizzeria.
- Contacts: 3 people — Alessandro Romano (Owner & Head Chef), Giulia Bianchi (General Manager), Lucas Ferreira (Sous Chef).
- Emails: 4 addresses at all 4 verification states —
deliverable× 2,risky,catch_all— so you can exercise conditional branches. - Phones: fixed-line main + mobile.
- Domain: company_vitals JSON populated (industry: Restaurants & Food Service, team_size: 24, founded: 1998, tech_stack + pain_points), lead_score 0.87.
- Registry: Mario's Pizzeria LLC (Florida SunBiz), 3 officers, financials with revenue.
- LinkedIn: Alessandro's profile — experience, education, skills, certifications.
The demo preview is safe to share internally — all values are obviously the same "Mario's Pizzeria" dataset for every tenant. For client demos, use a real Place ID.
Null safety & fallbacks
Every singular merge tag returns an empty string (not null, not undefined) when the source data is missing. Every array returns []. Templates never throw.
{# Safe — renders cleanly even if rating is missing #}
<p>Rating: {{ rating | default: "not yet rated" }}</p>
{# Conditional — branches correctly because missing = "" = falsy in Liquid #}
{% if revenue %}
<p>Annual revenue: ${{ revenue }}</p>
{% endif %}
{# For-loops on empty arrays skip the block entirely #}
{% for pain in pain_points %}
<li>{{ pain }}</li>
{% else %}
<p>No pain points discovered yet — run a deeper SpiderSite crawl.</p>
{% endfor %}
Some tags always populate (business name, legal name — they fall back to each other). Others only populate when the upstream pipeline has data — revenue, vat_number, team_size, etc. The reference table above marks each.
Power user: the raw lead object
Merge tags are a flat, discoverable surface over the full IDAP lead response. If you need a field that isn't surfaced as a merge tag — say, domains[0].company_vitals.tech_stack[] — the raw nested shape is still in scope:
{# Use the merge tag for common cases #}
<h1>{{ company_name }}</h1>
{# Drop into lead.* for anything off the beaten path #}
{% for tech in lead.related.domains[0].company_vitals.tech_stack %}
<span class="tech-pill">{{ tech }}</span>
{% endfor %}
Both surfaces are always available — merge tags are additive, not a replacement.
Discovering merge tags programmatically
If you're building or configuring SpiderPublish via an AI coding agent (Claude Code, Cursor, Windsurf), fetch this same vocabulary from the API:
- REST:
GET https://spideriq.ai/api/v1/content/variables?format=yaml— the document this page is built from, plus examples and selection rules in a single YAML payload. - MCP: call the
content_get_variablestool — shipped in@spideriq/mcp@0.8.3+. Flagged "START HERE for personalized landing pages" in the tool catalog so weak LLMs pick it up before anything else. - CLI (if bound to a project):
spideriq content variables(coming in the next minor release).
Related
- Tutorial: Personalized Landing Page — end-to-end walkthrough that uses these tags.
- Sessions —
spideriq useand the session binding required before any CLI call. - Deploy Safely — the preview → confirm flow every destructive operation goes through.
- Agents reference — the full MCP tool surface for AI coding agents.