Flaghand

Guides · Updated

How to post to Bluesky with the API

Bluesky's API is free and needs no developer account or API key. Sign in with your handle and an app password (com.atproto.server.createSession), then write the post as a record with com.atproto.repo.createRecord in the app.bsky.feed.post collection. A post holds up to 300 characters (graphemes) and 3,000 bytes, and up to four photos of 2 MB each. Links and mentions only become clickable if you mark them yourself, with facets.

Checked on November 3, 2026 against Bluesky's developer docs, which now live at bsky.network/docs (rate limits, OAuth), and the post and image schemas (lexicons) in Bluesky's atproto repository.

What you need first

  • The account's handle, like yourname.bsky.social.
  • An app password. Make one in the Bluesky app's settings, under App passwords. It signs in like the account password, and you can revoke it on its own. Never put the account's main password in a script.
  • The account's server. For an account Bluesky hosts, use https://bsky.social. A self-hosted account uses its own server's address.

App passwords suit a bot or a script for your own account. Bluesky's docs say apps with their own sign-in for other people should use OAuth instead; its SDKs handle that (atproto.com/sdks).

Post it

curl

# 1. Sign in. The answer holds accessJwt (the token) and did (the account's ID).
SESSION=$(curl -s -X POST https://bsky.social/xrpc/com.atproto.server.createSession \
  -H "Content-Type: application/json" \
  -d "{\"identifier\": \"$BLUESKY_HANDLE\", \"password\": \"$BLUESKY_APP_PASSWORD\"}")
TOKEN=$(echo "$SESSION" | jq -r .accessJwt)
DID=$(echo "$SESSION" | jq -r .did)

# 2. Post.
curl -X POST https://bsky.social/xrpc/com.atproto.repo.createRecord \
  -H "Authorization: Bearer $TOKEN" \
  -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\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}"

Bluesky answers with the post's address and content hash:

{
  "uri": "at://did:plc:u5cwb2mwiv2bfq53cjufe6yn/app.bsky.feed.post/3k4duaz5vfs2b",
  "cid": "bafyreibjifzpqj6o6wcq3hejh7y4z4z2vmiklkvykc57tw3pcbx3kxifpm"
}

The last part of the uri is the post's key: the post is at https://bsky.app/profile/<your handle>/post/3k4duaz5vfs2b.

Python

import os
from datetime import datetime, timezone

import requests

PDS = "https://bsky.social"
session = requests.post(
    f"{PDS}/xrpc/com.atproto.server.createSession",
    json={"identifier": os.environ["BLUESKY_HANDLE"], "password": os.environ["BLUESKY_APP_PASSWORD"]},
    timeout=30,
)
session.raise_for_status()
session = session.json()

resp = requests.post(
    f"{PDS}/xrpc/com.atproto.repo.createRecord",
    headers={"Authorization": f"Bearer {session['accessJwt']}"},
    json={
        "repo": session["did"],
        "collection": "app.bsky.feed.post",
        "record": {
            "$type": "app.bsky.feed.post",
            "text": "Hello from the Bluesky API",
            "createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
        },
    },
    timeout=30,
)
resp.raise_for_status()
print(resp.json()["uri"])

Node.js (18 or later)

const PDS = 'https://bsky.social';
const json = { 'Content-Type': 'application/json' };

const session = await fetch(`${PDS}/xrpc/com.atproto.server.createSession`, {
  method: 'POST',
  headers: json,
  body: JSON.stringify({ identifier: process.env.BLUESKY_HANDLE, password: process.env.BLUESKY_APP_PASSWORD }),
}).then((r) => r.json());

const resp = await fetch(`${PDS}/xrpc/com.atproto.repo.createRecord`, {
  method: 'POST',
  headers: { ...json, Authorization: `Bearer ${session.accessJwt}` },
  body: JSON.stringify({
    repo: session.did,
    collection: 'app.bsky.feed.post',
    record: { $type: 'app.bsky.feed.post', text: 'Hello from the Bluesky API', createdAt: new Date().toISOString() },
  }),
});
if (!resp.ok) throw new Error(`Bluesky answered ${resp.status}: ${await resp.text()}`);
console.log((await resp.json()).uri);

Keep the session and reuse it: Bluesky allows only 30 sign-ins per 5 minutes and 300 a day for each account.

