# X

Post to X with threads, replies, quotes, polls and media, edit recent posts, retweet, bookmark and follow, send DMs and read post analytics.

Publish posts, threads, replies, quotes, polls and long-form Articles to X, edit recent posts, and act as the account (retweet, bookmark, follow, search). The same connection reads post analytics, sends and receives DMs and manages replies to your posts. In the API the platform id is `twitter`; everywhere a person reads it, show **X**.

## What you can do

| Capability | Supported | Where |
| --- | --- | --- |
| Publish | Text, up to 4 images, 1 GIF or 1 video, threads, replies, quotes, polls, Articles | [Create a post](https://pozy.ai/docs/publishing) |
| Schedule | Yes | [Scheduling](https://pozy.ai/docs/publishing/scheduling) |
| Edit after publishing | Text, within 1 hour, Premium accounts | [Edit](#edit-a-published-post) |
| Engagement | Retweet, bookmark, follow, search recent posts, look up a post | [X Engagement endpoints](#endpoints) |
| Analytics | Impressions, likes, replies, reposts, bookmarks, clicks, media views | [Platform insights](https://pozy.ai/docs/analytics/platform-insights) |
| DMs | Start conversations, reply, attachments | [Messages](https://pozy.ai/docs/inbox/messages) |
| Comments | Replies to your posts: list, reply, like, hide, delete | [Comments](https://pozy.ai/docs/inbox/comments) |
| Ads | Through an X Ads connection | [X Ads](https://pozy.ai/docs/ads/x-ads) |

## Connect

> **A card on file is required:**
> 
> X charges for every API call, starting with the first one, so Pozy bills X usage per call at the rates on the [pricing page](https://pozy.ai/pricing). Until the workspace has a payment method, [`GET /v1/connect/twitter`](https://pozy.ai/docs/api/getConnectUrl) returns `402` with `type: "payment_required"`, `reason: "twitter_passthrough"` and a `dashboard_url` where the user can add a card. This applies on the free tier too. Rates are on [Pricing](https://pozy.ai/docs/billing).

With a card on file, call the connect endpoint with a `profileId` and send the user to `authUrl`. After they approve, they land on your `redirect_url` with `connected=twitter`, `profileId` and `accountId` appended.

```bash cURL
curl "https://api.pozy.ai/v1/connect/twitter?profileId=6720c0f24f3b2e0018d4a6e0&redirect_url=https://app.example.com/connected" \
  -H "Authorization: Bearer $POZY_API_KEY"
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/connect/twitter?profileId=6720c0f24f3b2e0018d4a6e0&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", {
  headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` },
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);

// Redirect the user to data.authUrl
```
```python Python
import os, requests

resp = requests.get(
    "https://api.pozy.ai/v1/connect/twitter",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    params={"profileId": "6720c0f24f3b2e0018d4a6e0", "redirect_url": "https://app.example.com/connected"},
)
print(resp.json()["authUrl"])
```

If the user denies consent, they come back with `error=oauth_denied&platform=twitter` on the redirect URL. Treat any other `error` value as a generic failure.

### Spend cap

Each workspace can set a monthly cap on X spend in billing settings. Pozy emails a warning at 80% of the cap. At the cap, every billable X call (posts, DMs, reads) is refused before it reaches X with `402` and code `x_spend_cap_reached`, until the cap is raised or the month rolls over. Drafts never count.

## Post types

```bash cURL
curl -X POST https://api.pozy.ai/v1/posts \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Release 2.4 is out: faster sync and a new export screen.",
    "mediaItems": [{ "type": "image", "url": "https://cdn.example.com/release-2-4.png" }],
    "platforms": [{ "platform": "twitter", "accountId": "6720c1a94f3b2e0018d4a7f6" }],
    "publishNow": true
  }'
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/posts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    content: "Release 2.4 is out: faster sync and a new export screen.",
    mediaItems: [{ type: "image", url: "https://cdn.example.com/release-2-4.png" }],
    platforms: [{ platform: "twitter", accountId: "6720c1a94f3b2e0018d4a7f6" }],
    publishNow: true,
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);

console.log(data.post.platforms[0].platformPostUrl);
```
```python Python
import os, requests

resp = requests.post(
    "https://api.pozy.ai/v1/posts",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    json={
        "content": "Release 2.4 is out: faster sync and a new export screen.",
        "mediaItems": [{"type": "image", "url": "https://cdn.example.com/release-2-4.png"}],
        "platforms": [{"platform": "twitter", "accountId": "6720c1a94f3b2e0018d4a7f6"}],
        "publishNow": True,
    },
)
print(resp.json()["post"]["platforms"][0]["platformPostUrl"])
```

> **Cross-posting long text:**
> 
> A free X account allows 280 characters. When the same post goes to LinkedIn or Facebook too, give the X entry its own `customContent` under the limit, or the X copy fails with "Tweet text is too long" while the others publish.

### Thread

Put the whole sequence in `platformSpecificData.threadItems`. Item 0 is the first post and each item replies to the one before. Each item can have its own `mediaItems`. When `threadItems` is set, the top-level `content` is kept for search and display in Pozy but is not published, so the first post must be `threadItems[0]`.

```json
{
  "platform": "twitter",
  "accountId": "6720c1a94f3b2e0018d4a7f6",
  "platformSpecificData": {
    "threadItems": [
      { "content": "How we halved our cloud bill in 6 weeks. A thread.", "mediaItems": [{ "type": "image", "url": "https://cdn.example.com/bill-chart.png" }] },
      { "content": "1. We turned off every environment nobody had logged into for 30 days." },
      { "content": "2. We moved batch jobs to spot instances." },
      { "content": "3. We set budgets per team, with alerts. That one changed behaviour fastest." }
    ]
  }
}
```

A thread cannot carry a poll.

### Reply and quote

- **Reply:** `replyToTweetId` with the id of the post. X only accepts replies to your own posts or posts that mention you. Combine it with `threadItems` to reply with a whole thread; only the first item replies to the target. It cannot be combined with `replySettings`.
- **Quote:** `quoteTweetId` with the post id or its full URL. X only accepts quotes of your own posts, posts that mention you, or posts in a conversation you are part of. A quote cannot carry media or a poll. A quote made this way is billed at the normal post rate, while pasting the post's URL into `content` is billed as a post with a link, which costs much more.

To find ids, use [Search recent tweets](https://pozy.ai/docs/api/searchTweets) (the last 7 days) or resolve a URL with [Look up a tweet](https://pozy.ai/docs/api/getTweet).

### Poll

`poll` takes 2 to 4 `options` of 1 to 25 characters and `duration_minutes` from 5 to 10,080 (7 days). It cannot be combined with media, `threadItems` or `quoteTweetId`.

```json
"platformSpecificData": {
  "poll": { "options": ["Dark mode", "Offline sync", "More exports"], "duration_minutes": 1440 }
}
```

### Article

`article` publishes a long-form X Article on an eligible Premium+ account. It needs a `title` and a `content_state` in X's own block format (`blocks` with types such as `unstyled`, `header-one`, `ordered-list-item`, plus `entities` for links and images). `mode: "draft"` stops after creating the draft on X; `publish` (default) publishes it and makes two billable X calls. An optional `cover.url` takes a public JPG, PNG or WebP. Articles cannot be combined with any other post option.

### Platform fields

Schema: `TwitterPlatformData`.

| Field | Notes |
| --- | --- |
| `threadItems` | The full thread, first post first. |
| `replyToTweetId` | Post to reply to. |
| `quoteTweetId` | Post id or URL to quote. |
| `replySettings` | Who can reply: `following`, `mentionedUsers`, `subscribers` or `verified`. Everyone when left out. First post only in a thread. |
| `poll` | `{ options, duration_minutes }`. |
| `mediaTaggedUsers` | Up to 10 handles to tag in the attached media. |
| `geoRestriction.countries` | Hides the media (not the text) outside up to 25 countries. Ignored without media. |
| `sensitiveMedia` | `{ adultContent, graphicViolence, other }`, at least one `true`. Adds a warning to every attached item. |
| `paidPartnership` | Paid promotion label. First post only. Depends on X's access tier. |
| `madeWithAi` | AI-generated media label (images and video, not text). First post only. |
| `longVideo` | Uses X's long-video upload path. Applied only on accounts with a paid X subscription and ignored otherwise; some accounts also need allowlisting by X. |
| `article` | Long-form Article (see above). |

## Edit a published post

[Edit published post](https://pozy.ai/docs/api/editPost) with `platform: "twitter"` changes the text, but X only allows it on accounts with X Premium, within 1 hour of publishing, up to 5 times, on single posts (not threads), and for text only. X creates a new post id on every edit and Pozy returns it as `id`. Outside those rules the call returns `400`.

[Unpublish](https://pozy.ai/docs/api/unpublishPost) deletes a published post from X.

## Media requirements

A post carries up to 4 images, or 1 GIF, or 1 video. Use direct file URLs or [upload the file](https://pozy.ai/docs/publishing/media).

| | Images | GIF | Video |
| --- | --- | --- | --- |
| Formats | JPEG, PNG, WebP, GIF | GIF | MP4, MOV |
| Max size | 5 MB | 15 MB | 512 MB |
| Count | Up to 4 | 1 (takes all slots) | 1 |
| Dimensions | 4 x 4 to 8192 x 8192 | Up to 1280 x 1080 | 32 x 32 to 1920 x 1200 |
| Duration | | | From 0.5 seconds; the maximum is set by X per account |
| Other | 1200 x 675 (16:9) recommended | Auto-plays | Up to 40 fps and 25 Mbps; 1280 x 720, 30 fps, H.264, AAC recommended |

Send a GIF as `type: "gif"`.

## Limits

| Limit | Value |
| --- | --- |
| Post text | 280 characters (free), 25,000 (Premium). URLs count as 23, emojis as 2. |
| Posts per account per hour | 25. Extra posts are retried when the hour resets. |
| DMs | 200 requests per 15 minutes and 1,000 per 24 hours per account |
| Poll options | 2 to 4, 25 characters each |
| Edits | 5 within 1 hour, Premium only |

If X rate-limits an account, Pozy pauses publishing on it for 10 minutes, doubling on each further hit within 24 hours. Posts scheduled inside the pause fail with the same message. X rejects text it already has from the account, so repeated posts need changes.

The API cannot use Spaces, post to Communities, pin a post to the profile, create cards (use meta tags on the linked page) or send broadcast DMs.

## Analytics

[`GET /v1/analytics?platform=twitter`](https://pozy.ai/docs/api/getAnalytics) returns `impressions`, `likes`, `comments`, `shares`, `saves` (bookmarks), `clicks` and `views` per post. `views` is X's media view count, so text and image posts report `0`. The date range defaults to the last 90 days and can be up to 366 days. Every analytics read is an X call billed at X's rate.

## Engagement

Act as a connected account with the [X Engagement](#endpoints) endpoints. Each `POST` has a `DELETE` on the same path to undo it.

| Action | Call | Body |
| --- | --- | --- |
| Retweet | `POST /v1/twitter/retweet` | `accountId`, `tweetId` |
| Bookmark | `POST /v1/twitter/bookmark` | `accountId`, `tweetId` |
| Follow | `POST /v1/twitter/follow` | `accountId`, `targetUserId`. Protected accounts return `pending_follow: true`. |

A `403` means X refused the action for this account, usually a missing permission or a suspended account. Reconnect and accept every permission.

## Inbox

**DMs.** Start a conversation with [Create conversation](https://pozy.ai/docs/api/createInboxConversation), passing `accountId`, the recipient as `participantUsername` or `participantId`, and `message`. An existing conversation with that person is reused. Pozy first checks that the person accepts DMs from you and returns `422` `DM_NOT_ALLOWED` if not; pass `skipDmCheck: true` when you have already checked.

```bash
curl -X POST https://api.pozy.ai/v1/inbox/conversations \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "accountId": "6720c1a94f3b2e0018d4a7f6", "participantUsername": "acme_support", "message": "Thanks for flagging this. Which screen showed the error?" }'
```

Later messages go through [Send message](https://pozy.ai/docs/api/sendInboxMessage) and can include images or video up to 25 MB. Conversation lists are cached for 2 minutes. X's newer encrypted chat is not visible to any third-party app, so some conversations may look empty or show only your side. X DMs are billed at X's rate, not on Pozy's outbound message meter.

**Comments** are the replies to your posts. Reply with [Reply to comment](https://pozy.ai/docs/api/replyToInboxPost), adding `commentId` to answer a specific reply. `postId` can be the Pozy post id or the X post id, so posts made outside Pozy work too. You can also like, hide and delete replies. Send an `Idempotency-Key` when you retry, so a retry never posts twice.

## Errors you may see

| Message | Cause | Fix |
| --- | --- | --- |
| `402` `payment_required`, `reason: "twitter_passthrough"` | No card on file | Add a payment method at `dashboard_url`, then connect again. |
| `402` `x_spend_cap_reached` | The workspace's monthly X cap is used up | Raise or remove the cap in billing. |
| Tweet text is too long | Over 280 characters on a free account | Shorten the text or set `customContent`. |
| X does not allow duplicate tweets | Same text posted before | Change the text. |
| Rate limit hit. Please wait 10 minutes | X rate-limited the account | Wait for the pause to end. |
| Hourly limit reached (25/25) | Pozy's per-account hourly limit | Nothing; the post is retried when the hour resets. |
| `422` `DM_NOT_ALLOWED` | The recipient does not accept your DMs | They need to open DMs to you first. |
| `401` on later calls | X revoked the session | Reconnect the account. |

A rejected `publishNow: true` post returns `207` with `post.status: "failed"` and the reason in `platforms[].errorMessage`. A failed X post is not retried automatically.

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`POST /v1/twitter/retweet`](https://pozy.ai/docs/api/retweetPost) | Retweet a post |
| [`DELETE /v1/twitter/retweet`](https://pozy.ai/docs/api/undoRetweet) | Undo retweet |
| [`POST /v1/twitter/bookmark`](https://pozy.ai/docs/api/bookmarkPost) | Bookmark a tweet |
| [`DELETE /v1/twitter/bookmark`](https://pozy.ai/docs/api/removeBookmark) | Remove bookmark |
| [`POST /v1/twitter/follow`](https://pozy.ai/docs/api/followUser) | Follow a user |
| [`DELETE /v1/twitter/follow`](https://pozy.ai/docs/api/unfollowUser) | Unfollow a user |
| [`GET /v1/twitter/search`](https://pozy.ai/docs/api/searchTweets) | Search recent tweets |
| [`GET /v1/twitter/tweet`](https://pozy.ai/docs/api/getTweet) | Look up a tweet |
| [`POST /v1/inbox/conversations`](https://pozy.ai/docs/api/createInboxConversation) | Create conversation |
| [`POST /v1/inbox/conversations/{conversationId}/messages`](https://pozy.ai/docs/api/sendInboxMessage) | Send message |
| [`POST /v1/inbox/comments/{postId}`](https://pozy.ai/docs/api/replyToInboxPost) | Reply to comment |
| [`POST /v1/posts/{postId}/edit`](https://pozy.ai/docs/api/editPost) | Edit published post |
| [`POST /v1/posts/{postId}/unpublish`](https://pozy.ai/docs/api/unpublishPost) | Unpublish post |

## Webhook events

| Event | When it fires |
| --- | --- |
| [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. |
| [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. |
| [`post.platform.published`](https://pozy.ai/docs/webhooks/events#post-platform-published) | Post platform published. |
| [`post.platform.failed`](https://pozy.ai/docs/webhooks/events#post-platform-failed) | Post platform failed. |
| [`post.platform.deleted`](https://pozy.ai/docs/webhooks/events#post-platform-deleted) | Post platform deleted. |
| [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. |
| [`comment.received`](https://pozy.ai/docs/webhooks/events#comment-received) | Comment received. |
