Bluesky API: profile, author feed, and why the first post is often a repost
Bluesky's AT Protocol is public. You can call AppView yourself. GET /v1/bluesky/profile (1 credit) and GET /v1/bluesky/user-posts (~0.1 credits per row) return the same records with Captapi's profile/post keys — and they mark reposts so you do not credit someone else's likes to the handle you queried.
Captured October 10, 2026 against jay.bsky.team (Jay Graber).
A profile is a DID plus a handle
{
"platform": "bluesky",
"id": "did:plc:oky5czdrnfjpqslsw2a5iclo",
"did": "did:plc:oky5czdrnfjpqslsw2a5iclo",
"handle": "jay.bsky.team",
"displayName": "Jay 🦋",
"followers": 594632,
"following": 3986,
"postCount": 4172,
"verified": true,
"verification": {
"verifiedStatus": "valid",
"verifications": [{
"issuerHandle": "bsky.app",
"issuerDisplayName": "Bluesky",
"isValid": true
}]
},
"createdAt": "2022-11-17T06:31:40.296Z",
"indexedAt": "2026-03-29T21:16:33.460Z"
}
id and did are the same PLC. indexedAt is when AppView last indexed the profile record — not last activity (this one was March; the account was still posting in October). verified is the boolean; verification.verifications[] is the issuer list with handles resolved.
The call
JavaScript:
const headers = { Authorization: `Bearer ${process.env.CAPTAPI_KEY}` };
const posts = await fetch(
"https://api.captapi.com/v1/bluesky/user-posts?" +
new URLSearchParams({
url: "https://bsky.app/profile/jay.bsky.team",
limit: "20",
}),
{ headers },
).then((r) => r.json());
for (const p of posts.data.posts) {
const who = p.isRepost ? p.repostedBy.handle : p.author.handle;
console.log(p.isRepost, who, p.engagement.likes, p.url);
}
Python:
import httpx, os
r = httpx.get(
"https://api.captapi.com/v1/bluesky/user-posts",
params={"url": "https://bsky.app/profile/jay.bsky.team", "limit": 20},
headers={"Authorization": f"Bearer {os.environ['CAPTAPI_KEY']}"},
timeout=30,
)
data = r.json()["data"]
for p in data["posts"]:
who = p["repostedBy"]["handle"] if p["isRepost"] else p["author"]["handle"]
print(p["isRepost"], who, p["engagement"]["likes"], p["url"])
print("cursor", data.get("nextCursor"))
Pass includeReposts=false to drop reposts. filter= maps to Bluesky's feed filter (posts_with_replies, posts_no_replies, posts_with_media, …) and does not control reposts.
The trap: the first row is often not theirs
The first of five posts on that call:
{
"url": "https://bsky.app/profile/attie.ai/post/3mx7z2rao7k26",
"text": "Use Attie to catch up on your Bluesky lore…",
"author": { "handle": "attie.ai", "displayName": "Attie" },
"isRepost": true,
"engagement": { "likes": 80, "reposts": 13, "replies": 5, "quotes": 5 },
"repostedBy": { "handle": "jay.bsky.team", "displayName": "Jay 🦋" },
"repostedAt": "2026-10-06T19:08:41.768Z",
"publishedAt": "2026-10-06T17:59:10.671Z"
}
The URL and author belong to attie.ai. The 80 likes are Attie's. Jay is on repostedBy / repostedAt. If you group by the queried handle and sum engagement.likes, you invent Jay's numbers. Rows are ordered by effective timestamp — repostedAt for reposts, publishedAt otherwise. Re-sorting the page by publishedAt alone is a different feed.
nextCursor was 2026-09-28T08:57:03.047Z — Bluesky's opaque cursor. Do not derive the next page from publishedAt.
Jay's profile also shipped associated{lists: 0, feedgens: 0, starterPacks: 0, labeler: false} and an empty labels[]. Those fields tell a feed/labeler service account from a person. name is a deprecated alias of displayName for one release — prefer displayName.
A single post thread — nested replies[], facet links[] / mentions[] / hashtags[] — is /v1/bluesky/post-details (1 credit, depth 0–6). That is the only Captapi surface that returns Bluesky reply content. Keyword search on Threads is a different product; how Meta's Top SERP search actually ranks.
The user-posts page has the embed types (external | images | video | quote) and the cursor rule. A free key is 100 credits — a profile plus a 20-row author feed.