<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[StoriesFly Dev]]></title><description><![CDATA[StoriesFly Dev]]></description><link>https://storiesfly.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 04 Nov 2026 07:20:52 GMT</lastBuildDate><atom:link href="https://storiesfly.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Get Instagram Story Alerts in Telegram with a Node.js Script]]></title><description><![CDATA[Stories disappear after 24 hours, and Instagram has no way to say "tell me when this account posts one" for accounts you do not run. If you follow a brand…]]></description><link>https://storiesfly.hashnode.dev/get-instagram-story-alerts-in-telegram-with-a-node-js-script</link><guid isPermaLink="true">https://storiesfly.hashnode.dev/get-instagram-story-alerts-in-telegram-with-a-node-js-script</guid><category><![CDATA[Node.js]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[instagram]]></category><category><![CDATA[telegram]]></category><dc:creator><![CDATA[Sly Bye]]></dc:creator><pubDate>Fri, 30 Oct 2026 16:12:56 GMT</pubDate><content:encoded><![CDATA[<p>Stories disappear after 24 hours, and Instagram has no way to say "tell me when this account posts one" for accounts you do not run. If you follow a brand, a creator or a competitor's public account and want a message the moment a new story appears, a short polling script does the job.</p>
<p><em>Updated October 2026. Originally published on <a href="https://storiesfly.com/blog/instagram-story-alerts-nodejs">storiesfly.com</a>.</em></p>
<p>This post builds one in Node.js: poll the stories of a list of accounts, remember which story ids you have already seen, save the media, and send a Telegram message for each new story. It also works out the cost per day, because polling is billed per request and the interval is the number you control.</p>
<p>Code was type-checked against <code>storiesfly</code> 1.1.0 (ESM, Node 18 or newer, no runtime dependencies). If you want the result without writing code, see the last section.</p>
<h2>What you need</h2>
<ul>
<li>A StoriesFly API key. Sign in with Telegram at <a href="https://storiesfly.com/developers/dashboard">storiesfly.com/developers/dashboard</a>. A new account gets 100 free requests.</li>
<li>A Telegram bot token from <a href="https://t.me/BotFather">@BotFather</a>, and the chat id the messages should go to. Send your bot a message, then open <code>https://api.telegram.org/bot&lt;token&gt;/getUpdates</code> to see the chat id.</li>
</ul>
<pre><code class="language-sh">npm install storiesfly
export STORIESFLY_API_KEY="your_key"
export TELEGRAM_BOT_TOKEN="123456:ABC..."
export TELEGRAM_CHAT_ID="123456789"
</code></pre>
<p>You never log in to Instagram with your own account. You send an API key, and your account never appears among a story's viewers.</p>
<h2>What the stories call returns</h2>
<pre><code class="language-ts">import { StoriesFly } from "storiesfly";

const client = new StoriesFly({ apiKey: process.env.STORIESFLY_API_KEY! });

const { user, stories } = await client.user.stories({ username: "instagram" });

console.log(user.id, stories.length);
console.log(stories[0]);
</code></pre>
<p>Output shape (values are illustrative):</p>
<pre><code class="language-json">{
  "id": "3012345678901234567",
  "url": "https://www.instagram.com/stories/instagram/3012345678901234567/",
  "taken_at": 1793000000,
  "taken_at_iso": "2026-10-28T09:30:00.000Z",
  "media_type": "video",
  "media_url": "https://...",
  "thumbnail_url": "https://...",
  "video_duration": 12.4
}
</code></pre>
<p><code>stories</code> is empty when the account has none up. Ids are strings, which matters: Instagram ids are larger than 2^53 and a JSON number would round them. Always store and compare them as strings.</p>
<h2>The polling script</h2>
<p>The logic is a diff: fetch the stories that are up now, drop the ids you have already seen, alert on the rest, save the new ids.</p>
<pre><code class="language-ts">import { readFile, writeFile } from "node:fs/promises";
import { StoriesFly, StoriesFlyError, type Story } from "storiesfly";

const client = new StoriesFly({ apiKey: process.env.STORIESFLY_API_KEY! });

const ACCOUNTS = ["instagram", "natgeo"];
const INTERVAL_MS = 30 * 60 * 1000; // every 30 minutes
const SEEN_FILE = "./seen-stories.json";

type Seen = Record&lt;string, string[]&gt;; // username -&gt; story ids

async function loadSeen(): Promise&lt;Seen&gt; {
  try {
    return JSON.parse(await readFile(SEEN_FILE, "utf8")) as Seen;
  } catch {
    return {};
  }
}

