# Bluesky

Connect Bluesky with an app password, publish text, image, video and thread posts, tag the post language, and answer DMs and comments.

Bluesky connects with an app password instead of OAuth, then behaves like any other account: you publish with `POST /v1/posts` using the platform id `bluesky`, and the same account shows up in the [Inbox](https://pozy.ai/docs/inbox) for DMs and comments.

## What you can do

| Feature | Supported | Where |
| --- | --- | --- |
| Text, image and video posts | Yes | [Post types](#post-types) |
| Threads | Yes, with `threadItems` | [Threads](#threads) |
| Post language tags | Yes, per post or as an account default | [Post language](#post-language) |
| Scheduling | Yes | [Scheduling](https://pozy.ai/docs/publishing/scheduling) |
| Editing a published post | No | Bluesky has no post editing |
| DMs | Text only | [Inbox](#inbox) |
| Comments | List, reply, delete, like | [Inbox](#inbox) |
| Analytics | Likes, comments, reposts | [Analytics](#analytics) |

## Connect

The user creates an app password in Bluesky (Settings, Privacy and security, App passwords) and gives you their handle. Custom-domain handles such as `brand.com` work as well as `brand.bsky.social`. An app password looks like `xxxx-xxxx-xxxx-xxxx` and never exposes the main account password. The user can revoke it in Bluesky at any time.

Send both to [Connect Bluesky account](https://pozy.ai/docs/api/connectBlueskyCredentials). `state` is `{userId}-{profileId}`, where `userId` is your user id from [GET /v1/users](https://pozy.ai/docs/api/listUsers) and `profileId` is the profile that should own the account. Sending only the profile id also works.

```bash cURL
curl -X POST https://api.pozy.ai/v1/connect/bluesky/credentials \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "identifier": "acme.bsky.social",
    "appPassword": "abcd-efgh-ijkl-mnop",
    "state": "66a0e8b1c2d3e4f5a6b7c8d9-66a1f0c2a4b9d3e8f1a2b3c4"
  }'
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/connect/bluesky/credentials", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    identifier: "acme.bsky.social",
    appPassword: "abcd-efgh-ijkl-mnop",
    state: "66a0e8b1c2d3e4f5a6b7c8d9-66a1f0c2a4b9d3e8f1a2b3c4",
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.account._id);
```
```python Python
import os, requests

resp = requests.post(
    "https://api.pozy.ai/v1/connect/bluesky/credentials",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    json={
        "identifier": "acme.bsky.social",
        "appPassword": "abcd-efgh-ijkl-mnop",
        "state": "66a0e8b1c2d3e4f5a6b7c8d9-66a1f0c2a4b9d3e8f1a2b3c4",
    },
)
print(resp.json()["account"]["_id"])
```

> **Warning:**
> 
> Collect the app password over HTTPS and send it straight to Pozy. Do not store it yourself. If the user pastes their main password instead, the connection fails. See [Credentials flows](https://pozy.ai/docs/connect/credentials) for the pattern and for building the form.

There are no OAuth scopes on Bluesky. What the account can do is whatever the app password allows.

## Post types

A post with only `content` is a text post. Add `mediaItems` for an image or video post.

```bash cURL
curl -X POST https://api.pozy.ai/v1/posts \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "New release notes are up: https://example.com/changelog",
    "mediaItems": [{ "type": "image", "url": "https://pozy.ai/media/release.png", "altText": "The new settings screen" }],
    "platforms": [{ "platform": "bluesky", "accountId": "66b2e19d8c3f5a7e9d0b1c2d" }],
    "publishNow": true
  }'
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/posts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    content: "New release notes are up: https://example.com/changelog",
    mediaItems: [{ type: "image", url: "https://pozy.ai/media/release.png", altText: "The new settings screen" }],
    platforms: [{ platform: "bluesky", accountId: "66b2e19d8c3f5a7e9d0b1c2d" }],
    publishNow: true,
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.post.platforms[0].platformPostUrl);
```
```python Python
resp = requests.post(
    "https://api.pozy.ai/v1/posts",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    json={
        "content": "New release notes are up: https://example.com/changelog",
        "mediaItems": [{"type": "image", "url": "https://pozy.ai/media/release.png", "altText": "The new settings screen"}],
        "platforms": [{"platform": "bluesky", "accountId": "66b2e19d8c3f5a7e9d0b1c2d"}],
        "publishNow": True,
    },
)
print(resp.json()["post"]["platforms"][0]["platformPostUrl"])
```

Pozy turns plain text into Bluesky's rich-text links for you:

- `@handle.bsky.social` becomes a mention.
- `#tag` becomes a clickable hashtag.
- A URL becomes a link, and Bluesky builds a preview card from the page's Open Graph tags. Put the URL at the end of the post and check the page has those tags.

> **Cross-posting:**
> 
> Bluesky's 300-character limit is strict and counts URLs and mentions. When the same post goes to platforms with longer limits, set `customContent` on the Bluesky entry with a shorter version. Otherwise only the Bluesky part fails.

### Threads

`platformSpecificData.threadItems` publishes a chain. The first item is the root post, each later item replies to the one before. Every item has its own text, its own media and its own 300-character limit. When `threadItems` is set, the top-level `content` is kept for display and search in Pozy but is not published.

```json
{
  "platform": "bluesky",
  "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
  "platformSpecificData": {
    "threadItems": [
      { "content": "Three things we changed in our API this quarter", "mediaItems": [{ "type": "image", "url": "https://pozy.ai/media/api.png" }] },
      { "content": "1. Cursor pagination on every list endpoint." },
      { "content": "2. Idempotency keys on every create." },
      { "content": "3. One error format everywhere." }
    ]
  }
}
```

[Threads](https://pozy.ai/docs/publishing/threads) covers threads across platforms.

### Post language

`langs` tags a post with 1 to 3 BCP-47 codes, such as `en` or `pt-BR`. Bluesky's language feeds filter on it, so an untagged post never shows up in them. Set it per post, or set an account default that applies whenever a post has none:

```bash cURL
curl -X PATCH https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/bluesky-settings \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "defaultLangs": ["pt"] }'
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/bluesky-settings", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ defaultLangs: ["pt"] }),
});
if (!res.ok) throw new Error((await res.json()).error);
```
```python Python
requests.patch(
    "https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/bluesky-settings",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    json={"defaultLangs": ["pt"]},
)
```

A per-post `langs` always wins. Send `"defaultLangs": null` to clear the default. Every item of a thread gets the same languages.

| Field | Type | Notes |
| --- | --- | --- |
| `langs` | array of string | 1 to 3 BCP-47 codes. Set at creation only. |
| `threadItems` | array of `{ content, mediaItems }` | The whole thread, root first. |

## Media requirements

| Images | Requirement |
| --- | --- |
| Per post | Up to 4 |
| Formats | JPEG, PNG, WebP, GIF |
| Max size | 1 MB per image. Larger images are compressed, which can cost quality |
| Max dimensions | 2000 x 2000 px |
| Recommended | 1200 x 675 px (16:9), 1000 x 1000 px (1:1) or 800 x 1000 px (4:5) |
| Alt text | `altText` on each media item |

| Videos | Requirement |
| --- | --- |
| Per post | 1 |
| Format | MP4 only |
| Max size | 50 MB |
| Max duration | 60 seconds |
| Max dimensions | 1920 x 1080 px |
| Recommended | 1280 x 720 px, 30 fps, H.264 video, AAC audio |

Videos outside these limits are rejected, not compressed. Compress large images yourself if quality matters. Hosting rules are on [Media](https://pozy.ai/docs/publishing/media).

## Limits

| Limit | Value |
| --- | --- |
| Characters per post | 300, including URLs and mentions |
| Characters per thread item | 300 each |
| Images | 4 per post |
| Videos | 1 per post |
| Edits after publishing | Not possible |

Not available through Bluesky's API: lists and starter packs, custom feeds, pinning a post to the profile, content warnings and labels, DM attachments, and follower or profile analytics.

## Analytics

[Get post analytics](https://pozy.ai/docs/api/getAnalytics) with `platform=bluesky` returns engagement counts only:

| Metric | Field |
| --- | --- |
| Likes | `likes` |
| Replies | `comments` |
| Reposts | `shares` |

Bluesky does not report impressions, reach, clicks or views, so those fields are empty for Bluesky posts.

## Inbox

| Feature | Supported |
| --- | --- |
| List and read conversations | Yes |
| Send DMs | Text only (Bluesky's chat API has no attachments) |
| Archive and unarchive | Yes |
| List comments on your posts | Yes |
| Reply to and delete comments | Yes |
| Like and unlike a comment | Yes |

To like a comment, pass the comment's `cid` from the comments endpoints to [Like comment](https://pozy.ai/docs/api/likeInboxComment). Keep the `likeUri` it returns, because [Unlike comment](https://pozy.ai/docs/api/unlikeInboxComment) needs it. See [Messages](https://pozy.ai/docs/inbox/messages) and [Comments](https://pozy.ai/docs/inbox/comments).

## Errors you may see

| Error | Cause | Fix |
| --- | --- | --- |
| "Bluesky posts cannot exceed 300 characters" | `content` is over the limit | Shorten it, or set `customContent` on the Bluesky entry |
| "Thread item N exceeds 300 characters" | One thread item is too long | Split it into more items |
| Invalid app password on connect | The main password was used, or the app password was revoked | Create a new app password and connect again |
| Image too large | An image is over 1 MB | Compress it before upload to control quality |
| "Publishing failed due to max retries reached" | Bluesky kept refusing the request | Usually temporary. Retry the post |

Check `post.status` and `platforms[].errorMessage` in the create response. A post can be accepted by Pozy and still fail on Bluesky. [Errors](https://pozy.ai/docs/getting-started/errors) has the error format.

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`POST /v1/connect/bluesky/credentials`](https://pozy.ai/docs/api/connectBlueskyCredentials) | Connect Bluesky account |
| [`GET /v1/accounts/{accountId}/bluesky-settings`](https://pozy.ai/docs/api/getBlueskySettings) | Get Bluesky account settings |
| [`PATCH /v1/accounts/{accountId}/bluesky-settings`](https://pozy.ai/docs/api/updateBlueskySettings) | Update Bluesky account settings |
| [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) | Create post |
| [`GET /v1/analytics`](https://pozy.ai/docs/api/getAnalytics) | Get post analytics |
| [`GET /v1/inbox/conversations`](https://pozy.ai/docs/api/listInboxConversations) | List conversations |
| [`POST /v1/inbox/conversations/{conversationId}/messages`](https://pozy.ai/docs/api/sendInboxMessage) | Send message |
| [`GET /v1/inbox/comments`](https://pozy.ai/docs/api/listInboxComments) | List commented posts |
| [`POST /v1/inbox/comments/{postId}`](https://pozy.ai/docs/api/replyToInboxPost) | Reply to comment |
| [`POST /v1/inbox/comments/{postId}/{commentId}/like`](https://pozy.ai/docs/api/likeInboxComment) | Like comment |
| [`DELETE /v1/inbox/comments/{postId}/{commentId}/like`](https://pozy.ai/docs/api/unlikeInboxComment) | Unlike comment |

## Webhook events

| Event | When it fires |
| --- | --- |
| [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. |
| [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. |
| [`post.platform.published`](https://pozy.ai/docs/webhooks/events#post-platform-published) | Post platform published. |
| [`post.platform.failed`](https://pozy.ai/docs/webhooks/events#post-platform-failed) | Post platform failed. |
| [`post.platform.deleted`](https://pozy.ai/docs/webhooks/events#post-platform-deleted) | Post platform deleted. |
| [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. |
| [`comment.received`](https://pozy.ai/docs/webhooks/events#comment-received) | Comment received. |
