Google Gemini Lead Intake for Jotform SMB Lead Qualification
Ingest form submissions from Jotform webhooks, classify lead quality with Gemini, auto-route hot leads to sales and ask clarifying questions for ambiguous entries.
SMBs using Jotform get flooded with submissions, but manually sorting qualified leads from spam and tyre-kickers wastes sales team hours and lets good leads go cold.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
In this tutorial, you’ll build an automated lead intake pipeline inside a Next.js project that receives form submissions from Jotform webhooks, classifies lead quality with Google Gemini, and takes action based on confidence levels. High-confidence leads are routed to a Slack sales channel with rich Block Kit formatting; medium-confidence leads trigger a clarifying email asking for more details; low-confidence submissions are silently dropped with cost telemetry logged. The pipeline uses an Express handler for the webhook endpoint backed by six REAA packages — confidence router, classifiers, structured repair, agent handoff routing, cost telemetry, and session continuity — with a full test suite and 90%+ coverage.
Prerequisites
Node.js >= 22 and pnpm 10.x installed
A Google AI API key for Gemini (get one at ai.google.dev)
A Slack bot token with chat:write scope and a channel ID to receive hot-lead notifications
SMTP credentials (host, port, user, pass) for sending clarification emails — any SMTP provider works
A Jotform webhook secret (the shared HMAC key you’ll configure in Jotform’s webhook settings)
Trigger.dev secret key and API URL (for the email task queue) — sign up at trigger.dev
Familiarity with TypeScript, Next.js, Express, and basic Zod schema validation
Step 1: Scaffold the Next.js project and install dependencies
Create a new Next.js project with TypeScript and install all the dependencies you’ll need — Express for the webhook handler, Gemini for classification, Slack for notifications, Nodemailer for email, Trigger.dev for task queuing, and six packages for the orchestration layer.
Expected output: Each pnpm add command prints a resolved dependency tree and writes the exact version into package.json with no ^ or ~ prefix.
Step 2: Configure the environment variables
Create a .env.example file with placeholders for every credential the pipeline needs. This is the template you’ll copy to .env and fill in.
Create .env.example:
env
# Env vars used by google-gemini-lead-intake-for-jotform-smb-lead-qualification.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=developmentJOTFORM_WEBHOOK_SECRET=<your-jotform-secret>GEMINI_API_KEY=<your-google-ai-key>SLACK_BOT_TOKEN=<your-slack-bot-token>SLACK_CHANNEL_ID=<your-sales-channel-id>SMTP_HOST=<smtp-host>SMTP_PORT=587SMTP_USER=<smtp-username>SMTP_PASS=<smtp-password>TRIGGER_SECRET_KEY=<your-trigger-secret>TRIGGER_API_URL=<trigger-api-url>PORT=3001
Now copy it and fill in your real values:
terminal
cp .env.example .env
Expected output: A .env file where you’ve replaced every <...> placeholder with a real credential.
Step 3: Define the type definitions
Start with the shared types that flow through the pipeline — the Jotform submission schema, the normalized lead shape, classification results, Slack notification shape, and email task types.
Build the integration with Google Gemini. The client wraps the @google/genai SDK, exposing three functions: creating the client, classifying a lead’s intent via a structured prompt, and generating a natural-language follow-up question.
Create src/services/gemini.ts:
ts
import { GoogleGenAI } from "@google/genai";import { z } from "zod";import { repair } from "@reaatech/structured-repair-core";import type { NormalizedLead } from "../types/jotform";const ClassificationSchema = z.object({ label: z.string(), confidence: z.number(),});export function createGeminiClient(): GoogleGenAI { return new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY ?? "" });}export async function classifyLeadIntent( ai: GoogleGenAI, text: string, categories: string[],): Promise<{ label: string; confidence: number }> { const prompt = `You are a lead classification assistant. Given the following text and categories, output a JSON object with the format: { "label": "<category>", "confidence": <0.0-1.0> }Categories: ${JSON.stringify(categories)}Text: "${text}"Respond with only the JSON object, no other text.`; const response = await ai.models.generateContent({ model: "gemini-2.5-flash", contents: prompt, }); const rawText = response.text ?? ""; try { const parsed = JSON.parse(rawText) as { label: string; confidence: number }; return ClassificationSchema.parse(parsed); } catch { const repaired = await repair(ClassificationSchema, rawText); return repaired; }}export async function generateFollowUpQuestion( ai: GoogleGenAI, leadData: NormalizedLead, ambiguity: string,): Promise<string> { const prompt = `You are a sales assistant. A lead submitted the following information:Name: ${leadData.name}Message: ${leadData.message}There is some ambiguity: ${ambiguity}Generate a single-sentence natural-language clarification question to ask the lead to resolve this ambiguity. Do not include any other text.`; const response = await ai.models.generateContent({ model: "gemini-2.5-flash", contents: prompt, }); return response.text ?? "";}
How it works: The classifyLeadIntent function sends a structured prompt to Gemini 2.5 Flash asking it to label the message text against your category list. If Gemini’s JSON response is malformed (common with LLM output), the repair() function from @reaatech/structured-repair-core steps in to fix it — stripping markdown fences, fixing trailing commas, or matching fuzzy keys.
Step 5: Build the classification pipeline
The classification pipeline combines two classifiers — a fast keyword matcher for common lead-intent signals and an embedding-similarity classifier for semantic scoring — feeding both into a ConfidenceRouter that decides whether to route, clarify, or fallback.
Create src/classify/lead.ts:
ts
import { ConfidenceRouter } from "@reaatech/confidence-router";import { KeywordClassifier, EmbeddingSimilarityClassifier, ClassifierRegistry,} from "@reaatech/confidence-router-classifiers";import { repair } from "@reaatech/structured-repair-core";import { z } from "zod";import { generateId, calculateCostFromTokens } from "@reaatech/llm-cost-telemetry";import type { GoogleGenAI } from "@google/genai";import type { NormalizedLead } from "../types/jotform";import { classifyLeadIntent } from "../services/gemini";export const router = new ConfidenceRouter
Decision thresholds:
ROUTE — confidence > 0.8 (hot lead, send to Slack)
CLARIFY — confidence between 0.4 and 0.8 (ambiguous, send email)
FALLBACK — confidence < 0.4 (low quality, drop silently)
Step 6: Build the Slack routing service
When a lead scores high confidence, you’ll notify the sales team through Slack using @slack/web-api with rich Block Kit formatting. The routing uses @reaatech/agent-handoff-routing to match the lead to the sales team agent.
Create src/routing/slack.ts:
ts
import { CapabilityBasedRouter, AgentRegistry } from "@reaatech/agent-handoff-routing";import { WebClient } from "@slack/web-api";import type { NormalizedLead } from "../types/jotform";import type { LeadClassification } from "../types/lead";export const web = new WebClient(process.env.SLACK_BOT_TOKEN ?? "");export const registry = new AgentRegistry();registry.register({ agentId: "sales-team", agentName: "Sales Team", skills: ["lead-qualification", "sales"], domains: [
Step 7: Create the email follow-up service
When a lead falls in the CLARIFY band (medium confidence), you’ll send a follow-up email asking for more details. This service uses Nodemailer and can optionally thread the email to an existing conversation.
Wrap the email service in a Trigger.dev task with exponential backoff retry. This ensures that transient SMTP failures are retried up to 3 times (1s, 2s, then 4s delays).
The session continuity package maintains conversation context across multi-turn email qualification flows. You’ll create an in-memory storage adapter and a SessionManager configured with token budgeting and sliding-window compression.
Create src/session/manager.ts:
ts
import { SessionManager, type IStorageAdapter, type TokenCounter, type Session, type SessionId, type Message, type HealthStatus,} from "@reaatech/session-continuity";const countCharacters: TokenCounter = { count: (text: string) => Math.ceil(text.length / 4), countMessages: () => 0, model: "character-count", tokenizer: "char/4",};class InMemoryStorageAdapter implements
Step 10: Wire up cost tracking
Track per-lead LLM costs using @reaatech/llm-cost-telemetry. You’ll define Gemini 2.5 Flash pricing rates and log structured cost spans to stdout for ingestion by your observability pipeline.
Cost note: Gemini 2.5 Flash is priced at $0.08 per million input tokens and $0.30 per million output tokens. For a typical 200-token submission, the classification cost is fractions of a cent.
Step 11: Create the webhook entry point
Now wire everything together into an Express app exported from your entry point. The server mounts the Jotform webhook router and listens on the configured port.
Create src/index.ts:
ts
import express from "express";import { jotformRouter } from "./api/webhook/jotform";const app = express();app.use(express.json());app.use("/api/webhook/jotform", jotformRouter);const port = parseInt(process.env.PORT ?? "3001", 10);app.listen(port, () => { console.log(`Webhook server listening on port ${String(port)}`);});export default app;
Step 12: Implement the Jotform webhook handler
This is the heart of the pipeline. The handler receives a POST from Jotform, verifies the HMAC-SHA256 signature, parses and validates the submission with Zod, normalizes the answers into a NormalizedLead, runs the classification pipeline, and dispatches the result.
Create src/api/webhook/jotform.ts:
ts
import express, { Router, type Request, type Response } from "express";import crypto from "node:crypto";import { ZodError } from "zod";import { JotformSubmissionSchema, type NormalizedLead } from "../../types/jotform";import type { LeadClassification } from "../../types/lead";import { classifyLead } from "../../classify/lead";import { routeToSlack } from "../../routing/slack";import { handleFollowUp } from "../../tasks/followUpTask";import { createGeminiClient, generateFollowUpQuestion } from "../../services/gemini";import { createLeadCostSpan, logLeadCost } from
HMAC verification explained: Jotform sends an x-jotform-signature header containing the HMAC-SHA256 hex digest of the raw body. Your server computes the same hash using the shared secret and compares them with crypto.timingSafeEqual() — a constant-time comparison that prevents timing attacks.
Step 13: Run the tests
The project includes 10 test files covering every source module — webhook handler, classification pipeline, Slack routing, email service, Gemini client, session manager, cost tracking, structured repair, and integration tests. Run the full suite:
All 87 tests pass and coverage is above 90% across all four metrics.
Step 14: Start the webhook server
With everything wired up, start the Express webhook handler. The project exports the Express app from src/index.ts, which you can run directly with tsx:
terminal
npx tsx src/index.ts
Expected output: The terminal prints:
text
Webhook server listening on port 3001
The server is now ready to receive Jotform webhooks at POST http://localhost:3001/api/webhook/jotform.
You can test it with curl using a valid HMAC signature:
terminal
# Compute HMAC with your JOTFORM_WEBHOOK_SECRETSECRET="your-jotform-secret"BODY='{"formId":"f1","submissionId":"s1","answers":{"1":{"name":"name","order":"1","text":"Jane Smith"},"2":{"name":"email","order":"2","text":"jane@example.com"},"3":{"name":"message","order":"3","text":"I need pricing for enterprise plan urgently"}},"createdAt":"2024-01-01T00:00:00Z"}'SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)curl -X POST http://localhost:3001/api/webhook/jotform \ -H "Content-Type: application/octet-stream" \ -H "x-jotform-signature: $SIG" \ -d "$BODY"
The pipeline classifies the lead, posts a formatted notification to your Slack sales channel, logs the cost span to stdout, and returns the result.
Next steps
Add a PostgreSQL storage adapter — Replace the in-memory session storage with a database-backed adapter so sessions survive server restarts and scale across instances.
Integrate Langfuse — Add Langfuse tracing to the webhook handler for observability, latency tracking, and experiment comparison across classification strategy changes.
Extend the classifier dictionary — Add more keyword labels (e.g., “partner”, “reseller”, “enterprise”) and train embedding reference vectors on your actual lead data for more accurate semantic matching.
Deploy with a process manager — Run the server behind PM2 or Docker with health checks and auto-restart for production readiness.
({
routeThreshold: 0.8,
fallbackThreshold: 0.4,
clarificationEnabled: true,
});
export const keywordClassifier = new KeywordClassifier(