async function notify(username: string, story: Story): Promise&lt;void&gt; {
  const text = [
    `New ${story.media_type} story from @${username}`,
    story.taken_at_iso ?? "",
    story.media_url ?? story.url ?? "",
  ]
    .filter(Boolean)
    .join("\n");

  const res = await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ chat_id: process.env.TELEGRAM_CHAT_ID, text }),
  });
  if (!res.ok) console.error("Telegram error", res.status, await res.text());
}

async function checkAccount(username: string, seen: Seen): Promise&lt;void&gt; {
  const { stories } = await client.user.stories({ username });
  const known = new Set(seen[username] ?? []);
  const fresh = stories.filter((s) =&gt; !known.has(s.id));

  // Oldest first, so the chat reads in order.
  fresh.sort((a, b) =&gt; (a.taken_at ?? 0) - (b.taken_at ?? 0));
  for (const story of fresh) await notify(username, story);

  // Keep only ids that are still up, so the file does not grow forever.
  seen[username] = stories.map((s) =&gt; s.id);
}

async function runOnce(): Promise&lt;void&gt; {
  const seen = await loadSeen();
  for (const username of ACCOUNTS) {
    try {
      await checkAccount(username, seen);
    } catch (err) {
      if (err instanceof StoriesFlyError) {
        console.error(`@${username}: ${err.code} (request ${err.requestId})`);
        if (err.code === "credits_exhausted") return; // stop until you top up
      } else {
        throw err;
      }
    }
  }
  await writeFile(SEEN_FILE, JSON.stringify(seen));
}

await runOnce();
setInterval(() =&gt; void runOnce(), INTERVAL_MS);
</code></pre>
<p>Notes on the choices in that script:</p>
<ul>
<li><strong>First run.</strong> Everything that is up when you start counts as new, so you get one batch of alerts. To avoid that, run once with the <code>notify</code> call commented out, then turn it back on.</li>
<li><strong>Why store only ids that are still up.</strong> A story lives about 24 hours, so there is no reason to remember an id longer. The file stays small.</li>
<li><strong>Errors.</strong> A private account answers <code>private_account</code> and a missing one <code>not_found</code>. Both are charged, so remove such usernames from the list rather than polling them forever. <code>rate_limited</code> is retried by the client twice before it reaches your <code>catch</code>.</li>
<li><strong>One failure does not stop the others.</strong> The <code>try</code> is inside the loop over accounts.</li>
<li><strong>Order of writes.</strong> The seen file is written after the alerts, so a crash between the two can send a duplicate alert. That is the safer direction compared with missing one.</li>
</ul>
<h2>Cron instead of setInterval</h2>
<p>A long-running <code>setInterval</code> is fine on a VPS. If you prefer cron, drop the last line (<code>setInterval</code>) and run the script once per tick:</p>
<pre><code class="language-sh">*/30 * * * * cd /path/to/project &amp;&amp; node poll.mjs &gt;&gt; poll.log 2&gt;&amp;1
</code></pre>
<p>Cron survives restarts and leaves nothing running between ticks. If you compile the script from TypeScript, point cron at the compiled <code>.js</code> file.</p>
<h2>Save the media soon</h2>
<p>The <code>media_url</code> values are signed links, and they expire within days. A story is gone from the account after a day, and the file link does not last much longer. If you want to keep it, download it when the alert fires:</p>
<pre><code class="language-ts">import { mkdir, writeFile } from "node:fs/promises";
import type { Story } from "storiesfly";

