What Is twimg? Twitter (X) Media CDN URLs Explained

By TwitterAPI.io Team•18 min read
Dark diagram titled twimg.com: the hosts behind every image and video on Twitter (X). Three panels. Blue panel pbs.twimg.com (Photo Blobstore: photos, video thumbnails, profile images) with three example URLs: https://pbs.twimg.com/media/HUwcJO6WgAAfBCd?format=jpg&name=orig labeled photo full size, https://pbs.twimg.com/media/HUwcJO6WgAAfBCd.jpg labeled API default, and https://pbs.twimg.com/profile_images/<user_id>/<file>_400x400.jpg labeled avatar; a note says name= picks the rendition: thumb, small, medium (default), large, orig. Green panel video.twimg.com / video-s.twimg.com (MP4 renditions and HLS playlists) with an amplify_video mp4 at 1080x1920, an amplify_video m3u8 playlist, and a tweet_video mp4 for animated GIFs. Gray panel abs.twimg.com (static site assets, never tweet media) with https://abs.twimg.com/favicons/twitter.3.ico. Footer: measured on 980 posts from 6 organization accounts collected in 2026, every media_url_https sat on pbs.twimg.com and 731 of 933 video renditions on video-s.twimg.com; a bare /media/<id> answered 404 on all 30 probes; the default .jpg URL is the medium rendition, 13.1% of the orig bytes.
The twimg hosts and the URL shapes each one serves. Diagram; the footer numbers come from the study below.

If you have ever opened a browser's network tab on x.com, saved a picture from a tweet, or printed a tweet object from an API, you have met twimg.com. It is not a separate product and it is not a tracker: it is the content delivery domain Twitter (X) has used for media for well over a decade, and the subdomain in front of it tells you what kind of file you are holding. Developers search for it because something broke: the image came back smaller than expected, a React component shows a broken picture, a profile image stopped resolving, or a URL copied from the API returns 404 when you add the obvious parameters.

This page is a reference, not a tutorial on scraping. It explains what pbs.twimg.com, video.twimg.com, video-s.twimg.com and abs.twimg.com are, the exact anatomy of their URLs, what each name= rendition actually weighs, how the video rendition ladder is built, and which mistakes produce a 404. Every number comes from a study we ran in 2026: 980 posts from six organization accounts pulled through /twitter/user/last_tweets, 692 media items, and 292 unproxied HEAD requests against the CDN itself.

The last two sections show how to get these URLs programmatically with TwitterAPI.io (the extendedEntities.media[] array carries everything you need) and what the same read costs on the official X API, so you can decide whether to build on the official rate card or on a pay-for-what-you-read one.

01 — Section

The twimg.com hosts and what each one serves

twimg.com is a plain CDN domain; the subdomains partition the traffic by file type. The official X API data dictionary (docs.x.com/x-api/fundamentals/data-dictionary) shows two of them in its media object examples: pbs.twimg.com for a photo URL (/media/<id>.jpg) and video.twimg.com for an MP4 (/ext_tw_video/<id>/pu/vid/<resolution>/<file>.mp4). Our 2026 sample adds the hosts the documentation does not mention and shows which path families actually appear.

HostWhat it servesSeen in our sample
pbs.twimg.comPhotos (/media/), video and GIF thumbnails (/amplify_video_thumb/, /tweet_video_thumb/), profile images (/profile_images/)692 of 692 media_url_https values: 572 /media/, 104 /amplify_video_thumb/, 16 /tweet_video_thumb/
video-s.twimg.comMP4 renditions and HLS playlists for videos and animated GIFs731 of 933 video renditions, 9 of 16 GIF files
video.twimg.comSame path families as video-s; the host the X docs show202 of 933 video renditions, 7 of 16 GIF files; the same MP4 path answered 200 with an identical Content-Length on both hosts
abs.twimg.comStatic site assets: favicons, JavaScript bundles, emoji sprites. Never tweet mediaOne probe: /favicons/twitter.3.ico, 549 bytes, Cache-Control: max-age=31536000, immutable

