Skip to content
Back to Resources
Guide

Posting to Bluesky Programmatically: The AT Protocol Guide

Skopx Team
August 21, 2026
16 min read

To post to Bluesky from code, you create an app password in your account settings, exchange it for a session token at com.atproto.server.createSession, then write an app.bsky.feed.post record with com.atproto.repo.createRecord. That is the whole loop, and unlike most social platforms the Bluesky API requires no developer account, no app review, and no OAuth client registration to get started with your own account.

What makes this different from posting to X or LinkedIn is that Bluesky is not really a social API in the traditional sense. It is a thin HTTP layer over a personal data repository. You are not calling a "create post" endpoint. You are writing a typed record into a repo you own, and the network reads that repo. Once that clicks, everything else in the AT Protocol makes sense: replies are references to other records, images are blobs uploaded to your repo first, and links inside your text are not parsed by the server at all. You have to annotate them yourself with byte offsets.

This guide walks the whole path with working request bodies: authentication, a plain text post, rich text facets, image and link embeds, threads, deletion, rate limits, and what to do when you want this running every day instead of every time you remember to run the script.

How does the Bluesky API differ from a traditional social API?

Three structural facts shape every request you will write.

Everything is XRPC. Endpoints are HTTP calls at https://<host>/xrpc/<nsid>. The NSID is a reverse-DNS method name like com.atproto.repo.createRecord or app.bsky.feed.getPostThread. Queries are GET with query parameters, procedures are POST with a JSON body. There are no REST-style paths and no versioned URL prefixes.

Everything is a lexicon-validated record. A post is a record of type app.bsky.feed.post living in the collection of the same name inside your repository. The lexicon defines which fields exist and what they may contain. Send a field the lexicon does not know about and the server rejects the write. This is stricter than most APIs you have used, and it is a good thing: schema errors surface immediately instead of silently dropping data.

Your host is not necessarily bsky.social. Bluesky is federated. bsky.social is the personal data server (PDS) that hosts most accounts, but a self-hosted account lives elsewhere. If you are only automating your own account, hardcoding your PDS host is fine. If you are building a tool other people sign into, resolve the handle to a DID, fetch the DID document, and read the #atproto_pds service endpoint from it.

The namespace prefixes tell you what layer you are on. com.atproto.* is protocol level and applies to any AT Protocol app. app.bsky.* is Bluesky's own microblogging application built on top. Posting touches both.

How do you create an app password and open a session?

Go to Settings, then Privacy and Security, then App Passwords, and create one. You get a string in the format xxxx-xxxx-xxxx-xxxx. Copy it immediately, because it is shown once.

App passwords are deliberately limited compared to your real password. They cannot change your account email, cannot change your handle, and cannot delete your account. By default they also cannot read or send direct messages, though there is an opt-in toggle for DM access when you create one. Use a separate app password per script so you can revoke one without breaking the others.

For a script that only manages your own account, an app password is the right tool. If you are shipping a product that other people authenticate into, use AT Protocol OAuth instead. Asking users to paste credentials into your app is the pattern OAuth exists to eliminate.

Exchange the app password for tokens:

curl -X POST https://bsky.social/xrpc/com.atproto.server.createSession \
  -H "Content-Type: application/json" \
  -d '{
    "identifier": "yourhandle.bsky.social",
    "password": "xxxx-xxxx-xxxx-xxxx"
  }'

The response gives you four things worth keeping:

{
  "did": "did:plc:abcdefghijklmnopqrstuvwx",
  "handle": "yourhandle.bsky.social",
  "accessJwt": "eyJhbGciOi...",
  "refreshJwt": "eyJhbGciOi..."
}

The did is your permanent identifier. Handles can change, DIDs cannot, so store the DID and never key your data on the handle. The accessJwt is short lived, on the order of a couple of hours. The refreshJwt lasts far longer and is exchanged at com.atproto.server.refreshSession using the refresh token itself as the bearer credential.

The trap here is that refresh tokens rotate. When you call refreshSession you get a new refresh token back, and the old one stops working. If your script writes the new pair to disk but crashes before the write flushes, you are locked out until you re-authenticate. Persist the new tokens before you use them, and keep the app password available as a fallback path.

How do you write your first post record?

A minimal post is two required fields:

curl -X POST https://bsky.social/xrpc/com.atproto.repo.createRecord \
  -H "Authorization: Bearer $ACCESS_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "repo": "did:plc:abcdefghijklmnopqrstuvwx",
    "collection": "app.bsky.feed.post",
    "record": {
      "$type": "app.bsky.feed.post",
      "text": "Shipping notes for this week.",
      "createdAt": "2026-08-20T09:00:00.000Z",
      "langs": ["en"]
    }
  }'

