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

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"
  }'

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 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"

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 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"
  }'

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 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"

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.

  • `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)

ig-flow-single-media
ig-flow-single-media

Carousel

ig-flow-carousel
ig-flow-carousel

About PostPulse

PostPulse handles the entire container lifecycle for you — creating containers, polling with exponential backoff, retrying on transient errors, recovering from expired containers, and managing carousels with multiple media items. You just pick your images and schedule the post. Try PostPulse →