Building an Inbound Lead Routing App with TypeScript
Building an Inbound Lead Routing App with TypeScript
Enrich, score, and route an inbound lead in one flow — using Clay's Enrich Person routine for real identity data and your own routing logic, with the CRM push itself left as an external integration (Clay has no CRM connector).
What you will build
An Express service exposing POST /inbound-lead, which enriches a form submission via Clay, decides a routing queue from the person's real title, and returns the payload a CRM push would receive.
POST /inbound-lead (email or linkedinUrl, formSource)
↓
POST /routines/{routine_id}/run (Enrich Person)
↓
GET /routines/run/{run_id}/results (poll until complete)
↓
decideRoute(title) → ae_queue | sdr_queue | nurture
↓
{ profile, route, crmPayload } (crmPayload is what you'd POST to your CRM's own API)
AI Prompt
Implement a TypeScript/Express service that enriches, scores, and
routes an inbound lead form submission using Clay's Public API.
Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- Use the "Enrich Person" routine (id via CLAY_ROUTINE_ID_ENRICH_PERSON)
with {"Professional Profile URL"} and/or {"Email"} to resolve the
lead's real current title and company. Result key is "Enrich person"
.
- Routing logic (which queue a lead goes to) is entirely custom app
code -- Clay has no native lead-routing or CRM-push capability.
Return the payload that WOULD be sent to a CRM; the actual CRM call
is a separate integration using that CRM's own API/SDK, out of scope
here.
- Per Clay's OpenAPI schema, the routine run request accepts
an optional "webhook_id" field for async completion notification
instead of polling -- but registering a webhook is a `clay webhooks
create <url>` CLI/OAuth-only call with no REST equivalent, and
exercising real webhook delivery needs a public receiver URL. Use
synchronous polling (the proven pattern) unless you've separately set
up webhook infrastructure.
- Run the verification step below before finishing.
Prerequisites
- Node.js 18+ and TypeScript
- A Clay Public API key (
clay_scoped_...) - The routine ID for
Enrich Person, discovered once viaclay routines list
1. Create the project
mkdir inbound-lead-router && cd inbound-lead-router npm init -y npm install express npm install -D typescript @types/express @types/node npx tsc --init
2. Discover the real schema
Reuses the Enrich Person routine schema from the contact-waterfall-enrichment example. The optional webhook_id field on the run request is documented in Clay's own openapi.json.
3. Configure credentials
CLAY_API_KEY= CLAY_ROUTINE_ID_ENRICH_PERSON=
In a CI/sandbox test environment, these are typically provided as pre-configured secrets; in your own deployment, set them as real environment variables or via your platform's secrets manager.
4. Implement the service
import express, { Request, Response as ExpressResponse } from "express";
const CLAY_API_BASE = "https://api.clay.com/public/v0";
const CLAY_API_KEY = process.env.CLAY_API_KEY;
const ENRICH_PERSON_ROUTINE_ID = process.env.CLAY_ROUTINE_ID_ENRICH_PERSON;
if (!CLAY_API_KEY) throw new Error("CLAY_API_KEY env var is required");
if (!ENRICH_PERSON_ROUTINE_ID) throw new Error("CLAY_ROUTINE_ID_ENRICH_PERSON env var is required");
async function clayFetch(path: string, init: RequestInit = {}): Promise<globalThis.Response> {
return fetch(`${CLAY_API_BASE}${path}`, {
...init,
headers: {
"clay-api-key": CLAY_API_KEY as string,
"Content-Type": "application/json",
...(init.headers || {}),
},
});
}
interface EnrichPersonProfile {
name?: string;
title?: string;
org?: string;
}
// Synchronous polling, the pattern proven throughout this session. Clay's
// Public API also accepts an optional "webhook_id" field on this same run
// request (per Clay's own OpenAPI schema: RunRoutineRequest ->
// webhook_id, "ID of a registered Clay webhook to notify when the run
// finishes") for an async, no-polling alternative -- registering that
// webhook is a `clay webhooks create <url>` CLI/OAuth call with no REST
// equivalent, and this example does not exercise live webhook delivery
// (it needs a public receiver URL not available in this environment).
// For production, register a webhook once and pass its id as
// { items: [...], webhook_id: process.env.CLAY_WEBHOOK_ID } instead of polling.
async function enrichLead(input: { email?: string; linkedinUrl?: string }): Promise<EnrichPersonProfile> {
const inputs: Record<string, string> = {};
if (input.linkedinUrl) inputs["Professional Profile URL"] = input.linkedinUrl;
if (input.email) inputs["Email"] = input.email;
const startRes = await clayFetch(`/routines/${ENRICH_PERSON_ROUTINE_ID}/run`, {
method: "POST",
body: JSON.stringify({ items: [{ id: input.email ?? input.linkedinUrl ?? "lead-1", inputs }] }),
});
if (!startRes.ok) {
throw new Error(`routine start failed: ${startRes.status} ${await startRes.text()}`);
}
const { routine_run_id } = (await startRes.json()) as { routine_run_id: string };
const resultsPath = `/routines/run/${routine_run_id}/results`;
for (let attempt = 0; attempt < 15; attempt++) {
const res = await clayFetch(resultsPath);
if (!res.ok) throw new Error(`routine results failed: ${res.status} ${await res.text()}`);
const body = (await res.json()) as {
status: string;
data: Array<{ id: string; status: string; result?: { "Enrich person"?: EnrichPersonProfile } }>;
};
if (body.status === "complete") {
return body.data[0]?.result?.["Enrich person"] ?? {};
}
await new Promise((r) => setTimeout(r, 5000));
}
throw new Error(`routine run ${routine_run_id} did not complete in time`);
}
type Route = "ae_queue" | "sdr_queue" | "nurture";
// Routing logic is entirely custom app code -- Clay has no native lead-
// routing capability. Reuses the seniority-classification pattern from the
// job-title-normalization example.
function decideRoute(title: string): Route {
const t = title.toLowerCase();
if (/\b(ceo|cfo|coo|cto|cmo|chief|founder|vp|vice president)\b/.test(t)) return "ae_queue";
if (/\bdirector\b|\bhead of\b|\bmanager\b/.test(t)) return "sdr_queue";
return "nurture";
}
const app = express();
app.use(express.json());
app.post("/inbound-lead", async (req: Request, res: ExpressResponse) => {
const { email, linkedinUrl, formSource } = req.body ?? {};
if (!email && !linkedinUrl) {
return res.status(400).json({ error: "email or linkedinUrl is required" });
}
try {
const profile = await enrichLead({ email, linkedinUrl });
const title = profile.title ?? "";
const route = title ? decideRoute(title) : "nurture";
// The CRM push itself is out of scope here (an external API call using
// your CRM's own SDK) -- this is the payload such a call would send.
const crmPayload = {
name: profile.name ?? null,
title,
company: profile.org ?? null,
email: email ?? null,
source: formSource ?? "inbound-form",
route,
};
res.json({ profile, route, crmPayload });
} catch (err) {
res.status(502).json({ error: (err as Error).message });
}
});
const port = Number(process.env.PORT) || 3000;
app.listen(port, () => console.log(`listening on ${port}`));
5. Run
npx tsc node --env-file=.env dist/index.js
curl -X POST http://localhost:3000/inbound-lead \
-H "Content-Type: application/json" \
-d '{"linkedinUrl":"https://www.linkedin.com/in/<public-profile>","formSource":"contact-us-form"}'
6. Verify the result
{
"profile": { "name": "Satya Nadella", "title": "Chairman and CEO", "org": "Microsoft", "...": "...full real profile object..." },
"route": "ae_queue",
"crmPayload": {
"name": "Satya Nadella",
"title": "Chairman and CEO",
"company": "Microsoft",
"email": null,
"source": "contact-us-form",
"route": "ae_queue"
}
}
The lead was correctly enriched, classified, and routed to ae_queue based on a real, current title.
How it works
This is a composite pattern: Clay supplies the one thing it's actually good at (resolving a real identity into real profile data), and everything downstream — the routing decision, the CRM write — is ordinary application logic and a separate third-party integration. The "app" here is the glue, not a Clay feature; that's true of every "build a full X app" example in this content set.
Common issues
Expecting Clay to push directly into a CRM
Cause: "full inbound lead routing app" sounds end-to-end, including the CRM leg.
Fix: Clay has no CRM connector in its Public API. Take the crmPayload this example returns and send it via your CRM's own API/SDK as a separate step.
Assuming webhook registration works with just the API key
Cause: the webhook_id field exists on the run request, so it seems like the whole flow should be API-key-only.
Fix: creating the webhook itself requires clay webhooks create via the OAuth-authenticated CLI — the API key alone can reference an already-registered webhook ID, not create one.
Next steps
- Register a real webhook (
clay webhooks create <url>) and switch from polling to thewebhook_idfield for a production async flow. - Replace the illustrative routing rules with your actual territory/queue logic.
verification:
status: verified
tested_at: "2026-09-02"
product_version: "public/v0"
command: "npx tsc && node --env-file=.env dist/index.js && curl -X POST http://localhost:3000/inbound-lead -d '{\"linkedinUrl\":\"https://www.linkedin.com/in/<public-profile>\",\"formSource\":\"contact-us-form\"}'"
expected_result: "A real lead is enriched with a real current title and company, correctly routed by the custom classifier, and a CRM-ready payload is returned."