createdAt must be an ISO 8601 timestamp with a timezone. It is client supplied, which means it is not a scheduling field. Setting it to the future does not delay publication, it just produces a post that claims to be from the future and sorts oddly in clients. Real scheduling means holding the request until the moment you want it sent.

langs is optional but you should always send it. Bluesky users filter their feeds by language, and a post with no language tag can be excluded from feeds where it belongs.

The response is the identity of the record you just created:

{
  "uri": "at://did:plc:abcdefghijklmnopqrstuvwx/app.bsky.feed.post/3kv8xq2mnop2k",
  "cid": "bafyreib2rxk3rh6kzwq..."
}

The trailing segment of the URI is the record key, and the public web URL is https://bsky.app/profile/<handle>/post/<rkey>. Store both uri and cid. You need the pair as a strong reference for replies and quotes, and you need the URI to delete the record later with com.atproto.repo.deleteRecord.

On length: the lexicon allows 300 graphemes and 3000 bytes. Graphemes, not characters, is what matters. A family emoji built from several joined code points is one grapheme to Bluesky but many JavaScript string indices. Use Intl.Segmenter to count if you are near the limit. The byte ceiling almost never binds first unless you are writing in a script where every character is three bytes.

Why do links and mentions need byte offsets?

This is the part that surprises everyone. Bluesky does not parse your text. If you post a bare URL it renders as literal text, not a clickable link. Mentions do not notify anyone. Hashtags do nothing.

Clickability comes from facets, an array of annotations that say "bytes 17 through 46 of this text are a link to this URI." Since the server does no parsing, the client library or your own code has to produce these ranges.

The critical detail: offsets are UTF-8 byte offsets, not character indices and not JavaScript string indices. Take this text:

🚀 Ship notes: https://example.com/changelog

text.indexOf("https://") in JavaScript returns 15, because the rocket emoji occupies two UTF-16 code units. The correct byteStart is 17, because the emoji is four bytes in UTF-8. Get this wrong and the link highlight lands a few characters off, or the request fails validation.

Compute offsets on the encoded bytes:

function linkFacets(text) {
  const bytes = new TextEncoder().encode(text);
  const decoder = new TextDecoder();
  const facets = [];
  const pattern = /https?:\/\/[^\s\)\]]+[^\s\.\,\)\]]/g;

  for (const match of text.matchAll(pattern)) {
    // Re-measure by encoding the prefix, never by using match.index directly.
    const prefixBytes = new TextEncoder().encode(text.slice(0, match.index)).length;
    const matchBytes = new TextEncoder().encode(match[0]).length;
    facets.push({
      index: { byteStart: prefixBytes, byteEnd: prefixBytes + matchBytes },
      features: [{ $type: "app.bsky.richtext.facet#link", uri: match[0] }]
    });
  }
  return facets;
}

There are three facet feature types:

Feature typePayload fieldNotes
app.bsky.richtext.facet#linkuriThe URI can differ from the visible text, which lets you show a shortened label
app.bsky.richtext.facet#mentiondidRequires resolving the handle first, a raw @handle in text does nothing
app.bsky.richtext.facet#tagtagSend the tag without the leading #

Mentions need an extra call. Resolve the handle to a DID with com.atproto.identity.resolveHandle?handle=alice.bsky.social, then put that DID in the facet. Cache the result, because handles rarely change and the lookup costs you a round trip on every post.

Facets must not overlap, and they must be sorted by byteStart. If you are building facets from multiple detectors, links plus mentions plus tags, merge and sort them before sending, and drop any that collide.

If all of this feels like busywork, the official @atproto/api package includes a RichText class that runs detection and produces the facet array for you. Hand rolling is worth understanding once, since it explains a whole class of "why is my link not clickable" bugs, but production code should usually use the library.

How do you attach images, link cards, and quote posts?

Media is a two step process. Upload the bytes as a blob, then reference the returned blob in the record's embed field.

curl -X POST https://bsky.social/xrpc/com.atproto.repo.uploadBlob \
  -H "Authorization: Bearer $ACCESS_JWT" \
  -H "Content-Type: image/jpeg" \
  --data-binary @chart.jpg

You send the raw bytes with the correct Content-Type, not multipart form data. The response contains a blob object with a ref, mimeType, and size. Pass that object through verbatim into the embed.

"embed": {
  "$type": "app.bsky.embed.images",
  "images": [
    {
      "image": { "$type": "blob", "ref": { "$link": "bafkrei..." }, "mimeType": "image/jpeg", "size": 482913 },
      "alt": "Line chart of weekly signups, rising from 40 to 110",
      "aspectRatio": { "width": 1600, "height": 900 }
    }
  ]
}

