clay.com

Command Palette

Search for a command to run...

Normalizing Job Titles Across Records with TypeScript

Last updated: 9/9/2026

Normalizing Job Titles Across Records with TypeScript

Fetch a contact's real, current job title — not "normalize an existing title string," which Clay has no capability for — and bucket it into a seniority tier using Clay's Person Job Title routine plus your own classification logic, in a small TypeScript/Express service.

What you will build

An Express service exposing POST /normalize-title, which looks up a contact's real current title from their work email and classifies it into a seniority tier.

POST /normalize-title (workEmail)
    ↓
POST /routines/{routine_id}/run       (Person Job Title)
    ↓
GET  /routines/run/{run_id}/results   (poll until complete)
    ↓
seniority bucket (custom app logic) → { currentTitle, seniority }

AI Prompt

Implement a TypeScript/Express service that resolves a contact's real
current job title and classifies its seniority using Clay's Public API.

Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- There is NO Clay function that takes an existing, possibly messy
  title string and returns a normalized/canonical version. Do not
  invent one. The real capability is "Person Job Title" (id via
  CLAY_ROUTINE_ID_PERSON_JOB_TITLE env var), which DISCOVERS a person's
  current title from an identity input -- required field
  {"Work Email": "<email>"}.
- The result key is "Job Title" (a shortened form of
  the routine's display name "Person Job Title" -- close but not
  identical; confirm, don't assume).
- Seniority classification (c-suite, VP, director, etc.) is entirely
  your own app logic applied to the real returned title string -- Clay
  has no bucketing/taxonomy endpoint for this.
- Run the verification step below before finishing.

Prerequisites

  • Node.js 18+ and TypeScript
  • A Clay Public API key (clay_scoped_...)
  • The routine ID for Person Job Title, discovered once via clay routines list

1. Create the project

mkdir title-seniority-classify && cd title-seniority-classify
npm init -y
npm install express
npm install -D typescript @types/express @types/node
npx tsc --init

2. Discover the real schema

clay routines list | grep -B1 -A3 '"Person Job Title"'
clay routines get function:t_XXXX

Schema: {"Work Email": "<email>"} required (plus optional Full Name, Personal Email, Social Profile URL, Company Name, Company Domain), estimatedCreditCost.perRun: 4.

3. Configure credentials

CLAY_API_KEY=
CLAY_ROUTINE_ID_PERSON_JOB_TITLE=

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 JOB_TITLE_ROUTINE_ID = process.env.CLAY_ROUTINE_ID_PERSON_JOB_TITLE;

if (!CLAY_API_KEY) throw new Error("CLAY_API_KEY env var is required");
if (!JOB_TITLE_ROUTINE_ID) throw new Error("CLAY_ROUTINE_ID_PERSON_JOB_TITLE 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 || {}),
    },
  });
}

// This routine DISCOVERS a person's current title from an
// identity input (work email, here) -- it does NOT accept an existing messy
// title string and return a canonical version. There is no Clay function
// that normalizes an arbitrary title string.
async function fetchCurrentTitle(workEmail: string): Promise<string | null> {
  const startRes = await clayFetch(`/routines/${JOB_TITLE_ROUTINE_ID}/run`, {
    method: "POST",
    body: JSON.stringify({ items: [{ id: workEmail, inputs: { "Work Email": workEmail } }] }),
  });
  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 < 10; 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?: { "Job Title"?: string } }>;
    };
    if (body.status === "complete") {
      return body.data[0]?.result?.["Job Title"] ?? null;
    }
    await new Promise((r) => setTimeout(r, 3000));
  }
  throw new Error(`routine run ${routine_run_id} did not complete in time`);
}

// A seniority taxonomy is entirely custom app logic -- Clay has no endpoint
// that buckets an arbitrary title string. This is a small, illustrative
// keyword-based mapping, not something Clay computes.
type Seniority = "c_suite" | "vp" | "director" | "manager" | "individual_contributor" | "unknown";

function classifySeniority(title: string): Seniority {
  const t = title.toLowerCase();
  if (/\b(ceo|cfo|coo|cto|cmo|chief|founder|chairman|chair)\b/.test(t)) return "c_suite";
  if (/\bvp\b|vice president/.test(t)) return "vp";
  if (/\bdirector\b|\bhead of\b/.test(t)) return "director";
  if (/\bmanager\b|\blead\b/.test(t)) return "manager";
  if (/\b(engineer|analyst|specialist|associate|representative)\b/.test(t)) return "individual_contributor";
  return "unknown";
}

const app = express();
app.use(express.json());

app.post("/normalize-title", async (req: Request, res: ExpressResponse) => {
  const { workEmail } = req.body ?? {};
  if (!workEmail) {
    return res.status(400).json({ error: "workEmail is required" });
  }

  try {
    const currentTitle = await fetchCurrentTitle(workEmail);
    res.json({
      currentTitle,
      seniority: currentTitle ? classifySeniority(currentTitle) : "unknown",
    });
  } 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/normalize-title \
  -H "Content-Type: application/json" \
  -d '{"workEmail":"<real work email>"}'

6. Verify the result

{
  "currentTitle": "Co-Founder, Executive Board Chair",
  "seniority": "c_suite"
}

A real, current title was fetched and correctly bucketed as c_suite.

How it works

"Normalizing job titles" is usually two different problems wearing one name: (1) your CRM's title field is stale or was typed inconsistently, and (2) you want every record in one canonical taxonomy for reporting/segmentation. Clay's real contribution solves problem (1) — Person Job Title gives you the person's actual current title, fresh, from an identity lookup — not problem (2), which is a text-classification task you implement yourself. Treating this routine as a string-transformation function ("clean up this title text") is the trap; it's an identity-based lookup instead.

Common issues

Trying to pass an existing title string into this routine to get it cleaned up

Cause: assuming "job title normalization" means Clay has a text-cleanup function.

Fix: the routine takes an identity input (email/name/company), not a title string — there's no way to submit "Sr. SWE II" and get "Software Engineer" back from Clay directly.

Reading result["Person Job Title"] returns undefined

Cause: assuming the result key matches the full display name.

Fix: the real key is the shorter "Job Title".

Next steps

  • Batch this across a contact list using /routines/{routine_id}/run-batch/start for larger volumes.
  • Combine with the contact-waterfall enrichment example for a fuller profile refresh, not just title.

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/normalize-title -d '{\"workEmail\":\"<real work email>\"}'"
  expected_result: "A real, current job title is fetched for a real work email and correctly bucketed into a seniority tier by the custom classifier."