A URL in text shows as plain text unless the record marks it. A facet names a link's start and end as byte offsets in the UTF-8 text, not character positions, so emoji and accented letters before the link shift it. This marks every https:// link:

function linkFacets(text) {
  const bytes = (s) => new TextEncoder().encode(s).length;
  return [...text.matchAll(/https?:\/\/[^\s]+[^\s.,;:!?)"']/g)].map((m) => ({
    index: { byteStart: bytes(text.slice(0, m.index)), byteEnd: bytes(text.slice(0, m.index + m[0].length)) },
    features: [{ $type: 'app.bsky.richtext.facet#link', uri: m[0] }],
  }));
}

const text = 'New post ✨ https://example.com/blog/hello';
const record = { $type: 'app.bsky.feed.post', text, facets: linkFacets(text), createdAt: new Date().toISOString() };

A mention works the same way, with app.bsky.richtext.facet#mention and the person's did (look it up with com.atproto.identity.resolveHandle). A hashtag uses app.bsky.richtext.facet#tag with the tag's text, without the #.

To post Do this
Photos Upload each file's bytes to com.atproto.repo.uploadBlob (with its Content-Type), then add "embed": {"$type": "app.bsky.embed.images", "images": [{"image": <blob>, "alt": "…"}]}. Up to 4, each up to 2,000,000 bytes
A link card Bluesky doesn't make one from the link. Add "embed": {"$type": "app.bsky.embed.external", "external": {"uri": "…", "title": "…", "description": "…"}}, with an optional uploaded thumb
A reply Add "reply": {"root": {"uri", "cid"}, "parent": {"uri", "cid"}}: the thread's first post and the post you're answering
A thread Post the first part, then each next part as a reply: root stays the first post, parent is the part before
A quote "embed": {"$type": "app.bsky.embed.record", "record": {"uri": "…", "cid": "…"}}

Bluesky's posts guide has the full upload and card code. Strip location data and other metadata from photos before you upload them; Bluesky leaves that to the app.

Limits

Limit
Post text 300 graphemes and 3,000 UTF-8 bytes; empty is allowed with an embed
Photos Up to 4 a post, each up to 2,000,000 bytes
Writes, per account 5,000 points an hour and 35,000 a day. A post costs 3 points: up to 1,666 posts an hour, 11,666 a day
Sign-ins (createSession), per account 30 per 5 minutes, 300 a day
All requests, per IP 3,000 per 5 minutes

Past a limit, Bluesky answers 429. These are the limits of servers Bluesky runs; a self-hosted server can set its own. Likes, follows and reposts are records too, so they count against the same writes limit.

Do it yourself, or let an agent draft it through Flaghand

Call the API yourself for a bot or a script on your own account: it's free, an app password takes a minute, and nothing waits for a person.

If an AI agent writes the posts, an app password lets it post anything, at once. Flaghand is a publishing desk for that case. You connect the account with Bluesky's own sign-in, never an app password. Your agent connects to Flaghand's MCP server (https://app.flaghand.com/mcp) or its API and drafts the post or thread, with photos, a video or a link card. Flaghand marks links, mentions and hashtags the way the Bluesky app does, counts the text as Bluesky does and previews it as bsky.app shows it. You approve the exact post with your passkey; Flaghand posts it at its time and reads it back to check it went out as approved. No token Flaghand issues can approve, schedule or post. Sign-up takes no card (current terms). Get started with Flaghand.

Questions

Is the Bluesky API free? Yes. There is no API key, developer account or fee: you sign in as the account, with an app password or OAuth, and call its server. Rate limits apply instead.

Do I need a Bluesky API key? No. A bot or script uses an app password for its own account. An app that signs in other people uses OAuth.

What is the Bluesky API character limit? 300 graphemes, which is what a person sees as characters, and 3,000 bytes of UTF-8. An emoji made of several code points counts as one.

How many posts can I make through the Bluesky API? Bluesky's servers allow 5,000 write points an hour and 35,000 a day per account, and a post costs 3, so up to 1,666 posts an hour and 11,666 a day.

Why isn't my link clickable on Bluesky? The API doesn't detect links. Add a facet with the link's UTF-8 byte offsets, and an app.bsky.embed.external embed if you want a card.

Can an AI agent post on Bluesky for me? With your app password it can post anything, immediately. Through Flaghand it drafts and asks for approval, and only your passkey approval of the exact post makes it go out.

Sources

Checked November 3, 2026: