Engineering

Instagram Reels API and Stories API: how publishing actually works

Reels and Stories are not separate APIs. On Instagram they are two values of media_type on one endpoint; on Facebook they are edges of their own, with a three-phase upload. The calls, the limits Meta enforces, and what breaks.

There is no endpoint called the Reels API, and there is no Stories API either. On Instagram, Reels and Stories are two values of media_type on the same content publishing endpoint you already use for photos. On Facebook they are the opposite: a Reel and a Story each live on their own edge, and a 9:16 video posted the normal way is just a Page video.

That difference is most of what goes wrong when people wire this up for the first time. Below is the actual call sequence for each, the constraints Meta enforces, and the failure modes that are worth knowing before you hit them in production.

Instagram: one endpoint, four media types

Instagram's Content Publishing API is two calls and a wait:

  1. POST /<IG_ID>/media creates a container and returns its id.
  2. GET /<CONTAINER_ID>?fields=status_code tells you when Instagram has finished processing it.
  3. POST /<IG_ID>/media_publish publishes the container by creation_id.

media_type decides what you are publishing: IMAGE, CAROUSEL, REELS or STORIES. A Reel is step one with media_type=REELS and a video_url.

bash
1# 1. Create the Reel container
2curl -X POST "https://graph.instagram.com/v24.0/<IG_ID>/media" \
3  -d "media_type=REELS" \
4  -d "video_url=https://cdn.example.com/reel.mp4" \
5  -d "caption=Shipping notes from this week" \
6  -d "cover_url=https://cdn.example.com/cover.jpg" \
7  -d "access_token=<TOKEN>"
8# => {"id":"17998123456789012"}

Do not skip step two. A video container is IN_PROGRESS while Instagram transcodes, and publishing it early fails.

bash
1# 2. Poll until the container is ready
2curl -G "https://graph.instagram.com/v24.0/17998123456789012" \
3  -d "fields=status_code" \
4  -d "access_token=<TOKEN>"
5# => {"status_code":"IN_PROGRESS","id":"17998123456789012"}
6
7# 3. Publish it
8curl -X POST "https://graph.instagram.com/v24.0/<IG_ID>/media_publish" \
9  -d "creation_id=17998123456789012" \
10  -d "access_token=<TOKEN>"
11# => {"id":"17912345678901234"}

Meta documents five status_code values:

Value

Meaning

IN_PROGRESS

Still processing. Keep polling.

FINISHED

Ready to publish.

ERROR

Processing failed. The container is dead, create a new one.

EXPIRED

The container was not published within 24 hours.

PUBLISHED

Already published.

How long the wait runs depends entirely on the file. A short clip is usually ready in seconds; a long, high-bitrate video can take minutes. Outstand polls every 10 seconds and gives a container up to 15 minutes before it declares the publish failed.

A Story is the same flow with fewer fields

bash
1curl -X POST "https://graph.instagram.com/v24.0/<IG_ID>/media" \
2  -d "media_type=STORIES" \
3  -d "video_url=https://cdn.example.com/story.mp4" \
4  -d "access_token=<TOKEN>"

Then poll and media_publish exactly as above. What changes is what you are allowed to send:

  • No caption. caption is not a parameter for STORIES. Text you want on the Story has to be burned into the media.
  • No stickers. Link, poll, location, question and countdown stickers are not publishable through the API.
  • One piece of media. There is no carousel Story.
  • 24 hours. The Story expires on schedule, like any other Story.

What Instagram actually permits

From Meta's IG User media reference:


Reels

Story video

Story image

Container

MOV or MP4

MOV or MP4

JPEG

Duration

3 seconds to 15 minutes

3 to 60 seconds

n/a

Max file size

300 MB

100 MB

8 MB

Aspect ratio

0.01:1 to 10:1, 9:16 recommended

0.1:1 to 10:1, 9:16 recommended

9:16 recommended

Frame rate

23-60 FPS

23-60 FPS

n/a

Codecs for video are H264 or HEVC with AAC audio, progressive scan, closed GOP, 4:2:0 chroma subsampling, and the moov atom at the front of the file. That last one is the quiet killer: a file that plays fine locally can still fail on Instagram if the metadata atom sits at the end.

Publishing is capped at 100 API-published posts per Instagram account per rolling 24 hours. A carousel counts as one, and Stories count against the same allowance. You can read your current usage from GET /<IG_ID>/content_publishing_limit.

