Bluesky User Posts API. Posts and reposts, page by page.
Posts and reposts with exact counts. We handle the proxies, blocks and platform changes.
Bluesky User Posts API at a glance
Returns a public Bluesky account's posts and reposts from its handle, DID or profile link, as the tabs of its profile list them. Ask for its replies too, or only posts with media or video. Each post has its link, publish time, full text with hashtags, mentions and full link URLs, images and video, link card, quote and reply, the pin, and exact like, reply, repost, quote and bookmark counts. A repost also says who reposted it and when.
GET /v1/bluesky/posts
- 1 credit per page, $0.75–$1.97 per 1,000 pages
- 22 typed fields null when unknown
- 100 posts and reposts per page, most recent activity first: a post by when it was posted, a repost by when it was reposted
handleis all it needs, e.g.espn.com
Send one request. Get up to 100 posts and reposts back.
Copy the request in your language, or hand it to your agent. Sign up for an API key and your first 100 credits are free.
$ npm install socialtaphandle = espn.comconst params = new URLSearchParams({
handle: "espn.com",
});
const res = await fetch(`https://socialtap.dev/v1/bluesky/posts?${params}`, {
headers: { "x-api-key": process.env.SOCIALTAP_API_KEY ?? "" },
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
const { data, page, meta } = body;
console.log(data, meta.credits_charged);import os
import requests
res = requests.get(
"https://socialtap.dev/v1/bluesky/posts",
params={"handle": "espn.com"},
headers={"x-api-key": os.environ["SOCIALTAP_API_KEY"]},
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
data, page, meta = body["data"], body["page"], body["meta"]
print(data, meta["credits_charged"])require "json"
require "net/http"
uri = URI("https://socialtap.dev/v1/bluesky/posts")
uri.query = URI.encode_www_form('handle' => 'espn.com')
req = Net::HTTP::Get.new(uri)
req["x-api-key"] = ENV.fetch("SOCIALTAP_API_KEY")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }
body = JSON.parse(res.body)
raise "#{body["error"]["code"]}: #{body["error"]["message"]}" unless res.is_a?(Net::HTTPSuccess)
pp body["data"]
puts body["meta"]["credits_charged"]<?php
$query = http_build_query([
'handle' => 'espn.com',
]);
$ch = curl_init('https://socialtap.dev/v1/bluesky/posts?' . $query);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['x-api-key: ' . getenv('SOCIALTAP_API_KEY')],
]);
$body = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) >= 400) {
throw new RuntimeException($body['error']['code'] . ': ' . $body['error']['message']);
}
print_r($body['data']);
echo $body['meta']['credits_charged'], PHP_EOL;package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
)
func main() {
q := url.Values{}
q.Set("handle", "espn.com")
req, err := http.NewRequest("GET", "https://socialtap.dev/v1/bluesky/posts?"+q.Encode(), nil)
if err != nil {
log.Fatal(err)
}
req.Header.Set("x-api-key", os.Getenv("SOCIALTAP_API_KEY"))
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
var body struct {
Data json.RawMessage `json:"data"`
Page json.RawMessage `json:"page"`
Meta map[string]any `json:"meta"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
log.Fatal(err)
}
if body.Error != nil {
log.Fatalf("%s: %s", body.Error.Code, body.Error.Message)
}
fmt.Println(string(body.Data), body.Meta["credits_charged"])
}curl 'https://socialtap.dev/v1/bluesky/posts?handle=espn.com' \
-H "x-api-key: $SOCIALTAP_API_KEY"{
"data": [
{
"approximate_fields": [],
"author": {
"avatar_url": "https://cdn.bsky.app/img/avatar/plain/did:plc:x7d6j54pm22ufehkes6jo4jf/bafkreie4oxawqqsfzvk47ysr3unsnmviedzpylxmwxnpa534to2bzas4ie",
"display_name": "ESPN",
"id": "did:plc:x7d6j54pm22ufehkes6jo4jf",
"is_verified": true,
"url": "https://bsky.app/profile/espn.com",
"username": "espn.com"
},
"coauthors": [],
"duration_ms": null,
"hashtags": [],
"id": "at://did:plc:x7d6j54pm22ufehkes6jo4jf/app.bsky.feed.post/3muznlkmspz2x",
"is_nsfw": false,
"is_pinned": false,
"is_sponsored": null,
"language": null,
"media": [
{
"alt_text": null,
"duration_ms": null,
"expires_at": null,
"height": null,
"thumbnail_url": "https://video.bsky.app/watch/did%3Aplc%3Ax7d6j54pm22ufehkes6jo4jf/bafkreiflqivgi6afl5jv4qnmwzo3ks7jwe2dfurohsjfgafbtd5crw4gaq/thumbnail.jpg",
"type": "video",
"url": "https://video.bsky.app/watch/did%3Aplc%3Ax7d6j54pm22ufehkes6jo4jf/bafkreiflqivgi6afl5jv4qnmwzo3ks7jwe2dfurohsjfgafbtd5crw4gaq/playlist.m3u8",
"width": null
}
],
"mentions": [],
"metrics": {
"comment_count": 15,
"like_count": 103,
"quote_count": 7,
"repost_count": 11,
"save_count": 0,
"share_count": null,
"view_count": null
},
"platform": "bluesky",
"platform_specific": {
"bluesky": {
"bridged_from_url": null,
"cid": "bafyreiceaay7jrgqznvxhttsmizofewx3mj6bwebjtz76nd2wh4hix4fty",
"created_at": "2026-09-08T18:27:24Z",
"embedded_record_uri": null,
"indexed_at": "2026-09-08T18:27:30Z",
"labels": [],
"languages": [],
"link_card": null,
"links": [],
"quoted_post": null,
"quoted_post_id": null,
"reply_root_post_id": null,
"reply_to_post_id": null,
"reposted_at": null,
"reposted_by": null
}
},
"platform_type": null,
"published_at": "2026-09-08T18:27:24Z",
"text": "One more sleep until NFL football is BACK. 🏈",
"thumbnail_url": "https://video.bsky.app/watch/did%3Aplc%3Ax7d6j54pm22ufehkes6jo4jf/bafkreiflqivgi6afl5jv4qnmwzo3ks7jwe2dfurohsjfgafbtd5crw4gaq/thumbnail.jpg",
"title": null,
"type": "video",
"unknown_fields": [],
"url": "https://bsky.app/profile/espn.com/post/3muznlkmspz2x"
},
{
"approximate_fields": [],
"author": {
"avatar_url": "https://cdn.bsky.app/img/avatar/plain/did:plc:x7d6j54pm22ufehkes6jo4jf/bafkreie4oxawqqsfzvk47ysr3unsnmviedzpylxmwxnpa534to2bzas4ie",
"display_name": "ESPN",
"id": "did:plc:x7d6j54pm22ufehkes6jo4jf",
"is_verified": true,
"url": "https://bsky.app/profile/espn.com",
"username": "espn.com"
},
"coauthors": [],
"duration_ms": null,
"hashtags": [],
"id": "at://did:plc:x7d6j54pm22ufehkes6jo4jf/app.bsky.feed.post/3mpglmolczz2w",
"is_nsfw": false,
"is_pinned": false,
"is_sponsored": null,
"language": null,
"media": [],
"mentions": [],
"metrics": {
"comment_count": 17,
"like_count": 139,
"quote_count": 16,
"repost_count": 34,
"save_count": 1,
"share_count": null,
"view_count": null
},
"platform": "bluesky",
"platform_specific": {
"bluesky": {
"bridged_from_url": null,
"cid": "bafyreid2eoctnshw7unb5ozrjqsy63kpqmwwvhj2xuxsfo4z5zucfazqum",
"created_at": "2026-06-29T13:25:44Z",
"embedded_record_uri": null,
"indexed_at": "2026-06-29T13:25:45Z",
"labels": [],
"languages": [],
"link_card": {
"description": "Former Titans and Cardinals running back Chris Johnson told \"Good Morning America\" that he was diagnosed with ALS in 2025.",
"thumbnail_url": "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:x7d6j54pm22ufehkes6jo4jf/bafkreicgzhnme4wgvolagtic3dki5icwfkmlxz23cmakg2mj2h4xkwvg2q",
"title": "Former NFL RB Chris Johnson discusses ALS diagnosis",
"url": "http://spr.ly/63329BDvG6W"
},
"links": [],
"quoted_post": null,
"quoted_post_id": null,
"reply_root_post_id": null,
"reply_to_post_id": null,
"reposted_at": null,
"reposted_by": null
}
},
"platform_type": null,
"published_at": "2026-06-29T13:25:44Z",
"text": "Former Tennessee Titans and Arizona Cardinals running back Chris Johnson revealed that he has been diagnosed with ALS in an interview with ABC's \"Good Morning America\".\n\nJohnson, 39, told GMA co-anchor Michael Strahan that he was diagnosed with the disease in 2025.",
"thumbnail_url": "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:x7d6j54pm22ufehkes6jo4jf/bafkreicgzhnme4wgvolagtic3dki5icwfkmlxz23cmakg2mj2h4xkwvg2q",
"title": null,
"type": "link",
"unknown_fields": [],
"url": "https://bsky.app/profile/espn.com/post/3mpglmolczz2w"
}
],
"page": {
"has_more": true,
"next_cursor": "c1.EXAMPLE_CURSOR",
"next_cursor_expires_at": "2026-10-26T12:00:00Z",
"end_reason": null
},
"meta": {
"request_id": "req_3f9c2a7d1e8b4c6a9f0e2d4b6a8c0e1f",
"platform": "bluesky",
"source": "api",
"is_cached": false,
"credits_charged": 1,
"latency_ms": 449,
"fetched_at": "2026-10-25T12:00:00Z",
"input": {
"kind": "profile",
"id": "did:plc:x7d6j54pm22ufehkes6jo4jf",
"url": "https://bsky.app/profile/espn.com"
},
"credits_remaining": 9999,
"client_request_id": null,
"warnings": []
}
}import { SocialTap } from "socialtap";
const client = new SocialTap(); // apiKey defaults to process.env.SOCIALTAP_API_KEY
const res = await client.bluesky.posts.list({ handle: "espn.com" });
console.log(res.data);
console.log(res.credits.charged, res.meta.request_id);import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
public class Main {
public static void main(String[] args) throws Exception {
String query = "handle=" + encode("espn.com");
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://socialtap.dev/v1/bluesky/posts?" + query))
.header("x-api-key", System.getenv("SOCIALTAP_API_KEY"))
.GET()
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
// An error is {"error": {"code", "message", ...}}; success is {data, meta}, meta.credits_charged its price.
if (response.statusCode() >= 400) throw new RuntimeException(response.body());
System.out.println(response.body());
}
static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}using System.Text.Json;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", Environment.GetEnvironmentVariable("SOCIALTAP_API_KEY"));
var query = string.Join("&",
"handle=" + Uri.EscapeDataString("espn.com"));
var res = await http.GetAsync("https://socialtap.dev/v1/bluesky/posts?" + query);
using var body = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (!res.IsSuccessStatusCode)
{
var error = body.RootElement.GetProperty("error");
throw new Exception($"{error.GetProperty("code")}: {error.GetProperty("message")}");
}
Console.WriteLine(body.RootElement.GetProperty("data"));
Console.WriteLine(body.RootElement.GetProperty("meta").GetProperty("credits_charged"));// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }, serde_json = "1"
use std::env;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let res = reqwest::blocking::Client::new()
.get("https://socialtap.dev/v1/bluesky/posts")
.query(&[("handle", "espn.com")])
.header("x-api-key", env::var("SOCIALTAP_API_KEY")?)
.send()?;
let ok = res.status().is_success();
let body: serde_json::Value = res.json()?;
if !ok {
return Err(format!("{}: {}", body["error"]["code"], body["error"]["message"]).into());
}
println!("{} {}", body["data"], body["meta"]["credits_charged"]);
Ok(())
}200 OK · GET https://socialtap.dev/v1/bluesky/postsDocs One schema on 21 platforms.
The response reference lists every field, its type and when it is null. See the full schema.
Null means unknown
A field Bluesky doesn't show is null, never zero or an empty string, so a real zero always means zero.
What it accepts
The Bluesky account: its handle, with or without the @ (alice.bsky.social, espn.com), its DID (did:plc:z72i7hdynmk6r22z27h6tvur, did:web:didweb.watch), which is the id every answer gives, or a link to its profile.
Write one parser
Bluesky, TikTok, Instagram and 18 more platforms answer in these fields, so the code that reads one reads them all.
platformstring- Platform ID (tiktok, youtube, …).
idstring- Platform-native post ID (string; Instagram shortcode, Reddit with its t3_ prefix).
urlstring- Canonical post URL.
typestring- Normalized content shape. New values may be added; treat unknown values as other.
platform_typestring- The platform's own product label, lowercase (reel, short, story, live, tweet, …).
titlestring- Headline (YouTube title, Reddit title); null on caption-only platforms.
textstring- Caption, description or body as plain text.
languagestring- BCP-47 language of text, when the platform states it.
Live data from 33 platforms.
What teams build with the Bluesky User Posts API.
Monitor an account for new posts
Export an account's post history
Compare engagement across an account's posts
Collect an account's images and video
Good to know before you build.
- The default tab shows what the app's Posts tab shows, without replies to other accounts. Set tab=replies for those too.
- The list goes by each item's latest activity: a post by when it was posted, a repost by when it was reposted, so a repost of an older post sits among recent items. published_at stays the post's own, and platform_specific.bluesky.reposted_by and reposted_at say who reposted it and when.
- The pinned post comes first on the posts and replies tabs, and isn't repeated where it was posted. The media and video tabs show it only where it was posted.
- An account that chose to be seen only by signed-in people returns private_or_unavailable, as the app answers a logged-out visitor.
- A post of such an account that this one reposted is left out, so a page can hold fewer posts than its limit, or none, and still have a next_cursor.
- If Bluesky's AppView (its main API) is down, the posts and replies tabs are read from the account's own repository instead. Counts, the author's badge and is_nsfw are then null, url names the author by DID, reposts aren't listed, and a partial_data warning says so. The media and video tabs then fail.
1 credit per page. Failed requests are free.
Buy credits once and spend them on any endpoint, on any platform.
| Source | Price per successful page | Per 1,000 pages |
|---|---|---|
Platform API api | 1 credit | $0.75–$1.97 |
- Cache hits are free.
- Credits from packs and top-ups never expire.
- A monthly plan's credits are spent first, and what is left of them ends at each renewal.
- Prices exclude tax: sales tax is added at checkout where it applies.
- Outside the US, plans and packs are sold to businesses: checkout asks for your VAT, GST or other business tax ID.
Questions about the Bluesky User Posts API.
More in the API reference.
How much does the Bluesky User Posts API cost?
How do I get the next page?
Try it on an account you know.
Paste its handle or link in the playground, or copy the request into your code.
- No credit card
- Failed requests are free
- Works on all 218 endpoints