Skip to main content

Quick reference

FieldValue
Character limit280 per tweet (Premium accounts: 4,000)
Thread length1–25 tweets
Images per tweet1–4
Videos per tweet1
Mixed media❌ Photos OR video, never both
Image formatsJPEG, PNG, GIF, WebP
Video formatsMP4, MOV
Max image size5 MB
Max video size512 MB
Video durationup to 2:20 (140s), Premium up to 4 hours
Aspect ratioAny (X auto-crops)
Post typesTweet, Thread, Reply
First comment✅ (posted as a self-reply to the root tweet)
BillingPay-Per-Use — $0.01 per write call
See also: Platform settings — X and Media uploads.

Before you start

X moved to Pay-Per-Use billing in February 2026 for new signups. Every tweet write is metered at 0.01(regular)or0.01 (regular) or 0.02 (write with URL). Postbreeze tracks this per-account and reports it via the Stripe meter.The first-comment feature on X doubles the cost per scheduled post — the main tweet + the self-reply are two separate writes.
The X access token is short-lived (~2 hours) but Postbreeze refreshes it transparently on a 30-minute tick. You don’t have to do anything other than connect once. Required scopes (already granted by the connect flow):
  • tweet.read, tweet.write, users.read, offline.access — base posting + refresh.
  • media.write — uploading images and videos.

Quick start

Workspace is inferred from your API key — no workspaceId argument. The flat shape (content + platforms) covers the common case; drop to the nested shape at the end of this page when you’d rather pass everything as targets.
SDK
import Postbreeze from "@postbreeze/node";

const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY! });

await postbreeze.posts.create({
  content: "Shipped a new dashboard today 🚀",
  scheduledFor: new Date(Date.now() + 30_000).toISOString(),
  mediaItems: [{ type: "image", url: "https://cdn.example.com/screenshot.png" }],
  platforms: [{ accountId: "soc_x_…" }],
});

With X-specific options

Set replySettings (or any other X field) under platformOptions on the target. The discriminator platform: "X" is required.
import Postbreeze from "@postbreeze/node";

const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY });

const post = await postbreeze.posts.create({
  content: "Shipped a new dashboard today 🚀",
  scheduledFor: new Date(Date.now() + 30_000).toISOString(),
  mediaIds: ["med_image_…"],
  platforms: [{
    accountId: "soc_x_…",
    platformOptions: {
      platform: "X",
      replySettings: "everyone",
    },
  }],
});

Content types

Single tweet

Plain text, plus up to 4 images OR 1 video.

Thread

Pass threadParts under platformOptions. content is the root tweet (the first one posted); each entry in threadParts is chained as a reply to the previous one, so threadParts[0] becomes the second tweet. Up to 25 parts, each ≤ 4,000 chars.
const post = await postbreeze.posts.create({
  content: "Five lessons from shipping our first SaaS 🧵",
  scheduledFor: new Date(Date.now() + 30_000).toISOString(),
  platforms: [{
    accountId: "soc_x_…",
    platformOptions: {
      platform: "X",
      replySettings: "everyone",
      threadParts: [
        "1. Ship before it feels ready.",
        "2. Pricing is product. Spend a week on it.",
        "3. Talk to ten customers. Then ten more.",
        "4. Boring tech > exciting tech.",
        "5. The 'simple version' usually wins.",
      ],
    },
  }],
});

Reply

Pass replyToId (the tweet id you’re replying to). The post becomes a reply rather than a top-level tweet.
platforms: [{
  accountId: "soc_x_…",
  platformOptions: {
    platform: "X",
    replySettings: "everyone",
    replyToId: "1789012345678901234",
  },
}]

Media requirements

Images

PropertyRequirement
Images per tweet1–4
FormatsJPEG, PNG, GIF, WebP
Max file size5 MB
Animated GIF✅ (counts as the single allowed video)
Min resolution4 × 4
Max resolution8192 × 8192

Videos

