Bluesky AT Protocol API: OAuth, Records and Facets
Every Bluesky tutorial teaches app passwords. This one covers the OAuth flow the protocol actually specifies: PAR, PKCE, private_key_jwt and a DPoP keypair per account, plus the nonce retry nobody documents. Also two different blob limits in one post, and why a pasted URL renders as dead text.
The Bluesky AT Protocol API has no central endpoint to post to. You write a record into the user's own repository, on whatever server hosts that account, through XRPC methods like com.atproto.repo.createRecord. Authentication is OAuth with DPoP-bound tokens, so the header reads Authorization: DPoP <token> and not Bearer. And a URL pasted into the post text stays dead text until you attach a facet or an embed card.
Verified 04.08.2026 against a local mirror of the AT Protocol lexicons and our own production integration. This surface moves faster than Meta's: scopes and lexicons change, so check the date above against the one on the docs you are reading.

Why Bluesky is not like other social APIs
Three differences do most of the damage on a first integration.
The account identifier is a DID, not a numeric ID. did:plc:ewvi7nxzyoun6zhxrhs64oiz is permanent; the handle (@name.bsky.social) is a mutable pointer at it. Store the DID, display the handle.
A post is a record, not a resource. There is no POST /posts. You write an object with $type: "app.bsky.feed.post" into that collection inside the account's repository, and deleting a post means deleting the record.
The API host depends on the user. There is no api.bluesky.com for writes. Every account lives on a Personal Data Server, and you look up which one before you can call it.
Auth: OAuth with DPoP, not app passwords
Every tutorial in the search results teaches app passwords, because com.atproto.server.createSession fits in a single curl. Here is the comparison nobody prints.
| App password | OAuth (atproto profile) | |
|---|---|---|
| You must publish | nothing | client-metadata.json on the public web; that URL is your client_id |
| You store per account | the password, a full-account credential | access token, refresh token, and a DPoP private key |
| Request header | Authorization: Bearer <jwt> | Authorization: DPoP <token> plus a signed DPoP proof |
| Scoping | account-wide | requested scopes, atproto required |
The client_id surprises people: no app registration, no dashboard, and no client secret for any client type. You host a JSON document and its address identifies you.
{ "client_id": "https://api.example.com/bluesky/client-metadata.json", "application_type": "web", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "redirect_uris": ["https://api.example.com/bluesky/callback"], "scope": "atproto transition:generic", "dpop_bound_access_tokens": true, "token_endpoint_auth_method": "private_key_jwt", "token_endpoint_auth_signing_alg": "ES256", "jwks": { "keys": [/* your ES256 public key */] } }
client_id must match the URL it is served from, exactly. Then: discover the authorization server at /.well-known/oauth-authorization-server, generate a fresh ES256 DPoP keypair for the session, push the request to pushed_authorization_request_endpoint (PAR) with PKCE S256 and a private_key_jwt assertion, redirect the user with only client_id and the request_uri you get back, then exchange the code at the token endpoint. We keep one DPoP keypair per connected account, stored encrypted; the spec forbids reusing keypairs across users or sessions.
The nonce dance
Every authorized request carries a DPoP header: a JWT signed with that keypair, containing htm, htu, jti, iat and, for requests to the PDS, ath, the S256 hash of the access token itself. Proofs cannot be replayed, so each request needs a new one.
You cannot know the server's nonce in advance. You find out by failing:
HTTP/1.1 401 Unauthorized WWW-Authenticate: DPoP error="use_dpop_nonce", error_description="..." DPoP-Nonce: eyJ7S_zG.eyJH0-Z.HX4w-7v
Read DPoP-Nonce, sign the proof a second time with nonce included, retry the same request. Ours is a response interceptor firing on 400 or 401 (the authorization server signals it in a JSON error field, the PDS in WWW-Authenticate) that re-signs once and caches the nonce per DID and host. Nonces rotate, and the auth server's nonce is tracked separately from the PDS's.
Send the token as Bearer and you get a 401 with nothing useful in it: the fastest way to lose an afternoon here. Refreshing is a normal OAuth 2 flow, covered in OAuth token refresh for social APIs, except that the refresh call is DPoP-signed with the same keypair and does the same nonce dance.
Your account is a DID, and your PDS is not always bsky.social
Before the first write, find the host. For did:plc: identifiers that means the PLC directory:
const { data } = await axios.get(`https://plc.directory/${did}`, { timeout: 8000 }); const svc = data.service.find((s) => s.type === "AtprotoPersonalDataServer"); const base = svc.serviceEndpoint.replace(/\/+$/, "") + "/xrpc/"; // → https://morel.us-east.host.bsky.network/xrpc/
did:web: identifiers resolve through the domain's own /.well-known/did.json instead. Either way the answer is a serviceEndpoint, and appending /xrpc/ gives you the base URL for that account. Both identifier methods are specified in the AT Protocol DID specification.
Hardcoding https://bsky.social/xrpc/ holds for exactly as long as all your users sit on Bluesky's own infrastructure. Cache the resolution per DID, which changes only when an account migrates, but do not skip it. The authorization server is a separate lookup: it can be the PDS itself, or an entryway in front of a fleet of them.

