I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them opens Instagram highlight albums — the permanent circles under a profile bio — and returns one row per media inside: the file link, when it was posted, the caption, and the stickers on it.

Highlights are the one part of Instagram that feels archival. Stories vanish in 24 hours; highlights are what the account decided to keep. So the natural assumption is that a highlight row is a stable thing you can store and come back to.

Half of that is true. The row is stable. The file link in it is not, and the two halves of the same row do not even die on the same day.

The run

Ten public accounts, highlights only, five albums each, measured on 8 November 2026:

{ "usernames": ["natgeo", "nasa", "nike", "starbucks", "airbnb", "gopro", "spotify", "adidas", "canva", "duolingo"], "mode": "highlights", "includeHighlightItems": true, "maxHighlightsPerUser": 5, "maxItemsPerUser": 15 } 

251 media rows, 83.7 seconds, $0.99. 140 videos, 111 photos, spread over 25 albums from 8 of the 10 accounts. Every row carries an imageUrl; the 140 video rows carry a videoUrl as well. That is 391 signed CDN links in one dataset, and they are what this post is about.

Both links are signed, and they carry their own expiry in plain sight

An Instagram media URL ends with something like &oh=00_AQT…&oe=6AF54AED. The oh is a signature. The oe is a Unix timestamp in hexadecimal — the moment the signature stops being accepted. 0x6AF54AED is 12 November 2026, 05:13 UTC.

Decoding oe across all 391 links from that run, measured against each row's own scrapedAt:

Link Count Median life Range Under 48 h
imageUrl 251 106.0 h 104.0 – 108.0 0 of 251
videoUrl 140 34.0 h 32.0 – 106.5 136 of 140

So the picture link lives about four and a half days, the video link about a day and a half, and in the same row they are roughly 72 hours apart. Four videos out of 140 came back with a long-lived URL instead; I have no explanation for those and I am not going to invent one.

This is not a guess from the timestamps. My own health check reads one natgeo album twice a day and keeps the datasets, so I could just fetch the old links today, on 8 November at 13:30 UTC:

Dataset age imageUrl videoUrl
read 2 Nov, 6 days old 5 × 403 5 × 403
read 4 Nov, 4 days old 7 × 200 7 × 403
read 7 Nov, 1 day old 8 × 200 8 × 200

The 4 November row is the one to look at. Same rows, same media, saved in the same JSON file: the thumbnails still work and the videos are gone. A pipeline that stores rows now and downloads media later will not fail cleanly — it will quietly produce a library of posters with no footage.

One more thing those three reads show. The same media, scraped on 2, 4 and 7 November at ~20:15 UTC each time, produced image expiries of 7 Nov 05:13, 9 Nov 05:13 and 12 Nov 05:13. The clock time is a property of the media; only the date moves. The window is therefore not a constant number of hours from your request — scrape the same album at 06:00 and you get noticeably longer than scraping it at 20:00. Do not hard-code "links are good for four days".

The practical rule is dull and I will say it anyway: download during the run, or treat the link as a 24-hour lease. mediaId is stable — in my three natgeo reads the album grew 5 → 7 → 8 media and the first five IDs never changed — so re-fetching a specific media later is cheap and deterministic. Storing the URL instead of the bytes is the expensive mistake.

A highlight is an archive, and the dates say so — but mind the order

takenAt on a highlight media is the day the story originally went up, not the day it was pinned. Across all 251 rows the median age was 151 days and the oldest — a canva "Canva Live" media — was posted on 2 October 2023, 1,133 days before my run.

Before trusting that number, one ordering fact that cost me a paragraph of wrong conclusions: media inside an album arrive oldest-first. All 25 albums were strictly ascending by takenAt. So a per-account cap does not sample an album, it truncates its recent end — and 10 of my 25 albums hit the cap of 15. Any age statistic pooled over a capped run is biased old, including the 151 days above.

The honest subsample is the 101 rows from the 15 albums that came back complete: median age 73 days, 24 of 101 over a year old, same 1,133-day maximum. By account, counting only their complete albums:

Account Rows Newest media Median age
nasa 29 today 70 days
natgeo 8 1 day 10
duolingo 25 11 days 23
gopro 35 73 days 410
canva 4 775 days 788