PropertyRequirement
Videos per tweet1
FormatsMP4, MOV
Max file size512 MB
Max duration140 seconds (Premium: 4 hours)
Aspect ratio1:3 to 3:1
CodecsH.264 (baseline / main / high), AAC
Resolution32 × 32 min, 1920 × 1200 max
Frame rateup to 60 fps
For URL ingest vs pre-uploaded mediaIds, see Media uploads.

Platform-specific fields

Full reference: Platform settings — X.
FieldTypeRequiredDescription
platform"X"YesDiscriminator.
replySettings"everyone" | "following" | "mentionedUsers"No (default everyone)Who can reply to the main tweet.
threadPartsstring[]NoAdditional tweet bodies. Each ≤ 4,000 chars, up to 25 total. content is the root tweet; threadParts[0] becomes the second tweet.
replyToIdstringNoIf set, the tweet posts as a reply to this tweet id.

First comment

Pass firstComment on the platform target. Postbreeze posts it as a self-reply to the root tweet — even in a thread, it always replies to content, not to the last threadParts entry.
platforms: [{
  accountId: "soc_x_…",
  platformOptions: { platform: "X", replySettings: "everyone" },
  firstComment: "Link to the full write-up in the next reply 👇",
}]
Reply-settings conflict: if replySettings is following or mentionedUsers AND firstComment is set, the author can’t actually reply to their own tweet (X returns 400 Invalid reply settings). Postbreeze auto-relaxes the main tweet’s replySettings to everyone server-side when both are present — the X tab in compose shows a banner about this.

Analytics

MetricAvailable
Impressions✅ (≤ 30 days old)
Likes
Retweets
Replies
Bookmarks
Followers (account)
Tweet count (account)
URL clicks✅ (≤ 30 days, owned tweets only)
Profile clicks✅ (≤ 30 days, owned tweets only)
Video views
Refresh cadence: every 12 hours. Gated behind X_ANALYTICS_ENABLED env on the Postbreeze server (off by default so deploys don’t accidentally bill the customer for analytics they didn’t ask for). When off, the analytics page shows a “warming up” banner.

Common errors

ErrorMeaningFix
X_RATE_LIMITEDApp or user rate limit hitPostbreeze retries with the Retry-After value X returns.
X_DUPLICATE_TWEETIdentical tweet body posted recentlyVary the text or wait. X enforces this aggressively on near-identical content.
X_MEDIA_TOO_LARGEImage > 5 MB or video > 512 MBCompress before uploading.
X_MIXED_MEDIAImages + video in mediaIdsPick one.
X_TOO_MANY_IMAGES> 4 imagesTrim to ≤ 4.
X_INVALID_REPLY_SETTINGSFirst-comment + restricted replySettings (handled, but surfaces as a fallback)Postbreeze forces everyone automatically.
X_TOKEN_EXPIREDRefresh token rejected by X (invalid_grant)Reconnect. The dashboard’s Account Health dialog explains the exact reason from X.
X_PAYMENT_REQUIREDWorkspace doesn’t have a payment method but X is Pay-Per-UseAdd a card in Settings → Billing.

What you can’t do

  • ❌ Schedule via X’s native scheduler (we use our own queue)
  • ❌ Mix images and video in one tweet
  • ❌ Edit a tweet after publish (Premium feature; not exposed by the API)
  • ❌ Tag a location
  • ❌ Add a poll
  • ❌ Quote-tweet from a scheduled post (use replyToId for a reply chain instead)
  • ❌ Spaces (audio rooms) — not in the API

Full control: nested shape

If you’d rather use the nested request body (caption + targets instead of content + platforms), the same X options apply. Pick one shape per request — don’t mix.
Node.js
const post = await postbreeze.posts.create({
  caption: "Shipped a new dashboard today 🚀",
  scheduledAt: new Date(Date.now() + 30_000).toISOString(),
  mediaIds: ["med_image_…"],
  targets: [{
    socialAccountId: "soc_x_…",
    platformOptions: {
      platform: "X",
      replySettings: "everyone",
      threadParts: [
        "Why we rebuilt it from scratch ↓",
        "The old one was a Rails monolith from 2019.",
      ],
    },
    firstComment: "Changelog in the next reply 👇",
  }],
});