Where the name comes from. The pbs prefix is commonly expanded as "Photo Blobstore" in developer forums; X has not documented the expansion, so treat it as folklore that happens to describe the job. abs is the asset host and shows up in page source for every x.com page load, which is why security-minded users sometimes ask whether twimg is malware: it is not, it is the site's own CDN, and a request to it is a normal part of loading x.com or an embedded tweet.

The one host rule that matters for code. media_url_https on a tweet is always a pbs.twimg.com image, even for a video or a GIF, where it is the poster frame. The playable files live under video_info.variants[] on the video or GIF host. Code that treats media_url_https as "the media" downloads a JPEG for every video.

02 — Section

Anatomy of a pbs.twimg.com image URL and what each name= rendition weighs

A photo URL has three parts: the host, the path /media/<media_id> (a 15-character ID such as HUwcJO6WgAAfBCd, not the numeric id_str), and a query string with two keys. format= names the encoding (jpg, png, webp) and name= names the rendition. The legacy form puts the extension in the path and no query string at all: https://pbs.twimg.com/media/<media_id>.jpg. That legacy form is what media_url_https contains in every API payload we have seen, which is where the "my image is smaller than the original" problem starts.

We took 30 random photos from the sample and sent a HEAD request for each at seven renditions plus the default URL and the bare path, 270 requests in all. Every named rendition answered 200 image/jpeg. The sizes below are medians across the 30 photos; the percentage is the rendition's total bytes as a share of the orig total.

URL formStatusMedian bytesShare of orig bytesNote
/media/<id> (no extension, no query)404 on 30 of 3000%the CDN needs a format
/media/<id>.jpg (what the API returns)200139,63213.1%identical to name=medium on 30 of 30
?format=jpg&name=thumb2005,6270.5%square crop
?format=jpg&name=small20052,7324.8%
?format=jpg&name=900x90020085,6347.9%fit inside 900 px
?format=jpg&name=medium200139,63213.1%the default
?format=jpg&name=large200308,60434.3%equal to orig on 11 of 30
?format=jpg&name=4096x4096200995,61995.2%equal to orig on 27 of 30
?format=jpg&name=orig200995,619100%the stored original

Three details fall out of the table. First, name=large is not the original: it matched orig on only 11 of the 30 photos, and across the set it carried a third of the bytes. The 11 matches were photos whose long edge already fit inside the large box, so there was nothing to shrink. Second, name=4096x4096 matched orig on 27 of 30; the three that differed were originals wider than 4,096 pixels (two at 6,064 x 4,043 from NASA and one at 4,275 x 1,408 from NASA Webb). In the full 485-photo sample, 23 photos had a long edge above 4,096 px and the largest was 8,192 px, so the widely repeated claim that X caps uploads at 4,096 px did not hold for these accounts in 2026. Third, ?format=jpg with no name= answered 200 with the medium rendition (61,738 bytes on the probed photo, exactly its name=medium size), while ?name=small with no format= answered 404: format= is required, name= is optional.

Horizontal bar chart titled What name= costs you: the same 30 twimg photos at seven renditions. Bars show median Content-Length in KB: name=orig 972 KB (100% of orig bytes in total, green), name=4096x4096 972 KB (95.2%), name=large 301 KB (34.3%), name=medium 136 KB (13.1%, orange, the default), name=900x900 84 KB (7.9%), name=small 51 KB (4.8%), name=thumb 5 KB (0.5%). Subtitle: the URL the API returns is the medium rendition; name=orig weighed 7.7x more in total; name=large matched orig byte-for-byte on 11 of 30 photos, name=4096x4096 on 27 of 30. Source: HEAD requests to pbs.twimg.com for 30 random photos from NASA, NatGeo, SpaceX, NASAWebb, ESA and Nintendo of America, collected in 2026; all 210 answered 200 image/jpeg.
Median Content-Length of the same 30 photos at each name= rendition. The default URL is the medium rendition; name=orig carried 7.7x the bytes in total. HEAD requests to pbs.twimg.com, collected in 2026.
03 — Section

