Platforms
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 for DMs and comments.
#What you can do
| Feature | Supported | Where |
|---|---|---|
| Text, image and video posts | Yes | Post types |
| Threads | Yes, with threadItems |
Threads |
| Post language tags | Yes, per post or as an account default | Post language |
| Scheduling | Yes | Scheduling |
| Editing a published post | No | Bluesky has no post editing |
| DMs | Text only | Inbox |
| Comments | List, reply, delete, like | Inbox |
| Analytics | Likes, comments, reposts | 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. state is {userId}-{profileId}, where userId is your user id from GET /v1/users and profileId is the profile that should own the account. Sending only the profile id also works.
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"
}'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);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"])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 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.
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
}'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);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.socialbecomes a mention.#tagbecomes 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.
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.
{
"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 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:
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"] }'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);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.
#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 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. Keep the likeUri it returns, because Unlike comment needs it. See Messages and 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 has the error format.
#Endpoints
| Endpoint | What it does |
|---|---|
POST /v1/connect/bluesky/credentials |
Connect Bluesky account |
GET /v1/accounts/{accountId}/bluesky-settings |
Get Bluesky account settings |
PATCH /v1/accounts/{accountId}/bluesky-settings |
Update Bluesky account settings |
POST /v1/posts |
Create post |
GET /v1/analytics |
Get post analytics |
GET /v1/inbox/conversations |
List conversations |
POST /v1/inbox/conversations/{conversationId}/messages |
Send message |
GET /v1/inbox/comments |
List commented posts |
POST /v1/inbox/comments/{postId} |
Reply to comment |
POST /v1/inbox/comments/{postId}/{commentId}/like |
Like comment |
DELETE /v1/inbox/comments/{postId}/{commentId}/like |
Unlike comment |
#Webhook events
| Event | When it fires |
|---|---|
post.published |
Post published. |
post.failed |
Post failed. |
post.platform.published |
Post platform published. |
post.platform.failed |
Post platform failed. |
post.platform.deleted |
Post platform deleted. |
message.received |
Message received. |
comment.received |
Comment received. |