Two editorial strategies in one table. nasa and natgeo use highlights as a live shelf — nasa's "MAX POWER" album was still being added to on the day I read it. gopro's five albums are product launches frozen at the launch: "HERO13" is eight media all posted on 4 September 2024. If you are scoring "brand activity on Instagram" from highlight volume, you are mostly scoring how long ago they stopped.

Two practical consequences. If you want the newest media from an album, do not use maxItemsPerUser as a sampler — it keeps the oldest. And if you are dating a brand's activity, date it from the newest takenAt in a complete album, not from the album count.

The link stickers, and what I could not measure

75 of the 251 rows carried a link sticker, resolving to 42 unique destinations after unwrapping l.instagram.com/?u=. I wanted to report how many of those have rotted, since some sit on media from 2023.

I cannot. Fetching all 42 from my server, 25 answered 200 and 17 answered 403 — and all 17 of the 403s are gopro.com or canva.com, every URL on those two domains, including ones a browser opens fine. That is a site-level block on a datacenter address, not link rot. The honest result is: this measurement needs residential egress, I did not have it, and a number produced anyway would have been a lie. What the data does support is narrower and still useful — a link sticker is attached to a story, not to a campaign, so its target has been live for a median of five months and sometimes three years, and any CTA audit has to join on takenAt before it concludes anything.

What my own run told me about my own Actor

Two things, and both were bugs.

maxItemsPerUser: 15 returned 64 rows for canva and 59 for nasa. The field is called "Max media per account". It was being applied per album. With maxHighlightsPerUser: 5 the real ceiling was 75 per account, and since the Actor bills $0.004 per media, a buyer who set 15 as a budget guard could be charged four times what they asked for. That is not a rounding error, it is someone's money. Fixed: the cap is now counted across all of an account's albums and album fetching stops when it is reached. Same input after the fix — canva 15, nasa 15; a separate check with a cap of 6 gave gopro exactly 6 across its five albums, where the old build would have given 30.

starbucks and spotify returned zero rows, were charged $0.01 each, and said nothing about it. My README promised a free notice row explaining every empty account. The code only emitted it when the entire run was empty, so in a mixed list the paid-for-but-silent accounts stayed silent. Fixed: every account that returns no media now gets its own free row with the reason.

Writing that reason was its own small lesson. My first attempt said "no such public account, or it is private" — because the data source answers 404 and 404 is all you get. Then the stories-mode check found starbucks posting an active story, so it is neither missing nor private; it simply has no highlights. Instagram's 404 covers three different situations and a scraper must not pick one of them for the buyer. The row now names all three.

Calling it

import requests rows = requests.post( "https://api.apify.com/v2/acts/lergassy~instagram-highlights-scraper" "/run-sync-get-dataset-items", params={"token": "apify_api_..."}, json={"usernames": ["natgeo", "nasa"], "maxHighlightsPerUser": 5, "maxItemsPerUser": 15}, timeout=600, ).json() media = [r for r in rows if r["type"] != "notice"] for m in media: # download now, not tomorrow  url = m["videoUrl"] or m["imageUrl"] open(f'{m["mediaId"]}.bin', "wb").write(requests.get(url, timeout=60).content) 

A real row from the run, trimmed:

{ "type": "highlight-item", "username": "natgeo", "mediaId": "4012120567117354624", "mediaType": "video", "takenAt": "2026-10-20T13:14:30.000Z", "imageUrl": "https://…&oe=6AF54AED", "videoUrl": "https://…&oe=6AF15A21", "videoDurationSeconds": 9.943, "links": ["https://l.instagram.com/?u=https%3A%2F%2Fwww.nationalgeographic.com%2F…"], "isPaidPartnership": false, "highlightId": "18023864549864045", "highlightTitle": "BOTW 2027" } 

The Actor is Instagram Highlights Scraper. It charges $0.01 per account looked up — whether or not it has anything, because the lookup costs the same either way — and $0.004 per media delivered. Turning includeHighlightItems off gives you one row per album with its title, cover and media count, which is the cheap way to find out whether opening an account is worth it.

When it is the wrong tool. If you need the files themselves archived, this is only half the job: you get links with a 34-hour fuse and the downloading is yours. If you need story viewers or insights, no scraper can help — only the account owner sees those. And if you want to know whether a brand is active right now, read takenAt, not the album count: gopro's shelf looks busy and its newest complete album is from August.