API GuidesAugust 17, 202612 min readingMarcel Czuryszkiewicz

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 PKCEOAuth 1.0a
v2 endpoints (POST /2/tweets)YesYes
v2 media upload (api.x.com/2/media/upload)Yes, with media.writeYes
Legacy v1.1 upload (upload.x.com)NoYes
CredentialsClient id and secret, per-user access and refresh tokenApp key and secret, per-user access token and secret
ExpiryAccess token expires, needs offline.access to refreshNo expiry until the user revokes
RotationRefresh token rotates on every useNothing to rotate

Text posting works fine on OAuth 2.0, and media does too - on the v2 endpoint, and only with the media.write scope (media upload quickstart). The legacy v1.1 endpoint on upload.x.com takes OAuth 1.0a signatures only. Either way, the standard publishing scope set does not cover uploads, which 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: one credential set covers both the post and either upload path, with no extra scope to re-consent and 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, five scopes cover publishing with media:

ScopeWhy
tweet.readRequired alongside write
tweet.writeCreate and delete posts
users.readResolve the authenticated user
media.writeUpload media on POST /2/media/upload. Granted on the consent screen, so accounts connected without it must re-authorize
media.writeUpload media on POST /2/media/upload. Granted on the consent screen, so accounts connected without it must re-authorize
offline.accessIssues 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

A fruit packing plant seen down its length, apples moving along several parallel conveyor lines with workers standing at separate stations along each one
INIT, APPEND, FINALIZE. Then wait for video.

Media does not go to the posts endpoint. It has an API of its own: POST https://api.x.com/2/media/upload, three commands against that one URL, with video adding a fourth. The legacy v1.1 endpoint at upload.x.com/1.1/media/upload.json still accepts the identical command flow signed with OAuth 1.0a, and X has announced no sunset date for it. The deeper tour of both paths, including the dedicated /2/media/upload/initialize|append|finalize resources, is in X API media upload.

This is the part worth being blunt about: the post and the upload are different APIs - a separate OAuth scope on the v2 path, a separate host on the legacy one. One page of X's own docs still claims media cannot be fully uploaded on v2; the media upload reference two pages away documents exactly that. The commands below match the v2 quickstart, and the same sequence against the legacy host is what our production adapter still runs.

The examples authenticate with an OAuth 2.0 user token carrying media.write. On the legacy v1.1 host, swap the Bearer header for the signed OAuth 1.0a header your client library builds - do not hand-roll the signing.

Step 1: INIT. Declare what you are about to send.

curl -X POST "https://api.x.com/2/media/upload" \
  -H "Authorization: Bearer ${ACCESS_TOKEN}" \
  -F "command=INIT" \
  -F "total_bytes=2048576" \
  -F "media_type=video/mp4" \
  -F "media_category=tweet_video"
{
  "data": {
    "id": "1919283746000000001",
    "media_key": "13_1919283746000000001",
    "expires_after_secs": 86400
  }
}

The v2 response carries the id in data.id, already a string - use it as-is. On the legacy v1.1 endpoint, use media_id_string and never the numeric 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://api.x.com/2/media/upload" \
  -H "Authorization: Bearer ${ACCESS_TOKEN}" \
  -F "command=APPEND" \
  -F "media_id=1919283746000000001" \
  -F "segment_index=0" \
  -F "[email protected]"

Chunks of 5 MB - our production setting; X's own quickstart cuts at 1 MB - sent as raw multipart bytes on v2 (the legacy v1.1 media field takes base64), 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://api.x.com/2/media/upload" \
  -H "Authorization: Bearer ${ACCESS_TOKEN}" \
  -F "command=FINALIZE" \
  -F "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:

{
  "data": {
    "id": "1919283746000000001",
    "media_key": "13_1919283746000000001",
    "size": 2048576,
    "expires_after_secs": 86400,
    "processing_info": {
      "state": "pending",
      "check_after_secs": 5
    }
  }
}

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://api.x.com/2/media/upload" \
  -H "Authorization: Bearer ${ACCESS_TOKEN}" \
  -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

The window of a vintage shop at dusk, a neon Vintage sign glowing behind the glass above a table of second-hand goods
Verified 31 July 2026. Check the date before you trust it.

Verified against X's pricing documentation on 31 July 2026.

OperationCost
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 3 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.

One API to schedule, publish manage and analyze content across your social media channels at scale.

Errors that are terminal

Retrying these wastes money, because a rejected write can still cost you the attempt.

ErrorMeaningRetry?
Duplicate contentX refuses identical text from the same accountNo. Change the text or accept the failure
Account suspendedTerminal until the user resolves it with XNo
Account temporarily lockedUsually a security checkpoint the user must clearNo
Invalid or expired tokenRefresh first, then retry onceRefresh, then yes
Media ids are invalidExpired (24 h) or from a failed uploadNo. Re-upload
Video longer than allowedSubscription-dependent duration limitNo
Not permitted to perform this actionMissing scope or restricted accountNo
Access packageThe operation is not in your access levelNo
429Rate limited - X API rate limits covers which of the three counters trippedYes, with backoff
5xxX-side failureYes, 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 POST https://api.x.com/2/media/upload with the INIT, APPEND and FINALIZE commands - the legacy v1.1 endpoint on upload.x.com takes the same flow - poll command=STATUS until processing_info.state is succeeded for video, then pass the returned media id 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?

Almost always auth. On v2, uploads require the media.write scope, which the standard publishing scope set does not include; the legacy v1.1 host accepts only OAuth 1.0a signatures. An OAuth 2.0 bearer token that posts text perfectly will be refused by the upload endpoint until it carries media.write - or you upload over OAuth 1.0a.

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.

Marcel Czuryszkiewicz
Written by

Marcel Czuryszkiewicz

Co-Founder

Marcel is one of the builders behind bundle.social, creating a social media API with a strong focus on developer experience, reliability, and real support. Before bundle.social, he worked at Samsung R&D, Reply AI, and Docplanner, building internal tools that helped companies grow. He brought that same practical, product-focused mindset to bundle.social, so you can trust that your project is in good hands.