export async function saveStory(username: string, story: Story): Promise&lt;string | null&gt; {
  if (!story.media_url) return null;

  const res = await fetch(story.media_url);
  if (!res.ok) return null;

  const ext = story.media_type === "video" ? "mp4" : "jpg";
  await mkdir(`./stories/${username}`, { recursive: true });
  const path = `./stories/${username}/${story.id}.${ext}`;
  await writeFile(path, Buffer.from(await res.arrayBuffer()));
  return path;
}
</code></pre>
<p>Call <code>saveStory(username, story)</code> next to <code>notify</code>. If you would rather get the file in Telegram than a link, the Bot API has <code>sendVideo</code> and <code>sendPhoto</code>, which take a URL.</p>
<h2>What it costs</h2>
<p>Each <code>client.user.stories</code> call is one request, and requests start from $1.50 per 1,000. The cost is accounts x polls per day:</p>
<table>
<thead>
<tr>
<th>Accounts</th>
<th>Interval</th>
<th>Requests / day</th>
<th>Cost / day</th>
<th>Cost / 30 days</th>
</tr>
</thead>
<tbody><tr>
<td>5</td>
<td>60 min</td>
<td>120</td>
<td>$0.18</td>
<td>$5.40</td>
</tr>
<tr>
<td>20</td>
<td>30 min</td>
<td>960</td>
<td>$1.44</td>
<td>$43.20</td>
</tr>
<tr>
<td>20</td>
<td>15 min</td>
<td>1,920</td>
<td>$2.88</td>
<td>$86.40</td>
</tr>
<tr>
<td>100</td>
<td>60 min</td>
<td>2,400</td>
<td>$3.60</td>
<td>$108</td>
</tr>
</tbody></table>
<p>The interval is the lever. Stories stay up for a day, so polling every 30 to 60 minutes still catches each one with hours to spare. Polling every minute only buys you earlier alerts, not more of them. A good rule is to poll busy accounts often and quiet ones rarely: keep two lists with two intervals.</p>
<p>Two things cost nothing to check. Your balance (<code>client.account()</code> is free) and the per-call charge (pass <code>{ withMeta: true }</code> and read <code>meta.unitsCharged</code>). Add a guard that stops the script when <code>balanceRemaining</code> falls below a number you pick. It comes as a string in US dollars, so compare <code>Number(meta.balanceRemaining)</code>.</p>
<h2>Privacy, in one paragraph</h2>
<p>You never log in with your own account, so your account never appears among a story's viewers. The person is not notified. The script reads public stories only: a private account gives <code>private_account</code>, and you cannot get around that.</p>
<h2>Limits to know about</h2>
<ul>
<li>It only sees stories that are up now. There is no history, so if the script was down for two days, you missed those stories.</li>
<li>It detects a story at the next poll, not at the moment it is posted. The delay is at most your interval.</li>
<li>Highlights are a separate call (<code>client.user.highlights</code>), not part of this script.</li>
</ul>
<h2>If you do not want to run this yourself</h2>
<p>The script above is a few dozen lines, but you have to host it, keep the bot token safe and watch the balance. If you only want the alerts, <a href="https://storiesfly.com">StoriesFly</a> has a Telegram bot and a tracker that does this without code: you add an account and get a message when it posts. The API is the better fit if you want the data inside your own app.</p>
<h2>FAQ</h2>
<h3>How do I get the Instagram story API without logging in?</h3>
<p>Use an API key from the dashboard and call <code>client.user.stories({ username })</code>. There is no Instagram login, cookie or session on your side.</p>
<h3>Can I look up an account by id?</h3>
<p>Yes. <code>client.user.stories({ id })</code> takes an id instead of a username. Pass one of the two, never both.</p>
<h3>How often should I poll?</h3>
<p>Every 30 to 60 minutes is enough for most uses, since a story stays up for a day.</p>
<h3>Will the account know I looked?</h3>
<p>The person is not notified.</p>
<h3>Do the media links last?</h3>
<p>No. They expire within days, so save the files when you get the alert.</p>
<p>Full method list and response types: <a href="https://storiesfly.com/developers/docs">storiesfly.com/developers/docs</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Instagram Data API in 2026: What Meta's API Can and Can't Do]]></title><description><![CDATA[You want Instagram data in your app. The first question is whose data it is. Meta's official API is built around accounts that connect to your app. Public…]]></description><link>https://storiesfly.hashnode.dev/instagram-data-api-in-2026-what-meta-s-api-can-and-can-t-do</link><guid isPermaLink="true">https://storiesfly.hashnode.dev/instagram-data-api-in-2026-what-meta-s-api-can-and-can-t-do</guid><category><![CDATA[instagram]]></category><category><![CDATA[api]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Sly Bye]]></dc:creator><pubDate>Fri, 30 Oct 2026 16:10:36 GMT</pubDate><content:encoded><![CDATA[<p>You want Instagram data in your app. The first question is whose data it is. Meta's official API is built around accounts that connect to your app. Public data from accounts that never heard of your app is a different job. This article draws the line using Meta's current documentation, and shows what to do on the other side of it.</p>
<p><em>Updated October 2026. Originally published on <a href="https://storiesfly.com/blog/instagram-data-api-2026">storiesfly.com</a>.</em></p>
<h2>TL;DR: pick by need (as of 2026-10-28)</h2>
<table>
<thead>
<tr>
<th>You need</th>
<th>Use</th>
</tr>
</thead>
<tbody><tr>
<td>Publish, moderate comments, read insights, handle DMs for accounts that connect to your app</td>
<td>Meta's Instagram API (Instagram Login or Facebook Login)</td>
</tr>
<tr>
<td>Follower count and recent posts of another Business or Creator account</td>
<td>Business Discovery (Meta, Facebook Login setup)</td>
</tr>
<tr>
<td>Posts under a hashtag, up to 30 hashtags per 7 days</td>
<td>Hashtag Search (Meta, Facebook Login setup)</td>
</tr>
<tr>
<td>A personal account's feed through a consumer API</td>
<td>Nothing from Meta: Basic Display API ended on 2024-12-04</td>
</tr>
<tr>
<td>Follower or following lists, stories, or profile data of accounts you don't own, with no login</td>
<td>A REST API for public data, such as StoriesFly</td>
</tr>
<tr>
<td>The same data inside ChatGPT or Claude</td>
<td>MCP server (last section)</td>
</tr>
</tbody></table>
<h2>What Meta's official API is</h2>
<p>Meta's <a href="https://developers.facebook.com/docs/instagram-platform/overview">Instagram Platform overview</a> describes two setups:</p>
<ul>
<li><strong>Instagram API with Instagram Login.</strong> App users sign in with Instagram credentials, no Facebook Page is needed, and requests go to <code>graph.instagram.com</code>. Scopes are the <code>instagram_business_*</code> family. See the <a href="https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login">setup page</a>.</li>
<li><strong>Instagram API with Facebook Login.</strong> App users sign in with Facebook, the account must be linked to a Facebook Page, and requests go to <code>graph.facebook.com</code>.</li>
</ul>
<p>Both serve Instagram professional accounts: business or creator accounts. The Instagram Login page lists comment moderation, content publishing, media insights, mentions and messaging as what the API is for.</p>
<p>Basic Display is gone. Meta's <a href="https://developers.facebook.com/blog/post/2024/09/04/update-on-instagram-basic-display-api">announcement</a> says that starting on December 4th, 2024 the API is no longer available and all its requests fail, and that after that date there is no set of Instagram APIs for consumer developer apps. Meta points business and creator use cases to the two setups above.</p>
<h2>Official API: can and cannot</h2>
<p>"Own" means accounts whose owners connected to your app and granted permissions.</p>
<table>
<thead>
<tr>
<th>Task</th>
<th>Official API</th>
</tr>
</thead>
<tbody><tr>
<td>Publish, read insights, moderate comments, mentions, messaging on <strong>your own</strong> professional account</td>
<td>Can (<a href="https://developers.facebook.com/docs/instagram-platform/overview">overview</a>)</td>
</tr>
<tr>
<td>Read comments</td>
<td>Can, on media owned by your app's users (<a href="https://developers.facebook.com/docs/instagram-platform/comment-moderation">comment moderation guide</a>)</td>
</tr>
<tr>
<td>Follower count and media of <strong>another</strong> Business or Creator account</td>
<td>Can, through <a href="https://developers.facebook.com/docs/instagram-platform/instagram-api-with-facebook-login/business-discovery">Business Discovery</a>: <code>followers_count</code>, <code>media_count</code>, media with <code>comments_count</code>, <code>like_count</code>, <code>view_count</code></td>
</tr>
<tr>
<td>Age-gated accounts through Business Discovery</td>
<td>Cannot: the docs say their data is not returned</td>
</tr>
<tr>
<td>Read a returned media object directly</td>
<td>Cannot: the Business Discovery page says a GET on a returned IG Media fails for insufficient permissions</td>
</tr>
<tr>
<td>Follower or following <strong>lists</strong> of other accounts</td>
<td>Not in the documented fields. Business Discovery returns counts only</td>
</tr>
<tr>
<td>Stories of accounts that haven't connected to your app</td>
<td>Not in the Business Discovery fields. The <a href="https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/stories">stories reference</a> says stories are only available for 24 hours and excludes Live and reshared stories</td>
</tr>
<tr>
<td>Hashtag search</td>
<td>Facebook Login setup only. The <a href="https://developers.facebook.com/docs/instagram-platform/overview">overview</a> marks it unsupported on Instagram Login. The <a href="https://developers.facebook.com/docs/instagram-platform/instagram-api-with-facebook-login/hashtag-search">hashtag guide</a> caps you at 30 unique hashtags per rolling 7 days, with no hashtags on Stories and no emoji in queries</td>
</tr>
<tr>
<td>Serve accounts you don't own or manage</td>
<td>Needs Advanced Access, which needs App Review and Business Verification (<a href="https://developers.facebook.com/docs/instagram-platform/overview">overview</a>)</td>
</tr>
</tbody></table>
<p>Rate limits: most endpoints use Instagram Business Use Case limits, where calls in 24 hours equal 4800 times the account's impressions, counted per app and app user pair. Business Discovery and Hashtag Search use Platform Rate Limits instead (<a href="https://developers.facebook.com/docs/instagram-platform/overview">overview</a>).</p>
<p>Two takeaways. First, Business Discovery is genuinely useful for a quick look at another professional account's counts and recent media, but it is an aggregate view. Second, anything that touches accounts you don't own at scale comes with review and verification steps, and tokens that depend on a signed-in app user.</p>
<h2>When you need public data from accounts you don't own</h2>
<p>If your job is "look up this username and give me data", the official API does not fit, and no amount of configuration changes that. This is where a REST API for public data fits.</p>
<p><a href="https://storiesfly.com/developers">StoriesFly</a> is a REST API that reads public Instagram and TikTok data. You never log in to Instagram, and your own account never appears among a story's viewers. The person is not notified. You send an API key and pay per request: from $1.50 per 1,000 requests, prepaid, and 100 free requests once per Telegram account.</p>
<p>Install the official client (<code>storiesfly</code> 1.1.0, Node 18+, ESM, no runtime dependencies):</p>
<pre><code class="language-sh">npm install storiesfly
</code></pre>
<pre><code class="language-ts">import { StoriesFly, StoriesFlyError } from "storiesfly";

const client = new StoriesFly({ apiKey: process.env.STORIESFLY_API_KEY! });

async function main() {
  try {
    const profile = await client.user.profile({ username: "instagram" });
    console.log(profile.username, profile.followers, profile.is_private);

    // First 3 pages of followers: up to 25 per page, one request per page.
    for await (const page of client.paginate(client.user.followers, { username: "instagram" }, { maxPages: 3 })) {
      for (const user of page.users) console.log(user.username);
    }

    const { stories } = await client.user.stories({ username: "instagram" });
    for (const s of stories) console.log(s.media_type, s.taken_at_iso, s.media_url);

    const { media } = await client.download({ url: "https://www.instagram.com/reel/DdY3MUJCfby/" });
    for (const file of media) console.log(file.type, file.url);
  } catch (err) {
    if (err instanceof StoriesFlyError &amp;&amp; err.code === "private_account") {
      console.log("Private account: no data.");
      return;
    }
    throw err;
  }
}
main();
</code></pre>
<p>Get a key by signing in with Telegram at <a href="https://storiesfly.com/developers/dashboard">storiesfly.com/developers/dashboard</a>. Full method list: <a href="https://storiesfly.com/developers/docs">docs</a> and the <a href="https://storiesfly.com/openapi.json">OpenAPI spec</a>. Beyond profiles, followers and stories, the client has calls for posts, reels, comments, hashtags, places and search, plus TikTok profiles, videos and lists.</p>
<p>Honest limits:</p>
<ul>
<li><strong>Private accounts</strong> return <code>private_account</code>, and that answer is charged like any answer. The API reads public data only.</li>
<li><strong>Media links expire.</strong> Links in answers are signed CDN links that last days, not months. Download what you need soon.</li>
<li><strong>Lists are paged.</strong> A page of followers holds up to 25 accounts and costs one request. Always set <code>maxPages</code>, or <code>paginate</code> walks the list to its end. For a verified account, Instagram shows only about 50 followers and no next page.</li>
<li><strong>Stories are live ones.</strong> <code>user.stories</code> returns what is up right now. It is not an archive.</li>
<li><strong>Numbers can be <code>null</code>.</strong> A value the answer doesn't carry is <code>null</code>, never <code>0</code>.</li>
</ul>
<h2>Use the same data from ChatGPT or Claude (MCP)</h2>
<p>The same data is available to AI assistants through an MCP server, with no code. In Claude Code:</p>
<pre><code class="language-sh">claude mcp add --transport http storiesfly https://storiesfly.com/mcp
</code></pre>
<p>ChatGPT and Claude connect to the same address and sign in with Telegram. Details: <a href="https://storiesfly.com/developers/mcp">storiesfly.com/developers/mcp</a>.</p>
<h2>Check the rules that apply to you</h2>
<p>This is not legal advice. Use public data only. Before you build, read Instagram's and Meta's terms and the privacy law that applies to your use and your users, such as rules on storing personal data, retention and deletion requests. Decide what you keep, for how long, and why.</p>
<h2>FAQ</h2>
<h3>Can the official Instagram API return the follower list of another account?</h3>
<p>Not in the documented fields. Business Discovery returns <code>followers_count</code> and media for another Business or Creator account, and its page does not mention follower lists.</p>
<h3>Is there still a way to read a personal account's feed through Meta's API?</h3>
<p>No. Basic Display ended on 2024-12-04, and Meta says there is no set of Instagram APIs for consumer developer apps after that date.</p>
<h3>Do I need App Review and Business Verification?</h3>
<p>For serving professional accounts you don't own or manage, Meta's overview says Advanced Access is needed, which requires App Review and Business Verification. For your own account, Standard Access applies.</p>
<h3>Do I log in to Instagram with StoriesFly?</h3>
<p>No. You send an API key. You never log in with your own account, and your account never appears among a story's viewers.</p>
<h3>What happens on a private account?</h3>
<p>The call returns <code>private_account</code> and no data. Only public data is available.</p>
<hr />
<p><em>Not affiliated with Instagram or Meta. Facts about Meta's API were checked against developers.facebook.com on 2026-10-28; docs change, so check the linked pages before you build.</em></p>
]]></content:encoded></item><item><title><![CDATA[How to Get a TikTok Followers and Following List in Node.js]]></title><description><![CDATA[You want the list of accounts that follow a TikTok account, or the accounts it follows, as data you can loop over. The official TikTok API does not give…]]></description><link>https://storiesfly.hashnode.dev/how-to-get-a-tiktok-followers-and-following-list-in-node-js</link><guid isPermaLink="true">https://storiesfly.hashnode.dev/how-to-get-a-tiktok-followers-and-following-list-in-node-js</guid><category><![CDATA[Node.js]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Tiktok]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[Sly Bye]]></dc:creator><pubDate>Fri, 30 Oct 2026 15:55:33 GMT</pubDate><content:encoded><![CDATA[<p>You want the list of accounts that follow a TikTok account, or the accounts it follows, as data you can loop over. The official TikTok API does not give you that for an arbitrary public account, so most people end up searching for "tiktok following list api" and finding nothing usable.</p>
<p><em>Updated October 2026. Originally published on <a href="https://storiesfly.com/blog/tiktok-followers-following-list-nodejs">storiesfly.com</a>.</em></p>
<p>This post shows a working approach in Node.js with the <code>storiesfly</code> npm client: install, page through a list, dedupe it, save a snapshot, and diff it on the next run to see who followed and who left. It also covers hidden lists, private accounts, error codes and what it costs.</p>
<p>Everything below was type-checked against <code>storiesfly</code> 1.1.0. It is ESM and needs Node 18 or newer (it uses the global <code>fetch</code>) and has no runtime dependencies.</p>
<h2>Install and get a key</h2>
<pre><code class="language-sh">npm install storiesfly
</code></pre>
<p>Sign in with Telegram at <a href="https://storiesfly.com/developers/dashboard">storiesfly.com/developers/dashboard</a> and create a key. A new account gets 100 free requests, so you can run everything here before paying. Keep the key on the server and read it from the environment:</p>
<pre><code class="language-sh">export STORIESFLY_API_KEY="your_key"
</code></pre>
<p>You do not log in to TikTok and you do not handle cookies. The key is the only credential.</p>
<h2>One page of followers</h2>
<p>A list call returns one page. Here is the shape:</p>
<pre><code class="language-ts">import { StoriesFly } from "storiesfly";

