DEV Community

ikka.inc
ikka.inc

Posted on

Fetching X Timelines with API v2 Pay-Per-Use: Cost Breakdown, Caching, and the Gotchas

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

X API Pay-Per-Use credit consumption details — developer.x.com

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,
] );
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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'];
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Important: max_results has a minimum of 5. Requesting fewer than 5 returns an API error. Plan your display logic accordingly.

Available expansions for GET /2/users/:id/tweets — X Developer Platform


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.

Response structure with includes — X API v2 Fields documentation

$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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Type values to know:

  • retweeted — standard retweet
  • quoted — quote tweet
  • replied_to — reply (usually excluded via the exclude param)

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;
}
Enter fullscreen mode Exit fullscreen mode

WordPress Transients API — set_transient() documentation

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 );
Enter fullscreen mode Exit fullscreen mode

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 ] ]
);
Enter fullscreen mode Exit fullscreen mode

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'] ?? []
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Call this before registering new cron events whenever settings change.

wp_unschedule_event() parameters — WordPress Developer Resources


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.' );
}
Enter fullscreen mode Exit fullscreen mode

Check for the cooldown before every API call:

if ( get_transient( 'xtl_api_cooldown' ) ) {
    return; // skip until cooldown clears
}
Enter fullscreen mode Exit fullscreen mode

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)