How to get the full-size original image

Rewrite the API URL. Take media_url_https, split off the extension, and append ?format=<ext>&name=orig: https://pbs.twimg.com/media/HUwcJO6WgAAfBCd.jpg becomes https://pbs.twimg.com/media/HUwcJO6WgAAfBCd?format=jpg&name=orig. Keep the extension the API gave you as the format= value. In our probes a PNG source (/media/<id>.png) requested as ?format=jpg&name=orig answered 404, while ?format=png&name=orig answered 200 with the same 520,267 bytes as the plain .png URL. The reverse direction worked on the JPEG we tried (?format=png&name=small returned image/png, ?format=webp&name=small returned image/webp at 16,580 bytes), but it is a transcode of a stored JPEG, not a lossless original, so there is no reason to ask for it unless you want WebP for a thumbnail grid.

The legacy colon syntax still works. https://pbs.twimg.com/media/<id>.jpg:orig answered 200 with the same Content-Length as ?format=jpg&name=orig (321,547 bytes on the probed photo). Older scripts and browser extensions still rely on it. It is undocumented and the query-string form is what x.com itself requests, so write new code against ?format=&name=.

Do not assume large is enough. For a thumbnail or a preview, name=small (4.8% of the bytes) or name=medium (13.1%) is the right call and saves real bandwidth. For an archive, a dataset or anything you will resize yourself, request name=orig; the median original in our 30-photo sample was 995,619 bytes and the largest was 3,895,817 bytes, so budget storage accordingly. The 485 photos in the full sample had a median long edge of 2,048 px, 225 exceeded 2,048 px, and the most common dimensions were 1,920 x 1,080 (41 photos), 4,096 x 2,304 (22) and 3,000 x 2,000 (21).

Video posters follow the same rule. The thumbnail URL on a video, https://pbs.twimg.com/amplify_video_thumb/<media_id>/img/<file>.jpg, accepts ?format=jpg&name=orig too: the probed poster grew from 104,759 bytes at the default URL to 178,602 bytes at orig.

python
from urllib.parse import urlsplit


def original_image_url(media_url_https: str) -> str:
    """Turn the legacy /media/<id>.jpg form into the name=orig form, keeping the stored format."""
    parts = urlsplit(media_url_https)
    path, _, ext = parts.path.rpartition(".")
    if not path or ext.lower() not in {"jpg", "jpeg", "png", "webp"}:
        return media_url_https  # already a ?format= URL, or something we do not understand
    return f"https://{parts.netloc}{path}?format={ext.lower()}&name=orig"


print(original_image_url("https://pbs.twimg.com/media/HUwcJO6WgAAfBCd.jpg"))
# https://pbs.twimg.com/media/HUwcJO6WgAAfBCd?format=jpg&name=orig
print(original_image_url("https://pbs.twimg.com/media/HQHf51pW0AEIWOI.png"))
# https://pbs.twimg.com/media/HQHf51pW0AEIWOI?format=png&name=orig
04 — Section

video.twimg.com and video-s.twimg.com: the MP4 rendition ladder and the HLS playlist

A video in a tweet payload is one media object of type: "video" whose video_info carries aspect_ratio, duration_millis and a variants[] array. Every one of the 191 videos in our sample had exactly one application/x-mpegURL variant (the HLS playlist, no bitrate key) plus between two and five video/mp4 variants, each with a bitrate. The path encodes the resolution, so you can read the ladder without parsing anything else: https://video-s.twimg.com/amplify_video/<media_id>/vid/avc1/1280x720/<file>.mp4?tag=29.

