Enrich and score an influencer list from a CSV

Turn a messy spreadsheet of Instagram handles into a ranked shortlist with the latest metrics, campaign-fit scores backed by post evidence, and emails where they are on file.

By Elliot Padfield · October 14, 2026

Time
30 minutes
Level
Intermediate
Stack
Node.js 22, TypeScript, Influship SDK
Cost
About $1 per 100 rows, measured on a real 500-row run

You end with

A command-line tool that reads any creator spreadsheet and writes a ranked CSV with metrics, fit decisions, evidence quotes and emails.

Say you are the only developer at Fernway, a small brand that makes a lightweight trail shoe. Marketing opened an affiliate program last month and hands you the export: 500 rows. Half the cells are profile URLs with tracking junk on the end. Some people applied twice. Someone pasted a link to a reel instead of their profile. Plenty of the applicants are not runners at all.

The question marketing wants answered is simple: who should we sign? By the end of this guide you will have a command-line tool that answers it. It reads the spreadsheet and writes a ranked one, where every creator has their latest metrics, a good, neutral or avoid decision against Fernway's brief with the reasons behind it, and an email address when one is on file.

enriched.csvsorted by decision, then fit score
  • @switchback.ty

    good0.84

    18.3K · 5.2% ER

    Races 100-mile trail ultras and builds regular runs on unfamiliar trails. Reviewed a running pack after long use, and his disclosed partners are a pack and a watch brand, not a shoe company.

    “My favorite pieces of gear are the ones that I’ve had the longest”

    email: valid

  • @milesandmud

    neutral0.64

    11.3K · 0.9% ER

    Documents marathon and ultra training with practical fueling and pacing detail. No shoe-review focus in the available posts, so a better fit for broader kit or training content.

    email: not looked up

  • @cedar.runs

    neutral0.55

    68K · 3.9% ER

    Took part in a trail-running event and has partnered with running brands on it. Posts point to event participation more than regular trail racing or gear reviews.

    email: not looked up

  • @fjell.racer

    avoid0.42

    35K · 4.7% ER

    Award-winning ultra-trail runner with major race wins, but his athlete deal with a large shoe brand conflicts with your request to avoid competing shoe sponsors.

    email: not looked up

Four rows scored against Fernway's brief. Handles are changed and brand names removed. Decisions and scores are as the API returned them; reasons are condensed.

Look at @fjell.racer. He is a race winner with strong engagement, the kind of account a follower filter puts near the top. He is also an athlete for a competing shoe brand, which makes him the one person on this list Fernway cannot sign. Nothing in his follower count says so. His posts do, and the scoring step read them.

Why this is hard to build yourself

Metrics are the easy part. The hard part is the judgement call, and doing it without an API means stitching together three systems:

  • A scraper to pull each creator's profile and recent posts, kept working as Instagram changes.
  • An LLM prompt per creator that reads those posts against your brief, plus the plumbing to keep its answers consistent and tied to the posts they came from.
  • An email finder from another vendor, matched back to the right person.

The Influship API does all three behind three endpoints. Your job shrinks to the part that is specific to your business: cleaning your list, writing a good brief, and deciding what to spend.

Your first call

Before building anything, see the core of it work on one creator. Install the SDK, set your key, and score a single handle against a one-line brief. New accounts get 500 starter credits with no card, and this call costs one.

bash
pnpm add influship
export INFLUSHIP_API_KEY="your_api_key"
first-call.ts
import Influship from 'influship';

const client = new Influship(); // reads INFLUSHIP_API_KEY

const { data } = await client.creators.match({
  creators: [{ platform: 'instagram', username: 'a_handle_from_your_list' }],
  intent: {
    query: 'Affiliate program for a trail running shoe brand',
    context: 'Avoid creators tied to a competing shoe sponsor.',
  },
});

console.log(data[0].match);
TypeScript SDK setup

Run it with npx tsx first-call.ts. It takes a few seconds, because the API is reading the creator's posts. For @fjell.racer, the response looks like this:

response (handle and brand name changed)
{
  "decision": "avoid",
  "score": 0.42,
  "reasons": [
    {
      "text": "He is an award-winning ultra-trail runner with major race wins, but his athlete status with a large shoe brand conflicts with your request to avoid creators tied to a competing shoe sponsor.",
      "provenance": "inferred"
    }
  ]
}

That is the whole value of the tool in one request. The rest of this guide is the engineering around it: running it over 500 messy rows without wasting credits, failing safely, and producing a file marketing can use.

The plan

The tool runs Fernway's list through six stages. The rule that shapes it: run the cheapest checks first, on the most rows, and save the expensive calls for the creators who survive. This is the real list going through the finished tool:

fernway applicantscredits per stage
  1. Raw export

    applicants.csv

    500
    free
  2. Clean handles

    dupes, reel links, junk

    472
    free
  3. Look up profiles

    14 not indexed

    458
    45.8
  4. Screen

    29 had gone quiet

    429
    free
  5. Score fit

    47 good, 132 neutral, 250 avoid

    429
    429
  6. Find emails

    on file for 7 of the 47

    7
    35
A real run of the finished tool over a 500-row list built from live searches across trail running, road running, hiking, fitness and outdoor accounts. Prices are the published ones (1 credit = $0.01).

Two more rules keep the bill predictable:

  • Every paid request is cached on disk, keyed by its exact inputs. If the run dies halfway, or you tweak the brief and run again, you only pay for requests whose inputs changed.
  • A hard budget (--max-credits) stops the run before any request that could push spend past it. The CSV still gets written with everything finished so far.

Set up the project

You need Node.js 22 or later. Create the project and add two CSV packages next to the SDK:

