Posting to Bluesky takes three calls: com.atproto.server.createSession to get tokens, com.atproto.repo.uploadBlob if you have images, and com.atproto.repo.createRecord to write an app.bsky.feed.post record. There is no app review and no OAuth consent screen to get approved. The cost of that simplicity is that links and mentions are not detected for you: you compute byte offsets over UTF-8 yourself.
What can the Bluesky API publish, and what can it not?
A post record is a plain JSON document. The required fields are $type (app.bsky.feed.post), text, and createdAt as an ISO 8601 timestamp. Everything else is optional structure.
| Feature | Support |
|---|---|
| Images per post | 4 maximum |
| Image size | 1,000,000 bytes each, stated in the post docs |
| Total blob size per post | 2,000,000 bytes maximum |
| Alt text | Required per image, with an aspect ratio |
| Quote posts | app.bsky.embed.record |
| Link cards | app.bsky.embed.external, with your own thumbnail |
| Replies | reply with root and parent strong references |
| Language tags | langs, an array like ["en-US"] |
The gaps to name up front:
- Nothing is auto-detected. Links, mentions and hashtags are inert text unless you attach facets. A URL you paste without a facet is not clickable.
- Link cards are your job.
app.bsky.embed.externalwants the title, description and thumbnail. Nothing scrapes the page for you. - Mentions need a resolved DID. You cannot put a handle in a facet; you resolve the handle to a DID first.
- Images must have EXIF stripped before upload, per the docs.
- The post docs state no character or grapheme limit. We could not verify one from that page, so we are not quoting a number. See the Bluesky character limit post for what the client enforces.
Note: Figures here were verified against the Bluesky developer docs (docs.bsky.app, which now redirects to bsky.network/docs) as of September 2026. Platforms change these without notice.
What is the auth model?
This is the shortest auth section you will read for any social platform.
There is no app registration, no app review and no OAuth consent screen for the app-password path. A user creates an app password in their Bluesky settings and gives it to your software. You exchange the handle plus that app password for an access JWT and a refresh JWT at com.atproto.server.createSession. Access tokens are short-lived; you refresh them with the refresh JWT.
Two consequences. An app password is a credential your user hands you directly: no consent screen means no platform-mediated scope grant, so the storage obligation sits entirely with you. Encrypt them, and give users a visible way to disconnect.
And the network is not owned by one company. AT Protocol accounts live on a Personal Data Server, and a PDS is self-hostable. Your client talks to the user’s PDS host, so hardcoding bsky.social works today but is not the protocol’s model. Read the host from the user’s DID document.
How do you actually post? The call sequence
POST /xrpc/com.atproto.server.createSessionwithidentifier(handle or DID) andpassword(the app password). ReturnsaccessJwt,refreshJwtanddid.- If you have images:
POST /xrpc/com.atproto.repo.uploadBlobper image, with the raw bytes and the correctContent-Type. Each returns a blob reference. - Compute facets for any links or mentions, as byte offsets into the UTF-8 encoding of
text. POST /xrpc/com.atproto.repo.createRecordwithreposet to your DID,collectionset toapp.bsky.feed.post, and the record itself.
const base = 'https://bsky.social/xrpc';
const auth = await fetch(`${base}/com.atproto.server.createSession`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: handle, password: appPassword }),
}).then((r) => r.json());
const text = 'Notes on the release: https://example.com/changelog';
const url = 'https://example.com/changelog';
const enc = new TextEncoder();
const byteStart = enc.encode(text.slice(0, text.indexOf(url))).length;
await fetch(`${base}/com.atproto.repo.createRecord`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${auth.accessJwt}`,
},
body: JSON.stringify({
repo: auth.did,
collection: 'app.bsky.feed.post',
record: {
$type: 'app.bsky.feed.post',
text,
createdAt: new Date().toISOString(),
facets: [
{
index: { byteStart, byteEnd: byteStart + enc.encode(url).length },
features: [{ $type: 'app.bsky.richtext.facet#link', uri: url }],
},
],
},
}),
});
The facet arithmetic, spelled out
byteStart and byteEnd are offsets into the UTF-8 bytes of the text, not into JavaScript string indices and not into characters.
"café".length is 4 in JavaScript, but the UTF-8 encoding is 5 bytes. Any emoji before your link shifts the offset by 4 bytes while moving the string index by 2. Get this wrong and the link renders as broken text or highlights the wrong span, with no error from the server: the record is valid, it just points at the wrong bytes.
Encode the string once, find offsets in the byte array, and never mix the two coordinate systems. The link facet feature uses uri, not url.
What are the Bluesky rate limits?
Writes are metered with a points system per account: CREATE costs 3 points, UPDATE 2, DELETE 1, against a budget of 5,000 points per hour and 35,000 per day. That works out to roughly 1,666 creates per hour and 11,666 per day.
| Limit | Value |
|---|---|
| Write points | 5,000/hour, 35,000/day |
| Creates (derived) | ~1,666/hour, ~11,666/day |
| Overall PDS requests | 3,000 per 5 minutes, per IP |
createSession | 30 per 5 minutes, 300 per day, per account |
| Blob upload ceiling | 52,428,800 bytes (50 MB) |
The createSession limit is the one that catches schedulers. 30 per 5 minutes per account means you cache the session and refresh it, rather than logging in per post. A worker that creates a session on every job will rate-limit itself long before it hits any posting limit.
Note the two blob figures: the PDS accepts blobs up to 50 MB, while the post documentation states a 1,000,000 byte per-image and 2,000,000 byte total limit for post images. Size for the smaller one.
Note: Figures here were verified against the Bluesky developer rate-limits documentation as of September 2026. Platforms change these without notice.
What will actually cost you three weeks?
Bluesky is genuinely the cheapest of the major platforms to integrate, but “cheap” is not “free”.
Facet computation for real text. Detecting URLs, trailing punctuation, handles and hashtags, converting every match to UTF-8 byte offsets, and keeping that correct when a user edits the text. This is where the bugs live.
Blob budgeting. A 2 MB total across up to 4 images means resizing and re-encoding server-side, stripping EXIF, and deciding what to do when the user’s photos will not fit.
Link cards. To make posts look like posts you fetch the target page, pull title, description and image, upload that image as a blob, and build the external embed. That is a small crawler with timeouts and failure handling.
Session caching, because of the 30-per-5-minutes ceiling, and PDS host resolution, because assuming bsky.social breaks self-hosted accounts in a way you cannot fix from your side.
If you are mirroring content in, cross-posting from X to Bluesky and cross-posting from Threads to Bluesky both cover the text-length and media differences you have to reconcile before the record is even valid.
The short version
- Three calls:
createSession,uploadBlobfor images,createRecordwith anapp.bsky.feed.postrecord. - No app review, no OAuth screen. App passwords, handed over by the user.
- Links and mentions need facets with byte offsets over UTF-8. Nothing is auto-detected.
- 4 images per post, 1,000,000 bytes each, 2,000,000 bytes total, alt text required.
- Writes cost 3 points per create against 5,000/hour and 35,000/day.
createSessionis 30 per 5 minutes. - Accounts can live on a self-hosted PDS. Do not hardcode the host.
Posting to Bluesky alongside 14 other networks
Bluesky is the easy one. The same product usually also needs X (OAuth 2.0 PKCE, chunked media upload, per-request billing), Threads (Meta App Review, container-then-publish, 60-day token refresh) and TikTok (a Content Posting API audit before you can publish at all). Each has its own auth, media pipeline and async failure model.
BulkPublish is one REST API across 15 platforms, Bluesky included, with facet computation, blob resizing and session refresh handled server-side. The developer docs and the REST API reference have the endpoints, and scheduling Bluesky posts covers it without code.