updated 28 October 2026

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):

HostUse it for
bsky.socialLogging in and authenticated calls for accounts Bluesky hosts. It forwards requests to the account's own PDS.
public.api.bsky.appUnauthenticated, cached reads of public profiles, posts, and feeds
The account's PDSAuthenticated 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).

LimitValue
Post text300 graphemes and 3,000 UTF-8 bytes (post lexicon)
Images per post4, up to 2 MB each
Record writes per account5,000 points an hour, 35,000 a day
All API requests to a PDS3,000 per 5 minutes per IP
createSession30 per 5 minutes, 300 per day, per account
Any single blob upload50 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 accessJwt is short-lived. Call com.atproto.server.refreshSession with the refreshJwt to 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 createdAt is 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: