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

Limit / capabilityValue
Character limit300 graphemes
Images per post4
Image formatsjpeg, png, webp, gif
Image size1,000,000 bytes (~976 KB)
Video formatsmp4
Video size100,000,000 bytes (100 MB)
Video durationplatform-enforced (no client-side cap)
Mixed image + videoRejected — pick one per post
Alt text per item2000 graphemes
Post types supportedtext, single image, multi-image, video
SchedulingYes (via scheduledAt)
Reply / threadsYes — reply to any post via options.replyToUri + replyToCid
First commentYes (firstComment.text)
Inbox / DMNot supported in v1
AnalyticsNot supported in v1 (AT Proto exposes no impressions data)

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
curl -X POST https://api.letmepost.dev/v1/accounts/connect/bluesky/complete \
  -H "Authorization: Bearer $LMP_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "identifier": "you.bsky.social", "appPassword": "abcd-efgh-ijkl-mnop" }'
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
{
  "targets": [{ "accountId": "..." }],
  "text": "Shipping letmepost.dev"
}

Single image

single-image.json
{
  "targets": [{ "accountId": "..." }],
  "text": "Look at this",
  "media": [
    { "kind": "image", "mediaId": "med_…", "altText": "Status page screenshot" }
  ]
}

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
{
  "targets": [{ "accountId": "..." }],
  "text": "Four shots",
  "media": [
    { "kind": "image", "mediaId": "med_a…", "altText": "front" },
    { "kind": "image", "mediaId": "med_b…", "altText": "back" },
    { "kind": "image", "mediaId": "med_c…", "altText": "left" },
    { "kind": "image", "mediaId": "med_d…", "altText": "right" }
  ]
}

Video

video.json
{
  "targets": [{ "accountId": "..." }],
  "text": "demo clip",
  "media": [
    { "kind": "video", "mediaId": "med_…", "altText": "..." }
  ]
}
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
{
  "targets": [{ "accountId": "..." }],
  "text": "Main post",
  "firstComment": { "text": "Reply that drops in seconds later" }
}
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
{
  "targets": [
    {
      "accountId": "...",
      "options": {
        "platform": "bluesky",
        "replyToUri": "at://did:plc:abc/app.bsky.feed.post/3kr…",
        "replyToCid": "bafyrei…"
      }
    }
  ],
  "text": "2/ …continuing the thread"
}
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

Error ruleWhat it meansHow to fix
bluesky.text.max_graphemesPost exceeded the 300-grapheme capTrim to ≤300 graphemes.
bluesky.text.non_emptyText field was empty or whitespaceProvide non-whitespace text — Bluesky has no media-only post shape.
bluesky.media.image_video_exclusiveAttached both images and a videoSplit into two posts; AT Proto records hold one media kind per record.
bluesky.media.count_maxMore than 4 images or more than 1 videoReduce to 4 images max, or 1 video.
bluesky.media.image_size_maxImage exceeded 976 KBRe-encode under 1,000,000 bytes.
bluesky.media.mime_allowedUnsupported image/video mimeImages: jpeg/png/webp/gif. Videos: mp4.
bluesky.video.quota_exhaustedDaily video upload quota hitWait for the quota window to roll over; letmepost reads the live limit from getUploadLimits so the check is current.
bluesky.video.job_failedBluesky’s transcoder rejected the clipInspect platformResponse for the upstream reason — usually codec or duration.

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