The Bluesky API, explained
The Bluesky API is the set of AT Protocol endpoints that Bluesky's servers expose. It needs no developer account or API key: to post, you log in as the account and create an app.bsky.feed.post record in its repository. A post holds up to 300 characters and four images, and an account can create about 1,666 records an hour.
What the Bluesky API is
Bluesky runs on the AT Protocol. Every account has a repository of records on a personal data server (PDS), and a post is one of those records. Writing to the API means writing records to the account's repository; reading means querying Bluesky's app view. There is no separate developer program, app registration, or API key for posting. The requests you make are the same ones the Bluesky app makes.
Three hosts matter for most apps (rate limits):
| Host | Use it for |
|---|---|
bsky.social | Logging in and authenticated calls for accounts Bluesky hosts. It forwards requests to the account's own PDS. |
public.api.bsky.app | Unauthenticated, cached reads of public profiles, posts, and feeds |
| The account's PDS | Authenticated calls without the extra hop; its address is in the DID document returned at login |
Bluesky calls bsky.social the Entryway. It handles sessions for every
account Bluesky hosts and forwards most other requests to the right PDS
(PDS Entryway). Accounts on a self-hosted PDS log in
to that server instead.
App passwords or OAuth
Bluesky supports two ways to act as an account. Its docs say apps with their own end-user login should implement OAuth, and that single-purpose apps such as bots and command-line tools may use app passwords instead (OAuth).
-
App password. The account owner creates one in the Bluesky app's
settings. Your code exchanges the handle and app password for a session with
com.atproto.server.createSession. It suits a bot or a script that posts to accounts you own. - OAuth. The user signs in on their own server and approves your app. AT Protocol OAuth requires PKCE, pushed authorization requests, and DPoP-bound tokens, and your client ID is the URL of a metadata document you host. Use it when other people connect their accounts to your product.
curl -X POST "https://bsky.social/xrpc/com.atproto.server.createSession" \
-H "Content-Type: application/json" \
-d '{"identifier": "alice.bsky.social", "password": "<APP_PASSWORD>"}'
# {"did": "did:plc:...", "handle": "alice.bsky.social", "accessJwt": "...", "refreshJwt": "..."}
The session has a short-lived accessJwt for API calls and a
refreshJwt. When the access token expires, call
com.atproto.server.refreshSession with the refresh token as the bearer
token; it returns a new pair and fails with ExpiredToken or
InvalidToken when the refresh token is no longer good
(refreshSession lexicon).
Refresh rather than logging in again: createSession is limited to 30
calls per 5 minutes and 300 per day per account.
Creating a post
A post is a record of type app.bsky.feed.post with two required fields,
text and createdAt. Create it with
com.atproto.repo.createRecord, passing the account's DID as the
repo (creating a post):
curl -X POST "https://bsky.social/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 Bluesky API",
"createdAt": "2026-10-28T12:00:00.000Z"
}
}'
# {"uri": "at://did:plc:.../app.bsky.feed.post/3k...", "cid": "bafyrei..."}
The response holds the post's at:// URI and its CID, a hash of the
record. Keep both: replies, quote posts, likes, and reposts point to a post by that
pair. Optional fields include langs (up to three language tags),
reply (the parent and the thread's root), and embed for
images, video, link cards, or a quoted post.
Links, mentions, and hashtags are facets
Bluesky doesn't detect links in post text. A URL you send as plain text shows up as plain text. Links, mentions, and hashtags are facets: annotations that point at a byte range of the text and say what it is (links, mentions, and rich text).
{
"$type": "app.bsky.feed.post",
"text": "Docs at https://social-sdk.dev",
"createdAt": "2026-10-28T12:00:00.000Z",
"facets": [
{
"index": { "byteStart": 8, "byteEnd": 30 },
"features": [{ "$type": "app.bsky.richtext.facet#link", "uri": "https://social-sdk.dev" }]
}
]
}
The offsets count UTF-8 bytes, not JavaScript string positions. In TypeScript,
text.indexOf() and .slice() give UTF-16 positions, which
go wrong as soon as the text contains an emoji or an accented letter. Encode with
TextEncoder first, or let a library build the facets. A mention facet
needs the mentioned account's DID, so resolve the handle before posting. Facets
can't overlap.
Images and video
Media is uploaded first and then referenced. Upload each image with
com.atproto.repo.uploadBlob, then put the returned blob in an
app.bsky.embed.images embed with its alt text. A post holds up to four
images, and each image may be up to 2 MB
(images lexicon; the
limit was 1 MB before). Bluesky recommends stripping image metadata before upload.
Video is a different service. Bluesky recommends uploading the MP4 to
video.bsky.app with a service token, checking the processing job until
it returns a blob, and only then creating the post. Accounts Bluesky hosts must have
a verified email to upload video, and there is a daily limit on video posts
(uploading video).
Rate limits
Bluesky's PDS hosts limit writes per account by points: creating a record costs 3, updating 2, and deleting 1, against 5,000 points an hour and 35,000 a day. That works out to about 1,666 new records an hour or 11,666 a day, and likes, follows, and reposts are records too. Requests over a limit get HTTP 429 (rate limits).
| Limit | Value |
|---|---|
| Post text | 300 graphemes and 3,000 UTF-8 bytes (post lexicon) |
| Images per post | 4, up to 2 MB each |
| Record writes per account | 5,000 points an hour, 35,000 a day |
| All API requests to a PDS | 3,000 per 5 minutes per IP |
createSession | 30 per 5 minutes, 300 per day, per account |
| Any single blob upload | 50 MB at the PDS |
Bluesky says these limits will change over time, and other PDS hosts set their own. Its developer guidelines also forbid automated bulk interactions, such as mass follows, likes, or replies, whatever the rate limit allows.
Posting to Bluesky from TypeScript with Social SDK
Social SDK is an open-source TypeScript library with a Bluesky
adapter. One posts.publish call checks the 300-grapheme and 3,000-byte
limits, adds link facets for URLs in the text with correct UTF-8 offsets, uploads
attached images, and creates the record. You get back a typed outcome with the
post's URI and CID. If the network fails after the request went out, the outcome is
unknown and the adapter doesn't retry it, so a flaky connection can't
post twice.
npm install @opencoredev/social-sdk
import { createSocial, connectedAccountRef } from "@opencoredev/social-sdk";
import { bluesky } from "@opencoredev/social-sdk/bluesky";
const identifier = process.env["BLUESKY_HANDLE"];
const password = process.env["BLUESKY_APP_PASSWORD"];
if (!identifier || !password) throw new Error("Set BLUESKY_HANDLE and BLUESKY_APP_PASSWORD");
// Exchange the app password for a session once, then reuse its access token.
const login = await fetch("https://bsky.social/xrpc/com.atproto.server.createSession", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ identifier, password }),
});
if (!login.ok) throw new Error(`Bluesky login failed with HTTP ${login.status}`);
const session: unknown = await login.json();
if (
typeof session !== "object" || session === null ||
!("did" in session) || typeof session.did !== "string" ||
!("accessJwt" in session) || typeof session.accessJwt !== "string"
) throw new Error("Unexpected createSession response");
const social = createSocial({
backend: bluesky({
auth: { service: "https://bsky.social", did: session.did, accessJwt: session.accessJwt },
}),
});
const account = connectedAccountRef({ backend: "default", platform: "bluesky", accountId: session.did });
const result = await social.posts.publish({
targets: [{ account }],
content: { text: "Posted to Bluesky from TypeScript https://social-sdk.dev" },
});
for (const outcome of result.outcomes) {
if (outcome.state === "published") console.log("published", outcome.post.native);
else console.log(outcome.state, outcome);
}
We type-checked this example against @opencoredev/social-sdk 0.5.0 from
npm and ran it on 28 October 2026 with a stub fetch standing in for
bsky.social. It made two calls, createSession and
createRecord, sent a link facet covering the URL, and printed the new
post's URI and CID. It runs on Node.js 22.12 or later and Bun. In a long-running
service, store the session and refresh it instead of logging in on every run.
For accounts your users connect, the SDK's blueskyOAuth provider
implements AT Protocol OAuth with PKCE, PAR, and DPoP. Mentions with DIDs, language
tags, video uploads, likes, reposts, and search are on the
Bluesky platform page. The same client can post
to Bluesky, X, and Threads in one call; the
platforms overview shows how. To try it without an
account, start with the mock quickstart.
Common questions
- Do I need an API key or developer account for Bluesky?
- No. You authenticate as a Bluesky account, and the rate limits above apply. Bots and scripts log in with the account's handle and an app password. Apps where other people sign in use AT Protocol OAuth, where your client ID is the URL of your client metadata document.
- Why isn't my link clickable?
- Bluesky only links text that has a link facet. Add a facet with the URL's UTF-8 byte range, or use a library that adds it for you.
- What does "Token has expired" mean?
-
The
accessJwtis short-lived. Callcom.atproto.server.refreshSessionwith therefreshJwtto get a new one, and only log in again if the refresh token has expired too. - Can I schedule Bluesky posts through the API?
-
Not natively. The record's
createdAtis a timestamp, not a schedule, and the post appears when you create it. Run your own job at the chosen time.
Sources
Read on 28 October 2026:
- Creating a post: record fields, createRecord, languages, replies, quote posts, image embeds
- Links, mentions, and rich text: facets and UTF-8 byte offsets
- Uploading video: the video service, email verification, daily limits
- Rate limits: write points, PDS request limits, createSession, blob size, public hosts
- OAuth and PDS Entryway: when to use app passwords, bsky.social
- Developer guidelines: spam and automation rules
- AT Protocol lexicons on GitHub: app.bsky.feed.post, app.bsky.embed.images, com.atproto.server.refreshSession