Twitter/X API Posting Guide 2026

The X API (formerly Twitter API) v2 is the primary way to programmatically create tweets, upload media, manage threads, and interact with the platform. Whether you are building a social media management tool, automating your brand's presence, or integrating posting into your workflow, this guide covers the complete process from authentication to publishing with working code examples.

The X API has undergone significant changes since Elon Musk's acquisition of Twitter. This guide reflects the current state of the API in 2026, including the tiered access model, OAuth 2.0 with PKCE, and the v2 endpoints.

API Access Tiers

X offers multiple API access tiers with different capabilities and rate limits:

For most scheduling and automation use cases, the Basic tier provides enough quota. The free tier works for personal automation but the 1,500 tweet monthly cap is restrictive for any production application.

OAuth 2.0 with PKCE Authentication

X API v2 uses OAuth 2.0 with PKCE (Proof Key for Code Exchange) for user authentication. This is more secure than the older OAuth 1.0a flow and is required for most v2 endpoints.

Step 1: Generate PKCE Parameters

const crypto = require('crypto');

// Generate code verifier (random string 43-128 chars)
function generateCodeVerifier() {
  return crypto.randomBytes(32).toString('base64url');
}

// Generate code challenge from verifier
function generateCodeChallenge(verifier) {
  return crypto.createHash('sha256')
    .update(verifier)
    .digest('base64url');
}

const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
// Store codeVerifier in session — you need it later

Step 2: Build the Authorization URL

const authUrl = 'https://twitter.com/i/oauth2/authorize'
  + '?response_type=code'
  + '&client_id=YOUR_CLIENT_ID'
  + '&redirect_uri=https://yourapp.com/callback'
  + '&scope=tweet.read%20tweet.write%20users.read%20offline.access'
  + '&state=random_state_value'
  + '&code_challenge=' + codeChallenge
  + '&code_challenge_method=S256';

// Redirect user to authUrl

Key scopes for posting:

Step 3: Exchange Code for Tokens

app.get('/callback', async (req, res) => {
  const { code, state } = req.query;

  // Verify state matches
  if (state !== expectedState) {
    return res.status(403).send('Invalid state');
  }

  const tokenRes = await fetch('https://api.twitter.com/2/oauth2/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'Authorization': 'Basic ' + Buffer.from(
        `${CLIENT_ID}:${CLIENT_SECRET}`
      ).toString('base64'),
    },
    body: new URLSearchParams({
      code: code,
      grant_type: 'authorization_code',
      redirect_uri: 'https://yourapp.com/callback',
      code_verifier: storedCodeVerifier, // from Step 1
    }),
  });

  const {
    access_token,
    refresh_token,
    expires_in,  // typically 7200 seconds (2 hours)
  } = await tokenRes.json();

  // Store both tokens securely
  // access_token expires in 2 hours
  // refresh_token does not expire but can only be used once
});

Step 4: Refresh Access Tokens

Access tokens expire every 2 hours. Use the refresh token to get a new pair:

async function refreshAccessToken(refreshToken) {
  const res = await fetch('https://api.twitter.com/2/oauth2/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'Authorization': 'Basic ' + Buffer.from(
        `${CLIENT_ID}:${CLIENT_SECRET}`
      ).toString('base64'),
    },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
    }),
  });

  const { access_token, refresh_token: newRefreshToken } = await res.json();
  // IMPORTANT: Store the new refresh_token — each one can only be used once
  return { accessToken: access_token, refreshToken: newRefreshToken };
}

Creating Tweets

The tweet creation endpoint is straightforward. Send a POST request to /2/tweets with your content:

Basic Text Tweet

async function createTweet(accessToken, text) {
  const res = await fetch('https://api.twitter.com/2/tweets', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ text }),
  });

  if (!res.ok) {
    const error = await res.json();
    throw new Error(`Tweet failed: ${JSON.stringify(error)}`);
  }

  const { data } = await res.json();
  return data; // { id: '...', text: '...' }
}

// Usage
const tweet = await createTweet(token, 'Hello from the X API!');
console.log('Tweet ID:', tweet.id);

The text field has a 280-character limit for standard accounts. X Premium subscribers can post up to 4,000 characters, but this requires the user to have an active Premium subscription.

Tweet with Poll

