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/twitterIntegration 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 type | Limit |
|---|---|
| Tweet text | 280 characters (URLs always count as 23) |
| Images per tweet | Up to 4 (no video if images included) |
| GIF per tweet | 1 (no images or video) |
| Video per tweet | 1 MP4 (max 512MB, max 2:20 duration) |
| Image formats | JPEG, 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)
| Test | Approach |
|---|---|
createTweet() POSTs to /2/tweets with correct body | Mock axios.post; assert text, Authorization header |
createTweet() includes media.media_ids when provided | Assert media block in body |
createTweet() returns tweetId and tweetUrl | Mock { data: { id: '123' } }; assert URL format |
uploadMedia() follows INIT → APPEND → FINALIZE sequence | Assert three signed POST calls in order |
uploadMedia() chunks large buffers into 5MB pieces | Assert multiple APPEND calls for buffer > 5MB |
getTweetMetrics() prefers organic_metrics over public_metrics | Mock both; assert organic used when present |
verify() throws on 401 | Mock 401; assert error |
Related
- Facebook Provider — organic Page publishing
- LinkedIn Provider — organic Company Page publishing
- Social Post Writer Agent — generates tweet copy
- Social Calendar Planner Agent — scheduling