The CRM Trap
Most small businesses that try local lead generation hit the same wall: they buy a CRM (HubSpot, Salesforce, Pipedrive), spend weeks setting it up, and then discover they're paying $50/month for a database that stores emails they never follow up on.
AiSalonHub took a different approach. Instead of adding a third-party CRM to the stack, we built the entire sales pipeline — from lead capture to warm outreach — on Cloudflare Workers, D1, and cron triggers. Zero monthly subscriptions, zero API limits, zero vendor lock-in.
This post walks through the architecture behind AiSalonHub's automated outreach system: how it captures leads from the comparison engine, scores them, and sends personalized email sequences — all running on the same Workers infrastructure as the CMS itself.
Architecture Overview
```
[Comparison Page CTA]
→ Lead submits email
→ Worker handles form POST
→ Write lead to D1 `leads` table
→ Enqueue in `outreach_queue` (D1 table)
→ Cron job (every 6h) processes queue
→ Lead scored by engagement signals
→ Email sent via SMTP or API
→ Activity logged back to D1
```
Three core components make this work:
1. The Leads Table (D1)
A single D1 table replaces an entire CRM:
```sql
CREATE TABLE leads (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
source TEXT, -- which comparison page
category TEXT, -- booking, POS, marketing
score INTEGER DEFAULT 0,
status TEXT DEFAULT 'new',
created_at TEXT,
last_contacted_at TEXT,
notes TEXT
);
CREATE TABLE outreach_log (
id TEXT PRIMARY KEY,
lead_id TEXT,
action TEXT, -- email_sent, email_opened, clicked
timestamp TEXT,
details TEXT
);
```
Two tables. That's the entire CRM schema. No migrations, no ORM, no webhook configurations. Just SQL.
2. The Lead Capture Worker
When a user submits their email on a comparison page, a Cloudflare Worker (running the EmDash site) handles the POST request. It doesn't redirect to an external form service or fire a webhook to Zapier. It writes directly to D1:
```typescript
// Simplified: handle lead capture form
async function handleLeadSubmission(request: Request, env: Env) {
const formData = await request.formData();
const email = formData.get('email');
const source = formData.get('source');
const category = formData.get('category');
const leadId = generateId(); // nanoid-style
const now = new Date().toISOString();
await env.DB.prepare(
`INSERT INTO leads (id, email, source, category, score, status, created_at)
VALUES (?, ?, ?, ?, 10, 'new', ?)`
).bind(leadId, email, source, category, now).run();
return new Response(JSON.stringify({ success: true, leadId }), {
headers: { 'Content-Type': 'application/json' }
});
}
```
Initial score of 10 means this lead came from a comparison page (high intent). Leads from blog posts start at 5. This scoring is how the system prioritizes follow-ups.
3. The Outreach Cron (Cron Triggers)
Cloudflare Workers cron triggers run every 6 hours and process the outreach queue:
```typescript
export default {
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
// Fetch leads that haven't been contacted in 3+ days
const { results } = await env.DB.prepare(
`SELECT * FROM leads
WHERE status = 'new'
AND (last_contacted_at IS NULL
OR datetime(last_contacted_at) < datetime('now', '-3 days'))
ORDER BY score DESC
LIMIT 10`
).all();
for (const lead of results) {
await sendFollowUpEmail(lead, env);
await env.DB.prepare(
`UPDATE leads SET status = 'contacted', last_contacted_at = ? WHERE id = ?`
).bind(new Date().toISOString(), lead.id).run();
}
}
};
```
Scoring & Sequencing
Leads aren't all equal. AiSalonHub's scoring model assigns points based on behavior:
| Signal | Score | Why |
|--------|-------|-----|
| Submitted email from comparison | +10 | High purchase intent |
| Downloaded comparison PDF | +15 | Engaged with detailed content |
| Visited 3+ pages | +5 | Exploring multiple options |
| Clicked "Get Started" CTA | +25 | Ready to buy |
| Opened follow-up email | +3 | Still interested |
Leads with score 20+ get a personal email from the team. Leads with score 10-19 get an automated sequence. Leads below 10 stay in the queue until they engage again.
Email Sequence Template
The automated sequence is simple — three emails over two weeks:
1. **Day 1:** "Here's the full comparison guide you requested" (with link to a downloadable PDF hosted on R2)
2. **Day 4:** "Three things salon owners wish they knew before choosing booking software" (value-add content)
3. **Day 10:** "Still comparing? Here's a 5-minute checklist" (low-pressure CTA)
Each email is a Worker-triggered API call to a transactional email service (Resend, Mailgun, or SMTP). The template is stored in D1 or R2 as plain markdown, converted to HTML at send time.
Why Workers > CRM
| Dimension | Traditional CRM | Worker-Based Pipeline |
|-----------|----------------|----------------------|
| Monthly cost | $50-200 | $0 (included in Workers plan) |
| Setup time | Days to weeks | Hours |
| Data residency | Vendor's cloud | Your D1 database |
| Customization | API limits, rate caps | Full SQL, arbitrary logic |
| Integration | Webhooks, Zapier | Direct function calls |
For a focused local lead gen operation like AiSalonHub, a full CRM is overkill. You don't need deal stages, pipeline views, or team dashboards. You need: capture, score, send, log. That's four operations — and D1 + Workers handles all of them.
The Hybrid Dev+Marketing Principle
This is the core of the Hybrid approach: the engineering architecture IS the marketing infrastructure. There's no separate "martech stack" that the marketing team manages. The Workers data pipeline that serves CMS pages is the same pipeline that scores leads and sends emails. Every Worker function pulls double duty.
When we add a new comparison page, we're simultaneously adding a new lead capture source, a new keyword target, and a new outreach trigger. One deploy, three outcomes.
AiSalonHub's sales pipeline isn't fancy. It's just two D1 tables, three cron queries, and a handful of Worker endpoints. But it runs at $0/month, captures every lead automatically, and scales to thousands without touching a config file.