FieldWhat we saw on 191 videos
Variants per video3 (6 videos), 4 (56), 5 (83), 6 (46)
MP4 renditions per video2 (6 videos), 3 (56), 4 (83), 5 (46)
HLS playlists per videoexactly 1 on 191 of 191
Most common MP4 resolutions480x270 (120 renditions), 640x360 (114), 1280x720 (114), 1920x1080 (89), 3840x2160 (45)
Bitrate range256,000 to 25,128,000 bits per second; median rendition 950,000
Top rung per video10,368,000 on 73 videos, 25,128,000 on 46, 2,176,000 on 30, 1,280,000 on 26
Durationmedian 36.0 s, longest 452.2 s
Host of the rendition URLsvideo-s.twimg.com on 731 renditions, video.twimg.com on 202
Path familyamplify_video on 933 renditions (all videos), tweet_video on 16 (all animated GIFs)

Pick the highest bitrate, not the last element. The variants array is not sorted by quality in a guaranteed order; filter to content_type == "video/mp4" and take the maximum bitrate. The X Developers walkthrough of the v2 variants field (dev.to/xdevs/get-a-video-url-from-twitter-api-v2-1713) shows the same ladder shape on the official API: 256,000 / 832,000 / 2,176,000 / 10,368,000 plus an .m3u8 with no bit rate. The 25,128,000 rung is the 4K (3840x2160) rendition that SpaceX, NASA and NatGeo upload; it did not exist in older ladders and your downloader should not assume 1080p is the ceiling.

The HLS playlist is for players, the MP4 is for files. The .m3u8 on video-s.twimg.com (1,628 bytes on the probed video) references fragmented MP4 segments and an audio track; a browser <video> element cannot play it without an HLS library on most desktop browsers, and a downloader has to stitch segments. The progressive MP4 renditions are single files: the probed 1080x1920 rendition was 8,401,119 bytes, answered 200 video/mp4 with Cache-Control: public, max-age=604800, and the identical path on video.twimg.com returned the same byte count. The ext_tw_video/<id>/pu/vid/ family that the X data dictionary and the X Developers walkthrough show did not appear once in this sample: all 933 video renditions from these six accounts used amplify_video, so a downloader should match on the host and the .mp4 extension rather than on a path prefix.

Animated GIFs are MP4s. A type: "animated_gif" object has a /tweet_video_thumb/ poster on pbs and a single video/mp4 variant under /tweet_video/ on the video host, with a bitrate of 0. There is no GIF file to download; convert the MP4 if you need one.

05 — Section

Profile images and the other pbs paths

Avatars live under https://pbs.twimg.com/profile_images/<numeric_id>/<file>_<size>.jpg. The user object from /twitter/user/info and the author block on every tweet carry the _normal form, and the size is a plain suffix swap on the file name. We probed three forms on one public organization account: the _normal file was 1,807 bytes, _400x400 was 8,476 bytes and the suffix-free file was 12,302 bytes, all 200 image/jpeg with Cache-Control: public, max-age=604800. The suffix-free form was the largest of the three, which is what you want for a profile card.

Why profile image links rot. The <numeric_id> segment is the ID of the upload, not of the user. When an account changes its avatar, a new upload ID is minted and the old path stops resolving. We probed a made-up file under a real upload ID and got 404 with Cache-Control: max-age=300, must-revalidate, which is also what a stale avatar path returns. Store the user ID, not the avatar URL, and re-read /twitter/user/info (18 credits, $0.18 per 1,000 profiles) when a picture breaks. Caching a _400x400 URL for a week in your own app is fine; caching it for a year is how dashboards end up full of broken circles.

Media IDs are not tweet IDs. The /media/<id> segment (HUwcJO6WgAAfBCd) is a base64-style key that also appears as the tail of media_key (3_2111093270277160960, where the prefix 3_ means photo, 13_ means video and 16_ means animated GIF, and the number is id_str). The tweet's own id is a different snowflake. You cannot derive a pbs URL from a tweet ID; you have to read the tweet.

