Publishing an Instagram Story through the API
An Instagram Story is published like any other post: POST /<IG_ID>/media creates a container with media_type=STORIES, and POST /<IG_ID>/media_publish publishes it. There is no separate story endpoint, no caption parameter, and the container expires after 24 hours. Images must be JPEG and under 8 MB, while video runs 3 to 60 seconds.
Updated:
How do you publish an Instagram Story through the API?
Two calls. First POST /<IG_ID>/media with image_url or video_url plus media_type=STORIES. Then POST /<IG_ID>/media_publish with creation_id. For video, wait for the container to reach FINISHED before the second call. Meta downloads the file itself, so the URL must be public HTTPS.
There is no separate media_type value for photo or video stories: whichever of image_url and video_url you pass decides the type. One container cannot carry both.
A story lives for 24 hours, and so does the container: an unpublished container turns EXPIRED and cannot be reused.
Story media specifications
Neither the SDK nor the dashboard can check these — Meta fetches the file on its own side. A mismatch pushes the container into ERROR.
| Property | Image | Video |
|---|---|---|
| Format | JPEG only | MOV or MP4 |
| Size | up to 8 MB | up to 100 MB |
| Duration | no | 3–60 seconds |
| Aspect ratio | 9:16 recommended | 0.1:1 to 10:1, 9:16 recommended |
| Codec | sRGB | H264 or HEVC, AAC audio at 48 kHz |
| Frame rate | no | 23–60 FPS |
yesno
PNG and WebP are rejected — convert to JPEG first. If the URL needs authentication or has expired, Meta cannot fetch the file.
The raw requests: container and publish
The flow is identical without an SDK. The first response returns the container id, which becomes creation_id in the second request.
# 1) Story container
curl -X POST "https://graph.facebook.com/v26.0/$IG_ID/media" \
-d "image_url=https://cdn.example.com/story.jpg" \
-d "media_type=STORIES" \
-d "access_token=$TOKEN"
# -> {"id":"17998...."}
# 2) For video, wait until the container is ready
curl -G "https://graph.facebook.com/v26.0/17998...." \
-d "fields=status_code" -d "access_token=$TOKEN"
# -> {"status_code":"FINISHED"}
# 3) Publish
curl -X POST "https://graph.facebook.com/v26.0/$IG_ID/media_publish" \
-d "creation_id=17998...." -d "access_token=$TOKEN"On the Instagram Login path the host is graph.instagram.com; on the Facebook Login path it is graph.facebook.com. The endpoint names are the same.
Images need no wait — the container is usually ready straight away. For video, do not call media_publish until status_code is FINISHED.
Why a story container has no caption
In the Content Publishing API, caption is the text of a feed post. A story never reaches the feed and has no description field, so a caption sent together with media_type=STORIES shows up nowhere.
Stickers are absent too: the Graph API will not add a link, poll, question, location, music, @mention or hashtag sticker. If a story needs text or a link, it has to be baked into the JPEG or into the video frames.
That is why publish_story() in the Socialift SDK deliberately takes no caption — there is no room left for a parameter that does not exist.
Permissions for both login paths
Instagram has two authorisation paths with completely different scopes. A Facebook Page is required on only one of them.
| Action | Instagram Login | Facebook Login |
|---|---|---|
| Publish a story | instagram_business_basic + instagram_business_content_publish | instagram_basic + instagram_content_publish + pages_read_engagement |
| List active stories | not available — the edge does not exist | instagram_basic + pages_read_engagement |
| Token | user token (graph.instagram.com) | Page token (graph.facebook.com) |
| Facebook Page | not required | a linked Page is required |
On the Facebook Login path, ads_management or ads_read is also needed when the Page role was granted through Business Manager.
Who can read the list of active stories
Stories never appear on the media edge — Meta exposes a separate GET /<IG_ID>/stories for them. The entries are ordinary IG Media objects, and the marker is media_product_type == "STORY": media_type returns IMAGE or VIDEO for stories too, so it cannot tell them apart.
The important part: Meta's own reference marks this edge as "Instagram API with Facebook Login only". With an Instagram Login token the edge simply does not exist, and there is no documented alternative for listing active stories on that path. The Socialift SDK therefore raises ConfigurationError without sending a request — clearer than waiting for Meta's misleading "nonexisting field" error.
The list only covers the last 24 hours: neither the archive nor highlights are exposed in the API. Live video stories and stories created when a user reshares are not returned either.
The daily limit: Meta's own docs give two numbers
A story goes out through the same media_publish endpoint, so it counts against the publishing limit — nothing in the documentation exempts stories from it.
The number itself, however, differs between Meta's pages: the Content Publishing overview says 100 posts per rolling 24 hours, while the media_publish and content_publishing_limit pages say 50. Do not hard-code either figure.
Query it instead: GET /<IG_ID>/content_publishing_limit returns quota_usage (posts published inside the window) and a config object with quota_total and quota_duration. The optional since parameter takes a Unix timestamp no older than 24 hours. Read the limit from the response and throttle your queue against it.
Containers have a separate cap: up to 400 containers per rolling 24 hours. An unpublished container expires after a day, so creating them far in advance buys nothing.
Publishing a story with the Socialift SDK
The SDK folds container creation, waiting and publishing into one call and returns container_id and media_id.
import asyncio, os
from socialift import ContainerNotReadyError, Instagram
CDN = "https://cdn.example.com"
async def main():
token = os.environ["IG_USER_TOKEN"]
async with Instagram.from_env() as ig:
photo = await ig.publish_story(token, image_url=f"{CDN}/story.jpg")
try:
clip = await ig.publish_story(token, video_url=f"{CDN}/story.mp4", wait_timeout=120.0)
except ContainerNotReadyError as exc:
print("container not ready yet:", exc.container_id)
return
active = await ig.stories(token, limit=25)
print(photo["media_id"], clip["media_id"], len(active))
asyncio.run(main())publish_story accepts exactly one of image_url and video_url; passing both raises ConfigurationError.
For video the container is polled for up to wait_timeout seconds (300 by default). On timeout you get ContainerNotReadyError, and its container_id lets you finish without re-uploading — the container lives 24 hours.
On the Facebook Login path build the client with Instagram.via_facebook(app_secret=...), then pass a Page token and ig_user_id=.
stories() works only on the Facebook Login path; with an Instagram Login token the SDK raises ConfigurationError before any request goes out.
Facebook Page stories: photo and video flows
Page stories do not use Instagram's container flow at all — they have two protocols of their own.
Photo: upload it unpublished first
The
photo_storiesedge takes onlyphoto_idand refuses a URL. So the first call is POST /<PAGE_ID>/photos?published=false&url=<link>, which returns just anidand keeps the photo out of the feed.Photo: publish it as a story
Pass that
idto POST /<PAGE_ID>/photo_stories?photo_id=<id>. An unpublished photo stays on Facebook's servers for roughly 24 hours, after which thephoto_idstops working.Video: start the session
POST /<PAGE_ID>/video_stories?upload_phase=start returns
video_idandupload_url. Thatupload_urlpoints at rupload.facebook.com and must be taken from the response, never assembled by hand.Video: upload the file
POST to that
upload_url. Here the token travels in anAuthorization: OAuth <token>header, not in the query. With a link you send afile_urlheader and an empty body; with a file you sendoffsetandfile_sizeheaders and the bytes as the body.Video: finish
POST /<PAGE_ID>/video_stories?upload_phase=finish&video_id=<id>. Before that, wait until
uploading_phase.status == "complete"via GET /<VIDEO_ID>?fields=status.
Page stories: SDK example and resumed uploads
The SDK runs both flows internally: two steps for a photo, three for a video, plus resuming an interrupted upload.
import asyncio, os
from socialift import Facebook
async def main():
page_id = os.environ["FB_PAGE_ID"]
page_token = os.environ["FB_PAGE_TOKEN"]
async with Facebook.from_env() as fb:
photo = await fb.publish_photo_story(
page_id, page_token, photo_url="https://cdn.example.com/story.jpg"
)
clip = await fb.publish_video_story(
page_id, page_token, video="/srv/media/story.mp4", upload_attempts=3
)
state = await fb.video_story_status(clip["video_id"], page_token)
print(photo["post_id"], state.get("publishing_phase"))
for item in await fb.stories(page_id, page_token, limit=25):
print(item["post_id"], item["status"])
asyncio.run(main())publish_photo_story takes exactly one of photo_url and photo_id. The second one matters when a first attempt died at the publish step: the photo does not need re-uploading.
On an interrupted upload, publish_video_story reads uploading_phase.bytes_transfered and resumes from that byte, up to upload_attempts times. The file is never re-sent from the start.
Permissions: pages_manage_posts, pages_read_engagement, pages_show_list, plus the CREATE_CONTENT task on the Page.
Video specs: .mp4, H.264 or H.265, AAC audio at 48 kHz; 9:16 aspect; 540x960 to 1080x1920; 24–60 FPS. On duration Meta's docs contradict themselves — 3–90 seconds in one place, no more than 60 in another; the safe range is 3–60 seconds.
Common mistakes and how to avoid them
- The image is PNG or WebP — Instagram stories accept JPEG only.
- A signed URL has expired or asks for a login — Meta fetches the file at call time.
- media_publish was called before the video container reached FINISHED.
- Both image_url and video_url were sent in one container.
- You expect a caption or a sticker — the API adds neither.
- GET /<IG_ID>/stories was called with an Instagram Login token, where the edge does not exist.
- The daily limit is hard-coded — read it from content_publishing_limit instead.
- A Page story video is below 540p — that error surfaces in the status after the upload, not at the finish step.
- A Page photo sat unpublished for more than a day: the photo_id has expired.
Frequently asked questions
Which permissions does publishing an Instagram Story need?
On the Instagram Login path: instagram_business_basic and instagram_business_content_publish. On the Facebook Login path: instagram_basic, instagram_content_publish and pages_read_engagement, used with a Page token. If the Page role came through Business Manager, ads_management or ads_read is required as well.
Why can't I add a caption to a story?
A story is not a feed post and has no description field. In the Content Publishing API caption applies to feed posts only, so one sent with media_type=STORIES never appears. If you need text, bake it into the image or the video.
Can the API add a story sticker or a link?
No. The Graph API does not support story stickers — link, poll, question, location, music, @mention or hashtag. They can only be added from the Instagram app.
How do I list the currently active stories?
With GET /<IG_ID>/stories, but that edge exists only on the Facebook Login path; an Instagram Login token has no access to it. The list covers the last 24 hours only — the archive and highlights are not in the API.
How many stories can I publish per day?
Stories share the media_publish limit with ordinary posts, but Meta's own documentation gives two figures: 100 on the general page and 50 on the media_publish and content_publishing_limit pages. Do not hard-code either — read quota_total and quota_usage from GET /<IG_ID>/content_publishing_limit.
Is a Facebook Page story different from an Instagram one?
Completely. A Page has no container: a photo is uploaded with published=false and then published by photo_id through photo_stories, while a video goes through upload_phase=start, an upload to rupload.facebook.com and upload_phase=finish.