Back to Blog
blueskyatprotoapidevelopers

How to Post to Bluesky via the API: createRecord, Facets, and Rate Limits Explained

M
Mel Owen
8 min read

Bluesky's API looks deceptively simple. One HTTP call, a JSON body, done. Then you post something with a link in it, the link shows up as plain gray text with no underline, and you spend the next hour learning what a facet is and why it wants byte offsets instead of character offsets. This is the guide I wish I had before that hour.

Sessions: app passwords vs OAuth

Before you can call anything, you need a session. Bluesky (built on the AT Protocol, or atproto) gives you two paths.

App passwords are the fast path. You generate one in Settings on Bluesky, then trade it for a session:

POST https://bsky.social/xrpc/com.atproto.server.createSession
{
  "identifier": "yourhandle.bsky.social",
  "password": "xxxx-xxxx-xxxx-xxxx"
}

That returns an accessJwt and refreshJwt, plus your did, the permanent identifier behind your handle. Every write call after this needs the accessJwt in an Authorization: Bearer header.

App passwords are fine for a script that only ever posts to your own account. They are not fine for a product other people connect their accounts to. Since roughly September 2024, Bluesky has steered public apps toward OAuth instead, where a user grants scoped access without ever handing you their password. If you are building something more than one person will use, start with OAuth. Retrofitting it later means re-authing every user.

Your first post with com.atproto.repo.createRecord

Once you have a session, posting is a single write to your own repository. Bluesky doesn't have a dedicated "post" endpoint the way older APIs do. Instead you write a record of a known type into your repo:

POST https://bsky.social/xrpc/com.atproto.repo.createRecord
Authorization: Bearer <accessJwt>

{
  "repo": "did:plc:yourdidhere",
  "collection": "app.bsky.feed.post",
  "record": {
    "$type": "app.bsky.feed.post",
    "text": "Shipping something small today.",
    "createdAt": "2026-09-24T14:00:00.000Z"
  }
}

Three fields are mandatory inside record: $type, text, and createdAt. Everything else, images, links, mentions, reply refs, is additive. That minimalism is the whole appeal of atproto: your post is just a record in a repo that anyone can read, and the app layer (the Bluesky client, or your own) decides how to render it.

The 300-grapheme / 3,000-byte rule

Here is the first trap for anyone coming from X or Mastodon: Bluesky's limit isn't measured in characters the way you'd count them by eye. It's 300 graphemes, capped by 3,000 UTF-8 bytes, whichever you hit first.

A grapheme is roughly "what a person perceives as one character." An emoji, an accented letter, even some multi-codepoint symbols count as one grapheme but can eat several bytes. If your content is emoji-heavy or non-Latin script, you can blow the 3,000-byte ceiling well before 300 graphemes. Count both, not just one.

Links get a flat accounting too: a URL counts as 22 characters toward your total regardless of its real length, similar in spirit to X's shortener but a fixed number rather than a redirect service. That matters for the next section, because the 22-character count is cosmetic. The actual bytes you send still contain the full URL.

Facets: why your links are not clickable

This is the part that trips up almost everyone on their first integration. Bluesky's server does no automatic parsing of your text for links, mentions, or hashtags. If you just put a URL in the text field and stop there, it publishes as inert text. No underline, no tap target, nothing.

To make a link (or a mention, or a hashtag) interactive, you attach a facets array to the record. Each facet says "at these byte offsets in the text, apply this feature." The critical detail: the offsets are UTF-8 byte indexes into the encoded text, not character indexes and not JavaScript string indexes. If your text has any multi-byte characters before the link, a naive indexOf in most languages will give you the wrong number and Bluesky will underline the wrong span, or crash the client trying.

Here's a worked example in Node, computing the byte offsets properly with TextEncoder:

function buildLinkFacet(text, url) {
  const encoder = new TextEncoder();
  const fullBytes = encoder.encode(text);
  const linkIndex = text.indexOf(url);
  if (linkIndex === -1) throw new Error("URL not found in text");

  // Slice the string BEFORE encoding, then encode each half,
  // so multi-byte characters ahead of the link count correctly.
  const beforeBytes = encoder.encode(text.slice(0, linkIndex));
  const linkBytes = encoder.encode(url);

  const byteStart = beforeBytes.length;
  const byteEnd = byteStart + linkBytes.length;

  return {
    index: { byteStart, byteEnd },
    features: [
      {
        $type: "app.bsky.richtext.facet#link",
        uri: url,
      },
    ],
  };
}

const text = "New post is up: https://timetopost.co/blog";
const facet = buildLinkFacet(text, "https://timetopost.co/blog");

