Skip to main content

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.

TagWhat it isExampleAlways 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
TagRule
{{ 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.

TagWhat it isExampleAlways 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
TagRule
{{ 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.

TagWhat it isExampleAlways 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
TagRule
{{ city }}Prefer businesses.city; else company_registry.city.
{{ state }}Identical to region.
{{ zip }}Identical to postal_code.

Vitals

Size, age, revenue — signals for qualification.

TagWhat it isExampleAlways 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
TagRule
{{ 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.

TagWhat it isProjectionExample 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)"
}

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_variables tool — 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).