Path family on pbs.twimg.comCarriesAccepts ?format=&name=
/media/<key>photosyes
/amplify_video_thumb/<id>/img/<file>video poster framesyes (probed: 104,759 to 178,602 bytes at orig)
/tweet_video_thumb/<key>animated GIF poster framessame family as above
/profile_images/<upload_id>/<file>_<size>avatarsno; swap the _size suffix (_normal, _400x400, none)
06 — Section

Why hotlinked twimg images break (and what is not the reason)

The most-searched variant of this keyword is a bug report: images from pbs.twimg.com are broken in a React app, a Discord embed, a Notion page or a spreadsheet. The usual guesses are a referrer check or CORS. We tested both and neither was the cause on this CDN in 2026. The failures we could reproduce were all malformed URLs.

RequestResultLikely origin of the bug
?format=jpg&amp;name=small404URL copied from HTML source, where & is escaped as &amp;; JSX and JSON strings do not unescape it
?name=small (no format=)404hand-built URL that dropped the format
?format=jpg&name=huge404invented rendition name; only the documented set resolves
/media/<id> (no extension, no query)404path trimmed by a regex or a URL shortener
PNG source with ?format=jpg404hard-coded jpg instead of the extension the API returned
/profile_images/<old_id>/...404, cached 300 suser changed avatar after you stored the URL
name=orig with Referer: https://example.com/page200not a referrer block
name=orig with Origin: https://example.com200, Access-Control-Allow-Origin: https://example.comnot a CORS block; fetch() and <canvas> reads work

CORS and referrers, measured. All 30 name=orig probes without any browser headers returned Access-Control-Allow-Origin: *; when we sent an Origin header the CDN echoed it back, which is what a credentialed cross-origin fetch needs. A third-party Referer changed nothing. The browser default referrer policy, strict-origin-when-cross-origin (MDN, developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy), sends only your origin to pbs on a cross-origin image load, and the CDN served the file anyway. If your hotlink fails, print the exact URL the browser requested from the network tab and compare it to the table above before touching headers.

The React-specific trap. The &amp; row explains most "images in pbs.twimg.com/media are broken on react" reports: a developer copies src="...?format=jpg&amp;name=small" out of rendered HTML into a JSX string literal. In HTML the browser decodes &amp; to &; in a JavaScript string it stays literal, the CDN sees a parameter named amp;name, and answers 404. The fix is to build the URL from media_url_https in code (see the helper above) rather than pasting markup.

Caching behavior you can rely on. Successful responses carried Cache-Control: max-age=604800 for images (22 of 30 orig probes as max-age=604800, must-revalidate, 8 as public, max-age=604800 from a server: cloudflare edge) and the same seven-day lifetime on MP4s; 404s carried max-age=300 or max-age=14400. Per RFC 9111 (rfc-editor.org/rfc/rfc9111), max-age makes the response stale after that many seconds and must-revalidate forbids a cache from reusing a stale copy without checking the origin, so a proxy in front of your app will re-validate a week-old image rather than serve it forever. Deleted media returns 404 on the next re-validation, which is the right behavior for a tweet that was taken down.

07 — Section

Getting a tweet's media URLs with TwitterAPI.io

You never have to guess a twimg URL. Every tweet endpoint on TwitterAPI.io (/twitter/user/last_tweets, /twitter/tweet/advanced_search, /twitter/tweets, /twitter/user/mentions, and the rest) returns the tweet's extendedEntities.media[] array as X's own clients see it, and no X account or developer app is needed for reads. The fields you use are the same ones X's data dictionary describes for its media object, under X's legacy names.

Field on extendedEntities.media[]Meaning
typephoto, video or animated_gif
media_url_httpspbs URL of the photo, or the poster frame for video and GIF
media_key<type prefix>_<id_str>: 3_ photo, 13_ video, 16_ GIF
original_info.width / .heightstored pixel size (used above for the 4,096 px check)
ext_alt_textthe author's alt text, when set
video_info.duration_millisvideo length
video_info.variants[]content_type, url, and bitrate on the MP4 entries