const client = new StoriesFly({ apiKey: process.env.STORIESFLY_API_KEY! });

const page = await client.tiktok.user.followers({ username: "khaby.lame" });

console.log(page.users.length, page.has_more, page.next_cursor, page.hidden);
console.log(page.users[0]);
</code></pre>
<p>The output looks like this (values are illustrative):</p>
<pre><code class="language-json">{
  "user": { "username": "khaby.lame" },
  "users": [
    {
      "id": "6812345678901234567",
      "sec_uid": "MS4wLjABAAAA...",
      "username": "some.follower",
      "full_name": "Some Follower",
      "profile_pic_url": "https://...",
      "verified": false,
      "is_private": false,
      "followers": 120,
      "following": 310,
      "likes": 4500,
      "videos": 12
    }
  ],
  "total": null,
  "hidden": false,
  "next_cursor": "...",
  "has_more": true
}
</code></pre>
<p>A few things to know about the fields. Every number can be <code>null</code> when it is not exact, so do not treat <code>null</code> as zero. <code>username</code> can also be <code>null</code> in a list entry, which matters for dedupe. <code>total</code> is the size of the whole list when it is known, and it is often not.</p>
<h2>Walk the whole list</h2>
<p><code>client.paginate</code> calls the method, yields the page, then calls it again with the previous <code>next_cursor</code> until <code>has_more</code> is <code>false</code>. Set <code>maxPages</code> unless you really mean to read everything, because each page is a billed request.</p>
<pre><code class="language-ts">import { StoriesFly, type TikTokUserShort } from "storiesfly";

