← All posts
Tutorials

How to Post to Bluesky With an API

Connect a Bluesky account with an app password and publish text, images, link cards and quote posts through the Relaycast API.

Relaycast Team · April 2, 2026 · 3 min read

Bluesky is one of the friendliest networks to automate. There is no developer app to register and no review to pass: you authenticate with an app password, a separate password you create in Bluesky's settings that can be revoked at any time without touching your main login.

This tutorial connects a Bluesky account to Relaycast entirely through the API, then publishes a few kinds of posts.

Before you start

You need:

  • A Relaycast project and an API key (sign up if you do not have one).
  • A Bluesky account.

Set your API URL and key as environment variables:

export RELAYCAST_API_URL="https://api.your-domain.com"
export RELAYCAST_API_KEY="your-key-here"

1. Create a Bluesky app password

In Bluesky, open Settings, find App passwords under privacy and security, and add a new one. Give it a name you will recognise, such as "Relaycast". Bluesky shows the password once; copy it.

Use an app password, not your account password. App passwords can be revoked individually, so if one ever leaks you can cut it off without changing your login.

2. Connect the account

Send your handle (or the email address on the account) as identifier, along with the app password, to POST /v1/accounts/bluesky:

curl -X POST "$RELAYCAST_API_URL/v1/accounts/bluesky" \
  -H "x-access-key: $RELAYCAST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "identifier": "yourname.bsky.social", "appPassword": "xxxx-xxxx-xxxx-xxxx" }'
const res = await fetch(`${process.env.RELAYCAST_API_URL}/v1/accounts/bluesky`, {
  method: "POST",
  headers: { "x-access-key": process.env.RELAYCAST_API_KEY!, "Content-Type": "application/json" },
  body: JSON.stringify({ identifier: "yourname.bsky.social", appPassword: process.env.BLUESKY_APP_PASSWORD }),
});
const { data: account } = await res.json();
console.log(account.id, account.handle);
import os, requests

res = requests.post(
    f"{os.environ['RELAYCAST_API_URL']}/v1/accounts/bluesky",
    headers={"x-access-key": os.environ["RELAYCAST_API_KEY"]},
    json={"identifier": "yourname.bsky.social", "appPassword": os.environ["BLUESKY_APP_PASSWORD"]},
)
account = res.json()["data"]
print(account["id"], account["handle"])

Relaycast verifies the credentials with Bluesky before saving them. If Bluesky rejects them, you get a 400 with the code invalid_credentials. On success you get 201 and the account, including its id, which is what you publish to.

Connecting the same Bluesky account again (for example with a new app password) updates the stored credentials in place, so the account ID stays the same and existing code keeps working.

You can also connect Bluesky from the dashboard under Integrations if you prefer.

3. Publish a text post

curl -X POST "$RELAYCAST_API_URL/v1/posts" \
  -H "x-access-key: $RELAYCAST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Hello Bluesky! Posted through the Relaycast API.",
    "targets": [{ "accountId": "BLUESKY_ACCOUNT_ID" }],
    "publishNow": true
  }'

Bluesky posts allow 300 characters, counted as graphemes (what a reader sees as one character, so an emoji counts once). Longer content is rejected with a validation error before anything is queued. Links, mentions and hashtags in the text are detected and turned into rich text automatically.

4. Add images or a video

Attach media with the media array. Bluesky accepts up to four images, or a single video, but not both in the same post. Alt text is passed through, and it makes your posts accessible, so include it.

{
  "content": "Three views from this morning's hike.",
  "media": [
    { "url": "https://cdn.example.com/hike-1.jpg", "type": "image", "altText": "A ridge trail above the clouds at sunrise" },
    { "url": "https://cdn.example.com/hike-2.jpg", "type": "image", "altText": "A small lake surrounded by pine trees" },
    { "url": "https://cdn.example.com/hike-3.jpg", "type": "image", "altText": "Two hikers at a summit marker" }
  ],
  "targets": [{ "accountId": "BLUESKY_ACCOUNT_ID" }],
  "publishNow": true
}

Media URLs must be publicly reachable over http(s), or reference a file in your project's media library (media://<asset-id>).

5. Use Bluesky-specific options

Bluesky options go in the target's options object. The most useful ones:

OptionWhat it does
linkCardAttach a preview card for a link when the post has no images or video. On by default.
linkUrl, linkTitle, linkDescriptionChoose the card URL (defaults to the first link in the text) and override its title or description.
tagsUp to 8 extra hashtags stored on the post but not shown in the text.
langsUp to 3 language codes, such as en or pt-BR.
contentWarningSelf-label: none, sexual, nudity, porn or graphic-media.
quoteUrlA Bluesky post to quote, as a bsky.app URL or an at:// URI.
replyControleveryone, nobody, or limited.
replyAllowWith limited: any of mentioned, followers, following.
disableQuotesPrevent others from quote-posting.

Here is a quote post that only your followers can reply to:

{
  "content": "This thread is a great summary of the release.",
  "targets": [
    {
      "accountId": "BLUESKY_ACCOUNT_ID",
      "options": {
        "quoteUrl": "https://bsky.app/profile/someone.bsky.social/post/3kexample",
        "replyControl": "limited",
        "replyAllow": ["followers"],
        "langs": ["en"]
      }
    }
  ],
  "publishNow": true
}

If you set replyControl to limited, you must pick at least one group in replyAllow; otherwise validation fails with a clear message.

The link card is fetched from the page's metadata at publish time. If the page cannot be read, the post still goes out as a plain post rather than failing.

6. Check the result

Fetch the post with GET /v1/posts/{id}. When the Bluesky target's status is published, externalUrl links to the post on bsky.app. If it failed, errorCode and errorMessage explain why.

Cross-post while you are at it

The same request can include targets on other networks. Since Bluesky's 300-character limit is tighter than most, set a shorter content on the Bluesky target and keep the longer version as the shared text for everyone else.

The full option list is in the Bluesky platform guide, and the request format is in the create post reference.

#bluesky#api#tutorial

Ready to build?

Get an access key and publish your first post in minutes.

Related posts