Small sales teams waste time manually researching new leads from Pipedrive, often missing high-intent opportunities buried in generic web form submissions.
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.
Small sales teams waste hours manually researching new leads from Pipedrive, often missing high-intent opportunities buried in generic web form submissions. In this tutorial, you’ll build a Next.js pipeline that automatically enriches and qualifies inbound Pipedrive leads using Anthropic Claude. When a new lead arrives via webhook, the pipeline verifies the HMAC signature, normalises the payload, sends it to Claude for company research and fit scoring, applies confidence-based routing, updates the deal in Pipedrive with enrichment data, enforces a daily budget cap, and records a full trace to Langfuse — all within a single request lifecycle.
You’ll wire up eight @reaatech/* packages: webhook-relay-core for event ingestion, agent-mesh for structured agent I/O, confidence-router for routing decisions, agent-budget-engine for spend control, llm-cost-telemetry for cost tracking, session-continuity for conversation context, agent-budget-spend-tracker for spend storage, and agent-budget-types for budget type definitions. By the end you’ll have a lead enrichment service with tests and coverage gates.
Prerequisites
Node.js >= 22 and pnpm 10 installed
A Pipedrive account with an API token and a webhook endpoint you can configure
An Anthropic API key (Claude model access)
A Langfuse account (free tier works) for telemetry
Basic familiarity with Next.js App Router, TypeScript, and REST APIs
Environment variables
env
NODE_ENV=development
PIPEDRIVE_API_TOKEN=<your-pipedrive-api-token>
PIPEDRIVE_WEBHOOK_SECRET=<your-webhook-secret>
ANTHROPIC_API_KEY=<your-anthropic-key>
ANTHROPIC_MODEL=claude-sonnet-4-6
LANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>
LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>
LANGFUSE_HOST=https://cloud.langfuse.com
DEFAULT_DAILY_BUDGET=5.0
AGENT_BUDGET_SOFT_CAP=0.8
AGENT_BUDGET_HARD_CAP=1.0
LOG_LEVEL=info
Step 1: Scaffold the project and install dependencies
Start with a Next.js project using the App Router. Create the project, then add the recipe’s dependencies as exact pinned versions.
The scaffold already includes Next.js 16, React 19, TypeScript, and ESLint. Now install the recipe-specific dependencies. The @reaatech/* packages handle webhook ingestion, agent orchestration, confidence routing, budget enforcement, cost telemetry, session continuity, spend tracking, and budget types. The pipedrive SDK manages deals and notes, @anthropic-ai/sdk calls Claude, langfuse records observability traces, and zod validates payloads at every boundary.
Copy the environment variable template and start the dev server to verify the scaffold works:
terminal
cp .env.example .env.local # fill in real valuespnpm dev
Expected output: Your terminal prints something like ▲ Next.js 16.x and the dev server is available at http://localhost:3000.
Step 2: Define the data types and schemas
Every payload that enters the system gets validated by a Zod schema. Create src/types/index.ts with schemas for the Pipedrive webhook payload, the normalized lead shape, the enrichment result, the enrichment context, and a generic API response wrapper.
Expected output: This file compiles cleanly. The PipedriveWebhookPayloadSchema validates the shape of incoming Pipedrive webhook events — the current field can be null (Pipedrive sends null for deleted entities), so .nullable() handles that. The NormalizedLead type is what flows through the rest of the pipeline after you strip out the raw event structure.
Step 3: Build the webhook handler
The webhook handler at src/api/pipedrive/webhook.ts receives the raw Pipedrive event, verifies its HMAC-SHA256 signature using Node’s built-in crypto module, parses the payload with Zod, normalises it into a consistent NormalizedLead shape, and logs the event.
Expected output:verifyPipedriveSignature uses crypto.timingSafeEqual to prevent timing attacks on signature comparison. If the signature header is missing or doesn’t match, it returns false. The handlePipedriveWebhook function combines all three steps into a single call the route handler will invoke.
Step 4: Build the Pipedrive client
The src/services/pipedrive.ts module wraps the pipedrive npm SDK’s v2 Deals API and v1 Notes API into a single PipedriveClient class. It handles deal CRUD, note creation, deal search, and error conversion.
ts
import { Configuration, DealsApi, Deal } from "pipedrive/v2";import { NotesApi, Configuration as V1Configuration } from "pipedrive/v1";import { WebhookRelayError } from "@reaatech/webhook-relay-core";export class PipedriveClient { private dealsApi: DealsApi; private notesApi: NotesApi; constructor(apiToken: string) { const apiConfig = new Configuration({ apiKey: apiToken }); this.dealsApi = new DealsApi(apiConfig); const v1Config = new V1Configuration({ apiKey: apiToken }); this.notesApi
Expected output: The PipedriveClient reads the API token from the constructor argument or falls back to PIPEDRIVE_API_TOKEN environment variable via the createPipedriveClient factory. The convertPipedriveError helper maps HTTP 404/401/429 responses into typed WebhookRelayError instances that the route handler can catch.
Step 5: Build the lead enrichment agent
The agent at src/agents/lead-enricher.ts is the core of the pipeline. It builds a structured prompt from the normalized lead, calls Anthropic Claude with exponential-backoff retry on 429 rate limits, parses the JSON response into enrichment fields (company profile, intent signals, fit score), validates the I/O against @reaatech/agent-mesh schemas, and feeds the fit score into the confidence router for a routing decision.
ts
import Anthropic from "@anthropic-ai/sdk";import { type TextBlock } from "@anthropic-ai/sdk/resources/messages/messages.js";import { IncomingRequestSchema, AgentResponseSchema } from "@reaatech/agent-mesh";import { ConfidenceRouter } from "@reaatech/confidence-router";import type { NormalizedLead, LeadEnrichmentResult, EnrichmentContext } from "../types/index.js";export type RoutingResult = { type: "ROUTE" | "CLARIFY" | "FALLBACK"; target?: string;};export type EnrichLeadResult = { enrichment: LeadEnrichmentResult
Expected output:callWithRetry implements exponential backoff (1s, 2s, 4s capped at 10s) for HTTP 429 responses and non-retryable errors are thrown immediately. The confidence router returns ROUTE when fitScore >= 80, CLARIFY between 30-79, and FALLBACK below 30.
Step 6: Build the session manager
The session manager at src/services/session.ts wraps @reaatech/session-continuity with in-memory storage. It creates per-lead sessions, records enrichment entries (the initial lead data and the AI’s response), retrieves conversation context, and cleans up when done.
ts
import { SessionManager, type Session, type Message, type TokenCounter, SessionNotFoundError,} from "@reaatech/session-continuity";import { MemoryAdapter } from "@reaatech/session-continuity-storage-memory";export class InlineTokenCounter implements TokenCounter { readonly model = "inline"; readonly tokenizer = "inline-char-4"; count(text: string): number { return Math.ceil(text.length / 4); } countMessages(messages: Message[]): number { let total = 0; for (const msg of messages) { if (typeof msg.content === "string") { total += this.count(msg.content); } else { for (const block of msg.content) { if (block.type === "text") { total += this.count(block.text); } } } } return total; }}export function createSessionManager(): SessionManager { const storage = new MemoryAdapter(); const tokenCounter = new InlineTokenCounter(); return new SessionManager({ storage, tokenCounter, tokenBudget: { maxTokens: 4096, reserveTokens: 500, overflowStrategy: "compress" }, compression: { strategy: "sliding_window", targetTokens: 3500 }, });}const manager: SessionManager = createSessionManager();export function createLeadSession(userId: string): Promise<Session> { return manager.createSession({ userId });}export function addEnrichmentEntry( sessionId: string, entry: { role: "user" | "assistant" | "system"; content: string }): Promise<Message> { return manager.addMessage(sessionId, entry);}export function getLeadContext( sessionId: string): Promise<Message[]> { return manager.getConversationContext(sessionId);}export async function endLeadSession( sessionId: string): Promise<void> { try { await manager.endSession(sessionId); } catch (err) { if (err instanceof SessionNotFoundError) { return; } throw err; }}
Expected output: The InlineTokenCounter approximates tokens at 1/4 character count — enough for budget tracking without pulling in a full tokeniser. The session is configured with a 4096-token budget, a 500-token reserve for system prompts, and a sliding-window compression strategy to keep context under 3500 tokens.
Step 7: Build the budget controller
The budget module at src/lib/budget.ts uses @reaatech/agent-budget-engine to enforce a daily LLM spend cap with soft and hard limits. The controller singleton defines a global budget for all users. The createBudgetController factory lets you create isolated instances with custom limits (useful in tests). recordSpend logs each enrichment call’s token usage and cost via @reaatech/llm-cost-telemetry.
Expected output: The soft cap (default 80%) triggers a warning at the budget controller level, and the hard cap (100%) stops new LLM calls with EnforcementAction.HardStop. The daily limit defaults to $5.00 USD.
Step 8: Build the Langfuse telemetry module
The telemetry module at src/lib/telemetry.ts wraps the Langfuse SDK. It initialises a singleton Langfuse client, records a trace + span for each enrichment cycle, and provides a graceful shutdown hook.
Expected output: If Langfuse isn’t reachable, the telemetry call silently warns and continues — enrichment is never blocked by observability failures.
Step 9: Wire the webhook route handler
The webhook route at app/api/webhook/pipedrive/route.ts is the main pipeline entry point. It receives the raw Pipedrive webhook, verifies the signature, checks the daily budget, creates a session, enriches the lead with Claude, writes enrichment data back to Pipedrive as deal fields and a note, records the spend and Langfuse trace, and returns the result.
ts
import { type NextRequest, NextResponse } from "next/server";import { handlePipedriveWebhook } from "../../../../src/api/pipedrive/webhook.js";import { createPipedriveClient } from "../../../../src/services/pipedrive.js";import { enrichLead } from "../../../../src/agents/lead-enricher.js";import { checkBudget, recordSpend } from "../../../../src/lib/budget.js";import { createLeadSession, addEnrichmentEntry } from "../../../../src/services/session.js";import { recordEnrichmentTrace } from "../../../../src/lib/telemetry.js";export async function POST(req: NextRequest) { try { const body: unknown = await
Expected output: A POST to /api/webhook/pipedrive with a valid Pipedrive payload and matching HMAC signature returns:
Missing or invalid signature returns 401 with {"ok":false,"error":"invalid_signature"}. Budget exceeded returns 429.
Step 10: Wire the leads API route
The leads route at app/api/leads/route.ts provides two endpoints. GET /api/leads returns recent deals from Pipedrive (filtered by the word “recent” against deal titles). POST /api/leads lets you create a lead manually (without a Pipedrive webhook) and run it through the same enrichment pipeline — useful for testing or for leads entered via a form.
ts
import { type NextRequest, NextResponse } from "next/server";import { z } from "zod";import { createPipedriveClient } from "../../../src/services/pipedrive.js";import { enrichLead } from "../../../src/agents/lead-enricher.js";import { checkBudget, recordSpend } from "../../../src/lib/budget.js";import { createLeadSession, addEnrichmentEntry } from "../../../src/services/session.js";import { recordEnrichmentTrace } from "../../../src/lib/telemetry.js";const CreateLeadBody = z.object({ name: z.string().min(1), email: z.email().optional(),
Expected output:POST /api/leads with {"name": "Alice", "email": "alice@example.com", "company": "Acme Corp"} creates a deal in Pipedrive, enriches it, and returns the same response shape as the webhook route.
Step 11: Wire the budget status endpoint
The budget route at app/api/budget/route.ts exposes the current budget state so you can monitor LLM spend programmatically.
ts
import { NextResponse } from "next/server";import { getBudgetState } from "../../../src/lib/budget.js";export function GET() { try { const state = getBudgetState("*"); return NextResponse.json({ ok: true, data: state }); } catch (err: unknown) { console.error("Budget GET error:", err); return NextResponse.json( { ok: false, error: "internal" }, { status: 500 }, ); }}
After enriching a few leads, totalSpent reflects the accumulated cost recorded by recordSpend.
Step 12: Run the tests
This recipe ships with a full test suite using Vitest and vi.mock to isolate every external dependency. All network calls (Anthropic, Pipedrive, Langfuse) are mocked so tests run fast and deterministically.
terminal
pnpm test
This runs:
terminal
vitest run --coverage --reporter=json --outputFile=vitest-report.json
Expected output: 99 tests pass across 11 test suites, with high coverage across all source files:
The test suite covers the Pipedrive client (deal CRUD, notes, error conversion, search filtering), webhook handler (signature verification, payload parsing, lead normalization), type schemas, budget controller (check, record, create with overrides, exceeded errors), session manager (create, add, get, end, token counting), lead enricher (JSON parsing, retry logic, routing decisions, error handling), telemetry (init, trace recording, graceful shutdown), the barrel export, and each route handler (happy path, signature errors, budget exceeded, malformed payload, internal errors).
Next steps
Add a Pipedrive webhook subscription endpoint — configure your Pipedrive account to send added.deal events to /api/webhook/pipedrive. Use the webhook secret to sign the callback URL in Pipedrive’s settings.
Replace the memory session adapter — swap MemoryAdapter with a Redis or PostgreSQL adapter from @reaatech/session-continuity-storage-* to persist session state across restarts.
Add a lead dashboard page — create a Next.js page at app/leads/page.tsx that calls GET /api/leads and renders enrichment data (fit score, intent signals) alongside each deal.
Implement a notification service — when the confidence router returns ROUTE, send an email or Slack notification to the assigned salesperson with the enrichment summary.
=
new
NotesApi
(v1Config);
}
async getDeal(id: number): Promise<Deal> {
try {
const response = await this.dealsApi.getDeal({ id });
if (!response.data) {
throw new WebhookRelayError(`Deal ${String(id)} not found`, "NOT_FOUND");