Bluesky API: how to post with the AT Protocol in 2026
Post to Bluesky from code: app passwords or OAuth, createSession and createRecord, facets in UTF-8 bytes, the 300-grapheme and 2 MB limits, and rate limits.
10 minute read By SocialHelper team
The Bluesky API lets you post from code in two calls. It’s free, and you don’t need an API key or an app review. You sign in with com.atproto.server.createSession, using your handle and an app password. Then you write the post with com.atproto.repo.createRecord. A post is a small JSON record with the text and a timestamp. It can hold up to 300 graphemes and 3,000 bytes of text and up to four images. Links, mentions and hashtags only work if you mark them yourself, using byte offsets.
Every rule below is checked against Bluesky’s developer docs and its lexicons. The worked example is the code SocialHelper uses to post to Bluesky.
What the Bluesky API is
The AT Protocol is the open network Bluesky runs on, often shortened to atproto. The Bluesky API is the set of HTTP endpoints for the Bluesky app on top of it, called XRPC methods. Each method has a name like com.atproto.repo.createRecord, and its schema is a lexicon, a JSON file that says which fields a request or record takes and what limits apply.
Your posts live in your repository on a PDS (personal data server), which is the server that hosts your account. Bluesky’s docs explain that creating posts, following and other writes “are made to the user's PDS instance” (Bluesky: API Hosts and Auth). For accounts Bluesky hosts, you sign in at bsky.social. The session response includes your DID document, which names your actual PDS. For anything that isn’t session related, Bluesky encourages “going directly to the PDS in order to avoid the extra hop” (Bluesky: PDS Entryway).
Bluesky’s developer docs now live at bsky.network, and old docs.bsky.app links redirect there. The lexicons for app.bsky.*, including the post record, live in the bluesky-social/bsky repository on GitHub.
App passwords or OAuth
Bluesky has two ways to sign in, and its docs say which to use. “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” (Bluesky: OAuth).
| App password | OAuth | |
|---|---|---|
| Best for | Bots, scripts, CLI tools, your own accounts | Apps where other people log in |
| How you sign in | createSession with a handle and the app password | A browser redirect to the account’s server, then a token exchange |
| Registration | None | None. Your client_id is the URL of a public metadata file |
| Extra work | Store the password and refresh the session | PKCE, PAR and DPoP, usually through an SDK |
An app password is a second password you create for one app and can revoke at any time. The AT Protocol spec says app passwords “grant slightly restricted permissions to the client application, preventing destructive actions”, such as changes to the account or its passwords. They “usually have the form xxxx-xxxx-xxxx-xxxx” (AT Protocol: HTTP API (XRPC)). In the Bluesky app, create one under Settings, then Privacy and security, then App passwords. Leave direct message access off unless your tool needs it.
OAuth in atproto has no registration step and no client secret. The OAuth spec says “PKCE and PAR are required for all client types” and DPoP is required too. Its transition:generic scope is “equivalent to the previous "App Password" authorization level” (AT Protocol: OAuth). Bluesky’s docs suggest an SDK for this part.
SocialHelper connects Bluesky with a handle and an app password. If someone pastes their main account password, it refuses the connection and asks for an app password instead.
Post to Bluesky in two calls
First, create a session. Send your handle (or email) and the app password to the sign-in endpoint.
curl -X POST https://bsky.social/xrpc/com.atproto.server.createSession \
-H "Content-Type: application/json" \
-d '{"identifier": "you.bsky.social", "password": "xxxx-xxxx-xxxx-xxxx"}'
That returns a session. It holds your did, an accessJwt that “expires after a few minutes” and a refreshJwt that “lasts longer and is used only to update the session with a new access token” (Bluesky: Build on Bluesky). Use the PDS address from the didDoc as $PDS below.
Next, write the post record. It needs two fields, text and createdAt. Set createdAt to the current time in UTC, because the timestamp must include a time zone.
curl -X POST "$PDS/xrpc/com.atproto.repo.createRecord" \
-H "Authorization: Bearer $ACCESS_JWT" \
-H "Content-Type: application/json" \
-d '{"repo": "'"$DID"'", "collection": "app.bsky.feed.post",
"record": {"$type": "app.bsky.feed.post",
"text": "Hello from the API",
"createdAt": "2026-11-09T14:00:00.000Z"}}'
It returns two values. The uri has the form at://did/app.bsky.feed.post/rkey, and the cid is a hash of the record. The post’s web address is https://bsky.app/profile/your-handle/post/rkey.
Keep the session and refresh it with com.atproto.server.refreshSession rather than signing in for every post. Sign-ins are rate limited to 30 per 5 minutes and 300 a day for each account. SocialHelper refreshes the session before a post when the access token has less than 5 minutes left, and in an hourly background job. If the refresh token has stopped working, it signs in again with the stored app password. If that fails too, it flags the account for reconnecting.
Links, mentions and hashtags need facets
“Bluesky does not use a markup language,” its rich text guide says. Markdown stays literal, and a URL in the text shows as plain text. A facet is an annotation that points at a range of the text and says what it is: a link, a mention or a hashtag (Bluesky: Links, mentions, and rich text).
Facet positions are counted in bytes. The facet lexicon says indices are “counting bytes of the UTF-8 encoded text”, with an inclusive start and an exclusive end (lexicon: app.bsky.richtext.facet). JavaScript strings count UTF-16 units and Python strings count code points, so neither gives byte offsets directly. Here is the example post SocialHelper’s tests use.
Cinnamon rolls are back 🍂 Order ahead: https://example.com/order #baking
| Way of counting | Whole post | Where the link starts |
|---|---|---|
| Graphemes (what Bluesky limits) | 72 | 39 |
| UTF-16 units (JavaScript) | 73 | 40 |
| UTF-8 bytes (what facets use) | 75 | 42 |
One emoji moves the link by three bytes. SocialHelper’s code turns this text into a link facet from byte 42 to 67 and a hashtag facet from byte 68 to 75.
"facets": [
{"index": {"byteStart": 42, "byteEnd": 67},
"features": [{"$type": "app.bsky.richtext.facet#link",
"uri": "https://example.com/order"}]},
{"index": {"byteStart": 68, "byteEnd": 75},
"features": [{"$type": "app.bsky.richtext.facet#tag",
"tag": "baking"}]}
]
Mentions need one more step. A mention facet holds the account’s DID, not its handle, so resolve each handle first with com.atproto.identity.resolveHandle. SocialHelper does that and leaves a handle as plain text if it doesn’t resolve. A tag can be up to 64 graphemes, without the #.
Link preview cards are separate. A card is an app.bsky.embed.external embed with a title, a description and an optional thumbnail, and “each client fetches and embeds this card metadata, including blob upload if needed” (Bluesky: Posts in-depth). A post made through the API shows no card unless you build one.
Text and image limits
These come from the lexicons for posts, images and video. Where a guide and a lexicon disagree, the lexicon is what the network checks.
| What | Limit |
|---|---|
| Post text | 300 graphemes and 3,000 UTF-8 bytes |
Languages (langs) | Up to 3 |
| Images per post | Up to 4, each with alt text |
| Image file size | 2,000,000 bytes |
| Hashtag | 64 graphemes |
| Video | One MP4, up to 300 MB |
| Any upload to a Bluesky PDS | 50 MB |
The post lexicon sets maxGraphemes to 300 and maxLength to 3000 (lexicon: app.bsky.feed.post). A grapheme is one character as a reader sees it, even when it’s built from several code points. The family emoji 👨👩👧👦 is one grapheme but 25 bytes, so 120 of them already fill 3,000 bytes. SocialHelper checks both limits before it sends a post.
Upload each image with com.atproto.repo.uploadBlob, then list the returned blobs in an app.bsky.embed.images embed. The images lexicon says an image “May be up to 2 MB, formerly limited to 1 MB” (lexicon: app.bsky.embed.images). The posts guide still says “Individual images are limited to 1,000,000 bytes” in its text, and older tutorials still give 1 MB. Alt text is a required field, though it may be empty. Send aspectRatio if you know it. Bluesky also recommends stripping image metadata such as EXIF before you upload.
Video takes a separate path, through Bluesky’s video service. Bluesky-hosted accounts “need to have verified their account email before uploading video”, and there’s a daily cap on video posts (Bluesky: Uploading Video). SocialHelper posts text with up to four JPEG, PNG or WebP images to Bluesky, and doesn’t post video there yet.
Bluesky API rate limits
Writes are counted in points for each account. “The limit is 5,000 points per hour and 35,000 points per day,” where creating a record costs 3 points, an update 2 and a delete 1. That works out to at most 1,666 new records an hour (Bluesky: Rate Limits).
| Limit | Measured | Value |
|---|---|---|
| All API requests to a PDS | Per IP address | 3,000 per 5 minutes |
| Record writes | Per account | 5,000 points an hour, 35,000 a day |
Sign-ins (createSession) | Per account | 30 per 5 minutes, 300 a day |
| Handle changes | Per account | 10 per 5 minutes, 50 a day |
Going over a limit returns HTTP 429. Read the rate limit headers and wait until the reset time rather than retrying in a loop. Bluesky’s developer guidelines also count “automated or bulk interactions, including any that would cause a notification to a user” as spam (Bluesky: Developer Guidelines). Mass replies and mentions from a bot fall under that.
Errors and duplicate posts
A failed XRPC call returns JSON with an error name and an optional message, such as {"error": "ExpiredToken", "message": "Token has expired"}. SocialHelper sorts them into three groups.
- Refresh and retry:
ExpiredToken. Refresh the session and send the request again. - Reconnect:
InvalidToken,AuthenticationRequired,AccountTakedown,AccountDeactivatedandAccountNotFound. The account needs a new app password or a new sign-in. - Wait: HTTP 429. SocialHelper waits for the time in
Retry-After, or for theRateLimit-Resettimestamp.
If a createRecord call times out or returns a server error, the post may already exist, and sending it again would post it twice. SocialHelper treats that as an unknown outcome. Before it tries again, it lists the account’s 10 latest posts with com.atproto.repo.listRecords. A record with the same text and image count, created after the first attempt, counts as the post, so it isn’t sent again. The guide to never posting twice explains the pattern.
The shortcut: one request through SocialHelper
If you’d rather not handle sessions, facets, blob uploads and retries yourself, SocialHelper does it for you. Connect the Bluesky account once with an app password, following the steps for connecting an account. Then send one request.
curl https://socialhelper.app/api/v1/posts \
-H "Authorization: Bearer $SOCIALHELPER_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"text": "Cinnamon rolls are back 🍂 Order ahead: https://example.com/order #baking",
"accounts": [
"BLUESKY_ACCOUNT_ID"
]
}'
SocialHelper does the rest. It counts the text, adds the link and hashtag facets, and writes the record to the account’s own server. The same request can post to other connected accounts at once or set a time with scheduled_at. The Bluesky integration page lists what it supports. AI assistants can do the same through MCP, and the MCP vs API guide shows how the two compare. The social media APIs guide compares Bluesky’s API with the other platforms’.
Frequently asked questions
Is the Bluesky API free?
Yes. Bluesky doesn’t charge for the API and doesn’t require an application or a review. Its developer site describes its endpoints as “Free and hostable” (Bluesky Protocol Services). Requests are subject to the rate limits above.
Is there a Bluesky API key?
No. You sign in with a handle and an app password, or with OAuth, and get short-lived access tokens. Create the app password in the Bluesky app under Settings, then Privacy and security, then App passwords.
Is Bluesky open source?
Yes. The Bluesky app is published on GitHub under the MIT license, and the AT Protocol code is under MIT or Apache 2.0, at your choice.
What is the Bluesky character limit?
A post can have 300 graphemes and at most 3,000 bytes of text. The character limits guide compares it with other platforms.
Can I use the Bluesky API from Python?
Yes. Bluesky’s “Build on Bluesky” page installs the atproto package for Python with pip install atproto. You can also send the raw HTTP requests shown above.
Can a bot post to Bluesky?
Yes, and an app password is the method Bluesky’s docs suggest for bots. Stay within the rate limits, and don’t automate replies, mentions or follows, which the developer guidelines treat as spam.
How we checked this
We read Bluesky’s developer docs at bsky.network, the AT Protocol specs and the current lexicons on GitHub on October 17, 2026, and ran a fact check of every limit and quote against them. We tested the example post with SocialHelper’s own code on October 17, 2026, and the counts and byte offsets above come from that test. We analyzed SocialHelper’s Bluesky integration on the same day for the behaviour described here.
If you spot something that has changed, contact us and we’ll correct it.