Four practical constraints. A post carries at most four images. Image blobs on bsky.social have a size ceiling around one megabyte, so resize and re-encode before uploading rather than after getting a BlobTooLarge error. alt text is optional to the schema but you should always write it, both because Bluesky's culture strongly expects it and because alt text is real content. aspectRatio is optional and prevents layout shift while the image loads.

Blobs are also temporary until referenced. A blob you upload and never attach to a record gets garbage collected, so upload and create in the same run.

Link cards are not automatic either. Bluesky does not crawl your URLs and build a preview. You build the card yourself:

"embed": {
  "$type": "app.bsky.embed.external",
  "external": {
    "uri": "https://example.com/changelog",
    "title": "Changelog: August release",
    "description": "Facet handling, threadgates, and a smaller image pipeline.",
    "thumb": { "$type": "blob", "ref": { "$link": "bafkrei..." }, "mimeType": "image/jpeg", "size": 91230 }
  }
}

That means fetching the target page, reading its Open Graph tags, downloading the OG image, and uploading it as a blob. It is more work than you expect, and it is why so many programmatic Bluesky posts show a bare link with no card.

Quote posts use app.bsky.embed.record with the uri and cid of the post you are quoting. To quote a post and attach your own image at the same time, wrap both in app.bsky.embed.recordWithMedia. Video uses app.bsky.embed.video and runs through a separate processing job, so treat it as asynchronous rather than assuming the blob is ready the moment upload returns.

How do you build threads and control replies?

Replies carry two strong references, and the distinction between them causes more broken threads than anything else in the API.

"reply": {
  "root":   { "uri": "at://.../app.bsky.feed.post/3kv8xq2mnop2k", "cid": "bafyreib2..." },
  "parent": { "uri": "at://.../app.bsky.feed.post/3kv8yr9stuv4m", "cid": "bafyreic7..." }
}

parent is the post you are directly answering. root is the first post in the entire thread. For the second post in a thread these are the same record. For the third and beyond, parent advances while root stays pinned to the original. If you copy the parent into the root field on every reply, clients will render your thread as a set of disconnected fragments.

So a five post thread is a simple loop: create post one, keep its uri and cid as both root and parent, then for each subsequent post send the stored root plus the previous post's reference as parent, and update parent from each response.

There is one more record worth knowing. app.bsky.feed.threadgate restricts who may reply: nobody, mentioned accounts only, followed accounts only, or members of specific lists. The gate is a separate record whose record key must exactly match the record key of the post it governs. Create the post, take the rkey from the returned URI, then write the threadgate with rkey set to that same value.

What are the rate limits and how should errors be handled?

Bluesky publishes limits at two layers: request counts on specific endpoints, and a points budget on repository writes. The figures below reflect what the protocol documentation describes, and you should confirm them against the current docs before designing around a hard number.

LimitScopePublished value
Global requestsPer IP address3,000 per 5 minutes
createSessionPer account30 per 5 minutes, 300 per day
Repo write pointsPer DID5,000 per hour, 35,000 per day
Create record costPer write3 points
Update record costPer write2 points
Delete record costPer write1 point

The points model is the one that matters for publishing. At three points per create, an hourly budget of 5,000 points allows well over a thousand new posts per hour, which no legitimate publishing workflow approaches. The session limit is the one people actually hit, because a script that authenticates fresh on every run and executes every few minutes will exhaust 300 sessions per day. Cache your session and refresh it instead of re-authenticating.

When you exceed a limit you get HTTP 429 with RateLimitExceeded and headers including ratelimit-remaining and ratelimit-reset. Read ratelimit-reset, which is a Unix timestamp, and sleep until then rather than retrying blindly.

Error responses share a consistent shape: an HTTP status plus a JSON body with error and message. The ones you will meet most often:

  • ExpiredToken on a 400, meaning refresh and retry once. Do not loop.
  • InvalidToken on a 400, usually a revoked app password or a token from a different PDS.
  • InvalidRequest on a 400, almost always lexicon validation. The message names the offending field.
  • BlobTooLarge when an image exceeds the size ceiling.
  • UpstreamFailure or a 502, which is transient. Retry with exponential backoff and jitter.

Make every write idempotent from your side. The API has no idempotency key, so if a create times out you cannot tell whether it landed. Record an outbound identifier in your own store before the request, and check com.atproto.repo.listRecords for a matching post before retrying. Duplicate posts are the most common visible failure of homegrown publishing scripts.