What a read costs. Tweet endpoints bill 15 credits per tweet returned, $0.15 per 1,000 tweets, with a 15-credit minimum per request; a full 20-tweet page of last_tweets is $0.003. The study behind this page read 980 tweets in 51 requests for 14,700 credits, $0.147. The official X API's pay-per-usage rate card (docs.x.com/x-api/getting-started/pricing) charges $0.005 per post read, so the same 980 posts would have cost $4.90, and per 1,000 tweets it is $5 against $0.15, about 33x; the X card also caps pay-per-usage at 3 million post reads a month and offers $20 of credits on a first saved card plus a match of the first auto-recharge up to $50. New TwitterAPI.io accounts that pass the automatic bot check get up to $1 of free credits (at most 100,000 credits, no card, one per person or network, expiring after 31 days); purchased credits never expire. Rate limits start at 0.2 requests per second before the first payment and 20 once paid; full tiers are on the pricing page.

Downloading is a separate HTTP call to the CDN, not to us. The script below reads a timeline, rewrites each photo to name=orig, picks the top-bitrate MP4 for each video, and fetches the files directly from pbs and video-s. Those CDN fetches are not API calls and are not billed; what you pay for is the tweet read. Respect the content: these are the authors' files, X's terms govern reuse, and organization accounts are the sensible target for bulk archives.

08 — Section

What organization accounts actually attach: the 2026 sample

To get real twimg URLs to probe we pulled the latest posts of six media-heavy organization accounts: NASA, National Geographic, SpaceX, NASA Webb, ESA and Nintendo of America. Two accounts returned fewer than ten pages (SpaceX 120 posts, Nintendo of America 60), which is why the total is 980 rather than 1,200.

AccountPostsWith mediaPhotosVideosGIFs
@NintendoAmerica6090.0%40230
@NASAWebb20069.5%123119
@SpaceX12062.5%110490
@NatGeo20053.5%44650
@esa20048.5%94197
@NASA20043.5%74240
All six98057.0%48519116

Across the 559 posts that carried media, 492 had a single item, 27 had two, 14 had three and 26 had the maximum of four; SpaceX accounted for 21 of the four-image posts. Of the 485 photos, 479 were JPEG and 6 were PNG, which is the practical reason the format= value must come from the API rather than being hard-coded. The reposts in the sample (NASA 90 of 200, ESA 83 of 200) carry the original author's media under retweeted_tweet, with the same URL shapes.

What the sample cannot show. These are large institutional accounts with professional media; a consumer timeline will have more phone-sized JPEGs and fewer 4K uploads, so the bitrate ladder and the share of originals above 4,096 px will differ. The HEAD probes were sent from one region on one day; CDN headers (the server: cloudflare split in particular) can vary by edge. The probes measured Content-Length, not visual quality.

Stacked horizontal bar chart titled What organization accounts attach: photos vs videos vs GIFs per account. Rows: @NintendoAmerica 63% photos 37% videos (90% of posts with media, 63 items); @NASAWebb 86% photos 8% videos and a GIF slice (70% of posts, 143 items); @SpaceX 69% photos 31% videos (62% of posts, 159 items); @NatGeo 40% photos 60% videos (54% of posts, 109 items); @esa 78% photos 16% videos and a GIF slice (48% of posts, 120 items); @NASA 76% photos 24% videos (44% of posts, 98 items). Subtitle: 57.0% of 980 posts carried media (692 items): 70.1% photos, 27.6% videos, 2.3% animated GIFs; 6 of 485 photos were PNG. Source: twitterapi.io /twitter/user/last_tweets, up to 200 most recent posts per account, extendedEntities.media[].type, collected in 2026.
Share of media items by type for each of the six accounts, with the share of posts that carried media. 980 posts, 692 media items, collected in 2026.
09 — Section

How we collected this

Question. What do the media URLs attached to tweets from media-heavy organization accounts look like on X's twimg CDN: which hosts, which path families, how many renditions, and what does each name= rendition weigh?

