Quick reference
| Field | Value |
|---|
| Character limit | 280 per tweet (Premium accounts: 4,000) |
| Thread length | 1–25 tweets |
| Images per tweet | 1–4 |
| Videos per tweet | 1 |
| Mixed media | ❌ Photos OR video, never both |
| Image formats | JPEG, PNG, GIF, WebP |
| Video formats | MP4, MOV |
| Max image size | 5 MB |
| Max video size | 512 MB |
| Video duration | up to 2:20 (140s), Premium up to 4 hours |
| Aspect ratio | Any (X auto-crops) |
| Post types | Tweet, Thread, Reply |
| First comment | ✅ (posted as a self-reply to the root tweet) |
| Billing | Pay-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.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.
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
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",
},
}]
Images
| Property | Requirement |
|---|
| Images per tweet | 1–4 |
| Formats | JPEG, PNG, GIF, WebP |
| Max file size | 5 MB |
| Animated GIF | ✅ (counts as the single allowed video) |
| Min resolution | 4 × 4 |
| Max resolution | 8192 × 8192 |
Videos
| Property | Requirement |
|---|
| Videos per tweet | 1 |
| Formats | MP4, MOV |
| Max file size | 512 MB |
| Max duration | 140 seconds (Premium: 4 hours) |
| Aspect ratio | 1:3 to 3:1 |
| Codecs | H.264 (baseline / main / high), AAC |
| Resolution | 32 × 32 min, 1920 × 1200 max |
| Frame rate | up to 60 fps |
For URL ingest vs pre-uploaded mediaIds, see Media uploads.
Full reference: Platform settings — X.
| Field | Type | Required | Description |
|---|
platform | "X" | Yes | Discriminator. |
replySettings | "everyone" | "following" | "mentionedUsers" | No (default everyone) | Who can reply to the main tweet. |
threadParts | string[] | No | Additional tweet bodies. Each ≤ 4,000 chars, up to 25 total. content is the root tweet; threadParts[0] becomes the second tweet. |
replyToId | string | No | If set, the tweet posts as a reply to this tweet id. |
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
| Metric | Available |
|---|
| 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
| Error | Meaning | Fix |
|---|
X_RATE_LIMITED | App or user rate limit hit | Postbreeze retries with the Retry-After value X returns. |
X_DUPLICATE_TWEET | Identical tweet body posted recently | Vary the text or wait. X enforces this aggressively on near-identical content. |
X_MEDIA_TOO_LARGE | Image > 5 MB or video > 512 MB | Compress before uploading. |
X_MIXED_MEDIA | Images + video in mediaIds | Pick one. |
X_TOO_MANY_IMAGES | > 4 images | Trim to ≤ 4. |
X_INVALID_REPLY_SETTINGS | First-comment + restricted replySettings (handled, but surfaces as a fallback) | Postbreeze forces everyone automatically. |
X_TOKEN_EXPIRED | Refresh token rejected by X (invalid_grant) | Reconnect. The dashboard’s Account Health dialog explains the exact reason from X. |
X_PAYMENT_REQUIRED | Workspace doesn’t have a payment method but X is Pay-Per-Use | Add 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.
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 👇",
}],
});