X API v2 has no native scheduling endpoint — build your own with a queue and cron that calls POST /2/tweets at the target time. The real story in 2026 is cost: $0.015 per text post or $0.20 per link post on pay-per-use, or $200/month for Basic tier. Media uploads require OAuth 1.0a (not OAuth 2.0) via POST /2/media/upload with a chunked INIT/APPEND/FINALIZE flow.
| Fact | Value |
|---|---|
| Platform | X (Twitter) |
| Rate limits | 50,000 posts/month on Basic; 2,400 posts/day account cap |
| Difficulty | Intermediate |
| Time required | 4–6 hours |
| Last updated | May 2026 |
API Quick Reference
50,000 posts/month on Basic; 2,400 posts/day account cap
REST only
API overview
https://api.x.com/2Authentication
- 1Create a Project and App at developer.x.com — new apps default to pay-per-use billing after February 2026
- 2Enable OAuth 2.0 User Authentication in App settings with callback URL and required scopes
- 3For media uploads, also generate OAuth 1.0a keys: API Key, API Secret, Access Token, Access Token Secret (all available in the Developer Portal)
- 4Implement the PKCE flow: generate code_verifier, derive code_challenge (SHA-256), redirect user to authorization URL
- 5After user grants access, exchange authorization code for access and refresh tokens
- 6Store both OAuth 2.0 tokens (for posting) and OAuth 1.0a credentials (for media) securely
1import os2import requests3from requests_oauthlib import OAuth145# OAuth 2.0 token refresh (for text posts)6def refresh_oauth2_token(refresh_token, client_id):7 resp = requests.post(8 'https://api.x.com/2/oauth2/token',9 data={10 'grant_type': 'refresh_token',11 'refresh_token': refresh_token,12 'client_id': client_id13 }14 )15 return resp.json() # {'access_token': '...', 'expires_in': 7200}1617# OAuth 1.0a auth object for media uploads18def get_oauth1_auth():19 return OAuth1(20 os.environ['X_API_KEY'],21 os.environ['X_API_SECRET'],22 os.environ['X_ACCESS_TOKEN'],23 os.environ['X_ACCESS_TOKEN_SECRET']24 )Security notes
- •Store all four OAuth 1.0a credentials as environment variables — never in code or config files
- •OAuth 2.0 access tokens expire in 2 hours; refresh automatically before expiry
- •Media upload (OAuth 1.0a) credentials do not expire but rotate them quarterly
- •Use separate app credentials for separate automation projects to isolate cost tracking
- •The media.write scope is required for media upload alongside OAuth 1.0a
Key endpoints
/2/tweetsPublish a tweet. For text-only posts, pass just the text field. For media posts, include media.media_ids with IDs from the media upload flow. Requires user-context OAuth 2.0.
| Parameter | Type | Required | Description |
|---|---|---|---|
text | string | required | Tweet content, max 280 characters |
media.media_ids | array | optional | Array of media IDs from completed media upload (max 4 images or 1 video/GIF) |
reply.in_reply_to_tweet_id | string | optional | If scheduling a reply, the parent tweet ID |
Request
1POST https://api.x.com/2/tweets2Authorization: Bearer USER_ACCESS_TOKEN3Content-Type: application/json45{6 "text": "Our weekly product update is live! Here's what's new this week."7}Response
1{2 "data": {3 "id": "1790000000000000001",4 "text": "Our weekly product update is live! Here's what's new this week."5 }6}/2/media/uploadUpload media for attaching to tweets. Requires OAuth 1.0a (not OAuth 2.0). Three-step flow: INIT (declare file size), APPEND (upload chunks), FINALIZE (complete upload). For videos, poll status until processing is done.
| Parameter | Type | Required | Description |
|---|---|---|---|
command | string | required | INIT, APPEND, or FINALIZE |
total_bytes | integer | required | Total file size in bytes (INIT step) |
media_type | string | required | MIME type: image/jpeg, image/png, video/mp4, image/gif |
media_data | string | optional | Base64-encoded chunk data (APPEND step) |
segment_index | integer | optional | Chunk index starting at 0 (APPEND step) |
media_id | string | optional | Media ID from INIT (required for APPEND and FINALIZE) |
Request
1# INIT2POST https://api.x.com/2/media/upload3Content-Type: application/x-www-form-urlencoded4[OAuth 1.0a headers]56command=INIT&total_bytes=204800&media_type=image%2Fjpeg78# Response: {"media_id_string": "710511363345354753", ...}Response
1{2 "media_id": 710511363345354753,3 "media_id_string": "710511363345354753",4 "expires_after_secs": 864005}/2/tweets/{id}Delete a scheduled tweet that has already been posted, or clean up test posts. Requires user-context OAuth 2.0 and the tweet must belong to the authenticated user.
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | required | ID of the tweet to delete |
Request
1DELETE https://api.x.com/2/tweets/17900000000000000012Authorization: Bearer USER_ACCESS_TOKENResponse
1{2 "data": {3 "deleted": true4 }5}Step-by-step automation
Build the scheduling queue with a database table
Why: X has no native scheduling API, so you need to store posts with their target publish time and status. A simple database table or JSON file tracks what to post and when. A cron job polls this queue every minute and publishes due posts.
Create a scheduled_posts table with columns: id, text, media_urls (optional), scheduled_at (timestamp), status (pending/published/failed), published_tweet_id, created_at. Your cron job queries for rows where status = 'pending' AND scheduled_at <= now().
1# No direct curl equivalent for the queue — use your database2# Test that your scheduled post will work by posting immediately:3curl -s -X POST 'https://api.x.com/2/tweets' \4 -H 'Authorization: Bearer USER_ACCESS_TOKEN' \5 -H 'Content-Type: application/json' \6 -d '{"text": "Test post from scheduler"}'Pro tip: Store container expiry awareness in the queue: if a post has been pending for more than 2 hours without attempting to publish, log a warning. Unlike Instagram, X posts don't expire before sending, but stale items indicate a cron failure.
Expected result: A persistent queue of scheduled posts. The cron job finds all posts with scheduled_at in the past and status = 'pending'.
Upload media using the chunked upload flow (OAuth 1.0a required)
Why: If your scheduled posts include images or video, you must upload media before creating the tweet. The media upload endpoint requires OAuth 1.0a specifically — using OAuth 2.0 Bearer returns '403 Not permitted OAuth2 on this endpoint'. Chunked upload is required for all media.
Three-phase upload: INIT declares the total file size and gets a media_id. APPEND sends the file in base64-encoded chunks (5MB per chunk recommended). FINALIZE completes the upload. For videos, after FINALIZE you must poll the processing_info field until state = 'succeeded'.
1# Step 1: INIT2curl -s -X POST 'https://api.x.com/2/media/upload' \3 -H 'Authorization: OAuth oauth_consumer_key="...",oauth_token="...",oauth_signature_method="HMAC-SHA1",...' \4 -d 'command=INIT&total_bytes=204800&media_type=image%2Fjpeg'56# Step 2: APPEND (chunk 0)7curl -s -X POST 'https://api.x.com/2/media/upload' \8 -H 'Authorization: OAuth ...' \9 -d 'command=APPEND&media_id=710511363345354753&segment_index=0&media_data=BASE64_CHUNK'1011# Step 3: FINALIZE12curl -s -X POST 'https://api.x.com/2/media/upload' \13 -H 'Authorization: OAuth ...' \14 -d 'command=FINALIZE&media_id=710511363345354753'Pro tip: Upload media assets ahead of schedule time, not at publish time. If you upload at publish time, video processing can take 30–120 seconds, causing your tweet to post late.
Expected result: A media_id_string is returned after FINALIZE. Use this ID in the tweet creation request. Media IDs are valid for 24 hours.
Publish the tweet at scheduled time
Why: The cron job fires every minute and processes all due scheduled posts. For each post, it optionally uploads media (if media_urls are present), then creates the tweet. After posting, it updates the queue record with the published_tweet_id and marks status as published.
Call POST /2/tweets with the text and optionally media.media_ids. Check the response for error 187 (duplicate status) — if you accidentally schedule two identical posts, the second will fail. Log the published tweet ID for reference and update queue status.
1# Text-only post2curl -s -X POST 'https://api.x.com/2/tweets' \3 -H 'Authorization: Bearer USER_ACCESS_TOKEN' \4 -H 'Content-Type: application/json' \5 -d '{"text": "Your scheduled post text here"}'67# Post with image8curl -s -X POST 'https://api.x.com/2/tweets' \9 -H 'Authorization: Bearer USER_ACCESS_TOKEN' \10 -H 'Content-Type: application/json' \11 -d '{"text": "Your post with image", "media": {"media_ids": ["710511363345354753"]}}'Pro tip: Add at least a 2-second gap between publishing multiple posts in the same cron run. X's account-level rate limits apply even within a single batch.
Expected result: Each due post is published and the queue record updated with status 'published' and the tweet ID. Failed posts are marked with their error status for review.
Track costs and monitor post performance
Why: On pay-per-use, every post costs real money. A post containing a URL costs $0.20 — 13x more than a text post at $0.015. Tracking costs per session and per day prevents surprises. Reading your own post metrics to confirm publication costs $0.001 per read.
After each post, calculate whether it contained a URL and log the cost. Keep a daily running total. Optionally fetch the published tweet's metrics (impression count, like count) after 1 hour to track initial performance. Alert if daily spend exceeds your budget.
1# Verify tweet was published and get metrics2curl -s 'https://api.x.com/2/tweets/1790000000000000001?tweet.fields=public_metrics,created_at' \3 -H 'Authorization: Bearer USER_ACCESS_TOKEN'Pro tip: Link posts at $0.20 each add up fast — 1,000 link posts/month = $200, the same as Basic tier. If you post more than ~1,333 link posts/month, Basic tier at $200/month is cheaper than pay-per-use.
Expected result: Each post's cost is tracked. Daily totals prevent exceeding budget. Post metrics are available for performance reporting.
Complete working code
Complete tweet scheduler with SQLite queue, media upload support, cost tracking, and cron runner. Run via crontab (every minute) or as a daemon.
Error handling
Using OAuth 2.0 Bearer token for POST /2/media/upload. Media upload specifically requires OAuth 1.0a.
You tried to post the same text content twice. X checks for duplicate status across recent posts.
Exceeded monthly write limit (50,000 on Basic) or the 2,400 posts/day account cap.
OAuth 2.0 access token expired (2-hour lifetime) or refresh token was revoked.
Video upload finalized but processing_info.state returned 'failed'. Typically unsupported codec or resolution.
Rate limits & throttling
| Scope | Limit | Window |
|---|---|---|
Security checklist
- Store OAuth 1.0a API key, secret, access token, and access token secret in separate environment variables
- Store OAuth 2.0 access and refresh tokens in environment variables or encrypted secrets store
- Never log token values — log only token presence (is_set: true/false)
- Implement daily cost caps with automatic scheduler shutdown when cap is reached
- Validate media files before queuing — check file size and MIME type to prevent upload failures
- Never post user-submitted content without moderation — only post from a pre-approved content queue
- Rotate OAuth 1.0a tokens quarterly even though they do not expire automatically
- Log every published tweet ID to enable deletion or audit if needed
Automation use cases
Best practices
- Calculate whether pay-per-use or Basic ($200/month) is cheaper before starting — the break-even point is about 13,333 text posts or 1,000 link posts per month
- Use the content_publishing_limit check equivalent — read x-user-limit-24hour-remaining on every tweet creation response to track toward the 2,400/day account cap
- Upload media before scheduled time (at least 1 hour earlier for videos) to decouple media processing delays from post timing
- Never post duplicate text — use a hash of the post content in your queue to detect and reject duplicates before attempting to post
- Add randomized timing variation of ±5 minutes to scheduled times to avoid looking like a bot posting at exactly :00 seconds
- For link posts ($0.20 each), use a URL shortener to reduce tweet character count but note: X counts t.co-wrapped URLs regardless of original length, and cost is based on URL presence not count
Ask AI to help
Copy one of these prompts to get a personalized, working implementation.
I'm building an X (Twitter) post scheduler using the X API v2. I need to: 1) Store posts with scheduled times in a SQLite database, 2) Run a cron job every minute to publish due posts via POST /2/tweets, 3) Handle media uploads with OAuth 1.0a via the INIT/APPEND/FINALIZE chunked upload flow, 4) Track costs at $0.015/text post and $0.20/link post. Please write the complete Python implementation with error handling for rate limits (429) and duplicate status (187).
Build a content scheduling dashboard for X (Twitter) posts. It should include: a calendar view of scheduled posts, a form to add new posts with text, optional image upload, and scheduled time, status badges (pending/published/failed), a daily cost tracker showing estimated spend vs daily cap, and a toggle to pause/resume the scheduler. Use Supabase as the backend.
Frequently asked questions
Is the X API free tier usable for scheduling automation?
No, not for new developers. The Free tier has been effectively closed since February 2026. New apps default to pay-per-use, where each text post costs $0.015 and each link post costs $0.20. The Free tier also allows only 500 posts/month total and 1 request per 24 hours on most read endpoints, making real automation impossible.
Why does media upload require OAuth 1.0a when everything else uses OAuth 2.0?
X deprecated the v1.1 media upload endpoint on March 31, 2025 and migrated to v2 — but the v2 media upload endpoint still requires OAuth 1.0a signing. This is a known inconsistency in X's API. You need both OAuth 2.0 credentials (for tweet creation) and OAuth 1.0a credentials (for media upload) in the same application.
How much does it cost to post 1,000 tweets per month?
It depends on link vs. text content. 1,000 text-only posts: $15/month on pay-per-use — significantly cheaper than Basic ($200/month). 1,000 posts with URLs: $200/month on pay-per-use — exactly the same as Basic. Mixed content: calculate your text/link ratio and compare. If your posts frequently include links, Basic tier often makes more economic sense.
Can I schedule a post with multiple images?
Yes — upload up to 4 images via the media upload flow and include all 4 media_id_string values in the media.media_ids array when creating the tweet. Note: you cannot mix images and video in the same tweet. Upload all images before the scheduled post time since media IDs expire after 24 hours.
My scheduler posted a tweet with an error — how do I delete it?
Use DELETE /2/tweets/{id} with the user-context OAuth 2.0 token. The tweet ID is returned in the POST /2/tweets response — always log it. Deleting a tweet does not refund the $0.015 or $0.20 post cost. Keep a published_tweet_ids log in your database specifically for cleanup operations.
Can RapidDev help me build the scheduling infrastructure?
Yes. RapidDev has guides for building content scheduling backends with Supabase (for the post queue) and Next.js Server Actions (for the publish triggers). If you're using Lovable or V0, we have step-by-step tutorials for connecting your scheduler to a visual content calendar dashboard.
Need this automated?
Our team has built 600+ apps with API automations. We can build this for you.
Book a free consultation