Engineering

Bluesky API: the AT Protocol parts that actually bite

There is no single Bluesky API — there is the AT Protocol, and a handful of details that decide whether your integration works. Byte-offset facets, the entryway that is not your PDS, two text ceilings instead of one, and a video pipeline that lives on its own host. Every limit linked to the lexicon it came from.

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

bsky.social

The entryway — account creation, session management, token signing

Your session

*.host.bsky.network

The actual PDS holding the repository

Your session, forwarded

public.api.bsky.app

The cached public AppView for reads

None

video.bsky.app

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:

bash
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.

bash
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.network

That 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:

bash
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:

json
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

maxGraphemes

300

Grapheme clusters

maxLength

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:

ts
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 did with format: did. Put a handle in there and the PDS rejects the entire record, post and all. You have to resolve first, through com.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.social matches 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 uri and 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.

bash
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.jpg

The 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

image/*

Images per post

4

alt

Required on every image — empty string if you have none

aspectRatio

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.

  1. Check the account can upload. app.bsky.video.getUploadLimits returns canUpload, plus remainingDailyVideos and remainingDailyBytes. Bluesky-hosted accounts must have a verified email before they can post video — read emailConfirmed from com.atproto.server.getSession if you want to catch that before you have burned an upload.
  2. Mint a service token. com.atproto.server.getServiceAuth, with aud set to the account's real PDS as described above, lxm=com.atproto.repo.uploadBlob, and a short expiry.
  3. 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.
  4. Poll. app.bsky.video.getJobStatus until state is JOB_STATE_COMPLETED, at which point the response carries the blob you put in the embed. The states you will see are JOB_STATE_CREATED, JOB_STATE_ENCODING, JOB_STATE_SCANNING, JOB_STATE_COMPLETED and JOB_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:

json
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

com.atproto.server.createSession

Per account

30 per 5 minutes, 300 per day

com.atproto.identity.updateHandle

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

byteStart from a UTF-16 index

Encode the prefix to UTF-8 and use its byte length

Invalid DID and the whole record is rejected

A handle in a mention facet's did

Resolve via com.atproto.identity.resolveHandle first; drop the facet if it fails

Record rejected with overlapping facets

A mention or hashtag inside a URL's range

Detect URLs first; skip anything that overlaps one

429 on login, only under load

createSession called per publish

Persist the session; refresh instead of re-authenticating

401 after about two hours

Access JWT expired

Refresh at com.atproto.server.refreshSession, with a buffer before expiry

Video upload returns 409 already_exists

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 emailConfirmed on getSession and tell the user what to fix

Thread renders as separate conversations

root moved along with parent

Pin root to the first post for the whole chain

Image embed renders in a grey box

aspectRatio omitted

Read real dimensions from the file header

Service-auth token refused on video upload

aud set to bsky.social

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:

bash
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:

bash
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 createSession per post.
  • Video is a separate service, and its service token's audience is the account's real PDS — never bsky.social.
  • A 409 already_exists on 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.