API Guides

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.
September 08, 2026 by Jesse Eisenbart11 min read
Oil painting of a wide blue sky over a high meadow with small ponds reflecting the light and low peaks in the distance

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.app answers 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.

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#link with a uri
  • app.bsky.richtext.facet#mention with a did (resolve the handle first)
  • app.bsky.richtext.facet#tag with a tag

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.

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.