RelayAPI

Bluesky API

Schedule and automate Bluesky posts with RelayAPI — text posts, images, videos, threads, and auto-detected rich text.

Quick Reference

PropertyValue
Platform keybluesky
Auth methodApp Password (not OAuth)
Character limit300 (hard limit)
Alt text limit1,000 characters per image
Images per post4
Videos per post1
Image formatsJPEG, PNG
Image max size1 MB (strict)
Video formatMP4 only
Video max size100 MB
Video max duration60 seconds
Post typesText, Image, Video, Thread
SchedulingYes
AnalyticsLimited (likes, comments, shares)

SDK sourceTypeScript · Python · Go · Java · REST API

Before You Start

Bluesky has a 300-character hard limit — this is the #1 cause of failed posts when cross-posting from other platforms. If you are cross-posting from Twitter (280), LinkedIn (3,000), or Facebook (63,000), always use target_options.bluesky.content to provide a version under 300 characters. Images have a strict 1 MB size limit — most phone photos are 3-5 MB and must be compressed before upload. Bluesky uses App Passwords instead of OAuth.

Quick Start

Post to Bluesky:

import Relay from '@relayapi/sdk';

const client = new Relay();
const post = await client.posts.create({
  content: 'Hello from RelayAPI!',
  targets: ['bluesky'],
  scheduled_at: 'now',
});

console.log(post.id); // post_abc123
from relay import Relay

client = Relay()
post = client.posts.create(
    content='Hello from RelayAPI!',
    targets=['bluesky'],
    scheduled_at='now',
)

print(post.id)  # post_abc123
client := relaygo.NewClient()

post, err := client.Posts.New(context.TODO(), relaygo.PostNewParams{
    Content:     relaygo.F("Hello from RelayAPI!"),
    Targets:     relaygo.F([]string{"bluesky"}),
    ScheduledAt: relaygo.F("now"),
})

fmt.Println(post.ID) // post_abc123
RelayClient client = RelayOkHttpClient.fromEnv();

PostCreateResponse post = client.posts().create(PostCreateParams.builder()
    .content("Hello from RelayAPI!")
    .addTarget("bluesky")
    .scheduledAt("now")
    .build());

System.out.println(post.id()); // post_abc123
curl -X POST https://api.relayapi.dev/v1/posts \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Hello from RelayAPI!",
    "targets": ["bluesky"],
    "scheduled_at": "now"
  }'

Content Types

Text Post

Simple text post. Hard limit of 300 characters.

const post = await client.posts.create({
  content: 'Hello from RelayAPI!',
  targets: ['bluesky'],
  scheduled_at: 'now',
});
post = client.posts.create(
    content='Hello from RelayAPI!',
    targets=['bluesky'],
    scheduled_at='now',
)
post, err := client.Posts.New(context.TODO(), relaygo.PostNewParams{
    Content:     relaygo.F("Hello from RelayAPI!"),
    Targets:     relaygo.F([]string{"bluesky"}),
    ScheduledAt: relaygo.F("now"),
})
PostCreateResponse post = client.posts().create(PostCreateParams.builder()
    .content("Hello from RelayAPI!")
    .addTarget("bluesky")
    .scheduledAt("now")
    .build());
curl -X POST https://api.relayapi.dev/v1/posts \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Hello from RelayAPI!",
    "targets": ["bluesky"],
    "scheduled_at": "now"
  }'

Image Post (Up to 4)

Each image must be under 1 MB. Images are auto-compressed by the platform but quality may degrade.

const post = await client.posts.create({
  content: 'Check out these photos!',
  targets: ['bluesky'],
  media: [
    { url: 'https://cdn.example.com/photo1.jpg', type: 'image' },
    { url: 'https://cdn.example.com/photo2.jpg', type: 'image' },
  ],
  scheduled_at: 'now',
});
post = client.posts.create(
    content='Check out these photos!',
    targets=['bluesky'],
    media=[
        {'url': 'https://cdn.example.com/photo1.jpg', 'type': 'image'},
        {'url': 'https://cdn.example.com/photo2.jpg', 'type': 'image'},
    ],
    scheduled_at='now',
)
post, err := client.Posts.New(context.TODO(), relaygo.PostNewParams{
    Content: relaygo.F("Check out these photos!"),
    Targets: relaygo.F([]string{"bluesky"}),
    Media: relaygo.F([]relaygo.PostNewParamsMedia{
        {URL: relaygo.F("https://cdn.example.com/photo1.jpg"), Type: relaygo.F(relaygo.PostNewParamsMediaTypeImage)},
        {URL: relaygo.F("https://cdn.example.com/photo2.jpg"), Type: relaygo.F(relaygo.PostNewParamsMediaTypeImage)},
    }),
    ScheduledAt: relaygo.F("now"),
})
PostCreateResponse post = client.posts().create(PostCreateParams.builder()
    .content("Check out these photos!")
    .addTarget("bluesky")
    .addMedia(PostCreateParams.Media.builder()
        .url("https://cdn.example.com/photo1.jpg")
        .type(PostCreateParams.Media.Type.IMAGE)
        .build())
    .addMedia(PostCreateParams.Media.builder()
        .url("https://cdn.example.com/photo2.jpg")
        .type(PostCreateParams.Media.Type.IMAGE)
        .build())
    .scheduledAt("now")
    .build());
