X API: How to Post a Tweet
Every X API tutorial shows the same three-line text post and stops there. This one covers why media upload still needs OAuth 1.0a and a v1.1 endpoint while the post itself is v2, the full upload sequence with polling, threads, and which errors are terminal. Each call is annotated with its cost.
Posting to the X API is one request. Here it is:
curl -X POST "https://api.x.com/2/tweets" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"text": "Shipped."}'
{ "data": { "id": "1919283746554321000", "text": "Shipped." } }
That call costs $0.015. If the text had contained a URL it would have cost $0.200, which is 13.3 times more. Since X moved to pay-per-use, every code decision in this guide is also a cost decision, so each section says what it charges.
Authentication: pick the one that covers media
This is the decision that determines whether your integration can post images and video, and most guides get it wrong by omission.
X has two user-context auth schemes, and they do not cover the same endpoints.
| OAuth 2.0 with PKCE | OAuth 1.0a | |
|---|---|---|
v2 endpoints (POST /2/tweets) | Yes | Yes |
v1.1 media upload (upload.x.com) | No | Yes |
| Credentials | Client id and secret, per-user access and refresh token | App key and secret, per-user access token and secret |
| Expiry | Access token expires, needs offline.access to refresh | No expiry until the user revokes |
| Rotation | Refresh token rotates on every use | Nothing to rotate |
Text posting works fine on OAuth 2.0. Media upload does not, because the endpoint that actually accepts bytes is still the v1.1 one on upload.x.com, and it takes OAuth 1.0a signatures. This is the reason behind the most common question about this API: text posts work, images fail, and nothing in the error says why.
We run OAuth 1.0a in production for exactly that reason. One credential set covers both the v2 post and the v1.1 upload, and there is no token expiry to schedule around. The three-legged flow is POST oauth/request_token, send the user to oauth/authorize, then exchange the returned oauth_verifier for an access token and secret that do not expire.
If you take the OAuth 2.0 route anyway, four scopes cover publishing:
| Scope | Why |
|---|---|
tweet.read | Required alongside write |
tweet.write | Create and delete posts |
users.read | Resolve the authenticated user |
offline.access | Issues a refresh token. Without it the access token dies and cannot be renewed |
offline.access is the one people omit. Skip it and the integration works for a couple of hours, then asks the user to reconnect, forever. X also rotates the refresh token on every use: the response carries a new one and the old stops working immediately, so both tokens have to be persisted in a single write. The mechanics of that failure, and which other platforms share it, are in OAuth token refresh for social media APIs.
Text posts
curl -X POST "https://api.x.com/2/tweets" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"text": "Two backend roles open. Remote, Europe."}'
{ "data": { "id": "1919283746554321000", "text": "Two backend roles open. Remote, Europe.", "edit_history_tweet_ids": ["1919283746554321000"] } }
Character limits depend on the account's subscription, not on your app: 280 characters on free and Basic, 25,000 on Premium and Premium+. If you are publishing for other people's accounts you cannot hardcode either. Check the account's subscription tier at connection time and store it, or you will truncate a Premium user's post or let a free user's post fail validation at X.
Deleting is straightforward and costs $0.010:
curl -X DELETE "https://api.x.com/2/tweets/${POST_ID}" \ -H "Authorization: Bearer ${ACCESS_TOKEN}"
There is no edit endpoint in the public API. edit_history_tweet_ids in the response is a read artefact, not an invitation.
Uploading media