A post is a record: createRecord, at:// URIs, deleteRecord
curl -X POST "${PDS}/xrpc/com.atproto.repo.createRecord" \ -H "Authorization: DPoP ${ACCESS_TOKEN}" \ -H "DPoP: ${PROOF_JWT}" \ -H "Content-Type: application/json" \ -d '{ "repo": "did:plc:ewvi7nxzyoun6zhxrhs64oiz", "collection": "app.bsky.feed.post", "record": { "$type": "app.bsky.feed.post", "text": "Shipped.", "createdAt": "2026-08-04T09:12:00.000Z" } }'
The response is { "uri": "at://did:plc:ewvi.../app.bsky.feed.post/3lsk2n4x7t22k", "cid": "bafyrei..." }. The URI reads at://<did>/<collection>/<rkey>, and the permalink is assembled from the first and last segments: https://bsky.app/profile/<did>/post/<rkey>.
Deleting is com.atproto.repo.deleteRecord with repo, collection and rkey, so store the at:// URI at publish time and split the rkey back out of it.
Replies are where cid starts to matter. reply.root and reply.parent are both com.atproto.repo.strongRef: { uri, cid }, a URI plus a content hash. The CID is not encoded in the URI, so if all you kept was a permalink you owe a round trip (app.bsky.feed.getPosts, or com.atproto.repo.getRecord) to recover it. Persist cid alongside uri and a thread costs one call per post instead of two.
bundle.social
One call instead of PAR, DPoP and UTF-8 offsets.
PAR, PKCE and a DPoP keypair per account, PDS resolution and UTF-8 facet offsets, behind one call.

