Bluesky API for Posting in 2026: Auth, Limits and Code
Bluesky API guide for posting: app passwords vs OAuth, the createRecord call, facets, image and video limits, rate limits and how to schedule posts.
The Bluesky API is the AT Protocol, and posting needs no API key, no developer approval and no paid plan. You open a session with a handle and an app password (or OAuth if your app has its own user login), then call com.atproto.repo.createRecord with an app.bsky.feed.post record. Posts allow 300 graphemes, four images or one MP4 video.
The Bluesky API is the fastest network to post to from code. There is no developer portal, no app review and no key to request: an account with an app password can publish its first post with two HTTP calls. What takes time is everything after that first post. Links stay plain text until you compute byte offsets, the session endpoint has its own daily cap, and videos go through a separate service with per-account limits.
This guide covers the publishing side of the Bluesky API as of October 2026: how authentication works, the exact calls to create a post, what a post record can hold, the published rate limits and how to schedule posts when the API has no schedule field. Bluesky is one of the twelve networks in our guide to every social platform with a posting API, and one of only three with no approval gate at all.
PostGate handles OAuth, publishing and delivery evidence for every network through one API. Start free or read the docs.
Is the Bluesky API free, and do you need an API key?
There is nothing to buy and no key to request. The official Bluesky API quick start goes straight from installing a library to logging in with a handle and password: no developer account, no app registration and no plan to pick. Compare that with X, where posting is billed by usage, or LinkedIn, where Company Page access needs an application and a review (see our breakdown of LinkedIn API pricing and access tiers).
What people call a "Bluesky API key" is one of two things. For bots, scripts and command-line tools, it is an app password: a separate password the account owner creates in Bluesky's settings, which you can revoke without touching the main login. For apps where other people sign in, Bluesky asks you to implement OAuth instead. Its OAuth documentation says applications with their own end-user login flow should use OAuth, while single-purpose apps such as bots or CLI tools may keep using app passwords. atproto OAuth involves DPoP, PKCE and PAR, so use one of the SDKs listed there rather than writing the flow by hand.
How do you post to Bluesky with the API?
Posting is two calls. com.atproto.server.createSession takes the handle and the app password and returns the account's DID plus two tokens: accessJwt, which authenticates requests and expires quickly, and refreshJwt, which lasts longer and is only used to get a new access token. Then com.atproto.repo.createRecord writes a record of type app.bsky.feed.post to the account's repository. The only required fields are text and createdAt. The response returns the post's at:// URI and its CID, and that URI is your proof the post exists.
# 1. Log in once with the handle and an app password
curl -X POST https://bsky.social/xrpc/com.atproto.server.createSession \
-H "Content-Type: application/json" \
-d '{"identifier": "yourhandle.bsky.social", "password": "xxxx-xxxx-xxxx-xxxx"}'
# -> { "did": "did:plc:...", "accessJwt": "...", "refreshJwt": "...", ... }
# 2. Write 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:...",
"collection": "app.bsky.feed.post",
"record": {
"$type": "app.bsky.feed.post",
"text": "Hello from the API",
"createdAt": "2026-10-31T13:00:00Z",
"langs": ["en"]
}
}'
# -> { "uri": "at://did:plc:.../app.bsky.feed.post/3k...", "cid": "bafy..." }
# 3. Later: swap the refresh token for a new pair instead of logging in again
curl -X POST https://bsky.social/xrpc/com.atproto.server.refreshSession \
-H "Authorization: Bearer $REFRESH_JWT"Two details from the posts guide save debugging time. Write createdAt as UTC with a trailing Z, which the docs prefer over +00:00. And bsky.social is the entry point for accounts hosted by Bluesky; an account on a self-hosted PDS lives somewhere else, so read the PDS address from the DID document the session returns instead of hardcoding the host. In production you would normally use an SDK (@bsky/sdk for TypeScript, atproto for Python, indigo for Go), which also refreshes the session for you.
What can a Bluesky post contain?
The limits live in the lexicons, the JSON schemas that define each record type. These are the numbers that matter when you validate a post before sending it, taken from the app.bsky.feed.post lexicon and the image and video embed lexicons next to it:
| Field | Limit | What trips people up |
|---|---|---|
| Text | 300 graphemes (maxGraphemes), maxLength 3000 | Graphemes are what a reader sees as one character. An emoji family counts as one, so count with a grapheme splitter, not string.length. |
| Images | Up to 4 per post, 2,000,000 bytes each in the lexicon | The posts guide prose still says 1,000,000 bytes. Resizing to under 1 MB satisfies both. |
| Video | One MP4 (video/mp4), 300,000,000 bytes in the lexicon | Account email must be verified, and there is a daily cap on video posts per account. |
| Languages | Up to 3 values in langs | Custom feeds filter on it, so set it instead of leaving it empty. |
| Link card | app.bsky.embed.external with uri, title, description and an optional thumbnail blob | Bluesky does not unfurl links for you. Your code fetches the Open Graph tags and uploads the thumbnail. |
Media is never inlined in the post. You upload each file with com.atproto.repo.uploadBlob, get back a blob reference, and put that reference in the embed together with alt text and an aspect ratio. The docs recommend stripping EXIF metadata before upload, which today is the client's job. For video, the video upload guide recommends sending the file to video.bsky.app first and polling app.bsky.video.getJobStatus until it returns the blob. If you skip that and upload the video as a plain blob, processing only starts after the post goes out, and readers see a missing video for a few seconds.
Why are links, mentions and hashtags not clickable in API posts?
Because the text field is plain text. A URL, an @mention or a #hashtag only becomes interactive when the record carries a facet pointing at that span of text. The facet lexicon defines three features: #link with a uri, #mention with the account's DID (so you resolve the handle first with com.atproto.identity.resolveHandle), and #tag for hashtags, capped at 64 graphemes.
The common bug is the index. byteStart and byteEnd count bytes of the UTF-8 encoded text, start inclusive and end exclusive. JavaScript string indexes count UTF-16 code units, so any accent or emoji before the link shifts the facet and the wrong characters turn blue. Encode the text with TextEncoder (or text.encode("utf-8") in Python) and search the bytes, not the string. If a handle does not resolve, the posts guide suggests skipping that mention and leaving it as plain text rather than failing the post.
What are the Bluesky API rate limits?
Bluesky publishes its numbers on the rate limits page, and requests over a limit get HTTP 429. Writes are counted in points per account: creating a record costs 3 points, updating 2 and deleting 1.
| Limit | Measured per | Value |
|---|---|---|
| Record writes | Account (DID) | 5,000 points per hour, 35,000 per day (at most 1,666 creates per hour) |
createSession | Account | 30 per 5 minutes, 300 per day |
| All API requests to the PDS | IP address | 3,000 per 5 minutes |
| Single blob upload | File | 50 MB at the PDS, on top of the lexicon limits above |
For a posting tool, the write limit is rarely the problem. The session limit is. A cron job that calls createSession before every post runs out at 300 logins a day for that account, and the error looks like an auth failure. Store the refreshJwt, call com.atproto.server.refreshSession when the access token expires, and save the new pair it returns, since the refresh token rotates. The IP limit matters when many accounts post from one server. Our social media API rate limits reference puts these numbers next to the other networks.
How do you schedule Bluesky posts with the API?
The post record has no schedule field, and createRecord publishes the moment it succeeds. So scheduling on Bluesky means running something that holds a valid session and calls createRecord at the chosen time: a queue with delayed jobs, a retry policy for 429s, and a place to store the returned URI so you can show the post went out. createdAt is the time the record says it was written; it does not delay publication.
If you would rather not run that part, this is where PostGate's Bluesky integration fits. Accounts connect with an app password through a hosted page, sessions are refreshed on a schedule, and links, hashtags and resolvable @mentions in the text are turned into facets automatically. You send one scheduled post to the PostGate API and get per-destination delivery status back. One limit to know before you build on it: PostGate publishes Bluesky posts with text and up to four images today, and does not publish Bluesky video yet.
What should you check before shipping a Bluesky integration?
- Count text in graphemes and reject anything over 300 before calling the API.
- Build facets from UTF-8 byte offsets and test them with accented text and emoji before the link.
- Resize images under 1 MB, strip EXIF, and send alt text and an aspect ratio with every image.
- Log in once per account and refresh the session; never call
createSessionper post. - Read the PDS host from the session instead of assuming
bsky.social. - For video, check the account email is verified and pre-process through
video.bsky.app. - Back off on HTTP 429 and keep the returned
at://URI as the record of each post. - Use OAuth, not app passwords, if people outside your team sign in to your app.
Frequently asked questions
Is the Bluesky API free?
Yes. Bluesky does not sell API access: the official quick start goes from installing a library to posting with only a handle and an app password, with no developer account, app review or paid plan. The limits you deal with are rate limits, not fees.
Does Bluesky have an API key?
No. Bots and scripts authenticate with an app password the account owner creates in Bluesky settings, exchanged for access and refresh tokens through com.atproto.server.createSession. Apps where other people sign in should use atproto OAuth, ideally through an official or community SDK.
What is the character limit for a Bluesky post through the API?
The app.bsky.feed.post lexicon allows 300 graphemes of text, with a separate maxLength of 3000. Graphemes are user-perceived characters, so an emoji sequence counts as one; count them with a grapheme splitter instead of the string length.
Can you post videos to Bluesky with the API?
Yes, one MP4 per post through the app.bsky.embed.video embed. Bluesky recommends uploading to video.bsky.app and polling app.bsky.video.getJobStatus for the blob before creating the post. The account email must be verified and there is a daily cap on video posts per account.
What happens if you hit the Bluesky rate limit?
Requests over a limit return HTTP 429. The per-account write limit is 5,000 points per hour and 35,000 per day, with a create costing 3 points, and createSession is limited to 30 calls per 5 minutes and 300 per day, so reuse and refresh sessions.