bash
mkdir list-enricher && cd list-enricher
pnpm init
pnpm add influship csv-parse csv-stringify
pnpm add -D tsx typescript @types/node

Set "type": "module" in package.json and add an enrich script that runs tsx src/enrich.ts. The finished project has two source files:

applicants.csv# the list
brief.md# what you are scoring against
src/handles.ts# step 1
src/enrich.ts# steps 2 to 6
enriched.csv# the output

Clean the handles

500 rows472 usable handles

Fernway's export is a typical one. Before any of it goes near an API, each cell has to become a lowercase Instagram username, or be rejected with a reason someone can act on:

In the spreadsheetAfter cleaning
https://www.instagram.com/Switchback.Ty/?igsh=MWQ1Zswitchback.ty
@milesandmudmilesandmud
instagram.com/cedar.runscedar.runs
@switchback.ty (second application)rejected: duplicate
https://www.instagram.com/reel/C8xYz12AbCd/rejected: post or reel link
my ig is same as tiktokrejected: not a valid username

The core of src/handles.ts is one function. It pulls the username out of a profile URL, refuses post and reel links, and validates what is left:

src/handles.ts
export function normalizeHandle(raw: string): string | { reason: string } {
  let value = raw.trim();
  if (!value) return { reason: 'empty' };

  // Profile URLs: instagram.com/name, instagram.com/name/?igsh=..., m.instagram.com/name
  const url = value.match(/instagram\.com\/([^/?#\s]+)/i);
  if (url) {
    if (RESERVED_PATHS.has(url[1].toLowerCase())) {
      return { reason: 'post or reel link, not a profile' };
    }
    value = url[1];
  } else if (/^https?:\/\//i.test(value)) {
    return { reason: 'not an Instagram link' };
  }

  value = value.replace(/^@+/, '').toLowerCase();
  if (!VALID_USERNAME.test(value)) return { reason: 'not a valid Instagram username' };
  return value;
}

The rest of the file finds the handle column, drops duplicates and keeps each row's original number, so every line of the output maps back to the spreadsheet (full file). On Fernway's list it rejected 28 rows: 18 repeat applications, 6 reel links and 4 cells with no handle in them.

Refuse to guess. A reel link names a post, not a person. Resolving it to whoever posted it is possible, and wrong often enough to hurt. Reject the row and let the list owner fix it.

Resolve handles to profiles

472 handles458 profiles, 14 not indexed

POST /v1/profiles/lookup takes up to 100 usernames per request and returns the indexed ones in data and the rest in not_found. It costs 0.1 credits per profile found: 45.8 credits for Fernway's whole list, in about four seconds.

src/enrich.ts
async function resolveProfiles(handles: Handle[]) {
  const profiles = new Map<string, ProfileResponseData>();
  const notFound: string[] = [];

  for (const batch of chunk(handles, 100)) {
    const result = await cached('lookup', batch.map((h) => h.username), batch.length * PRICE.lookup, () =>
      client.profiles
        .lookup({ profiles: batch.map((h) => ({ platform: 'instagram', username: h.username })) })
        .withResponse(),
    );
    for (const profile of result.data) profiles.set(profile.username.toLowerCase(), profile);
    for (const missing of result.not_found) notFound.push(missing.username.toLowerCase());
  }

  return { profiles, notFound };
}
POST /v1/profiles/lookup reference

Each profile carries what the next steps need:

  • metrics: followers, engagement rate (a percentage, so 3.5 means 3.5%), posts in the last 30 days and posts per week.
  • activity.last_post_at and growth.followers_30d_pct.
  • data_updated_at: when Influship last collected the profile. Metrics and activity are as of that date, and the script writes it into every row.
  • creator_id, which scoring and email lookup use. One person can own several accounts, and this ID ties them together.

Every paid call goes through cached(). It hashes the request inputs and returns the saved response if it has one. If not, it checks the budget, counting requests already in flight, makes the call, and adds the actual charge from the X-Credits-Charged response header to a running total:

src/enrich.ts
let spent = 0;
let reserved = 0; // worst-case cost of requests still in flight

class BudgetExceeded extends Error {}

/**
 * Runs a paid request once per unique payload. Re-running the script after a crash,
 * or after editing the brief, only pays for requests whose inputs changed.
 */
async function cached<T>(
  step: string,
  payload: unknown,
  maxCost: number,
  request: () => Promise<{ data: T; response: Response }>,
): Promise<T> {
  const key = createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 16);
  const file = `.cache/${step}-${key}.json`;
  if (existsSync(file)) return JSON.parse(await readFile(file, 'utf8')) as T;

  if (spent + reserved + maxCost > MAX_CREDITS) {
    throw new BudgetExceeded(
      `${step} could cost up to ${maxCost} credits; ${(MAX_CREDITS - spent).toFixed(1)} left of --max-credits ${MAX_CREDITS}`,
    );
  }

  reserved += maxCost;
  try {
    const { data, response } = await request();
    spent += Number(response.headers.get('x-credits-charged') ?? 0);
    await mkdir('.cache', { recursive: true });
    await writeFile(file, JSON.stringify(data));
    return data;
  } finally {
    reserved -= maxCost;
  }
}

/** Runs `work` over `items` with at most `limit` in flight. Stops taking new items after a failure. */
async function inParallel<T>(items: T[], limit: number, work: (item: T) => Promise<void>) {
  let next = 0;
  let failed = false;
  const worker = async () => {
    while (!failed && next < items.length) {
      const item = items[next++];
      try {
        await work(item);
      } catch (error) {
        failed = true;
        throw error;
      }
    }
  };
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
}

function chunk<T>(items: T[], size: number): T[][] {
  const chunks: T[][] = [];
  for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size));
  return chunks;
}
Pricing and the X-Credits-Charged header

