Lead capture and delivery
Verified against this repository on 2026-07-30. Variable **names** only — never paste a URL, token, or secret value here.
Lead capture and delivery
Verified against this repository on 2026-07-30. Variable names only — never paste a URL, token, or secret value here.
This is the conversion half of the offer: ../sales/pitch-script.md sells it as a "digital receptionist", and Step 2 of the demo shows a lead arriving live (../sales/demo-framework.md). Decision record: ../decisions/0004-single-crm-webhook-vs-hosted-crm.md.
Flow
MCP tool request_dev_consultation ──┐
REST POST /api/leads/submit ────┼──▶ lib/leads.ts submitConsultationLead()
│ │
│ ├─ parseConsultationLead() — Zod validation
│ ├─ client_id (body / X-Client-ID / X-API-Key; default "default")
│ ├─ lead_id = "jsg-" + randomUUID()
│ ├─ received_at = ISO-8601
│ │
│ ▼
│ resolveClientWebhookUrl(client_id)
│ ├─ CLIENT_WEBHOOK_MAP[client_id]
│ ├─ else CLIENT_WEBHOOK_<CLIENT_ID>
│ ├─ else CRM_WEBHOOK_URL
│ ├─ URL ──▶ POST JSON ──▶ Apps Script ──▶ Sheet (incl. client_id col)
│ └─ none ──▶ console.info only (local / unconfigured)
Dogfood: use client_id: "jsglobtech" in demos so the Sheet proves tenancy (../sales/tenant-demo-checklist.md).
POST /api/contact is a separate on-site form path with a different payload and does not flow through submitConsultationLead().
REST surface: POST /api/leads/submit
| Item | Detail |
|---|---|
| Route file | app/api/leads/submit/route.ts |
| Runtime | nodejs |
| Methods | POST, OPTIONS |
| CORS | Access-Control-Allow-Origin: *, methods POST, OPTIONS, headers Content-Type, Authorization, X-Client-ID, X-API-Key |
| Rate Limiting | Enforced via lib/rate-limit.ts (10 req/min per IP). Returns 429 with X-RateLimit-* headers. |
| Multi-Tenancy | Supports client_id parameter or X-Client-ID / X-API-Key headers for dynamic tenant webhook routing. |
| Recorded source | "openapi" |
| Spec | lib/openapi-spec.ts, operationId submitDevConsultationLead; served at /docs/openapi.json and /api/openapi.json with CORS * |
Responses
| Status | Body error |
When |
|---|---|---|
200 |
— | { ok: true, lead_id, client_id, message, project_type, received_at } |
400 |
invalid_json |
Body was not parseable JSON |
400 |
validation_error |
Zod failure; includes issues[] with path and message |
429 |
rate_limit_exceeded |
Rate limit exceeded (10 requests/min per IP) |
502 |
webhook_error |
Lead validated but CRM delivery failed; response suggests emailing getintouch@jsglobtech.com |
500 |
internal_error |
Anything else; logged as [leads/submit] unexpected error |
Note the deliberate asymmetry: a webhook failure is a 502, not a 500, because the caller's payload was fine. Agents can distinguish "fix your input" from "retry later".
Validation contract (lib/leads.ts)
consultationLeadSchema, shared by both surfaces:
| Field | Rule |
|---|---|
client_id |
Optional trimmed string (max 100 chars), defaults to "default". Used for multi-tenant webhook routing. |
client_name |
Trimmed string, min 2 chars |
client_email_or_phone |
Trimmed, min 5, must match an email pattern or a phone pattern (^[+]?[\d\s().-]{7,}$) |
project_type |
Enum PROJECT_TYPES: flutter_mobile_app, web_application, geo_ai_integration |
budget_range_usd |
Positive finite number or non-empty trimmed string |
requirements_notes |
Optional, trimmed, max 5000 chars, defaults to "" |
Error types: LeadValidationError (carries issues: z.ZodIssue[]) and LeadWebhookError (carries the upstream status, message truncated to 500 chars).
LEAD_SUCCESS_MESSAGE is the single user-facing string returned by both surfaces:
"Success! JSGlobtech technical team notified. We will reach out on WhatsApp/Email within 1 hour."
This is a commitment made to every lead by an AI agent, unattended. Treat the 1-hour follow-up as an operational obligation — see ../operations/client-engagement-sop.md.
Delivery payload
Sent to CRM_WEBHOOK_URL as JSON with Content-Type: application/json and User-Agent: JSGlobtech-LeadCapture/1.0:
| Key | Value |
|---|---|
lead_id |
jsg-<uuid> (randomUUID() from node:crypto) |
client_id |
Tenant key (effective ID after header/body resolution) |
source |
"mcp" | "openapi" | "api" |
agency |
"JSGlobtech" |
location |
"Dubai, UAE" |
| …validated lead fields | client_name, client_email_or_phone, project_type, budget_range_usd, requirements_notes |
received_at |
ISO-8601 timestamp |
Outbound request also sets header X-Client-ID to the effective tenant id.
Any non-2xx response from the webhook raises LeadWebhookError. There is no retry and no queue — a webhook outage means the lead is lost beyond what is in the server log.
Destination
| Item | Detail |
|---|---|
| Env vars | CRM_WEBHOOK_URL (default), optional CLIENT_WEBHOOK_<ID>, optional CLIENT_WEBHOOK_MAP — names only; never record values here |
| Recommended target | Google Apps Script Web App appending rows to a Google Sheet |
| Script source | scripts/google-apps-script/Code.gs |
| Setup guide | scripts/google-apps-script/SETUP.md |
| Deploy settings | Web app, Execute as Me, Who has access Anyone |
| Sheet columns | lead_id, date, time, client_id, source, contact/project fields, agency, location (Asia/Dubai date/time) |
| When unset | Lead is validated and logged via console.info — local dev works with no webhook. Note: HTTP still returns 200 ok in that mode, so always verify the Sheet mid-demo. |
The deployed Web App URL is effectively a write credential: anyone holding it can append rows. Keep it in .env.local and host environment settings only.
⚠ WhatsApp is not a wired channel
The playbook and pitch both promise that lead details "land directly in your WhatsApp or CRM instantly" (§3 Tier 2, §4 pitch script), and GeoSchema.tsx lists "CRM/WhatsApp webhook lead routing" as a feature.
What is actually implemented is one generic outbound webhook. It can be pointed at any receiver — including something that relays to WhatsApp — but no WhatsApp integration exists in this repository. LEAD_SUCCESS_MESSAGE only promises that a human will follow up on WhatsApp or email.
When selling this: the webhook can target whatever the client already uses, and WhatsApp relay is scoped work. Do not describe it as pre-built.
Known gaps
- No retry, queue, or dead-letter path. A transient webhook failure loses the lead.
- No persistence. No database; the Sheet is the only record.
../architecture/README.mdconfirms no ORM or DB. - No rate limiting or spam protection on a public, unauthenticated, CORS-
*endpoint. A single script can flood the Sheet. - No vertical field.
project_typehas three engineering values and no notion of the four target verticals in../strategy/target-markets.md; GEO retainer leads from a clinic and from a car-rental company both arrive asgeo_ai_integration. - No notification on arrival. Nothing alerts the owner that a lead landed, yet the response promises a reply within an hour.
Security: REST
/api/leads/submitand/api/contactnow have in-memory rate limits (see ADR../decisions/0008-multi-tenant-lead-routing-and-rate-limiting.md), but MCP remains unrate-limited and several production blockers remain. Full audit:../operations/production-security-audit.md.
Runbooks
Lead-delivery setup and the "leads are not arriving" checklist live in ../operations/README.md.