const client = new StoriesFly({ apiKey: process.env.STORIESFLY_API_KEY! });

export async function fetchList(
  method: typeof client.tiktok.user.followers | typeof client.tiktok.user.following,
  username: string,
  maxPages: number,
): Promise&lt;{ users: TikTokUserShort[]; hidden: boolean; complete: boolean }&gt; {
  const byId = new Map&lt;string, TikTokUserShort&gt;();
  let hidden = false;
  let complete = true;

  for await (const page of client.paginate(method, { username }, { maxPages })) {
    if (page.hidden) {
      hidden = true;
      break;
    }
    for (const u of page.users) {
      const key = u.id ?? u.sec_uid ?? u.username;
      if (key) byId.set(key, u);
    }
    complete = !page.has_more;
  }

  return { users: [...byId.values()], hidden, complete };
}

const followers = await fetchList(client.tiktok.user.followers, "khaby.lame", 10);
console.log(followers.users.length, followers.complete ? "whole list" : "stopped at maxPages");
</code></pre>
<p>Two details in there are worth explaining.</p>
<p><strong>Dedupe.</strong> Keep a <code>Map</code> keyed by <code>id</code>, falling back to <code>sec_uid</code> and then <code>username</code>. Lists are read while accounts follow and unfollow, so an entry can appear on two pages, and a plain array would count it twice.</p>
<p><strong><code>complete</code>.</strong> The loop ends either because the list ended (<code>has_more</code> is <code>false</code>) or because it hit <code>maxPages</code>. Those are different outcomes. If you diff a truncated list against a full one, every account past the cut looks like an unfollow. Track whether you got the whole list and refuse to diff when you did not.</p>
<p>The same function reads the other direction: pass <code>client.tiktok.user.following</code> instead of <code>followers</code>. Lists are paged and how deep they go varies by account, so check <code>complete</code> rather than assuming.</p>
<h2>Detect new followers and unfollows</h2>
<p>Save the ids from one run, load them on the next, and compare. A JSON file is enough to start with.</p>
<pre><code class="language-ts">import { readFile, writeFile } from "node:fs/promises";
import { StoriesFly } from "storiesfly";