Screen out obvious misses for free

458 profiles429 worth scoring

Private accounts, accounts under a follower floor and accounts that had stopped posting can be dropped with data you already have. On Fernway's list this kept 29 creators out of the paid scoring step.

src/enrich.ts
function screenOut(profile: ProfileResponseData): string | null {
  if (profile.is_private) return 'private account';
  if (profile.metrics.followers < MIN_FOLLOWERS) return `under ${MIN_FOLLOWERS} followers`;

  // last_post_at is as of data_updated_at, when Influship last collected the profile.
  // Measure the gap at that moment, not against today, or older snapshots look inactive.
  // A null on either side means "unknown", not "inactive": only screen out what you can prove.
  const lastPost = profile.activity.last_post_at;
  const collected = profile.data_updated_at;
  if (lastPost && collected) {
    const days = (Date.parse(collected) - Date.parse(lastPost)) / 86_400_000;
    if (days > INACTIVE_DAYS) return `no posts in ${Math.floor(days)} days before ${collected.slice(0, 10)}`;
  }
  return null;
}

Measure inactivity at the snapshot, not today. The first version of this script compared last_post_at with today's date, and it screened out 433 of Fernway's 458 creators, most of whom post every week. Stored profiles are collected on a schedule, so a creator whose profile was collected in May shows a May last_post_at even if they posted this morning. The fix is to compare against data_updated_at: did they post in the 60 days before Influship last looked?

Treat null as unknown. If either date is missing, the script does not know, and it keeps the creator in. Screening out unknowns as if they were failures is the most common way enrichment scripts throw away good creators.

Don't filter on engagement rate here. Normal engagement varies a lot by niche, format and audience size, and a cutoff tuned for one campaign silently drops strong creators in the next. Keep it as a column and sort on it during review.

Score fit against the brief

429 creators47 good · 132 neutral · 250 avoid

This is the step that makes the tool worth building. Here are the four creators from the preview, ranked the way a metrics filter would rank them, and the way the brief does:

Sorted by followers

  1. 1@cedar.runs68K · event recaps
  2. 2@fjell.racer35K · competing sponsor
  3. 3@switchback.ty18.3K · ultras and gear
  4. 4@milesandmud11.3K · training logs

Sorted by fit

  1. 1@switchback.ty0.84good
  2. 2@milesandmud0.64neutral
  3. 3@cedar.runs0.55neutral
  4. 4@fjell.racer0.42avoid

The creator Fernway should sign is third by followers. The one it must not sign is second. Across the full list, most of the 250 avoids were simply outside the niche: van builds, bodybuilding, acting. The interesting ones were runners. The same sponsor rule rejected a 369K-follower athlete with partnerships at three shoe brands.

POST /v1/creators/match takes a list of creators and a campaign intent, reads each creator's content against it, and returns a decision, a score from 0 to 1 for ranking within a decision, and reasons. Each reason says how it is grounded:

  • post_evidence: backed by a specific post, often with a verbatim evidence_quote.
  • profile_fact: backed by a fact on the profile.
  • inferred: reasoning over the profile without a single post to point at.

The good fit's reasons show the difference. Each one is tied to something he posted:

response for @switchback.ty (trimmed, brands removed)
{
  "decision": "good",
  "score": 0.84,
  "reasons": [
    { "text": "He races 100-mile trail ultras and is building regular runs on unfamiliar trails, so trail running is central to his training.", "provenance": "post_evidence" },
    { "text": "He has tested and reviewed running kit in a branded post, praising a pack's comfort and durability after extensive use.", "provenance": "post_evidence" },
    { "text": "His disclosed brand affiliations are with a pack brand and a watch brand, rather than a competing shoe sponsor.", "provenance": "post_evidence" }
  ]
}

The brief lives in brief.md. Its first paragraph becomes the intent query (up to 500 characters). Everything after the first blank line becomes context (up to 2,000 characters):

brief.md
Affiliate program for Fernway, a trail running shoe brand: creators who run trails and talk honestly about gear

Small DTC brand selling a lightweight trail shoe. We want people who race or train on trails and review shoes or kit in their own words. Avoid pure lifestyle accounts and creators tied to a competing shoe sponsor.

Put your dealbreakers in the context, and say exactly what you mean. On Fernway's list, "creators tied to a competing shoe sponsor" also rejected a 248K-follower trail runner whose honest shoe reviews matched the brief almost word for word. Her conflict was a past partnership with a large brand's trail line. If past deals are fine for you, write "current sponsors only". Exclusivity, audience, tone and the claims you cannot make belong in the context too. A vague brief gets vague decisions.

The scoring call sends creator IDs from step 2, not raw handles, so nothing fails on an unknown username and two accounts owned by one person are scored once:

src/enrich.ts
// Scoring reads each creator's recent posts, which takes a few seconds per creator.
// Small batches running side by side finish sooner than one big batch, and every request
// stays well inside the client timeout.
const MATCH_BATCH = 10;
const MATCH_PARALLEL = 4;

async function scoreFit(creatorIds: string[], brief: { query: string; context?: string }) {
  const matches = new Map<string, Match>();

  await inParallel(chunk(creatorIds, MATCH_BATCH), MATCH_PARALLEL, async (batch) => {
    const result = await cached('match', { batch, brief }, batch.length * PRICE.match, () =>
      client.creators
        .match({ creators: batch.map((id) => ({ creator_id: id })), intent: brief })
        .withResponse(),
    );
    for (const item of result.data) matches.set(item.creator.id, item.match);
    process.stdout.write(`\rScored ${matches.size} of ${creatorIds.length}`);
  });
  process.stdout.write('\n');

  return matches;
}
How match reasons and provenance work

