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 (version 2.172) and its docs, linked under Sources. None was sent: real calls cost money. Prices are from X's pricing page, 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.writeandusers.read, or OAuth 1.0a user context. Uploads also needmedia.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.
# 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
idas 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_idstakes the user IDs to leave out. X's spec also listsreply.auto_populate_reply_metadata, but says it's dropped before the post is made.
A poll: one field on the post
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_minutesfrom 5 to 10,080 (7 days). 1,440 is one day.- Who can reply is
reply_settingson the post:following,mentionedUsers,subscribersorverified. Leave it out for everyone. X's spec acceptsreply_settingsinsidepolltoo, 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.
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.
# 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:
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,APPENDorFINALIZEto/2/media/upload: that was X's previous upload protocol.command=STATUSis 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/metadataper 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 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.
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 and agent documentation. Get access to Flaghand.
Sources
All read on October 26, 2026. No X API call was made for this guide.
- X's OpenAPI spec, version 2.172
- Posts: create post, manage posts quickstart, get posts by IDs
- Media: media upload, chunked upload, one-step upload, alt text
- Prices: pricing