Accounts and endpoint. NASA, NatGeo, SpaceX, NASAWebb, esa and NintendoAmerica, read through /twitter/user/last_tweets with userName=<account>, default includeReplies (false), newest first, up to 10 pages of 20 per account. 51 requests, 980 posts, 14,700 credits, $0.147.

Window. The most recent posts of each account at collection time in 2026; the per-account window runs from the oldest to the newest post returned and differs by account.

Sample. 980 posts, 559 with media, 692 media items (485 photos, 191 videos, 16 animated GIFs). For the rendition probe we drew 30 random /media/ photos (seeded, so the draw is reproducible) and sent unproxied curl -sI HEAD requests at nine URL forms each, plus 22 extra probes covering the video host, the :orig legacy form, format= transcodes, profile images, the asset host, and the hotlink failure cases. Every number on this page is in results.json next to the analysis script and the gzip-compressed raw pages.

What it cannot show. Six accounts are not X; consumer accounts upload different sizes and fewer 4K videos. HEAD responses report bytes and headers, not visual quality or decode time. Headers were read from one network vantage on one day, and CDN behavior can change without notice; re-run the probes before relying on a specific header value.

Privacy. Aggregates only. All six accounts are public organizations, so the raw timelines are committed in compressed form and the probed media URLs are listed in sample.json. No individual's posts, media or profile images were analyzed, and the one profile-image URL probed belongs to a public organization account.

python
"""List and download the media from an account's recent tweets.

Reads tweets from TwitterAPI.io (15 credits per tweet returned), then fetches the files
straight from X's CDN: name=orig for photos, the top-bitrate MP4 for videos and GIFs.
"""
import os
import pathlib
import requests
from urllib.parse import urlsplit

BASE = "https://api.twitterapi.io"
HEADERS = {"X-API-Key": os.environ["TWITTERAPI_IO_KEY"]}
OUT = pathlib.Path("media")
OUT.mkdir(exist_ok=True)


def original_image_url(media_url_https: str) -> str:
    parts = urlsplit(media_url_https)
    path, _, ext = parts.path.rpartition(".")
    if not path or ext.lower() not in {"jpg", "jpeg", "png", "webp"}:
        return media_url_https
    return f"https://{parts.netloc}{path}?format={ext.lower()}&name=orig"


def best_mp4(video_info: dict) -> str | None:
    mp4s = [v for v in video_info.get("variants", []) if v.get("content_type") == "video/mp4"]
    if not mp4s:
        return None
    return max(mp4s, key=lambda v: v.get("bitrate", 0))["url"]