const res = await fetch('https://api.twitter.com/2/tweets', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    text: 'What is your preferred social media scheduling tool?',
    poll: {
      options: ['Kleo', 'Buffer', 'Hootsuite', 'Manual posting'],
      duration_minutes: 1440, // 24 hours
    },
  }),
});

Quote Tweet

const res = await fetch('https://api.twitter.com/2/tweets', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    text: 'Great thread on API best practices!',
    quote_tweet_id: '1234567890123456789',
  }),
});

Media Upload

Media upload uses the v1.1 media endpoint (the v2 media endpoint is not yet available for all tiers). The process involves uploading the media first, then attaching it to your tweet.

Simple Image Upload

const fs = require('fs');
const FormData = require('form-data');

async function uploadMedia(accessToken, filePath) {
  const form = new FormData();
  form.append('media', fs.createReadStream(filePath));
  form.append('media_category', 'tweet_image');

  const res = await fetch('https://upload.twitter.com/1.1/media/upload.json', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      ...form.getHeaders(),
    },
    body: form,
  });

  const { media_id_string } = await res.json();
  return media_id_string;
}

// Upload image, then create tweet with it
const mediaId = await uploadMedia(token, './my-image.jpg');

const tweetRes = await fetch('https://api.twitter.com/2/tweets', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    text: 'Check out this image!',
    media: { media_ids: [mediaId] },
  }),
});

Chunked Upload for Large Files

For files over 5MB or videos up to 512MB, use chunked upload:

async function chunkedUpload(accessToken, filePath, mediaType) {
  const fileSize = fs.statSync(filePath).size;
  const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB chunks

  // INIT
  const initRes = await fetch(
    'https://upload.twitter.com/1.1/media/upload.json',
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams({
        command: 'INIT',
        total_bytes: fileSize,
        media_type: mediaType, // e.g. 'video/mp4'
        media_category: 'tweet_video',
      }),
    }
  );
  const { media_id_string } = await initRes.json();

  // APPEND chunks
  const fileBuffer = fs.readFileSync(filePath);
  let segmentIndex = 0;
  for (let offset = 0; offset < fileSize; offset += CHUNK_SIZE) {
    const chunk = fileBuffer.slice(offset, offset + CHUNK_SIZE);
    const form = new FormData();
    form.append('command', 'APPEND');
    form.append('media_id', media_id_string);
    form.append('segment_index', segmentIndex.toString());
    form.append('media', chunk, { filename: 'chunk' });

    await fetch('https://upload.twitter.com/1.1/media/upload.json', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        ...form.getHeaders(),
      },
      body: form,
    });
    segmentIndex++;
  }

  // FINALIZE
  const finalRes = await fetch(
    'https://upload.twitter.com/1.1/media/upload.json',
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams({
        command: 'FINALIZE',
        media_id: media_id_string,
      }),
    }
  );

  const finalData = await finalRes.json();

  // Check processing status for videos
  if (finalData.processing_info) {
    await waitForMediaProcessing(accessToken, media_id_string);
  }

  return media_id_string;
}

Supported media formats:

Creating Threads

To create a thread (series of connected tweets), use the reply parameter to chain tweets together:

async function createThread(accessToken, tweets) {
  const postedTweets = [];
  let replyToId = null;

  for (const text of tweets) {
    const body = { text };
    if (replyToId) {
      body.reply = { in_reply_to_tweet_id: replyToId };
    }

    const res = await fetch('https://api.twitter.com/2/tweets', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(body),
    });

    const { data } = await res.json();
    postedTweets.push(data);
    replyToId = data.id;
  }

  return postedTweets;
}

// Usage
const thread = await createThread(token, [
  '1/ Here is a thread about building with the X API...',
  '2/ First, you need to set up OAuth 2.0 with PKCE...',
  '3/ Then you can start creating tweets programmatically...',
  '4/ And that is the basics! Follow for more dev content.',
]);

Mentions and Replies

Mentioning users is done by including their @username in the tweet text. Replying to tweets requires the reply parameter:

// Reply to a specific tweet
const replyRes = await fetch('https://api.twitter.com/2/tweets', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    text: '@username Great point! I totally agree.',
    reply: {
      in_reply_to_tweet_id: '1234567890123456789',
    },
  }),
});

When replying, the @username of the original author is automatically included in the reply context. You do not need to manually prepend it unless you want the mention visible in the tweet text.

Rate Limits

X API rate limits are per-user and per-app, depending on your access tier:

Rate limit information is included in response headers:

