Lead Capture
The lead capture system in src/lead.ts handles form submissions from marketing websites and enriches them with behavioral intelligence before forwarding to CRM.
Flow
- Blocked IP check — The request IP is checked against the BlockedIPs table. If the IP is blocked, the submission is not stored in Leads or LeadSubmissions and is not sent to Make.com. It is recorded in BlockedIPLeads for audit, and the client receives the same success/redirect response. See IP Blocking.
- Parse — Accepts JSON, form-data, multipart, and text/plain request bodies
- Extract — Pulls lead fields: name, email, phone, project, location, team
- Enrich — Adds geolocation from Cloudflare headers (country, city, timezone, region)
- Detect — Checks for bot submissions using honeypot signal (
__pg_hp) whenBOT_LEAD_DETECTIONis enabled. Honeypot leads are logged only — not written to D1, KV, Analytics Engine, or sent to Make.com. - Score — Looks up visitor's tracking session for engagement scoring (see Lead Scoring)
- Assess suspicion — Calculates fraud/suspicion score from honeypot, time-to-submit, and other signals (see Fraud Detection)
- Link projects — Finds other projects this visitor previously inquired about (see Related Projects)
- Resolve primary lead — Finds existing lead by project + identity (
visitor_id> email > phone > IP) - Store submission history — Writes every form submit to
LeadSubmissions(CTA/page history) - Forward — Sends to Make.com webhook for new primary leads, or for re-engage (same user submits again after
LEAD_REENGAGE_HOURS, default 24) - Store — Writes/updates canonical lead in D1
Leadstable - Redirect — Returns 301 redirect to the source domain's thank-you page
Lead Payload
Every lead sent to the webhook and stored in D1 includes:
| Category | Fields |
|---|---|
| Contact | first_name, phone_mobile, email, email1 (legacy compatibility), country_code |
| Project | project_name, user_location, lead_source, source_team |
| Geo | user_country, user_city, user_timezone, user_region |
| Scoring | engagement_score, engagement_label, session_duration_seconds, pages_visited, return_visits |
| Fraud | suspicion_score, suspicion_reasons, time_to_submit, form_submit_count |
| Identity | visitor_id, session_id, vpn_suspected |
| Context | related_projects, refered_by, searched_keyword, user_agent, user_ip |
| Status | is_duplicate, is_bot_lead, test_lead, dev_environment |
| Mapping (for CRM callback) | lead_public_id, lead_submission_public_id (UUIDs, preferred for webhooks/URLs), and lead_id, lead_submission_id (integer IDs, legacy) — so Make.com can call back with our ID + CRM lead ID to store the mapping. Prefer UUIDs for public communication. |
| Make.com link | make_send_public_id — UUID sent so Make.com can call make-callback with the execution URL. |
For a full sample payload (what Make.com receives), see Lead Capture API – Payload sent to Make.com webhook.
Storage Model
Leadsstores one canonical row per user+project (primary lead record).ContactPhonestores the formatted phone (e.g.+91-9999999999) fromformatPhoneWithCountryCode, so the dashboard and CRM see the same value.LeadSubmissionsstores every submit attempt (including repeated submits from different CTAs/pages).ContactPhonehere is also the formatted phone. Each row includesPayloadJson(raw form body at submit time) so the dashboard can show exactly what was submitted in each follow-up. For using PayloadJson as the source for submission-level analytics, see PayloadJson — submission analytics store.
Dashboard
In the dashboard Leads page you can view a lead in two ways:
- Side panel — Click a row (or a kanban card) to open the detail panel on the right. Use "Full details" to open the full-page view.
- Full details page (
/leads/:id) — Click the lead name in the table or "Full details" in the side panel. This page gives maximum space:- Primary lead summary — Contact info, project, scores, status, CRM link, suspicion reasons, related projects.
- All submissions as cards — Each form submission (primary and follow-ups) is shown in its own card with full details: name, email, phone, project, source, CTA, and optional message. Use this to see how lead data changed across multiple submits (e.g. corrected phone number or name).
- Activity timeline — Combined submissions and sessions with optional session replay links.
Duplicate handling (same user, same project)
When the same user submits the form again for the same project, the system treats it as one primary lead and attaches the new submit as another submission.
Identity matching (in order): An existing lead for that project is found by:
- Visitor ID — same browser/session (website forms with tracker)
- Email — same email (works for website and Facebook/API leads)
- Phone — same formatted phone (with country code)
- IP — same client IP (website; raw IP used for lookup)
Behavior:
- No new row in
Leads— The existing lead row is reused;lead_idandlead_public_idstay the same. - New row in
LeadSubmissions— Every submit is recorded (CTA, page, timestamp, payload), so you keep full history. - Webhook (Make.com):
- Within 30 minutes of a previous submit for that lead → not sent (duplicate window).
- Within
LEAD_REENGAGE_HOURS(default 24h) → not sent (avoids CRM noise). - After
LEAD_REENGAGE_HOURS→ sent again (re-engage) so CRM can treat it as a follow-up.
Facebook / JustLead leads (same project): The same logic applies. Facebook leads usually have no visitor_id; duplicate detection uses email and phone. So if the same person submitted on the website and later via Facebook Lead Ads for the same project, they are matched by email/phone, linked to the same primary lead, and the new submission is stored in LeadSubmissions. Re-engage rules (24h default) apply the same way.
Configuration
| Variable | Default | Description |
|---|---|---|
ENVIRONMENT | — | development or production |
BOT_LEAD_DETECTION | true | Enable bot handling using honeypot detection (__pg_hp) |
LEAD_REENGAGE_HOURS | 24 | When the same user (same project + identity) submits again after this many hours, the lead is sent to Make.com again (re-engage). Set to 0 to disable. |
Webhook Integration
Leads are forwarded to Make.com for CRM integration:
| Webhook | Trigger |
|---|---|
| Make.com | Production non-bot leads: new (non-duplicate) leads, or re-engage (duplicate who last submitted ≥ LEAD_REENGAGE_HOURS ago, default 24h) |
Duplicate re-engage — If the same user (same project + identity) fills the form again after the configured number of hours (LEAD_REENGAGE_HOURS, default 24), the lead is sent to Make.com again so CRM can treat it as a re-engagement. Set LEAD_REENGAGE_HOURS=0 to never send duplicates to Make.com.
Honeypot (bot) leads — When BOT_LEAD_DETECTION is enabled and the honeypot field (__pg_hp) is filled, the submission is logged only. It is not written to D1 (Leads or LeadSubmissions), KV, or Analytics Engine, and is not sent to any Make.com webhook. This avoids flooding tables and pipelines with bot traffic.
Retry Strategy
Webhook delivery uses exponential backoff retry via src/lib/webhook.ts:
- Max retries: 3 attempts
- Backoff: 1s → 2s → 4s between attempts
- Timeout: 10s per request
- 5xx responses: Retried (server error, likely transient)
- 4xx responses: Not retried (client error, won't resolve on retry)
- Network errors: Retried (DNS failure, connection reset, timeout)
Resilience: leads still sent when D1 or Analytics Engine fail
So that leads are never dropped when storage has issues:
- D1 failure (e.g. DB unavailable, insert error): The Worker still sends the lead to the Make.com webhook so the CRM receives it. The request returns 200 and the form redirect works. A structured log event
lead.persistence_failedis emitted and a summary is stored in KV (key prefixlead_fail_d1:, TTL 7 days) so admins can see that persistence failed and retry or fix D1. - Analytics Engine write failure: Writing to the Analytics Engine dataset is best-effort. If the write throws, the Worker logs
lead.analytics_engine_write_failedbut does not block; the lead remains saved in D1 and the Make.com webhook is still sent. - KV: Not on the critical path for lead capture; rate limits and tracking use KV but lead submission and webhook do not depend on it.
You can alert on lead.persistence_failed and lead.analytics_engine_write_failed in your log pipeline to detect storage issues.
Dead Letter Queue
On final failure (all retries exhausted), the payload is stored in KV:
- Key:
dead_letter:{timestamp}:{random_id} - TTL: 7 days
- Contents: webhook URL, full payload, error message, attempt count, failure timestamp
Leads are always persisted to D1 first, so the dead letter queue is a safety net for webhook delivery only — no data is lost even if the webhook permanently fails.
Future: Queue-based pipeline (planning)
For a design that uses Cloudflare Queues so the form handler only enqueues and a consumer performs D1, Make.com, and Analytics Engine (with a Dead Letter Queue for failures), see Planning: Lead processing with Cloudflare Queues. That document is for future implementation and is not in use today.