Small batches, side by side. Scoring reads each creator's recent posts, which takes a few seconds per creator: a batch of 10 takes about 30 seconds. The script sends batches of 10, four at a time, so every request finishes well inside the client timeout and the 429 creators on Fernway's list took about seven minutes. A Scored 120 of 429 line keeps you posted.

The cache key includes the brief. When marketing rewrites it, the next run re-scores everyone against the new version while the profile lookups stay cached and free. Expect borderline creators to move a little when they are re-scored: two runs over Fernway's list gave 54 and 47 good fits. Treat the decision as a strong first pass, and read the reasons on anyone near the line.

Find emails for the good fits

47 good fits7 emails

POST /v1/creator-emails/lookup returns the addresses Influship has on file for each creator. It costs 5 credits per creator with at least one address and nothing for the rest, so it only runs on the decisions listed in --emails (default good). When a creator has several addresses, the script keeps the strongest by status, then prefers the primary one:

src/enrich.ts
const EMAIL_RANK: Record<Email['status'], number> = {
  creator_verified: 0,
  valid: 1,
  unvalidated: 2,
  risky: 3,
};

async function findEmails(creatorIds: string[]) {
  const emails = new Map<string, Email>();

  for (const batch of chunk(creatorIds, 100)) {
    const result = await cached('emails', batch, batch.length * PRICE.email, () =>
      client.creatorEmails
        .lookup({ creators: batch.map((id) => ({ creator_id: id })) })
        .withResponse(),
    );
    for (const row of result.data.results) {
      const best = [...row.emails].sort(
        (a, b) => EMAIL_RANK[a.status] - EMAIL_RANK[b.status] || Number(b.is_primary) - Number(a.is_primary),
      )[0];
      if (row.creator_id && best) emails.set(row.creator_id, best);
    }
  }

  return emails;
}
Email lookup guide and status meanings

Plan for partial coverage. Many creators keep their contact details out of public view. On Fernway's list, 7 of the 47 good fits had an address on file: 4 valid and 3 unvalidated, for 35 credits. For everyone else, the profile_url column is one click from a DM or the contact link in their bio. Because empty results are free, there is no penalty for asking.

Write the ranked CSV

500 rows inenriched.csv

Every input row comes out the other side, including the ones that never reached the API. Scored creators sort first (good, then neutral, then avoid, each by score), followed by screened-out, not-indexed and rejected rows, each with a note saying why.

src/enrich.ts
const DECISION_ORDER: Record<Decision, number> = { good: 0, neutral: 1, avoid: 2 };
const PROVENANCE_ORDER = { post_evidence: 0, profile_fact: 1, inferred: 2 } as const;

type Row = Record<string, string | number | null>;

function scoredRow(handle: Handle, profile: ProfileResponseData, match?: Match, email?: Email): Row {
  const reasons = [...(match?.reasons ?? [])].sort(
    (a, b) => PROVENANCE_ORDER[a.provenance] - PROVENANCE_ORDER[b.provenance],
  );
  const evidence = reasons.find((r) => r.evidence_quote)?.evidence_quote ?? null;

  return {
    username: handle.username,
    status: match ? 'scored' : 'not scored',
    decision: match?.decision ?? null,
    fit_score: match ? Number(match.score.toFixed(2)) : null,
    why: reasons.map((r) => r.text).join(' | ') || null,
    evidence_quote: evidence,
    email: email?.email ?? null,
    email_status: email?.status ?? null,
    followers: profile.metrics.followers,
    engagement_rate_pct: profile.metrics.engagement_rate,
    posts_last_30d: profile.metrics.posts_last_30d,
    follower_growth_30d_pct: profile.growth.followers_30d_pct,
    last_post_at: profile.activity.last_post_at,
    profile_url: profile.url,
    data_updated_at: profile.data_updated_at,
    note: null,
    source_row: handle.row,
  };
}

function skippedRow(username: string, row: number, status: string, note: string): Row {
  return { username, status, note, source_row: row };
}

function sortRows(rows: Row[]) {
  const rank = (row: Row) =>
    row.status === 'scored' ? DECISION_ORDER[row.decision as Decision] : 3;
  return rows.sort(
    (a, b) => rank(a) - rank(b) || Number(b.fit_score ?? 0) - Number(a.fit_score ?? 0),
  );
}

main() wires the steps together. A finally block writes the scored rows even if the email step fails, and a budget stop or a 402 ends the run with a partial CSV instead of a stack trace:

