Why Your Scraped Instagram and TikTok URLs 404 — and How to Read the Expiry Before They Do

You pull 50,000 posts. You store the JSON. Everything looks fine.
Ten days later half your images are gone and you're staring at 403 Forbidden on URLs that worked when you saved them.
This isn't rate limiting, and it isn't the posts being deleted. Social platforms serve media through signed CDN URLs, and every signature carries an expiry. The window varies from about a day to about three weeks depending on the platform and the media type.
The good news: the expiry is already sitting in the URL you have. No extra request, no API call. You just have to know where each platform puts it.
The reference table
Every number below was measured across live responses, not copied from documentation:
| Platform | Window | Where it lives |
|---|---|---|
| TikTok video | ~25–36 h | hex segment in the URL path |
| TikTok images | ~48 h | x-expires= (decimal) |
| Instagram / Meta video | ~1.5 d | oe= (hex) |
| Instagram / Meta images | ~4–5 d | oe= (hex) |
| Facebook Ad Library | ~5 d | oe= (hex) |
| Facebook Marketplace | ~4.4 d | oe= (hex) |
| ~19 d | e= (decimal) | |
| Truth Social | never | no signature at all |
| Snapchat | unknown | mo= (base64, not a timestamp) |
TikTok video is the one that catches people out. A day and a bit is short enough that a nightly job can finish after the links it collected have already died.
Meta: Instagram, Facebook, Marketplace, Ad Library
Meta uses oe=, a hex-encoded unix timestamp, across all of its surfaces:
https://scontent-den2-1.xx.fbcdn.net/v/t39.84726-6/792882496_...jpg
?_nc_cat=109&ccb=1-7&oh=00_AQLl8Ts...&oe=6AA0AC68
Decoding it is one line in either direction:
// JavaScript
const oe = new URL(url).searchParams.get("oe");
const expiresAt = new Date(parseInt(oe, 16) * 1000);
// 0x6AA0AC68 → 1788914792 → 2026-09-08T...Z
# Python
from datetime import datetime, timezone
from urllib.parse import urlparse, parse_qs
oe = parse_qs(urlparse(url).query)["oe"][0]
expires_at = datetime.fromtimestamp(int(oe, 16), tz=timezone.utc)
How to verify you've got it right — rather than trusting a blog post: decode two different URLs from the same response. If they come back a few minutes apart, it's a genuine per-URL signature:
oe=6AA0AC68 → 1788914792
oe=6AA0C205 → 1788920325 (92 minutes later)
If they're all identical, you're not looking at an expiry. More on that trap below.
TikTok: two different schemes
TikTok signs images and video differently, so you need both.
Images use a straightforward x-expires= in plain decimal seconds:
const exp = new URL(url).searchParams.get("x-expires");
const expiresAt = new Date(Number(exp) * 1000);
Video doesn't use a query parameter at all. The deadline is a hex segment inside the path:
https://v16-webapp.tiktok.com/6a997481/.../video.mp4
^^^^^^^^
0x6A997481 → 1788441729
This is the shortest window in the entire catalogue — roughly 25 to 36 hours. If you're archiving TikTok video, you are not storing a link, you are storing a countdown.
LinkedIn: day-granular, which is unusually convenient
LinkedIn uses e= in plain decimal:
?e=1790208000&v=beta&t=TxDx-KCIfRXX...
1790208000 / 86400 === 20720 // exactly
An exact multiple of 86,400 means the expiry lands on midnight UTC. LinkedIn issues these with day granularity rather than per-request, so every asset fetched on the same day shares a deadline — which makes batch refresh scheduling trivial.
At about 19 days, it's also the most generous window we measured anywhere.
Trap 1: the token that looks like an expiry and isn't
This one nearly caught us twice.
TikTok Shop product images carry a t= parameter that looks exactly like the timestamps above:
?dr=15582&t=555f072d&ps=933b5bde&shp=7745054a&idc=my2
0x555f072d → 1432291117 → 2015-05-22
2015. That's not an expiry, that's a build or version token. The giveaway: it was byte-identical across all nine image URLs on the page. TikTok's popular-songs endpoint has the same thing with t=4d5b0474 → 2011.
Two rules that catch this:
- If the decoded timestamp is in the past, it isn't an expiry.
- If the value is identical across every URL in one response, it isn't a per-URL signature.
Apply both before you write a refresh job around a number that never changes.
Trap 2: one expiry field per response is wrong by construction
Here's the finding that surprised us most. Take a single Instagram reel — one post, one API response:
videoUrl oe=6AA0E161 → 2026-09-09 (~1.5 days)
thumbnailUrl oe=6AA4A207 → 2026-09-12 (~4.3 days)
user.avatar oe=6AA4CEDE → 2026-09-12 (~4.4 days)
Three URLs in the same payload, three different deadlines, spread across nearly three days.
So a single mediaExpiresAt field on a response is wrong no matter how you compute it. It either collapses to the minimum — throwing away three days of thumbnail life — or it reports the maximum and lies about the video.
You need one expiry per URL:
"videoUrlExpiresAt": "2026-09-09T03:00:28Z",
"thumbnailUrlExpiresAt": "2026-09-12T06:05:39Z"
What to actually do about it
Store the expiry alongside the URL. Decode once at ingest. An expires_at column next to every media URL costs nothing and turns a silent failure into a query you can run.
Decide early: link or bytes. If the media matters to you beyond a week, download it. No expiry strategy survives a platform shortening its window, and they do. If you only need links for a dashboard that renders today's data, storing the URL with its deadline is enough.
Re-fetch before expiry, not after. Once a URL 404s you need the whole post again, which costs a full request. Refreshing a day before expiry costs the same request but keeps your dataset continuous. For LinkedIn's day-granular expiries you can batch an entire day's assets into one job.
Don't assume the window is stable. Every number in this article is a measurement taken from live responses, not a documented guarantee. Platforms change these. Decode the actual URL rather than hardcoding "five days".
The part nobody warns you about
None of these platforms tell you any of this in the response. You get a URL that works, and the fact that it has a deadline is something you find out when it stops working — usually long after the requests that produced it are spent.
That's why Captapi ships videoUrlExpiresAt and thumbnailUrlExpiresAt as first-class fields — decoded at the source, one per URL, never collapsed to a single minimum — on endpoints like Instagram Channel Reels and the TikTok Trending Feed. 178 endpoints, 32 platforms, and nobody has to rediscover any of the above.
But the decodes are up there and they work whether or not you use us. If you're building a social media dataset, spend the ten minutes now rather than the afternoon later.