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

@tsofindavid/threads-api

v1.0.3

Published

Typed client library for the Threads Graph API

Readme

threads-api

Typed client library for the Threads Graph API. Runs on Bun and Node.js (uses only standard fetch/URL/URLSearchParams).

Install

npm i @tsofindavid/threads-api
yarn add @tsofindavid/threads-api
pnpm add @tsofindavid/threads-api
bun add @tsofindavid/threads-api

Quick start

import { ThreadsAPI } from "@tsofindavid/threads-api"

const client = new ThreadsAPI({ accessToken: process.env.THREADS_ACCESS_TOKEN })

const { id } = await client.raw.publishing.createContainer("me", {
  media_type: "TEXT",
  text: "Hello from threads-api",
})
await client.raw.publishing.publishContainer("me", id)

Architecture

The client is split into two strictly non-overlapping layers on top of a single transport:

| Layer | Purpose | Access | | --- | --- | --- | | Raw | methods 1:1 with API endpoints, no business logic | client.raw.* | | Convenience | wrappers on top of raw: composition, pagination, polling, helpers | client.*, client.paginate, metricsFrom |

Dependencies flow in one direction only: models ← raw ← convenience. The reverse is prohibited.

Raw layer

One method per endpoint; signatures mirror the spec. No defaults or call-chaining.

// Publishing
await client.raw.publishing.createContainer(userId, { media_type: "IMAGE", image_url, text })
await client.raw.publishing.publishContainer(userId, creationId)
await client.raw.publishing.getContainerStatus(containerId, "id,status,error_message")
await client.raw.publishing.repost(mediaId)
await client.raw.publishing.delete(mediaId)

// Media retrieval
await client.raw.media.get(mediaId, "id,text,permalink")
await client.raw.media.search({ q: "threads", search_type: "RECENT", media_type: "IMAGE" })

// Reply management
await client.raw.replyManagement.listReplies(mediaId, { reverse: true })
await client.raw.replyManagement.getConversation(mediaId)
await client.raw.replyManagement.manageReply(replyId, true)   // hide
await client.raw.replyManagement.listPendingReplies(mediaId)
await client.raw.replyManagement.managePendingReply(replyId, true) // approve

// User
await client.raw.user.getThreads(userId)
await client.raw.user.getProfile(userId)
await client.raw.user.lookupProfile("username")
await client.raw.user.getProfilePosts("username")
await client.raw.user.getPublishingLimit(userId)
await client.raw.user.getReplies(userId)
await client.raw.user.getMentions(userId)
await client.raw.user.getGhostPosts(userId)

// Locations
await client.raw.locations.get(locationId)
await client.raw.locationSearch.search({ query: "Paris", latitude, longitude })

// Insights
await client.raw.insights.getMediaInsights(mediaId, "views,likes")
await client.raw.insights.getUserInsights(userId, "views,followers_count", { since, until })

// oEmbed (no token required)
await client.raw.oembed.get("https://www.threads.com/t/DDzbnVKx57R", 500)

// Debug
await client.raw.debug.token(inputToken)

// Token endpoints
await client.raw.authTokens.exchangeToken(shortLived, clientSecret)   // short → long
await client.raw.authTokens.refreshLongLivedToken(longLived)          // refresh
await client.raw.authTokens.getAppToken(appId, appSecret)             // app token
await client.raw.authTokens.exchangeCode({ client_id, client_secret, code, redirect_uri })

Convenience layer

// Create a container and publish it right away
await client.publishing.createAndPublish(userId, { media_type: "TEXT", text: "Hello" })

// Poll a container until a terminal status (per spec recommendations)
const status = await client.publishing.waitForContainer(containerId, {
  intervalMs: 60_000,   // once per minute
  timeoutMs: 5 * 60_000,
})

// Pagination: async iterator over the `after` cursor
const all = await collect(client.media.searchAll({ q: "threads" }))
for await (const post of client.media.searchAll({ q: "threads", media_type: "IMAGE" })) {
  console.log(post.permalink)
}

// Generic pagination over any raw method that returns a page
for await (const item of client.paginate((pp) => client.raw.user.getThreads("me", pp))) {
  // ...
}

// metric: object → string for insights
await client.raw.insights.getUserInsights(userId, metricsFrom({ views: true, likes: true }))

Errors

A single hierarchy rooted at ThreadsAPIError. Transport-library exceptions never escape.

import {
  ThreadsAPIError,
  ThreadsNetworkError,
  ThreadsTimeoutError,
  ThreadsAuthenticationError,
  ThreadsPermissionError,
  ThreadsRateLimitError,
  ThreadsRequestError,
  ThreadsServerError,
  ThreadsParseError,
} from "@tsofindavid/threads-api"

try {
  await client.raw.user.getProfile("me")
} catch (err) {
  if (err instanceof ThreadsAuthenticationError) {
    // token expired — re-issue / refresh
  } else if (err instanceof ThreadsRateLimitError) {
    // not retriable: limit is a rolling 24h window
  }
}

