X's Pay-Per-Use pricing changed how I think about API integrations. Before it existed, "display a Twitter/X timeline on a website" meant either a bloated embed widget or a $200/month API plan — neither made sense for small projects.
Pay-Per-Use flips that. You pay per request. For fetching 10 posts three times a day, the real-world cost lands around $2–3/month. That's a different conversation.
This article covers how I implemented X API v2 timeline fetching in PHP/WordPress — including the cost math, the API structure, retweet handling, caching strategy, and two WP-Cron gotchas that will bite you if you're not ready.
Cost Breakdown First
Before writing a line of code, it's worth understanding what you're paying for.
Timeline fetching is a two-step process:
| Action | Cost |
|---|---|
| User lookup (Step 1) | $0.010 / cycle |
| Per post fetched (Step 2) | $0.005 / post |
| 5 posts per cycle | $0.035 |
| 10 posts per cycle | $0.060 |
At 10 posts × 3 updates/day, the ceiling is $0.060 × 90 = $5.40/month. In practice it's lower.
The no-duplicate-billing rule
X API does not charge twice for the same post within a 24-hour UTC window. On the second and third daily updates, you only pay for newly published posts. For accounts that don't post constantly, the second and third updates often cost almost nothing. Real-world cost for most use cases: $2–3/month.
One important caveat: X has stated that Pay-Per-Use is a pilot program with provisional pricing that may change. Always verify current rates at developer.x.com before building anything that depends on these numbers.
Authentication
Bearer Token only. App-only OAuth 2.0. No user context needed for public timelines.
$response = wp_remote_get( $url, [
'headers' => [
'Authorization' => 'Bearer ' . $bearer_token,
],
'timeout' => 15,
] );
If you're in WordPress, use wp_remote_get() instead of curl or file_get_contents. It respects WordPress SSL settings and timeout configuration out of the box.
Step 1: User Lookup
GET https://api.twitter.com/2/users/by/username/{username}
You need the numeric user ID to call the timeline endpoint. This call costs $0.010.
$url = 'https://api.twitter.com/2/users/by/username/' . rawurlencode( $username );
$response = wp_remote_get( $url, [ 'headers' => $headers ] );
$data = json_decode( wp_remote_retrieve_body( $response ), true );
$user_id = $data['data']['id'];
Note: use rawurlencode() on the username, not urlencode(). The latter encodes spaces as +, which breaks the endpoint.
Step 2: Timeline Fetch
GET https://api.twitter.com/2/users/{id}/tweets
The expansions and field parameters are where most of the complexity lives.
$params = http_build_query( [
'max_results' => 10,
'tweet.fields' => 'created_at,entities,attachments,referenced_tweets',
'expansions' => 'attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id',
'media.fields' => 'url,preview_image_url,type,variants,width,height',
'user.fields' => 'name,profile_image_url,username',
'exclude' => 'replies', // optional: hide replies
] );
$url = 'https://api.twitter.com/2/users/' . $user_id . '/tweets?' . $params;
Important: max_results has a minimum of 5. Requesting fewer than 5 returns an API error. Plan your display logic accordingly.
Retweet Handling (The Tricky Part)
When a retweet appears in the timeline, the API returns a reference to the original post — not the content itself. This trips a lot of people up.
The fix: include referenced_tweets.id in your expansions. The original post data will appear in includes.tweets in the response.
$tweets = $data['data']; // timeline posts
$includes = $data['includes'] ?? []; // expanded data
foreach ( $tweets as $tweet ) {
$ref_tweets = $tweet['referenced_tweets'] ?? [];
foreach ( $ref_tweets as $ref ) {
if ( $ref['type'] === 'retweeted' ) {
// find the original in includes.tweets
$original = array_filter(
$includes['tweets'] ?? [],
fn( $t ) => $t['id'] === $ref['id']
);
$original = array_values( $original )[0] ?? null;
}
if ( $ref['type'] === 'quoted' ) {
// same lookup, different type
}
}
}
Type values to know:
-
retweeted— standard retweet -
quoted— quote tweet -
replied_to— reply (usually excluded via theexcludeparam)
Media attached to referenced tweets is also in includes.media, keyed by media_key. You'll need to cross-reference tweet.attachments.media_keys against includes.media to render images and video.
Caching Strategy: Stale-While-Revalidate
Never call the API on every page load. It defeats the purpose of caching and burns through your credits.
The pattern I use: WordPress Transients + Stale-While-Revalidate (SWR).
function get_timeline_data() {
$cached = get_transient( 'xtl_timeline_data' );
if ( $cached !== false ) {
$age = time() - $cached['last_updated'];
if ( $age > 12 * HOUR_IN_SECONDS ) {
// Stale but present: serve it, refresh in background
wp_schedule_single_event( time(), 'xtl_swr_update_event' );
}
return $cached;
}
// No cache: schedule a fetch, return nothing for now
wp_schedule_single_event( time(), 'xtl_swr_update_event' );
return false;
}
The tradeoff: first-time visitors see nothing until the background fetch completes (usually a few seconds). Every subsequent visitor gets instant cached content. For most use cases this is acceptable.
Store your fetched data with a TTL long enough that it won't expire between scheduled updates:
set_transient( 'xtl_timeline_data', $data, DAY_IN_SECONDS );
Two WP-Cron Gotchas
1. DST drift with wp_schedule_event('daily')
WordPress's daily interval is a fixed 86400 seconds. During daylight saving time transitions, this causes scheduled events to drift by an hour. If you need updates at specific times, use wp_schedule_single_event() and re-schedule after each run:
// After executing, schedule the next occurrence
$next = strtotime( 'tomorrow ' . $time_str, current_time( 'timestamp' ) );
wp_schedule_single_event(
$next,
'xtl_cron_update_event',
[ [ 'time_str' => $time_str ] ]
);
This keeps the scheduled time accurate regardless of DST changes.
2. wp_clear_scheduled_hook() doesn't remove argument-keyed events
If you're scheduling cron events with arguments (as above), wp_clear_scheduled_hook() will silently fail to remove them. Old events accumulate every time you save settings.
The fix: iterate _get_cron_array() and unschedule explicitly.
function clear_cron_events() {
$crons = _get_cron_array();
foreach ( (array) $crons as $timestamp => $cron ) {
if ( ! isset( $cron['xtl_cron_update_event'] ) ) {
continue;
}
foreach ( $cron['xtl_cron_update_event'] as $data ) {
wp_unschedule_event(
$timestamp,
'xtl_cron_update_event',
$data['args'] ?? []
);
}
}
}
Call this before registering new cron events whenever settings change.
Rate Limit Handling
When you hit a 429, back off for at least an hour. A simple Transient-based cooldown works well:
if ( 429 === wp_remote_retrieve_response_code( $response ) ) {
set_transient( 'xtl_api_cooldown', true, HOUR_IN_SECONDS );
return new WP_Error( 'rate_limited', 'X API rate limit exceeded.' );
}
Check for the cooldown before every API call:
if ( get_transient( 'xtl_api_cooldown' ) ) {
return; // skip until cooldown clears
}
Key Takeaways
On cost: The no-duplicate-billing rule makes Pay-Per-Use more affordable than the raw numbers suggest. Second and third daily updates often cost almost nothing for low-volume accounts.
On retweets: The most common implementation mistake. Always use expansions=referenced_tweets.id and look up the original in includes.tweets. Don't assume the content is in data.
On caching: SWR is the right pattern for external API data in WordPress. Serve stale content immediately and refresh in the background — don't make visitors wait.
On WP-Cron: Two silent failure modes that are easy to miss: DST drift with daily interval, and argument-keyed events not being removed by wp_clear_scheduled_hook().
I packaged this implementation into two WordPress plugins if you'd rather skip the plumbing:
- X Timeline — displays a configurable timeline feed with retweet support, filters, and auto-update scheduling
- X Pinned Post — fetches only the pinned post from an account, at a lower API cost per cycle
ikka.inc — design and web production studio based in Japan.





Top comments (0)