Back to blog
Bluesky APIAT Protocolsocial media API

Bluesky API: profile, author feed, and why the first post is often a repost

CaptapiOctober 10, 20263 min read
TL;DR
AT Protocol profiles as Captapi JSON — and the isRepost / repostedBy fields that stop you crediting someone else's likes to the handle you asked for.
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.