Publishing to X (Twitter) via API: A technical guide

Understanding OAuth authentication, the chunked media upload flow, and API endpoints for posting to X.

Publishing to X (Twitter) via API: A technical guide

Before you start: Developer access

Before your app can post to X on behalf of users, you need access through the X Developer Portal:

  1. Developer Account — Apply at developer.x.com. You’ll need to describe your intended use case
  2. Project & App — Create a project and app in the Developer Portal to get your API keys
  3. 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_signature in the Authorization header
  • OAuth 2.0 with PKCE — User authorization flow that returns user access tokens. Requires the tweet.read, tweet.write, users.read, and offline.access scopes

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:

  1. Direct the user to https://x.com/i/oauth2/authorize with your client_id, redirect_uri, scope, and a PKCE code_challenge
  2. User authorizes your app and is redirected back with an authorization code
  3. Exchange the code for an access token via POST to https://api.x.com/2/oauth2/token
  4. Use the access token in the Authorization: Bearer header 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 — Images
  • tweet_gif — Animated GIFs
  • tweet_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 data
  • segment_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 processing
  • in_progress — Currently processing, check progress_percent
  • succeeded — Ready to attach to a post
  • failed — 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:

Terminal window
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.

Ready to get started?

Start with our free plan and scale as your needs grow. No credit card required.