curl -X POST https://api.relayapi.dev/v1/posts \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Check out these photos!",
    "targets": ["bluesky"],
    "media": [
      {"url": "https://cdn.example.com/photo1.jpg", "type": "image"},
      {"url": "https://cdn.example.com/photo2.jpg", "type": "image"}
    ],
    "scheduled_at": "now"
  }'

Video Post

MP4 only, max 100 MB, max 60 seconds.

const post = await client.posts.create({
  content: 'New product demo!',
  targets: ['bluesky'],
  media: [
    { url: 'https://cdn.example.com/demo.mp4', type: 'video' },
  ],
  scheduled_at: 'now',
});
post = client.posts.create(
    content='New product demo!',
    targets=['bluesky'],
    media=[
        {'url': 'https://cdn.example.com/demo.mp4', 'type': 'video'},
    ],
    scheduled_at='now',
)
post, err := client.Posts.New(context.TODO(), relaygo.PostNewParams{
    Content: relaygo.F("New product demo!"),
    Targets: relaygo.F([]string{"bluesky"}),
    Media: relaygo.F([]relaygo.PostNewParamsMedia{
        {URL: relaygo.F("https://cdn.example.com/demo.mp4"), Type: relaygo.F(relaygo.PostNewParamsMediaTypeVideo)},
    }),
    ScheduledAt: relaygo.F("now"),
})
PostCreateResponse post = client.posts().create(PostCreateParams.builder()
    .content("New product demo!")
    .addTarget("bluesky")
    .addMedia(PostCreateParams.Media.builder()
        .url("https://cdn.example.com/demo.mp4")
        .type(PostCreateParams.Media.Type.VIDEO)
        .build())
    .scheduledAt("now")
    .build());
curl -X POST https://api.relayapi.dev/v1/posts \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "New product demo!",
    "targets": ["bluesky"],
    "media": [
      {"url": "https://cdn.example.com/demo.mp4", "type": "video"}
    ],
    "scheduled_at": "now"
  }'

Thread

Create a thread of connected posts. Each item has its own 300-character limit.

const post = await client.posts.create({
  targets: ['bluesky'],
  scheduled_at: 'now',
  target_options: {
    bluesky: {
      thread: [
        {
          content: 'A thread about building APIs',
          media: [{ url: 'https://cdn.example.com/api.jpg', type: 'image' }],
        },
        { content: 'First, design around resources, not actions.' },
        { content: 'Second, always version from day one.' },
        { content: 'Finally, document everything!' },
      ],
    },
  },
});
post = client.posts.create(
    targets=['bluesky'],
    scheduled_at='now',
    target_options={
        'bluesky': {
            'thread': [
                {
                    'content': 'A thread about building APIs',
                    'media': [{'url': 'https://cdn.example.com/api.jpg', 'type': 'image'}],
                },
                {'content': 'First, design around resources, not actions.'},
                {'content': 'Second, always version from day one.'},
                {'content': 'Finally, document everything!'},
            ],
        },
    },
)
post, err := client.Posts.New(context.TODO(), relaygo.PostNewParams{
    Targets:     relaygo.F([]string{"bluesky"}),
    ScheduledAt: relaygo.F("now"),
    TargetOptions: relaygo.F(map[string]interface{}{
        "bluesky": map[string]interface{}{
            "thread": []map[string]interface{}{
                {"content": "A thread about building APIs",
                    "media": []map[string]interface{}{
                        {"url": "https://cdn.example.com/api.jpg", "type": "image"},
                    }},
                {"content": "First, design around resources, not actions."},
                {"content": "Second, always version from day one."},
                {"content": "Finally, document everything!"},
            },
        },
    }),
})
PostCreateResponse post = client.posts().create(PostCreateParams.builder()
    .addTarget("bluesky")
    .scheduledAt("now")
    .targetOptions(PostCreateParams.TargetOptions.builder()
        .putAdditionalProperty("bluesky", JsonValue.from(Map.of(
            "thread", List.of(
                Map.of("content", "A thread about building APIs",
                    "media", List.of(Map.of("url", "https://cdn.example.com/api.jpg", "type", "image"))),
                Map.of("content", "First, design around resources, not actions."),
                Map.of("content", "Second, always version from day one."),
                Map.of("content", "Finally, document everything!")
            )
        )))
        .build())
    .build());