Media does not go to the posts endpoint, and it does not go to a v2 endpoint at all. It goes to https://upload.x.com/1.1/media/upload.json, signed with OAuth 1.0a, in three commands against that one URL. Video adds a fourth.
This is the part worth being blunt about: the post is v2 and the upload is v1.1. Different API versions, different auth requirements, in the same publish flow. X documents the chunked sequence in its media upload reference. Everything below is the path we run in production.
${OAUTH1_HEADER} in the examples stands for the signed OAuth 1.0a header your client library builds (OAuth oauth_consumer_key=..., oauth_token=..., oauth_signature=...). Do not hand-roll the signing.
Step 1: INIT. Declare what you are about to send.
curl -X POST "https://upload.x.com/1.1/media/upload.json" \ -H "Authorization: ${OAUTH1_HEADER}" \ -d "command=INIT" \ -d "total_bytes=2048576" \ -d "media_type=video/mp4" \ -d "media_category=tweet_video"
{ "media_id": 1919283746000000001, "media_id_string": "1919283746000000001", "expires_after_secs": 86400 }
Use media_id_string, not media_id. The numeric form exceeds what a JavaScript number holds precisely, and a silently rounded id fails at attach time with an "invalid media ids" error that points nowhere useful.
media_category matters too. tweet_image, tweet_video and tweet_gif are processed differently, and getting it wrong surfaces at FINALIZE rather than at INIT.
Step 2: APPEND. Send the bytes in chunks against the same URL.
curl -X POST "https://upload.x.com/1.1/media/upload.json" \ -H "Authorization: ${OAUTH1_HEADER}" \ -d "command=APPEND" \ -d "media_id=1919283746000000001" \ -d "segment_index=0" \ -d "media=<base64-encoded chunk>"
Chunks of 5 MB, base64 encoded, with segment_index starting at zero and incrementing by one. The index is the byte offset divided by the chunk size, so a gap in the sequence fails the whole upload at the next step. Stream the source rather than loading the file into memory; a 512 MB video is 103 chunks and you do not want all of it resident.
Step 3: FINALIZE.
curl -X POST "https://upload.x.com/1.1/media/upload.json" \ -H "Authorization: ${OAUTH1_HEADER}" \ -d "command=FINALIZE" \ -d "media_id=1919283746000000001"
For an image, that is the end. For video, the response carries a processing_info block and the media is not usable yet:
{ "media_id_string": "1919283746000000001", "size": 2048576, "expires_after_secs": 86400, "video": { "video_type": "video/mp4" }, "processing_info": { "state": "pending", "check_after_secs": 5, "progress_percent": 0 } }
FINALIZE can also fail terminally right here, with state: "failed" and an error object. Surface that immediately rather than letting it fall into the polling loop as "pending", or a file X has already rejected sits in your queue burning retries.
Step 4: poll until processing finishes. This is the step tutorials skip, and it is why "my image posts work but my video posts fail" is such a common question.
curl -G "https://upload.x.com/1.1/media/upload.json" \ -H "Authorization: ${OAUTH1_HEADER}" \ -d "command=STATUS" \ -d "media_id=1919283746000000001"
States are pending, in_progress, succeeded and failed. Respect check_after_secs rather than polling on your own schedule. On failed, the error object names what X rejected, and it is terminal: the same file will fail again.
One thing that is easy to get wrong: a response with no processing_info at all means the media is ready. Images come back that way. Treating a missing block as "not done yet" hangs every image upload until the poll times out.
Then attach it to a post:
curl -X POST "https://api.x.com/2/tweets" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "text": "The new build.", "media": { "media_ids": ["1919283746000000001"] } }'
Up to 4 images, or 1 video, in one post. Images up to 5 MB, video up to 512 MB. Video duration is capped by the account's subscription: 2 minutes 20 seconds on free and Basic, 10 minutes on Premium. Aspect ratio between 0.33 and 3. The cross-platform version of this table is in media requirements per platform.
Media ids expire after 24 hours. Upload close to when you publish, not when the customer schedules.
Threads and replies
A thread is a chain of posts, each replying to the previous one.
# First post curl -X POST "https://api.x.com/2/tweets" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"text": "Three things we got wrong this quarter."}' # → { "data": { "id": "1919283746554321000" } } # Second post, replying to the first curl -X POST "https://api.x.com/2/tweets" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "text": "One. We shipped the migration before the backfill finished.", "reply": { "in_reply_to_tweet_id": "1919283746554321000" } }'
Two things to decide before you build this.
A thread is n billable writes, and any post in the chain containing a URL is charged at the URL rate. A ten-post thread ending with a link to your blog costs $0.135 for the first nine plus $0.200 for the last.
Partial failure needs a policy. If post four of eight fails, you have a truncated thread that is live and cannot be cleanly rolled back. Deleting the first three is destructive and the user may have already seen them. Our position is to fail loudly with the ids that did publish, and let a human decide, rather than silently continuing or silently unwinding.
What each call costs

