Bluesky API Posting Guide 2026
Bluesky is built on the AT Protocol (Authenticated Transfer Protocol), an open and decentralized protocol for social networking. Unlike the proprietary APIs of LinkedIn, X, or Threads, the AT Protocol is designed to be open by default. This means the API is well-documented, free to use, and does not require application approval or tiered pricing.
This guide covers everything you need to know about posting to Bluesky programmatically in 2026, from authentication through publishing posts with rich text, images, and link cards.
Prerequisites
To use the Bluesky API, you need:
- A Bluesky account (bsky.social or any AT Protocol PDS)
- An app password generated from your Bluesky settings (Settings > App Passwords)
- Node.js (or any HTTP client — the API is standard REST)
That is it. No developer applications, no approval processes, no API keys. Bluesky's API is open by design. The official API server for bsky.social is at https://bsky.social, and the base URL for all API calls is https://bsky.social/xrpc.
Authentication with App Passwords
Bluesky uses app passwords for API authentication. These are separate from your account password and can be revoked individually. Generate one from Settings > App Passwords in the Bluesky app.
Creating a Session
Authentication creates a session that returns an access token (JWT) and a refresh token:
async function createSession(identifier, appPassword) {
const res = await fetch('https://bsky.social/xrpc/com.atproto.server.createSession', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifier: identifier, // Your handle (e.g., 'yourname.bsky.social') or DID
password: appPassword, // The app password, NOT your account password
}),
});
if (!res.ok) {
const error = await res.json();
throw new Error(`Auth failed: ${error.message}`);
}
const session = await res.json();
return {
accessJwt: session.accessJwt, // Short-lived JWT (~2 hours)
refreshJwt: session.refreshJwt, // Long-lived refresh token
did: session.did, // Your DID (decentralized identifier)
handle: session.handle, // Your handle
};
}
const session = await createSession('yourname.bsky.social', 'your-app-password');
Refreshing the Session
Access JWTs expire after about 2 hours. Use the refresh token to get a new access token:
async function refreshSession(refreshJwt) {
const res = await fetch('https://bsky.social/xrpc/com.atproto.server.refreshSession', {
method: 'POST',
headers: { 'Authorization': `Bearer ${refreshJwt}` },
});
if (!res.ok) throw new Error('Session refresh failed');
const session = await res.json();
return {
accessJwt: session.accessJwt,
refreshJwt: session.refreshJwt,
};
}
Understanding AT Protocol Records
In the AT Protocol, everything is a record stored in a repository. Your Bluesky account is a repository, identified by your DID (Decentralized Identifier, like did:plc:abc123). Posts are records of type app.bsky.feed.post.
The key concepts:
- DID: your permanent, portable identifier (e.g.,
did:plc:abc123) - Handle: your human-readable username (e.g.,
yourname.bsky.social) - Collection: the type of record (e.g.,
app.bsky.feed.post) - Record key (rkey): a unique identifier for each record, typically a timestamp-based TID
- AT URI: the full path to a record (e.g.,
at://did:plc:abc123/app.bsky.feed.post/3abc123) - CID: a content hash that uniquely identifies a specific version of a record
Creating a Basic Text Post
Posts are created using the com.atproto.repo.createRecord endpoint:
async function createPost(session, text) {
const res = await fetch('https://bsky.social/xrpc/com.atproto.repo.createRecord', {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.accessJwt}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
repo: session.did,
collection: 'app.bsky.feed.post',
record: {
$type: 'app.bsky.feed.post',
text: text,
createdAt: new Date().toISOString(),
},
}),
});
if (!res.ok) {
const error = await res.json();
throw new Error(`Post failed: ${error.message}`);
}
const { uri, cid } = await res.json();
return { uri, cid };
// uri is like: at://did:plc:abc123/app.bsky.feed.post/3abc123
// cid is the content hash
}
const post = await createPost(session, 'Hello from the AT Protocol!');
console.log('Posted:', post.uri);
The createdAt field is required and must be an ISO 8601 timestamp. Bluesky uses this for chronological ordering. The text field has a 300-character limit (measured in grapheme clusters, not bytes).
Rich Text with Facets
Bluesky does not use markdown or HTML for formatting. Instead, it uses facets — annotations that mark specific byte ranges in the text with special meaning. Facets are used for links, mentions, and hashtags.
Link Facets
To make a URL clickable or create a hyperlink on custom text:
// Important: facet indices are BYTE positions, not character positions
function getByteLength(text) {
return new TextEncoder().encode(text).length;
}
function getByteIndex(text, charIndex) {
return new TextEncoder().encode(text.slice(0, charIndex)).length;
}
const text = 'Check out our website for more details';
const linkText = 'our website';
const linkStart = text.indexOf(linkText);
const linkEnd = linkStart + linkText.length;
const post = {
$type: 'app.bsky.feed.post',
text: text,
createdAt: new Date().toISOString(),
facets: [
{
index: {
byteStart: getByteIndex(text, linkStart),
byteEnd: getByteIndex(text, linkEnd),
},
features: [
{
$type: 'app.bsky.richtext.facet#link',
uri: 'https://example.com',
},
],
},
],
};
A critical detail: facet byte indices must be calculated using UTF-8 byte positions, not JavaScript string character indices. This matters when your text contains emoji, accented characters, or other multi-byte characters.
Mention Facets
To mention another user, you need their DID. First resolve their handle to a DID, then create the facet:
// Resolve handle to DID
async function resolveHandle(handle) {
const res = await fetch(
`https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`
);
const { did } = await res.json();
return did;
}
const mentionedDid = await resolveHandle('friend.bsky.social');
const text = 'Great post by @friend.bsky.social about API development';
const mentionText = '@friend.bsky.social';
const mentionStart = text.indexOf(mentionText);
const mentionEnd = mentionStart + mentionText.length;
const post = {
$type: 'app.bsky.feed.post',
text: text,
createdAt: new Date().toISOString(),
facets: [
{
index: {
byteStart: getByteIndex(text, mentionStart),
byteEnd: getByteIndex(text, mentionEnd),
},
features: [
{
$type: 'app.bsky.richtext.facet#mention',
did: mentionedDid,
},
],
},
],
};
Hashtag Facets
const text = 'Working on a new project #buildinpublic #atproto';
const hashtag1 = '#buildinpublic';
const hashtag1Start = text.indexOf(hashtag1);
const post = {
$type: 'app.bsky.feed.post',
text: text,
createdAt: new Date().toISOString(),
facets: [
{
index: {
byteStart: getByteIndex(text, hashtag1Start),
byteEnd: getByteIndex(text, hashtag1Start + hashtag1.length),
},
features: [
{
$type: 'app.bsky.richtext.facet#tag',
tag: 'buildinpublic', // without the # symbol
},
],
},
{
index: {
byteStart: getByteIndex(text, text.indexOf('#atproto')),
byteEnd: getByteIndex(text, text.indexOf('#atproto') + '#atproto'.length),
},
features: [
{
$type: 'app.bsky.richtext.facet#tag',
tag: 'atproto',
},
],
},
],
};
Automatic Facet Detection
Manually calculating byte positions is error-prone. Here is a helper that automatically detects URLs, mentions, and hashtags in text and generates the correct facets:
function detectFacets(text) {
const encoder = new TextEncoder();
const facets = [];
// Detect URLs
const urlRegex = /https?:\/\/[^\s)]+/g;
let match;
while ((match = urlRegex.exec(text)) !== null) {
facets.push({
index: {
byteStart: encoder.encode(text.slice(0, match.index)).length,
byteEnd: encoder.encode(text.slice(0, match.index + match[0].length)).length,
},
features: [{ $type: 'app.bsky.richtext.facet#link', uri: match[0] }],
});
}
// Detect hashtags
const tagRegex = /#([a-zA-Z0-9_]+)/g;
while ((match = tagRegex.exec(text)) !== null) {
facets.push({
index: {
byteStart: encoder.encode(text.slice(0, match.index)).length,
byteEnd: encoder.encode(text.slice(0, match.index + match[0].length)).length,
},
features: [{ $type: 'app.bsky.richtext.facet#tag', tag: match[1] }],
});
}
return facets;
}
Image Posts
Bluesky handles images as blobs. You upload the image binary first, get a blob reference, then include it in your post record.
Step 1: Upload the Image Blob
const fs = require('fs');
async function uploadBlob(session, filePath, mimeType) {
const imageData = fs.readFileSync(filePath);
const res = await fetch('https://bsky.social/xrpc/com.atproto.repo.uploadBlob', {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.accessJwt}`,
'Content-Type': mimeType, // e.g., 'image/jpeg', 'image/png'
},
body: imageData,
});
if (!res.ok) {
const error = await res.json();
throw new Error(`Blob upload failed: ${error.message}`);
}
const { blob } = await res.json();
return blob; // Contains $type, ref.$link, mimeType, size
}
const blob = await uploadBlob(session, './photo.jpg', 'image/jpeg');
Step 2: Create Post with Embedded Image
async function createImagePost(session, text, images) {
const record = {
$type: 'app.bsky.feed.post',
text: text,
createdAt: new Date().toISOString(),
embed: {
$type: 'app.bsky.embed.images',
images: images.map(img => ({
alt: img.alt || '',
image: img.blob,
aspectRatio: img.aspectRatio, // optional: { width: 1200, height: 630 }
})),
},
};
// Auto-detect facets
record.facets = detectFacets(text);
const res = await fetch('https://bsky.social/xrpc/com.atproto.repo.createRecord', {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.accessJwt}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
repo: session.did,
collection: 'app.bsky.feed.post',
record: record,
}),
});
const { uri, cid } = await res.json();
return { uri, cid };
}
// Upload and post
const blob = await uploadBlob(session, './screenshot.png', 'image/png');
const post = await createImagePost(session, 'New feature just shipped!', [
{ blob, alt: 'Screenshot of the new dashboard feature', aspectRatio: { width: 1200, height: 630 } },
]);
Image constraints:
- Maximum file size: 1MB per image
- Supported formats: JPEG, PNG, WebP
- Up to 4 images per post
- Alt text is optional but strongly recommended for accessibility
- Aspect ratio hints help the client display the image correctly before it loads
Link Card Embeds
To create a post with a link preview card (like Open Graph cards on other platforms), you use the app.bsky.embed.external embed type. Unlike other platforms, Bluesky does not automatically generate link previews. You must fetch the metadata and provide it yourself:
async function createLinkPost(session, text, url, title, description, thumbBlob) {
const record = {
$type: 'app.bsky.feed.post',
text: text,
createdAt: new Date().toISOString(),
embed: {
$type: 'app.bsky.embed.external',
external: {
uri: url,
title: title,
description: description,
},
},
};
// Add thumbnail if provided
if (thumbBlob) {
record.embed.external.thumb = thumbBlob;
}
record.facets = detectFacets(text);
const res = await fetch('https://bsky.social/xrpc/com.atproto.repo.createRecord', {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.accessJwt}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
repo: session.did,
collection: 'app.bsky.feed.post',
record: record,
}),
});
return res.json();
}
// Example: create a link card post
const thumb = await uploadBlob(session, './og-image.jpg', 'image/jpeg');
await createLinkPost(
session,
'We just published a guide on social media automation',
'https://example.com/blog/guide',
'The Complete Guide to Social Media Automation',
'Everything you need to know about automating your social presence in 2026.',
thumb
);
Reply Posts
To reply to an existing post, you need both the AT URI and the CID of the post you are replying to. You also need the root post's URI and CID if the reply is to a post in an existing thread:
async function createReply(session, text, parentUri, parentCid, rootUri, rootCid) {
const record = {
$type: 'app.bsky.feed.post',
text: text,
createdAt: new Date().toISOString(),
reply: {
root: {
uri: rootUri || parentUri, // The first post in the thread
cid: rootCid || parentCid,
},
parent: {
uri: parentUri, // The post being replied to
cid: parentCid,
},
},
};
record.facets = detectFacets(text);
const res = await fetch('https://bsky.social/xrpc/com.atproto.repo.createRecord', {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.accessJwt}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
repo: session.did,
collection: 'app.bsky.feed.post',
record: record,
}),
});
return res.json();
}
Deleting Posts
async function deletePost(session, postUri) {
// Extract rkey from URI: at://did:plc:abc/app.bsky.feed.post/rkey
const rkey = postUri.split('/').pop();
const res = await fetch('https://bsky.social/xrpc/com.atproto.repo.deleteRecord', {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.accessJwt}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
repo: session.did,
collection: 'app.bsky.feed.post',
rkey: rkey,
}),
});
return res.ok;
}
Rate Limits
Bluesky's rate limits are relatively generous compared to commercial social media APIs:
- Session creation: 30 per 5 minutes per account
- Record creation (posts): 1,667 per day per account, 100 per hour
- Blob uploads: 1,000 per day per account
- General XRPC calls: 3,000 per 5 minutes
- Rate limit headers:
RateLimit-Limit,RateLimit-Remaining,RateLimit-Reset,RateLimit-Policy
When rate limited, you receive a 429 response with a Retry-After header indicating how many seconds to wait:
async function postWithRateLimit(url, options) {
const res = await fetch(url, options);
if (res.status === 429) {
const retryAfter = parseInt(res.headers.get('Retry-After') || '30', 10);
console.log(`Rate limited. Waiting ${retryAfter}s...`);
await new Promise(r => setTimeout(r, retryAfter * 1000));
return fetch(url, options); // Retry once
}
return res;
}
Common Errors and Troubleshooting
Error: "InvalidToken" or "ExpiredToken"
Your access JWT has expired (approximately 2-hour lifespan). Call com.atproto.server.refreshSession with your refresh JWT to get a new access token. If the refresh token is also expired, create a new session with your app password.
Error: "Record/text must not be longer than 300 graphemes"
Bluesky counts characters as grapheme clusters, not bytes or code points. Some emoji count as a single grapheme even though they are multiple code points. Use the Intl.Segmenter API to count graphemes accurately:
function countGraphemes(text) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
return [...segmenter.segment(text)].length;
}
Error: "BlobTooLarge"
Image blobs must be under 1MB. Resize or compress your image before uploading. Consider using a library like sharp to resize images server-side.
Error: "InvalidRequest" with facets
Facet byte indices do not match the actual text content. This is almost always a UTF-8 byte calculation error. Double-check that you are using TextEncoder to calculate byte positions, not JavaScript string indices.
Error: "AuthenticationRequired"
You are either not sending the Authorization header or the session has been invalidated. Re-authenticate with createSession.
Using the Official SDK
While the raw HTTP API is straightforward, Bluesky provides an official TypeScript SDK (@atproto/api) that simplifies common operations:
const { BskyAgent, RichText } = require('@atproto/api');
const agent = new BskyAgent({ service: 'https://bsky.social' });
// Login
await agent.login({
identifier: 'yourname.bsky.social',
password: 'your-app-password',
});
// Create a post with auto-detected facets
const rt = new RichText({
text: 'Check out https://example.com and follow @friend.bsky.social #buildinpublic',
});
await rt.detectFacets(agent); // Resolves handles to DIDs and detects links
await agent.post({
text: rt.text,
facets: rt.facets,
createdAt: new Date().toISOString(),
});
// Image post
const imageData = fs.readFileSync('./photo.jpg');
const uploadRes = await agent.uploadBlob(imageData, { encoding: 'image/jpeg' });
await agent.post({
text: 'Photo from today!',
embed: {
$type: 'app.bsky.embed.images',
images: [{ alt: 'A beautiful sunset', image: uploadRes.data.blob }],
},
createdAt: new Date().toISOString(),
});
Best Practices for Production
- Use app passwords, not account passwords. App passwords can be individually revoked without affecting your account or other integrations.
- Calculate facets accurately. Use
TextEncoderfor byte-level positions. One wrong byte offset will corrupt the entire post's rich text. - Always include createdAt. The AT Protocol requires this field. Use
new Date().toISOString()for current time. - Compress images. The 1MB blob limit is strict. Build image compression into your upload pipeline.
- Handle DID resolution. If you are mentioning users, resolve their handles to DIDs and cache the results. Handles can change, but DIDs are permanent.
- Consider federation. While most users are on bsky.social, the AT Protocol supports self-hosted PDS instances. Your integration should ideally work with any PDS, not just bsky.social.
- Use the SDK for complex operations. The
@atproto/apiSDK handles session management, token refresh, and facet detection automatically. - Store session tokens securely. Even though app passwords are revocable, treat all authentication tokens as sensitive data.
Complete Working Example
class BlueskyClient {
constructor(service = 'https://bsky.social') {
this.service = service;
this.session = null;
}
async login(identifier, appPassword) {
const res = await fetch(`${this.service}/xrpc/com.atproto.server.createSession`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password: appPassword }),
});
if (!res.ok) throw new Error('Login failed');
this.session = await res.json();
return this.session;
}
async refreshIfNeeded() {
// Simple JWT expiry check
const payload = JSON.parse(
Buffer.from(this.session.accessJwt.split('.')[1], 'base64').toString()
);
if (Date.now() / 1000 > payload.exp - 300) {
const res = await fetch(`${this.service}/xrpc/com.atproto.server.refreshSession`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.session.refreshJwt}` },
});
if (!res.ok) throw new Error('Refresh failed');
this.session = await res.json();
}
}
async post(text, options = {}) {
await this.refreshIfNeeded();
const record = {
$type: 'app.bsky.feed.post',
text,
createdAt: new Date().toISOString(),
facets: this.detectFacets(text),
};
if (options.images) {
record.embed = {
$type: 'app.bsky.embed.images',
images: options.images,
};
}
if (options.replyTo) {
record.reply = {
root: options.replyTo.root || options.replyTo.parent,
parent: options.replyTo.parent,
};
}
const res = await fetch(`${this.service}/xrpc/com.atproto.repo.createRecord`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.session.accessJwt}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
repo: this.session.did,
collection: 'app.bsky.feed.post',
record,
}),
});
if (!res.ok) {
const error = await res.json();
throw new Error(`Post failed: ${error.message}`);
}
return res.json();
}
async uploadImage(filePath, mimeType) {
await this.refreshIfNeeded();
const data = require('fs').readFileSync(filePath);
const res = await fetch(`${this.service}/xrpc/com.atproto.repo.uploadBlob`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.session.accessJwt}`,
'Content-Type': mimeType,
},
body: data,
});
if (!res.ok) throw new Error('Upload failed');
const { blob } = await res.json();
return blob;
}
detectFacets(text) {
const encoder = new TextEncoder();
const facets = [];
const urlRegex = /https?:\/\/[^\s)]+/g;
let m;
while ((m = urlRegex.exec(text)) !== null) {
facets.push({
index: {
byteStart: encoder.encode(text.slice(0, m.index)).length,
byteEnd: encoder.encode(text.slice(0, m.index + m[0].length)).length,
},
features: [{ $type: 'app.bsky.richtext.facet#link', uri: m[0] }],
});
}
return facets;
}
}
// Usage
const bsky = new BlueskyClient();
await bsky.login('yourname.bsky.social', 'your-app-password');
await bsky.post('Hello from my custom Bluesky client!');
Skip the Protocol Details with Kleo
The AT Protocol is well-designed and open, but building a production Bluesky integration still means managing sessions, calculating UTF-8 byte offsets for facets, compressing images under 1MB, resolving handles to DIDs, and staying current with protocol changes. That is engineering time that could go toward your actual product.
Kleo handles all of this. Connect your Bluesky account with your app password, generate posts with AI that understands your brand, and schedule them alongside your LinkedIn, X, and Threads content. No byte calculations, no DID resolution, no blob management.
Post to Bluesky without the protocol complexity
Kleo handles authentication, facets, image blobs, and scheduling. Just write and publish.
Get Started with Kleo