One more thing that catches people on the read side: media_type on a published media object comes back as IMAGE, VIDEO or CAROUSEL_ALBUM. A Reel you published with media_type=REELS does not read back as REELS. If you are pulling published media, see how to get past posts from the Instagram API for the rest of that shape.

Facebook Reels: a different edge and a three-phase upload

Facebook does not have a container model. A Page Reel goes to /<PAGE_ID>/video_reels as a resumable upload in three phases, documented here.

bash
1# Phase 1: start - returns a video_id and an upload_url
2curl -X POST "https://graph.facebook.com/v24.0/<PAGE_ID>/video_reels" \
3  -d "upload_phase=start" \
4  -d "access_token=<PAGE_TOKEN>"
5# => {"video_id":"1234567890","upload_url":"https://rupload.facebook.com/video-upload/v24.0/1234567890"}
6
7# Phase 2: hand Meta a public URL to pull the file from
8curl -X POST "https://rupload.facebook.com/video-upload/v24.0/1234567890" \
9  -H "Authorization: OAuth <PAGE_TOKEN>" \
10  -H "file_url: https://cdn.example.com/reel.mp4"
11
12# Phase 3: finish and publish
13curl -X POST "https://graph.facebook.com/v24.0/<PAGE_ID>/video_reels" \
14  -d "upload_phase=finish" \
15  -d "video_id=1234567890" \
16  -d "video_state=PUBLISHED" \
17  -d "description=Behind the scenes of this week's build" \
18  -d "access_token=<PAGE_TOKEN>"
19# => {"success":true}

Two details in phase two are unlike every other Graph call and fail only at runtime: the token goes in an Authorization: OAuth header rather than an access_token parameter, and the source file is a file_url header, not a parameter. You can also POST the bytes directly if the file is local, in which case you send offset and file_size headers instead.

video_state takes DRAFT, SCHEDULED (with a scheduled_publish_time at least 10 minutes and at most 29 days out) or PUBLISHED.

Meta documents the phase-three response as {"success": true} with no post id. If you need something to store, keep the video_id from phase one and resolve it to a post id later.

Facebook Page Stories work the same way on different edges: /<PAGE_ID>/photo_stories for an image, and the identical start / upload / finish sequence against /<PAGE_ID>/video_stories for a video.

Facebook's Reel specs are tighter than Instagram's: MP4, 3 to 90 seconds, 9:16, minimum 540x960 and 1080x1920 recommended, 24-60 fps, H.264 or H.265, AAC audio at 128 kbps or better. Facebook Stories cap video at 60 seconds.

The rule to internalise: on Facebook, Reel and Story are edges, not properties of the file. The same 9:16 MP4 posted to /feed is a plain Page video. Nothing about the media decides it.

The failure modes

  • Your media URL must be publicly fetchable. Meta pulls the file server-side. Hosts that block crawlers in robots.txt are rejected, and so are files served from Meta's own CDN (fbcdn URLs). If you sign your media URLs, the signature has to still be valid at publish time, not just at request time. That bites scheduled posts specifically.
  • Media cannot be reused on Facebook Stories. Meta rejects a photo or video that already appeared in a published post. Upload a fresh file.
  • A caption on a Story is an error, not a dropped field. Meta's Story edges accept no message field at all.
  • Containers expire. An Instagram container you created and never published is gone after 24 hours.
  • Rate limits are per account, and the Instagram allowance is shared across Reels, Stories, carousels and photos.
  • Instagram Stories expect a Business account. When Meta rejects a Story container because the account is a Creator account, the raw Graph error does not say so in plain English. Outstand maps that one to an error that does.

The same four posts through Outstand

Outstand collapses all of that into one request shape. Media first: POST /v1/media/upload returns a presigned URL, you PUT the bytes to it, then POST /v1/media/{id}/confirm returns the public url you reference in a post.

bash
1curl -X POST https://api.outstand.so/v1/media/upload \
2  -H "Authorization: Bearer $OUTSTAND_API_KEY" \
3  -H "Content-Type: application/json" \
4  -d '{ "filename": "reel.mp4", "content_type": "video/mp4" }'
5# => { "success": true, "data": { "id": "9dyJS", "upload_url": "https://...", "expires_in": 3600 } }
6
7curl -X PUT -T reel.mp4 -H "Content-Type: video/mp4" "<upload_url>"
8
9curl -X POST https://api.outstand.so/v1/media/9dyJS/confirm \
10  -H "Authorization: Bearer $OUTSTAND_API_KEY"
11# => { "success": true, "data": { "url": "https://media.outstand.so/...", "status": "active" } }

