# Threads, polls and media with the X API v2

All three go through one endpoint, `POST /2/tweets`, with a token for the account that posts. A thread is a chain of
replies, a poll is a field on the post, and an image or video is uploaded first and attached by its ID.

Each request here was checked on October 26, 2026 against X's published
[OpenAPI spec](https://api.x.com/2/openapi.json) (version 2.172) and its docs, linked under [Sources](#sources). None
was sent: real calls cost money. Prices are from X's [pricing page](https://docs.x.com/x-api/getting-started/pricing),
checked the same day. X can change both, so check them before you build on them.

## Before you start

- **A user token for the posting account.** Posting needs OAuth 2.0 with `tweet.read`, `tweet.write` and `users.read`,
  or OAuth 1.0a user context. Uploads also need `media.write`. An app-only bearer token can't post. The examples use
  `$USER_ACCESS_TOKEN`, as X's own do.
- **Credits.** X's API is pay per use: you buy credits in X's Developer Console, and each call is paid from them.

## A thread: each part replies to the one before

X's API has no thread call. Post the first part, keep the `id` X returns, then post each next part as a reply to the
part before it.

```sh
# Part 1
curl -X POST "https://api.x.com/2/tweets" \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"text": "We shipped weekly charts today. Here is what changed."}'
# {"data": {"id": "1880000000000000001", "text": "We shipped weekly charts today. …"}}

# Part 2, a reply to part 1
curl -X POST "https://api.x.com/2/tweets" \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"text": "Charts now cover any week you pick.",
       "reply": {"in_reply_to_tweet_id": "1880000000000000001"}}'

# Part 3 replies to part 2's id, and so on.
```

- **Each part is its own request**, billed as a post: $0.015, or $0.200 for a part with a link. Five plain parts cost
  $0.075; a link in one of them makes it $0.260.
- **A failure halfway leaves the first parts live.** Keep each `id` as it comes back and carry on from the last live
  part. Starting again from the top posts part 1 twice.
- **Leaving accounts out of a reply.** `reply.exclude_reply_user_ids` takes the user IDs to leave out. X's spec also
  lists `reply.auto_populate_reply_metadata`, but says it's dropped before the post is made.

## A poll: one field on the post

```sh
curl -X POST "https://api.x.com/2/tweets" \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"text": "What should we build next?",
       "poll": {"options": ["Better charts", "More exports"], "duration_minutes": 1440},
       "reply_settings": "following"}'
```

- **2 to 4 options**, each 1 to 25 characters, in the order they show.
- **`duration_minutes` from 5 to 10,080** (7 days). 1,440 is one day.
- **Who can reply** is `reply_settings` on the post: `following`, `mentionedUsers`, `subscribers` or `verified`. Leave
  it out for everyone. X's spec accepts `reply_settings` inside `poll` too, but drops it there, so set it on the post.
- **A poll costs the same as a post**: $0.015, or $0.200 if its text has a link.
- **To read it back**, ask for the poll by name. Each post read costs $0.005.

```sh
curl -G "https://api.x.com/2/tweets" \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
  -d ids=1880000000000000003 -d expansions=attachments.poll_ids \
  -d poll.fields=options,duration_minutes
```

## An image or video: upload, then attach

Upload the file first, then put the `id` X returns in `media.media_ids`. A post takes up to 4 photos, 1 animated GIF
or 1 video.

Video, and any large file, goes through X's chunked upload: start it, send the file in pieces, finish it, and wait while
X processes it.

```sh
# 1. Start: the file's type, its exact size in bytes, and what it's for
curl -X POST "https://api.x.com/2/media/upload/initialize" \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"media_type": "video/mp4", "total_bytes": 10485760,
       "media_category": "tweet_video"}'
# {"data": {"id": "1880028106020515840", "expires_after_secs": 86400, …}}

# 2. Send pieces of 5 MB or less (4 MB here), numbered from 0
split -b 4194304 clip.mp4 part_
curl -X POST "https://api.x.com/2/media/upload/1880028106020515840/append" \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
  -F "segment_index=0" -F "media=@part_aa"
# Then segment_index=1 with part_ab, and so on.

# 3. Finish. If the answer has processing_info, check until it says succeeded
curl -X POST "https://api.x.com/2/media/upload/1880028106020515840/finalize" \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN"
curl -G "https://api.x.com/2/media/upload" \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
  -d command=STATUS -d media_id=1880028106020515840

# 4. Post it
curl -X POST "https://api.x.com/2/tweets" \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"text": "Weekly charts, in 40 seconds.",
       "media": {"media_ids": ["1880028106020515840"]}}'
```

A small image can go up in one request instead. Give it alt text before you post it:

```sh
curl -X POST "https://api.x.com/2/media/upload" \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
  -F "media=@chart.png" -F "media_category=tweet_image"
# {"data": {"id": "1880028106020515841", …}}

curl -X POST "https://api.x.com/2/media/metadata" \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"id": "1880028106020515841",
       "metadata": {"alt_text": {"text": "Bar chart of weekly views, up 40%"}}}'
```

- **Size limits.** Images 5 MB, animated GIFs 15 MB, video up to 20 minutes and 8 GB (125 minutes and 16 GB for an X
  Premium or verified account). X checks size and length again when you post, against the posting account, so an
  upload that worked can still be refused with a 403.
- **Not the old command style.** Don't send `command=INIT`, `APPEND` or `FINALIZE` to `/2/media/upload`: that was
  X's previous upload protocol. `command=STATUS` is only for the status check.
- **Upload close to posting time.** The upload expires after `expires_after_secs`: 86,400 seconds, a day, in X's
  example.
- **Alt text** is one `POST /2/media/metadata` per file, up to 1,000 characters.
- **Uploads have no listed price.** X's pricing table doesn't list the upload calls, so their cost is unknown, not
  free. Alt text is billed as Media Metadata, $0.005 a request.

## What each call costs

| Call | X bills it as | Price |
|---|---|---|
| `POST /2/tweets`: a post, a thread part or a poll | Post: Create | $0.015 a request |
| `POST /2/tweets` with a link in the text | Post: Create (with URL) | $0.200 a request |
| `/2/media/upload`: one-step, initialize, append, finalize, status | Not in X's table | Not listed |
| `POST /2/media/metadata`: alt text | Media Metadata | $0.005 a request |
| `GET /2/tweets`: reading posts back | Posts: Read | $0.005 a post |

A three-part thread with an image and alt text on part 1, a link in part 3, and one read-back of all three parts costs
$0.250, plus whatever X charges for the upload: $0.030 for parts 1 and 2, $0.200 for part 3, $0.005 for the alt text
and $0.015 to read the three back. X charges a read of the same post once per UTC day, which it calls a soft guarantee.
[X API pricing, explained](/docs/guides/x-api-pricing) has monthly totals.

## What Flaghand adds

Flaghand is a publishing desk where your agent drafts and the owner approves. The agent sends Flaghand the content, not
the X calls: a thread is one draft of 2 to 25 posts, a poll is the same `poll` object, and media is uploaded to
Flaghand once, with its alt text.

```sh
curl -X POST "https://app.flaghand.com/api/v1/workspaces/$WS/drafts" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"kind": "x_thread", "connection_id": "con_…",
       "post_at": "2026-11-02T16:30:00.000Z", "body": {"posts": [
         {"text": "What should we build next?", "poll": {
           "options": ["Better charts", "More exports"], "duration_minutes": 1440}},
         {"text": "Here is the chart we have now.", "media": [
           {"media_id": "med_…", "alt": "Bar chart of weekly views"}]}]}}'
```

The agent then checks the draft and asks the owner to approve it. From there:

- **The owner approves the exact post.** No agent token can approve, schedule or post. The owner approves in the desk
  with a passkey, and the approval freezes the account, time, text, poll, reply setting, media and alt text into a
  snapshot hashed with SHA-256. Any change needs a new approval, and the scheduler checks the snapshot again right
  before posting.
- **The preview matches what posts.** The desk shows the thread, the poll (unvoted) and the media the way X lays them
  out. The check before approval flags anything that wouldn't post exactly as previewed: X's character count, media
  counts, sizes and alt-text lengths, the poll's rules, and whether the account's login lasts to the post time. It
  also returns the exact request it would send to X.
- **Flaghand makes the X calls.** At the approved time it uploads the media through X's chunked endpoints, adds the alt
  text and posts the thread as a reply chain. If a part fails, a retry carries on after the last live part. Replies
  under the account's own posts leave out the accounts the part above mentions, which X would otherwise add as hidden
  mentions.
- **It reads each post back.** After posting, Flaghand compares the live posts with what you approved: normalized text,
  author, reply chain, hidden reply mentions, media count, available alt text, the poll's choices, order and duration,
  and the opening post's reply setting. It doesn't verify link destinations or the media files themselves.
- **No X credits to buy.** Flaghand prepays X's API.

**Where it does less:** files up to 50 MB, so no long videos; X and LinkedIn personal profiles only; and it's in early
access, so sign-up isn't open yet. Every field is in Flaghand's [drafts and media docs](https://app.flaghand.com/docs/drafts-and-media)
and [agent documentation](https://app.flaghand.com/docs/agents). [Get access to Flaghand](/schedule).

## Sources

All read on October 26, 2026. No X API call was made for this guide.

- X's [OpenAPI spec](https://api.x.com/2/openapi.json), version 2.172
- Posts: [create post](https://docs.x.com/x-api/posts/create-post),
  [manage posts quickstart](https://docs.x.com/x-api/posts/manage-tweets/quickstart),
  [get posts by IDs](https://docs.x.com/x-api/posts/get-posts-by-ids)
- Media: [media upload](https://docs.x.com/x-api/media/introduction),
  [chunked upload](https://docs.x.com/x-api/media/quickstart/media-upload-chunked),
  [one-step upload](https://docs.x.com/x-api/media/upload-media),
  [alt text](https://docs.x.com/x-api/media/create-media-metadata)
- Prices: [pricing](https://docs.x.com/x-api/getting-started/pricing)

[All guides](/docs/guides.md)
