Skip to Content
ProvidersTwitter / X

Twitter / X

Category: Social Publishing
Integration type: Tenant OAuth (stored in integrations table)
External API: Twitter API v2 (primary) + v1.1 (media upload)


Purpose

Twitter/X integration enables organic social publishing and performance analytics on the tenant’s Twitter/X account. The Social Post Writer agent generates tweet copy; the Social Publisher worker posts it via the API.

The platform also pulls tweet-level and account-level metrics for the Report Writer and Anomaly Detector.

Auth model: Twitter/X uses OAuth 1.0a for user-context actions (posting, reading own metrics) and OAuth 2.0 PKCE for read-only access. The platform uses OAuth 2.0 with the user auth flow where possible, falling back to OAuth 1.0a for endpoints not yet migrated to v2.


Config Structure

OAuth 2.0 flow (primary)

scope: tweet.read,tweet.write,users.read,offline.access Stored in integrations: provider: 'twitter' api_key: encrypt(refresh_token) metadata: { userId: '1234567890123456789', username: 'acmeplumbing', name: 'Acme Plumbing', accessToken: string, tokenExpiresAt: string, // 2-hour expiry; refresh via offline.access scope }

Platform app credentials (env vars)

TWITTER_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # OAuth 2.0 Client ID TWITTER_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TWITTER_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # OAuth 1.0a key (media upload) TWITTER_API_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TWITTER_CALLBACK_URL=https://app.leadmetrics.io/api/auth/callback/twitter

Integration Pattern

Provider class (packages/tools/src/twitter.ts)

import axios from 'axios'; class TwitterTool { private v2Base = 'https://api.twitter.com/2'; private v1Base = 'https://api.twitter.com/1.1'; private uploadBase = 'https://upload.twitter.com/1.1'; constructor( private accessToken: string, // OAuth 2.0 bearer token private oauth1Creds?: OAuth1Creds, // Required for media upload (v1.1 endpoint) ) {} // ── Publishing ────────────────────────────────────────────────────────────── async createTweet(options: { text: string; // Max 280 chars mediaIds?: string[]; // Up to 4 images or 1 GIF or 1 video replyTo?: string; // Tweet ID to reply to quoteTweetId?: string; }): Promise<{ tweetId: string; tweetUrl: string }> { const body: any = { text: options.text }; if (options.mediaIds?.length) body.media = { media_ids: options.mediaIds }; if (options.replyTo) body.reply = { in_reply_to_tweet_id: options.replyTo }; if (options.quoteTweetId) body.quote_tweet_id = options.quoteTweetId; const response = await axios.post(`${this.v2Base}/tweets`, body, { headers: { Authorization: `Bearer ${this.accessToken}`, 'Content-Type': 'application/json', }, }); const id = response.data.data.id; return { tweetId: id, tweetUrl: `https://x.com/i/web/status/${id}`, }; } // ── Media upload (v1.1 — INIT/APPEND/FINALIZE chunked upload) ────────────── async uploadMedia(options: { buffer: Buffer; mimeType: string; // 'image/jpeg' | 'image/png' | 'image/gif' | 'video/mp4' category?: string; // 'tweet_image' | 'tweet_gif' | 'tweet_video' }): Promise<string> { // INIT const initResp = await this.signedPost(`${this.uploadBase}/media/upload.json`, { command: 'INIT', total_bytes: options.buffer.length, media_type: options.mimeType, media_category: options.category ?? 'tweet_image', }); const mediaId = initResp.data.media_id_string; // APPEND (chunk in 5MB pieces) const CHUNK_SIZE = 5 * 1024 * 1024; let segmentIndex = 0; for (let offset = 0; offset < options.buffer.length; offset += CHUNK_SIZE) { const chunk = options.buffer.slice(offset, offset + CHUNK_SIZE); const form = new FormData(); form.append('command', 'APPEND'); form.append('media_id', mediaId); form.append('segment_index', String(segmentIndex++)); form.append('media', new Blob([chunk]), 'chunk'); await this.signedPost(`${this.uploadBase}/media/upload.json`, form); } // FINALIZE await this.signedPost(`${this.uploadBase}/media/upload.json`, { command: 'FINALIZE', media_id: mediaId, }); return mediaId; } // ── Analytics ─────────────────────────────────────────────────────────────── async getTweetMetrics(tweetId: string): Promise<TweetMetrics> { const response = await axios.get(`${this.v2Base}/tweets/${tweetId}`, { headers: { Authorization: `Bearer ${this.accessToken}` }, params: { 'tweet.fields': 'public_metrics,non_public_metrics,organic_metrics', }, }); const m = response.data.data; return { impressions: m.organic_metrics?.impression_count ?? m.public_metrics?.impression_count ?? 0, engagements: m.organic_metrics?.engagement_count ?? 0, likes: m.public_metrics?.like_count ?? 0, retweets: m.public_metrics?.retweet_count ?? 0, replies: m.public_metrics?.reply_count ?? 0, profileClicks: m.organic_metrics?.user_profile_clicks ?? 0, }; } async getUserMetrics(userId: string, options: { startDate: string; endDate: string; }): Promise<UserMetrics> { const response = await axios.get(`${this.v2Base}/users/${userId}`, { headers: { Authorization: `Bearer ${this.accessToken}` }, params: { 'user.fields': 'public_metrics', }, }); return { followersCount: response.data.data.public_metrics.followers_count, followingCount: response.data.data.public_metrics.following_count, tweetCount: response.data.data.public_metrics.tweet_count, }; } async verify(): Promise<void> { const response = await axios.get(`${this.v2Base}/users/me`, { headers: { Authorization: `Bearer ${this.accessToken}` }, }); if (!response.data.data?.id) throw new Error('Twitter authentication failed'); } // ── OAuth 1.0a signing for media upload ───────────────────────────────────── private async signedPost(url: string, body: any): Promise<any> { if (!this.oauth1Creds) throw new Error('OAuth 1.0a credentials required for media upload'); // Use oauth-1.0a library to sign request const oauth = new OAuth1(this.oauth1Creds); const headers = oauth.toHeader(oauth.authorize({ url, method: 'POST' }, this.oauth1Creds.token)); return axios.post(url, body, { headers }); } }

Character Limits and Media Restrictions

Content typeLimit
Tweet text280 characters (URLs always count as 23)
Images per tweetUp to 4 (no video if images included)
GIF per tweet1 (no images or video)
Video per tweet1 MP4 (max 512MB, max 2:20 duration)
Image formatsJPEG, PNG, WebP, GIF

The Social Post Writer agent is prompted to stay under 240 characters to leave room for hashtags and URLs.


Token Refresh

Twitter OAuth 2.0 access tokens expire after 2 hours. The refresh token is long-lived but expires after 6 months of inactivity. The platform refreshes the access token before each API call if expired:

async function refreshTwitterToken(refreshToken: string): Promise<OAuthTokens> { const response = await axios.post( 'https://api.twitter.com/2/oauth2/token', new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: config.TWITTER_CLIENT_ID, }), { auth: { username: config.TWITTER_CLIENT_ID, password: config.TWITTER_CLIENT_SECRET }, }, ); return response.data; }

Test Cases

Unit tests (packages/tools/src/twitter.test.ts)

TestApproach
createTweet() POSTs to /2/tweets with correct bodyMock axios.post; assert text, Authorization header
createTweet() includes media.media_ids when providedAssert media block in body
createTweet() returns tweetId and tweetUrlMock { data: { id: '123' } }; assert URL format
uploadMedia() follows INIT → APPEND → FINALIZE sequenceAssert three signed POST calls in order
uploadMedia() chunks large buffers into 5MB piecesAssert multiple APPEND calls for buffer > 5MB
getTweetMetrics() prefers organic_metrics over public_metricsMock both; assert organic used when present
verify() throws on 401Mock 401; assert error

© 2026 Leadmetrics — Internal use only