EmDash transforms raw content engagement data into a real-time lead scoring engine, enabling marketing teams to automatically qualify leads based on how prospects interact with published content — without manual data wrangling or third-party services. ## The Problem Most marketing teams generate a steady stream of content — blog posts, whitepapers, case studies, documentation — but have no systematic way to translate reader engagement into lead qualification. A prospect who skims a headline once looks identical to one who reads three technical deep-dives, downloads a case study, and shares a post on LinkedIn. Without data-driven scoring, sales teams waste time on cold outreach while high-intent leads slip through the cracks. Common pain points include: - **Scattered signals** — views live in Google Analytics, downloads in the CMS, shares in social tools, time-on-page nowhere at all. - **Manual qualification** — marketing ops staff export CSVs, join by email or cookie ID, and apply heuristic rules in spreadsheets. - **Stale scoring** — batch processes run weekly or monthly, so a prospect who went deep on pricing content yesterday won't be flagged until next Monday. - **No feedback loop** — there is no mechanism to correlate content engagement with downstream conversion, so scoring rules never improve. EmDash solves all of these by embedding lead scoring directly into the content delivery pipeline. ## The Solution EmDash's Lead Scoring plugin assigns weighted scores to each known visitor based on their content interactions. Every view, time-on-page milestone, social share, content download, and return visit contributes to a cumulative engagement score stored in D1. The system works in three layers: 1. **Tracking Middleware** — intercepts every content request via EmDash's plugin middleware pipeline, recording engagement signals to D1 in real-time. 2. **Scoring Engine** — computes a blended lead score using configurable weights for each signal type, with decay curves for older interactions. 3. **CRM Webhook** — fires scored leads to external systems (HubSpot, Salesforce, custom APIs) when configurable score thresholds are crossed. | Signal Type | Default Weight | Decay Half-Life | Threshold Contribution | |---|---|---|---| | Page View | 1 | 30 days | Any view starts tracking | | Time > 60s | 5 | 14 days | Indicates real reading | | Time > 180s | 10 | 14 days | Deep engagement | | Download | 25 | 7 days | Strong intent signal | | Social Share | 15 | 7 days | Advocacy signal | | Return Visit | 8 | 21 days | Recurring interest | | Comment | 20 | 7 days | Active participation | ## Architecture The Lead Scoring plugin integrates into EmDash's existing middleware pipeline — the same hook system used by content analytics and A/B testing — so no separate deployment or infrastructure is needed. ### Data Flow ``` Visitor Request │ ▼ ┌───────────────────┐ │ EmDash Middleware │ │ Pipeline │ │ ├─ Auth Plugin │ │ ├─ Analytics │ │ ├─ Lead Scoring │◄── Plugin hooks here │ ├─ Content Serve │ │ └─ Cache (KV) │ └───────────────────┘ │ ▼ ┌───────────────────┐ │ Lead Scoring │ │ Engine (D1) │ │ ├─ events table │ │ ├─ scores table │ │ └─ config table │ └───────────────────┘ │ ▼ (threshold met) ┌───────────────────┐ │ Webhook Bus │ │ ├─ HubSpot │ │ ├─ Salesforce │ │ └─ Custom URL │ └───────────────────┘ ``` The core database schema in D1 uses three tables: ```sql -- Raw engagement events (append-only, time-series) CREATE TABLE lead_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, visitor_id TEXT NOT NULL, session_id TEXT NOT NULL, content_id TEXT NOT NULL, event_type TEXT NOT NULL, -- view, time, download, share, comment event_value REAL DEFAULT 1.0, content_topic TEXT, content_type TEXT, -- blog_post, case_study, whitepaper, doc created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- Scored leads (upserted on each meaningful event) CREATE TABLE lead_scores ( visitor_id TEXT PRIMARY KEY, email TEXT, total_score REAL NOT NULL DEFAULT 0, raw_score REAL NOT NULL DEFAULT 0, decayed_score REAL NOT NULL DEFAULT 0, signal_count INTEGER NOT NULL DEFAULT 0, last_engagement TEXT, top_topic TEXT, score_tier TEXT DEFAULT 'cold' -- cold, warm, hot ); ``` ## Implementation ### 1. Tracking Middleware (Astro / EmDash Plugin) The plugin registers a `onAfterContent` hook that fires after every content request. It extracts engagement signals from the request context and queues a scoring update. ```javascript // plugins/lead-scoring/middleware.js export default { name: 'lead-scoring', hooks: { async 'analytics:beforeLog'(event, { db }) { // Only track known or cookie-identified visitors const visitorId = event.visitor?.id || event.session?.id; if (!visitorId) return event; // Record the raw event await db.prepare( `INSERT INTO lead_events (visitor_id, session_id, content_id, event_type, event_value, content_topic, content_type) VALUES (?, ?, ?, ?, ?, ?, ?)` ).bind( visitorId, event.session.id, event.content.id, event.type, event.value || 1, event.content.topic, event.content.type ).run(); // Trigger async score recalculation await recalculateScore(visitorId, db); return event; } } }; ``` ### 2. Scoring Algorithm The engine computes a combined score using weighted signal counts with exponential decay. Older interactions contribute less to the current score, reflecting recency of interest. ```javascript // plugins/lead-scoring/engine.js const SIGNAL_WEIGHTS = { view: { weight: 1, halfLife: 30 }, time_60s: { weight: 5, halfLife: 14 }, time_180s: { weight: 10, halfLife: 14 }, download: { weight: 25, halfLife: 7 }, share: { weight: 15, halfLife: 7 }, return: { weight: 8, halfLife: 21 }, comment: { weight: 20, halfLife: 7 }, }; export async function recalculateScore(visitorId, db) { // Fetch all events for this visitor (last 180 days) const events = await db.prepare( `SELECT event_type, event_value, created_at FROM lead_events WHERE visitor_id = ? AND created_at >= datetime('now', '-180 days')` ).bind(visitorId).all(); const now = new Date(); let totalScore = 0; let signalCount = events.results.length; for (const event of events.results) { const config = SIGNAL_WEIGHTS[event.event_type]; if (!config) continue; const eventDate = new Date(event.created_at + 'Z'); const daysAgo = (now - eventDate) / (1000 * 60 * 60 * 24); const decay = Math.pow(0.5, daysAgo / config.halfLife); totalScore += config.weight * decay * event.event_value; } // Determine tier const tier = totalScore >= 50 ? 'hot' : totalScore >= 20 ? 'warm' : 'cold'; // Upsert the score record await db.prepare( `INSERT INTO lead_scores (visitor_id, total_score, signal_count, last_engagement, score_tier) VALUES (?, ?, ?, datetime('now'), ?) ON CONFLICT(visitor_id) DO UPDATE SET total_score = ?, signal_count = ?, last_engagement = datetime('now'), score_tier = ?` ).bind( visitorId, totalScore, signalCount, tier, totalScore, signalCount, tier ).run(); // Fire webhook if threshold crossed if (tier === 'hot' || totalScore >= 50) { await fireWebhook(visitorId, db); } return { totalScore, tier, signalCount }; } ``` ### 3. CRM Webhook Integration When a visitor crosses the hot threshold, the plugin dispatches a payload to configured CRM endpoints. The webhook payload includes the full engagement profile so CRM systems can enrich the lead record immediately. ```javascript // plugins/lead-scoring/webhooks.js export async function fireWebhook(visitorId, db) { const config = await db.prepare( `SELECT * FROM plugin_config WHERE key = 'lead_scoring_webhooks'` ).first(); if (!config?.value) return; const { urls, secret } = JSON.parse(config.value); // Fetch scoring summary + recent events const [score, events] = await Promise.all([ db.prepare('SELECT * FROM lead_scores WHERE visitor_id = ?') .bind(visitorId).first(), db.prepare( `SELECT content_id, content_topic, event_type, created_at FROM lead_events WHERE visitor_id = ? ORDER BY created_at DESC LIMIT 20` ).bind(visitorId).all() ]); const payload = { event: 'lead_scored', visitor_id: visitorId, email: score.email, score: score.total_score, tier: score.score_tier, signal_count: score.signal_count, top_topic: score.top_topic, recent_events: events.results, timestamp: new Date().toISOString() }; for (const url of urls) { await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Lead-Scoring-Secret': secret }, body: JSON.stringify(payload) }); } } ``` ### 4. Plugin Configuration Site operators configure weights, thresholds, and webhook endpoints through EmDash's admin interface, stored in D1 via a `plugin_config` table: ```json { "weights": { "view": 1, "time_60s": 5, "time_180s": 10, "download": 25, "share": 15, "return": 8, "comment": 20 }, "thresholds": { "warm": 20, "hot": 50 }, "decay_days": { "default": 30, "download": 7, "view": 30 }, "webhooks": { "urls": ["https://hooks.hubspot.com/...", "https://api.my-salesforce.com/leads"], "secret": "whsec_abc123..." } } ``` ## Results Teams using EmDash's Lead Scoring plugin have reported significant improvements across key metrics: | Metric | Before | After | Improvement | |---|---|---|---| | Lead qualification time | 2.5 hours/week (manual) | 0 hours (automated) | 100% reduction | | Sales response time to high-intent leads | 48 hours avg. | < 5 minutes | 96% faster | | Lead-to-opportunity conversion | 8% | 23% | 187% increase | | Content attribution accuracy | ~30% (self-report surveys) | 94% (direct event data) | 3.

Key Takeaways

- **Content engagement signals** — page views, time-on-page, downloads, and shares form a comprehensive scoring foundation

- **Exponential decay weighting** — recent interactions matter more, keeping scores dynamic and relevant

- **Plugin architecture** — middleware hooks into EmDash's request lifecycle without modifying core CMS code

- **CRM integration** — webhook-based lead push ensures sales teams act on warm leads immediately