# 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](https://bsky.network/docs/about-bluesky-content/posts) ([rate limits](https://bsky.network/docs/rate-limits),
[OAuth](https://bsky.network/docs/oauth-client)), and the post and image schemas (lexicons) in Bluesky's
[atproto repository](https://github.com/bluesky-social/atproto/tree/main/packages/api/lexicons/app/bsky).

## 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](https://atproto.com/sdks)).

## Post it

### curl

```bash
# 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:

```json
{
  "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

```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)

```js
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:

```js
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 `#`.

## Photos, link cards, replies

| 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](https://bsky.network/docs/about-bluesky-content/posts) 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](/pricing)).
[Get started with Flaghand](/schedule).

## 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:

- Bluesky: [Posts in-depth](https://bsky.network/docs/about-bluesky-content/posts)
- Bluesky: [Rate limits](https://bsky.network/docs/rate-limits)
- Bluesky: [OAuth](https://bsky.network/docs/oauth-client)
- Bluesky: the [post](https://github.com/bluesky-social/atproto/blob/main/packages/api/lexicons/app/bsky/feed/post.json) and
  [images](https://github.com/bluesky-social/atproto/blob/main/packages/api/lexicons/app/bsky/embed/images.json) lexicons

[All guides](/docs/guides.md)