Common error fields: status, providerCode, requestId (fbtrace_id), responseBody (redacted — secrets replaced with [REDACTED]), retriable.

Retriable: network errors, timeout, 5xx. The transport retries automatically with exponential backoff (maxRetries defaults to 3).

Not retriable: 4xx (including 429 — the limit rolls over 24h), 401, 403.

OAuth

Full server-side flow, from sending the user to Threads to getting a refreshable long-lived token.

import {
  buildAuthorizationUrl,
  parseAuthorizationRedirect,
  ThreadsAPI,
} from "@tsofindavid/threads-api"

// 1. Send the user to the Authorization Window
const authUrl = buildAuthorizationUrl({
  clientId: process.env.THREADS_APP_ID!,
  redirectUri: "https://example.com/auth/callback",
  scope: ["threads_basic", "threads_content_publish"],
  state: crypto.randomUUID(), // CSRF protection, verify it back on the callback
})
// redirect the user's browser to `authUrl`

// 2. Handle the redirect back to your `redirectUri`
const result = parseAuthorizationRedirect(request.url)
if (!result.ok) {
  // result.error / result.errorReason / result.errorDescription
  throw new Error(result.errorDescription ?? result.error)
}

// 3. Exchange the code for a short-lived token, then upgrade it to a long-lived one
const client = new ThreadsAPI({ accessToken: "" }) // token not needed for these calls
const { access_token: shortLived } = await client.raw.authTokens.exchangeCode({
  client_id: process.env.THREADS_APP_ID!,
  client_secret: process.env.THREADS_APP_SECRET!,
  code: result.code,
  redirect_uri: "https://example.com/auth/callback",
})
const { access_token: longLived } = await client.raw.authTokens.exchangeToken(
  shortLived,
  process.env.THREADS_APP_SECRET!,
)
// persist `longLived` (valid 60 days, refreshable via RefreshingTokenProvider below)

scope accepts any of threads_basic, threads_content_publish, threads_manage_replies, threads_read_replies, threads_manage_insights (also exported as THREADS_SCOPES); threads_basic is required by the API.

Authentication

TokenProvider is a separate abstraction. By default a fixed token is used:

const client = new ThreadsAPI({ accessToken: "..." })

Auto-refresh on 401 via RefreshingTokenProvider. The refresh callback is injected by the consumer:

import { ThreadsAPI, RefreshingTokenProvider } from "@tsofindavid/threads-api"

const auth = new RefreshingTokenProvider({
  token: longLivedToken,
  refresh: async () => {
    const { access_token } = await client.raw.authTokens.refreshLongLivedToken(longLivedToken)
    return access_token
  },
})

const client = new ThreadsAPI({ auth })

Custom implementation: implement the TokenProvider interface (getToken() plus optional refresh()) and pass it via config.auth.

Secrets (access_token, client_secret, etc.) never appear in error messages or logs.

Configuration

const client = new ThreadsAPI({
  accessToken: "...",
  baseUrl: "https://graph.threads.net",   // default
  apiVersion: "v1.0",                      // default
  timeoutMs: 30_000,                       // default
  retry: { maxRetries: 3, initialDelayMs: 200, maxDelayMs: 8000 },
  hooks: {
    onRequest: (spec) => {},       // request tracing/logging
    onResponse: (spec) => {},      // response tracing/logging
  },
  fetch: customFetch,              // transport injection (tests, proxies)
})

The library never reads environment variables and never prints to stdout. Logging happens only through hooks or your logger.

Package subpath imports

import type { ThreadsMedia } from "@tsofindavid/threads-api/models"
import { ThreadsAPIError } from "@tsofindavid/threads-api/errors"
import { FetchTransport } from "@tsofindavid/threads-api/transport"
import { StaticTokenProvider } from "@tsofindavid/threads-api/auth"
import { RawPublishing } from "@tsofindavid/threads-api/raw"
import { paginate } from "@tsofindavid/threads-api/convenience"

Testing

bun test

The transport is swapped via config.fetch or FetchTransport without monkey-patching. Example raw test:

import { FetchTransport, StaticTokenProvider } from "@tsofindavid/threads-api"
import { RawUser } from "@tsofindavid/threads-api/raw"

const fetchImpl = () => Promise.resolve(new Response(JSON.stringify({ id: "1", username: "u" }), { status: 200 }))
const transport = new FetchTransport({
  baseUrl: "https://graph.threads.net",
  apiVersion: "v1.0",
  auth: new StaticTokenProvider("tok"),
  timeoutMs: 5000,
  retry: { maxRetries: 3, initialDelayMs: 1, maxDelayMs: 5 },
  fetchImpl,
})
const user = await new RawUser(transport).getProfile("me")

API docs

The spec is mirrored locally under docs/ (see download_threads_docs.sh). No OpenAPI codegen is used — the spec is markdown only, so the raw layer is maintained by hand.