src/enrich.ts
async function main() {
  const csv = await readFile(args.input, 'utf8');
  const records = parse(csv, { columns: true, skip_empty_lines: true, bom: true }) as Record<string, string>[];
  const column = pickHandleColumn(Object.keys(records[0] ?? {}));
  const { handles, rejected } = extractHandles(records, column);

  const [query, ...rest] = (await readFile(args.brief, 'utf8')).trim().split(/\n\s*\n/);
  const brief = { query: query.slice(0, 500), context: rest.join('\n\n').slice(0, 2000) || undefined };

  const worstCase =
    handles.length * PRICE.lookup +
    handles.length * PRICE.match +
    (EMAIL_DECISIONS.size ? handles.length * PRICE.email : 0);
  console.log(`${handles.length} usable handles from column "${column}", ${rejected.length} rejected`);
  console.log(`Worst case: ${worstCase.toFixed(1)} credits. Budget: ${MAX_CREDITS} credits.`);
  if (args['dry-run']) return;

  const rows: Row[] = rejected.map((r: Rejected) => skippedRow(r.raw, r.row, 'rejected', r.reason));
  let stoppedEarly: string | null = null;

  try {
    const { profiles, notFound } = await resolveProfiles(handles);
    console.log(`Resolved ${profiles.size}, not indexed ${notFound.length}`);

    const eligible = new Map<string, Handle>(); // creator_id -> first handle seen
    for (const handle of handles) {
      const profile = profiles.get(handle.username);
      if (!profile) continue;
      const reason = screenOut(profile);
      if (reason) rows.push({ ...scoredRow(handle, profile), status: 'screened out', note: reason });
      else if (!eligible.has(profile.creator_id)) eligible.set(profile.creator_id, handle);
      else rows.push({ ...scoredRow(handle, profile), status: 'duplicate creator', note: 'same creator as another row' });
    }

    let matches = new Map<string, Match>();
    let emails = new Map<string, Email>();
    try {
      matches = await scoreFit([...eligible.keys()], brief);
      const contactable = [...matches].filter(([, m]) => EMAIL_DECISIONS.has(m.decision)).map(([id]) => id);
      emails = await findEmails(contactable);
    } finally {
      for (const [creatorId, handle] of eligible) {
        const profile = profiles.get(handle.username);
        if (profile) rows.push(scoredRow(handle, profile, matches.get(creatorId), emails.get(creatorId)));
      }
    }

    for (const username of notFound) {
      const handle = handles.find((h) => h.username === username);
      rows.push(skippedRow(username, handle?.row ?? 0, 'not indexed', args['ingest-missing'] ? 'queued for indexing; re-run tomorrow' : 'use --ingest-missing to queue'));
    }
    if (args['ingest-missing'] && notFound.length) await ingestMissing(notFound);
  } catch (error) {
    if (error instanceof BudgetExceeded) stoppedEarly = error.message;
    else if (error instanceof APIError && error.status === 402) stoppedEarly = 'out of API credits';
    else throw error;
  }

  await writeFile(args.out, stringify(sortRows(rows), { header: true, columns: COLUMNS }));

  const count = (decision: Decision) => rows.filter((r) => r.decision === decision).length;
  console.log(`good ${count('good')} · neutral ${count('neutral')} · avoid ${count('avoid')}`);
  console.log(`Emails found: ${rows.filter((r) => r.email).length}`);
  console.log(`Spent ${spent.toFixed(2)} credits ($${(spent / 100).toFixed(2)}). Wrote ${args.out}`);
  if (stoppedEarly) console.log(`Stopped early: ${stoppedEarly}. Re-run to resume; finished steps are cached.`);
}

const COLUMNS = [
  'username', 'status', 'decision', 'fit_score', 'why', 'evidence_quote', 'email', 'email_status',
  'followers', 'engagement_rate_pct', 'posts_last_30d', 'follower_growth_30d_pct', 'last_post_at',
  'profile_url', 'data_updated_at', 'note', 'source_row',
];

main().catch((error) => {
  if (error instanceof APIError) {
    console.error(`API error ${error.status}: ${error.message}`);
    console.error(`Request ID: ${error.headers?.get('x-request-id') ?? 'unknown'}`);
  } else {
    console.error(error);
  }
  process.exit(1);
});

Run it

Start with a dry run. It parses the CSV and prints the worst-case cost without calling the API:

terminal
$ pnpm enrich --input applicants.csv --dry-run472 usable handles from column "instagram", 28 rejectedWorst case: 2879.2 credits. Budget: 300 credits.

The worst case assumes every handle resolves, passes screening, scores good and has an email. Real lists land far below it, and the budget check stops the run before it can overspend. Give it a budget and let it go:

real run over Fernway's 500 rows, 7m 39s
$ pnpm enrich --input applicants.csv --max-credits 900472 usable handles from column "instagram", 28 rejectedWorst case: 2879.2 credits. Budget: 900 credits.Resolved 458, not indexed 14Scored 429 of 429good 47 · neutral 132 · avoid 250Emails found: 7Spent 509.80 credits ($5.10). Wrote enriched.csv

Open enriched.csv in a spreadsheet. The top 47 rows are the creators worth a conversation: race champions, ultra coaches, runners who test kit on camera, each with the reason in plain English. Marketing can filter on decision, sort by engagement_rate_pct and read the why column before anyone sends a message. The bottom of the file lists the rows to fix and resubmit.

That run cost $5.10 for 500 rows, about a cent a row. Re-running with a new brief costs only the scoring stage, $4.29 here, and runs over the same list after that come from the cache.

The flags you are most likely to change:

  • --emails good,neutral widens email lookup; none skips it.
  • --min-followers and --inactive-days tune screening (defaults 1,000 and 60).
  • --input, --brief and --out point at other files, so one list can be scored against several briefs.

Creators Influship hasn't indexed yet

The 14 handles in not_found cost nothing and land in the CSV as not indexed. Run with --ingest-missing to queue them:

src/enrich.ts
async function ingestMissing(usernames: string[]) {
  // The ingest endpoint is capped at 50 new creators per UTC day.
  for (const username of usernames.slice(0, 50)) {
    await cached('ingest', username, PRICE.ingest, () =>
      client
        .post<unknown>('/v1/creators/ingest', {
          body: { platform: 'instagram', username, source_query: 'list enrichment' },
        })
        .withResponse(),
    );
  }
}
On-demand ingestion guide

Ingest costs 5 credits per new creator, up to 50 a day. The SDK has no dedicated method for it, so the script uses the client's generic post(), which keeps authentication and retries. Run the script again the next day: the new creators resolve, and everything else comes from the cache.

Troubleshooting