An Instagram Reel is then a normal post with a video. Outstand sets media_type=REELS for you, creates the container, polls it to FINISHED and publishes it:

bash
1curl -X POST https://api.outstand.so/v1/posts \
2  -H "Authorization: Bearer $OUTSTAND_API_KEY" \
3  -H "Content-Type: application/json" \
4  -d '{
5    "accounts": ["instagram"],
6    "containers": [
7      {
8        "content": "Shipping notes from this week",
9        "media": [{ "url": "https://media.outstand.so/.../reel.mp4", "filename": "reel.mp4" }]
10      }
11    ],
12    "instagram": { "reelCoverUrl": "https://media.outstand.so/.../cover.jpg" }
13  }'

An Instagram Story is the same call with one flag, one media item and no caption:

bash
1curl -X POST https://api.outstand.so/v1/posts \
2  -H "Authorization: Bearer $OUTSTAND_API_KEY" \
3  -H "Content-Type: application/json" \
4  -d '{
5    "accounts": ["instagram"],
6    "containers": [
7      { "media": [{ "url": "https://media.outstand.so/.../story.mp4", "filename": "story.mp4" }] }
8    ],
9    "instagram": { "publishAsStory": true }
10  }'

A Facebook Reel is facebook.publishAsReel. The container content becomes the Reel description, and because Reels accept comments, any containers[1+] are published as comments on it. A Facebook Story is facebook.publishAsStory, with no content on the container. The two flags are mutually exclusive and sending both is a 400.

bash
1curl -X POST https://api.outstand.so/v1/posts \
2  -H "Authorization: Bearer $OUTSTAND_API_KEY" \
3  -H "Content-Type: application/json" \
4  -d '{
5    "accounts": ["facebook"],
6    "containers": [
7      {
8        "content": "Behind the scenes of this week'\''s build",
9        "media": [{ "url": "https://media.outstand.so/.../reel.mp4", "filename": "reel.mp4" }]
10      }
11    ],
12    "facebook": { "publishAsReel": true }
13  }'

Add "scheduledAt": "2026-10-05T09:00:00Z" to any of these and Outstand holds the post and publishes at that time. This matters for Stories in particular: Meta's Story edges have no scheduling of their own, so they publish the instant you call them.

A few options worth knowing on Reels specifically:

  • instagram.reelCoverUrl sets your own JPEG as the Reels tab cover; instagram.reelThumbOffset picks a frame by millisecond offset instead. If you send both, the cover URL wins.
  • instagram.trialReel publishes a Trial Reel, shared only with non-followers until it graduates, either MANUAL or SS_PERFORMANCE.
  • instagram.isAiGenerated sets Meta's is_ai_generated flag, which surfaces as the "AI info" label. It works on Reels, Stories, feed posts and carousels.
  • instagram.collaborators works on feed posts and Reels, not Stories.

Meta's share_to_feed parameter is not exposed in Outstand's Instagram config today, so Instagram's own default applies.

Knowing it worked

Publishing is asynchronous on both platforms, so neither a 201 from Outstand nor a container id from Meta means the Reel is live. GET /v1/posts/{id} carries a per-account status:

json
1{
2  "success": true,
3  "post": {
4    "id": "9dyJS",
5    "publishedAt": "2026-10-05T09:00:03Z",
6    "socialAccounts": [
7      {
8        "network": "instagram",
9        "status": "published",
10        "platformPostId": "17912345678901234",
11        "publishedAt": "2026-10-05T09:00:03Z",
12        "error": null
13      }
14    ]
15  }
16}

status is pending, published or failed, and on a failure error carries the message the platform returned rather than a generic code. If you would rather not poll, the post.published and post.error webhooks fire within seconds of publishing completing.

Which route to take

If Instagram and Facebook are the only two places your content goes, calling Meta directly is a weekend of work plus app review, and you own the container polling, the resumable upload and the id resolution forever.

If you are publishing the same vertical video to Reels, Stories, TikTok and YouTube Shorts, the per-platform quirks multiply faster than the value of owning them. That is the case for one API: see Instagram publishing and Facebook publishing, or the Instagram and Facebook configuration references for every option in full.