Bluesky's API is free and needs no developer account or API key. Sign in with your handle and an app password
(com), then write the post as a record with com in the
app 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://. A self-hosted account uses its own server's address.bsky .social
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://.
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.
Make links clickable: facets
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 and the person's did (look it up with
com). A hashtag uses app with the tag's text, without the #.
Photos, link cards, replies
| To post | Do this |
|---|---|
| Photos | Upload each file's bytes to com (with its Content-Type), then add "embed": {"$type": "app. 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, 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 |
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://) 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 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:
- Bluesky: Posts in-depth
- Bluesky: Rate limits
- Bluesky: OAuth
- Bluesky: the post and images lexicons