const client = new StoriesFly({ apiKey: process.env.STORIESFLY_API_KEY! });
const username = "khaby.lame";
const file = `./followers-${username}.json`;

async function loadSeen(): Promise&lt;Record&lt;string, string&gt; | null&gt; {
  try {
    return JSON.parse(await readFile(file, "utf8")) as Record&lt;string, string&gt;;
  } catch {
    return null; // first run
  }
}

const current: Record&lt;string, string&gt; = {};
let complete = true;
for await (const page of client.paginate(client.tiktok.user.followers, { username }, { maxPages: 20 })) {
  if (page.hidden) throw new Error("This account hides its followers list");
  for (const u of page.users) {
    const key = u.id ?? u.sec_uid ?? u.username;
    if (key) current[key] = u.username ?? "";
  }
  complete = !page.has_more;
}

const previous = await loadSeen();
if (previous &amp;&amp; complete) {
  const joined = Object.keys(current).filter((id) =&gt; !(id in previous));
  const left = Object.keys(previous).filter((id) =&gt; !(id in current));
  console.log(`new: ${joined.length}, gone: ${left.length}`);
  for (const id of joined) console.log("+", current[id] || id);
  for (const id of left) console.log("-", previous[id] || id);
}

if (complete) await writeFile(file, JSON.stringify(current));
else console.warn("List was cut at maxPages. Snapshot not saved.");
</code></pre>
<p>Output on the second run:</p>
<pre><code>new: 2, gone: 1
+ some.follower
+ another.one
- someone.who.left
</code></pre>
<p>Some honest limits of this approach:</p>
<ul>
<li>It sees who is in the list now compared with last time. It cannot tell you when someone followed, only that they were not there on the previous run.</li>
<li>Someone who follows and unfollows between two runs is invisible. Shorten the interval if that matters.</li>
<li>An account that renames itself keeps its <code>id</code>, which is why the snapshot is keyed by id and stores the username only for display.</li>
</ul>
<p>Run it from cron, once a day for most accounts.</p>
<h2>Hidden lists and private accounts</h2>
<p>Some accounts hide their followers or following list. The call still succeeds and returns an empty page with <code>hidden: true</code>. Check that flag before you treat an empty list as "no followers". In the code above, the diff throws instead of saving an empty snapshot, because an empty snapshot would make everyone look like a new follower the day the list becomes visible.</p>
<p>Private accounts are separate. To check one before you spend requests on its list, read the profile first, which is one request:</p>
<pre><code class="language-ts">import { StoriesFly } from "storiesfly";

