BskySuite is built entirely on the AT Protocol's public API, no scraping, no browser automation, no unofficial endpoints. Every profile lookup, post fetch, and trending topic is a standard HTTPS call to an open API that any developer can access. This post explains the key concepts you need to understand before building something on AT Protocol, and walks through the most useful public endpoints.
Core Concepts
DIDs, Your Portable Identity
Every account on AT Protocol has a DID (Decentralised Identifier), a unique, persistent ID that looks like did:plc:abc123xyz.... Unlike a username, your DID never changes even if you move your account to a different server, change your handle, or switch providers. It's the stable foundation of your identity on the network.
Your handle (@alice.bsky.social) is a human-readable alias that resolves to your DID via a DNS TXT record. This is why custom domain handles work, you add a TXT record to your domain pointing to your DID, and Bluesky resolves it.
PDS, Personal Data Server
Your posts, follows, likes, and profile data are stored on a PDS (Personal Data Server). By default this is bsky.social, Bluesky's hosting service. But the architecture allows you to run your own PDS and host your own data. Your DID points to your PDS, and because your identity is in the DID (not the PDS), you can migrate between servers without losing your social graph.
AppView
The AppView is the aggregation layer, a service that indexes posts from all PDSes across the network and makes them queryable. Bluesky runs a public AppView at api.bsky.app (authenticated) and public.api.bsky.app (unauthenticated, read-only). BskySuite uses the public AppView exclusively.
Lexicon
AT Protocol uses Lexicon, a schema definition system, to define API methods and record types. Each method has an ID in reverse-domain format: app.bsky.feed.getTimeline, com.atproto.identity.resolveHandle. The namespace prefix (app.bsky vs com.atproto) indicates whether it's a Bluesky-specific feature or a core protocol feature.
The Public API, No Auth Required
The most important thing for developers to know: Bluesky's public content is accessible without any API key or authentication. All you need is HTTPS. The base URL for unauthenticated requests is:
Useful Endpoints
Resolve a handle to a DID:
Get a user's profile:
Get a post thread (post + replies):
Get a user's recent posts:
Get trending topics:
Search posts:
Note on URIs: Posts on AT Protocol are identified by AT URIs: at://did:plc:abc123/app.bsky.feed.post/recordkey. The record key is the last segment of a bsky.app URL. So https://bsky.app/profile/alice.bsky.social/post/3k7x2abc → record key is 3k7x2abc.
Getting Media from a Post
This is specifically how BskySuite works. When a user pastes a post URL:
- Extract the handle and record key from the URL.
- Resolve the handle to a DID using
com.atproto.identity.resolveHandle. - Fetch the post record from the user's PDS:
GET {pds}/xrpc/com.atproto.repo.getRecord?repo={did}&collection=app.bsky.feed.post&rkey={recordKey} - Parse the
embedfield, it will beapp.bsky.embed.video,app.bsky.embed.images, orapp.bsky.embed.external. - Construct the blob URL:
https://{pds}/xrpc/com.atproto.sync.getBlob?did={did}&cid={blobCid}
The blob URL is a direct link to the file on the user's PDS. Because the AT Protocol is open, this is legitimate, documented, and officially supported, not scraping.
The Official SDKs
Bluesky provides an official TypeScript SDK that handles authentication, request signing, and type safety:
For unauthenticated read-only access you can use it without credentials. For Python, the community-maintained atproto package on PyPI provides similar functionality. Both are well-documented and actively maintained.
Rate Limits
The public API does apply rate limits, though they're generous for individual use. Authenticated requests have higher limits than unauthenticated ones. For production applications that will make many requests, you should:
- Cache responses where appropriate, profile data doesn't change every second
- Implement exponential backoff on 429 responses
- Consider running your own PDS if you need write access at scale
- Check the official documentation for current limits, as they're subject to change
What You Can Build
The public API is rich enough to build a wide range of tools without authentication: analytics dashboards, content aggregators, feed generators, handle availability checkers, cross-posting tools, archiving services, and more. Anything that only reads public data is straightforward. For write operations (posting, following, liking) you'll need OAuth or app passwords, which Bluesky also supports.
The AT Protocol specification and Bluesky's documentation are at atproto.com and docs.bsky.app respectively. The social-app repository on GitHub is also worth reading, it's the reference implementation of a full Bluesky client, all open source.
See AT Protocol in action
BskySuite's tools are all built on the public AT Protocol API. Try the Profile Analytics tool to see what data is available from any public Bluesky account.
Try Profile Analytics →A Worked Example, Handle to Image URLs
The steps above are easier to follow as running code. This is the whole path from a pasted bsky.app URL to a list of downloadable image URLs, with no dependencies and no authentication.
async function getPostImages(postUrl) {
// 1. pull the handle and record key out of the URL
const m = postUrl.match(/profile\/([^/]+)\/post\/([^/?]+)/);
if (!m) throw new Error('Not a Bluesky post URL');
const [, handle, rkey] = m;
// 2. resolve the handle to a DID
const r = await fetch(API + '/com.atproto.identity.resolveHandle?handle=' + handle);
if (!r.ok) throw new Error('Handle not found');
const { did } = await r.json();
// 3. fetch the thread, which carries the fully hydrated post
const uri = 'at://' + did + '/app.bsky.feed.post/' + rkey;
const t = await fetch(API + '/app.bsky.feed.getPostThread?uri=' + encodeURIComponent(uri));
if (!t.ok) throw new Error('Post not found or deleted');
const { thread } = await t.json();
// 4. read the embed
const embed = thread.post.embed;
if (!embed) return [];
if (embed.$type === 'app.bsky.embed.images#view')
return embed.images.map(i => i.fullsize);
if (embed.$type === 'app.bsky.embed.recordWithMedia#view')
return (embed.media.images || []).map(i => i.fullsize);
return [];
}
Two details are worth calling out. Using getPostThread rather than getRecord returns the hydrated view, where blob references have already been turned into usable CDN URLs. Going through com.atproto.repo.getRecord instead gives you the raw record, and you have to build blob URLs yourself. Both work; the view is less code.
The other is recordWithMedia. A quote post that also carries images uses this type rather than the plain images type, and code that only checks for app.bsky.embed.images#view silently returns nothing for those posts. That single omission is the most common bug in third-party Bluesky downloaders.
Pagination and Cursors
Any endpoint returning a list is paginated with an opaque cursor rather than page numbers or offsets. The response carries a cursor field, and you pass it back to get the next page. When the field is absent, you have reached the end.
const out = [];
let cursor;
do {
const u = new URL(API + '/app.bsky.feed.getAuthorFeed');
u.searchParams.set('actor', actor);
u.searchParams.set('limit', '100');
if (cursor) u.searchParams.set('cursor', cursor);
const res = await fetch(u);
if (!res.ok) break;
const data = await res.json();
out.push(...data.feed);
cursor = data.cursor;
await new Promise(r => setTimeout(r, 100)); // be polite
} while (cursor);
return out;
}
Never treat a cursor as sortable or meaningful. It is an opaque token, its format is not part of the contract, and building logic on its contents will break without warning. Add a hard page cap in production too, because an account with a very long history can otherwise loop for a surprisingly long time.
Errors You Will Actually Hit
The API returns conventional HTTP status codes with a JSON body containing error and message. These are the ones worth handling explicitly.
| Status | Error | What it really means |
|---|---|---|
| 400 | InvalidRequest | Malformed AT URI, or a handle that does not resolve. Check you encoded the URI. |
| 400 | RecordNotFound | Post deleted, or the record key is wrong |
| 400 | ActorNotFound | Account deleted, renamed, or the handle was mistyped |
| 401 | AuthenticationRequired | You hit an authenticated endpoint on the public host |
| 429 | RateLimitExceeded | Back off; read the ratelimit-reset header rather than guessing |
| 502 or 503 | UpstreamFailure | A PDS is unreachable. Transient; retry with backoff. |
A blocked or deactivated account often surfaces as a successful response containing a #blockedPost or #notFoundPost node inside the thread, not as an HTTP error. Check the $type on the thread node before assuming thread.post exists, or you will get an undefined property crash on perfectly valid responses.
When You Genuinely Need Authentication
Public reads cover far more than people expect, which is why BskySuite has never needed a login. You need credentials only for a specific set of things.
| Works unauthenticated | Requires auth |
|---|---|
| Profiles, posts, threads, media | Posting, replying, liking, reposting |
| Follower and following lists | Following and blocking |
| Post search and trending topics | Your own notifications and timeline |
| Public feeds and starter packs | Direct messages |
| Handle resolution | Muted and blocked lists |
When you do need it, prefer OAuth for anything other people will use. App passwords are simpler and fine for your own scripts, but they hand a third party broad access to an account, and asking strangers to generate one for your tool is a pattern worth avoiding.
Five Mistakes That Cost People a Day
Forgetting to encode the AT URI. An at:// URI contains slashes and colons. Passing it raw into a query string produces a 400 that reads like the post does not exist. Use encodeURIComponent.
Assuming the handle is stable. Handles change. If you store an identity, store the DID, and resolve the handle each time you need to display it. Caching a handle as a primary key will eventually point at the wrong account.
Hitting the wrong host. public.api.bsky.app is read-only and unauthenticated. api.bsky.app expects a session. Sending an authenticated request to the public host, or an anonymous one to the authenticated host, produces confusingly unrelated errors.
Ignoring 429 until production. Limits are generous enough that you will not notice them while testing on one account and will hit them immediately on a loop over a thousand. Add backoff before you need it.
Only handling one embed type. As above, recordWithMedia catches out almost everyone. Handle it from the start rather than fielding bug reports about quote posts later.
Frequently Asked Questions
What is the AT Protocol?
The AT Protocol (Authenticated Transfer Protocol) is an open, decentralised social networking protocol developed by Bluesky PBC. It defines how social data is stored, addressed, and transferred. Bluesky is the first major application built on it, but the protocol is designed to support multiple independent apps.
Do I need an API key?
No. Public data on Bluesky is accessible through public.api.bsky.app without any authentication. You only need an auth token for write operations or accessing private content.
What languages are supported?
Bluesky provides an official TypeScript/JavaScript SDK. Community libraries exist for Python, Go, Rust, Ruby, and others. Any language that can make HTTP requests works, the protocol is just JSON over HTTPS.
What is a DID?
A DID (Decentralised Identifier) is a unique, persistent identifier not tied to any server. On Bluesky, it looks like did:plc:abc123... and remains stable even if you move accounts or change your handle. Your @handle is a human-readable alias that resolves to your DID.