There is no single thing called the Bluesky API. There is the AT Protocol — an open spec for repositories, records and identities — and Bluesky is one application built on top of it. Every call you make is an XRPC method under /xrpc/, named after the lexicon that defines it: com.atproto.* for protocol-level operations, app.bsky.* for the Bluesky application layer.
That sounds like pedantry until it starts costing you time. It is the reason posting is com.atproto.repo.createRecord and not POST /posts. It is the reason your access token is signed by a host that is not the host your data lives on. And it is the reason the single most common Bluesky integration bug — links that render as plain text — is not a bug in your HTTP client at all.
This is the reference we wanted while building Bluesky support: the auth story as it actually stands in 2026, what a PDS is and when the distinction bites, the record and facet model, media including the video pipeline nobody documents well, the real rate limits, and the failure modes with what to do about each. Every number here is linked to the lexicon or spec page it came from.
One naming note, because the search results are a mess: atproto api, at protocol api and bluesky api are the same surface. There is no separate REST API sitting beside the protocol.
The four hosts you will talk to
Most Bluesky confusion traces back to assuming there is one server. There are four roles, and they are documented in API Hosts and Auth.
Host | Role | Auth |
|---|---|---|
| The entryway — account creation, session management, token signing | Your session |
| The actual PDS holding the repository | Your session, forwarded |
| The cached public AppView for reads | None |
| The video upload and transcode service | A scoped service token |
The one that surprises people is the first two being different machines. More on that below, because it is also the only place where getting it wrong produces an error message that tells you nothing useful.
App passwords, OAuth, and which one you should actually use
Bluesky has two auth paths, and the honest answer is that both are live and neither is comfortable.
App passwords are the old path. The user generates a credential at bsky.app/settings/app-passwords, hands it to you, and you exchange it for a session:
1curl -s -X POST "https://bsky.social/xrpc/com.atproto.server.createSession" \
2 -H "Content-Type: application/json" \
3 -d '{
4 "identifier": "alice.bsky.social",
5 "password": "xxxx-xxxx-xxxx-xxxx"
6 }'You get back a did, a handle, an accessJwt and a refreshJwt. The access token is good for roughly two hours; you trade the refresh token at com.atproto.server.refreshSession for a new pair.
OAuth is the path Bluesky tells you to use for anything with an end-user login flow, and it is a genuinely unusual profile of OAuth. Per the atproto OAuth spec: there is no dynamic client registration — your client_id is an HTTPS URL that serves a client metadata JSON document, which the authorization server fetches during the flow. PAR, PKCE and DPoP are all mandatory, for every client type. You resolve the user's handle or DID to a DID document, read the PDS out of it, fetch that server's resource metadata, and follow its authorization_servers pointer to find the authorization server — which for Bluesky-hosted accounts is the entryway, not the PDS.
Which to pick:
- Building a product where users log in? OAuth. App passwords are deprecated for this and ask your users to paste a credential into your UI, which is a bad thing to teach them.
- Building a bot, a CLI, or a server-side integration against accounts you control? App passwords are still supported and still much less work. Bluesky's own guidance carves out exactly this case.
We use app passwords today. That is a deliberate trade and worth naming rather than glossing: it means connecting a Bluesky account through us is the one place in our product where there is no OAuth consent screen — the user pastes a handle and an app password. It also means we have no transition:* scope story yet, because app passwords have no scopes; they are near-full account access minus a few protected operations. If the thing you are optimising for is a least-privilege grant, raw OAuth gets you closer than we do.
What a PDS is, and the one moment it matters
A Personal Data Server hosts a user's repository. Bluesky runs many of them, with hostnames like morel.us-east.host.bsky.network, and each federates exactly as a self-hosted PDS would. But the user-facing identity of the whole fleet is bsky.social, which is not a PDS at all — it is the entryway, and the entryway docs are explicit about what it does: it handles createAccount, it manages sessions, it signs the access tokens, and it forwards most requests on to whichever PDS actually holds the account.
Which means that for almost everything, you can pretend the distinction does not exist. Point every call at bsky.social and it works.
Then you try to upload a video, and it stops working.
Video uploads need a service-auth token minted by com.atproto.server.getServiceAuth, and that call takes an aud — the audience the token is good for. The audience has to be the real PDS, as did:web:<pds-host>, because that is the host that will check it. Pass did:web:bsky.social and you have minted a token for the entryway, which is not the server being asked to accept the blob. So you resolve it properly: fetch the DID document, find the service entry, read the endpoint.
1# did:plc:* resolves at plc.directory; did:web:* at /.well-known/did.json on its own host
2curl -s "https://plc.directory/did:plc:EXAMPLE" \
3 | jq -r '.service[] | select(.id == "#atproto_pds") | .serviceEndpoint'
4# -> https://morel.us-east.host.bsky.networkThat hostname, as did:web:morel.us-east.host.bsky.network, is your aud. This is the single piece of AT Protocol trivia most likely to cost you an afternoon, and it is why the identity resolution docs are worth reading before you need them rather than after.
The record model: posts are rows in a repo you own
A Bluesky post is a record in a collection in the user's repository. Creating one is a generic repo write:
1curl -s -X POST "https://bsky.social/xrpc/com.atproto.repo.createRecord" \
2 -H "Authorization: Bearer $ACCESS_JWT" \
3 -H "Content-Type: application/json" \
4 -d '{
5 "repo": "did:plc:EXAMPLE",
6 "collection": "app.bsky.feed.post",
7 "record": {
8 "$type": "app.bsky.feed.post",
9 "text": "hello from the protocol",
10 "createdAt": "2026-10-09T09:00:00Z"
11 }
12 }'You get back a uri and a cid:
1{
2 "uri": "at://did:plc:EXAMPLE/app.bsky.feed.post/3kxyzabc123",
3 "cid": "bafyreib2rxk3rh6kzwq..."
4}Three things follow from that shape, and all three catch people.
The AT-URI is your post id. It decomposes as at://<did>/<collection>/<rkey>, and you need all three parts back to delete: com.atproto.repo.deleteRecord takes repo, collection and rkey separately, not the URI. Store the URI and parse it, or store the parts.
The CID is not decoration. It is a content hash, and replies and quote posts both require the cid of what they point at alongside the URI. If you only kept URIs, you will be making an extra app.bsky.feed.getPostThread call to recover CIDs you already had.
The public web URL is a third format entirely. https://bsky.app/profile/<did>/post/<rkey> — derivable offline from the DID and the rkey, which is worth knowing because it means you never need an API round-trip just to link a user to their own post.
createdAt is a client-supplied string. The server does not overwrite it, which is a feature when you are backfilling and a foot-gun when your clock is wrong.
Text: there are two ceilings, not one
Everyone quotes 300 characters. The app.bsky.feed.post lexicon actually sets two independent constraints on text:
Constraint | Value | Counted in |
|---|---|---|
| 300 | Grapheme clusters |
| 3000 | UTF-8 bytes |
Both are enforced. The lexicon spec is unambiguous about the units: "The basic minLength/maxLength validation constraints are counted as UTF-8 bytes", while maxGraphemes works on grapheme clusters, which "loosely correspond to 'distinct visual characters'".
For English prose the grapheme limit binds first and the byte limit never comes up. For anything else it is not that simple. A ZWJ-sequence emoji is one grapheme and can be twenty-five or more bytes; CJK text runs three bytes per character. Validate against both, and validate graphemes with a real segmenter — Intl.Segmenter in JavaScript, not String.length, which counts UTF-16 code units and will happily tell you a 150-emoji post is 300 characters long.
langs is capped at 3 entries. Setting it is optional and worth doing: it drives language filtering in the Bluesky app, and a post with no langs is invisible to users who have filtered to a specific language.
Facets: the reason your links are not clickable
This is the one. If you take a single thing from this page, take this.
The AT Protocol does no automatic entity detection. A post whose text contains https://example.com renders as literal, unclickable text unless you also send a facet annotating that byte range. Same for @mentions. Same for #hashtags. The protocol's position is that text is text and markup is explicit, and every client library that appears to "just handle links" is doing the detection itself before it sends the record.
A facet is a byte range plus a feature. The app.bsky.richtext.facet lexicon defines the range as a byteSlice, and its description contains the warning that matters:
Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.
So: text.indexOf(url) gives you a UTF-16 offset. Putting that in byteStart is correct for pure ASCII and silently wrong the moment an emoji or an accented character appears earlier in the post. The failure is not an error — the record is accepted, and the link highlight lands a few characters off, or wraps the wrong word. Encode the prefix and take its length:
1const encoder = new TextEncoder();
2const byteStart = encoder.encode(text.slice(0, charIndex)).length;Three more facet rules that are not obvious:
- A mention carries a DID, not a handle. The lexicon requires
didwithformat: did. Put a handle in there and the PDS rejects the entire record, post and all. You have to resolve first, throughcom.atproto.identity.resolveHandle— an unauthenticated call. Treat a failed resolution as "drop the facet, keep the text", never as "fail the post": a typo'd handle in a user's draft should not cost them the publish. - Ranges may not overlap.
https://example.com/@alice.bsky.socialmatches both a URL pattern and a mention pattern. Detect URLs first, let their ranges claim the text, and skip any mention or hashtag that falls inside one. - Trailing punctuation is yours to strip. "see https://example.com." — the sentence's full stop is not part of the URL, and a naive greedy match will put it in the
uriand produce a 404 link.
Hashtag tag values are capped at 640 bytes / 64 graphemes and should not include the leading #.
Images: blobs, four of them, and a new embed that changes the count
Images are uploaded first and referenced second. com.atproto.repo.uploadBlob takes raw bytes with the real Content-Type and returns a blob ref you drop into the embed.
1curl -s -X POST "https://bsky.social/xrpc/com.atproto.repo.uploadBlob" \
2 -H "Authorization: Bearer $ACCESS_JWT" \
3 -H "Content-Type: image/jpeg" \
4 --data-binary @photo.jpgThe limits, from the app.bsky.embed.images lexicon, which is what the server validates against:
Constraint | Value |
|---|---|
Blob size | 2,000,000 bytes — "May be up to 2 MB, formerly limited to 1 MB" |
Accepted types |
|
Images per post | 4 |
| Required on every image — empty string if you have none |
| Optional |
Two notes on that last pair. alt being required means an image embed with the key missing is invalid; an empty string is the correct way to say "no alt text". And aspectRatio being optional is a trap of a different kind: clients reserve layout space from it before the blob loads, so omitting it renders your image inside a default-sized box with a grey gap above it. Read the real dimensions out of the file header and send them. If you genuinely cannot, omit the key — a wrong ratio renders worse than no ratio.
Separately, the four-image ceiling is no longer the whole story. app.bsky.embed.gallery is a newer embed whose items array has a schema ceiling of 20, with the lexicon noting that "Clients should currently enforce a soft limit of 10 items in authoring UIs". Worth knowing two things before you reach for it: the per-image blob cap is still 2 MB, and in the gallery embed aspectRatio is required on every item, not optional as it is in images. Code that omits aspectRatio when it cannot read dimensions will produce a valid images embed and an invalid gallery one.
A post carries exactly one embed. Images, video, a quoted record, an external link card and recordWithMedia are alternatives, not a set you combine.
Video: a different pipeline, not a bigger blob
Video does not go through uploadBlob. It goes to a separate service at video.bsky.app, which transcodes asynchronously, and the whole dance is four steps.
- Check the account can upload.
app.bsky.video.getUploadLimitsreturnscanUpload, plusremainingDailyVideosandremainingDailyBytes. Bluesky-hosted accounts must have a verified email before they can post video — reademailConfirmedfromcom.atproto.server.getSessionif you want to catch that before you have burned an upload. - Mint a service token.
com.atproto.server.getServiceAuth, withaudset to the account's real PDS as described above,lxm=com.atproto.repo.uploadBlob, and a short expiry. - Upload.
POST https://video.bsky.app/xrpc/app.bsky.video.uploadVideo?did=…&name=…with that token. Stream it rather than buffering — these files are large enough to matter. - Poll.
app.bsky.video.getJobStatusuntilstateisJOB_STATE_COMPLETED, at which point the response carries theblobyou put in the embed. The states you will see areJOB_STATE_CREATED,JOB_STATE_ENCODING,JOB_STATE_SCANNING,JOB_STATE_COMPLETEDandJOB_STATE_FAILED.
Two shape details that will cost you a debugging session each. getJobStatus wraps its result in a jobStatus key; uploadVideo's response is the job-status object directly, unwrapped. And re-uploading identical bytes — exactly what a retry after a downstream failure does — returns 409 with error: "already_exists" and the original job's id, without the blob. That is a success you have to go and collect, not a failure: fetch the original job's status and take the blob from there. Treating the 409 as fatal turns one transient downstream error into a permanently unpublishable post.
From the app.bsky.embed.video lexicon: the blob cap is 300,000,000 bytes — "May be up to 300mb, formerly limited to 100mb" — and accept is ["video/mp4"] and nothing else. Send a MOV with its real content type and you are relying on a transcode path the lexicon does not promise you. Captions are supported as up to 20 WebVTT files, each ≤ 20,000 bytes, each tagged with a language.
Note that the per-account limits from getUploadLimits are dynamic and sit underneath that 300 MB ceiling. Both can stop you, and only one of them is a constant.
Threads: every reply needs two pointers
A thread is a chain of ordinary post records, each with a reply block:
1{
2 "$type": "app.bsky.feed.post",
3 "text": "and one more thing",
4 "createdAt": "2026-10-09T09:01:00Z",
5 "reply": {
6 "root": { "uri": "at://…/3kxyzabc123", "cid": "bafy…" },
7 "parent": { "uri": "at://…/3kxyzdef456", "cid": "bafy…" }
8 }
9}root stays pinned to the first post for the entire thread. parent moves to whatever you just published. Set parent correctly but leave root pointing at the parent too and the thread fragments into a chain of two-post conversations — which is, again, not an error, just a wrong-looking thread you find out about from a user.
This is also where a partial failure needs a decision in advance. Post three of a five-post thread fails: the first two are live and public. Aborting does not unpublish them. Continuing from the last success leaves a gap. Both are defensible; silently retrying the whole thread is not, because it double-publishes posts one and two.
Rate limits
Writes are budgeted in points per account, not per request. From the rate limits guide:
Window | Points | Effective post creations |
|---|---|---|
Per hour | 5,000 | 1,666 |
Per day | 35,000 | 11,666 |
CREATE costs 3 points, UPDATE 2, DELETE 1. com.atproto.repo.applyWrites does not get a discount — the guide is explicit that the limits "sum up all of those individual record writes".
The HTTP-level limits are separate, and one of them is the actual constraint on most integrations:
Limit | Scope | Budget |
|---|---|---|
All endpoints | Per IP | 3,000 per 5 minutes |
| Per account | 30 per 5 minutes, 300 per day |
| Per account | 10 per 5 minutes, 50 per day |
Blob upload size | Per blob, at the PDS | 52,428,800 bytes (50 MB) |
30 sessions per 5 minutes is the one that bites. If your publish path calls createSession before each post — the obvious way to write it, and the way every quickstart implies — you are fine in development and rate-limited the first time a queue drains. Create the session once, persist both JWTs, and refresh ahead of the two-hour expiry. Over a day, 300 sessions per account is a hard ceiling no amount of retrying gets you past.
Reads are cheaper than you think: public.api.bsky.app is unauthenticated, cached, and Bluesky asks that you use it for public-web reads. Pointing read traffic there keeps it out of your authenticated budget entirely.
Crossing a limit gets you a 429. Most services return rate-limit headers on the way, so you can back off before you get there rather than after.
The failure modes worth writing code for
Symptom | Cause | What to do |
|---|---|---|
Links and mentions render as plain text | No facets sent | Detect and attach them; the protocol does nothing automatically |
Highlight lands on the wrong characters |
| Encode the prefix to UTF-8 and use its byte length |
| A handle in a mention facet's | Resolve via |
Record rejected with overlapping facets | A mention or hashtag inside a URL's range | Detect URLs first; skip anything that overlaps one |
|
| Persist the session; refresh instead of re-authenticating |
| Access JWT expired | Refresh at |
Video upload returns | Identical bytes already uploaded | Read the cited job's status and take its blob — this is a success |
Video rejected before upload | Account email not verified | Check |
Thread renders as separate conversations |
| Pin |
Image embed renders in a grey box |
| Read real dimensions from the file header |
Service-auth token refused on video upload |
| Resolve the account's real PDS from its DID document |
A general note on retries: of everything above, only the 409 and a genuine 429 deserve one. Auth failures, validation failures and facet rejections are deterministic — retrying them delays the moment somebody reads the error, and in the thread case risks publishing twice.
If you would rather not implement all of that
Everything above is what we implement so that you do not have to. Limits first, because they are the part worth knowing before you choose.
What we do not solve
- No OAuth. We connect Bluesky with app passwords, so your users paste a credential rather than approving a consent screen. Every other network we support is OAuth; Bluesky is the exception.
- The 300-grapheme limit is not checked at request time. Our API accepts a long body and Bluesky rejects it at publish. Validate on your side if you need the error early.
- No gallery embed yet. Up to 4 images per post, via
app.bsky.embed.images. - No external link-card embeds. Links in your text become clickable through facets, which is not the same thing as a rendered preview card.
- No video captions, and our video path currently caps at 100 MB — the protocol's former ceiling — so the largest files Bluesky now accepts still need the raw path.
- No reading or replying to other people's replies. We publish threads; we do not yet expose the conversation surface.
- No view or impression counts, because Bluesky does not expose them. Likes, reposts and reply counts are snapshots with no time series behind them.
What we do
One request creates the session (or reuses it), computes facets with correct byte offsets, resolves mentions to DIDs, uploads media on the right pipeline for its type, sets root and parent down a thread, and hands back addressable ids:
1curl -s -X POST "https://api.outstand.so/v1/posts" \
2 -H "Authorization: Bearer $OUTSTAND_API_KEY" \
3 -H "Content-Type: application/json" \
4 -d '{
5 "accounts": ["Kx7vQ"],
6 "containers": [
7 {
8 "content": "Shipping Bluesky support today 🦋 details at https://outstand.so",
9 "media": ["https://cdn.example.com/shot.png"],
10 "mediaAltTexts": ["The Outstand dashboard showing a connected Bluesky account"]
11 },
12 { "content": "And the AT Protocol notes that came out of building it." }
13 ]
14 }'That containers array is the thread — we set the reply refs in order. mediaAltTexts is per-image, which is a Bluesky-shaped field rather than a generic one: Bluesky requires alt on every image, so this is the network where it actually lands. A bluesky.content override lets a long body go to LinkedIn while a 300-grapheme version goes to Bluesky, from one request.
Connecting an account is the one place our Bluesky path differs from every other network, because there is no OAuth redirect to send anyone through:
1curl -s -X POST "https://api.outstand.so/v1/social-accounts/bluesky" \
2 -H "Authorization: Bearer $OUTSTAND_API_KEY" \
3 -H "Content-Type: application/json" \
4 -d '{
5 "handle": "alice.bsky.social",
6 "appPassword": "xxxx-xxxx-xxxx-xxxx"
7 }'The handle must contain a dot — alice is not a handle, alice.bsky.social is. Reconnecting the same handle under the same tenant replaces that account rather than duplicating it, which makes credential rotation a repeat of the same call. The full contract is in the connect a Bluesky account reference, and the Bluesky configuration docs cover the app-password setup from the user's side.
Bluesky needs no developer portal and no app review, which genuinely is the easiest onboarding of any network we support — the work is the protocol, not the paperwork. If you are sizing that up across platforms, we wrote up what approval looks like on each platform separately. Scheduling, retries and what a per-account publish failure looks like are in the post lifecycle docs; the fields above are in the create a post reference.
For the platform-specific version of all this, our Bluesky API page covers what we support on Bluesky specifically. If you are wiring Bluesky into an agent rather than an app, the Bluesky MCP server exposes the same publishing surface as tools — note that it takes app_password and tenant_id in snake case where the REST endpoint takes appPassword and tenantId. And if Bluesky is one of several platforms you need, that is what the rest of the social media api does. The Threads API and Pinterest API references are built the same way if you are comparing shapes.
The short version
The AT Protocol is a good protocol that does not flatter beginners. Six things carry most of the distance:
- Posts are records. Keep the AT-URI and the CID, because replies and quotes need both.
- Attach facets yourself, with UTF-8 byte offsets, and resolve mentions to DIDs before you send them.
- Validate against 300 graphemes and 3,000 bytes, with a real grapheme segmenter.
- Create one session per account and refresh it. Do not call
createSessionper post. - Video is a separate service, and its service token's audience is the account's real PDS — never
bsky.social. - A
409 already_existson a video upload is a blob you already own. Go and collect it.
Get those right and Bluesky is one of the more pleasant platforms to publish to programmatically — no app review, no access tiers, no quota request form. Just a protocol that means exactly what it says and declines to guess what you meant.