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:

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:

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:

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:

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

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