Instagram API Node.js Tutorial: Read, Publish, Refresh
To use the Instagram API from Node.js, call Meta's Instagram API with Instagram Login at graph.instagram.com using the built-in fetch and an Instagram User access token. Read the profile from /me and posts from /{ig-id}/media, publish by creating a container, polling its status, and calling media_publish, and refresh the token before day 60. Skip unofficial npm packages. Adeli does the same publish in one POST.
You can use the Instagram API from Node.js with the built-in fetch and no npm package: send an Instagram User access token to graph.instagram.com, read the profile and media, and publish with a container plus media_publish. Below is Node.js code for each call, written against Meta's documented endpoints and parameters, then a token refresh and the same publish through Adeli.
This tutorial assumes you already have a Meta app and a token. If you don't, start with our Instagram API guide. Writing Python instead? The Python version of this tutorial has the same steps.
Is there an Instagram API package for Node.js?
You don't need one, and the "private API" packages break Instagram's terms. Meta documents the Instagram API as plain HTTP calls, so fetch covers everything on this page.
The npm packages that call themselves an Instagram private API work differently. They log in with an account's username and password and imitate the Instagram mobile app's internal endpoints. That breaks the rules in two places:
- Instagram's Terms of Use say you can't "access or collect information in unauthorized ways," including automated collection without Instagram's express permission.
- Meta's Automated Data Collection Terms say you won't collect data by automated means without Meta's express written permission.
These packages also depend on undocumented endpoints that can change without notice, and they need your customers' passwords. For anything you ship to customers, use the official API with a Meta app.
Should I use Instagram Login or Facebook Login?
Use Instagram Login for most new Node.js projects. Meta runs two versions of the Instagram API. The read and publish calls differ in the host and the token, and Facebook Login adds a Page lookup to find the account.
| Instagram API with Instagram Login | Instagram API with Facebook Login | |
|---|---|---|
| Host | graph.instagram.com |
graph.facebook.com |
| Token | Instagram User access token | Facebook Page access token for publishing |
| Facebook Page needed | No | Yes |
| Read permission | instagram_business_basic |
instagram_basic, pages_read_engagement |
| Publish permission | instagram_business_content_publish |
instagram_content_publish |
Sources: Meta's content publishing guide and IG Media reference.
The examples below use Instagram Login. To switch to Facebook Login, change the base URL to https://graph.facebook.com/v25.0 and use a Page token. Our permissions guide explains which scopes each path needs.
What do you need before you start?
- Node.js 18 or later.
fetchis a global, so there's nothing to install. - A professional Instagram account. Business or Creator. Personal accounts can't be read or published to.
- An Instagram User access token. For a test on your own account, click Generate token on the Instagram API setup page in the App Dashboard. Meta says those tokens are valid for 60 days.
- A public JPEG URL for the publish step. Meta downloads the image from it.
Keep the token out of your source. The examples are an ES module (ig.mjs) that reads it from an environment variable and shares one helper:
const GRAPH = "https://graph.instagram.com/v25.0";
const TOKEN = process.env.IG_ACCESS_TOKEN;
async function ig(path, { method = "GET", params = {}, body } = {}) {
const url = new URL(`${GRAPH}${path}`);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
const resp = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await resp.json();
if (!resp.ok || data.error) {
const err = data.error ?? {};
throw new Error(`${err.code}/${err.error_subcode}: ${err.message}`);
}
return data;
}Step 1: Read the Instagram profile
Call /me with the fields you want. Meta's getting-started guide uses user_id and username, and lists account_type, followers_count, follows_count, media_count, name, and profile_picture_url as other fields.
const body = await ig("/me", {
params: { fields: "user_id,username,account_type,followers_count,media_count" },
});
// Meta's sample response wraps the user in a "data" array
const profile = body.data ? body.data[0] : body;
const igId = profile.user_id;
console.log(profile.username, igId);user_id is the IG ID that every other Instagram endpoint takes. Store it with the token.
Step 2: List the account's media
Call the /<IG_ID>/media edge. It returns media IDs in data and paging cursors in paging. Ask for fields to get more than the ID:
const page = await ig(`/${igId}/media`, {
params: { fields: "id,media_type,media_url,permalink,timestamp" },
});
for (const media of page.data) {
console.log(media.timestamp, media.media_type, media.permalink);
}Some fields don't always come back, per Meta's IG Media reference:
media_urlcan be missing. Meta omits it when the media contains copyrighted material.thumbnail_urlis video-only. Image posts don't return it.like_countcan be missing. It's omitted when the owner hides like counts.
To fetch the next page, pass page.paging.cursors.after as the after parameter. For likes, reach, and views, see our Instagram insights API guide.
Step 3: Create a media container
Publishing starts with a container: POST /<IG_ID>/media with a public image_url. Meta's content publishing guide sends a JSON body with a bearer token:
const container = await ig(`/${igId}/media`, {
method: "POST",
body: {
image_url: "https://www.example.com/images/photo.jpg",
caption: "Posted from Node.js",
},
});
const containerId = container.id;The image has to be a JPEG on a publicly reachable server when Meta fetches it. A presigned URL that expires in seconds, or a file behind auth, fails. Our guide to posting to Instagram via API lists the size and aspect-ratio rules.
Step 4: Wait for the container to finish
Poll status_code until it reads FINISHED before you publish. Meta recommends checking once per minute, for no more than 5 minutes:
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForContainer(id) {
for (let attempt = 0; attempt < 5; attempt++) {
const { status_code: status } = await ig(`/${id}`, {
params: { fields: "status_code" },
});
if (status === "FINISHED") return;
if (status === "ERROR" || status === "EXPIRED") {
throw new Error(`Container ${id} is ${status}`);
}
await sleep(60_000);
}
throw new Error("Container did not finish in 5 minutes");
}
await waitForContainer(containerId);Images usually finish fast, so the first check often passes. Video takes longer, which is why the loop exists. In a web server, run this in a background job rather than inside a request handler.
Step 5: Publish with media_publish
POST the container ID as creation_id. The response is the ID of the live post:
const published = await ig(`/${igId}/media_publish`, {
method: "POST",
body: { creation_id: containerId },
});
console.log("Published:", published.id);Each account can publish 100 posts in a rolling 24 hours through the API, per Meta's publishing guide. Our rate limit guide covers the call limits on top of that. Carousels add a child container per image, which our carousel API guide walks through.
How do you refresh the token in Node.js?
Call refresh_access_token with grant_type=ig_refresh_token before day 60. Meta's refresh reference only accepts a long-lived token that is at least 24 hours old and not expired, and the refreshed token lasts 60 days from the refresh:
async function refreshToken(longLivedToken) {
const url = new URL("https://graph.instagram.com/refresh_access_token");
url.searchParams.set("grant_type", "ig_refresh_token");
url.searchParams.set("access_token", longLivedToken);
const resp = await fetch(url);
const data = await resp.json();
if (!resp.ok) throw new Error(data.error?.message ?? "Refresh failed");
return data; // access_token, token_type, expires_in (seconds)
}Save the new access_token and compute its expiry from expires_in. Run the refresh on a schedule, around day 45, so a failed job has time to retry. If you start from the 1-hour token that the login flow returns, exchange it first with grant_type=ig_exchange_token. Our access token guide has that call and the error 190 fixes.
How do you handle Instagram API errors in Node.js?
Read the error object instead of stopping at the HTTP status. Graph API errors carry a code, an error_subcode, and a message, and the ig() helper above puts all three in the thrown error.
- Code 190 means the token is dead. Send the user back through login.
- Subcode 2207052 means Meta couldn't fetch the media from your URL.
- Subcode 2207027 means the container isn't ready to publish yet.
The full list is in Meta's error code reference.
How do you publish the same image with Adeli in Node.js?
One POST to https://app.tryadeli.com/api/v1/posts replaces Steps 3 to 5 and the refresh job. Adeli holds the Meta developer app, carried Meta's review, and refreshes every connected account's tokens. Your code sends one API key and the image as base64, and Adeli stages it where Instagram can fetch it:
import { readFile } from "node:fs/promises";
const imageB64 = (await readFile("photo.png")).toString("base64");
const response = await fetch("https://app.tryadeli.com/api/v1/posts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ADELI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
platform: "instagram",
accountId: "00000000-0000-0000-0000-000000000000",
caption: "Posted from Node.js",
image: { contentType: "image/png", base64: imageB64 },
}),
});
console.log(response.status, await response.json());What changes compared with the direct calls:
- No public hosting. Images can be JPEG, PNG, or WebP up to 8 MiB each, and Adeli normalizes them to JPEG.
- No polling. Success returns
201with the post'spermalink. - Readable failures. An expired connection returns
422 connection_expired, and an Instagram rejection returns422 provider_rejectedwith Instagram's message. - Carousels in the same call. Swap
imageforimageswith 2 to 10 entries.
Every field is in the Publish to Instagram docs. The same endpoint in Adeli's posting API publishes to TikTok, YouTube, Facebook, X, and Bluesky, and your first 3 connected accounts are free on Adeli's pricing.