Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages