Instagram Graph API Error Codes: Causes and Fixes
Every Instagram Graph API error code and its fix, checked against 243 real failed publishes: 36003, 9004, 9007, OAuthException 190, and limits. Find yours.
TLDR: Instagram Graph API errors usually arrive as HTTP 400 with a code and an error_subcode, and the subcode carries the meaning. In PostZen’s production log, 163 of 243 failed publishes were one image with a bad aspect ratio, and 74 valid posts exhausted their retries with no Meta code kept. Retry throttling, wait out limits, reconnect on 190, fix the rest.
PostZen publishes to Instagram through the Graph API for 69 connected accounts, and every failed publish lands in our database with the error code our publisher recorded, which is usually Meta’s. For this guide we pulled every Instagram target created since June 20, 2026, joined to the media file behind it: 553 published, 243 failed. That log is the spine of this post, read next to Meta’s error tables as of October 15, 2026.
The log does not look like the error reference. Meta lists dozens of publishing codes; our failures came from six recorded codes, one of them our own, one image produced two thirds of them, and the second largest group carried no Meta code at all because our retry loop threw it away. Our Instagram API integration guide covers access, the container flow, and media specs; this post covers what comes back when a publish fails, how each code behaves in a retry loop, and where our own handling fell short.
What does an Instagram Graph API error look like?
Every failed Graph API call returns an error object. Meta’s error handling guide documents the fields: message; type, almost always OAuthException even when auth is not the problem; code; error_subcode; error_user_title and error_user_msg, localized and meant for display; and fbtrace_id, which Meta warns will expire shortly, so log it at failure time. Instagram responses add a boolean is_transient, shown in the sample on Meta’s Instagram error codes page.
Three habits decide how to read it. The subcode carries the meaning: code 4 is throttling with no subcode and a spam restriction with subcode 2207051, and code 100 covers a dozen publishing mistakes. Throttling arrives as HTTP 400, not 429. And the message text is not stable, as our 9004s show below. Match on code plus error_subcode; keep the message for humans.
Every code you can meet while publishing, as of October 15, 2026: Instagram rows from Meta’s Instagram error codes page (updated June 2, 2026), all HTTP 400 unless noted; general rows from the error handling guide and the Graph API rate limiting page; the 110 row from our log.
| Code / subcode | Message (short) | When | Retry? | Fix |
|---|---|---|---|---|
| 190 (subcodes 458 to 492) | Error validating access token: session has been invalidated, expired, or revoked | Any call | No | Reconnect the user; refresh long-lived tokens before day 60 |
| 102 | API session invalid | Any call | No | Same as 190; the subcode says which case |
| 10 | Application does not have permission for this action | Any call, sometimes with a 5xx status | No | Re-request the permission; check Advanced Access |
| 200 to 299 | Permission not granted or removed | Any call | No | Handle the missing permission |
| 200 (HTTP 403) | Missing ads_management for the target |
Business-managed accounts | No | Request ads_management |
| 3 | Capability or permission issue | Any call | No | Check the app’s capabilities |
| 1 | API unknown, possibly downtime | Any call | Yes, with backoff | Retry; if it persists, check the endpoint exists |
| 2 | API service, temporary | Any call | Yes, with backoff | Retry later |
| 4 (no subcode) | Application request limit reached | Any call | Yes, after the advertised wait | App-level throttling; read x-business-use-case-usage |
| 4 / 2207051 | Activity restricted to protect the community | Publishing | No; stop for that account | Suspected spam; Meta says tell them if it is a mistake |
| 17 | User request limit reached | Any call | Yes, with backoff | User-level throttling |
| 32 | Page request limit reached | Page calls with a user token | Yes, with backoff | Page-level throttling |
| 613 (subcode 1996) | Custom rate limit; 1996 means inconsistent request volume | Any call | Yes, with backoff | Check recent changes to your call volume |
| 80002 | Instagram Business Use Case limit reached | Any Instagram call | Yes, after estimated_time_to_regain_access |
Budget is 4800 times the account’s 24-hour impressions |
| 341 | Application limit reached | Any call | Yes, with backoff | Downtime or throttling |
| 368 | Temporarily blocked for policy violations | Any call | Wait, then retry | Nothing in your code; wait |
| 506 | Duplicate post | Publish | No | Change the content |
| 9 / 2207042 | Maximum number of posts reached (daily publishing limit) | media_publish |
Next day | Read content_publishing_limit before scheduling |
| 25 / 2207050 | The Instagram account is restricted | Publish | No; until the user acts | User signs into the Instagram app and clears the checkpoint |
| -2 / 2207003 | It takes too long to download the media | Container create | Yes | Faster origin, smaller file |
| -2 / 2207020 | The media has expired | media_publish |
New container | Containers last 24 hours |
| -1 / 2207001 | Instagram server error | Any | Yes | Retry |
| -1 / 2207032 | Create media failed | Container create | Yes | Re-create the container |
| -1 / 2207053 | Unknown upload error (video) | Video container | New container | Re-create and retry |
| 1 / 2207057 | Thumbnail offset out of the video’s bounds | Reel container | No | Fix thumb_offset in milliseconds |
| 24 / 2207006 | Media cannot be found | media_publish |
New container | Possible permission or token problem |
| 24 / 2207008 | Media builder does not exist or has expired | media_publish |
1 to 2 quick retries within 30 s to 2 min, then a new container | Temporary |
| 100 / 2207023 | Unknown media type | Container create | No | Fix media_type |
| 100 / 2207028 | Carousel needs 2 to 10 items | Carousel create | No | Fix the item count |
| 100 / 2207035, 2207036, 2207037 | Product tag positions wrong, missing, or tags failed | Container create | No | Videos take no x/y, photos require it; refresh the catalog |
| 100 / 2207040 | More than 20 @ tags | Container create | No | Fewer tags |
| 110 | Invalid user id | Tags or collaborators | No | Resolve the username; send it without the @ |
| 352 / 2207026 | Video format not supported | Video container | No | MOV or MP4 |
| 9004 / 2207052 | Media could not be fetched from this URI | Container create | No; fix the URL, then a new container | Public, fast, long-lived URL |
| 9007 / 2207027 | Media is not ready for publishing | media_publish |
Poll status_code |
Publish only after FINISHED |
| 36000 / 2207004 | Image too large (over 8 MiB) | Container create | No | Smaller file |
| 36001 / 2207005 | Image format not supported | Container create | No | JPEG; Meta’s fix text for this row talks about tokens and looks pasted from 2207006 |
| 36003 / 2207009 | Aspect ratio is not supported | Container create | No | 4:5 to 1.91:1 for feed images |
| 36004 / 2207010 | Caption too long | Container create | No | 2,200 characters, 30 hashtags, 20 @ tags |
What does our production log say?
Every Instagram post target created on PostZen between June 20 and October 15, 2026:
| Outcome | Targets |
|---|---|
| Published | 553 |
| Failed | 243 |
| Scheduled, still in the future | 88 |
| Draft | 54 |
| Canceled | 10 |
The 243 failures by the code our publisher recorded:
| Recorded code | Count | What it is |
|---|---|---|
| 36003 / 2207009 | 163 | Aspect ratio rejected |
retries_exhausted |
75 | PostZen’s own code: attempt limit hit on errors we classed as temporary |
| 9004 | 2 | Media could not be fetched |
| 190 | 1 | Token invalidated |
| 10 | 1 | Permission denied |
| 110 | 1 | Invalid user id |
First, 240 of the 243 failures came from one customer account that scheduled about six image posts a day through our API, created in bulk on August 18 to 20 and running from August 19 to September 18. The same account published 116 posts successfully in that period: a pipeline that shipped one bad file and then ran into something else.
Second, the 163 aspect-ratio failures were one kind of file: a 1152 by 2496 pixel PNG, ratio 0.462, sent as a feed post. Every slot carrying it failed on the first attempt, nothing changed the image, and so it failed 163 times over a month. The 75 retries_exhausted failures are the opposite case: 74 were 1080 by 1350 JPEGs, exactly 4:5 and valid, plus one 1024 by 1792 PNG, and all 75 ran out of attempts between August 28 and September 9 without our log recording what Meta said. That story has its own section; the other four failures are one each and appear under their codes.
Why does Instagram return 36003 when the aspect ratio is not supported?
Meta’s IG User media reference sets the feed image rules: JPEG, 8 MB maximum, aspect ratio within 4:5 to 1.91:1, width 320 to 1440, sRGB. As a number, width divided by height must land between 0.8 and 1.91; 1080 by 1350 is 0.8 exactly. The 1152 by 2496 PNG in our log is 0.462, roughly 1:2.17, and Meta’s message names the submitted ratio.
36003 is deterministic: the same file gets the same answer every time, which is how one image became 163 failures, and no retry or wait helps. What helps is choosing the right surface:
| Surface | Ratio rule in Meta’s reference |
|---|---|
| Feed image | 4:5 to 1.91:1, enforced; 36003 outside it |
| Story image | 9:16 recommended; no hard range listed |
| Reel | 0.01:1 to 10:1 required, 9:16 recommended |
| Carousel | Every item cropped to the first item’s ratio, 1:1 by default; a ratio problem shows up as cropping, not an error |
Our inference from the log: a ratio check before the container call, or sending that image as a story, would have avoided all 163. PostZen does not run that check today: we do not read image dimensions, crop, or pad, so a 36003 comes back on the failed target with Meta’s message. Here is the guard we would put in front of a feed publish, in Node with sharp:
import sharp from 'sharp';
// Instagram feed images: width / height must be 0.8 (4:5) to 1.91 (1.91:1).
// Example: 1152 by 2496 is 0.462. Padding to 4:5 makes it 1997 by 2496,
// and the 1440 width cap then scales that to 1440 by 1800.
const MIN = 0.8;
const MAX = 1.91;
const MAX_WIDTH = 1440;
export async function fitFeedImage(input, mode = 'pad', bg = '#ffffff') {
const { width, height } = await sharp(input).metadata();
const ratio = width / height;
const target = Math.min(Math.max(ratio, MIN), MAX); // nearest legal ratio
let [w, h] = [width, height];
if (target !== ratio) {
const tooTall = ratio < target;
if (mode === 'pad') {
if (tooTall) w = Math.round(height * target); // bars left and right
else h = Math.round(width / target); // bars top and bottom
} else if (tooTall) {
h = Math.round(width / target); // cut top and bottom
} else {
w = Math.round(height * target); // cut left and right
}
}
if (w > MAX_WIDTH) [w, h] = [MAX_WIDTH, Math.round((h * MAX_WIDTH) / w)];
const fit = mode === 'pad' ? 'contain' : 'cover';
return sharp(input)
.resize(w, h, { fit, background: bg })
.flatten({ background: bg }) // JPEG has no alpha channel
.toColorspace('srgb')
.jpeg({ quality: 90 })
.toBuffer();
}
The same in Python with Pillow:
from PIL import Image, ImageOps
# Instagram feed images: width / height must be 0.8 (4:5) to 1.91 (1.91:1).
# Example: 1152 by 2496 is 0.462. Padding to 4:5 makes it 1997 by 2496,
# and the 1440 width cap then scales that to 1440 by 1800.
MIN, MAX, MAX_WIDTH = 0.8, 1.91, 1440
def fit_feed_image(src, dst, mode="pad", bg=(255, 255, 255)):
rgba = Image.open(src).convert("RGBA")
img = Image.new("RGB", rgba.size, bg) # JPEG has no alpha: flatten
img.paste(rgba, mask=rgba.getchannel("A"))
width, height = img.size
ratio = width / height
target = min(max(ratio, MIN), MAX) # nearest legal ratio
w, h = width, height
if target != ratio:
too_tall = ratio < target
if mode == "pad":
if too_tall:
w = round(height * target) # bars left and right
else:
h = round(width / target) # bars top and bottom
elif too_tall:
h = round(width / target) # cut top and bottom
else:
w = round(height * target) # cut left and right
if w > MAX_WIDTH:
w, h = MAX_WIDTH, round(h * MAX_WIDTH / w)
if mode == "pad":
out = ImageOps.pad(img, (w, h), color=bg) # letterbox
else:
out = ImageOps.fit(img, (w, h)) # centre crop
out.save(dst, "JPEG", quality=90)
Pad or crop is a product decision: padding keeps every pixel and adds bars; cropping fills the frame and, for the 1152 by 2496 image, drops more than 40 percent of its height. Either way, keep the original untouched.
Can you post PNG to the Instagram API?
Meta’s content publishing guide says “JPEG is the only image format supported”, and excludes even the extended variants MPO and JPS. Our own integration guide repeated that as PNG and WebP being rejected; our production data says otherwise.
PostZen does not convert images for Instagram (we do for TikTok, where the format check really fails, as our TikTok Content Posting API errors guide documents); Instagram receives whatever the customer uploaded. Between June 22 and October 15, 2026, 245 PNG images and 2 WebP images published successfully across 26 accounts, in feed posts, carousels (187 of the PNGs were carousel items), and stories. We spot-checked 23 and they are real PNG files. The 163 PNGs that failed were rejected for aspect ratio, 36003, not format, 36001; there is no 36001 anywhere in our log.
So: Instagram accepts PNG in practice today, and a hard PNG rejection built on the docs would have blocked 245 good posts. The documented spec is still JPEG and Meta can enforce it at any time, so converting at publish time keeps you inside the spec.
What do 9004 and 9007 mean?
9004 with subcode 2207052 is a fetch failure: Meta’s fetcher could not download the file from the URL you gave it when you created the container. Meta’s fix is a valid, publicly available URI; likely causes include expiring signed URLs, authenticated origins, and CDNs that block datacenter IPs.
Our two 9004s, on September 1 and September 16, came from the same account as the rest: one 1080 by 1350 JPEG and one 1152 by 2496 PNG, both on our own media domain, which served hundreds of successful publishes. Their message was “Only photo or video can be accepted as media type.”, not the wording in Meta’s table, where 9004 says the media could not be fetched from this URI. We cannot say from the log why the fetcher failed on those two, only that the same code arrived with a different message: match on code and subcode, not text.
9007 with subcode 2207027 is not a failure of the media: you called media_publish before the container finished. The lifecycle is IN_PROGRESS, then FINISHED, then PUBLISHED, with ERROR for failed processing and EXPIRED for a container not published within 24 hours. Meta recommends polling once per minute for no more than 5 minutes, and the media reference caps container creation at 400 per rolling 24 hours. 9007 has three neighbours on the publish call:
| Code / subcode | Meaning | Meta’s advice |
|---|---|---|
| 24 / 2207008 | Media builder does not exist or has expired | Retry 1 to 2 times within 30 seconds to 2 minutes, then a new container |
| -2 / 2207020 | The container expired | New container |
| 24 / 2207006 | Media cannot be found; possibly a permission or token problem | New container |
PostZen polls the status before every publish and treats ERROR and EXPIRED as failures, so 9007 does not appear in our log.
How do you fix OAuthException 190 and permission errors?
190 and 102 both mean the token is no good: with no subcode, expired, revoked, or otherwise invalid, per Meta’s error handling guide; with a subcode, the subcode says which:
| Subcode | Name | What to do |
|---|---|---|
| 458 | App not installed | The user has not logged into your app; reauthenticate |
| 459 | User checkpointed | The user logs in at facebook.com to clear the issue |
| 460 | Password changed | The user logs in to your app again |
| 463 | Expired | Handle the expired token; refresh or reconnect |
| 464 | Unconfirmed user | The user logs in at facebook.com to clear the issue |
| 467 | Invalid access token | Expired, revoked, or invalid; reconnect |
| 492 | Invalid session | The user lacks an appropriate role on the Page |
Our one 190, on October 14, 2026, was a reel with a first comment; the message read “Error validating access token: The session has been invalidated because the user changed their password or Facebook has changed the session for security reasons.” That matches Meta’s password-changed case, subcode 460, though our record kept only the code. No refresh fixes it; the user comes back through login. For the ordinary case, a long-lived token ageing toward day 60, our Instagram access token guide covers refresh. PostZen marks the account as needing reconnection on 190 or 102 and never treats them as rate limits, even when the message mentions a limit.
Code 10 and the 200 to 299 range are permission errors. Our one code 10, on October 11, 2026, said “Application does not have permission for this action” on a trial reel, the trial_params feature our API exposes as settings.trialParams; we shipped it on October 9 and published a real trial reel with it the same day, and we do not know why that account was refused. Meta returns code 10 with 5xx statuses as well as 400s, so PostZen treats 10, 100, and 200 as non-retryable whatever the HTTP status.
Code 110 is the collaborator trap. Our one 110 was a reel created on October 8, 2026, with a collaborator whose username did not resolve. Meta’s raw message is Invalid user id; PostZen rewrites it to say Instagram could not find one of the tagged people or collaborators. API clients often send a handle with a leading @, and Meta answers the @ itself with 110, so PostZen strips it.
Which Instagram API errors mean you hit a limit?
Meta’s own pages give three numbers for the publishing limit as of October 15, 2026. The content publishing guide’s rate limit section says 100 API-published posts in a rolling 24 hours, a carousel counting as one, enforced at media_publish; the same guide’s carousel limitations say 50; and the content_publishing_limit reference, updated July 23, 2024, says quota_total is currently 50. Do not hard-code any of them: read GET /{ig-user-id}/content_publishing_limit?fields=quota_usage,config and use config.quota_total with config.quota_duration (86400 seconds). Hitting the limit returns 9 with subcode 2207042 and the fix is the next day. Container creation has its own cap of 400 per rolling 24 hours; a scheduler should count both before booking a slot; our guide to scheduling Instagram posts compares the ways to schedule.
Call-volume throttling is a separate mechanism with its own codes:
| Code | Limit | Rule |
|---|---|---|
| 4 | Application request limit reached | App-level; no subcode |
| 17 | User request limit reached | User-level |
| 32 | Page request limit reached | Page calls made with a user token |
| 613 | Custom rate limit | Subcode 1996 means inconsistent request volume |
| 80002 | Instagram Business Use Case limit | 4800 times impressions (times the account’s content entered a screen in 24 hours), per app and account pair, over a rolling 24 hours |
A fresh account with no audience has almost no budget. Throttled responses carry an x-business-use-case-usage header with estimated_time_to_regain_access in minutes; PostZen honours it, capped at 6 hours, and defers 30 minutes when Meta names no wait. Private replies to comments have their own cap of 750 per hour per account, the ceiling our comment-to-DM automations run under.
Then the limits that are not about volume. 4 with subcode 2207051 says Meta restricts certain activity to protect its community: it suspects spam, and it shares code 4 with ordinary throttling, which is the trap in the next section. 368 is a temporary block for policy violations; wait and retry. 25 with subcode 2207050 means the account is restricted, inactive, or checkpointed; nothing moves until the user signs into the Instagram app and clears it.
Why did 74 valid posts fail with no Meta error?
Between August 28 and September 9, 2026, the account stopped publishing. Its slots kept firing, about six a day, carrying 1080 by 1350 JPEGs that were exactly 4:5, plus one 1024 by 1792 PNG, and every one failed. PostZen attempted each one eight times, deferring 30 minutes when Meta gave no wait, then gave up and wrote retries_exhausted with our own message, “Publishing failed after too many attempts.” On September 9 the same pipeline, unchanged, started publishing again.
PostZen only retries what it classifies as transient: Meta’s throttling codes 4, 17, 32, 613, and 80000 to 80014; messages saying a request limit was reached; HTTP 429 or 5xx; or is_transient set to true. So for 12 days Meta answered with something in that set, and we cannot say what: when the attempt limit is hit, our publisher records its own message and Meta’s code from the last attempt is not kept. That is a gap we found while writing this post.
What follows is inference. One account, valid media, every post failing as temporary for days, then recovering with no change on our side: that fits 4 with subcode 2207051, Meta’s spam restriction, which our classifier would have read as throttling because it shares code 4. Plain throttling is the other candidate, though twelve days of it against six posts a day would be unusual. And the 1024 by 1792 PNG would have drawn a 36003 on any normal day and did not, which suggests Meta refused those requests before looking at the image. We cannot prove any of it.
Two changes follow. Keep the provider’s code, error_subcode, and fbtrace_id on the failure record for every attempt, including the last one before giving up. And treat 4 with subcode 2207051 as a stop, not a retry: pause the account’s queue and tell someone, because eight attempts help nobody.
How should your code classify Instagram API errors?
The classifier below is the version we wish we had run through that window, with a quota helper that reads the live number instead of assuming 50 or 100. Node.js 18 or later, as an ES module:
// Node.js 18 or later, ES module. Codes as of October 15, 2026.
// Use graph.facebook.com for the Facebook Login flavour.
const IG = 'https://graph.instagram.com/v26.0';
const REAUTH = new Set([190, 102]);
// Stop for the day, or until the user acts. Do not hammer these.
const WAIT = new Set(['9/2207042', '4/2207051', '25/2207050', '368']);
// The container is spent; create a new one.
const NEW_CONTAINER = new Set([
'24/2207006', '24/2207008', '-2/2207020', '-1/2207053',
]);
// Transient on Meta's side, or throttling; retry with backoff.
const RETRY = new Set([
'4', '17', '32', '613', '1', '2', '341',
'-1/2207001', '-1/2207032', '-2/2207003',
]);
const key = (code, subcode) => (subcode ? `${code}/${subcode}` : `${code}`);
// Returns 'reauth' | 'retry' | 'new_container' | 'wait' | 'fail'.
export function classify({ status = 0, code, subcode, isTransient = false }) {
const k = key(code, subcode);
// 190 and 102 are never rate limits, whatever the message says.
if (REAUTH.has(code)) return 'reauth';
// Specific subcodes before bare codes: 4/2207051 is not throttling.
if (WAIT.has(k) || WAIT.has(`${code}`)) return 'wait';
if (NEW_CONTAINER.has(k)) return 'new_container';
// 9007 is not a failure: poll status_code until FINISHED, then publish.
if (code === 9007) return 'retry';
// Meta returns these with 5xx statuses too. Never retry them.
if (code === 10 || code === 100 || (code >= 200 && code <= 299)) {
return 'fail';
}
if (RETRY.has(k) || RETRY.has(`${code}`)) return 'retry';
if (code >= 80000 && code <= 80014) return 'retry'; // BUC limits
if (status >= 500 || status === 429 || isTransient) return 'retry';
return 'fail';
}
// Keep this on the failure record for EVERY attempt, including the last
// one before you give up. Our 75 retries_exhausted rows had none of it.
export function failureRecord(res, json) {
const e = json?.error ?? {};
return {
status: res.status,
code: e.code ?? null,
subcode: e.error_subcode ?? null,
isTransient: e.is_transient ?? false,
fbtraceId: e.fbtrace_id ?? null,
message: e.error_user_msg ?? e.message ?? res.statusText,
action: classify({
status: res.status, code: e.code, subcode: e.error_subcode,
isTransient: e.is_transient,
}),
};
}
// Remaining posts in the rolling 24-hour publishing window.
export async function publishingQuota(igUserId, token) {
const url = new URL(`${IG}/${igUserId}/content_publishing_limit`);
url.searchParams.set('fields', 'quota_usage,config');
url.searchParams.set('access_token', token);
const res = await fetch(url);
const json = await res.json().catch(() => ({}));
if (!res.ok || json.error) {
const record = failureRecord(res, json);
throw Object.assign(new Error(record.message), record);
}
const row = json.data?.[0] ?? {};
const used = row.quota_usage ?? 0;
const total = row.config?.quota_total ?? 0; // 50 or 100: read, never assume
return { used, total, remaining: Math.max(0, total - used) };
}
The same in Python with requests:
import requests
# Codes as of October 15, 2026. Use graph.facebook.com for Facebook Login.
IG = "https://graph.instagram.com/v26.0"
REAUTH = {190, 102}
# Stop for the day, or until the user acts. Do not hammer these.
WAIT = {"9/2207042", "4/2207051", "25/2207050", "368"}
# The container is spent; create a new one.
NEW_CONTAINER = {"24/2207006", "24/2207008", "-2/2207020", "-1/2207053"}
# Transient on Meta's side, or throttling; retry with backoff.
RETRY = {"4", "17", "32", "613", "1", "2", "341",
"-1/2207001", "-1/2207032", "-2/2207003"}
def _key(code, subcode):
return f"{code}/{subcode}" if subcode else f"{code}"
def classify(status=0, code=None, subcode=None, is_transient=False):
"""Returns 'reauth', 'retry', 'new_container', 'wait', or 'fail'."""
key = _key(code, subcode)
if code in REAUTH: # never a rate limit, whatever the message says
return "reauth"
# Specific subcodes before bare codes: 4/2207051 is not throttling.
if key in WAIT or str(code) in WAIT:
return "wait"
if key in NEW_CONTAINER:
return "new_container"
if code == 9007: # not a failure: poll status_code, then publish
return "retry"
if code in (10, 100) or (code is not None and 200 <= code <= 299):
return "fail" # Meta sends these with 5xx too; never retry
if key in RETRY or str(code) in RETRY:
return "retry"
if code is not None and 80000 <= code <= 80014: # BUC limits
return "retry"
if status >= 500 or status == 429 or is_transient:
return "retry"
return "fail"
def failure_record(res):
"""Keep this for EVERY attempt, including the last before giving up."""
try:
error = res.json().get("error") or {}
except ValueError:
error = {}
code, subcode = error.get("code"), error.get("error_subcode")
transient = error.get("is_transient", False)
return {
"status": res.status_code,
"code": code,
"subcode": subcode,
"is_transient": transient,
"fbtrace_id": error.get("fbtrace_id"),
"message": error.get("error_user_msg") or error.get("message")
or res.reason,
"action": classify(res.status_code, code, subcode, transient),
}
def publishing_quota(ig_user_id, token):
"""Remaining posts in the rolling 24-hour publishing window."""
res = requests.get(
f"{IG}/{ig_user_id}/content_publishing_limit",
params={"fields": "quota_usage,config", "access_token": token},
timeout=30,
)
try:
body = res.json()
except ValueError:
body = {}
if not res.ok or "error" in body:
raise RuntimeError(failure_record(res))
row = (body.get("data") or [{}])[0]
used = row.get("quota_usage", 0)
total = (row.get("config") or {}).get("quota_total", 0) # 50 or 100
return {"used": used, "total": total, "remaining": max(0, total - used)}
Why it is shaped this way:
- Five outcomes.
reauthopens a reconnect prompt,waitparks the account until the window passes or the user acts,new_containerrestarts the container flow,retrybacks off, andfailshowserror_user_msgor, failing that,message. - Subcode before code.
4/2207051and9/2207042are checked before the bare4and9, because the bare codes are retryable and the subcoded ones are not. - Status is a fallback. Throttling arrives as HTTP 400, so the status check only catches 429 and 5xx with no Meta code, and never overrides a 10, 100, or 200.
- The record outlives the loop.
failureRecordis stored on every attempt, so the last one before giving up still carries Meta’s code andfbtrace_id.
The shorter path: one request to every platform
If you would rather not own any of this, here is one 4:5 image going to Instagram, Threads, and a Facebook Page in one PostZen request:
curl -X POST https://api.postzen.dev/v1/posts \
-H "Authorization: Bearer pzn_live_..." \
-H "Content-Type: application/json" \
-H "x-request-id: ig-errors-launch-2026-10-20" \
-d '{
"content": "Every Instagram API error in our production log, explained.",
"scheduledFor": "2026-10-20T15:00:00Z",
"mediaItems": [{ "url": "https://cdn.example.com/launch-4x5.jpg" }],
"platforms": [
{
"platform": "instagram",
"accountId": "acc_instagram",
"settings": {
"postType": "feed",
"firstComment": "Full guide in the link in bio."
}
},
{ "platform": "threads", "accountId": "acc_threads" },
{ "platform": "facebook", "accountId": "acc_facebook" }
]
}'
Line by line:
platformsholds one target per network with its ownaccountIdandsettings. Instagram takespostType(feed,carousel,reel, orstory), up to 3collaborators, afirstComment, andshareToFeedandtrialParamsfor reels.mediaItemsis a URL PostZen downloads and re-hosts on its own media domain, up to 100 MB (POST /v1/media/presignfor larger files), so the signed-URL and blocked-origin causes of 9004 do not apply.scheduledFormust be at least 60 seconds ahead, or sendpublishNow: true;x-request-idmakes the create idempotent, so a retry after a timeout returns the original post.
PostZen absorbs most of this guide: OAuth and refresh are ours, a 190 or 102 becomes an account.needs_reauth webhook, containers are polled so 9007 never reaches you, throttling waits out estimated_time_to_regain_access, the leading @ is stripped from collaborators, and requests are validated first (media counts and types per post type, collaborators, first comment length). What it does not do, as of October 15, 2026, is check aspect ratios or crop or pad: a 36003 comes back on the failed target in GET /v1/posts/{id} as platforms[].status: "failed" with an error field, reported by the post.failed and post.partially_failed webhooks. Meta’s publishing limit, spam restrictions, and account restrictions apply through PostZen too. The Instagram integration docs list every setting, our Instagram API pricing breakdown covers what the integration costs, and API rate limits are per minute by plan on the pricing page.
The same request reaches all 10 platforms, the @postzen/node and postzen-sdk Python SDKs wrap it, and AI agents publish through the Instagram MCP server; see the Instagram integration page. Build directly if Instagram is your only network; otherwise this is what our social media API is for: one request, one error shape, and the errors above handled before they reach you.
Frequently asked questions
What does Instagram API error 36003 mean?
Error 36003 with subcode 2207009 means the image's aspect ratio is outside Meta's feed range of 4:5 to 1.91:1, which is width divided by height from 0.8 to 1.91. It is a validation error, so retrying the same file fails again: in our production log one 1152 by 2496 image failed 163 times. Pad or crop before creating the container, or send tall images as stories or reels.
How do I fix OAuthException 190 in the Instagram API?
Code 190 means the access token has expired, been revoked, or is otherwise invalid, and no retry fixes it. If the message says the session was invalidated because the user changed their password, the only fix is sending that user through login again. Long-lived tokens last 60 days and can be refreshed before they expire. Treat 190 and 102 as reconnect signals, never as rate limits.
What is Instagram API error 9004?
Code 9004 with subcode 2207052 means Instagram could not fetch the media from the URL you supplied when creating the container. The URL must be public, fast, and alive long enough for Meta's fetcher. The message text varies: our two 9004s said only photo or video can be accepted as a media type, not the wording in Meta's table, so match on code and subcode, not on the message.
How many posts can you publish per day with the Instagram API?
Meta's own pages disagree as of October 15, 2026: the content publishing guide says 100 API-published posts per rolling 24 hours, its carousel section says 50, and the content_publishing_limit reference says quota_total is currently 50. Do not hard-code either number. Read GET /{ig-user-id}/content_publishing_limit?fields=quota_usage,config for the live quota, and expect code 9 with subcode 2207042 when an account reaches it.
Can the Instagram API publish PNG images?
Meta's content publishing guide documents JPEG as the only supported image format. In practice, 245 PNG and 2 WebP images published successfully to Instagram through PostZen between June 22 and October 15, 2026, across 26 accounts, in feed posts, carousels, and stories, with no conversion on our side. PNG works today but is undocumented, so convert to JPEG if you want to stay inside the spec.
What does "The media is not ready for publishing" (9007) mean?
Code 9007 with subcode 2207027 means you called media_publish before the container finished processing. It is not a failure of the media. Poll GET /{container-id}?fields=status_code until it returns FINISHED, then publish; Meta recommends checking once a minute for no more than five minutes. ERROR and EXPIRED are the real failures, and a container that is not published within 24 hours expires.



