At a glance
Status, checked 2026-09-18: the lifetime of a media_url link is not published by Meta; each link's oe parameter shows its own expiry. Whether ElectricBlaze stores copies of images for connected accounts is not established yet.
- Use it when
- Instagram images on your site worked at deploy time and now fail with
403, or opening amedia_urldirectly showsURL signature expired. - Do not use it when
- the API call itself fails or returns no posts. That is a token or permission problem; see feed not working.
- What the human does
- nothing for the first fix. The copy-on-a-schedule fix needs a repository or bucket the site is served from, and the Instagram token saved as a CI secret.
- Next step
- decode one broken image URL with the script below. If
expiresAtis in the past, pick a fix by where your HTML is rendered.
Why the images break
The API does not hand out permanent image addresses. Each media_url is a signed link to Instagram's CDN, and the signature has an end date.
A developer report describes the mechanism: the oe query parameter holds the expiry as a hexadecimal Unix timestamp, and after that moment the CDN answers 403 with the body URL signature expired (verbb/social-feeds issue 21 (2026-01), checked 2026-09-18). That source is a public issue tracker, not Meta, so treat it as secondary.
How long a link lives is not documented. A question about it on the Meta developer forum has no official answer (Meta developer community thread, checked 2026-09-18), so a number of hours quoted in a blog post is someone's observation, not a contract.
The code that fetched the posts is rarely at fault. What breaks is the place where the links were stored:
- Static export or SSG. The build writes the links of that moment into HTML that is then served for weeks.
- ISR or a CDN cache. A
revalidatewindow ors-maxagelonger than the remaining life of the links keeps serving dead URLs until the next regeneration. - A JSON cache. Posts saved to KV, Redis or a file at fetch time go stale the same way.
- An image optimizer.
next/imageand similar proxies fetch the original again on a cache miss and receive the same403.
A dev server usually hides the problem because it calls the API on each request, so every link it renders is fresh. Handle thumbnail_url and the URLs inside children the same way as media_url unless you have checked that they carry no oe.
Read the expiry from oe
The oe value is seconds since 1970, written in hexadecimal. parseInt(oe, 16) gives Unix seconds; multiply by 1000 for a JavaScript Date. In the browser console the same check on a broken image is one line: new Date(parseInt(new URL(img.src).searchParams.get("oe"), 16) * 1000).
decode-oe.mjs
// Prints when an Instagram CDN link stops working.
// Usage: node decode-oe.mjs "<media_url or thumbnail_url>"
const link = new URL(process.argv[2]);
const oe = link.searchParams.get("oe");
if (!oe) {
console.log(JSON.stringify({ oe: null, note: "no oe parameter: expiry unknown" }));
} else {
const unixSeconds = parseInt(oe, 16);
const expiresAt = new Date(unixSeconds * 1000);
console.log(JSON.stringify({
oe,
unixSeconds,
expiresAt: expiresAt.toISOString(),
expired: expiresAt.getTime() <= Date.now(),
}, null, 2));
}
Output for an example link with oe=6AB0F580, run on 2026-09-18 (illustrative value, not a measured lifetime)
{
"oe": "6AB0F580",
"unixSeconds": 1789982080,
"expiresAt": "2026-09-21T09:14:40.000Z",
"expired": false
}
Run it on a link fresh from the API and on a broken one. The fresh link shows how much time a new response gives you. The broken one confirms that expiry, and not a token problem, emptied the page.
Do not hardcode what you observe. The lifetime is Meta's to change, and nothing guarantees that every link in one response expires at the same moment.
Fix 1: fetch again before the links expire
When a server renders the page, cache the posts for less time than their links live. Instead of guessing a number, derive it from the response: take the earliest oe and subtract a safety margin.
lib/instagram-cache.ts
// Seconds until the first Instagram CDN link in a /me/media response expires, minus a margin.
type Item = { media_url?: string; thumbnail_url?: string; children?: { data: Item[] } };
function links(items: Item[]): string[] {
return items
.flatMap((m) => [m.media_url, m.thumbnail_url, ...links(m.children?.data ?? [])])
.filter((u): u is string => Boolean(u));
}
export function secondsUntilFirstExpiry(items: Item[], marginSec = 600): number | null {
const expiries = links(items)
.map((u) => new URL(u).searchParams.get("oe"))
.filter((oe): oe is string => oe !== null)
.map((oe) => parseInt(oe, 16));
if (expiries.length === 0) return null; // no readable expiry: choose a short TTL yourself
const now = Math.floor(Date.now() / 1000);
return Math.max(0, Math.min(...expiries) - now - marginSec);
}
Use the result wherever the cache lifetime can be set per response: Cache-Control: s-maxage=<seconds> from a route handler or serverless function, or the TTL of a KV entry. Each regeneration calls the API, receives new signatures and restarts the clock.
Where the window must be fixed in advance, as with a static revalidate value in Next.js, log the computed number for a few days and set the window well below the smallest value you saw.
This fix needs a server at request time. GitHub Pages and other static hosts have none, and calling graph.instagram.com from the browser would expose the token. On those hosts use fix 2 or 3.
Fix 2: copy the images on a schedule
Download each image once, while its link is still valid, and serve that copy. A copy has no signature, so it keeps loading until you delete it. A scheduled CI job is enough for most sites:
scripts/sync-instagram.mjs
// Downloads the latest posts and one cover image per post into public/instagram/.
// Runs in CI. IG_ACCESS_TOKEN comes from repository secrets and never reaches the browser.
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
const OUT = "public/instagram";
const token = process.env.IG_ACCESS_TOKEN;
if (!token) throw new Error("IG_ACCESS_TOKEN is not set");
const api = new URL("https://graph.instagram.com/v25.0/me/media");
api.searchParams.set("fields", "id,media_type,media_url,thumbnail_url,permalink,timestamp,children{media_type,media_url,thumbnail_url}");
api.searchParams.set("limit", "12");
api.searchParams.set("access_token", token);
const res = await fetch(api);
if (!res.ok) throw new Error(`Instagram API ${res.status}: ${await res.text()}`);
const { data } = await res.json();
// Photo: media_url. Video: thumbnail_url. Carousel: the same rule on the first slide.
function coverOf(m) {
const item = m.media_type === "CAROUSEL_ALBUM" ? m.children?.data?.[0] : m;
return item?.media_type === "VIDEO" ? item.thumbnail_url : item?.media_url;
}
await mkdir(OUT, { recursive: true });
const posts = [];
for (const m of data) {
const src = coverOf(m);
if (!src) continue; // media_url is omitted for copyright-flagged media
const img = await fetch(src);
if (!img.ok) continue; // a fresh link should load; if not, the post waits for the next run
const ext = img.headers.get("content-type")?.includes("webp") ? "webp" : "jpg";
const file = `${m.id}.${ext}`;
await writeFile(`${OUT}/${file}`, Buffer.from(await img.arrayBuffer()));
posts.push({ id: m.id, mediaType: m.media_type, url: m.permalink, image: `/instagram/${file}`, publishedAt: m.timestamp });
}
// Delete copies of posts that are no longer in the response.
const keep = new Set(posts.map((p) => p.image.split("/").pop()));
for (const f of await readdir(OUT)) {
if (f !== "feed.json" && !keep.has(f)) await rm(`${OUT}/${f}`);
}
await writeFile(`${OUT}/feed.json`, JSON.stringify({ fetchedAt: new Date().toISOString(), posts }, null, 2) + "\n");
.github/workflows/instagram-sync.yml
name: Sync Instagram
on:
schedule:
- cron: "23 5 * * *" # sets how soon new posts appear; copies stay valid between runs
workflow_dispatch:
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
- run: node scripts/sync-instagram.mjs
env:
IG_ACCESS_TOKEN: ${{ secrets.IG_ACCESS_TOKEN }}
- name: Commit changed files
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add public/instagram
git diff --cached --quiet || { git commit -m "Sync Instagram posts"; git push; }
public/instagram/feed.json written by the script (example values)
{
"fetchedAt": "2026-09-18T05:23:41.512Z",
"posts": [
{
"id": "17900000000000001",
"mediaType": "IMAGE",
"url": "https://www.instagram.com/p/EXAMPLE1/",
"image": "/instagram/17900000000000001.jpg",
"publishedAt": "2026-09-12T07:41:00+0000"
},
{
"id": "17900000000000002",
"mediaType": "VIDEO",
"url": "https://www.instagram.com/reel/EXAMPLE2/",
"image": "/instagram/17900000000000002.jpg",
"publishedAt": "2026-09-10T16:05:00+0000"
}
]
}
Three details decide whether this holds up in production:
- The token still expires. A long-lived token is valid for 60 days (Meta: access_token reference, checked 2026-09-18), so the job fails after that unless someone refreshes it. See access tokens.
- Removed posts leave the site. The script deletes files that are no longer in the latest response, so a post the owner deletes on Instagram disappears here on the next run.
- Videos are not copied. The script keeps the cover frame and links video posts to
permalink. For playback on the page, upload the video files to object storage, not to the repository.
Committing images works for a small feed on GitHub Pages or Netlify. For more posts, upload to S3, Cloudflare R2 or Supabase Storage in the same job and write the public object URLs into feed.json instead of local paths.
Fix 3: a hosted feed that serves copies
Some feed services download media and serve it from their own servers; others pass Instagram's signed links through. That can change over time, so check it before you rely on a vendor for a static site. The images column of the feed API comparison records, with a check date, which services keep copies.
A quick test works for any service: fetch its feed, pick an image URL, and look for an oe= parameter or an Instagram CDN host such as cdninstagram.com or fbcdn.net. If the link carries oe, decode it with the script above.
What ElectricBlaze can promise today
What we observed, kept apart from what we do not know:
- The widget asks for its data on every page load. Each call to the package endpoint returns a newly signed data address (observed 2026-09-17), so the post list is never baked into your HTML and a static site does not freeze the feed at build time.
- Where the images of a connected account are served from is not established yet. They may be Instagram CDN links or copies; we have not verified either and do not claim one. The demo feed's images sit on
s.electricblaze.com, which says nothing about real accounts. - The CLI 0.1.2 ships demo data only. How the 0.2 JSON API will serve images of real posts is not established yet.
If image longevity on a static site is a hard requirement today, fix 2 is the option you control end to end.