Developer guide

Bluesky API Posting, facets, media and rate limits on the AT Protocol

Checked against Bluesky’s developer documentation and lexicons in September 2026

Short answer: the Bluesky API is the open AT Protocol. No developer account, no review, no fee. Log in with an app password (bots) or OAuth (apps), then write a app.bsky.feed.post record with com.atproto.repo.createRecord. Text is capped at 300 graphemes, and links only become clickable if you send facets.

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.post to that repo. Likes, follows and reposts are records too.
  • Endpoints are XRPC methods named like com.atproto.repo.createRecord, called at https://<pds-host>/xrpc/<method>. For accounts hosted by Bluesky, https://bsky.social works as the entry point.
  • Reading is open. https://public.api.bsky.app serves 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 passwordOAuth
Use it forBots, scripts, command-line tools acting as your own accountApps where other people sign in with their Bluesky account
How you log incom.atproto.server.createSession with handle and app passwordatproto OAuth: PAR, PKCE and DPoP-bound tokens
SetupCreate an app password in the Bluesky app settingsPublish client metadata; an SDK is strongly recommended
EffortMinutesA 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 uploadBlob like an image and embed it as app.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 (audience did:web:<your PDS host>, method com.atproto.repo.uploadBlob, about 30 minutes of validity), upload the file to https://video.bsky.app/xrpc/app.bsky.video.uploadVideo, poll app.bsky.video.getJobStatus until it returns a blob, then create the post. If the same file was processed before, the job reports already_exists and 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

LimitValue
Record writes per account5,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 requests3,000 per 5 minutes per IP
createSession30 per 5 minutes and 300 per day per account
Post text300 graphemes, 3,000 bytes
Languages / extra tags3 / 8 per post
Images4 per post, 2 MB each
VideoMP4, up to 300 MB and 10 minutes; 25 videos or 10 GB per day; verified email
Any blob on a Bluesky PDS52,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

SymptomCauseFix
429 on createSessionLogging in for every requestReuse the session; refresh with refreshJwt
429 on writesHourly or daily write points used upBack off; batch fewer creates
Record rejectedText over 300 graphemes, more than 4 images, or a field that fails the lexiconValidate locally; split long text into a thread
Link not clickableNo facet sentAdd a #link facet
Link highlights the wrong wordsFacet offsets counted in characters instead of UTF-8 bytesCompute offsets on the encoded bytes
Blob too largeImage over 2 MB, or any upload over 50 MB via uploadBlobCompress images; send video through video.bsky.app
Video upload refusedEmail not verified, or daily video cap reachedVerify the email; wait for the next day
Post shows a missing videoSimple upload method; processing starts after postingUse 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_url builds the external embed with title, description and thumbnail.
  • Images: up to 4 per post with bluesky_alt_text, or bluesky_gallery=true for up to 20 as a gallery.
  • Post controls: bluesky_threadgate (who can reply), bluesky_postgate (block quotes), bluesky_quote_uri, bluesky_langs and self-labels with bluesky_labels.
  • Scheduling: scheduled_date with a timezone, up to 365 days ahead, or add_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.

Skip the facets and the byte offsets

Schedule Bluesky posts, threads, images and video, and send the same content to X, Threads and LinkedIn in the same request.

Free forever plan. No credit card. 10 uploads a month.