# Bluesky

Post text, images, video, link cards and quote posts to Bluesky, connected with an app password.


## Overview

The `bluesky` platform posts to Bluesky with an app password (no OAuth app needed). You can post:

- Text posts with automatic link, mention and hashtag detection
- Up to 4 images, **or** 1 video
- A link preview card (automatic for the first link, or a URL you choose)
- Quote posts (alone or combined with images, video or a link card)
- Languages, extra hidden tags, content warnings, reply controls and quote controls

## Connect the account

Bluesky uses **app passwords** instead of OAuth.

1. In the Bluesky app, go to **Settings → Privacy and security → App passwords** and create one.
2. Connect it in one of two ways:
   - **Dashboard:** open **[Integrations](https://social-publish-kappa.vercel.app/dashboard/integrations)**, choose **Bluesky**, and enter your handle and the app password.
   - **API:** call [`POST /v1/accounts/bluesky`](/docs/api/connect-bluesky).

```bash
curl -X POST https://social-publish-api.onrender.com/v1/accounts/bluesky \
  -H "x-access-key: $RELAYCAST_API_KEY" \
  -H "content-type: application/json" \
  -d '{ "identifier": "yourname.bsky.social", "appPassword": "abcd-efgh-ijkl-mnop" }'
```

The credentials are checked against Bluesky before they are saved, and stored encrypted. Connecting the same Bluesky account again updates its app password in place, so its account ID doesn't change.

> [!NOTE]
> Accounts are signed in through `bsky.social`. If you revoke the app password in Bluesky, the next publish fails and the account is marked `reconnect_required`.

## Post types & media rules

| Rule | Limit |
| --- | --- |
| Text | 300 characters (graphemes) |
| Images | Up to 4, 2 MB each |
| Video | 1, up to 300 MB |
| Images and video | Can't be combined |
| Alt text | `media[].altText` is sent for images |
| Link card | Only when the post has no images or video; the page's thumbnail is used if it is under 1 MB |
| Extra tags | Up to 8, 64 characters each |
| Languages | Up to 3 BCP-47 codes (for example `en`, `pt-BR`) |

Hashtags, @mentions and links written in the text are detected and linked automatically.

## Options

| Option | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `linkCard` | boolean | No | `true` | Link preview card. When the post has no images or video, attach a preview card for the first link in the text (or the URL below). Fails quietly to a plain post if the page can't be read. |
| `linkUrl` | string (URL) | No |  | Card URL. Defaults to the first link in the post text. Only when `linkCard` is `true`. |
| `linkTitle` | string | No |  | Card title. Overrides the page's og:title / <title>. Only when `linkCard` is `true`. |
| `linkDescription` | string | No |  | Card description. Overrides the page's og:description / meta description. Only when `linkCard` is `true`. |
| `tags` | string[] | No |  | Extra tags. Up to 8 hashtags stored on the post but not shown in the text (searchable). Hashtags written in the text are detected automatically. |
| `langs` | string[] | No |  | Languages. Up to 3 BCP-47 language codes for the post text. Used for feeds and translation. |
| `contentWarning` | `none` \\| `sexual` \\| `nudity` \\| `porn` \\| `graphic-media` | No | `"none"` | Content warning. Self-label shown to viewers as a content warning (mainly for images/video). |
| `quoteUrl` | string (URL) | No |  | Quote post. A Bluesky post to quote. Can be combined with images, video or a link card. |
| `replyControl` | `everyone` \\| `nobody` \\| `limited` | No | `"everyone"` | Who can reply |
| `replyAllow` | string[] | No | `[]` | Allow replies from Only when `replyControl` is `limited`. |
| `disableQuotes` | boolean | No | `false` | Disable quote posts. Prevents other people from quoting this post. |
`quoteUrl` accepts a `https://bsky.app/profile/<handle>/post/<id>` URL or an `at://` post URI. With `replyControl: "limited"`, pick at least one group in `replyAllow`.

## Examples

### Post with a link card and reply limits

```bash tab="cURL"
curl -X POST https://social-publish-api.onrender.com/v1/posts \
  -H "x-access-key: $RELAYCAST_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "content": "We wrote up how our scheduler works: https://example.com/blog/scheduler #engineering",
    "targets": [{
      "accountId": "7c6b5a49-3827-4e1d-8c0b-9a8f7e6d5c4b",
      "options": {
        "linkCard": true,
        "langs": ["en"],
        "replyControl": "limited",
        "replyAllow": ["followers", "mentioned"],
        "disableQuotes": false
      }
    }],
    "publishNow": true
  }'
```

```ts tab="Node.js"
const res = await fetch("https://social-publish-api.onrender.com/v1/posts", {
  method: "POST",
  headers: {
    "x-access-key": process.env.RELAYCAST_API_KEY!,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    content: "We wrote up how our scheduler works: https://example.com/blog/scheduler #engineering",
    targets: [
      {
        accountId: "7c6b5a49-3827-4e1d-8c0b-9a8f7e6d5c4b",
        options: {
          linkCard: true,
          langs: ["en"],
          replyControl: "limited",
          replyAllow: ["followers", "mentioned"],
          disableQuotes: false,
        },
      },
    ],
    publishNow: true,
  }),
});
const { data } = await res.json();
```

```python tab="Python"
import os, requests

res = requests.post(
    "https://social-publish-api.onrender.com/v1/posts",
    headers={"x-access-key": os.environ["RELAYCAST_API_KEY"]},
    json={
        "content": "We wrote up how our scheduler works: https://example.com/blog/scheduler #engineering",
        "targets": [{
            "accountId": "7c6b5a49-3827-4e1d-8c0b-9a8f7e6d5c4b",
            "options": {
                "linkCard": True,
                "langs": ["en"],
                "replyControl": "limited",
                "replyAllow": ["followers", "mentioned"],
                "disableQuotes": False,
            },
        }],
        "publishNow": True,
    },
)
data = res.json()["data"]
```

### Images with alt text and a quote

```json title="Request body"
{
  "content": "Before and after the redesign",
  "media": [
    { "url": "https://cdn.example.com/before.jpg", "type": "image", "altText": "Old settings page" },
    { "url": "https://cdn.example.com/after.jpg", "type": "image", "altText": "New settings page" }
  ],
  "targets": [{
    "accountId": "7c6b5a49-3827-4e1d-8c0b-9a8f7e6d5c4b",
    "options": {
      "quoteUrl": "https://bsky.app/profile/example.bsky.social/post/3l7abcxyz2k2a",
      "tags": ["design", "ux"]
    }
  }],
  "scheduledFor": "2026-11-10T09:00:00Z"
}
```

## Analytics & comments

Analytics show likes, replies (comments), reposts + quotes (shares) and bookmarks (saves). Bluesky does not report views, impressions or clicks.

Replies and messaging are not supported for Bluesky.

## Limitations & common errors

| Error | Cause |
| --- | --- |
| `invalid_credentials` (connect) | Bluesky rejected the handle or app password. |
| `Bluesky allows 300 characters (this post has …)` | Text is too long. |
| `Bluesky posts can have images or one video, not both` | Mixed media. |
| `Bluesky allows up to 4 images` | Too many images. |
| `Bluesky allows up to 8 tags` / `up to 3 languages` | Too many `tags` or `langs`. |
| `Pick at least one group who can reply …` | `replyControl: "limited"` with an empty `replyAllow`. |
| Target fails: image too large | Bluesky images are limited to 2 MB each. |
| Account becomes `reconnect_required` | The app password was revoked or the account was deactivated. |

A link card that can't be built (the page can't be fetched or has no metadata) quietly falls back to a plain post.