How do you keep this running without babysitting a script?

A single-file script is a fine way to learn the Bluesky API. It is a poor way to run a publishing schedule. The gap between working code and reliable publishing is not the API, it is the operational surface around it: token rotation that survives restarts, retry logic that does not duplicate, a queue that recovers when your host was asleep at the scheduled minute, per-network character limits, and some way to see what actually went out.

Then multiply that by every network you post to. Bluesky wants facets. Mastodon wants a status field and an idempotency header, covered in our Mastodon posting automation guide. Threads uses a two step container-then-publish flow, described in the Threads API posting guide. Each one has a distinct auth model, a distinct media pipeline, and a distinct length ceiling. The shared logic between them is smaller than it looks, which is why cross-posting tools exist and why teams that start with one script per network eventually consolidate. If you are deciding between building and buying, the tradeoffs are laid out in our overview of automated social media posting and the comparison of social media scheduling tools.

Skopx approaches this from a different direction. Social Autopilot generates content per batch and adapts each piece to the character limit of its destination, then publishes to LinkedIn, Facebook Pages, Reddit, Instagram, X, Threads, Bluesky, Mastodon, Telegram, Discord, an email newsletter through your own Resend account, and the Skopx community feed. Posts are spread across the day rather than fired simultaneously, failures surface with a reason and a retry, and a missed window expires instead of firing hours late into an empty timeline. Skopx connects nearly 1,000 business tools and runs on your own model key with zero markup or an included AI allowance, with plans at $5 per month for Solo and $16 per seat per month for Team. SOC 2 controls are in place.

If you have already built the script, keep it. Understanding facets and strong references will make you better at debugging whatever you use. Just be honest about the maintenance you are signing up for when the same logic has to hold across eleven destinations.

Frequently Asked Questions

Do I need approval or a developer account to use the Bluesky API?

No. Unlike most social platforms there is no application form, no review process, and no waiting period. Create an app password in your account settings and you can post within a few minutes. This is a deliberate consequence of the AT Protocol design: your repository is yours, and writing to it does not require anyone's permission. Third party apps that authenticate other users should use AT Protocol OAuth rather than collecting app passwords, but that is a client registration concern, not an approval gate.

Can I schedule posts through the Bluesky API?

Not natively. There is no scheduling parameter, and setting createdAt to a future timestamp does not delay anything, it just publishes immediately with a misleading timestamp. Scheduling has to live in your infrastructure: a job store holding queued content, a worker that wakes on time, and an expiry rule for windows that were missed while the worker was down. That last piece is the one most homegrown schedulers skip, and it is why posts sometimes arrive six hours late.

Why is my link showing as plain text instead of a clickable link?

Because you did not send a facet, or you sent one with the wrong offsets. Bluesky performs no text parsing on the server. Every link, mention, and hashtag needs an explicit facet with byteStart and byteEnd measured in UTF-8 bytes. If your text contains an emoji or any non-ASCII character before the link, JavaScript string indices will not match byte offsets and the highlight will land in the wrong place. Encode the prefix with TextEncoder to get the real byte length, or use the RichText helper in @atproto/api.

How long do access tokens last, and what happens when they expire?

Access tokens are short lived, on the order of a couple of hours. When one expires you get a 400 with ExpiredToken. Call com.atproto.server.refreshSession with the refresh token as the bearer credential to get a fresh pair. Refresh tokens rotate on every use, so persist the new one immediately and treat the old one as dead. Do not re-run createSession on every expiry, since that endpoint is rate limited to 300 calls per day per account and a frequently scheduled script will burn through that.

What is the actual character limit for a Bluesky post?

300 graphemes, with a secondary ceiling of 3000 bytes. Graphemes are user-perceived characters, so a flag emoji or a multi-part family emoji counts as one even though it spans several code points. Counting with String.length in JavaScript will overcount and cause you to truncate posts that would have fit. Use Intl.Segmenter with granularity set to grapheme for an accurate count. The byte limit only becomes the binding constraint for text in scripts where characters encode to three or four bytes each.

Can I delete or edit a post through the API?

You can delete with com.atproto.repo.deleteRecord, passing your repo DID, the app.bsky.feed.post collection, and the record key from the post URI. There is no edit. Posts are immutable records, so a correction means deleting and re-creating, which produces a new URI and breaks any replies or quotes pointing at the original. This is a strong argument for validating content before it goes out rather than fixing it afterward, especially in an automated pipeline where nobody is watching the timeline.

Share this article

Skopx Team

The Skopx engineering and product team

Related Articles

Stay Updated

Get the latest insights on AI-powered code intelligence delivered to your inbox.