Facets: why your link is dead text
Bluesky has no markup language. text is plain, and every annotation lives in a parallel facets array pointing at byte ranges. Paste a URL and it renders as characters: no highlight, no click.
{ "text": "Docs are at bundle.social/docs", "facets": [{ "index": { "byteStart": 12, "byteEnd": 30 }, "features": [{ "$type": "app.bsky.richtext.facet#link", "uri": "https://bundle.social/docs" }] }] }
Three feature types, and they are not interchangeable:
$type | Payload | The catch |
|---|---|---|
#link | uri | The visible text may be shortened or truncated; the uri must be the complete URL |
#mention | did | Not the handle. Resolve @name.bsky.social through com.atproto.identity.resolveHandle first: one network call per mention, before the record exists |
#tag | tag, without the leading # | maxGraphemes: 64, maxLength: 640 |
byteStart is inclusive, byteEnd is exclusive, and both count bytes of the UTF-8 encoding. JavaScript strings are UTF-16, so "🚀".length is 2 while the same emoji occupies 4 bytes. One emoji earlier in the text shifts every later offset by two and the facet lands on the wrong characters. The lexicon warns about this in the schema description itself: convert to bytes before indexing. Bluesky documents the facet model in its post richtext guide.
const byteStart = Buffer.byteLength(text.slice(0, matchIndex), "utf8"); const byteEnd = byteStart + Buffer.byteLength(matchedText, "utf8");
Facets must not overlap. Renderers are advised to sort by byteStart and discard collisions, so an overlap quietly drops an annotation instead of returning an error you could debug.
A facet link and a preview card are different features. A #link facet makes the text clickable in place. app.bsky.embed.external renders a card with a thumbnail underneath the post, and it takes uri, title, description and an optional thumb blob, all supplied by you, because nothing server-side scrapes the page. Most people asking how to get a link preview want the card and go looking for facets. Our own richtext helper builds #tag facets only; links go out as an external card.
Limits: 300 graphemes, 3000 bytes, 2 MB blobs
The values below come straight from the AT Protocol lexicons.
| Limit | Value | Lexicon |
|---|---|---|
| Post text | maxGraphemes: 300 and maxLength: 3000 | app.bsky.feed.post |
| Images per post | 4 | app.bsky.embed.images |
| Image blob | 2,000,000 bytes | app.bsky.embed.images |
| External card thumb | 1,000,000 bytes | app.bsky.embed.external |
| Video blob | 100,000,000 bytes | app.bsky.embed.video |
| Caption files | 20 files, 20,000 bytes each | app.bsky.embed.video |
| Hashtag | maxGraphemes: 64, maxLength: 640 | app.bsky.richtext.facet#tag |
| Any single blob upload to a Bluesky PDS | 52,428,800 bytes | rate-limit documentation |
Text carries two ceilings at once and whichever is reached first wins. ASCII runs out of graphemes long before bytes; emoji and CJK burn bytes three to four times faster.
The blob pair is the trap. The same 1.6 MB JPEG is a valid image attachment and an invalid card thumbnail, in the same post. The thumb budget is half the image budget, and no error message says so.
Then MB against MiB. Lexicon limits are decimal: 2,000,000 bytes. A validator written as 2 * 1024 * 1024 permits 2,097,152. Files inside that 97,152-byte window pass your own checks and fail at uploadBlob, the exact shape of the gap in our own stack, where the media validator uses the MiB conversion and the uploader caps at the decimal value. Video repeats it: 104,857,600 against 100,000,000. Enforce the lexicon number. Cross-platform figures are in our social media API media requirements rundown.
Writes are priced in points against the account, not the app: CREATE costs 3, UPDATE 2, DELETE 1, against 5,000 points per hour and 35,000 per day, so at most 1,666 record creations an hour. Those are Bluesky's documented limits for its own instances; other hosts set their own.
Video goes to a different host
Video does not go through the PDS. A Bluesky PDS caps individual blob uploads at 52,428,800 bytes while the video lexicon allows 100,000,000, so the bytes go somewhere else: video.bsky.app.
Getting in requires a service auth token: a short-lived JWT the PDS mints on behalf of the account, bound to one audience and one method.
const { data: svc } = await client.get("com.atproto.server.getServiceAuth", { params: { aud: `did:web:${pdsHost}`, lxm: "com.atproto.repo.uploadBlob", exp: Math.floor(Date.now() / 1000) + 600, }, }); await axios.post( `https://video.bsky.app/xrpc/app.bsky.video.uploadVideo?did=${did}&name=${fileName}`, videoStream, { headers: { Authorization: `Bearer ${svc.token}`, "Content-Type": "video/mp4" } }, );
Note the header: Bearer, not DPoP. Service auth is a different mechanism. The response is a jobStatus carrying either the finished blob or only a jobId you poll with app.bsky.video.getJobStatus until it does. A failed job state is terminal, a 5xx on the poll is retryable, and telling them apart decides whether your queue retries or gives up; classification rules are in our social media API error handling guide. One caveat: app.bsky.video.* sits in the application namespace, not core com.atproto, so treat this pipeline as Bluesky's rather than the protocol's.
What we do about all of this
bundle.social's Bluesky API collapses the above into one authenticated call. We run the OAuth flow with PAR, PKCE and private_key_jwt, generate and store an encrypted ES256 DPoP keypair per connected account, sign a fresh proof per request, handle the use_dpop_nonce retry, resolve each account's PDS through the PLC directory with a per-DID cache, and upload blobs against the correct cap for the embed type rather than one global number. Video goes to the video service, gets polled to completion, and comes back as a blob in the record. The response hands you the at:// URI and the bsky.app permalink.
Who this is not for: if you are building a Bluesky-native client, custom lexicons or a feed generator, use the atproto SDK directly. We cover publishing and reading posts across platforms, not the whole protocol surface. If Bluesky is one of several networks you ship to, the social media API integration guide covers the rest.
Frequently asked questions
Do Bluesky app passwords still work?
Yes. com.atproto.server.createAppPassword and createSession are still in the lexicons, and an app password is still the fastest route to a working script. The trade-off is a full-account credential with no scoping. OAuth with DPoP is the flow the atproto client documentation specifies, and what we run in production.
Why is my Bluesky link not clickable?
Because post text is plain, with no markup. You need either a #link facet whose byteStart and byteEnd cover the URL in UTF-8 bytes, or an app.bsky.embed.external embed if what you want is a preview card. Offsets taken from a JavaScript string length break as soon as the text contains an emoji.
How do I delete a Bluesky post?
Call com.atproto.repo.deleteRecord with the account DID as repo, app.bsky.feed.post as collection, and the rkey, the last segment of the at:// URI that createRecord returned. Store that URI when you publish; the permalink holds the same parts, but the URI is what the API takes.
Do I have to support other people's PDS hosts?
If your users can be anyone on the network, yes. Resolve each account's DID document to a serviceEndpoint and call that host. Hardcoding bsky.social works until the first self-hosted account connects, and then every write fails for that one user while everyone else is fine.
Does the Bluesky API give post views or impressions?
No. The post view definition exposes likeCount, repostCount, replyCount and quoteCount, and nothing resembling views. Any reach figure in a Bluesky analytics tool is derived from those four counters, not reported by the platform. Plan dashboards around engagement counts, and say so in the UI.