> ## Documentation Index
> Fetch the complete documentation index at: https://docs.postbreeze.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# X (Twitter)

> Schedule tweets, threads, and replies — with media, reply settings, and Pay-Per-Use billing notes.

## 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](/concepts/platform-settings#x-twitter) and [Media uploads](/concepts/media-uploads).

## Before you start

<Warning>
  X moved to **Pay-Per-Use** billing in February 2026 for new signups. Every tweet write is metered at $0.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.
</Warning>

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](#full-control-nested-shape) at the end of
this page when you'd rather pass everything as `targets`.

```ts SDK theme={null}
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.

<CodeGroup>
  ```js Node.js theme={null}
  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",
      },
    }],
  });
  ```

  ```python Python theme={null}
  import os
  from datetime import datetime, timedelta, timezone
  import requests

  API = "https://api.postbreeze.ai/api/v1"

  res = requests.post(
      f"{API}/posts",
      headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"},
      json={
          "content": "Shipped a new dashboard today 🚀",
          "scheduledFor": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(),
          "mediaIds": ["med_image_…"],
          "platforms": [{
              "accountId": "soc_x_…",
              "platformOptions": {"platform": "X", "replySettings": "everyone"},
          }],
      },
  )
  res.raise_for_status()
  ```

  ```bash curl theme={null}
  curl -X POST https://api.postbreeze.ai/api/v1/posts \
    -H "Authorization: Bearer $POSTBREEZE_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "content": "Shipped a new dashboard today 🚀",
      "scheduledFor": "2026-06-02T12:00:30Z",
      "mediaIds": ["med_image_…"],
      "platforms": [{
        "accountId": "soc_x_…",
        "platformOptions": { "platform": "X", "replySettings": "everyone" }
      }]
    }'
  ```
</CodeGroup>

## 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.

<CodeGroup>
  ```js Node.js theme={null}
  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.",
        ],
      },
    }],
  });
  ```

  ```python Python theme={null}
  res = requests.post(
      f"{API}/posts",
      headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"},
      json={
          "content": "Five lessons from shipping our first SaaS 🧵",
          "scheduledFor": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(),
          "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.",
                  ],
              },
          }],
      },
  )
  res.raise_for_status()
  ```

  ```bash curl theme={null}
  curl -X POST https://api.postbreeze.ai/api/v1/posts \
    -H "Authorization: Bearer $POSTBREEZE_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "content": "Five lessons from shipping our first SaaS 🧵",
      "scheduledFor": "2026-06-02T12:00:30Z",
      "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."
          ]
        }
      }]
    }'
  ```
</CodeGroup>

### Reply

Pass `replyToId` (the tweet id you're replying to). The post becomes a reply rather than a top-level tweet.

```js theme={null}
platforms: [{
  accountId: "soc_x_…",
  platformOptions: {
    platform: "X",
    replySettings: "everyone",
    replyToId: "1789012345678901234",
  },
}]
```

## Media requirements

### 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](/concepts/media-uploads).

## Platform-specific fields

Full reference: [Platform settings — X](/concepts/platform-settings#x-twitter).

| 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.                                                                                 |

## 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.

```js theme={null}
platforms: [{
  accountId: "soc_x_…",
  platformOptions: { platform: "X", replySettings: "everyone" },
  firstComment: "Link to the full write-up in the next reply 👇",
}]
```

<Warning>
  **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.
</Warning>

## 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.

```js Node.js theme={null}
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 👇",
  }],
});
```
