Publishing to X (Twitter) via API: A technical guide
Understanding OAuth authentication, the chunked media upload flow, and API endpoints for posting to X.
Before you start: Developer access
Before your app can post to X on behalf of users, you need access through the X Developer Portal:
- Developer Account — Apply at developer.x.com. You’ll need to describe your intended use case
- Project & App — Create a project and app in the Developer Portal to get your API keys
- Access Level — Free tier allows 500 posts per month. Basic ($200/month) allows 3,000. Pro ($5,000/month) allows 300,000
The free tier is enough to start building but is limited. Rate limits and monthly caps apply at every tier.
Authentication
X supports two authentication methods for posting:
- OAuth 1.0a — Three-legged flow using consumer key, consumer secret, access token, and access token secret. The request must include a computed
oauth_signaturein the Authorization header - OAuth 2.0 with PKCE — User authorization flow that returns user access tokens. Requires the
tweet.read,tweet.write,users.read, andoffline.accessscopes
For posting on behalf of users, both require user-level authentication — app-only Bearer tokens cannot create posts.
OAuth 2.0 with PKCE is the recommended approach. The flow:
- Direct the user to
https://x.com/i/oauth2/authorizewith yourclient_id,redirect_uri,scope, and a PKCEcode_challenge - User authorizes your app and is redirected back with an authorization
code - Exchange the code for an access token via POST to
https://api.x.com/2/oauth2/token - Use the access token in the
Authorization: Bearerheader for subsequent requests
Access tokens expire after 2 hours. Use the refresh_token to get a new one.
Creating a post
POST to https://api.x.com/2/tweets:
{ "text": "Your post text here"}Returns:
{ "data": { "id": "1234567890", "text": "Your post text here" }}The text field supports up to 280 characters on the free and basic tiers, or up to 25,000 characters on the Pro tier.
Posting with media
Media must be uploaded separately before attaching it to a post. The flow is: upload the media, get a media_id, then reference it when creating the post.
POST to https://api.x.com/2/tweets:
{ "text": "Your caption here", "media": { "media_ids": ["<MEDIA_ID>"] }}You can attach up to 4 images, 1 video, or 1 animated GIF per post.
Uploading media: The chunked upload flow
All media uploads use a three-step chunked upload process: INIT, APPEND, FINALIZE.
File size limits
- Images: 5 MB (JPEG, PNG, WebP)
- Animated GIFs: 15 MB
- Videos: 512 MB (MP4, MOV)
Step 1: Initialize
POST to https://api.x.com/2/media/upload/initialize:
{ "media_type": "video/mp4", "total_bytes": 12345678, "media_category": "tweet_video"}The media_category values for posts are:
tweet_image— Imagestweet_gif— Animated GIFstweet_video— Videos
Returns:
{ "data": { "id": "<MEDIA_ID>", "expires_after_secs": 86400 }}Step 2: Append
POST to https://api.x.com/2/media/upload/<MEDIA_ID>/append:
Upload the file in chunks using multipart/form-data with:
media— The binary chunk datasegment_index— Integer starting at 0, incremented for each chunk
Chunk size should be no more than 5 MB per request. For a 12 MB video, you would send three APPEND requests with segment_index values 0, 1, and 2.
Step 3: Finalize
POST to https://api.x.com/2/media/upload/<MEDIA_ID>/finalize
Returns:
{ "data": { "id": "<MEDIA_ID>", "processing_info": { "state": "pending", "check_after_secs": 5 } }}Checking upload status
Videos and GIFs require server-side processing after finalization. If processing_info is present in the FINALIZE response, you must poll for completion before attaching the media to a post.
GET https://api.x.com/2/media/upload/<MEDIA_ID>:
{ "data": { "processing_info": { "state": "in_progress", "progress_percent": 45, "check_after_secs": 10 } }}Processing states:
pending— Queued for processingin_progress— Currently processing, checkprogress_percentsucceeded— Ready to attach to a postfailed— Processing failed, upload must be retried
Always wait check_after_secs before polling again.
Replies and quote posts
To create a reply, include the reply object:
{ "text": "Your reply", "reply": { "in_reply_to_tweet_id": "<TWEET_ID>" }}To create a quote post, include quote_tweet_id:
{ "text": "Your commentary", "quote_tweet_id": "<TWEET_ID>"}Polls
Posts can include a poll:
{ "text": "Which do you prefer?", "poll": { "options": ["Option A", "Option B", "Option C"], "duration_minutes": 1440 }}Duration must be between 5 and 10,080 minutes (7 days).
The same video, through Postproxy
Here’s how it’s done with Postproxy. One simple request with only what matters:
curl -X POST "https://api.postproxy.dev/api/posts" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "post": { "body": "3 tips that changed how we approach customer onboarding" }, "profiles": ["twitter"], "media": ["https://example.com/video.mp4"] }'One request. Postproxy handles the chunked upload, the processing status polling, and the post creation.
What Postproxy handles
Postproxy manages OAuth tokens and handles the complexity:
- OAuth 2.0 token exchange and automatic refresh
- The three-step chunked media upload (INIT, APPEND, FINALIZE)
- Processing status polling for videos and GIFs
- Unified API for text posts, images, videos, and GIFs
- Rate limit monitoring and request pacing
Your system sends content. Postproxy handles the X-specific implementation.
Connect your X account and start publishing through the Postproxy API.