const client = new StoriesFly({ apiKey: process.env.STORIESFLY_API_KEY! });

const profile = await client.tiktok.user.profile({ username: "khaby.lame" });
console.log(profile.followers, profile.following, profile.is_private);

if (profile.is_private) {
  console.log("Private account, skipping");
}
</code></pre>
<p>The profile gives <code>followers</code> and <code>following</code>, which is a good guide to the size of a list, so you can estimate the cost before you paginate. Either number can be <code>null</code> when TikTok does not give an exact one, so treat it as a hint and not as a promise. A call that reaches a private account can also come back as a <code>private_account</code> error, which is charged (see below).</p>
<h2>Error handling</h2>
<p>Every call that does not return data throws <code>StoriesFlyError</code>. Test <code>code</code>, not <code>message</code>.</p>
<pre><code class="language-ts">import { StoriesFly, StoriesFlyError } from "storiesfly";

const client = new StoriesFly({ apiKey: process.env.STORIESFLY_API_KEY! });

try {
  const page = await client.tiktok.user.followers({ username: "no_such_account_12345" });
  console.log(page.users.length);
} catch (err) {
  if (!(err instanceof StoriesFlyError)) throw err;

  switch (err.code) {
    case "not_found":
      console.error("No such account");
      break;
    case "private_account":
      console.error("Private account");
      break;
    case "credits_exhausted":
      console.error("Top up your balance");
      break;
    case "rate_limited":
      console.error(`Slow down, retry in ${err.retryAfterMs ?? "a few"} ms`);
      break;
    case "upstream_unavailable":
      console.error("Not available right now, retry shortly");
      break;
    default:
      console.error(err.status, err.code, err.requestId);
  }
  console.error("charged:", err.meta?.unitsCharged);
}
</code></pre>
<p>What matters in practice:</p>
<ul>
<li><code>rate_limited</code> is retried for you, twice by default, using the <code>Retry-After</code> the API sends. You only see it if the retries run out.</li>
<li><code>credits_exhausted</code> is never retried. Another call would not change it.</li>
<li><code>upstream_unavailable</code> and <code>internal_error</code> are refunded, so retrying them yourself is safe.</li>
<li><code>not_found</code> and <code>private_account</code> are charged, because the call got an answer. A bad parameter (<code>bad_request</code>), a rate limit and an empty balance cost nothing.</li>
<li>Keep <code>err.requestId</code> in your logs. Support can find a call with it.</li>
</ul>
<h2>What it costs</h2>
<p>Pricing is from $1.50 per 1,000 requests, prepaid. Every call is one request, and that includes each page of a list. So the cost of reading a list is the number of pages, not the number of followers.</p>
<pre><code>requests = ceil(list size / page size)
cost     = requests x $0.0015
</code></pre>
<p>Page size follows TikTok and varies, so measure it with one real call before you plan a budget. As an illustration only: if a page held 50 accounts, a 10,000-follower list would be 200 requests, about $0.30. A daily snapshot of that account for a month is about $9. The profile call above usually gives you the list size (when it is not <code>null</code>), so you can do this arithmetic per account before you commit.</p>
<p>To see exactly what a call charged, pass <code>{ withMeta: true }</code> as the last argument and read <code>meta.unitsCharged</code> and <code>meta.balanceRemaining</code>. With <code>paginate</code>, you get <code>{ data, meta }</code> for every page.</p>
<h2>Reposts</h2>
<p>The videos an account reposted come through the same paging pattern:</p>
<pre><code class="language-ts">import { StoriesFly } from "storiesfly";