No handle column found
Rename the column with the handles to instagram, handle, username, ig, url or profile.
API error 401
INFLUSHIP_API_KEY is missing or revoked. Export it in the same shell that runs the script.
The Scored line is slow to move
Each batch of 10 takes about 30 seconds, four at a time. If a batch fails, the run stops and every finished batch is already cached.
Stopped early: out of API credits
The account balance ran out (a 402). Add billing, then run the same command again: finished steps come from the cache.
Stopped early: … left of --max-credits
Your budget, working as intended. Raise --max-credits and re-run to continue where it stopped.
Almost everyone is screened out as inactive
Compare against data_updated_at, not today, as step 3 does. Then check --inactive-days.

Where to take it next

  • Put it where your team works. Guide 02 turns the same API into a Slack command, so anyone on the team can ask for creators without opening a terminal.
  • Run it on new applicants every week. Point a scheduled job at the latest export. Creators you have already scored come from the cache for free.
  • Check the shortlist live before you sign. For the final few, the live Instagram profile endpoint returns today's follower count and recent posts for 0.18 credits each.
  • Grow the list once you know who works. The lookalike endpoint takes your best good creators as seeds and finds similar ones. The Claude Code pipeline guide covers discovery and lookalikes end to end.
  • TikTok and YouTube lists. Lookup, scoring and email lookup cover Instagram. For other platforms, the live platform endpoints return profile and video data per handle, and the screening step works the same way on that data.

The complete script

src/enrich.ts in full. Together with src/handles.ts it is the whole tool.

src/enrich.ts
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { parseArgs } from 'node:util';
import { parse } from 'csv-parse/sync';
import { stringify } from 'csv-stringify/sync';
import Influship, { APIError } from 'influship';
import type { CreatorEmailLookupResponse } from 'influship/resources/creator-emails';
import type { CreatorMatchResponse } from 'influship/resources/creators';
import type { ProfileResponseData } from 'influship/resources/profiles';
import { extractHandles, type Handle, pickHandleColumn, type Rejected } from './handles.js';

type Match = CreatorMatchResponse.Data['match'];
type Email = CreatorEmailLookupResponse.Data.Result['emails'][number];
type Decision = Match['decision'];

// ---------- configuration ----------

const { values: args } = parseArgs({
  options: {
    input: { type: 'string', default: 'creators.csv' },
    brief: { type: 'string', default: 'brief.md' },
    out: { type: 'string', default: 'enriched.csv' },
    'max-credits': { type: 'string', default: '300' },
    'min-followers': { type: 'string', default: '1000' },
    'inactive-days': { type: 'string', default: '60' },
    emails: { type: 'string', default: 'good' }, // "good", "good,neutral" or "none"
    'ingest-missing': { type: 'boolean', default: false },
    'dry-run': { type: 'boolean', default: false },
  },
});

const MAX_CREDITS = Number(args['max-credits']);
const MIN_FOLLOWERS = Number(args['min-followers']);
const INACTIVE_DAYS = Number(args['inactive-days']);
const EMAIL_DECISIONS = new Set(args.emails === 'none' ? [] : args.emails.split(','));

// Published prices, in credits (1 credit = $0.01). The response headers are the source of truth.
const PRICE = { lookup: 0.1, match: 1, email: 5, ingest: 5 };

// Reads INFLUSHIP_API_KEY. 429s and 503s are retried after the Retry-After delay.
const client = new Influship({ timeout: 90_000, maxRetries: 5 });

// ---------- cost tracking and a disk cache ----------

let spent = 0;
let reserved = 0; // worst-case cost of requests still in flight

class BudgetExceeded extends Error {}

/**
 * Runs a paid request once per unique payload. Re-running the script after a crash,
 * or after editing the brief, only pays for requests whose inputs changed.
 */
async function cached<T>(
  step: string,
  payload: unknown,
  maxCost: number,
  request: () => Promise<{ data: T; response: Response }>,
): Promise<T> {
  const key = createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 16);
  const file = `.cache/${step}-${key}.json`;
  if (existsSync(file)) return JSON.parse(await readFile(file, 'utf8')) as T;

  if (spent + reserved + maxCost > MAX_CREDITS) {
    throw new BudgetExceeded(
      `${step} could cost up to ${maxCost} credits; ${(MAX_CREDITS - spent).toFixed(1)} left of --max-credits ${MAX_CREDITS}`,
    );
  }

  reserved += maxCost;
  try {
    const { data, response } = await request();
    spent += Number(response.headers.get('x-credits-charged') ?? 0);
    await mkdir('.cache', { recursive: true });
    await writeFile(file, JSON.stringify(data));
    return data;
  } finally {
    reserved -= maxCost;
  }
}

/** Runs `work` over `items` with at most `limit` in flight. Stops taking new items after a failure. */
async function inParallel<T>(items: T[], limit: number, work: (item: T) => Promise<void>) {
  let next = 0;
  let failed = false;
  const worker = async () => {
    while (!failed && next < items.length) {
      const item = items[next++];
      try {
        await work(item);
      } catch (error) {
        failed = true;
        throw error;
      }
    }
  };
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
}

function chunk<T>(items: T[], size: number): T[][] {
  const chunks: T[][] = [];
  for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size));
  return chunks;
}

// ---------- step 2: resolve handles to profiles ----------

async function resolveProfiles(handles: Handle[]) {
  const profiles = new Map<string, ProfileResponseData>();
  const notFound: string[] = [];

  for (const batch of chunk(handles, 100)) {
    const result = await cached('lookup', batch.map((h) => h.username), batch.length * PRICE.lookup, () =>
      client.profiles
        .lookup({ profiles: batch.map((h) => ({ platform: 'instagram', username: h.username })) })
        .withResponse(),
    );
    for (const profile of result.data) profiles.set(profile.username.toLowerCase(), profile);
    for (const missing of result.not_found) notFound.push(missing.username.toLowerCase());
  }

  return { profiles, notFound };
}

