How the Bluesky API is different
Bluesky runs on the AT Protocol (atproto), an open network, and that changes the basics compared with X or Instagram:
- No developer program. There is no app to register for bots and scripts, no review and no paid tier. Any account can use the API.
- Posts are records in a repository. Each account has a repo on its PDS (personal data server). Posting means writing a record of type
app.bsky.feed.postto that repo. Likes, follows and reposts are records too. - Endpoints are XRPC methods named like
com.atproto.repo.createRecord, called athttps://<pds-host>/xrpc/<method>. For accounts hosted by Bluesky,https://bsky.socialworks as the entry point. - Reading is open.
https://public.api.bsky.appserves public data without authentication and is cached; Bluesky asks developers to use it for public web use cases.
One practical note: Bluesky’s developer documentation moved from docs.bsky.app to bsky.network/docs. Old links redirect, but some tutorials still quote the old paths.
App passwords or OAuth?
| App password | OAuth | |
|---|---|---|
| Use it for | Bots, scripts, command-line tools acting as your own account | Apps where other people sign in with their Bluesky account |
| How you log in | com.atproto.server.createSession with handle and app password | atproto OAuth: PAR, PKCE and DPoP-bound tokens |
| Setup | Create an app password in the Bluesky app settings | Publish client metadata; an SDK is strongly recommended |
| Effort | Minutes | A real integration project |
Sources: OAuth and get started. Read 29 September 2026.
Bluesky’s wording: “Applications with their own end-user login flow should implement OAuth. Single-purpose applications such as bots or command-line tools may use app password authentication instead.” In both cases, never use the account’s main password.
How to post to Bluesky with the API
1. Create a session
curl -X POST https://bsky.social/xrpc/com.atproto.server.createSession \
-H "Content-Type: application/json" \
-d '{"identifier": "yourname.bsky.social", "password": "xxxx-xxxx-xxxx-xxxx"}'
# -> {"did": "did:plc:abc...", "accessJwt": "...", "refreshJwt": "...", ...}
# When the access token expires, refresh instead of logging in again
curl -X POST https://bsky.social/xrpc/com.atproto.server.refreshSession \
-H "Authorization: Bearer REFRESH_JWT" accessJwt authenticates requests and expires after a few minutes; refreshJwt lasts longer and is only used to get a new access token. Store the session and refresh it. Logging in again for every post is the fastest way to hit the createSession limit of 30 per 5 minutes and 300 per day.
2. Create the post record
curl -X POST https://bsky.social/xrpc/com.atproto.repo.createRecord \
-H "Authorization: Bearer ACCESS_JWT" \
-H "Content-Type: application/json" \
-d '{
"repo": "did:plc:abc...",
"collection": "app.bsky.feed.post",
"record": {
"$type": "app.bsky.feed.post",
"text": "Hello from the Bluesky API.",
"createdAt": "2026-09-29T10:00:00Z",
"langs": ["en"]
}
}'
# -> {"uri": "at://did:plc:abc.../app.bsky.feed.post/3lxyz...", "cid": "bafyrei..."} text and createdAt are required. langs takes up to three language codes, and tags up to eight extra hashtags that are not in the text. The response gives the post’s at:// URI and its CID; keep both, because replies and quotes reference a post by that pair (creating a post).
The 300-grapheme limit
The post lexicon caps text at 300 graphemes and 3,000 bytes. A grapheme is what a reader sees as one character: the family emoji 👨👩👧👧 is 25 UTF-16 code units but one grapheme. Count graphemes, not len(), before you send, and split longer text into a reply thread.
Facets: links, mentions and hashtags
Bluesky does not use markup and does not detect URLs in the text for you. Anything interactive is a facet: a byte range in the text plus a feature. Three feature types exist: #link (with a uri), #mention (with the account’s did) and #tag (with the tag). A link over the words “this site”:
{
"text": "Go to this site",
"facets": [{
"index": {"byteStart": 6, "byteEnd": 15},
"features": [{"$type": "app.bsky.richtext.facet#link", "uri": "https://example.com"}]
}]
}The indexes are UTF-8 byte offsets, start inclusive and end exclusive. That is the part everyone gets wrong: JavaScript string indexes are UTF-16, Python indexes are code points, and both drift as soon as the text contains an emoji or an accented letter. Bluesky warns that incorrect indexing “will produce bad data” (rich text guide). The safe way in Python:
import re
def link_facets(text):
"""Facets for every https:// URL, indexed in UTF-8 bytes."""
facets = []
for m in re.finditer(r"https?://\S+", text):
start = len(text[:m.start()].encode("utf-8"))
end = len(text[:m.end()].encode("utf-8"))
facets.append({
"index": {"byteStart": start, "byteEnd": end},
"features": [{"$type": "app.bsky.richtext.facet#link", "uri": m.group(0)}],
})
return facets
# "✨ " is 4 bytes, so the URL starts at byte 4, not character 2
print(link_facets("✨ https://example.com"))
For a mention, resolve the handle to a DID first (for example with com.atproto.identity.resolveHandle) and put the DID in the facet. Facets must not overlap.
Images, link cards and replies
Images
# 1. Upload the image bytes as a blob (max 2 MB for a post image)
curl -X POST https://bsky.social/xrpc/com.atproto.repo.uploadBlob \
-H "Authorization: Bearer ACCESS_JWT" \
-H "Content-Type: image/jpeg" \
--data-binary @photo.jpg
# -> {"blob": {"$type": "blob", "ref": {"$link": "bafkrei..."}, "mimeType": "image/jpeg", "size": 183422}}
# 2. Reference it in the post record
"embed": {
"$type": "app.bsky.embed.images",
"images": [{
"alt": "Team photo at the offsite",
"image": { ...the blob object from step 1... },
"aspectRatio": {"width": 1600, "height": 1200}
}]
}
A post takes up to four images, each up to 2 MB, each with its own alt text. Send aspectRatio so clients can lay out the post before the image loads. The newer gallery embed (app.bsky.embed.gallery) allows more media items; its lexicon sets a ceiling of 20 and asks clients to stay at 10 for now.
Link preview cards
A pasted URL does not produce a card on its own. The client fetches the page’s title, description and image, uploads the image as a blob, and embeds it all in the record. That way the card looks the same for everyone and the linked site is not hit by every reader’s app:
"embed": {
"$type": "app.bsky.embed.external",
"external": {
"uri": "https://example.com/changelog",
"title": "Release notes v2.4",
"description": "Dark mode, faster sync and 14 fixes.",
"thumb": { ...blob from uploadBlob... }
}
}Replies and threads
A reply references both the post it answers (parent) and the first post of the thread (root), each by URI and CID:
"reply": {
"root": {"uri": "at://did:plc:abc.../app.bsky.feed.post/FIRST", "cid": "bafyrei...first"},
"parent": {"uri": "at://did:plc:abc.../app.bsky.feed.post/PREVIOUS", "cid": "bafyrei...prev"}
}For a thread you write yourself, post 1 is the root of every reply, and each reply’s parent is the post before it.
Uploading video
Video has its own rules (video guide). Bluesky-hosted accounts must have a verified email before they can upload video; check it with com.atproto.server.getSession. There are two ways to attach a video:
- Simple: upload it with
uploadBloblike an image and embed it asapp.bsky.embed.video. Processing only starts once the post exists, so for several seconds people see a post with a missing video. - Recommended: get a service token with
com.atproto.server.getServiceAuth(audiencedid:web:<your PDS host>, methodcom.atproto.repo.uploadBlob, about 30 minutes of validity), upload the file tohttps://video.bsky.app/xrpc/app.bsky.video.uploadVideo, pollapp.bsky.video.getJobStatusuntil it returns a blob, then create the post. If the same file was processed before, the job reportsalready_existsand still returns the blob.
Bluesky raised the limits to 10 minutes and 300 MB per video in August 2026 (announcement). The daily cap is 25 videos or 10 GB per account (Bluesky blog). The video embed also accepts up to 20 WebVTT caption files.
Bluesky API in Python
The atproto package that Bluesky’s own Python examples use wraps sessions, facets, uploads and replies:
from atproto import Client, client_utils
client = Client() # defaults to https://bsky.social
client.login("yourname.bsky.social", "xxxx-xxxx-xxxx-xxxx") # app password
# Rich text: TextBuilder computes the byte offsets for you
tb = client_utils.TextBuilder()
tb.text("New release: ")
tb.link("changelog", "https://example.com/changelog")
tb.text(" ")
tb.tag("#opensource", "opensource")
post = client.send_post(tb)
# A two-post thread: reply with the first post as root and parent
from atproto import models
ref = models.create_strong_ref(post)
client.send_post(
"Thanks to everyone who filed issues this cycle.",
reply_to=models.AppBskyFeedPost.ReplyRef(parent=ref, root=ref),
)
# Images: up to 4 per post, 2 MB each
with open("screenshot.png", "rb") as f:
client.send_image(text="The new settings page", image=f.read(),
image_alt="Settings page in dark mode")
Save the session string (client.export_session_string()) and restore it on the next run instead of calling login each time, for the createSession limit mentioned above.
Bluesky API limits
| Limit | Value |
|---|---|
| Record writes per account | 5,000 points per hour, 35,000 per day; create = 3, update = 2, delete = 1 (at most 1,666 new records an hour, 11,666 a day) |
| All API requests | 3,000 per 5 minutes per IP |
createSession | 30 per 5 minutes and 300 per day per account |
| Post text | 300 graphemes, 3,000 bytes |
| Languages / extra tags | 3 / 8 per post |
| Images | 4 per post, 2 MB each |
| Video | MP4, up to 300 MB and 10 minutes; 25 videos or 10 GB per day; verified email |
| Any blob on a Bluesky PDS | 52,428,800 bytes (50 MB) per upload |
Sources: rate limits, app.bsky lexicons, video guide, video announcement. Read 29 September 2026.
These are the limits on Bluesky’s own servers. Other PDS hosts on the network can set their own. Over a limit you get HTTP 429, and many responses carry rate-limit headers you can use for backoff.
Common Bluesky API errors
| Symptom | Cause | Fix |
|---|---|---|
| 429 on createSession | Logging in for every request | Reuse the session; refresh with refreshJwt |
| 429 on writes | Hourly or daily write points used up | Back off; batch fewer creates |
| Record rejected | Text over 300 graphemes, more than 4 images, or a field that fails the lexicon | Validate locally; split long text into a thread |
| Link not clickable | No facet sent | Add a #link facet |
| Link highlights the wrong words | Facet offsets counted in characters instead of UTF-8 bytes | Compute offsets on the encoded bytes |
| Blob too large | Image over 2 MB, or any upload over 50 MB via uploadBlob | Compress images; send video through video.bsky.app |
| Video upload refused | Email not verified, or daily video cap reached | Verify the email; wait for the next day |
| Post shows a missing video | Simple upload method; processing starts after posting | Use video.bsky.app and wait for the job |
The shortcut: Bluesky in the same call as everything else
Posting one text to Bluesky is easy. Doing it well is facets, grapheme counting, thread splitting, link cards, blob uploads, the video job and session reuse, and then doing the same for every other network. Upload-Post’s Bluesky API handles all of that behind one request:
# A video to Bluesky, TikTok and YouTube Shorts, with reply controls
curl -X POST https://api.upload-post.com/api/upload \
-H "Authorization: Apikey YOUR_KEY" \
-F "user=my-brand" \
-F "platform[]=bluesky" \
-F "platform[]=tiktok" \
-F "platform[]=youtube" \
-F "[email protected]" \
-F "title=60-second tour of the new editor" \
-F "bluesky_alt_text=Screen recording of the editor" \
-F "bluesky_langs=en" \
-F "bluesky_threadgate=following,mention" \
-F "first_comment=Docs: https://example.com/docs" \
-F "add_to_queue=true"That queues the video for Bluesky, TikTok and YouTube, restricts Bluesky replies to people you follow and people you mention, and posts the docs link as a reply. Other options in the docs:
- Automatic threads: text over 300 characters is split into a Bluesky thread, grouping paragraphs and then breaking at line breaks or words.
- Link cards:
bluesky_link_urlbuilds the external embed with title, description and thumbnail. - Images: up to 4 per post with
bluesky_alt_text, orbluesky_gallery=truefor up to 20 as a gallery. - Post controls:
bluesky_threadgate(who can reply),bluesky_postgate(block quotes),bluesky_quote_uri,bluesky_langsand self-labels withbluesky_labels. - Scheduling:
scheduled_datewith atimezone, up to 365 days ahead, oradd_to_queue=true. - Comments and analytics: list, reply to and delete replies by
at://URI, and read profile engagement from the analytics endpoint.
The Bluesky MCP server gives Claude, ChatGPT and Cursor the same publishing tools.
The Free plan is free forever with no card: 10 uploads a month and 2 profiles, with Bluesky and API access included. Paid plans with unlimited uploads start at $24 a month ($16 billed annually). Posting to X as well? See the X API pricing breakdown, the Threads API guide, or compare options in the social media API overview.
Frequently asked questions
Is the Bluesky API free?
Yes. There is no developer account, no app review and no fee. Any Bluesky account can call the API with its own credentials. The limits are rate limits on the account and IP, not a price.
Do I need an API key for Bluesky?
No. You authenticate as an account. For a bot or script, create an app password in the Bluesky app settings and log in with com.atproto.server.createSession. For an app where other people sign in, Bluesky’s docs say to use OAuth instead.
App password or OAuth: which should I use?
Bluesky’s guidance is simple: applications with their own end-user login flow should implement OAuth; single-purpose applications such as bots or command-line tools may use app passwords. Never use the account’s main password.
What is the Bluesky post character limit?
300 graphemes, which is what people perceive as characters, so a family emoji built from several code points counts as one. The text field is also capped at 3,000 bytes. Longer text has to be split into a reply thread; Upload-Post does that split for you.
Why are my links not clickable when I post through the API?
Bluesky does not parse URLs out of the text. A link, mention or hashtag only becomes interactive if you send a facet that marks its position in UTF-8 bytes. Libraries such as the Python atproto package and @bsky/sdk build facets for you.
How many images can a Bluesky post have?
Up to four with the standard images embed, each up to 2 MB, with alt text and an aspect ratio. A newer gallery embed allows more items, with clients advised to keep it to 10 for now.
Can I upload video with the Bluesky API?
Yes. Bluesky-hosted accounts need a verified email first. Videos can be up to 10 minutes and 300 MB since August 2026, with a daily cap of 25 videos or 10 GB. The recommended flow uploads to video.bsky.app, waits for processing, then creates the post.
What are the Bluesky API rate limits?
Each account gets 5,000 write points per hour and 35,000 per day, where creating a record costs 3 points, updating 2 and deleting 1: at most 1,666 new records an hour. createSession is limited to 30 per 5 minutes and 300 per day per account, and all requests to 3,000 per 5 minutes per IP.
How do I use the Bluesky API with Python?
Install the atproto package, call client.login(handle, app_password) and client.send_post(text). It handles sessions, facets through TextBuilder, and image uploads. The raw HTTP version with requests is on this page if you prefer no dependency.
Can I schedule Bluesky posts with the API?
Not natively. createRecord publishes immediately; createdAt is only a timestamp the client declares. You need a scheduler of your own, or a service such as Upload-Post, which takes a scheduled_date up to 365 days ahead.