npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 socially

Look 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 read

The 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. retryable and retryAfterSecs say 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 200

Receive 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_REQUIRED and PLAN_GATE never clear by waiting, unlike RATE_LIMIT_EXCEEDED and QUOTA_EXCEEDED, which carry a retryAfter.
  • undecided is 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.