Bluesky API Guide 2026: Posting with the AT Protocol, App Passwords, OAuth, Facets, and Limits
How to post to Bluesky through the AT Protocol in 2026: app passwords vs OAuth, createRecord, the 300-grapheme limit, facets in UTF-8 bytes, images and link cards, threadgates, rate limits, and what the API does not offer.
TLDR: Bluesky’s API is the AT Protocol, and it is the easiest of the ten networks we publish to: no review, no scopes, no fee. A post is a record you write with com.atproto.repo.createRecord, limited to 300 graphemes, with links and mentions declared as facets whose offsets are UTF-8 bytes, and up to four images uploaded as blobs first. Authenticate with an app password today or OAuth (DPoP and PKCE required) if you want the recommended path. Writes cost points, 3 per post, against 5,000 an hour and 35,000 a day. There is no analytics API. Bluesky was PostZen’s first platform without an OAuth redirect, and this guide includes what that taught us. Checked against the AT Protocol specs and lexicons on September 8, 2026.
Does Bluesky have an API?
Yes, and it is unlike the others in this series. Bluesky is one app on the AT Protocol, an open network where your account’s data lives in a repository on a Personal Data Server (PDS), most often bsky.social, and apps read an aggregated view from an AppView. Everything the Bluesky app does, it does through the same API you can call.
Three consequences for developers:
- No gatekeeping. There is no app registration, no review queue, and no permission to apply for before you can publish. The Threads API needs App Review; the X API bills per request. Bluesky needs a user and a password.
- Reads are public. The AppView at
https://public.api.bsky.appanswers profile and thread queries without any auth. We fetched a profile from it while writing this, no token attached. - The schema is published. Every record type is a Lexicon, a JSON schema in the atproto repository, so limits like “300 graphemes” are readable facts rather than support-article folklore.
The trade is that you take on protocol concepts: DIDs, repositories, blobs, and facets. None of them is hard, but the docs assume you know them.
App passwords or OAuth: how do you authenticate?
App passwords are the pragmatic path. The user creates one under Settings → Privacy and Security → App Passwords; it looks like xxxx-xxxx-xxxx-xxxx, can be revoked individually, and by default cannot read the account’s direct messages unless the user ticks the privileged option. Your app calls com.atproto.server.createSession with the handle and app password and gets back accessJwt, refreshJwt, did, and handle. The createSession lexicon also returns active and a status of takendown, suspended, or deactivated when the account is not usable.
Tokens are short-lived. In our integration access tokens expire after roughly two hours, and com.atproto.server.refreshSession returns a new pair; the refresh token is single-use, so store the returned one every time. Bluesky publishes no numeric lifetimes, so build for expiry rather than a schedule.
OAuth is what the protocol recommends. The AT Protocol OAuth spec states that “most user-facing software is expected to use OAuth,” and it is stricter than the OAuth you know: DPoP-bound tokens with server-issued nonces are mandatory, PKCE with S256 is mandatory, and your client_id is the URL of a public JSON client-metadata document you host. Scopes are coarse today, atproto plus transitional scopes like transition:generic, with a granular system (repo:app.bsky.feed.post, blob:*/*, permission sets) rolling out through 2025 and 2026.
Our experience shipping Bluesky as the first PostZen platform without an OAuth redirect: the connect flow needed a hosted page where the user types their handle and app password, that page had to be public because API customers’ end users have no PostZen login, and the single-use connect token became the security boundary. Token refresh needed a fallback to a fresh createSession with the stored app password when a refresh failed, but only on definite failures; a timeout or a 429 must be retried, not treated as a dead credential, or you disconnect healthy accounts during a Bluesky outage. If you go the app-password route, plan for all three.
How do you create a post with the Bluesky API?
A post is an app.bsky.feed.post record written into the user’s repository with com.atproto.repo.createRecord. The post lexicon fixes the shape:
| Field | Rule |
|---|---|
text |
maxGraphemes: 300, maxLength: 3000 bytes; may be empty when there is an embed |
createdAt |
Required; a client-supplied timestamp |
facets |
Rich text annotations for links, mentions, and hashtags |
embed |
Images, an external link card, a quoted record, video, or record-with-media |
langs |
Up to 3 BCP-47 codes |
reply |
root and parent strong references (uri plus cid) for replies |
labels |
Self-applied content labels |
tags |
Up to 8 additional tags, 64 graphemes each |
Grapheme counting matters. “300 characters” in Bluesky’s UI means 300 user-perceived characters, so an emoji with a skin-tone modifier is one, not several; but the 3,000-byte ceiling means a post of 300 CJK characters can still be rejected if it exceeds the byte cap with facets. Count graphemes with Intl.Segmenter or a grapheme library, not .length.
With the official @atproto/api package, the SDK handles sessions, facet detection, and record shape:
import { AtpAgent, RichText } from '@atproto/api'
export async function postToBluesky({ handle, appPassword, text, pdsUrl = 'https://bsky.social' }) {
const agent = new AtpAgent({ service: pdsUrl })
await agent.login({ identifier: handle, password: appPassword })
// RichText converts links, @mentions, and #tags into facets with correct byte offsets,
// resolving mentions to DIDs via the network.
const rt = new RichText({ text })
await rt.detectFacets(agent)
if (rt.graphemeLength > 300) throw new Error(`post is ${rt.graphemeLength} graphemes; limit is 300`)
const result = await agent.post({
text: rt.text,
facets: rt.facets,
langs: ['en'],
createdAt: new Date().toISOString(),
})
return result.uri // at://did:plc:.../app.bsky.feed.post/...
}
In Python the raw XRPC calls are short enough to write by hand, and doing so shows what the SDKs hide:
import datetime as dt
import requests
PDS = 'https://bsky.social'
def create_session(handle: str, app_password: str) -> dict:
r = requests.post(f'{PDS}/xrpc/com.atproto.server.createSession',
json={'identifier': handle, 'password': app_password}, timeout=30)
r.raise_for_status()
return r.json() # accessJwt, refreshJwt, did, handle
def create_post(session: dict, text: str, facets: list | None = None, embed: dict | None = None) -> dict:
record = {
'$type': 'app.bsky.feed.post',
'text': text,
'createdAt': dt.datetime.now(dt.timezone.utc).isoformat().replace('+00:00', 'Z'),
'langs': ['en'],
}
if facets:
record['facets'] = facets
if embed:
record['embed'] = embed
r = requests.post(
f'{PDS}/xrpc/com.atproto.repo.createRecord',
headers={'Authorization': f"Bearer {session['accessJwt']}"},
json={'repo': session['did'], 'collection': 'app.bsky.feed.post', 'record': record},
timeout=30,
)
r.raise_for_status()
return r.json() # uri, cid
Two details in that code are load-bearing: repo is the user’s DID, not their handle, and the PDS URL should come from the user’s DID document rather than being hard-coded, because self-hosted accounts live elsewhere. Resolve a handle with com.atproto.identity.resolveHandle, fetch the DID document, and read the #atproto_pds service endpoint. For bsky.social accounts the endpoint resolves to Bluesky’s own servers, which is why hard-coding works until the first self-hosted user connects.
Facets: how links, mentions, and hashtags work
Bluesky does not parse post text. A URL in text is just characters until you attach a facet saying “bytes 42 to 65 are a link to this URI.” The facet lexicon defines three features:
app.bsky.richtext.facet#linkwith auriapp.bsky.richtext.facet#mentionwith adid(resolve the handle first)app.bsky.richtext.facet#tagwith atag
Each facet has an index with byteStart (inclusive) and byteEnd (exclusive), and those are UTF-8 byte offsets, not JavaScript string indices or Python character positions. This is the single most common Bluesky bug we see and the one we hit ourselves: a post with an emoji before the link renders the facet shifted by two or more characters, underlining the wrong text. Encode the string to UTF-8 and measure there:
def link_facet(text: str, url: str) -> dict:
encoded = text.encode('utf-8')
start = encoded.index(url.encode('utf-8'))
return {
'index': {'byteStart': start, 'byteEnd': start + len(url.encode('utf-8'))},
'features': [{'$type': 'app.bsky.richtext.facet#link', 'uri': url}],
}
Facet text still counts toward the 300 graphemes. A long URL in the text eats the budget even though the facet could point anywhere; the common pattern is to display shortened text and put the full URL in the facet’s uri.
Images, link cards, quotes, and video
Media is a two-step process: upload the bytes with com.atproto.repo.uploadBlob (raw body, correct Content-Type), get back a blob reference, and put that reference in the post’s embed. The uploadBlob lexicon carries a warning worth reading twice: “The blob will be deleted if it is not referenced within a time window (eg, minutes).” Upload immediately before you create the record, not in a batch an hour earlier.
| Embed | What it holds | Limits |
|---|---|---|
app.bsky.embed.images |
1 to 4 images, each with required alt and optional aspectRatio |
Historically 1,000,000 bytes per image; the lexicon now notes a raised cap, but clients still enforce 1 MB, so stay under it |
app.bsky.embed.external |
A link card: uri, title, description, optional thumb blob |
Thumb ≤ 1,000,000 bytes; you fetch and supply the metadata yourself |
app.bsky.embed.record |
A quote post: a strong ref (uri + cid) to another record |
|
app.bsky.embed.recordWithMedia |
A quote plus images or video | |
app.bsky.embed.video |
An MP4 blob with optional captions (VTT, up to 20) and alt |
Lexicon: up to 300 MB, “formerly limited to 100mb”; duration cap reported as 3 minutes, not published in the lexicon |
Two things are easy to miss. Link cards are not automatic: Bluesky will not unfurl a URL for you, so a scheduler has to fetch the page, extract title and description, upload a thumbnail, and build the external embed, and a post can carry either images or a link card, not both. Video does not upload through the PDS at all: it goes to video.bsky.app with a service-auth token from com.atproto.server.getServiceAuth, then you poll app.bsky.video.getJobStatus for the processed blob, and app.bsky.video.getUploadLimits tells you the account’s remaining daily videos and bytes.
Who can reply: threadgates and postgates
Two companion records control interaction. A threadgate, written at the same record key as the post, holds up to 5 allow rules from mentionRule, followerRule, followingRule, and listRule; an empty allow array blocks all replies, and no threadgate at all allows everyone. It can also list up to 300 hiddenReplies. A postgate controls quoting: embeddingRules with disableRule turns quote posts off, and detachedEmbeddingUris removes your post from quotes that already exist. Neither is required, and both are separate createRecord calls.
What are the Bluesky API rate limits?
Bluesky publishes its limits at bsky.network/docs/rate-limits, and they are per account, per IP, and per operation.
| Limit | Value |
|---|---|
| Repository writes | 5,000 points per hour, 35,000 per day, per account |
| Points | Create 3, update 2, delete 1; “at most 1,666 records per hour and 11,666 records per day” |
| All requests | 3,000 per 5 minutes per IP |
createSession |
30 per 5 minutes and 300 per day per account |
| Account creation | 100 per 5 minutes per IP |
The createSession limit is the one an app-password integration trips. If you re-authenticate on every publish instead of refreshing sessions, 300 posts a day is your ceiling, and a burst of retries after an outage can lock a user out for the day. Persist the session, refresh it, and fall back to createSession only when refresh definitively fails. Responses carry RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, and RateLimit-Policy headers; read RateLimit-Reset before retrying a 429.
Reading data, and the analytics that do not exist
Reads go to the AppView. app.bsky.feed.getPostThread “does not require auth, but additional metadata and filtering will be applied for authed requests,” with depth and parentHeight parameters up to 1,000. app.bsky.actor.getProfile and app.bsky.feed.getAuthorFeed follow the same pattern at https://public.api.bsky.app.
There is no analytics API. A post view exposes likeCount, repostCount, replyCount, quoteCount, and bookmarkCount, and that is the complete list; there are no impressions, reach, or click metrics anywhere in the protocol. Products that show “Bluesky analytics” are counting engagement objects, and so does ours.
Bot etiquette
Bluesky’s bot guidance asks automated accounts to add the bot self-label to their profile and to interact (like, repost, reply) only when a user has tagged them, “or else the bot may be taken for spam.” The community guidelines treat “automated or bulk interactions that would cause notifications to users” and “any method to automate generating followers or interactions” as spam. Publishing an account’s own posts on a schedule is fine; automated engagement is not.
Bluesky API errors you will see
| Error | Meaning | Fix |
|---|---|---|
AuthenticationRequired |
No or unusable token | Refresh or re-create the session |
ExpiredToken |
Access token expired (about two hours) | Refresh with the refresh token |
InvalidToken |
Refresh token already used or revoked | Fall back to createSession with the app password; if that fails, the user must reconnect |
AuthFactorTokenRequired |
Account has email 2FA | Prompt for the emailed code and pass authFactorToken |
AccountTakedown |
Account suspended or taken down | Nothing to fix in code; surface it |
HandleNotFound |
Mention or login handle does not resolve | Check spelling; handles can change |
InvalidRequest |
Schema violation, such as text over 300 graphemes or an image over the blob limit | Validate before sending; the message names the field |
| HTTP 429 | Points or per-IP limit hit | Sleep until RateLimit-Reset |
Bluesky’s errors are terse; the message field usually names the offending field, so log the whole body.
Is the Bluesky API free?
Yes, entirely, and with no approval gate. It is the cheapest platform in our comparison of the ten to get started with, and the protocol concepts are the whole cost.
The shorter path: one request, facets and blobs handled
Everything above is a few hundred lines once you add session persistence with the refresh-then-fallback logic, UTF-8 facet math, image compression under 1 MB, link-card fetching, and the rate-limit bookkeeping. PostZen does that behind one request, with a hosted connect page where the user enters their handle and app password, and the same request publishes to nine other networks.
curl -X POST https://api.postzen.dev/v1/posts \
-H "Authorization: Bearer pzn_live_..." \
-H "Content-Type: application/json" \
-d '{
"content": "Queue-based scheduling is live. Fill your slots once and publish forever: https://www.postzen.dev/queue-posts #buildinpublic",
"publishNow": true,
"mediaItems": [
{ "url": "https://cdn.example.com/queues-1.png" },
{ "url": "https://cdn.example.com/queues-2.png" }
],
"platforms": [
{
"platform": "bluesky",
"accountId": "jh72r5nqk9wx3v8m1t4cz6bs0fy5dg3e",
"settings": {
"altTexts": ["The queue slots screen", "A scheduled post preview"],
"languages": ["en"],
"disableLinkCard": true
}
}
]
}'
PostZen enforces the 300-character limit before the request reaches Bluesky, turns links, mentions, and hashtags into facets with correct byte offsets, accepts 1 to 4 images at 1 MB each with per-image altTexts, generates a link card for the first URL unless disableLinkCard is set, and takes up to 3 languages. Video posts are not yet supported through PostZen. Details are in the Bluesky integration docs, the Bluesky page, and the social media API overview. Bluesky’s own limits apply on the other side of any tool, ours included; the difference is that on this network there is no review standing between you and the first post.
Frequently asked questions
Does Bluesky have an API?
Yes. Bluesky runs on the AT Protocol, an open API with no app review, no scopes to apply for, and no fee. Any account can create posts with com.atproto.repo.createRecord after authenticating with an app password or OAuth.
What is the character limit for Bluesky API posts?
The post record allows 300 graphemes and 3,000 bytes of text. Links, mentions, and hashtags count as their visible text, and the API rejects longer posts rather than truncating them.
Should I use app passwords or OAuth for Bluesky?
App passwords are the quick path: the user creates one in Settings and your app calls createSession with it. OAuth is what AT Protocol recommends for user-facing software and requires DPoP, PKCE, and a hosted client metadata document. Most publishing tools start with app passwords.
How many images can a Bluesky post have and how large?
Up to 4 images per post. The lexicon has historically capped each image blob at 1,000,000 bytes and clients still enforce 1 MB, so treat 1 MB as the safe ceiling and compress before uploading.
Why do my Bluesky links and mentions render as plain text?
Bluesky does not parse text. Links, mentions, and hashtags must be sent as facets with byteStart and byteEnd measured in UTF-8 bytes, not string indices. Emoji and accented characters shift the offsets.
What are the Bluesky API rate limits?
Repository writes are scored in points: 3 to create, 2 to update, 1 to delete, capped at 5,000 points per hour and 35,000 per day per account. Requests are also limited to 3,000 per 5 minutes per IP, and createSession to 30 per 5 minutes and 300 per day per account.