def iter_tweets(user_name: str, max_pages: int = 5):
    cursor = ""
    for _ in range(max_pages):
        r = requests.get(
            f"{BASE}/twitter/user/last_tweets",
            headers=HEADERS,
            params={"userName": user_name, "cursor": cursor},
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        for tweet in body["data"]["tweets"]:
            yield tweet
        if not body.get("has_next_page"):
            break
        cursor = body["next_cursor"]


def media_files(tweet: dict):
    """Yield (kind, url) for every media item; reposts carry the original's media."""
    source = tweet.get("retweeted_tweet") or tweet
    for m in (source.get("extendedEntities") or {}).get("media") or []:
        if m["type"] == "photo":
            yield "photo", original_image_url(m["media_url_https"])
        else:  # video or animated_gif: media_url_https is only the poster frame
            url = best_mp4(m.get("video_info", {}))
            if url:
                yield m["type"], url


def download(url: str) -> pathlib.Path:
    name = urlsplit(url).path.rstrip("/").rsplit("/", 1)[-1]
    if "?format=" in url:  # /media/<id>?format=jpg&name=orig -> <id>.jpg
        name += "." + url.split("format=")[1].split("&")[0]
    target = OUT / name
    if not target.exists():
        with requests.get(url, stream=True, timeout=60) as resp:
            resp.raise_for_status()  # 404 here means a malformed URL or deleted media
            with open(target, "wb") as f:
                for chunk in resp.iter_content(1 << 16):
                    f.write(chunk)
    return target


if __name__ == "__main__":
    seen = set()
    for tweet in iter_tweets("NASA", max_pages=3):
        for kind, url in media_files(tweet):
            if url in seen:
                continue
            seen.add(url)
            path = download(url)
            print(f"{kind:12s} {path.stat().st_size:>10,d} bytes  {url}")
10 — Questions

Questions readers ask

What is pbs.twimg.com?

pbs.twimg.com is the Twitter (X) host that serves photos, video poster frames and profile images. Every one of the 692 media_url_https values in our 2026 sample pointed at it: 572 under /media/, 104 under /amplify_video_thumb/ and 16 under /tweet_video_thumb/. The pbs prefix is commonly expanded as Photo Blobstore in developer forums; X has not documented the expansion.

Is twimg a virus or a tracker?

No. twimg.com is X's own content delivery domain. abs.twimg.com serves the site's scripts, favicons and emoji sprites, pbs.twimg.com serves images, and the video hosts serve MP4s and HLS playlists. Seeing these hosts in a firewall log, a browser extension or a network tab means a page loaded x.com content or an embedded tweet. Blocking abs.twimg.com breaks x.com itself.

How do I get the full-size original image from a pbs.twimg.com URL?

Remove the extension and add ?format=<ext>&name=orig, keeping the extension the API gave you: /media/<id>.jpg becomes /media/<id>?format=jpg&name=orig. In our probes name=orig carried 7.7x the bytes of the default URL in total, name=large matched the original on only 11 of 30 photos, and three originals were wider than 4,096 px, so name=4096x4096 is not a substitute. The legacy /media/<id>.jpg:orig form also still answered 200.

Why does my pbs.twimg.com image URL return 404?

In our tests every 404 was a malformed URL: a bare /media/<id> with no extension or format= (404 on 30 of 30), an &amp; left in the query from copied HTML, ?name=small without format=, an invented name= value, a PNG source requested as format=jpg, or a profile_images path from before the user changed their avatar. A third-party Referer and a cross-origin Origin header both received 200, so a referrer or CORS block was not the cause.

What is video-s.twimg.com, and is it different from video.twimg.com?

Both serve MP4 renditions and .m3u8 playlists under the same path families (amplify_video for videos, tweet_video for animated GIFs). In our 2026 sample 731 of 933 video renditions were on video-s.twimg.com and 202 on video.twimg.com, and the same MP4 path answered 200 with an identical Content-Length on both hosts. Use whichever host the variants array gives you.

Can I hotlink or fetch twimg images from my own site?

Technically the CDN allowed it in every probe: Access-Control-Allow-Origin was * without an Origin header and echoed the origin when one was sent, and a third-party Referer did not change the response. Whether you may reuse the file is a question for X's terms and the author's rights, not for the CDN. For your own app, build URLs from media_url_https in code and cache them for no longer than the seven-day max-age the CDN itself uses.

Which name= value should I use for thumbnails?

name=small was 4.8% of the original bytes (median 52,732) and name=thumb 0.5% (median 5,627, a square crop); name=900x900 fit inside 900 px at 7.9%. The default medium rendition was 13.1%. For a grid of previews, small or 900x900 is plenty; for a lightbox, large (34.3%) or orig.

How do I get a tweet's image and video URLs with an API?

Any TwitterAPI.io tweet endpoint, such as /twitter/user/last_tweets or /twitter/tweet/advanced_search, returns extendedEntities.media[] with type, media_url_https and video_info.variants[] (content_type, url, bitrate). Reads bill 15 credits per tweet returned, $0.15 per 1,000; the official X API pay-per-usage card is $0.005 per post read, $5 per 1,000. No X account is needed for reads. The full script is on this page.

11 — Further reading

Continue

Sources & further reading
More from this series
Build it

Stop reading. Start building.

Starter credits cover real testing on real data. Google sign-in, no card, no application queue.

Get an API key