Bluesky

Publish posts, threads, replies, images, videos and link cards to Bluesky (AT Protocol), answer comments, read post analytics and send direct messages through the Postgale API and MCP server.

Postgale publishes to Bluesky accounts: text posts with links, mentions and hashtags, up to 4 images or one video, link cards, threads and replies, and deletes of what was published. It also reads and answers the replies to your posts (comments), reports likes, comments and shares per post, and sends and receives text direct messages. Everything is available over the REST API (paths relative to https://staging.postgale.com/api/v1) and the MCP server (connect_account, create_post, manage_published_post, list_comments, moderate_comment, like_post, get_analytics, list_conversations, send_message, start_conversation, manage_message, bluesky_settings). Each connected Bluesky account has platform bluesky; its platformAccountId is the account's DID (did:plc:…).

Connect an account

Bluesky accounts connect with their handle and an app password. Postgale never needs the account password.

  1. In Bluesky, open Settings → Privacy and security → App passwords and choose Add App Password. Name it (e.g. Postgale), check Allow access to your direct messages if Postgale should read and send DMs (leave it off otherwise), and copy the password: it looks like xxxx-xxxx-xxxx-xxxx.
  2. In Postgale open Dashboard → Accounts → Bluesky, enter the handle (e.g. yourname.bsky.social or your own domain) and paste the app password.

Or connect from your code (the answer is the connected account; account.connected is sent as well):

POST /api/v1/connect/bluesky
curl -X POST https://staging.postgale.com/api/v1/connect/bluesky \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"handle":"yourname.bsky.social","appPassword":"xxxx-xxxx-xxxx-xxxx","externalId":"customer-42"}'

# {"platform":"bluesky","account":{"id":"…","platform":"bluesky","accountName":"@yourname.bsky.social","platformAccountId":"did:plc:…","externalId":"customer-42"}}

Postgale resolves the handle to its DID and personal data server (PDS), works on any PDS (bsky.social or self-hosted), and signs in there. The session tokens and the app password are stored encrypted; sessions are renewed automatically, and if the renewal fails Postgale signs in again with the app password. A wrong handle or password answers 400 with the reason; anything other than an app password is refused.

If you delete the app password in Bluesky (or change the account password), the next call fails: the account becomes needs_reconnect and account.disconnected is sent (reason token_invalid). Connect it again with a new app password; the same account is updated, its post history stays.

Publish

The post content is the text, up to 300 characters as Bluesky counts them (graphemes: an emoji with modifiers counts once). Links, @handle mentions and #hashtags in the text become clickable: mentions are resolved to the person's DID and left as plain text when the handle does not exist. Without media, the first link gets a link card (title, description and image from the page) when the page can be read; a card that cannot be built never fails the post.

POST /api/v1/posts
curl -X POST https://staging.postgale.com/api/v1/posts \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"content":"Our spring release is out 🌱 https://example.com/blog/spring #release","platforms":[{"accountId":"acc_bsky","platformSpecificData":{"mediaUrls":["https://media.postgale.com/…/hero.jpg"],"altTexts":["The new dashboard in dark mode"],"langs":["en"]}}],"publishNow":true}'
platformSpecificDataWhat it does
mediaUrl / mediaUrlsUp to 4 images (JPEG, PNG, WebP or GIF, each ≤1,000,000 bytes) or one MP4 video (≤100 MB, ≤3 minutes). Images and a video cannot be mixed. Public https URLs, e.g. an uploaded media publicUrl
altText / altTextsAlt text for the image(s) or the video (≤2000 characters each)
threadItemsA thread: up to 25 posts of {content, mediaUrl?, mediaUrls?, altText?, altTexts?}, each a reply to the previous one. Replaces the post text
replyToReply to an existing post: its at:// URI or a https://bsky.app/profile/<handle>/post/<id> link
langsUp to 3 language tags, e.g. ["en"]. Default: the account's defaultLangs (see Settings)
disableLinkPreviewNo link card

Limits are checked when the post is created; file sizes, types and the video length are checked again when the files are fetched, before anything is posted. The target's platformPostId is the post's at:// URI and url its https://bsky.app/profile/<handle>/post/<id> link. For a thread they are those of the first post; if a later post of the thread fails, the thread stops there and the target gets a warning (the published posts are not repeated).

Endpoints

MethodPathWhat it does
POST/posts/{id}/unpublishDelete the post from Bluesky (for a thread: the first post)
GET/accounts/{id}/healthWhether the session still works
GET / PATCH/accounts/{id}/bluesky-settingsDefault post languages (defaultLangs)
GET/inbox/comments?postId= or ?accountId=&platformPostId=Replies to a post, each with its cid
POST/inbox/commentsComment on a post: {accountId, platformPostId, text}
POST/inbox/comments/{id}/replyReply to a comment: {accountId, text}
DELETE/inbox/comments/{id}?accountId=Delete one of the account's own replies
POST / DELETE/inbox/comments/{id}/likeLike ({accountId, cid}) or unlike (?accountId=&likeUri=)
POST / DELETE/inbox/posts/{postId}/likeLike or unlike any post ({accountId}; postId: an at:// URI, URL-encoded)
GET/analytics/posts/{id}Likes, comments, shares (reposts + quotes), replies, reposts, quotes
POST/inbox/conversations/syncBackfill DM conversations: {accountId}
GET/inbox/conversations?platform=blueskyConversations, latest first
GET/inbox/conversations/{id}/messagesMessages of a conversation
POST/inbox/conversationsStart a conversation: {accountId, participantUsername | participantId, text}
POST/inbox/conversations/{id}/messagesSend a text DM: {text}
POST / DELETE/inbox/conversations/{id}/messages/{messageId}/reactionsAdd or remove an emoji reaction: {emoji} (also needed to remove)
DELETE/inbox/conversations/{id}/messages/{messageId}Delete a message for the account (the other person still sees it)
PATCH/inbox/conversations/{id}Archive or unarchive: {status: archived | active}
POST/inbox/conversations/{id}/readMark read in Postgale and on Bluesky

Every write (deletes, comments, replies, likes, DMs, reactions) is queued for a person while approval applies to the API key, the organization or the account (202 pending_approval; the MCP tools return the queued action). Bluesky posts cannot be edited.

Comments

Comments are the replies to your posts. Their id is the reply's at:// URI (URL-encode it in a path, e.g. /inbox/comments/at%3A%2F%2Fdid%3Aplc%3A…%2Fapp.bsky.feed.post%2F3k…/reply; a https://bsky.app/…/post/… link works too), parentId the post or reply it answers, and cid its content id. A top-level comment is a reply to the post itself.

Liking a comment needs its cid (Postgale looks it up when you leave it out). The answer carries likeUri: keep it to unlike. Unlike without likeUri removes the account's current like, if any.

POST /api/v1/inbox/comments/{commentId}/like
curl -X POST https://staging.postgale.com/api/v1/inbox/comments/at%3A%2F%2Fdid%3Aplc%3Aabc%2Fapp.bsky.feed.post%2F3kxyz/like \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"accountId":"acc_bsky","cid":"bafyrei…"}'

# {"commentId":"at://did:plc:abc/app.bsky.feed.post/3kxyz","liked":true,"likeUri":"at://did:plc:…/app.bsky.feed.like/3l…"}

Bluesky only lets an account delete its own replies, and has no hidden replies.

Analytics

GET /analytics/posts/{id} returns, per Bluesky target: likes, comments (replies), shares (reposts + quotes), and replies, reposts and quotes separately. Bluesky does not expose impressions, reach, clicks or views; requested metrics it cannot return are listed in unavailable. There are no account analytics (not_supported).

Direct messages

Bluesky DMs need an app password created with Allow access to your direct messages. Postgale reads the app password's access when the account connects: without it, DM calls answer 403 missing_permission before anything reaches Bluesky, and account health recommends a new app password. Connect again with one that has DM access to turn them on (settings and history stay).

  • POST /inbox/conversations/sync {accountId} backfills conversations and their latest messages; the worker then keeps them current.
  • Messages are text only, up to 1000 characters (Bluesky's chat has no attachments: platform_limitation). There is no messaging window: the person's chat settings decide who can write to them.
  • POST /inbox/conversations starts a conversation with a handle (participantUsername) or a DID (participantId).
  • Emoji reactions: POST /inbox/conversations/{id}/messages/{messageId}/reactions {emoji: "👍"}; DELETE with the same {emoji} removes it. Deleting a message removes it for the account only: Bluesky keeps it for the other person.
  • Archiving is kept in Postgale (Bluesky has no archive); a new message from the person makes the conversation active again.

Webhooks

Bluesky has no push events for these, so Postgale's worker polls each account (every 5 minutes by default):

EventWhen
comment.receivedSomeone replied to one of the account's posts (comment.cid included, postId set for posts made through Postgale)
mention.receivedSomeone mentioned the account
message.receivedA person sent the account a DM
message.sentThe account sent a DM from somewhere else (e.g. the Bluesky app); sends through Postgale are announced right away

Events start with the first poll after an account connects (nothing older is replayed). Inbound events are also listed at GET /inbox/events.

Settings

GET /accounts/{id}/bluesky-settings returns {defaultLangs}: the languages applied to posts that set no langs (null when none). PATCH it with 1-3 BCP-47 tags, or null to clear them. A post's own langs always win; published posts keep their languages (Bluesky has no edits). MCP: bluesky_settings.

PATCH /api/v1/accounts/{id}/bluesky-settings
curl -X PATCH https://staging.postgale.com/api/v1/accounts/acc_bsky/bluesky-settings \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"defaultLangs":["pt"]}'

# {"defaultLangs":["pt"]}

Bluesky's rules to know

  • Rate limits: when Bluesky answers 429, Postgale retries after the reset time Bluesky gives.
  • Revoked app passwords: an app password Bluesky refuses marks the account needs_reconnect (reconnect_required on the API) and the post is not retried automatically.
  • Analytics: per post only (likes, replies, reposts, quotes); no views, reach or account analytics.
  • Direct messages: text only, and only with an app password that allows direct messages.

On this page