const record = {
  $type: "app.bsky.feed.post",
  text,
  createdAt: new Date().toISOString(),
  facets: [facet],
};

The rule that matters: encode the substring before the link, take its byte length, that's your byteStart. Encode the link itself, add its byte length, that's your byteEnd. Do this for mentions (app.bsky.richtext.facet#mention, pointing at a did) and hashtags (app.bsky.richtext.facet#tag) the same way. Get the offsets wrong and the highlighted span slides onto neighboring words, which is a subtle bug that only shows up with emoji or accented names in the post.

Want to put this into practice? Start a 14-day TimeToPost trial and start scheduling smarter today.

Images and blob limits

Images aren't embedded inline in the record. You upload each one separately as a blob, then reference the returned blob object from your post's embed.

POST https://bsky.social/xrpc/com.atproto.repo.uploadBlob
Authorization: Bearer <accessJwt>
Content-Type: image/jpeg

<raw image bytes>

That returns a blob reference you attach under embed.images, alongside required alt text for each image. A post supports up to 4 images, and each blob currently has to fit under roughly a 1MB cap. Bluesky has reportedly discussed raising that ceiling toward 2MB, but as of this writing 1MB is the number to build against. Don't assume the higher limit is live; check the response for a size-rejection error and compress before you upload rather than after a failed call.

Rate limits, points math

Bluesky's rate limiting runs on a points system rather than a flat request count, and it catches people off guard because a single "post a tweet" action can cost more than one unit.

The headline number is 5,000 points per account per hour. A createRecord write, the kind of call you make to post, costs 3 points. Do the arithmetic and that's a ceiling well north of a thousand posts an hour for a single account, which is generous for anything a human or a reasonable scheduler would do, but easy to blow through with a buggy retry loop that doesn't back off.

Separately, there's a per-IP ceiling of 3,000 requests per 5 minutes. That one matters more for anyone running a service that posts on behalf of many accounts from one server: the per-account points budget looks fine in isolation, but a shared IP can hit the request ceiling first if you're serving a lot of accounts from one box. Spread load or add backoff on 429s, and read the response headers, which report your remaining budget so you can throttle before you get cut off rather than after.

Scheduling (there is no scheduledAt)

Search the AT Protocol lexicon and you won't find a scheduledAt field on app.bsky.feed.post. There is no native way to tell Bluesky's servers "publish this at 9am Thursday." The createdAt field is metadata about when the post was authored, not an instruction to delay publication, and setting it in the future does not queue anything; the record still goes live the moment you call createRecord.

Scheduling is entirely a client-side concern: your own server (or a scheduler like TimeToPost) holds the draft, waits until the target time, computes the facets, and fires the createRecord call at that moment. If you're building this yourself, that means you own a job queue, a retry policy for failed publishes, and a clock. If you'd rather not, that's the whole reason a hosted scheduler exists: TimeToPost's Bluesky integration handles the session refresh, facet computation, and timing, and its MCP server exposes the same scheduling and publishing primitives to AI agents that want to draft and queue posts on your behalf.

FAQ

Do I need an app password or OAuth to get started? An app password is enough for a personal script that only ever touches your own account. For anything other people will connect their accounts to, use OAuth. Bluesky has pushed public integrations toward OAuth since around September 2024, and retrofitting it after users have already granted app-password access is more painful than starting there.

Why is my link showing up as plain text instead of a clickable preview? Because Bluesky doesn't auto-parse text for URLs. You have to attach a facets array with a app.bsky.richtext.facet#link feature and the correct UTF-8 byte offsets, computed as shown above. No facet, no link styling.

Why did my byte offsets work in testing but break in production? Almost always emoji or non-Latin characters appearing before the link in the text. Character-based offsets and byte-based offsets diverge the moment a multi-byte character shows up, so a facet computed with .length in most languages will be off by however many extra bytes those characters use.

Can I schedule a post for a future time using the API directly? No. There's no scheduledAt parameter. Scheduling has to happen in your own application logic, or in a tool built for it, that holds the draft and calls createRecord at the right moment.

What happens if I go over the 300-grapheme limit but I'm under 3,000 bytes? The post still gets rejected. Both limits apply independently, and you need to check whichever one you're closer to hitting given your content. Emoji-heavy or CJK-heavy text tends to hit the byte ceiling first; plain ASCII tends to hit the grapheme ceiling first.

If you'd rather skip building and maintaining the facet math, the token refresh, and a scheduling queue yourself, that's exactly the layer TimeToPost handles for Bluesky alongside the other platforms it posts to natively.

Related posts

Put these strategies into action

TimeToPost helps you schedule content, track performance, and grow your audience, all in one place.