const client = new StoriesFly({ apiKey: process.env.STORIESFLY_API_KEY! });

for await (const page of client.paginate(client.tiktok.user.reposts, { username: "khaby.lame" }, { maxPages: 2 })) {
  for (const v of page.videos) {
    console.log(v.author.username, v.url, v.views, v.likes);
  }
}
</code></pre>
<p>Each item has the video's author, caption, cover, <code>views</code> and <code>likes</code>. <code>comments</code> and <code>shares</code> are <code>null</code> here, and <code>taken_at</code> is when the author posted the video, not when it was reposted. An empty list means the account has no reposts or hides them.</p>
<h2>The same thing from Claude or ChatGPT, no code</h2>
<p>If you only need an answer once, you can skip the script. The same data is available to AI assistants over MCP:</p>
<pre><code class="language-sh">claude mcp add --transport http storiesfly https://storiesfly.com/mcp
</code></pre>
<p>ChatGPT and Claude connect to the same address and sign in with Telegram. Then ask in plain language, for example "list the first pages of followers of this TikTok account and tell me which ones are verified". Details are at <a href="https://storiesfly.com/developers/mcp">storiesfly.com/developers/mcp</a>. It is a good way to try a question before you turn it into a script.</p>
<h2>FAQ</h2>
<h3>Do I need a TikTok login or cookies?</h3>
<p>No. You send an API key and nothing else.</p>
<h3>Can I get the full followers list of any account?</h3>
<p>Lists are paged and depth varies by account. Check <code>has_more</code> to see whether you reached the end, and <code>hidden</code> to see whether the account hides the list.</p>
<h3>What happens with a private account?</h3>
<p>Check <code>is_private</code> on the profile first. A call that reaches a private account may answer <code>private_account</code>, and that answer is charged.</p>
<h3>How do I know who unfollowed?</h3>
<p>Compare two complete snapshots, as above. The API returns the current list, not a history.</p>
<h3>How do I stay inside a budget?</h3>
<p>Always set <code>maxPages</code>, check the profile counts first, and skip the diff when <code>complete</code> is <code>false</code>.</p>
<h3>Is the free tier real?</h3>
<p>A new account gets 100 free requests, once. After that you prepay a balance.</p>
<p>Full method list and response types: <a href="https://storiesfly.com/developers/docs">storiesfly.com/developers/docs</a>. The client's source and README are in the npm package.</p>
]]></content:encoded></item></channel></rss>