You can't just POST an image to Instagram and call it a day. You have to create a "container", wait for Instagram to process it, and then publish it. Oh, and carousels? That's containers inside containers.
The Problem
If you've worked with other social media APIs — Facebook, Twitter, LinkedIn — you're used to a fairly direct flow: upload media, create a post, done. Instagram is different.
Instagram uses a container-based publishing model. Instead of directly uploading and posting, you:
- Create a container — tell Instagram where your media lives (a public URL) and what type of post it is
- Wait for processing — Instagram downloads your media, validates it, transcodes video, generates thumbnails... and this can take minutes
- Publish the container — only after processing is complete
For carousels, you create individual containers for each item first, then a parent container referencing all the children, wait for that to process, and then publish.
If you skip the wait step or try to publish a container that's still IN_PROGRESS, you'll get errors. If you don't handle the ERROR status correctly, you'll miss retryable failures. And if you don't know that VIDEO as a media type is deprecated (use REELS instead), you'll waste hours debugging.
Let's walk through all of it.
Prerequisites
- A valid long-lived Instagram access token (see Part 1: Meta OAuth Token Lifecycle)
- Your Instagram account's user ID (returned during authorization)
- Required scope:
instagram_content_publish - Media files hosted at a publicly accessible URL — Instagram will download them from your server (it won't accept direct file uploads)
- Images must be JPEG or PNG. Videos must be MP4
Step-by-Step: Single Media Post
Step 1 — Create the Container
Tell Instagram about your media by creating a container. The endpoint and parameters differ by media type:
Image post:
curl -X POST "https://graph.instagram.com/v22.0/{user_id}/media" \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"image_url": "https://your-cdn.com/photo.jpg",
"caption": "Check out this view! #travel"
}'
Reel (video):
curl -X POST "https://graph.instagram.com/v22.0/{user_id}/media" \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://your-cdn.com/video.mp4",
"caption": "Behind the scenes 🎬",
"media_type": "REELS",
"cover_url": "https://your-cdn.com/thumbnail.jpg"
}'
Story (image):
curl -X POST "https://graph.instagram.com/v22.0/{user_id}/media" \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"image_url": "https://your-cdn.com/story-photo.jpg",
"media_type": "STORIES"
}'
Story (video):
curl -X POST "https://graph.instagram.com/v22.0/{user_id}/media" \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://your-cdn.com/story-video.mp4",
"media_type": "STORIES"
}'
Response (same for all):
{
"id": "17889615691921648"
}
That id is your container ID. It's not a published post yet — it's a container that Instagram is now processing.
⚠️ Stories don't get a caption. If you pass
captionwithmedia_type: "STORIES", it's ignored. Stories are media-only.⚠️
VIDEOis deprecated. Always useREELSfor single video posts. If you sendmedia_type: "VIDEO", the API may reject it or behave unexpectedly.
Step 2 — Poll Until the Container is Ready
Instagram needs time to download your media, validate it, and process it (especially for video). You must poll the container status before attempting to publish:
curl -G "https://graph.instagram.com/{container_id}" \
-H "Authorization: Bearer {access_token}" \
-d "fields=status_code,status"
Response when still processing:
{
"status_code": "IN_PROGRESS",
"id": "17889615691921648"
}
Response when ready:
{
"status_code": "FINISHED",
"id": "17889615691921648"
}
Response on error:
{
"status_code": "ERROR",
"status": "2207026",
"id": "17889615691921648"
}
Polling Strategy: Exponential Backoff
Don't just hammer the endpoint every second. Use increasing delays between attempts:
Attempt 1: wait 5 seconds, then poll
Attempt 2: wait 10 seconds, then poll
Attempt 3: wait 15 seconds, then poll
...
Attempt N: wait (5 × N) seconds, then poll
Set a maximum of ~25 attempts. For images, you'll typically get FINISHED within the first few polls. For video (especially longer Reels), it can take several minutes.
Handling the ERROR Status
This is where it gets nuanced. An ERROR status doesn't always mean you should give up. The status field contains a numeric subcode that tells you what went wrong:
Retryable errors (create a new container and try again):
| Subcode | Meaning |
|---|---|
| 2207051 | Media download timed out — Instagram couldn't fetch your file |
| 2207052 | Media expired |
| 2207001 | Server error |
| 2207003 | Failed to create media |
| 2207016 | Unknown upload error |
| 2207027 | Container not found |
| 2207028 | URI fetch failed |
| 2207050 | Media not ready |
Non-retryable errors (fix the issue before retrying):
| Subcode | Meaning |
|---|---|
| 2207006 | Suspected spam |
| 2207024 | Publish limit reached |
| 2207009 | Unknown/unsupported media type |
| 2207010 | Carousel has invalid item count |
| 2207026 | Unsupported video format |
| 2207034 | Image too large |
| 2207035 | Unsupported image format |
| 2207042 | Invalid image aspect ratio |
| 2207048 | Caption too long |
When you encounter a retryable error, the right approach is to clear the container reference, wait, and create a fresh container. Don't keep polling a failed container — it won't recover.
For non-retryable errors, you need to fix the underlying issue (resize the image, change the format, shorten the caption, etc.) before retrying.
💡 Error tolerance during polling: In practice, you may see a few generic
ERRORstatuses before a container eventually succeeds. A reasonable approach is to tolerate up to ~5 generic errors before giving up, while immediately failing on known non-retryable subcodes.
Step 3 — Publish the Container
Once the status is FINISHED, publish it:
curl -X POST "https://graph.instagram.com/v22.0/{user_id}/media_publish" \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"creation_id": "17889615691921648"
}'
Response:
{
"id": "17895432187654321"
}
This id is the actual published post ID — the one you'd use for fetching insights, comments, etc.
⚠️ The publish call can fail due to rate limits. Implement retry with exponential backoff on the publish step too. Instagram returns transient errors here that succeed on retry.
Step-by-Step: Carousel Post
Carousels add another layer. You need to create a container for each item, then a parent container that references all the children.
Step 1 — Create Child Containers (No Caption)
For each image or video in the carousel, create a container with is_carousel_item: true:
Image child:
curl -X POST "https://graph.instagram.com/v22.0/{user_id}/media" \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"image_url": "https://your-cdn.com/photo1.jpg",
"is_carousel_item": true
}'
Video child:
curl -X POST "https://graph.instagram.com/v22.0/{user_id}/media" \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://your-cdn.com/video1.mp4",
"media_type": "VIDEO",
"is_carousel_item": true
}'
💡 Note: Unlike single video posts where
VIDEOis deprecated in favor ofREELS, carousel video items still usemedia_type: "VIDEO".⚠️ No caption on children. The caption goes on the parent carousel container, not on individual items.
Repeat for each item. Collect all the returned container IDs.
Step 2 — Wait for ALL Children to Finish
Poll each child container until it reaches FINISHED. All children must be ready before you can create the parent:
# Poll each child
curl -G "https://graph.instagram.com/{child_container_id}" \
-H "Authorization: Bearer {access_token}" \
-d "fields=status_code,status"
Step 3 — Create the Carousel Parent Container
Once all children are FINISHED, create the parent container referencing all children:
curl -X POST "https://graph.instagram.com/v22.0/{user_id}/media" \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"media_type": "CAROUSEL",
"children": "17889615691921648,17889615691921649,17889615691921650",
"caption": "Swipe through our latest collection! ✨"
}'
Response:
{
"id": "17889615691921700"
}
⚠️ Children is a comma-separated string of IDs, not a JSON array. This trips people up.
Step 4 — Wait for the Carousel Container
The parent container also needs processing:
curl -G "https://graph.instagram.com/{carousel_container_id}" \
-H "Authorization: Bearer {access_token}" \
-d "fields=status_code,status"
Step 5 — Publish the Carousel
Same as before — publish the parent container:
curl -X POST "https://graph.instagram.com/v22.0/{user_id}/media_publish" \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"creation_id": "17889615691921700"
}'
Handling Container Expiry and Recovery
Containers don't live forever. If you create a container and don't publish it within a certain window, it expires. Your status poll will return EXPIRED, and you'll need to start over.
The right recovery strategy depends on the container type:
Single media: Clear the container reference and create a new container from scratch.
Carousel children: If a child expires, clear its reference and recreate just that child. You don't need to recreate children that already finished — unless the parent carousel itself hasn't been created yet.
Carousel parent: If the parent carousel container expires or errors, clear the carousel container ID but keep the finished children. Create a new parent container referencing the same children. If the children have also expired, you'll need to recreate those too.
This is where persistent tracking of container states pays off. For each media item, store:
- The container ID returned by Instagram
- The current status (
IN_PROGRESS,FINISHED,ERROR,EXPIRED) - Whether it's a single item, carousel child, or carousel parent
When retrying, check what's already finished before recreating everything from scratch.
Multiple Stories
Unlike feed posts and carousels, Stories are published individually — there's no grouping mechanism. If you have 5 images to publish as Stories, you create and publish each one separately:
For each media file:
1. Create container (media_type: "STORIES")
2. Wait for FINISHED
3. Publish
4. Move to next
They'll appear in your story timeline in the order you publish them.
PNG Handling
Instagram accepts PNG images, but in practice, converting PNG to JPEG before uploading leads to more reliable results and faster processing. If your storage pipeline serves PNGs, consider converting to JPEG server-side before passing the URL to Instagram. This also reduces file size, which means faster downloads for Instagram's servers and fewer MEDIA_DOWNLOAD_TIMEOUT errors.
Common Pitfalls
Media must be at a public URL — Instagram downloads the file from the URL you provide. If it's behind authentication, a CDN with restricted access, or a pre-signed URL that's expired, you'll get a download timeout error.
VIDEOis deprecated for single posts — UseREELSas themedia_typefor any single video post.VIDEOstill works for carousel children.Don't skip polling — Publishing a container that's still
IN_PROGRESSwill fail. Always poll untilFINISHED.Stories don't support captions — The
captionfield is ignored forSTORIESmedia type.Carousel children don't get captions — Put the caption on the carousel parent container only.
The
childrenparameter is a comma-separated string — Not a JSON array."id1,id2,id3"not["id1","id2","id3"].Container IDs expire — If you create containers ahead of time (e.g., for scheduled posts), you need recovery logic in case they expire before publishing time.
Publish calls need retry logic — The
media_publishendpoint is rate-limited and can return transient errors. Implement backoff-based retry.Error subcodes matter — Don't treat all
ERRORstatuses the same. Some are retryable (server errors, download timeouts), others require fixing the media (wrong format, too large, bad aspect ratio).
TL;DR — The Full Flow (Diagram)
Single Media (Image/Reel/Story)
Carousel
Stop Wrestling with Instagram Containers
If your goal is to build user-facing features rather than managing complex polling loops, retry logic for 2207xxx error codes, and multi-step carousel state machines, you might want to look at PostPulse for Developers.
We built the PostPulse Social Media API to turn Instagram’s multi-step "Container" nightmare into a single POST request. Instead of managing separate flows for Reels, Stories, and Carousels, you get:
-
Atomic Publishing: No more polling for
FINISHEDstatus. Send us the media, and we handle the container creation, processing wait times, and final publication. - Unified Media Handling: One standard syntax for scheduling posts across 9+ platforms. We handle the "video vs. reels" deprecation logic so you don't have to.
- Automatic Retries: If Instagram throws a retryable subcode (like a media download timeout), our system handles the backoff and recovery automatically.
- Bypass App Review: Use our pre-approved Meta, LinkedIn, and TikTok integrations to go live in hours, not weeks.
Don't waste engineering sprints on social media infrastructure. Try the PostPulse API for free →


Top comments (0)