# Bluesky API: How to Post to Bluesky Programmatically (2026) > Post to Bluesky programmatically with the AT Protocol. Covers app passwords, createSession, createRecord, rich text facets, image uploads, the real rate limits, and how to schedule posts the protocol cannot schedule itself. **Source:** https://posteverywhere.ai/blog/post-to-bluesky-api **Author:** Jamie Partridge **Published:** 2026-08-01 --- *Last updated: August 2026.* **Can you post to Bluesky with an API?** Yes. The Bluesky API is the most open of any major social platform. Bluesky runs on the [AT Protocol](https://atproto.com), which is public: there is no application form, no review process and no API key to be granted. You authenticate with an app password, call `com.atproto.repo.createRecord`, and your post is live. The one thing the protocol does **not** do is schedule, which is the gap this guide ends on. That last point is worth stating plainly because every other guide skips it. Bluesky has no native scheduling endpoint. [Bluesky's own bot documentation](https://docs.bsky.app/docs/starter-templates/bots) demonstrates automated posting with a cron job, not a scheduled-post API. If you want a post to go out at 9am Tuesday, something has to be awake at 9am Tuesday to send it, or you use a service that is. ## Table of Contents 1. [Why the Bluesky API is different](#why-the-bluesky-api-is-different) 2. [Option 1: the AT Protocol directly](#option-1-the-at-protocol-directly) 3. [Rich text, links and mentions](#rich-text-links-and-mentions) 4. [Uploading images](#uploading-images) 5. [The real rate limits](#the-real-rate-limits) 6. [Option 2: a unified API that schedules](#option-2-a-unified-api-that-schedules) 7. [Which one should you use](#which-one-should-you-use) 8. [FAQs](#faqs) ## Why the Bluesky API is different Every other platform in this series starts with an approval process. [Posting to Facebook](/blog/post-to-facebook-api) means App Review. [Posting to TikTok](/blog/post-to-tiktok-api) means an audited Content Posting API application. [Posting to Instagram](/blog/schedule-instagram-posts-api) means a Business account and a reviewed Meta app. Bluesky has none of that. The AT Protocol is an open specification, your account lives on a Personal Data Server, and the same XRPC endpoints your official client uses are available to you. That makes it the fastest platform to automate and the best one to prototype against if you are learning how programmatic posting works before tackling the gated APIs. The trade-off is that "open" also means "unmanaged". There is no vendor abstracting token refresh, no dashboard showing you why a post failed, and no scheduling layer. You build those or you buy them. ## Option 1: the AT Protocol directly ### Use an app password, not your account password Authentication is a session exchange, and the credential you use matters. Bluesky provides **App Passwords**, described in the [AT Protocol specification](https://atproto.com/specs/xrpc) as "a mechanism to reduce security risks when logging in to third-party clients and web applications". They are separate credentials with restricted permissions that block destructive actions. Never put your main account password in a script or a `.env` file. Generate an app password in Bluesky settings and use that. If it leaks, you revoke one credential instead of losing the account. ### Create a session Post `identifier` (your handle) and `password` (the app password) to `com.atproto.server.createSession`: ```bash 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"}' ``` You get back two tokens: an `accessJwt` and a `refreshJwt`. The `accessJwt` is short-lived and expires within minutes; the `refreshJwt` lasts longer and is used to mint a new access token. The specification is explicit that you should "treat the tokens as opaque string tokens: the JWT fields and semantics are not a stable part of the specification", so do not parse them to read an expiry. This is the first place a naive integration breaks. If your script calls `createSession` on every post rather than refreshing, you will hit the login limit fast (see [rate limits](#the-real-rate-limits) below). ### Create the post A Bluesky post is a record in your repository. Call `com.atproto.repo.createRecord` with the collection `app.bsky.feed.post`: ```bash curl -X POST https://bsky.social/xrpc/com.atproto.repo.createRecord \ -H "Authorization: Bearer $ACCESS_JWT" \ -H "Content-Type: application/json" \ -d '{ "repo": "yourhandle.bsky.social", "collection": "app.bsky.feed.post", "record": { "$type": "app.bsky.feed.post", "text": "Posted from the AT Protocol.", "createdAt": "2026-08-01T09:00:00.000Z" } }' ``` That is the whole thing. No review, no app ID, no scopes to request. ### The character limit is two numbers, not one Almost every guide says Bluesky posts are capped at 300 characters. That is close but not exact, and the difference bites on emoji and non-Latin scripts. The [post lexicon](https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/feed/post.json) sets two separate constraints on `text`: | Constraint | Value | What it counts | |---|---|---| | `maxGraphemes` | **300** | User-visible characters | | `maxLength` | **3000** | UTF-8 bytes | A grapheme is what a reader perceives as one character. A family emoji is one grapheme but many bytes. Validate against graphemes for the user-facing count, and be aware the byte ceiling exists so a post of 300 unusual characters cannot blow past it. ## Rich text, links and mentions This is the part that surprises people coming from other APIs: **Bluesky does not auto-link anything.** Paste a URL into the `text` field and it renders as inert text. Type `@someone` and it is not a mention. Links and mentions are **facets**, described in the [Bluesky posts guide](https://docs.bsky.app/docs/advanced-guides/posts) as "annotations that point into the text of a post". You supply byte offsets into the text plus a feature type: ```json { "text": "Scheduling for Bluesky: posteverywhere.ai", "facets": [{ "index": { "byteStart": 24, "byteEnd": 41 }, "features": [{ "$type": "app.bsky.richtext.facet#link", "uri": "https://posteverywhere.ai/bluesky-scheduler" }] }], "createdAt": "2026-08-01T09:00:00.000Z" } ``` Two traps here: **They are byte offsets, not character offsets.** `byteStart` and `byteEnd` index into the UTF-8 encoding. If your text contains an emoji or an accented character before the link, a naive character index will be wrong and the link will land on the wrong span. **Mentions need a DID, not a handle.** For `app.bsky.richtext.facet#mention` you must resolve the handle to a decentralised identifier first, via `com.atproto.identity.resolveHandle`, then put that DID in the feature. Handles can change; DIDs do not. If you use an official SDK, its rich-text helper detects links and mentions and builds the facets for you. Doing it by hand is where most first integrations produce posts with dead links. ## Uploading images Images are not embedded in the post record. As the docs put it, "Image files are referenced by posts, but are not actually included". You upload the bytes first, get a blob reference back, then attach that reference. Upload via `com.atproto.repo.uploadBlob`, then attach the returned blob in an `app.bsky.embed.images` embed. Two hard limits: - **Maximum four images per post.** - **Maximum 1,000,000 bytes per image.** That second one catches people out constantly. One megabyte is small for a modern photo, so resize and re-encode before upload rather than after a rejection. The docs also recommend stripping metadata before uploading, which is good practice for anything user-supplied. ## The real rate limits Bluesky publishes [specific rate limits](https://docs.bsky.app/docs/advanced-guides/rate-limits), and they use a points system for writes rather than a flat request count: | Operation | Points | |---|---| | CREATE | 3 | | UPDATE | 2 | | DELETE | 1 | The ceiling is **5,000 points per hour and 35,000 points per day**, which works out to at most **1,666 records per hour and 11,666 per day**. For a publishing workflow that is effectively unlimited. The limit you will actually hit is authentication. `createSession` is capped at **30 per 5 minutes and 300 per day, per account**. A script that logs in before every post runs out after 300 posts a day regardless of how much write budget it has left. Cache the session and refresh it. There is also an overall ceiling of **3,000 requests per 5 minutes** measured per IP address, which matters if you are running many accounts from one server. > **Skip the session-caching and retry code?** Our [social media API](/social-media-api) handles token refresh, retries and scheduling across Bluesky and 10 other platforms. Included on every plan from $29/mo, with a 7-day trial; a card is required and there is no free plan. > **Want the scheduling layer without building it?** [PostEverywhere](/bluesky-scheduler) publishes to Bluesky and 10 other platforms from one API, with token refresh and retries handled. [Start a 7-day trial](/pricing); a card is required and there is no free plan. ## Option 2: a unified API that schedules Everything above gets a post live *now*. If you need it live on Tuesday at 9am, the AT Protocol has no answer, and neither does any Bluesky client. You need a process that is running at that moment. You have three options: keep a cron job or worker alive yourself, use a queue with delayed jobs, or call an API that owns the schedule. Our [social media API](/social-media-api) is the third. One call, a timestamp, and a timezone: ```bash curl -X POST https://api.posteverywhere.ai/v1/posts \ -H "Authorization: Bearer $PE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Posted on schedule, from one API call.", "account_ids": [1234], "scheduled_for": "2026-08-05T09:00:00Z", "timezone": "Europe/London" }' ``` The same call reaches [all 11 platforms](/cross-posting), so a post that goes to Bluesky can go to X and Threads in the same request with per-platform copy. Token refresh, retries on a failed publish, and per-account health checks are handled rather than being your problem. If your assistant is the thing doing the posting, the same surface is exposed as MCP tools: our [Bluesky MCP server](/mcp/bluesky) page covers connecting Claude, ChatGPT or Cursor so an agent can draft and schedule Bluesky posts in plain language. ## Which one should you use | | AT Protocol direct | Unified API | |---|---|---| | Approval needed | None | None | | Scheduling | Build it yourself | Included | | Token refresh | Your code | Handled | | Other platforms | Bluesky only | 11 platforms | | Retries on failure | Your code | Handled | | Cost | Free | From $29/mo | **Use the AT Protocol directly** if Bluesky is the only platform you care about, you are publishing immediately rather than on a schedule, and you enjoy owning the plumbing. It is genuinely pleasant to work with and costs nothing. **Use a unified API** once you need posts to go out while you are asleep, you have added a second or third platform, or you have started writing your own retry logic. That is the point where the build stops being about Bluesky and starts being about infrastructure. For a broader comparison across every platform's API, see our [social media API guide](/blog/social-media-scheduling-api-guide) and our roundup of the [best social media APIs](/blog/best-social-media-apis). ## FAQs ### Does the Bluesky API require approval or an API key? No. The AT Protocol is an open specification and there is no application, review process or issued API key. You authenticate with your handle and an app password and start calling endpoints immediately. This makes Bluesky the least gated of any major platform API. ### Can you schedule Bluesky posts through the API? Not natively. The AT Protocol has no scheduled-post endpoint, and Bluesky's own bot documentation uses a cron job rather than advance scheduling. To schedule, you either run a process that is awake at the publish time or use a scheduling service that holds the post and publishes it for you. ### What is the Bluesky character limit for the API? Two limits apply to the `text` field: `maxGraphemes` of 300 and `maxLength` of 3000 bytes. The 300 grapheme count is the user-visible limit, and the byte ceiling exists so that multi-byte characters cannot exceed the record size. ### Why do links in my Bluesky posts not work? Bluesky does not auto-detect URLs. A link is only clickable if you supply a `facet` with `app.bsky.richtext.facet#link` and the byte offsets of the link text. The offsets are byte positions in the UTF-8 encoding, not character positions, which is the usual cause of links landing on the wrong span. ### How many images can I attach to a Bluesky post? Up to four, and each must be under 1,000,000 bytes. Images are uploaded separately via `com.atproto.repo.uploadBlob` and referenced in the post record rather than embedded in it. ### What are the Bluesky API rate limits? Writes use a points system: CREATE costs 3 points, UPDATE 2, DELETE 1, against 5,000 points per hour and 35,000 per day, which is roughly 1,666 records per hour. The limit most integrations actually hit is `createSession` at 30 per 5 minutes and 300 per day per account, so cache your session rather than logging in per post. ### Should I use my account password or an app password? An app password, always. They are separate credentials with restricted permissions that prevent destructive actions, and they can be revoked individually if leaked. Putting a main account password in a script or environment file risks the whole account. ### Can I post to Bluesky and X at the same time? Yes, though not through the AT Protocol, which only reaches Bluesky. A unified API can send one instruction to both, and it is worth writing the copy differently for each: the audiences overlap heavily, so identical text on both reads as automation. Our [cross-posting guide](/blog/best-cross-posting-tools) covers the approach. ## Wrap up Bluesky is the easiest major platform to post to programmatically, and that is a genuine advantage worth using. No approval, an open protocol, generous write limits and a clean record model. The three things that trip people up are all avoidable: facets are byte offsets rather than character offsets, images cap at 1,000,000 bytes and four per post, and `createSession` is far more tightly limited than posting is. Get those right and a direct integration is a comfortable afternoon's work. Scheduling is the one thing you cannot get from the protocol. If that is what you need, either keep something running or let a [Bluesky scheduler](/bluesky-scheduler) own it.