// ---------- step 3: screen out obvious misses for free ----------

function screenOut(profile: ProfileResponseData): string | null {
  if (profile.is_private) return 'private account';
  if (profile.metrics.followers < MIN_FOLLOWERS) return `under ${MIN_FOLLOWERS} followers`;

  // last_post_at is as of data_updated_at, when Influship last collected the profile.
  // Measure the gap at that moment, not against today, or older snapshots look inactive.
  // A null on either side means "unknown", not "inactive": only screen out what you can prove.
  const lastPost = profile.activity.last_post_at;
  const collected = profile.data_updated_at;
  if (lastPost && collected) {
    const days = (Date.parse(collected) - Date.parse(lastPost)) / 86_400_000;
    if (days > INACTIVE_DAYS) return `no posts in ${Math.floor(days)} days before ${collected.slice(0, 10)}`;
  }
  return null;
}

// ---------- step 4: score campaign fit ----------

// Scoring reads each creator's recent posts, which takes a few seconds per creator.
// Small batches running side by side finish sooner than one big batch, and every request
// stays well inside the client timeout.
const MATCH_BATCH = 10;
const MATCH_PARALLEL = 4;

async function scoreFit(creatorIds: string[], brief: { query: string; context?: string }) {
  const matches = new Map<string, Match>();

  await inParallel(chunk(creatorIds, MATCH_BATCH), MATCH_PARALLEL, async (batch) => {
    const result = await cached('match', { batch, brief }, batch.length * PRICE.match, () =>
      client.creators
        .match({ creators: batch.map((id) => ({ creator_id: id })), intent: brief })
        .withResponse(),
    );
    for (const item of result.data) matches.set(item.creator.id, item.match);
    process.stdout.write(`\rScored ${matches.size} of ${creatorIds.length}`);
  });
  process.stdout.write('\n');

  return matches;
}

// ---------- step 5: emails for the creators worth contacting ----------

const EMAIL_RANK: Record<Email['status'], number> = {
  creator_verified: 0,
  valid: 1,
  unvalidated: 2,
  risky: 3,
};

async function findEmails(creatorIds: string[]) {
  const emails = new Map<string, Email>();

  for (const batch of chunk(creatorIds, 100)) {
    const result = await cached('emails', batch, batch.length * PRICE.email, () =>
      client.creatorEmails
        .lookup({ creators: batch.map((id) => ({ creator_id: id })) })
        .withResponse(),
    );
    for (const row of result.data.results) {
      const best = [...row.emails].sort(
        (a, b) => EMAIL_RANK[a.status] - EMAIL_RANK[b.status] || Number(b.is_primary) - Number(a.is_primary),
      )[0];
      if (row.creator_id && best) emails.set(row.creator_id, best);
    }
  }

  return emails;
}

// ---------- optional: queue creators Influship has not indexed yet ----------

async function ingestMissing(usernames: string[]) {
  // The ingest endpoint is capped at 50 new creators per UTC day.
  for (const username of usernames.slice(0, 50)) {
    await cached('ingest', username, PRICE.ingest, () =>
      client
        .post<unknown>('/v1/creators/ingest', {
          body: { platform: 'instagram', username, source_query: 'list enrichment' },
        })
        .withResponse(),
    );
  }
}

// ---------- step 6: write the ranked CSV ----------

const DECISION_ORDER: Record<Decision, number> = { good: 0, neutral: 1, avoid: 2 };
const PROVENANCE_ORDER = { post_evidence: 0, profile_fact: 1, inferred: 2 } as const;

type Row = Record<string, string | number | null>;

function scoredRow(handle: Handle, profile: ProfileResponseData, match?: Match, email?: Email): Row {
  const reasons = [...(match?.reasons ?? [])].sort(
    (a, b) => PROVENANCE_ORDER[a.provenance] - PROVENANCE_ORDER[b.provenance],
  );
  const evidence = reasons.find((r) => r.evidence_quote)?.evidence_quote ?? null;

  return {
    username: handle.username,
    status: match ? 'scored' : 'not scored',
    decision: match?.decision ?? null,
    fit_score: match ? Number(match.score.toFixed(2)) : null,
    why: reasons.map((r) => r.text).join(' | ') || null,
    evidence_quote: evidence,
    email: email?.email ?? null,
    email_status: email?.status ?? null,
    followers: profile.metrics.followers,
    engagement_rate_pct: profile.metrics.engagement_rate,
    posts_last_30d: profile.metrics.posts_last_30d,
    follower_growth_30d_pct: profile.growth.followers_30d_pct,
    last_post_at: profile.activity.last_post_at,
    profile_url: profile.url,
    data_updated_at: profile.data_updated_at,
    note: null,
    source_row: handle.row,
  };
}

function skippedRow(username: string, row: number, status: string, note: string): Row {
  return { username, status, note, source_row: row };
}

function sortRows(rows: Row[]) {
  const rank = (row: Row) =>
    row.status === 'scored' ? DECISION_ORDER[row.decision as Decision] : 3;
  return rows.sort(
    (a, b) => rank(a) - rank(b) || Number(b.fit_score ?? 0) - Number(a.fit_score ?? 0),
  );
}

// ---------- main ----------