Verified against X's pricing documentation on 31 July 2026.
| Operation | Cost |
|---|---|
| Create a post | $0.015 |
| Create a post containing a URL | $0.200 |
| Delete a post | $0.010 |
| Read a post | $0.005 |
| Owned reads (your app reading its own data) | $0.001 |
| DM and user interactions | $0.015 |
Webhook post.create delivered | $0.005 |
The URL multiplier is the number that changes how you write code. 13.3 times. For a product whose whole purpose is publishing links, that is not a line item, it is the business model.
Two consequences that are easy to miss:
A link in any position counts. A thread's final post with a "read more" link is charged at the URL rate even though the other nine are not.
Detect URLs before you send, not after you are billed. If your product lets users compose freely, showing the cost difference at compose time is more useful than explaining the invoice later.
The full rate card, the 2 million read cap and cost modelling at different volumes are in X API pricing.
bundle.social
OAuth 1.0a for media, v2 for the post, and the cost of each call. All handled.
$0.015 per post, $0.200 for a post containing a link. No quote call, no minimum.
Errors that are terminal
Retrying these wastes money, because a rejected write can still cost you the attempt.
| Error | Meaning | Retry? |
|---|---|---|
| Duplicate content | X refuses identical text from the same account | No. Change the text or accept the failure |
| Account suspended | Terminal until the user resolves it with X | No |
| Account temporarily locked | Usually a security checkpoint the user must clear | No |
| Invalid or expired token | Refresh first, then retry once | Refresh, then yes |
| Media ids are invalid | Expired (24 h) or from a failed upload | No. Re-upload |
| Video longer than allowed | Subscription-dependent duration limit | No |
| Not permitted to perform this action | Missing scope or restricted account | No |
| Access package | The operation is not in your access level | No |
| 429 | Rate limited | Yes, with backoff |
| 5xx | X-side failure | Yes, with backoff |
The duplicate rule is the one that ambushes schedulers. A retry after a timeout, where the original actually succeeded, comes back as a duplicate rejection. That rejection is useful information: it usually means the first attempt worked. Check before you report a failure to the customer. The general pattern for this, across platforms, is in social media API error handling.
The shorter path
OAuth 2.0 with PKCE and rotating refresh tokens, a four-step media flow with polling, character and duration limits that depend on the end user's subscription rather than your app, and a per-request bill where one content decision changes the cost by a factor of thirteen.
bundle.social's X API handles the token lifecycle, the media upload and the retry policy. Costs run through a prepaid wallet, so you see what a post will cost before it goes out rather than at the end of the month. One call, media included.
Frequently asked questions
How do I post a tweet with the X API?
POST https://api.x.com/2/tweets with a JSON body containing text, authenticated with a user-context OAuth 2.0 bearer token carrying the tweet.write scope. The call costs $0.015, or $0.200 if the text contains a URL.
How do I attach an image or video to a post?
Upload it to https://upload.x.com/1.1/media/upload.json with the INIT, APPEND and FINALIZE commands, poll command=STATUS until processing_info.state is succeeded for video, then pass media_id_string in the media.media_ids array when creating the post. Media ids expire after 24 hours.
Why do my text posts work but media uploads fail?
Because they are different APIs. The post is v2 and accepts OAuth 2.0, while media upload is still v1.1 on upload.x.com and requires OAuth 1.0a. An OAuth 2.0 bearer token that posts text perfectly will not upload a file.
Why does my video upload succeed but the post fail?
Usually because FINALIZE returned while the video was still processing. The media id exists but is not usable until STATUS reports succeeded. Images come back with no processing_info block at all, which means ready, not pending.
Do I need offline.access?
Only on OAuth 2.0, and then yes. Without it X issues no refresh token, so the integration stops when the access token expires and the user has to reconnect manually. OAuth 1.0a tokens do not expire, which is one reason we use them.
Why does a post with a link cost so much more?
X prices link-containing posts at $0.200 against $0.015 for a plain post, a 13.3 times difference. It is a deliberate pricing choice, and for products that publish links it dominates the cost model.