Go client for the Threads (Meta) private API. Zero dependencies, cookie-based reads + Bearer-token writes.
go get github.com/teslashibe/threads-goimport threads "github.com/teslashibe/threads-go"
// Reads (cookie auth)
c, _ := threads.New(threads.Cookies{
SessionID: os.Getenv("THREADS_SESSIONID"),
CSRFToken: os.Getenv("THREADS_CSRFTOKEN"),
DSUserID: os.Getenv("THREADS_DS_USER_ID"),
Mid: os.Getenv("THREADS_MID"),
IgDid: os.Getenv("THREADS_IG_DID"),
})
ctx := context.Background()
me, _ := c.Me(ctx)
zuck, _ := c.GetProfileByUsername(ctx, "zuck")
page, _ := c.UserThreads(ctx, zuck.ID, 25, "")
ctx2, _ := c.GetThread(ctx, page.Threads[0].ThreadItems[0].ID)
likers,_ := c.GetLikers(ctx, page.Threads[0].ThreadItems[0].ID)
feed, _ := c.GetHashtag(ctx, "golang")
// Writes (Bearer auth from Bloks login)
login, _ := threads.Login(ctx, "user@example.com", "password", "")
w, _ := threads.NewWithAuth(threads.Auth{
Token: login.Token,
UserID: login.UserID,
DeviceID: login.DeviceID,
})
post, _ := w.CreatePost(ctx, "Hello from threads-go!")
_ = w.Like(ctx, post.ID)
_ = w.Follow(ctx, zuck.ID)
_ = w.Repost(ctx, post.ID)threads-go uses two distinct auth modes because Threads does too: reads go through www.threads.com with browser session cookies, writes go through i.instagram.com with a Bearer token from the Bloks login flow.
| Mode | Host | Constructor | Required credentials |
|---|---|---|---|
| Cookies (read) | www.threads.com |
New(Cookies) |
sessionid, csrftoken, ds_user_id, mid, ig_did |
| Bearer (write) | i.instagram.com |
NewWithAuth(Auth) |
Token (IGT:2:…), UserID, DeviceID |
| Both | both | NewFull(Cookies, Auth) |
both sets |
Open https://www.threads.com in a logged-in browser, open DevTools → Application → Cookies → .threads.com, and copy these five values:
export THREADS_SESSIONID="75472043478%3A...%3A11%3A..."
export THREADS_CSRFTOKEN="od4SlgoL4bFP0YTLqg89O8XlHyAYgFux"
export THREADS_DS_USER_ID="75472043478"
export THREADS_MID="aekP4AAEAAFB_7rh0M8tO5Hh244o"
export THREADS_IG_DID="F0FFA972-40C0-4CB3-9DFC-CF5BFA0A2BF8"sessionid is URL-encoded (note the %3A's); pass it through verbatim.
res, err := threads.Login(ctx, "user@example.com", "password", "" /* device id */)
// or pass a stable device ID generated once:
// deviceID := threads.GenerateDeviceID()The token is a ~160-char IGT:2:… string and is valid for hours to days. Persist it together with the device ID; refresh by re-calling Login when you start getting ErrUnauthorized.
The Bloks login is best-effort and may be challenged with checkpoints by Meta. For production use, hold a long-lived device ID and run the login from the same egress IP each time.
c, _ := threads.NewFull(cookies, auth) // can both read and write
me, _ := c.Me(ctx) // uses cookies
post, _ := c.CreatePost(ctx, "hi") // uses bearerCalling a write method on a cookie-only client returns ErrWriteAuthRequired; calling a read method on a Bearer-only client returns ErrUnauthorized.
me, _ := c.Me(ctx) // authenticated user
user, _ := c.GetProfile(ctx, "314216") // by numeric ID
user, _ = c.GetProfileExtended(ctx, "314216") // adds extended fields
user, _ = c.GetProfileByUsername(ctx, "zuck") // resolve handle → profile
page, _ := c.SearchUsers(ctx, "golang", 20)User exposes ID, Username, FullName, Biography, IsPrivate, IsVerified, FollowerCount, FollowingCount, MediaCount, ProfilePicURL, HDProfilePicURL, plus Threads-specific badge / link fields.
page, _ := c.UserThreads(ctx, userID, 25, "") // a user's posts ✓
page, _ = c.UserReplies(ctx, userID, 25, "") // a user's replies ✓
page, _ = c.HomeTimeline(ctx, 25, "") // For You — BearerResponse: PostPage{ Threads []Thread; NextCursor string; HasNext bool }. Each Thread is a chain of one or more Posts (ThreadItems). Use flattenThreads (or the iterator helpers) to walk every post.
Heads up:
LikedPostsandSearchPostsare kept as method stubs for forward-compatibility but currently returnErrNotFound. Meta has retired both REST endpoints fromwww.threads.com(text search now goes through rotating GraphQLdoc_ids on the SERP page; the Threads-only liked feed is no longer surfaced separately from the Instagram likes feed). See the Endpoints covered table below.
ctx2, _ := c.GetThread(ctx, postID) // post + reply context
ctx2, _ = c.GetThreadReplies(ctx, postID, 25, cursor) // paginate replies
post, _ := c.GetPost(ctx, postID) // focal post onlyThreadContext returns the ContainingThread (root + parent posts), ReplyThreads, SiblingThreads, and DownwardCursor for paginating further replies.
likers, _ := c.GetLikers(ctx, postID) // who liked ✓ (count always; identities best-effort)GetLikers returns the public like-count in NumResults and a
best-effort Users slice. Meta increasingly hides individual liker
identities from non-author callers; when that happens you still get
the accurate count, just an empty user list.
GetReposters and GetQuoters are kept as method stubs but currently
return ErrNotFound — Meta does not expose those lists publicly via the
web API. Use Post.RepostCount / Post.QuoteCount for the aggregates.
followers, _ := c.GetFollowers(ctx, userID, 100, "")
following, _ := c.GetFollowing(ctx, userID, 100, "")
fs, _ := c.GetFriendship(ctx, userID) // single relationship
many, _ := c.GetFriendships(ctx, []string{id1, id2})// bulk
pending, _ := c.PendingRequests(ctx) // private-account requeststags, _ := c.SearchHashtags(ctx, "golang", 20) // ✓ list matching tags
meta, _ := c.GetHashtag(ctx, "golang") // ✓ tag metadata onlyGetHashtag returns metadata (id, name, post counts, follow status). The
per-tag post feed is no longer exposed as a usable REST endpoint on the
web — the /api/v1/tags/{name}/sections/ path returns navigation tabs
only, with sections=[]. GetHashtagPage therefore returns ErrNotFound.
Discover hashtag content by walking known users instead.
notifs, _ := c.Notifications(ctx)
suggested,_ := c.RecommendedUsers(ctx, 30)Long results paginate cleanly via the iterator helpers, with built-in
MaxPosts / MaxUsers caps, StopAtID for incremental scraping, and
serialisable Checkpoints for resume across runs.
it := threads.NewUserThreadsIterator(c, userID, 25,
threads.WithMaxPosts(500),
threads.WithStopAtID(lastSeenID), // optional incremental boundary
)
for it.Next(ctx) {
for _, p := range it.Page() { process(p) }
}
if err := it.Err(); err != nil { ... }
cp := it.Checkpoint() // save for next runAvailable iterators:
| Iterator | Source | Status |
|---|---|---|
NewUserThreadsIterator |
UserThreads |
✓ |
NewUserRepliesIterator |
UserReplies |
✓ |
NewHomeTimelineIterator |
HomeTimeline (Bearer) |
✓ |
NewFollowersIterator |
GetFollowers |
✓ |
NewFollowingIterator |
GetFollowing |
✓ |
NewLikedPostsIterator |
LikedPosts |
iterates an ErrNotFound source — kept for forward compatibility |
NewHashtagIterator |
GetHashtagPage |
iterates an ErrNotFound source — kept for forward compatibility |
NewSearchPostsIterator |
SearchPosts |
iterates an ErrNotFound source — kept for forward compatibility |
post, _ := c.CreatePost(ctx, "Hello world!")
post, _ = c.CreatePost(ctx, "Look at this cat",
threads.WithImage("/tmp/cat.jpg"),
threads.WithReplyControl("accounts_you_follow"),
)
post, _ = c.CreatePost(ctx, "Multi-pic",
threads.WithImage("a.jpg"), threads.WithImage("b.jpg"),
)
reply, _ := c.Reply(ctx, postID, "Great point!")
quote, _ := c.Quote(ctx, postID, "Hot take →")
_ = c.Like(ctx, postID)
_ = c.Unlike(ctx, postID)
_ = c.Repost(ctx, postID)
_ = c.DeleteRepost(ctx, postID)
_ = c.DeletePost(ctx, postID)
_ = c.Follow(ctx, userID)
_ = c.Unfollow(ctx, userID)
_ = c.Block(ctx, userID)
_ = c.Unblock(ctx, userID)
_ = c.Mute(ctx, userID)
_ = c.Unmute(ctx, userID)
_ = c.Restrict(ctx, userID)
_ = c.Unrestrict(ctx, userID)
mediaID, _ := c.UploadImage(ctx, "/tmp/cat.jpg") // or call directly
post, _ = c.CreatePost(ctx, "x", threads.WithMediaIDs(mediaID))CreatePost chooses between the text-only, single-photo, and carousel
configure endpoints automatically based on attached images.
c, _ := threads.New(cookies,
threads.WithUserAgent("Barcelona 289.0.0.14.109 Android"),
threads.WithReadUserAgent(...), // override only reads
threads.WithWriteUserAgent(...), // override only writes
threads.WithMinRequestGap(2 * time.Second), // pacing (default 1.5s)
threads.WithRetry(3, 750*time.Millisecond), // attempts, base
threads.WithProxy("http://user:pass@proxy:8080"),
threads.WithHTTPClient(myCustomClient),
)All errors are errors.Is-comparable to the sentinels:
| Sentinel | Meaning |
|---|---|
ErrInvalidAuth |
Required credentials missing from Cookies / Auth |
ErrUnauthorized |
Server rejected session (401, expired) |
ErrSessionSuspended |
403 with logout_reason: 8 — temporary device-fingerprint anomaly. Wait 15-30 min and retry. |
ErrForbidden |
Genuine access denial (private profile, blocked viewer) |
ErrNotFound |
Resource doesn't exist or was deleted |
ErrRateLimited |
HTTP 429 |
ErrUserAgentMismatch |
Backend rejected the User-Agent — use a Barcelona/Instagram UA |
ErrWriteAuthRequired |
Write method called on a cookie-only client |
ErrInvalidParams |
Caller passed empty / bad arguments |
ErrRequestFailed |
Generic transport / decode failure (use errors.As to inspect *FailStatusError for application-level errors) |
post, err := c.CreatePost(ctx, "hi")
switch {
case errors.Is(err, threads.ErrSessionSuspended):
time.Sleep(20 * time.Minute)
case errors.Is(err, threads.ErrWriteAuthRequired):
log.Fatal("need Bearer client; call NewWithAuth or NewFull")
case errors.Is(err, threads.ErrRateLimited):
backoff()
}Threads uses behavioural rate limiting — there are no X-RateLimit-*
headers. After roughly 20–30 consecutive API calls, the session is parked
with 403 {"message":"login_required","logout_reason":8} and recovers on
its own in 15–30 minutes if you back off.
The default WithMinRequestGap of 1.5s keeps a single client well clear
of the limiter for sustained use. For batch scraping, increase the gap or
plug in a proxy pool via WithProxy. The client retries transient 5xx
and network errors with exponential backoff but does not retry
ErrSessionSuspended — surface it to your scheduler so you can sleep.
Status legend: ✓ verified end-to-end against the live API · ✗ surface
removed by Meta — method returns ErrNotFound.
| Method | Threads endpoint | Status |
|---|---|---|
Me |
GET /api/v1/accounts/current_user/?edit=true |
✓ |
GetProfile |
GET /api/v1/users/{id}/info/ |
✓ |
GetProfileExtended |
GET /api/v1/users/{id}/info/?entry_point=profile&from_module=profile_page |
✓ |
GetProfileByUsername |
GET /api/v1/users/search/ then GetProfile |
✓ |
SearchUsers |
GET /api/v1/users/search/?q=&count= |
✓ |
UserThreads |
GET /api/v1/text_feed/{id}/profile/ |
✓ |
UserReplies |
GET /api/v1/text_feed/{id}/profile/replies/ |
✓ |
LikedPosts |
stub: ErrNotFound — Meta retired /api/v1/text_feed/text_app_liked_feed/ |
✗ |
SearchPosts |
stub: ErrNotFound — Meta retired /api/v1/text_feed/text_search/ (now GraphQL-only) |
✗ |
GetThread / GetThreadReplies |
GET /api/v1/text_feed/{post_id}/replies/ |
✓ |
GetLikers |
GET /api/v1/media/{post_id}/likers/ |
✓ (count always; identities best-effort) |
GetReposters |
stub: ErrNotFound — not exposed publicly | ✗ |
GetQuoters |
stub: ErrNotFound — not exposed publicly | ✗ |
GetFollowers |
GET /api/v1/friendships/{id}/followers/ |
✓ |
GetFollowing |
GET /api/v1/friendships/{id}/following/ |
✓ |
GetFriendship |
GET /api/v1/friendships/show/{id}/ |
✓ |
GetFriendships |
POST /api/v1/friendships/show_many/ |
✓ |
PendingRequests |
GET /api/v1/friendships/pending/ |
✓ |
SearchHashtags |
GET /api/v1/tags/search/?q= |
✓ |
GetHashtag |
GET /api/v1/tags/web_info/?tag_name= |
✓ (metadata only) |
GetHashtagPage |
stub: ErrNotFound — /api/v1/tags/{name}/sections/ returns nav-only |
✗ |
| Method | Endpoint |
|---|---|
HomeTimeline |
GET /api/v1/feed/text_post_app_timeline/ |
Notifications |
GET /api/v1/text_feed/notifications/ |
RecommendedUsers |
GET /api/v1/text_feed/recommended_users/ |
| Method | Endpoint |
|---|---|
Login |
POST /api/v1/bloks/apps/com.bloks.www.bloks.caa.login.async.send_login_request/ |
CreatePost (text) |
POST /api/v1/media/configure_text_only_post/ |
CreatePost (single image) |
POST /api/v1/media/configure_text_post_app_feed/ |
CreatePost (carousel) |
POST /api/v1/media/configure_text_post_app_carousel/ |
UploadImage |
POST /rupload_igphoto/{name} |
Like |
POST /api/v1/media/{id}/like/ |
Unlike |
POST /api/v1/media/{id}/unlike/ |
Repost |
POST /api/v1/repost/create_repost/ |
DeleteRepost |
POST /api/v1/repost/delete_text_app_repost/ |
DeletePost |
POST /api/v1/media/{id}/delete/?media_type=TEXT_POST |
Follow |
POST /api/v1/friendships/create/{id}/ |
Unfollow |
POST /api/v1/friendships/destroy/{id}/ |
Block |
POST /api/v1/friendships/block/{id}/ |
Unblock |
POST /api/v1/friendships/unblock/{id}/ |
Mute |
POST /api/v1/friendships/mute_posts_or_story_from_follow/ |
Unmute |
POST /api/v1/friendships/unmute_posts_or_story_from_follow/ |
Restrict |
POST /api/v1/restrict_action/restrict_many/ |
Unrestrict |
POST /api/v1/restrict_action/unrestrict/ |
All Bearer write endpoints take a signed_body envelope of the form
SIGNATURE.{url-encoded JSON} — the literal string SIGNATURE is used
as the signing prefix; no real cryptographic signing is required.
Runnable examples in examples/:
go run ./examples/get_profile # fetch self + @zuck profile
go run ./examples/get_threads # paginate @zuck's posts
go run ./examples/search_users -q golang -n 20Read-only, against the live API, gated behind a build tag:
export THREADS_SESSIONID=...
export THREADS_CSRFTOKEN=...
export THREADS_DS_USER_ID=...
export THREADS_MID=...
export THREADS_IG_DID=...
go test -tags=integration -v ./...The tests are paced at 4 s between requests (configured via
WithMinRequestGap(4*time.Second)) and run sequentially with -p 1. A
full suite of 13 tests completes in ~2 minutes and burns ~30 requests on
the supplied session. Increase the gap further if you're sharing one
cookie set across multiple jobs.
- DMs / inbox messaging
- Insights & analytics endpoints
- Story posting (Threads doesn't surface stories yet)
- Live video and audio rooms
- Edit a published post (no public endpoint)
- Public-data API (the official Threads Graph API — different surface)
MIT (matches the rest of the teslashibe/* SDKs).