X API: How to Post a Tweet with POST /2/tweets
To post a tweet with the X API, send POST https://api.x.com/2/tweets with a JSON text field and a user access token: OAuth 2.0 with the tweet.read, tweet.write, and users.read scopes, or OAuth 1.0a. Upload media first and pass its IDs. X charges $0.015 per post and $0.20 per post with a link. Adeli publishes to X with one API key and passes X's fees through at X's rates.
To post a tweet with the X API, you send POST https://api.x.com/2/tweets with a JSON body containing text, authorized as the user you're posting for. That takes a user access token: OAuth 2.0 with the tweet.read, tweet.write, and users.read scopes, or OAuth 1.0a. X bills each call, $0.015 per post and $0.20 per post with a link.
Getting the developer app and tokens is covered in our X API access guide, and the full price list is in X API pricing. This post covers the posting call itself: the request, the media upload, threads, the body fields, and the errors. Every X fact links to X's developer docs, fetched 2026-10-19.
What do you need before you start?
- An X developer app from the Developer Console, with OAuth 2.0 turned on if you'll use it.
- Credits in your balance. X is pay-per-use, so requests are blocked when the balance runs out.
- A user access token for the account you're posting as. The app-only Bearer Token can't create posts.
- The
media.writescope too, if you'll attach images or video.
Which X API keys do I need for posting?
It depends on the auth method. X's Manage Posts guide accepts two kinds of user context for POST /2/tweets:
| OAuth 2.0 with PKCE | OAuth 1.0a User Context | |
|---|---|---|
| What you send | The user's access token as Authorization: Bearer |
Your API Key and Secret plus the user's Access Token and Secret, signed per request |
| Scopes for posting | tweet.read, tweet.write, users.read |
None; the tokens must belong to the posting user |
| For media | Add media.write |
Same tokens |
| For long-lived access | Add offline.access to get a refresh token |
Not applicable |
For your own account, the Access Token and Secret the console generates are enough. For other people's accounts, each user goes through the OAuth flow, which X API access walks through step by step.
Step 1: Post a text tweet
Send the text as JSON with the user's token.
curl -X POST "https://api.x.com/2/tweets" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"text": "Shipping notes for v2.4 are up."}'A success is 201 Created with the new post's ID, per the Create Post reference:
{
"data": {
"id": "1840000000000000000",
"text": "Shipping notes for v2.4 are up.",
"edit_history_post_ids": ["1840000000000000000"]
}
}Store data.id. You need it to reply, build a thread, or delete the post later.
Count characters the way X does. X's character counting guide allows 280 characters, but every URL counts as 23, every emoji as 2, and Chinese, Japanese, and Korean characters as 2 each, so the CJK limit is effectively 140.
Step 2: Upload media and attach it
Upload first, then pass the media ID in media.media_ids. A post takes up to 4 images, 1 GIF, or 1 video.
For an image, one request to POST /2/media/upload does it. Send the file as base64 in JSON or as raw bytes in multipart, with a media_category:
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"The response's data.id is the media ID. Then post with it:
curl -X POST "https://api.x.com/2/tweets" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"text": "Signups by week", "media": {"media_ids": ["<MEDIA_ID>"]}}'Video and large GIFs use the chunked flow, from X's chunked upload guide:
- Initialize.
POST /2/media/upload/initializewithmedia_type,total_bytes, andmedia_category(tweet_videoortweet_gif). It returns the media ID. - Append.
POST /2/media/upload/{id}/appendonce per chunk, withsegment_indexstarting at 0. X recommends chunks of 5 MB or less; 8 MB is the maximum. - Finalize.
POST /2/media/upload/{id}/finalize. If the response hasprocessing_info, the video is still processing. - Poll status.
GET /2/media/uploadwithcommand=STATUSand themedia_id, waitingcheck_after_secsbetween calls, until the state issucceeded.
| Category | Size limit |
|---|---|
tweet_image |
5 MB |
tweet_gif |
15 MB |
tweet_video |
20 minutes / 8 GB; 125 minutes / 16 GB for Premium or verified users |
The video cap belongs to the posting user, not your developer plan. A video over that user's limit uploads fine and then fails at POST /2/tweets with 403 Forbidden.
Step 3: Reply and build a thread
A thread is a chain of replies. Post the first tweet, then reply to its ID, then reply to the reply:
curl -X POST "https://api.x.com/2/tweets" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"text": "2/ The biggest change is the new export.", "reply": {"in_reply_to_tweet_id": "1840000000000000000"}}'Plan for these:
- Replies to other people need a summons on self-serve. X's Manage Posts guide says a reply is only allowed "if the replying account has been explicitly summoned by the original post's author," by an @mention or a quote. Enterprise isn't subject to the rule.
- Threads aren't atomic. If the fourth post fails, the first three are already live. Save each ID as you go so you can resume from the last one.
What else can the POST /2/tweets body do?
Polls, reply controls, disclosures, communities, and edits. The fields from the Create Post reference that most products use:
| Field | What it does | Limits |
|---|---|---|
poll |
A poll with options and duration_minutes |
2–4 options of 1–25 characters; 5 to 10,080 minutes |
reply_settings |
Who can reply: following, mentionedUsers, subscribers, or verified |
|
quote_tweet_id |
Quote another post | Enterprise only |
community_id |
Post into an X Community | |
made_with_ai |
Discloses AI-generated media | |
paid_partnership |
Labels the post as a paid partnership | |
edit_options.previous_post_id |
Edits an existing post instead of creating one | |
media.tagged_user_ids |
Tags up to 10 users in the media | Dropped if the user's photo tagging is off |
nullcast |
Keeps the post off the public timeline |
One cashtag per post on self-serve. X rejects a post with more than one $TICKER.
How much does each X API post cost?
Each successful create is billed from your credit balance. From X's pricing page, fetched 2026-10-19:
| Call | Price |
|---|---|
| Create a post | $0.015 per request |
| Create a post with a URL | $0.200 per request |
| Delete a post | $0.010 per request |
| Upload media | No price listed |
Links are the expensive part. A post with a URL costs about 13 times a plain post, and each post in a thread is billed separately, so a 5-post thread with one link costs $0.26. X's page doesn't define what counts as a URL. Adeli's records count a bare domain such as example.com as a link (X API pricing docs), so treat anything that looks like a domain as one. Scenarios at SaaS scale are in our X API pricing breakdown.
What are the rate limits for posting to X?
100 posts per 15 minutes per user and 10,000 per 24 hours per app, per X's rate limits page:
| Endpoint | Per app | Per user |
|---|---|---|
POST /2/tweets |
10,000 / 24 hours | 100 / 15 min |
DELETE /2/tweets/:id |
Not listed | 50 / 15 min |
POST /2/media/upload |
50,000 / 24 hours | 500 / 15 min |
| Chunked upload endpoints | 180,000 / 24 hours | 1,875 / 15 min |
X's own docs disagree. The Manage Posts guide lists 200 posts per 15 minutes per user and 300 per 3 hours. Read the x-rate-limit-limit, x-rate-limit-remaining, and x-rate-limit-reset headers on every response instead of hard-coding either number. A 429 with error code 88 means you've hit the limit; wait until the reset time.
How do you know it worked?
A 201 with data.id means the post is live. The failures you'll see most:
| Response | Cause | Fix |
|---|---|---|
403 Forbidden |
A video longer than the posting user's cap | Check the user's tier, or trim the video |
| Reply rejected | A reply to someone who didn't mention or quote the account (self-serve only) | Reply only when summoned, or move to Enterprise |
429, error code 88 |
Rate limit | Wait for x-rate-limit-reset |
| Requests blocked | Credit balance at zero | Top up, and set a spending limit in the console |
Sources: Manage Posts guide and rate limits.
X also refuses duplicates. Posting the same text the account published recently gets rejected, so vary the text of automated posts.
To delete a post, send DELETE https://api.x.com/2/tweets/{id} with the author's token. X's Manage Posts quickstart says you can only delete posts you authored, and the delete is billed.
Is there a faster way to post to X via API?
Yes, if your users are the ones posting. Posting directly means an X developer app, the OAuth flow, a refresh job for every account, the media upload steps, and resuming broken threads yourself.
Adeli's X API maintains the X developer app, has passed X's review, and refreshes every connected account's tokens, so you hold one API key. A post with a thread is one request to POST /api/v1/posts:
curl -X POST "https://app.tryadeli.com/api/v1/posts" \
-H "Authorization: Bearer $ADELI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"platform": "x",
"accountId": "00000000-0000-0000-0000-000000000000",
"text": "Herons fish at dawn. A thread:",
"thread": [{ "text": "They stand still for minutes at a time." }]
}'How Adeli maps to the X steps above:
- No separate upload step. Send
mediainline as{ content_type, base64 }, or a video as a public HTTPS{ url }: up to 4 images, 1 GIF, or 1 video. Adeli caps X images at 5 MB, GIFs at 15 MB, and video at 512 MB. - Threads of up to 24 more posts. X publishes synchronously, so
201is the finished post. If a later post fails, you get207withstatus: "partial", the failed post'serror, andnot_attemptedfor the rest, so you know exactly where to resume. - Duplicates come back as
409 duplicate_post, and the 280-character limit is counted the way X counts it, with every link as 23. - Deleting is
DELETE /api/v1/posts/x/{postId}with the post'sproviderId. - X's fees are passed through at X's published rates, with no markup. X needs a card on file, so until your workspace has one, publishing returns
402 x_billing_required.GET /api/v1/usage/xreports the month's running total.
The same posting API publishes to Instagram, TikTok, YouTube, Facebook, and Bluesky. One X account is one connected account on Adeli's pricing, and your first 3 connected accounts are free.