curl -X POST https://api.relayapi.dev/v1/posts \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "targets": ["bluesky"],
    "scheduled_at": "now",
    "target_options": {
      "bluesky": {
        "thread": [
          {"content": "A thread about building APIs",
           "media": [{"url": "https://cdn.example.com/api.jpg", "type": "image"}]},
          {"content": "First, design around resources, not actions."},
          {"content": "Second, always version from day one."},
          {"content": "Finally, document everything!"}
        ]
      }
    }
  }'

Each thread item has its own 300-character limit. Exceeding the limit on any single item will cause that item to fail.

Rich Text (Auto-Detected)

Bluesky uses "facets" for rich text formatting. RelayAPI auto-detects and converts these for you:

  • @handle.bsky.social becomes a clickable profile link
  • #hashtag becomes a clickable hashtag search
  • URLs become clickable links with preview cards

No special formatting is needed from your side. Just write natural text and RelayAPI handles the conversion.

Connection

Bluesky uses App Passwords instead of OAuth. To connect:

  1. Go to Bluesky Settings > App Passwords
  2. Create an App Password (format: xxxx-xxxx-xxxx-xxxx)
  3. Connect via the RelayAPI dashboard or API with your handle and app password

Custom domain handles are supported (e.g., brand.com instead of brand.bsky.social).

Media Requirements

Images

PropertyRequirement
Max per post4
FormatsJPEG, PNG, WebP, GIF
Max file size1 MB per image (strict)
Max dimensions2000 x 2000 px
Recommended1200 x 675 px (16:9)
Alt textUp to 1,000 chars per image

Videos

PropertyRequirement
Max per post1
FormatMP4 only
Max file size100 MB
Max duration60 seconds
Max dimensions1920 x 1080 px
Recommended1280 x 720, 30fps, H.264, AAC

target_options Fields

All fields go inside target_options.bluesky on your post request.

FieldTypeDescription
contentstringOverride post content for Bluesky specifically. Use this to provide a version under 300 characters when cross-posting.
mediaobject[]Override media for Bluesky specifically
threadobject[]Array of {content, media?} for thread chains. Each item has its own 300-char limit.

Bluesky is one of the simplest platforms to post to. No additional platform-specific fields are required beyond content and media.

Common Errors

ErrorCauseFix
Posts cannot exceed 300 charactersContent exceeds the 300-character hard limitUse target_options.bluesky.content to provide a shorter version. This is the #1 cause of Bluesky failures.
Thread item N exceeds 300 charactersIndividual thread item is too longEach thread item has its own independent 300-character limit.
App Password invalidWrong password type or expired passwordUse an App Password (format xxxx-xxxx-xxxx-xxxx), not the main account password.
Image too largeImage exceeds the 1 MB limitCompress the image before upload. Auto-compression may degrade quality.
Publishing failedMax retries reachedUsually temporary. Wait a moment and retry manually.

Known Quirks

  • 300-character hard limit — the #1 cause of failed posts when cross-posting. Always provide a Bluesky-specific version.
  • 1 MB image limit — strictest of any platform. Most phone photos are 3-5 MB and need compression.
  • Images are auto-compressed to meet the blob size limit, which may degrade quality.
  • Link previews auto-generated when no media is attached to the post.
  • Custom domain handles supported — e.g., brand.com instead of brand.bsky.social.
  • Rich text auto-detected — mentions, hashtags, and URLs are automatically converted to clickable elements.
  • AT Protocol uses DIDs (decentralized identifiers) internally for user identity.
  • Session tokens expire — RelayAPI handles refresh automatically.

Automations

Bluesky automation uses the AT Protocol for public actions (repo.createRecord) and the Chat API for DMs.

Triggers

TypeFires on
bluesky_dmChat-API DM
bluesky_replyReply to your post
bluesky_mention@-mention
bluesky_followNew follower
bluesky_likeSomeone likes your post

Send nodes

Post-side: POST https://bsky.social/xrpc/com.atproto.repo.createRecord with app.bsky.feed.{post|like|repost}. DMs: POST https://api.bsky.chat/xrpc/chat.bsky.convo.sendMessage (after resolving the convo id via getConvoForMembers).

NodeRequired fields
bluesky_replytext, parent_uri, parent_cid
bluesky_likesubject_uri, subject_cid
bluesky_repostsubject_uri, subject_cid
bluesky_send_dmtext (contact's DID resolved from channels)

Bluesky needs both uri and cid for any reply/like/repost — the cid prevents action on replaced content. Triggers store both in the enrollment state so downstream nodes can reference state.post_uri / state.post_cid.

Found something wrong? Help us improve this page.

On this page

Submit an Issue
Requires a GitHub account.View repo