Zernio
Zernio
Overview

Social Posting

XInstagramFacebookLinkedInTikTokYouTubePinterestRedditBlueskyThreadsGoogle Business ProfileSnapchat

Messaging

WhatsAppTelegramDiscordSlack

Telephony

Phone NumbersVoice & CallsSMS

Advertising

Meta AdsGoogle AdsLinkedIn AdsTikTok AdsPinterest AdsX AdsOpenAI Ads

Commerce

Shopify
Dashboard
llms.txtOpenAPI
OverviewPlatformsAPI ReferenceResources

Bluesky

Publish text, image, video and thread posts to Bluesky with the Zernio API, connected with an app password instead of OAuth.


Publish text, image, video and thread posts to Bluesky with POST /v1/posts and platform: "bluesky". Bluesky connects with an app password instead of OAuth, and the same account also serves DMs and comments.

Quick reference

PropertyValue
Character limit300 (hard limit)
Images per post4
Videos per post1
Image formatsJPEG, PNG, WebP, GIF
Image max size1 MB (auto-compressed)
Video formatMP4 only
Video max size50 MB
Video max duration60 seconds
Post typesText, Image, Video, Thread
SchedulingYes
Inbox (DMs)Yes (text only)
Inbox (comments)Yes
AnalyticsLimited (likes, comments, reposts)

Before you start

Bluesky requires an app password for the account, created in Bluesky's settings, and a handle. Custom domain handles work (brand.com as well as brand.bsky.social).

The 300-character count includes text, URLs and mentions. When you cross-post from a platform with a longer limit, set customContent on the Bluesky entry with a shorter version, or the Bluesky entry fails.

Connect

Call POST /v1/connect/bluesky/credentials with the handle, the app password and state on Connect Bluesky account. state is {userId}-{profileId}: userId is currentUserId from GET /v1/users, profileId from GET /v1/profiles. The full sample is under Bluesky in the connecting accounts guide.

curl -X POST https://zernio.com/api/v1/connect/bluesky/credentials \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "identifier": "yourhandle.bsky.social",
    "appPassword": "xxxx-xxxx-xxxx-xxxx",
    "state": "66a0e8b1c2d3e4f5a6b7c8d9-66a1f0c2a4b9d3e8f1a2b3c4"
  }'

Response (200):

{
  "message": "Bluesky connected successfully",
  "account": {
    "platform": "bluesky",
    "username": "yourhandle.bsky.social",
    "displayName": "Your Name",
    "isActive": true
  }
}

There are no OAuth scopes. The app password grants API access to the account over the AT Protocol without exposing the main password, and the user can revoke it at any time from Bluesky's settings.

Publish

A plain post becomes a text post. Media in mediaItems makes an image or video post, and threadItems in platformSpecificData on the Bluesky entry makes a thread.

Text post

import Zernio from '@zernio/node';

const zernio = new Zernio();

const { data: published } = await zernio.posts.createPost({
  body: {
    content: 'Shipped a new feature today. Notes at https://example.com/changelog',
    platforms: [
      { platform: 'bluesky', accountId: '66b2e19d8c3f5a7e9d0b1c2d' }
    ],
    publishNow: true
  }
});

console.log(published.post.platforms[0].platformPostUrl);

Response (201):

{
  "post": {
    "_id": "65f1c0a9e2b5af0012ab34cd",
    "status": "published",
    "platforms": [
      {
        "platform": "bluesky",
        "status": "published",
        "platformPostUrl": "https://bsky.app/profile/yourhandle.bsky.social/post/..."
      }
    ]
  }
}

Every sample below changes only the mediaItems or the platforms entry of this request.

Zernio converts the text to AT Protocol facets on its own: @handle.bsky.social becomes a profile link, #hashtag a clickable hashtag, and a URL a clickable link with a preview card. Bluesky builds the link card from the target page's Open Graph tags, so put the URL at the end of the post and check that the page has them.

Image post

Up to 4 images. Alt text goes in altText on the media item:

"mediaItems": [
  { "type": "image", "url": "https://cdn.example.com/photo1.jpg", "altText": "The launch screen" },
  { "type": "image", "url": "https://cdn.example.com/photo2.jpg" },
  { "type": "image", "url": "https://cdn.example.com/photo3.jpg" },
  { "type": "image", "url": "https://cdn.example.com/photo4.jpg" }
]

Video post

One MP4 per post, at most 50 MB and 60 seconds:

"mediaItems": [
  { "type": "video", "url": "https://cdn.example.com/demo.mp4" }
]

Thread

threadItems publishes a chain of posts. The first item is the root post and every later item replies to the one before it, each with its own text and media and its own 300-character limit. When threadItems is set, the top-level content is used for display and search only and is not published, so the first post goes in threadItems[0]:

{
  "platform": "bluesky",
  "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
  "platformSpecificData": {
    "threadItems": [
      {
        "content": "A thread about building APIs",
        "mediaItems": [{ "type": "image", "url": "https://cdn.example.com/api.jpg" }]
      },
      { "content": "First, design your endpoints around resources, not actions." },
      { "content": "Second, version your API from day one." },
      { "content": "Third, document everything. Your future self will thank you." }
    ]
  }
}

Post language

langs tags the post with 1 to 3 BCP-47 codes. Bluesky feed generators filter on this field, so a post without it never appears in language-scoped feeds. It is set at creation only, because Bluesky has no post editing, and every item of a thread carries the same langs:

{
  "platform": "bluesky",
  "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
  "platformSpecificData": { "langs": ["pt", "en"] }
}

