socially
v0.13.0
Published
Official TypeScript client for socially.run: public social profiles, posts, web pages, username search, email lookup and trends. Profile and post lookups answer found, not found or we could not check.
Maintainers
Readme
socially
The official TypeScript client for socially.run for public social profiles, posts, web pages, username search, email lookup and trends over a flat REST API, with answers that say what they know.
Every method, type and doc comment in this package is generated from the API's own operation definitions, so what TypeScript tells you here is what the server actually does.
npm install sociallyLook up a profile
import { Socially } from "socially";
const socially = new Socially({ apiKey: process.env.SOCIALLY_KEY! });
const result = await socially.profiles.lookup({ input: "https://instagram.com/smartgenius.app/" });
if (result.outcome === "found") console.log(result.instagram?.graphId);
// "17841435081119969" on the verified public readThe key comes from the console. A URL works as well as a handle, and with a URL the platform is read off the host.
Account ids and public details
Platform blocks sit beside profile: instagram, tiktok, youtube, x, facebook and github.
Instagram's userId is its pk; graphId is its public id. These are different
identifiers, and neither grants publishing, messaging or private-data access. The blocks
also carry source-provided links, creation dates, public counts and account flags.
Unsupported blocks are absent. Missing fields within a returned block are null.
Numeric ids are strings; unsafe JSON numbers become null instead of rounded ids.
These fields survive batch, cache and saved reads. extractedAt always names the
original read, not the time you asked again. Instagram's own profile page shows no post
likes or comments, so they are null unless the deployment has its Instagram reader on. Read
the post URL for its public counts.
const result = await socially.posts.lookup({ url: "https://instagram.com/p/DcMXl1IPNtB/" });
if (result.outcome === "found") console.log(result.frames, result.author.id);A full Instagram read includes source media ids and ordered frames, with image/video
URLs, dimensions, duration in seconds and alt text where available. Hidden counts and
missing dimensions are null. YouTube returns video/channel ids, title and description,
not a playable stream. An X post comes with exact likes, replies (comments), reposts
(shares) and views. Download media files: CDN URLs can expire.
Every post read says how much it saw. detailLevel is detailed for the post's own page or
API and embed for a thin public card, which leaves what it does not show as null.
confidence says how sure the road that answered is. A repeat from the cache keeps both.
Find popular recent videos
const result = await socially.search.youtube({ q: "electric cars", recency: "week", sort: "views" });
console.log(result.videos);This finds the most-watched videos uploaded in the past week. It does not measure
whether their views are growing faster. relevance keeps YouTube's own ranking.
Find reels on an Instagram topic
const result = await socially.search.instagram({ q: "travel" });
if (result.indexed) console.log(result.reels.map((reel) => [reel.author.handle, reel.plays]));This reads the popular page Instagram publishes for a word: up to 12 reels, each with its
exact play count. It is not a hashtag feed. Instagram has a page for some words only (travel
and music have one; nasa and fitness did not when we measured). A word with no page
answers indexed: false, which is free and does not mean nobody posts about it: try another
word. hasMore says there are more reels than the page lists, and they cannot be read without
signing in, so there is no second page.
Find videos on TikTok
const result = await socially.search.tiktok({ q: "padaria", recency: "month", sort: "likes", country: "BR" });
console.log(result.videos.map((video) => [video.author.handle, video.views, video.likes]));A word reads TikTok's search bar and keeps the window, the order and the country you asked for.
A #padaria reads the hashtag's own page instead: its popular videos of any age, from any
country, with no filters. A word's page lists up to 30 videos and a tag's about 20. Pass
result.nextCursor back as cursor for the next page, which is another read. Counts are
exact. The author's followers come on a word's results and are null on a hashtag's. A
deployment without a TikTok reader answers 503 and charges nothing.
Find posts under an Instagram hashtag
const result = await socially.search.instagramHashtag({ q: "padaria", sort: "recent" });
console.log(result.posts.map((post) => [post.author.handle, post.likes, post.plays]));sort: "top" (the default) is Instagram's own mix of popular and recent posts, in its order. It
is not a list ranked by likes, and it can reach back months. "recent" lists the newest first. A
page lists 24 to 33 posts. Pass result.nextCursor back as cursor for the next page, which is
another read; a limit that cuts the page leaves nextCursor null. Counts are exact, and a like
count the owner hid is null. A tag with no posts answers an empty page and costs nothing. A
deployment without an Instagram reader answers 503 and charges nothing.
socially.platforms.list() separates supported capabilities from source health.
status/strategies describe profile reads; postStatus/postStrategies describe
individual posts. A supported feature may still have no working source on a deployment.
YouTube has topic video search (topicVideoSearch) and a country trend board. TikTok has
topicVideoSearch and hashtagSearch. Instagram has indexedTopicSearch and hashtagSearch. Google Trends is a separate
source of socially.trending.list(), not a profile platform.
What a call cost
Methods return the answer and not the envelope, so the meter reading has to be asked for.
withMeta wraps one call and hands back both:
import { withMeta } from "socially";
const { data, meta } = await withMeta((o) =>
socially.profiles.lookup({ input: "@nasa", platform: "instagram" }, o),
);
console.log(meta?.cached, meta?.credits, meta?.creditsRemaining);A cache hit always costs 0 and says so. meta is undefined on the routes that carry no
meter, and on a retried call it reports the attempt that actually answered, which on a
repeat is usually the cache, and free.
Three answers, not two
Every profile and post lookup answers HTTP 200 with one of three outcomes:
found: the public page was read; the DTO carries what it showed.notFound: the platform said, authoritatively, that there is no such account.undecided: we could not check: blocked, rate-limited, timed out, or the page changed shape. Nothing was charged.retryableandretryAfterSecssay whether asking again is worth it.
Never read undecided as a typo in the handle. It is the honest answer when the outside
world did not cooperate, and treating it as "not found" is how a real account gets
reported missing.
Search a username
Username search, email lookup and dossiers run on the worker and answer a job:
const job = await socially.usernames.search({ username: "nasa", tier: "fast" });
// Either wait for it…
const settled = await socially.jobs.wait(job.id); // polls; throws WAIT_TIMEOUT after 5 min
console.log(settled.status, settled.counts, settled.hits.length);
// …or watch it land, site by site.
for await (const frame of socially.jobs.stream(job.id)) {
if (frame.type === "hit") console.log(frame.data.siteName, frame.data.outcome);
if (frame.type === "done") console.log(frame.data.status, frame.data.counts);
}jobs.stream reopens the socket with Last-Event-ID when it drops, so no frame is
missed. An identical request reuses the job while it is queued or running. A successful
result is cached for one hour and costs 0 to repeat. After that, or after a partial, failed
or cancelled job, the same request can start a new job and charge again. A partial job
keeps its charge, even when some sites answer undecided. Read creditsCharged and
creditsRefunded on the row.
Batch + intelligence
Ten profiles in one call, aggregated into one report:
const batch = await socially.profiles.batch(
{
targets: [{ input: "https://instagram.com/nasa" }, { input: "https://tiktok.com/@nasa" }],
aggregate: true,
},
{ timeoutMs: 130_000 }, // the server budgets 120 s for the whole batch
);
console.log(batch.intelligence?.consolidated.totalFollowers);
console.log(batch.failures); // read this one: a partial batch is still a 200Receive events
One endpoint per account receives job.completed and job.failed, signed. Verify
before you trust:
import { verifyWebhook, WebhookSignatureError } from "socially";
app.post("/webhooks/socially", express.raw({ type: "application/json" }), async (req, res) => {
try {
const event = await verifyWebhook({
payload: req.body, // the RAW body, not the parsed one
signature: req.header("x-socially-signature"),
secret: process.env.SOCIALLY_WEBHOOK_SECRET!,
});
if (event.type === "job.completed") await fetchHits(event.data.resultUrl);
res.sendStatus(200);
} catch (error) {
res.sendStatus(error instanceof WebhookSignatureError ? 400 : 500);
}
});The payload carries the job and its counts, never the hits, so fetch resultUrl for
those. Deliveries retry, so the same event.id can arrive twice: record it and drop
repeats.
Errors
Every failure is a SociallyError carrying the API's own code, plus status,
retryAfter, details and the requestId to quote at support.
import { SociallyError } from "socially";
try {
await socially.emails.lookup({ email });
} catch (error) {
if (!(error instanceof SociallyError)) throw error;
if (error.code === "PAYMENT_REQUIRED") return askThemToTopUp();
if (error.code === "PLAN_GATE") return askThemToUpgrade(error.details);
if (error.code === "AUP_REQUIRED") return sendThemToAcceptThePolicy();
throw error;
}Two rules worth knowing before you write a retry loop of your own:
PAYMENT_REQUIREDandPLAN_GATEnever clear by waiting, unlikeRATE_LIMIT_EXCEEDEDandQUOTA_EXCEEDED, which carry aretryAfter.undecidedis not an error. It is data on a 200, and this client never retries it for you. The DTO says whether you should.
Retries
A lookup charges on a cache miss and refunds our-side failures. An identical job request
reuses a queued or running job, or a successful result cached for one hour. Outside those
cases it can create a new job and charge again. The client retries a call that got no
answer, a 408, 425 or 429, or a 5xx: twice by default, for GET and POST alike. It waits about 1 s before the first
retry and about 2 s before the second, or as long as Retry-After says when that is 10 s or
less. A longer wait comes straight back to you, as the error's retryAfter.
A call with refresh: true skips the cache on every attempt, and so does web.read with
render: "always". Each attempt reads again and charges again. So if the first attempt was
read and charged but its answer never reached you, the retry is a second charge. To read
fresh exactly once, make those calls on a client that does not retry:
const once = new Socially({ apiKey: process.env.SOCIALLY_KEY!, maxRetries: 0 });
await once.profiles.lookup({ input: "@nasa", platform: "instagram", refresh: true });webhooks.test fires a real delivery, so it is repeated only after a 408, 425 or 429, which
say the delivery never started. A spent quota, a plan gate, a suppressed profile or a platform
that is down are answers, not hiccups, and come straight back to you. So does any refusal
whose body says retryAfterSecs: null, which means waiting will never fix it.
A call's last argument takes timeoutMs, a timeout for that call alone, and signal, which
stops the call and any retry of it when you abort it.
Agents
If you are wiring up an LLM rather than an app, you probably want the MCP server
instead of this package: the same operations, as tools, at
https://api.socially.run/mcp with the same so_ key. See
the MCP reference.
Before you ship
socially.run reads public pages only and never goes past a login. What a platform
shows can be stale, partial or rate-limited, so results carry a confidence and an
undecided outcome instead of a guess. Username search matches on names, and names are
shared. Treat a hit as a lead, not an identity. People can opt out at
socially.run/opt-out. After contact-email confirmation and
review, an approved exclusion blocks reads of the named profile, email address or website.
A profile exclusion applies to that platform; it does not block the same username across
other sites in a username search or dossier.
Email lookup needs a paid plan and an explicit acceptance of the
acceptable-use policy.
Requirements
Node 20+, or any runtime with fetch and Web Crypto (Bun, Deno, Cloudflare Workers,
the browser, though an so_ key does not belong in a browser). ESM only; on Node
22.12+ require("socially") works too.
- Docs: socially.run/docs
- REST reference: socially.run/docs/api
