Cross-Platform Creator Profiles with One API (2026)
Look up the same creator on TikTok, YouTube, Instagram, and X with one schema and one API key — no per-platform scrapers, no drifting field names.
Agency rosters and creator databases usually start as a spreadsheet column per network: TikTok handle here, YouTube there, Instagram somewhere else. The painful part is not fetching four pages — it is deciding whether @janesmith on TikTok is the same person as @jane.smith on Instagram, then normalizing follower counts that YouTube calls subscribers and everyone else calls followers.
Product teams building creator intelligence need four things at once: discovery when the roster is empty, identity linking when handles disagree, comparable metrics for ranking, and fresh numbers before a client meeting. Home-grown scrapers tend to ship those one at a time, each with its own JSON shape and breakage schedule.
This guide walks through an identity resolution pass: the same workflow intelligence teams run before a campaign shortlist, but with API calls you can schedule, log, and replay. We use a fictional mid-tier fitness creator (sarahfit) as the running example; swap in your roster handles.
The short version
Call /v1/tiktok/profiles/{handle}, /v1/youtube/channel, /v1/instagram/profiles/{handle}, and /v1/twitter/profiles/{handle} with one API key. Every response shares the same envelope — branch on data.lookupStatus, merge into one warehouse row, and log meta.creditsCharged per platform.
You'll need an API key and the TypeScript SDK or curl. Try a live lookup in the Playground.
Why same handle ≠ same person
DIY scrapers force you to maintain four codepaths that break on different schedules. Social Fetch removes that maintenance, but it does not remove the identity problem: platforms do not share a global user ID you can join on.
| Pitfall | What goes wrong | What to do instead |
|---|---|---|
| Exact-handle fan-out | @nova on TikTok is a dancer; @nova on X is a game studio | Treat each platform lookup as a candidate edge, not ground truth |
| Ignoring bio links | CRM says sarahfit everywhere; her YouTube is SarahFitOfficial | Parse profile.bio and channel.description for declared URLs first |
| HTTP 200 = data | Private Instagram returns 200 with lookupStatus: "private" | Branch on lookupStatus, not status code |
| Stale CSV imports | Follower count from last quarter wins the sort | Store capturedAt and re-pull before client deliverables |
When a platform changes its HTML, we absorb it upstream. Your parser keeps reading metrics.followers (or the YouTube equivalent inside metrics) instead of re-mapping subscriberCount from a new JSON blob every month.
| DIY per platform | Social Fetch |
|---|---|
| Four proxy stacks, four breakage schedules | One maintained API surface |
Field names differ (followers vs subscriberCount) | Normalized metrics + profile objects |
| Auth and rate-limit logic duplicated | One x-api-key header on metered routes |
The response envelope
Every profile route returns the same top-level keys. Your ETL can treat TikTok and Instagram identically at the outer layer:
{
"data": {
"lookupStatus": "found",
"profile": {
"handle": "sarahfit",
"displayName": "Sarah Fit",
"verified": false,
"bio": "YT: youtube.com/@SarahFitOfficial"
},
"metrics": {
"followers": 284000,
"following": 412,
"likes": 4200000,
"videos": 318
}
},
"meta": {
"requestId": "req_01example",
"creditsCharged": 1,
"version": "v1"
}
}YouTube swaps profile for channel and may include metrics.subscribers — still inside data.metrics. Branch on lookupStatus before reading counts; not_found responses often omit metrics entirely.
Identity pipeline at a glance
Phase 0: Sketch the creator row
Before the first API call, decide what one person looks like in your database. A flat table works for agencies; graph products often use a parent creators row and child creator_platforms rows.
Minimum columns worth defining upfront:
| Column | Example | Source |
|---|---|---|
creatorId | crt_8f2a | your UUID |
platform | tiktok | enum per network |
handle | sarahfit | path param you queried |
lookupStatus | found | data.lookupStatus from that call |
followers | 284000 | metrics.followers or subscribers |
capturedAt | ISO timestamp | your clock at ingest |
requestId | req_01… | meta.requestId for support |
Also reserve matchMethod (declared_link, exact_handle, fuzzy_name, manual) so analysts know why two handles were linked. You will thank yourself when a client disputes a merge.
Phase 1: Anchor on one network
Pick the network where your seed data is most trustworthy — usually whichever handle the client gave you first, or TikTok if you discovered the creator via search.
TikTok profile lookup:
curl -sS \
-H "x-api-key: $SOCIALFETCH_API_KEY" \
"https://api.socialfetch.dev/v1/tiktok/profiles/charlidamelio"Reference: Get TikTok profile. One credit per completed lookup, including not_found.
Read the anchor response before fanning out:
import { SocialFetchClient } from "@socialfetch/sdk";
const client = new SocialFetchClient({
apiKey: process.env.SOCIALFETCH_API_KEY!,
});
const anchor = await client.tiktok.getProfile({ handle: "sarahfit" });
if (!anchor.ok) {
console.error(anchor.error.code, anchor.error.requestId);
process.exit(1);
}
const { lookupStatus, profile, metrics } = anchor.value.data;
if (lookupStatus !== "found") {
// Stop or try alternate spellings — don't burn 3 more credits blindly
console.log("anchor unresolved:", lookupStatus);
} else {
console.log(profile?.displayName, metrics?.followers);
}If the anchor is not_found, try common variants (sarah.fit, sarah_fit) before parallelizing the other networks. Each attempt is another credit.
Phase 2: Mine declared links from bios
Instagram and YouTube bios are where creators paste their “real” handles. Pull Instagram first when your CRM only has a first name or brand string — link-in-bio pages often list the TikTok @ explicitly.
const response = await fetch(
"https://api.socialfetch.dev/v1/instagram/profiles/instagram",
{
headers: {
"x-api-key": process.env.SOCIALFETCH_API_KEY,
},
}
);
const body = await response.json();
console.log(response.status, body);Reference: Get Instagram profile.
YouTube channel lookup accepts handle, channel ID, or URL in one request:
const params = new URLSearchParams({"handle":"mrbeast"});
const response = await fetch(
`https://api.socialfetch.dev/v1/youtube/channel?${params.toString()}`,
{
headers: {
"x-api-key": process.env.SOCIALFETCH_API_KEY,
},
}
);
const body = await response.json();
console.log(response.status, body);Reference: Get YouTube channel.
Extract handles from bio text with boring string rules before fuzzy matching:
function extractHandles(bio: string | undefined): string[] {
if (!bio) return [];
const matches = bio.match(/@[\w.]+/g) ?? [];
return [...new Set(matches.map((m) => m.slice(1).toLowerCase()))];
}
function extractUrls(bio: string | undefined): string[] {
if (!bio) return [];
return bio.match(/https?:\/\/[^\s]+/g) ?? [];
}Feed extractUrls output into YouTube's url query param when you see youtube.com/@… or youtu.be/… links. For Linktree-style domains, fetch the profile once, store the raw bio, and let a human confirm — do not auto-merge on a landing-page redirect alone.
X/Twitter uses the same path-parameter pattern as Instagram:
curl -sS \
-H "x-api-key: $SOCIALFETCH_API_KEY" \
"https://api.socialfetch.dev/v1/twitter/profiles/sarahfit"Reference: Get Twitter profile.
Phase 3: Fan out profile lookups
Once you have a handle list per platform (exact handle plus any bio-derived candidates), resolve them in parallel. Every platform returns the same top-level shape, so one loop handles all four.
import { SocialFetchClient } from "@socialfetch/sdk";
const client = new SocialFetchClient({
apiKey: process.env.SOCIALFETCH_API_KEY!,
});
const handle = "mrbeast";
const [tiktok, youtube, instagram] = await Promise.all([
client.tiktok.getProfile({ handle }),
client.youtube.getChannel({ handle }),
client.instagram.getProfile({ handle }),
]);
for (const result of [tiktok, youtube, instagram]) {
if (!result.ok) {
console.error(result.error.code, result.error.requestId);
continue;
}
console.log(result.value.data.lookupStatus, result.value.meta.creditsCharged);
}Production version with per-platform handle map and status logging:
import { SocialFetchClient } from "@socialfetch/sdk";
const client = new SocialFetchClient({
apiKey: process.env.SOCIALFETCH_API_KEY!,
});
type Platform = "tiktok" | "youtube" | "instagram" | "twitter";
const candidates: Record<Platform, string> = {
tiktok: "sarahfit",
youtube: "SarahFitOfficial", // from Instagram bio, not guessed
instagram: "sarahfit",
twitter: "sarahfit",
};
const lookups = await Promise.all([
client.tiktok.getProfile({ handle: candidates.tiktok }),
client.youtube.getChannel({ handle: candidates.youtube }),
client.instagram.getProfile({ handle: candidates.instagram }),
client.twitter.getProfile({ handle: candidates.twitter }),
]);
const platforms: Platform[] = ["tiktok", "youtube", "instagram", "twitter"];
for (let i = 0; i < lookups.length; i++) {
const result = lookups[i];
const platform = platforms[i];
if (!result.ok) {
console.error(platform, result.error.code, result.error.requestId);
continue;
}
const { lookupStatus, metrics } = result.value.data;
console.log(platform, lookupStatus, metrics, result.value.meta.creditsCharged);
}Each result carries its own meta.creditsCharged. A four-platform pass is typically four credits when all complete, even if two return not_found.
Concurrency: metered routes have no published rate cap; your balance is the limit. For imports of 5,000 creators, batch Promise.all in chunks of 50–100 so a single failure does not stall the whole file.
Phase 4: Disambiguate when handles diverge
Exact-handle fan-out is step one, not the finish line. Use a short decision tree:
- Declared link in bio — if Instagram bio contains
youtube.com/@SarahFitOfficial, that edge outranks guessingsarahfiton YouTube. - Display name + avatar hash — weak signal alone; useful when handles differ by one character (
sarahfitvssarah.fit). - Follower magnitude — a 2M TikTok paired with a 200-subscriber YouTube is probably wrong unless the bio says "new channel."
- Manual queue — anything below your confidence threshold gets
matchMethod: "manual"and skips auto-merge.
Store unresolved platforms as lookupStatus: "not_found" on the child row instead of copying the anchor's metrics. Empty cells in a vetting UI are honest; duplicated follower totals are not.
Worked example — CRM says sarahfit everywhere, but Instagram bio says youtube.com/@SarahFitOfficial:
| Step | Action | Outcome |
|---|---|---|
| 1 | GET /v1/instagram/profiles/sarahfit | found, bio contains YouTube URL |
| 2 | GET /v1/youtube/channel?handle=sarahfit | not_found — wrong handle |
| 3 | GET /v1/youtube/channel?handle=SarahFitOfficial | found, 98k subscribers |
| 4 | Merge with matchMethod: "declared_link" | YouTube row linked to parent crt_8f2a |
That middle not_found call still costs a credit. Parse bios before guessing identical handles — it is cheaper than fanning out blind and cleaner for your audit log.
Confidence scoring (optional, but teams ask for it):
function linkConfidence(signals: {
declaredUrl: boolean;
exactHandle: boolean;
displayNameMatch: boolean;
followerRatio: number; // max/min across platforms, 1 = identical
}): "high" | "medium" | "low" {
if (signals.declaredUrl) return "high";
if (signals.exactHandle && signals.displayNameMatch && signals.followerRatio < 5) {
return "medium";
}
return "low";
}Send low rows to a human queue. Auto-merge only high in production pipelines.
Phase 5: Normalize into one card
Map platform responses into one UI card without if (youtube) subscriberCount else followers scattered through React components:
type CreatorCard = {
creatorId: string;
displayName: string | null;
totalReach: number;
platforms: Array<{
platform: Platform;
handle: string;
lookupStatus: string;
followers: number | null;
verified: boolean;
capturedAt: string;
requestId: string;
}>;
};
function followersFromEnvelope(platform: Platform, data: Record<string, unknown>): number | null {
const metrics = data.metrics as Record<string, number> | undefined;
if (!metrics) return null;
// YouTube subscribers live in the same metrics object
return metrics.followers ?? metrics.subscribers ?? null;
}Pull displayName from whichever found platform you trust most — often Instagram or TikTok. YouTube's channel.title can differ ("Sarah Fit - Workouts" vs @sarahfit); show per-platform names in a detail drawer if they disagree.
Example merged JSON your dashboard API might return:
{
"creatorId": "crt_8f2a",
"displayName": "Sarah Fit",
"totalReach": 412000,
"platforms": [
{
"platform": "tiktok",
"handle": "sarahfit",
"lookupStatus": "found",
"followers": 284000,
"verified": false,
"capturedAt": "2026-06-30T14:00:00.000Z",
"requestId": "req_01example"
},
{
"platform": "youtube",
"handle": "SarahFitOfficial",
"lookupStatus": "found",
"followers": 98000,
"verified": true,
"capturedAt": "2026-06-30T14:00:01.000Z",
"requestId": "req_02example"
},
{
"platform": "instagram",
"handle": "sarahfit",
"lookupStatus": "found",
"followers": 30000,
"verified": false,
"capturedAt": "2026-06-30T14:00:02.000Z",
"requestId": "req_03example"
},
{
"platform": "twitter",
"handle": "sarahfit",
"lookupStatus": "not_found",
"followers": null,
"verified": false,
"capturedAt": "2026-06-30T14:00:03.000Z",
"requestId": "req_04example"
}
]
}totalReach is a business rule — sum only found platforms, or sum with a cap per network. Document the rule in code; the API will not pick for you.
Phase 6: Recent posts for engagement checks
Follower count gets you on a shortlist; median views tells you whether anyone watches. After profiles resolve, pull recent uploads for creators above your cutoff.
TikTok videos for the anchor handle:
curl -sS \
-H "x-api-key: $SOCIALFETCH_API_KEY" \
"https://api.socialfetch.dev/v1/tiktok/profiles/sarahfit/videos?sortBy=latest"Instagram posts paginate the same way:
curl -sS \
-H "x-api-key: $SOCIALFETCH_API_KEY" \
"https://api.socialfetch.dev/v1/instagram/profiles/sarahfit/posts"References: TikTok profile videos · Instagram profile posts.
Compute engagement in your warehouse — the API returns per-item metrics, not a black-box score:
const videos = result.value.data.videos ?? [];
const views = videos.map((v) => v.metrics?.views ?? 0).filter((n) => n > 0);
const medianViews = views.sort((a, b) => a - b)[Math.floor(views.length / 2)] ?? 0;Call GET /v1/tiktok/profiles/{handle} before interpreting an empty video list; list routes do not always expose private the same way profile routes do. See the capability matrix for route-specific lookupStatus behavior.
For spoken-content classification, pair post captions with transcript routes when hashtags are thin.
TypeScript pagination for TikTok videos (same cursor pattern as Reddit search in our other guides):
const allVideos = [];
let cursor: string | undefined;
do {
const result = await client.tiktok.getProfileVideos({
handle: "sarahfit",
sortBy: "latest",
cursor,
});
if (!result.ok) break;
allVideos.push(...(result.value.data.videos ?? []));
cursor = result.value.data.page.nextCursor ?? undefined;
} while (cursor);
const avgViews =
allVideos.reduce((sum, v) => sum + (v.metrics?.views ?? 0), 0) /
(allVideos.length || 1);Stop after two or three pages unless you are building a forensic report — most vetting workflows only need the last 12–20 posts.
Phase 7: Seed lists with discovery search
Identity resolution assumes you already have a name. Discovery fills the top of the funnel — keyword search on TikTok users when you expand a vertical.
curl -sS \
-H "x-api-key: $SOCIALFETCH_API_KEY" \
-G "https://api.socialfetch.dev/v1/tiktok/users/search" \
--data-urlencode "query=home workout"const result = await client.tiktok.searchUsers({ query: "home workout" });
if (result.ok) {
for (const user of result.value.data.users ?? []) {
console.log(user.handle, user.metrics?.followers);
}
}Reference: Search TikTok users. Persist each handle with the query that found it so you can re-run the same list after a model refresh.
Filter client-side on follower floors before spending four credits per person on full cross-platform enrichment. A search page plus 200 profile fan-outs is 201 credits — fine for a targeted import, expensive as a default loop.
TikTok video search (GET /v1/tiktok/search) is an alternate seed when you care about content niche more than account name — pull handles from high-view videos, then run Phase 1–5 on the unique set. Deduplicate handles before enrichment; the same creator may appear in twenty search hits.
Store rows your team can audit
Flatten API responses into rows your ops team already uses (Airtable, Postgres, Google Sheets):
| Column | Source | Why |
|---|---|---|
matchMethod | your resolver | Explains auto vs manual links |
lookupStatus | per platform | Drives UI badges (private, not_found) |
followers | metrics | Sort and filter |
verified | profile.verified | Contract eligibility |
requestId | meta.requestId | Support trail when a number looks wrong |
creditsCharged | meta.creditsCharged | Client-level cost allocation |
Re-pull on a cron before QBRs or campaign reviews. Credits are prepaid and do not expire between quarterly bursts, so a quiet graph between agency reporting cycles does not run up a subscription.
Weekly refresh job pattern (same idea as the social listening guide, but per creator row):
const roster = await db.creators.findMany({ where: { active: true } });
for (const row of roster) {
const handles = await db.creatorPlatforms.findMany({
where: { creatorId: row.id },
});
for (const platform of handles) {
// re-call the matching profile endpoint; upsert followers + lookupStatus
await sleep(50); // modest spacing during large rosters
}
}Write meta.requestId on every upsert. When a client says "this number is wrong," you can trace the exact lookup without re-running the whole import.
Troubleshooting
YouTube not_found but the channel loads in a browser
- Pass the full channel URL:
?url=https://www.youtube.com/@SarahFitOfficialinstead of a shortened handle. - Handles are case-sensitive on some legacy channels — copy the
@from the address bar. @handleand/c/ChannelNameURLs refer to different identifiers; use what the creator's bio declares.
Instagram found but follower count is null
privateaccounts may return identity fields without public metrics. ChecklookupStatusbefore showing zeros.- Re-fetch if the profile was public five minutes ago; creators flip privacy settings often.
TikTok handle exists; fan-out returns mixed results
- Regional or banned accounts can return
not_foundon one network only. LogrequestIdand retry once before marking dead. - Strip
@from path params —/v1/tiktok/profiles/@sarahfitis wrong; usesarahfit.
Merged card shows impossible total reach
- Dedupe by person, not by handle string. Two different people named
alexon TikTok and YouTube should stay two parent rows until bio links confirm a merge. - Do not sum
privateornot_foundplatforms intototalReachunless your product spec explicitly says to.
lookup_failed or HTTP 503
- Not charged. Retry with exponential backoff; pass the failed
meta.requestIdto support if it persists. - A burst of 10,000 parallel profile calls is unnecessary — chunk work.
Empty video/post lists on a found profile
- Genuinely inactive creators exist. Compare against
metrics.videosor post count on the profile object. - Paginate — first page may be empty for sort windows; check
data.page.hasMore.
Rate limits
- No hard rate cap on metered routes; your credit balance is the practical limit. Chunk large backfills (50–100 concurrent profile calls) so retries stay manageable.
SDK Result errors vs lookupStatus
result.ok === falsemeans transport or auth failed — checkresult.error.codeandresult.error.requestId.result.ok === truewithlookupStatus: "not_found"is a successful, billable domain outcome. Do not retry those unless the handle string changed.
Billing and credit budgeting
Each profile lookup is typically 1 credit when the call completes, including not_found and private. You are not charged for validation errors, lookup_failed, or 503 temporarily_unavailable.
Rough math for planning:
| Step | Calls per creator | Credits (approx.) |
|---|---|---|
| Anchor TikTok profile | 1 | 1 |
| Bio-driven Instagram + YouTube + X fan-out | 3 | 3 |
| Recent TikTok videos (1 page) | 1 | 1 |
| Recent Instagram posts (1 page) | 1 | 1 |
| Enriched creator row | 6 | 6 |
Import 500 creators with full enrichment: ~3,000 credits. Discovery search adds 1 credit per results page before you filter.
Agency tip: log meta.creditsCharged per creatorId and bill clients per import job using your own ledger. Public data only — lawful use is on you under Terms. Full matrix: Credits.
Batch import spreadsheet (500 rows, enrich everyone):
| Stage | Calculation | Credits |
|---|---|---|
| Profile fan-out (4 networks × 500) | 2,000 | 2,000 |
Skip not_found anchors (~8%) | −160 × 3 remaining calls | −480 |
| Video/post page for top 200 by followers | 200 × 2 | 400 |
| Net estimate | ~1,920 |
Run anchor lookup first and short-circuit dead rows — most savings come from not fanning out when TikTok returns not_found on a typoed handle.
What you can build
- Influencer vetting — reconcile CRM handles against live follower counts and flag
privateaccounts before outreach. - Cross-platform leaderboards — rank by summed reach with per-platform drill-down when totals tie.
- Agency reporting — one dashboard template; platform tabs read the same JSON parser.
- Creator graph products — parent/child rows with
matchMethodprovenance for merge audits. - Engagement benchmarks — median views per post across TikTok and Instagram for the same resolved person.
For a product-level overview of discovery, scoring, and refresh cadence, see the creator intelligence use case.
Next steps: Playground · Creator intelligence use case · TikTok scraping guide · API reference · Pricing