# X (Twitter)

Post text, images, GIFs, video, threads, polls and quote posts to X through the X API v2.


## Overview

The `twitter` platform publishes to an X account with the X API v2. You can post:

- Text posts (280 weighted characters, or 25,000 with **Long post** on Premium accounts)
- Up to 4 images, **or** 1 GIF, **or** 1 video
- Threads: extra posts chained as replies to the first one
- Polls with 2–4 options
- Quote posts, Community posts, reply restrictions and an AI-generated media label

## Connect the account

In the dashboard, open **[Integrations](https://social-publish-kappa.vercel.app/dashboard/integrations)**, choose **X (Twitter)** and approve access on X. Each X login connects one account.

### App setup (self-hosted)

1. In the X developer console, create a Project and an App inside it.
2. Under **User authentication settings**, set **App permissions** to *Read and write* and **Type of App** to *Web App, Automated App or Bot* (confidential client).
3. Add the callback URI `https://social-publish-kappa.vercel.app/api/integrations/twitter/callback` (or set `TWITTER_REDIRECT_URI` to override it).
4. Copy the OAuth 2.0 Client ID and Client Secret into `TWITTER_CLIENT_ID` and `TWITTER_CLIENT_SECRET`.

Scopes requested: `tweet.read`, `tweet.write`, `users.read`, `media.write`, `offline.access`. Access tokens last about two hours and are refreshed automatically; X rotates the refresh token on every refresh.

> [!WARNING]
> Posting and media upload are billed by X. If the developer app has no credits or plan, X answers with 402/403 and the target fails with a message pointing at the X developer console.

## Post types & media rules

| Rule | Limit |
| --- | --- |
| Text length | 280 weighted characters; 25,000 with `longPost: true` |
| Weighting | Every URL counts as 23; emoji and CJK characters count as 2 |
| Text or media | A post needs text or media |
| Images | Up to 4 per post, 5 MB each |
| GIFs | 1 per post, no other media, up to 15 MB |
| Video | 1 per post, no other media, up to 512 MB |
| Alt text | Sent for images and GIFs (`media[].altText`, up to 1,000 characters) |
| Thread | Up to 24 extra posts; each follows the same length limit as the main post |
| Poll | 2–4 different options, 25 characters each, 5 minutes to 7 days; cannot be combined with media |

Thread items are text only. Media and the poll attach to the first post.

## Options

| Option | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `communityId` | string | No |  | Community ID. Post to a Twitter Community (optional). Paste the Community ID or its x.com/i/communities/… URL; you must be a member. |
| `shareWithFollowers` | boolean | No | `false` | Share with followers. Also share the Community post with your followers. Only applies when a Community ID is set. |
| `replySettings` | `everyone` \\| `following` \\| `mentionedUsers` \\| `subscribers` \\| `verified` | No | `"everyone"` | Who can reply |
| `longPost` | boolean | No | `false` | Long post (Premium). Allow over 280 characters. Requires an X Premium / Premium+ account; X rejects it otherwise. |
| `thread` | object[] | No |  | Thread. Add more tweets as a thread. Each is posted as a reply to the previous one, in order. If a later tweet fails, the first one stays live. Items: `text` (max 24). |
| `pollOptions` | object[] | No |  | Add a poll. 2–4 different options, up to 25 characters each. Polls can't be combined with media. Items: `label` (max 4). |
| `pollDurationMinutes` | number | No | `1440` | Poll duration (minutes). Only used when poll options are added. 5 minutes to 7 days (10080); default 1 day. |
| `quoteTweet` | string | No |  | Quote post. Optional post URL or ID to quote. X may restrict quoting via the API on some access tiers. |
| `madeWithAi` | boolean | No | `false` | Made with AI. Label the attached media as AI-generated. |
`quoteTweet` accepts a numeric post ID or an `x.com` / `twitter.com` status URL. `communityId` accepts a numeric ID or an `x.com/i/communities/<id>` URL, and `shareWithFollowers` requires a Community ID.

## Examples

### Thread with an image

```bash tab="cURL"
curl -X POST https://social-publish-api.onrender.com/v1/posts \
  -H "x-access-key: $RELAYCAST_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "content": "We just shipped v2.0. Here is what changed 🧵",
    "media": [{ "url": "https://cdn.example.com/launch.png", "type": "image", "altText": "Release notes screenshot" }],
    "targets": [{
      "accountId": "6f1c2a7e-4b8d-4f7a-9a51-2d3c4b5a6e7f",
      "options": {
        "replySettings": "following",
        "thread": [
          { "text": "1. Faster uploads for large videos." },
          { "text": "2. Scheduled posts now show in your calendar." }
        ]
      }
    }],
    "publishNow": true
  }'
```

```ts tab="Node.js"
const res = await fetch("https://social-publish-api.onrender.com/v1/posts", {
  method: "POST",
  headers: {
    "x-access-key": process.env.RELAYCAST_API_KEY!,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    content: "We just shipped v2.0. Here is what changed 🧵",
    media: [{ url: "https://cdn.example.com/launch.png", type: "image", altText: "Release notes screenshot" }],
    targets: [
      {
        accountId: "6f1c2a7e-4b8d-4f7a-9a51-2d3c4b5a6e7f",
        options: {
          replySettings: "following",
          thread: [
            { text: "1. Faster uploads for large videos." },
            { text: "2. Scheduled posts now show in your calendar." },
          ],
        },
      },
    ],
    publishNow: true,
  }),
});
const { data } = await res.json();
```

```python tab="Python"
import os, requests

res = requests.post(
    "https://social-publish-api.onrender.com/v1/posts",
    headers={"x-access-key": os.environ["RELAYCAST_API_KEY"]},
    json={
        "content": "We just shipped v2.0. Here is what changed 🧵",
        "media": [{"url": "https://cdn.example.com/launch.png", "type": "image", "altText": "Release notes screenshot"}],
        "targets": [{
            "accountId": "6f1c2a7e-4b8d-4f7a-9a51-2d3c4b5a6e7f",
            "options": {
                "replySettings": "following",
                "thread": [
                    {"text": "1. Faster uploads for large videos."},
                    {"text": "2. Scheduled posts now show in your calendar."},
                ],
            },
        }],
        "publishNow": True,
    },
)
data = res.json()["data"]
```

### Poll

```json title="Request body"
{
  "content": "Which feature should we build next?",
  "targets": [{
    "accountId": "6f1c2a7e-4b8d-4f7a-9a51-2d3c4b5a6e7f",
    "options": {
      "pollOptions": [{ "label": "Dark mode" }, { "label": "Calendar view" }, { "label": "CSV export" }],
      "pollDurationMinutes": 2880
    }
  }],
  "scheduledFor": "2026-11-02T15:00:00Z"
}
```

### Quote post in a Community

```json title="Request body"
{
  "content": "Great write-up on API design.",
  "targets": [{
    "accountId": "6f1c2a7e-4b8d-4f7a-9a51-2d3c4b5a6e7f",
    "options": {
      "quoteTweet": "https://x.com/example/status/1850000000000000000",
      "communityId": "1493446837214187523",
      "shareWithFollowers": true
    }
  }],
  "publishNow": true
}
```

## Analytics & comments

Post analytics are available from **Posts → Analytics** in the dashboard: impressions, likes, replies (comments), reposts + quotes (shares) and bookmarks (saves). Link and profile clicks are only returned by X for your own posts within 30 days of posting. Each analytics load is a billed post read on X's pay-per-use pricing.

Comments and DMs are not supported for X.

## Limitations & common errors

| Error | Cause |
| --- | --- |
| `X posts allow 280 characters …` | Text is over the weighted limit. Shorten it or set `longPost: true` on a Premium account. |
| `X allows one video per post, without other media` | A video was combined with other media. |
| `X allows one GIF per post, without other media` | A GIF (URL ending in `.gif`) was combined with other media. |
| `X posts can't have both a poll and media` | Remove the media or the poll. |
| `Share with followers needs a Community ID` | `shareWithFollowers` without `communityId`. |
| Target fails with "duplicate of a recent post" | X rejects identical text posted shortly after an earlier post. |
| Target fails with a plan / credits message | The X developer app has no paid access or credits. |
| Account becomes `reconnect_required` | The token was revoked or expired without a valid refresh token. Reconnect from Integrations. |

> [!NOTE]
> If a later post in a thread fails, the first post stays live and the target is still marked `published` with the first post's URL. The remaining thread posts are skipped rather than retried, so the first post is never duplicated.
