DEV Community

Cover image for Instagram Container-Based Publishing: The 3-Step Dance for Reels, Stories & Carousels
Oleksandr Pohorelov
Oleksandr Pohorelov

Posted on

Instagram Container-Based Publishing: The 3-Step Dance for Reels, Stories & Carousels

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:

  1. Create a container — tell Instagram where your media lives (a public URL) and what type of post it is
  2. Wait for processing — Instagram downloads your media, validates it, transcodes video, generates thumbnails... and this can take minutes
  3. 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"
  }'
Enter fullscreen mode Exit fullscreen mode

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"
  }'
Enter fullscreen mode Exit fullscreen mode

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"
  }'
Enter fullscreen mode Exit fullscreen mode

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"
  }'
Enter fullscreen mode Exit fullscreen mode

Response (same for all):

{
  "id": "17889615691921648"
}
Enter fullscreen mode Exit fullscreen mode

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 caption with media_type: "STORIES", it's ignored. Stories are media-only.

⚠️ VIDEO is deprecated. Always use REELS for single video posts. If you send media_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"
Enter fullscreen mode Exit fullscreen mode

Response when still processing:

{
  "status_code": "IN_PROGRESS",
  "id": "17889615691921648"
}
Enter fullscreen mode Exit fullscreen mode

Response when ready:

{
  "status_code": "FINISHED",
  "id": "17889615691921648"
}
Enter fullscreen mode Exit fullscreen mode

Response on error:

{
  "status_code": "ERROR",
  "status": "2207026",
  "id": "17889615691921648"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ERROR statuses 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"
  }'
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "id": "17895432187654321"
}
Enter fullscreen mode Exit fullscreen mode

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
  }'
Enter fullscreen mode Exit fullscreen mode

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
  }'
Enter fullscreen mode Exit fullscreen mode

💡 Note: Unlike single video posts where VIDEO is deprecated in favor of REELS, carousel video items still use media_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"
Enter fullscreen mode Exit fullscreen mode

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! ✨"
  }'
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "id": "17889615691921700"
}
Enter fullscreen mode Exit fullscreen mode

⚠️ 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"
Enter fullscreen mode Exit fullscreen mode

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"
  }'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

  • VIDEO is deprecated for single posts — Use REELS as the media_type for any single video post. VIDEO still works for carousel children.

  • Don't skip polling — Publishing a container that's still IN_PROGRESS will fail. Always poll until FINISHED.

  • Stories don't support captions — The caption field is ignored for STORIES media type.

  • Carousel children don't get captions — Put the caption on the carousel parent container only.

  • The children parameter 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_publish endpoint is rate-limited and can return transient errors. Implement backoff-based retry.

  • Error subcodes matter — Don't treat all ERROR statuses 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)

Single Media Flow Diagram

Carousel

Carousel Flow Diagram

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 FINISHED status. 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)