To use the Bluesky API to create a post, you first call createSession with your handle and an app password to get an access token. You then send a POST request to createRecord on the app.bsky.feed.post collection. Sending text requires a simple JSON payload, but making a link clickable requires calculating UTF-8 byte offsets, and generating link preview cards requires an initial image upload step.
How do I authenticate with the Bluesky API?
The AT Protocol requires an active session for all write operations. You establish a session by sending a POST request to the com.atproto.server.createSession endpoint. If your account is hosted on the main Bluesky network, the full URL is https://bsky.social/xrpc/com.atproto.server.createSession.
The JSON body of this request takes two fields: an identifier and a password. You should create a dedicated app password in your Bluesky account settings. Using your primary account password works, but generating a specific app password limits the scope of access and allows you to revoke it easily if your script is compromised.
When formatting the identifier, use your exact handle without any prefixes. A common mistake is adding the standard social media symbol before the name. If you send @yourname.bsky.social as the identifier, the createSession endpoint will fail and return an Invalid email address error. The system attempts to parse any string containing that symbol as an email rather than a handle. Use yourname.bsky.social.
A successful response returns a JSON object containing your Decentralized Identifier (DID) and an accessJwt. The DID acts as your user ID across the network, and you will pass it as the repository parameter in your publishing requests. The accessJwt is your authentication token. You must include it in the Authorization header as a Bearer token for all subsequent API calls.
How do I send a basic text post?
With an active session token and your DID in hand, you can publish content. The endpoint for adding data to your repository is com.atproto.repo.createRecord.
The AT Protocol is organized around collections. Every type of data, from a follow to a profile update to a post, belongs to a specific collection. For standard microblog updates, the collection is app.bsky.feed.post.
Your POST request requires a JSON body with three main keys. The repo key takes your DID. The collection key takes the string app.bsky.feed.post. The record key takes an object containing the actual data of your post.
Inside the record object, you must include a $type property set to app.bsky.feed.post, a text property containing your string, and a createdAt property set to the current time as an ISO 8601 string.
{
"repo": "did:plc:yourdidstring",
"collection": "app.bsky.feed.post",
"record": {
"$type": "app.bsky.feed.post",
"text": "This is a basic text post sent from the API.",
"createdAt": "2024-02-15T10:00:00Z"
}
}
You send this payload to the createRecord endpoint with a Content-Type of application/json. Once the server accepts the record, the text appears on your Bluesky timeline.
Why are the links in my API posts not clickable?
If you include a URL like https://example.com in the text field of your basic record, the official Bluesky apps will display it as plain text. The AT Protocol does not scan your text on the server to find links, hashtags, or mentions. It expects the client creating the post to identify those elements and submit their exact positions.
You identify these elements by adding a facets array to your record object. A facet tells the client application that a specific slice of the text has a special feature, such as functioning as a hyperlink.
The most frequent cause of broken links in custom posting scripts is incorrect indexing. The byteStart and byteEnd properties in a facet index indicate exactly where the link sits in your text. However, these are UTF-8 byte offsets, not character offsets.
JavaScript strings calculate length using UTF-16 code units. If your text includes an emoji or a special character, JavaScript might count it as two units, while a UTF-8 parser counts it as four bytes. For example, the rocket emoji requires four bytes in UTF-8 but only two code units in JavaScript. If you use JavaScript's native string properties to find your start and end points, your offsets will be wrong. The server will highlight the wrong letters or reject the record entirely.
To calculate the correct byte offsets, you must convert the text to bytes first. In Node.js or browser environments, you can pass your string to a TextEncoder.
const text = "New article: https://example.com 🚀";
const encoder = new TextEncoder();
// Find the position by encoding the text before the link
const textBeforeLink = "New article: ";
const byteStart = encoder.encode(textBeforeLink).length;
// Find the end by adding the byte length of the link itself
const linkText = "https://example.com";
const byteEnd = byteStart + encoder.encode(linkText).length;
You then construct the facet using these exact byte values and append it to your record.
"facets": [
{
"index": {
"byteStart": 13,
"byteEnd": 32
},
"features": [
{
"$type": "app.bsky.richtext.facet#link",
"uri": "https://example.com"
}
]
}
]
How do I add a link preview card to a post?
Including a URL and a facet makes the text clickable, but it does not generate the rich preview card that users expect. Bluesky does not unfurl a bare URL from your text to build preview cards. If you want a card, you have to build an external embed object and attach it to your post.
Because the preview card requires an image, publishing a rich post is a multi-step process. First, your script needs to fetch the target URL, parse the HTML for the Open Graph image tag, and download that image into a buffer.
You then send a POST request containing the raw image buffer to the com.atproto.repo.uploadBlob endpoint. You must set the Content-Type header to match the image format, such as image/jpeg or image/png. Bluesky enforces a maximum blob size of 976.56 KB, so you may need to compress the image before uploading it.
If the upload is successful, the API returns a JSON object containing a blob property. This blob is a reference to the image stored on the server.
Finally, you add an embed object to your createRecord payload. You set the $type of the embed to app.bsky.embed.external. The external property inside this object takes the uri of the article, a title, a description, and the exact thumb blob object you received from the upload step.
"embed": {
"$type": "app.bsky.embed.external",
"external": {
"uri": "https://example.com/blog/article",
"title": "Article Title",
"description": "A short summary of the article content.",
"thumb": {
"$type": "blob",
"ref": {
"$link": "bafkreibxyz..."
},
"mimeType": "image/jpeg",
"size": 45000
}
}
}
When you send this complete record payload, the official apps will render a standard clickable preview card underneath your text.
Handling the publishing steps in a production script
Coordinating the session creation, image processing, blob upload, and record creation introduces multiple points where a script can fail. A network timeout might occur right after the image uploads but before the post publishes.
I run AmplifySignal as a blog autopilot on my own sites. When it takes a keyword from Google Search Console data, drafts an article, schedules it to WordPress or Ghost, and waits two hours after publication to send the Bluesky post, it has to execute this entire multi-step API sequence.
If the createRecord step times out, you have an orphaned image blob stored in your repository. If your script restarts and runs the whole sequence again blindly, it will upload a duplicate image blob, consuming unnecessary storage and bandwidth. Failed posts retry with backoff, but managing this efficiently requires saving the state of the blob reference. If the final post fails, the retry logic should skip the image upload phase and attempt to publish the record using the existing blob reference. This approach is identical to the logic I use for handling partial syndication failures without creating duplicate posts across different platforms.
You also have to respect the server limits. Bluesky sets caps on how many sessions you can create and how many records you can publish in a given window. Reusing your session token for multiple requests, rather than calling createSession every time, prevents authentication rate limits. This concept applies just as strictly here as it does when finding the correct Ghost Admin API URL and authenticating requests for your content management system.