When a post has no langs, Zernio applies the account's default at publish time. Read it with Get Bluesky account settings and set it with Update Bluesky account settings; a per-post langs always wins, and the default applies to posts published after the change:

await zernio.accounts.updateBlueskySettings({
  path: { accountId: '66b2e19d8c3f5a7e9d0b1c2d' },
  body: { defaultLangs: ['pt'] }
});

Response (200), the updated settings. GET /v1/accounts/{accountId}/bluesky-settings reads the same shape back:

{
  "defaultLangs": ["pt"]
}

Send "defaultLangs": null to clear the default.

Platform fields

All fields go in platformSpecificData on the Bluesky entry.

FieldTypeDefaultDescription
langsArray<string>account default1 to 3 BCP-47 codes (pt, en-US) written to the post's langs field. Absent when neither the post nor the account sets one.
threadItemsArray<{content, mediaItems?}>The whole thread, root post first. Top-level content is then display and search only.

Media requirements

Images above these limits are compressed, and quality can drop. Videos outside them are rejected.

Images

PropertyRequirement
Max images4 per post
FormatsJPEG, PNG, WebP, GIF
Max file size1 MB per image
Max dimensions2000 x 2000 px
Recommended1200 x 675 px (16:9)
OrientationRatioDimensions
Landscape16:91200 x 675 px
Square1:11000 x 1000 px
Portrait4:5800 x 1000 px

Videos

PropertyRequirement
Max videos1 per post
FormatMP4
Max file size50 MB
Max duration60 seconds
Max dimensions1920 x 1080 px
Recommended1280 x 720 px, 16:9 or 1:1, 30 fps, H.264 video, AAC audio

Hosting rules for media URLs are in Media uploads.

Analytics

Call GET /v1/analytics?platform=bluesky (Analytics API). Bluesky exposes engagement counts only, so analytics are limited to these metrics:

MetricAvailable
Likes
Comments
Shares (reposts)

Bluesky's API does not provide impressions, reach, clicks or view counts.

const { data: analytics } = await zernio.analytics.getAnalytics({
  query: { platform: 'bluesky', fromDate: '2026-08-01', toDate: '2026-08-31' }
});

console.log(analytics.posts);

Response (200), one entry per post:

{
  "posts": [
    {
      "_id": "65f1c0a9e2b5af0012ab34cd",
      "platform": "bluesky",
      "status": "published",
      "publishedAt": "2026-08-14T10:00:05Z",
      "platformPostUrl": "https://bsky.app/profile/yourhandle.bsky.social/post/3kabc123",
      "analytics": {
        "likes": 42,
        "comments": 6,
        "shares": 11
      }
    }
  ]
}

Inbox

Bluesky supports DMs and comments. DMs are text only, because Bluesky's Chat API does not support media.

Direct messages

FeatureSupported
List conversations
Fetch messages
Send text messages
Send attachments (API limitation)
Archive and unarchive

Comments

FeatureSupported
List comments on posts
Reply to comments
Delete comments
Like comments (requires cid)
Unlike comments (requires likeUri)

Liking a comment needs its content identifier: pass the cid from the comments endpoints in the body of Like comment. Store the likeUri the response returns, because Unlike comment needs it.

Messages and Comments document the inbox endpoints.

What you cannot do

Bluesky's API does not expose:

  • Lists and starter packs
  • Custom feeds
  • Pinning a post to the profile
  • Content warnings and labels
  • DM attachments
  • Follower counts and profile analytics
  • Editing a published post

Common errors

ErrorCauseFix
"Bluesky posts cannot exceed 300 characters"content is above the 300-character hard limitShorten it to 300 characters. Use customContent when cross-posting.
"Thread item N exceeds 300 characters"One thread item is too longEvery item has its own 300-character limit. Split it into more items.
"Publishing failed due to max retries reached"Every retry failedUsually temporary. Retry the post.
App password invalidThe main account password was used, or the app password was revokedUse an app password (xxxx-xxxx-xxxx-xxxx), not the account password. Create a new one if needed.
Image too largeThe image is above 1 MBCompress the image yourself before upload to control the quality loss.

A publishNow: true post that Bluesky rejects returns 207 with post.status: "failed" and the message in platforms[].errorMessage:

{
  "message": "Post created but publishing failed",
  "error": "All platforms failed",
  "post": {
    "_id": "65f1c0a9e2b5af0012ab34cd",
    "status": "failed",
    "platforms": [
      {
        "platform": "bluesky",
        "status": "failed",
        "errorMessage": "Bluesky posts cannot exceed 300 characters"
      }
    ]
  }
}

Shorten content, or keep the long text for the other platforms and set customContent on the Bluesky entry, then create the post again. 207 is a 2xx status, so fetch(...).ok is true; branch on the status code and on post.status. Error handling covers the envelope.

Related

  • Connecting accounts: the app-password flow.
  • Create post: every field of the request.
  • Media uploads: upload images and videos instead of hosting them.
  • Analytics: post performance metrics.
  • Messages and Comments: the inbox API.
Was this page helpful?

Reddit

Publish text, link, image, gallery and native video posts to a subreddit with the Zernio API, with flairs, NSFW and spoiler tags.

Threads

Publish text, image, video and carousel posts and thread sequences to Threads with the Zernio API, with topic tags, first comments and reply management.

On this page

Quick referenceBefore you startConnectPublishText postImage postVideo postThreadPost languagePlatform fieldsMedia requirementsImagesVideosAnalyticsInboxDirect messagesCommentsWhat you cannot doCommon errorsRelated