async function main() {
  const csv = await readFile(args.input, 'utf8');
  const records = parse(csv, { columns: true, skip_empty_lines: true, bom: true }) as Record<string, string>[];
  const column = pickHandleColumn(Object.keys(records[0] ?? {}));
  const { handles, rejected } = extractHandles(records, column);

  const [query, ...rest] = (await readFile(args.brief, 'utf8')).trim().split(/\n\s*\n/);
  const brief = { query: query.slice(0, 500), context: rest.join('\n\n').slice(0, 2000) || undefined };

  const worstCase =
    handles.length * PRICE.lookup +
    handles.length * PRICE.match +
    (EMAIL_DECISIONS.size ? handles.length * PRICE.email : 0);
  console.log(`${handles.length} usable handles from column "${column}", ${rejected.length} rejected`);
  console.log(`Worst case: ${worstCase.toFixed(1)} credits. Budget: ${MAX_CREDITS} credits.`);
  if (args['dry-run']) return;

  const rows: Row[] = rejected.map((r: Rejected) => skippedRow(r.raw, r.row, 'rejected', r.reason));
  let stoppedEarly: string | null = null;

  try {
    const { profiles, notFound } = await resolveProfiles(handles);
    console.log(`Resolved ${profiles.size}, not indexed ${notFound.length}`);

    const eligible = new Map<string, Handle>(); // creator_id -> first handle seen
    for (const handle of handles) {
      const profile = profiles.get(handle.username);
      if (!profile) continue;
      const reason = screenOut(profile);
      if (reason) rows.push({ ...scoredRow(handle, profile), status: 'screened out', note: reason });
      else if (!eligible.has(profile.creator_id)) eligible.set(profile.creator_id, handle);
      else rows.push({ ...scoredRow(handle, profile), status: 'duplicate creator', note: 'same creator as another row' });
    }

    let matches = new Map<string, Match>();
    let emails = new Map<string, Email>();
    try {
      matches = await scoreFit([...eligible.keys()], brief);
      const contactable = [...matches].filter(([, m]) => EMAIL_DECISIONS.has(m.decision)).map(([id]) => id);
      emails = await findEmails(contactable);
    } finally {
      for (const [creatorId, handle] of eligible) {
        const profile = profiles.get(handle.username);
        if (profile) rows.push(scoredRow(handle, profile, matches.get(creatorId), emails.get(creatorId)));
      }
    }

    for (const username of notFound) {
      const handle = handles.find((h) => h.username === username);
      rows.push(skippedRow(username, handle?.row ?? 0, 'not indexed', args['ingest-missing'] ? 'queued for indexing; re-run tomorrow' : 'use --ingest-missing to queue'));
    }
    if (args['ingest-missing'] && notFound.length) await ingestMissing(notFound);
  } catch (error) {
    if (error instanceof BudgetExceeded) stoppedEarly = error.message;
    else if (error instanceof APIError && error.status === 402) stoppedEarly = 'out of API credits';
    else throw error;
  }

  await writeFile(args.out, stringify(sortRows(rows), { header: true, columns: COLUMNS }));

  const count = (decision: Decision) => rows.filter((r) => r.decision === decision).length;
  console.log(`good ${count('good')} · neutral ${count('neutral')} · avoid ${count('avoid')}`);
  console.log(`Emails found: ${rows.filter((r) => r.email).length}`);
  console.log(`Spent ${spent.toFixed(2)} credits ($${(spent / 100).toFixed(2)}). Wrote ${args.out}`);
  if (stoppedEarly) console.log(`Stopped early: ${stoppedEarly}. Re-run to resume; finished steps are cached.`);
}

const COLUMNS = [
  'username', 'status', 'decision', 'fit_score', 'why', 'evidence_quote', 'email', 'email_status',
  'followers', 'engagement_rate_pct', 'posts_last_30d', 'follower_growth_30d_pct', 'last_post_at',
  'profile_url', 'data_updated_at', 'note', 'source_row',
];

main().catch((error) => {
  if (error instanceof APIError) {
    console.error(`API error ${error.status}: ${error.message}`);
    console.error(`Request ID: ${error.headers?.get('x-request-id') ?? 'unknown'}`);
  } else {
    console.error(error);
  }
  process.exit(1);
});
TypeScript SDK reference

Questions

How much does it cost to enrich 1,000 influencer handles?

About $10. A real run over a 500-row list cost $5.10: 45.8 credits to look up 458 profiles, 429 to score the creators that passed screening, and 35 for the 7 good fits with an email on file. Screened-out accounts and empty email results cost nothing, and re-running with a new brief only pays for scoring.

Can I find influencer emails from a list of Instagram handles?

Yes. POST /v1/creator-emails/lookup accepts up to 100 handles or creator IDs per request and returns known addresses with a status (creator_verified, valid, unvalidated or risky) and when Influship last saw them. You only pay for creators that return at least one email. Check the status before you send, because a returned address is not a deliverability guarantee.

What happens to handles Influship has not indexed?

They come back in the not_found array of the profile lookup and cost nothing. You can queue up to 50 new public Instagram creators per day with POST /v1/creators/ingest for 5 credits each, then re-run the script once they are processed.

Does this work for TikTok and YouTube creators?

Profile lookup, fit scoring and email lookup cover Instagram. For TikTok and YouTube lists, use the live platform endpoints to pull profile and video data per handle, then apply your own screening rules.

Why score fit instead of filtering on followers and engagement?

Metrics tell you an account is alive. They cannot tell you whether a runner is sponsored by a competing shoe brand or posts gear reviews. The match endpoint reads the creator’s content against your brief and returns a good, neutral or avoid decision with reasons, many of them tied to a specific post.

Run it on your own list.

Start with 500 one-time credits. No card, no expiry. Every request returns what it cost.