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.
@switchback.ty
good0.8418.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.6411.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.5568K · 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.4235K · 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
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.
pnpm add influship
export INFLUSHIP_API_KEY="your_api_key"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);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:
{
"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:
Raw export
applicants.csv
free500Clean handles
dupes, reel links, junk
free472Look up profiles
14 not indexed
45.8458Screen
29 had gone quiet
free429Score fit
47 good, 132 neutral, 250 avoid
429429Find emails
on file for 7 of the 47
357
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:
mkdir list-enricher && cd list-enricher
pnpm init
pnpm add influship csv-parse csv-stringify
pnpm add -D tsx typescript @types/nodeSet "type": "module" in package.json and add an enrich script that runs tsx src/enrich.ts. The finished project has two source files:
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 spreadsheet | After cleaning |
|---|---|
| https://www.instagram.com/Switchback.Ty/?igsh=MWQ1Z | switchback.ty |
| @milesandmud | milesandmud |
| instagram.com/cedar.runs | cedar.runs |
| @switchback.ty (second application) | rejected: duplicate |
| https://www.instagram.com/reel/C8xYz12AbCd/ | rejected: post or reel link |
| my ig is same as tiktok | rejected: 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:
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.
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 };
}Each profile carries what the next steps need:
metrics: followers, engagement rate (a percentage, so3.5means 3.5%), posts in the last 30 days and posts per week.activity.last_post_atandgrowth.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:
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;
}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.
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@cedar.runs68K · event recaps
- 2@fjell.racer35K · competing sponsor
- 3@switchback.ty18.3K · ultras and gear
- 4@milesandmud11.3K · training logs
Sorted by fit
- 1@switchback.ty0.84good
- 2@milesandmud0.64neutral
- 3@cedar.runs0.55neutral
- 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 verbatimevidence_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:
{
"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):
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:
// 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;
}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:
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;
}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.
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:
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:
$ 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:
$ 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,neutralwidens email lookup;noneskips it.--min-followersand--inactive-daystune screening (defaults 1,000 and 60).--input,--briefand--outpoint 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:
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(),
);
}
}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,urlorprofile. - API error 401
INFLUSHIP_API_KEYis 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
goodcreators 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.
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);
});