// Check rate limit headers
const res = await fetch('https://api.twitter.com/2/tweets', { /* ... */ });

const remaining = res.headers.get('x-rate-limit-remaining');
const resetTime = res.headers.get('x-rate-limit-reset'); // Unix timestamp
const limit = res.headers.get('x-rate-limit-limit');

console.log(`${remaining}/${limit} requests remaining`);
console.log(`Resets at: ${new Date(resetTime * 1000)}`);

Deleting Tweets

async function deleteTweet(accessToken, tweetId) {
  const res = await fetch(`https://api.twitter.com/2/tweets/${tweetId}`, {
    method: 'DELETE',
    headers: { 'Authorization': `Bearer ${accessToken}` },
  });

  const { data } = await res.json();
  return data.deleted; // true if successful
}

Common Errors and Troubleshooting

Error 401: Unauthorized

Your access token has expired (they expire every 2 hours) or was revoked. Use your refresh token to get a new access token. If the refresh token also fails, the user needs to re-authorize your app.

Error 403: Forbidden — "You are not permitted to perform this action"

This usually means your app does not have the required API tier or the user has not granted the necessary scopes. Check that your developer account has the correct tier and your OAuth scopes include tweet.write.

Error 429: Too Many Requests

You have hit a rate limit. Check the x-rate-limit-reset header to know when you can resume. Implement exponential backoff and consider queuing tweets to distribute them over time.

Error: "Tweet text too long"

Standard accounts have a 280-character limit. URLs count as 23 characters regardless of actual length (t.co wrapping). Emojis may count as more than one character. Use the weighted_character_count function from Twitter's text library for accurate counting.

Error: "Duplicate content"

X rejects tweets that are identical or near-identical to recently posted content. Add variation to your automated posts or add timestamps to ensure uniqueness.

Error: "Media upload failed"

Check file size limits (5MB for images, 15MB for GIFs, 512MB for videos), supported formats, and that the file is not corrupted. For videos, ensure you are using the chunked upload endpoint.

Complete Working Example

const crypto = require('crypto');

class XApiClient {
  constructor(clientId, clientSecret) {
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.baseUrl = 'https://api.twitter.com/2';
  }

  async createTweet(accessToken, { text, mediaIds, replyToId, quoteTweetId }) {
    const body = { text };

    if (mediaIds && mediaIds.length > 0) {
      body.media = { media_ids: mediaIds };
    }
    if (replyToId) {
      body.reply = { in_reply_to_tweet_id: replyToId };
    }
    if (quoteTweetId) {
      body.quote_tweet_id = quoteTweetId;
    }

    const res = await fetch(`${this.baseUrl}/tweets`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(body),
    });

    if (res.status === 429) {
      const resetTime = res.headers.get('x-rate-limit-reset');
      const waitMs = (resetTime * 1000) - Date.now();
      throw new Error(`Rate limited. Retry after ${Math.ceil(waitMs / 1000)}s`);
    }

    if (!res.ok) {
      const error = await res.json();
      throw new Error(`Tweet failed (${res.status}): ${JSON.stringify(error)}`);
    }

    const { data } = await res.json();
    return data;
  }

  async refreshToken(refreshToken) {
    const res = await fetch(`${this.baseUrl.replace('/2', '')}/2/oauth2/token`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Authorization': 'Basic ' + Buffer.from(
          `${this.clientId}:${this.clientSecret}`
        ).toString('base64'),
      },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
      }),
    });

    if (!res.ok) throw new Error('Token refresh failed');
    return res.json();
  }
}

// Usage
const client = new XApiClient('your_client_id', 'your_client_secret');

const tweet = await client.createTweet(accessToken, {
  text: 'Automated tweet from my scheduling tool!',
});
console.log('Posted:', tweet.id);

Best Practices for Production

Skip the API Complexity with Kleo

Working with the X API directly means managing OAuth tokens that expire every 2 hours, handling chunked media uploads, navigating tiered rate limits, and staying current with X's frequently changing API policies. For most founders and small teams, this is not a good use of engineering time.

Kleo manages all of this behind the scenes. Connect your X account, generate posts with AI that learns your brand voice, and schedule them alongside your LinkedIn, Threads, and Bluesky content. No API keys, no token rotation, no rate limit math.

Post to X without managing API tokens

Kleo handles OAuth, media uploads, rate limits, and scheduling. Focus on your content, not infrastructure.

Get Started with Kleo