Skip to main content
Before you start. Bluesky doesn’t have OAuth — you connect with an app password generated at bsky.app/settings/app-passwords. Don’t paste a main account password; the platform’s session endpoint accepts it but the publisher refuses by design.

Quick reference

Connect an account

Generate an app password at bsky.app/settings/app-passwords. The format is 19 characters with hyphens (e.g. abcd-efgh-ijkl-mnop). Submit it via the dashboard’s Accounts page or programmatically:
connect.sh
The complete response carries the id you’ll send posts against. App passwords can be revoked at any time on bsky.app — when that happens the connection breaks cleanly with platform_auth_failed. App passwords don’t have OAuth scopes; they grant the same permissions as the password itself. AT Protocol JWTs are short-lived (minutes); the publisher refreshes on every 401 so you don’t manage tokens. Subscribe to token.revoked for upstream revocations.

Post types

Text post

text.json

Single image

single-image.json

Multi-image

Up to four images per post. Mixing images and video on the same post is rejected by preflight (bluesky.media.image_video_exclusive).
multi-image.json

Video

video.json
The publisher runs Bluesky’s documented video flow transparently: mint a service-auth JWT scoped to app.bsky.video.uploadVideo, probe getUploadLimits so quota exhaustion fails loudly with bluesky.video.quota_exhausted, stream bytes to uploadVideo, poll getJobStatus until COMPLETED, then embed the blob ref as app.bsky.embed.video. Videos do not go through com.atproto.repo.uploadBlob — that’s the most common reason naive integrations break.

First comment

first-comment.json
The reply posts as a child of the main record under the same DID. If the parent publishes but the comment fails, the parent stays — the response carries a warnings[] entry with code: "first_comment_failed" instead of failing the whole call.

Reply (threading)

Thread a post under any existing Bluesky post by passing the parent’s strong ref — its uri and cid — in the target’s options. Both come straight from the parent post’s publish response.
reply.json
replyToUri and replyToCid must be sent together. To build a multi-post thread: publish post 1, read its uri + cid from the response, and pass them as post 2’s replyToUri/replyToCid. For replies deeper than the first, also pin the thread’s original post with replyRootUri + replyRootCid so every reply stays anchored to the root — omit the root and it defaults to the parent (correct for a reply to a top-level post).

Wisdom (platform-specific things that bite)

  • Mentions, hashtags, and URLs are AT Proto facets — letmepost auto-detects them from plain text and builds the facet ranges. Don’t construct facet byte offsets manually; the post will index correctly without them.
  • Bluesky’s image blob ceiling is 976 KB, not 1 MB — a 1,000,000-byte file is at the edge and re-encoded JPEGs around 950 KB are the safe zone.
  • Videos count against a daily account quota that lives entirely on Bluesky’s side. The first preflight call to getUploadLimits surfaces remaining headroom before bytes are sent — exhausted callers fail with bluesky.video.quota_exhausted and no upload happens.
  • Bluesky deduplicates videos by content hash, so re-uploading the same clip can short-circuit JOB_STATE_COMPLETED without re-transcoding.
  • The uri on the response is the AT Proto record URI (at://did:plc:abc/app.bsky.feed.post/3kr…), not a bsky.app URL. The public URL is derived from the DID + record key.
  • Bluesky has no scheduled-publish API; scheduledAt is handled by letmepost’s queue — the post fires from our worker when the time comes.

Common errors

What you can’t do (yet)

  • Starter packs, custom feeds, pin-to-profile.
  • Direct messages and DM attachments (AT Proto chat lives on a separate XRPC surface not exposed in v1).
  • Analytics — AT Proto records carry no impression count by design.
  • Polls, quote posts, and labeler interactions.
  • Editing or backdating a published record.

API reference