How to post to Bluesky through an API

You can post to Bluesky through its own AT Protocol API or through PostSail with one HTTPS request. Here are both, then the PostSail steps with code for text and video.

Two ways to post

  • The AT Protocol directly. Sign in with an app password or OAuth, then create an app.bsky.feed.post record with com.atproto.repo.createRecord. You turn links and hashtags into facets (UTF-8 byte ranges), send video through Bluesky's video service and wait for processing, and handle retries so that a lost answer does not create a second post. It is free and documented at docs.bsky.app and atproto.com.
  • PostSail. Connect the account once in the dashboard, then send one HTTPS request. PostSail checks the post before it goes out, processes video, retries temporary errors without double-posting and reports the result. The same request can also post to X.

Before you start

  1. Create a workspace at postsail.app/app and start the free trial.
  2. Under Accounts, choose Bluesky, enter the handle and approve PostSail on Bluesky's sign-in page.
  3. Create an API key on the API page and keep it in POSTSAIL_API_KEY on your server.

Find the account's slug with GET /v1/accounts (here it is brand-bluesky).

Post text

curl
curl -X POST https://postsail.app/api/v1/posts \
  -H "Authorization: Bearer $POSTSAIL_API_KEY" \
  -H "Idempotency-Key: bsky-hello-1" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "bsky-hello-1",
    "targets": [
      {
        "account": "brand-bluesky",
        "text": "Testing the PostSail API https://example.com #devlog"
      }
    ]
  }'
Node.js
const res = await fetch("https://postsail.app/api/v1/posts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POSTSAIL_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "bsky-hello-1",
  },
  body: JSON.stringify({
    "external_id": "bsky-hello-1",
    "targets": [
      {
        "account": "brand-bluesky",
        "text": "Testing the PostSail API https://example.com #devlog"
      }
    ]
  }),
});
console.log(res.status, await res.json());
Python
import os

import requests

res = requests.post(
    "https://postsail.app/api/v1/posts",
    headers={
        "Authorization": f"Bearer {os.environ['POSTSAIL_API_KEY']}",
        "Idempotency-Key": "bsky-hello-1",
    },
    json={
        "external_id": "bsky-hello-1",
        "targets": [
            {
                "account": "brand-bluesky",
                "text": "Testing the PostSail API https://example.com #devlog",
            },
        ],
    },
)
print(res.status_code, res.json())
  • Text: at most 300 characters, counted as graphemes (an emoji counts as one), and 3,000 bytes.
  • Links and #hashtags in the text become clickable: PostSail adds the facets for you.
  • Bluesky has no drafts; a post is public when it is published.

Add "dry_run": true (with its own external_id) to check a post without publishing it.

Post a video

Upload the file, then post it. Bluesky accepts video up to 3 minutes and 100 MB; uploads to PostSail can be up to 100 MB.

curl
# 1. Start the upload: you get id, upload_url, method, headers and expires_at
curl -X POST https://postsail.app/api/v1/uploads \
  -H "Authorization: Bearer $POSTSAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"content_type": "video/mp4", "size_bytes": 7340032}'

# 2. Send the file to upload_url (method PUT, the Content-Type from headers)
curl -X PUT "$UPLOAD_URL" -H "Content-Type: video/mp4" --data-binary @clip.mp4

# 3. Create the post; replace UPLOAD_ID with the id from step 1
curl -X POST https://postsail.app/api/v1/posts \
  -H "Authorization: Bearer $POSTSAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: clip-2026-11-20" \
  -d '{"external_id":"clip-2026-11-20","media_upload_id":"UPLOAD_ID","targets":[{"account":"brand-bluesky","text":"New clip"}]}'
Node.js
import {readFile, stat} from "node:fs/promises";

const auth = {Authorization: `Bearer ${process.env.POSTSAIL_API_KEY}`};
const file = "clip.mp4";

// 1. Start the upload
const start = await fetch("https://postsail.app/api/v1/uploads", {
  method: "POST",
  headers: {...auth, "Content-Type": "application/json"},
  body: JSON.stringify({content_type: "video/mp4", size_bytes: (await stat(file)).size}),
}).then((r) => r.json());

// 2. Send the file with the given method and headers
const put = await fetch(start.upload_url, {method: start.method, headers: start.headers, body: await readFile(file)});
if (!put.ok) throw new Error(`upload failed: ${put.status}`);

// 3. Create the post with the upload id
const post = await fetch("https://postsail.app/api/v1/posts", {
  method: "POST",
  headers: {...auth, "Content-Type": "application/json", "Idempotency-Key": "clip-2026-11-20"},
  body: JSON.stringify({external_id: "clip-2026-11-20", media_upload_id: start.id, targets: [{"account":"brand-bluesky","text":"New clip"}]}),
}).then((r) => r.json());
console.log(post.id, post.status);
Python
import os

import requests

API = "https://postsail.app/api"
auth = {"Authorization": f"Bearer {os.environ['POSTSAIL_API_KEY']}"}
path = "clip.mp4"

# 1. Start the upload
start = requests.post(f"{API}/v1/uploads", headers=auth,
                      json={"content_type": "video/mp4", "size_bytes": os.path.getsize(path)}).json()

# 2. Send the file with the given method and headers
with open(path, "rb") as f:
    requests.request(start["method"], start["upload_url"], headers=start["headers"], data=f).raise_for_status()

# 3. Create the post with the upload id
post = requests.post(f"{API}/v1/posts", headers={**auth, "Idempotency-Key": "clip-2026-11-20"}, json={
    "external_id": "clip-2026-11-20",
    "media_upload_id": start["id"],
    "targets": [{"account":"brand-bluesky","text":"New clip"}],
}).json()
print(post["id"], post["status"])

In our own test on 10 November 2026, a text post was live in 4.9 seconds (cold start included) and a 6-second video in 20.7 seconds end to end, most of it Bluesky's video processing. One run each, not a benchmark.

Check the result

curl
curl https://postsail.app/api/v1/posts/pst_1a2b3c4d5e6f7g8h9i0j \
  -H "Authorization: Bearer $POSTSAIL_API_KEY"
Node.js
const res = await fetch("https://postsail.app/api/v1/posts/pst_1a2b3c4d5e6f7g8h9i0j", {
  headers: {
    Authorization: `Bearer ${process.env.POSTSAIL_API_KEY}`,
  },
});
console.log(res.status, await res.json());
Python
import os

import requests

res = requests.get(
    "https://postsail.app/api/v1/posts/pst_1a2b3c4d5e6f7g8h9i0j",
    headers={
        "Authorization": f"Bearer {os.environ['POSTSAIL_API_KEY']}",
    },
)
print(res.status_code, res.json())

When the target is published, permalink is the post's address on bsky.app and platform_post_id its record URI. If the Bluesky sign-in expires, the target ends needs_reauth: reconnect the account in the dashboard and retry the target with POST /v1/posts/{post_id}/targets/{account}/retry.