amplifysignal.comamplifysignal.com →

Setting up blog post to Bluesky and LinkedIn automation

How to automate your blog distribution to Bluesky and LinkedIn. A technical guide to calculating UTF-8 byte offsets for AT Protocol facets, handling 60-day LinkedIn API tokens, and staggering your schedule.

·5 min read

When you publish an article, opening multiple tabs to paste the link across different social networks immediately after is a chore. Setting up blog post to Bluesky and LinkedIn automation fixes the distribution step, getting your content in front of readers without requiring you to be at your keyboard.

I covered the case for automatically turning blog posts into social media posts previously. This article covers the concrete implementation for Bluesky and LinkedIn specifically. The mechanics of these two APIs are entirely different, and handling their quirks requires specific payload structures, authentication flows, and scheduling logic.

Structuring the queue and staggering delivery

Blasting every network at the exact second you hit publish looks robotic. Staggering the distribution gives the post a longer lifecycle and fits the natural rhythm of each platform.

A good baseline is pushing the Bluesky post about two hours after the article goes live. For LinkedIn, the next morning works better. Spacing it out means your content surfaces on different days, catching different segments of your audience.

To implement this, you need a database table to act as a queue. When an article publishes, you insert two rows into a social_posts table. Each row needs a network column, a payload column containing the generated text, a scheduled_for timestamp, a status flag (pending, published, or failed), and an attempt_count integer.

A cron job runs every minute, executing a simple query to find pending posts where the scheduled time has passed. Before it sends anything, it should check a weekly cap per channel. Pushing too many links in a short window exhausts your audience. A simple SQL count for the trailing seven days lets you abort or delay the job if the channel cap is reached.

Bluesky: The 300-character limit and byte offsets

Bluesky: The 300-character limit and byte offsets

Bluesky restricts posts to 300 characters. Validating the length is simple, but attaching the link to your blog post requires understanding the AT Protocol's requirement for facets.

Unlike older APIs that parse plain text and automatically turn URLs into clickable links, Bluesky requires you to explicitly define where a link starts and ends in your text. You pass these positions in a facets array.

The complexity comes from how Bluesky measures these positions. It uses UTF-8 byte offsets, not string character indexes. If your post contains an emoji, a standard JavaScript string.length or indexOf will give the wrong position. The API will either reject the payload entirely or turn the wrong word into a link.

To find the correct byte index in JavaScript, you must convert the text to a Uint8Array using TextEncoder.

const text = "New post 🚀: Read it here";
const urlIndex = text.indexOf("Read it here");
const textBeforeLink = text.substring(0, urlIndex);

const encoder = new TextEncoder();
const byteStart = encoder.encode(textBeforeLink).byteLength;
const byteEnd = byteStart + encoder.encode("Read it here").byteLength;

Once you calculate the byte start and end, you construct the payload for the com.atproto.repo.createRecord endpoint. The payload requires the text, the timestamp, and the facets array defining the clickable areas.

{
  "$type": "app.bsky.feed.post",
  "text": "New post 🚀: Read it here",
  "createdAt": "2023-10-24T12:00:00Z",
  "facets": [
    {
      "index": {
        "byteStart": 15,
        "byteEnd": 27
      },
      "features": [
        {
          "$type": "app.bsky.richtext.facet#link",
          "uri": "https://yourblog.com/article-url"
        }
      ]
    }
  ]
}

Send this payload using your Bluesky app password, and the post will render with a properly functioning link, regardless of any multibyte characters in the text.

LinkedIn: 3,000 characters and 60-day tokens

LinkedIn: 3,000 characters and 60-day tokens

LinkedIn allows up to 3,000 characters, giving you room to write a substantial summary of your blog post. The API payload for a text post with an embedded link uses the /v2/ugcPosts endpoint.

Before you can post, you need your author URN. You fetch this once via the /v2/userinfo endpoint, which returns a string formatted like urn:li:person:12345ABCDE.

The payload itself requires you to place the text in a shareCommentary object and the blog link in a shareMediaCategory object set to ARTICLE.

{
  "author": "urn:li:person:12345ABCDE",
  "lifecycleState": "PUBLISHED",
  "specificContent": {
    "com.linkedin.ugc.ShareContent": {
      "shareCommentary": {
        "text": "Your blog post summary goes here."
      },
      "shareMediaCategory": "ARTICLE",
      "media": [
        {
          "status": "READY",
          "originalUrl": "https://yourblog.com/article-url"
        }
      ]
    }
  },
  "visibility": {
    "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
  }
}

The real technical friction with LinkedIn is authentication. Their OAuth 2.0 access tokens expire after exactly 60 days. Unlike platforms that issue non-expiring personal access tokens, LinkedIn forces a refresh flow.

If you forget to refresh the token, your cron job will fail to post. Your queue system needs to handle this gracefully. When the API returns a 401 Unauthorized, the system should catch the error, flag the authentication as invalid, pause future attempts for that network, and send you an alert.

Handling failures with backoff

Network APIs drop connections, rate limits trigger, and endpoints occasionally return 500 errors. A robust queue requires a retry mechanism with exponential backoff.

If a Bluesky or LinkedIn request fails, increment the attempt_count column in your database. Calculate the next run time by multiplying the base delay by the attempt count squared. A failure at 9:00 AM retries at 9:05 AM. A second failure retries at 9:25 AM. After five attempts, mark the row as permanently failed so it doesn't clog the queue.

Manual fallback for other networks

Not all networks warrant API integration. For X, Threads, and Mastodon, managing API credentials, rate limits, and approval processes for automated posting is often more trouble than it is worth for an independent site.

Instead of hitting their APIs, you can write the post text and hand it over as a prefilled composer link. This approach removes API dependencies entirely while still saving you from writing the social post from scratch.

You URL-encode the generated text and append it to the network's intent URL. Clicking the link opens the platform with the text already sitting in the compose box, ready for you to hit send.

  • X: https://x.com/intent/tweet?text=ENCODED_TEXT
  • Threads: https://www.threads.net/intent/post?text=ENCODED_TEXT
  • Mastodon: https://mastodon.social/share?text=ENCODED_TEXT

Tying the workflow together

The complete flow starts with the target keyword and ends with the scheduled social distribution. If you use Ghost or WordPress, a webhook can notify your backend when a new article is published, kicking off the database inserts for the social queue.

If you want to skip building the queue runner, the byte offset calculations, and the retry logic yourself, AmplifySignal drafts the articles directly into your CMS and schedules these social posts for you. It handles the two-hour delay for Bluesky, the next-morning drop for LinkedIn, enforces your weekly caps, and provides the prefilled intent links for the manual networks.

By defining the execution times and handling the specific API quirks for each network, your distribution happens reliably behind the scenes.