Instagram Posting API
Publishing to Instagram is two API calls with a wait in between, and that wait is where integrations break. Covers the container lifecycle and its four states, why a carousel is n+1 containers and n+1 ways to fail, Instagram vs Facebook Login, and why the documented 25-post cap is not the real one.
The Instagram posting API publishes in two steps: create a media container, then publish it. Every tutorial shows those two calls back to back, and that is where integrations go wrong, because in production there is a wait between them that can run from a second to several minutes. This covers the container lifecycle, what each of its four states means, how carousels multiply the failure modes, and the rolling cap that no amount of retrying will get you past.

What you need first
Three requirements, and the third one surprises people.
A Professional account. Business or Creator. Personal accounts cannot publish through the API at all, and the error you get is unhelpful enough that people spend an afternoon on it. Check the account type during connection, not at publish time.
The publishing scope. Which one depends on how the account connected, and that is the third requirement.
A connection method, chosen deliberately. There are two, and they are not equivalent.
| Instagram Login | Facebook Login | |
|---|---|---|
| Scopes | instagram_business_basic, instagram_business_content_publish, instagram_business_manage_comments, instagram_business_manage_insights | instagram_basic, instagram_content_publish, instagram_manage_insights, instagram_manage_comments, plus the pages_* set |
| Requires a Facebook Page | No | Yes, the IG account must be linked to a Page |
| Publish posts, Reels, Stories | Yes | Yes |
| Add licensed audio to a Reel | No | Yes |
| Tag a sponsor in a paid partnership | Label only | Yes, sponsor resolution works |
| Connection friction | Low | Higher, the user must pick a Page |
Instagram Login is the easier flow and the one most users will complete without help. It also cannot do audio on Reels. If your product promises that feature, you need the Facebook Login path for those accounts, and you need to tell the user why they are being asked for more.
This is the kind of detail that only shows up once you maintain both paths. Most guides describe whichever one their product uses.
The container flow, and why it is not two calls
Publishing is POST /{ig-user-id}/media to create a container, then POST /{ig-user-id}/media_publish to publish it.
curl -X POST "https://graph.facebook.com/v26.0/${IG_USER_ID}/media" \ -d "image_url=https://cdn.example.com/photo.jpg" \ -d "caption=Morning shift" \ -d "access_token=${TOKEN}"
{ "id": "17895695668004550" }
That id is a container, not a post. Nothing is visible to anyone yet. Instagram is now fetching your media from the URL you gave it, and how long that takes depends on your CDN, the file size, and whether the media needs transcoding. An image is usually quick. A 200 MB Reel is not.
Publishing before the container is ready fails. So you check:
curl -G "https://graph.facebook.com/v26.0/${CONTAINER_ID}" \ -d "fields=status_code,status" \ -d "access_token=${TOKEN}"
{ "status_code": "IN_PROGRESS", "id": "17895695668004550" }
And here is the part that matters architecturally. The obvious implementation is a loop with a sleep, and it does not scale. A worker sleeping for 140 seconds while Instagram transcodes a video is a worker doing nothing, and at any real volume you run out of workers long before you run out of capacity.
The fix is to split the operation into three independently scheduled steps:
- Submit. Create the container, persist the creation id, release the worker.
- Poll. A separate step checks status once and either finishes or reschedules itself. The wait lives in the scheduler, not in a thread.
- Publish. Once the container reports
FINISHED, publish it and resolve the permalink.
One consequence worth planning for: if you upload media to temporary storage for Instagram to fetch, you cannot clean it up after submit. Instagram is still pulling from that URL. Delete it only after the container reaches FINISHED, or the fetch fails halfway and the error tells you nothing useful.

Container states
Four states, four different reactions. Treating them as "ready or not ready" loses information you need.
status_code | Meaning | What to do |
|---|---|---|
IN_PROGRESS | Instagram is still fetching or processing the media | Wait and check again. Back off, do not hammer |
FINISHED | Ready to publish | Publish now |
ERROR | Processing failed. The status field carries the reason | Terminal for this container. Read status, fix the input, create a new container |
EXPIRED | The container was not published within 24 hours | Terminal. Create a new container from scratch |
ERROR is where the useful detail lives, and it is in the status string rather than the code. Common causes: the media URL was unreachable from Instagram's fetchers, the file failed validation after download, or the video codec was rejected. A generic "publishing failed" message here wastes everyone's time, so surface the status text to whoever is going to fix it.
EXPIRED sounds like an edge case and is not. A container created at the end of a scheduling window, or one whose publish step got stuck behind a retry backoff, can quietly cross 24 hours. When it does, there is nothing to recover: the media has to be re-uploaded and a new container created.
Publishing
curl -X POST "https://graph.facebook.com/v26.0/${IG_USER_ID}/media_publish" \ -d "creation_id=${CONTAINER_ID}" \ -d "access_token=${TOKEN}"
{ "id": "17920385662001234" }
That id is the published media. To get a link you can show a customer, fetch it separately:
curl -G "https://graph.facebook.com/v26.0/17920385662001234" \ -d "fields=permalink,thumbnail_url" \ -d "access_token=${TOKEN}"
Treat the permalink lookup as best-effort. It occasionally fails right after publishing while the post is still propagating, and a post that is live with a missing link is a much better outcome than a post marked failed because of a cosmetic lookup.
Carousels are n+1 containers

A carousel of five images is six containers: one per child with is_carousel_item=true, then a parent with media_type=CAROUSEL listing the children.
# Each child, in order curl -X POST "https://graph.facebook.com/v26.0/${IG_USER_ID}/media" \ -d "image_url=https://cdn.example.com/1.jpg" \ -d "is_carousel_item=true" \ -d "access_token=${TOKEN}" # Then the parent curl -X POST "https://graph.facebook.com/v26.0/${IG_USER_ID}/media" \ -d "media_type=CAROUSEL" \ -d "children=${CHILD_1},${CHILD_2},${CHILD_3}" \ -d "caption=Three from the weekend" \ -d "access_token=${TOKEN}"
Three things that only bite in production.
Partial failure has no clean recovery. If child three fails while one and two succeeded, you cannot publish a four-item carousel from a five-item request without silently changing what the customer asked for. Fail the whole post and say which item failed. Silently dropping an image is worse than failing.
Children expire on the same 24-hour clock as the parent. Creating children slowly enough that the first one expires before the parent is created is possible if a retry sits in between.
Carousel-specific parameters belong on the parent only. Caption, location, collaborators and paid-partnership settings go on the parent container. Putting them on children is silently ignored or rejected depending on the field, and the failure looks like the feature not working rather than being misplaced.
A carousel takes 1 to 10 items, mixing images and video is allowed, and each item is validated on its own. The per-item limits are in media requirements per platform.
Reels and Stories
Same container flow, different parameters and different constraints.
| Post | Reel | Story | |
|---|---|---|---|
media_type | omit, or VIDEO for a single video | REELS | STORIES |
| Media | 1 to 10 items, images and video | 1 video | 1 image or 1 video |
| Duration | 3 s to 15 min for video | 3 s to 15 min | 3 s to 60 s |
| Caption | Yes | Yes | No |
| Collaborators | Yes | Yes | No |
| Location tag | Yes | Yes | No |
| Licensed audio | No | Yes, Facebook Login only | No |
| Lifetime | Permanent | Permanent | 24 hours |
Two of those rows are the source of most support tickets. Stories accept neither collaborators nor a location tag, and if your UI offers those fields for every post type you will get requests that fail at validation for reasons the user cannot see. Reject them in your own layer with a clear message rather than passing them through.
Reels processing is also meaningfully slower than image posts. A Reel container commonly sits in IN_PROGRESS for tens of seconds. If your poll interval is aggressive you will make dozens of pointless status calls; if it is too lazy you add latency to every Reel. Backing off from a short first interval to a longer one handles both.
bundle.social
Containers, polling, carousels and two connection methods, behind a single call.
Containers, polling and carousels behind a single call.
The posting cap is not the number in the docs
Meta documents a limit of 25 published posts per 24 hours through the Content Publishing API. That number is a floor, not the actual behaviour, and building against it either wastes capacity or walks you into a wall the documentation never mentions.
What we see across a large fleet of connected accounts:
The real ceiling is per account, and it varies. Some accounts publish up to 100 posts a day without complaint. There is no field that tells you which kind of account you have.
Accounts Meta considers less than ideal start failing much earlier. Problems begin around 50 posts in a day, and past roughly 70 there is effectively nothing left. "Less than ideal" is Meta's judgement, not a documented status, and it correlates with account age, prior violations and how the account behaves generally.
Sustained maximum volume borrows from tomorrow. An account that publishes 100 posts in a day is very unlikely to manage the same the next day. The limit behaves less like a fixed quota and more like a budget that regenerates based on standing.
Three consequences for how you build.
Do not hardcode 25, and do not hardcode 100. Treat headroom as unknown and discover it per account.
It is a rolling window, not a midnight reset. You cannot wait for the date to change; the oldest post has to age out.
When you hit it, fail rather than retry. Meta surfaces it as subcode 2207042. No sane retry budget outlasts a rolling 24-hour window, so parking the post in a retry state just delays a failure the customer needs to see now. This is one of the clearest cases where classifying an error as terminal beats being resilient.
Ask the platform instead of guessing:
curl -G "https://graph.facebook.com/v26.0/${IG_USER_ID}/content_publishing_limit" \ -d "fields=config,quota_usage" \ -d "access_token=${TOKEN}"
For any account posting at volume, call this in the scheduler and defer rather than fail. Only published posts count against the allowance, so containers you prepare in advance are free.
The practical advice for anyone running client accounts: pace well below whatever the ceiling appears to be. The accounts that hit these limits are the same accounts most likely to be treated as less than ideal next month, and the recovery is slow.
The shorter path
Two connection methods with different capabilities, a container lifecycle with four states, a polling loop that has to be non-blocking to scale, carousels that multiply every failure mode by n, and a rolling cap that behaves unlike any other rate limit. That is Instagram alone, before you add the other platforms your product promises.
bundle.social's Instagram API is one call. You send text, media and a post type; the container flow, polling, temporary storage lifecycle and permalink resolution happen behind it. You get a webhook when the post is live, or a classified error explaining exactly which part failed.
For the wider surface of the platform, including reading, insights and account discovery, see our Instagram Graph API guide.
Frequently asked questions
How do I publish a post with the Instagram API?
Create a media container with POST /{ig-user-id}/media, wait until its status_code is FINISHED, then publish it with POST /{ig-user-id}/media_publish using the container's id as creation_id. The wait between the two calls is not optional.
How long does an Instagram media container last?
24 hours. After that it reports EXPIRED and cannot be published. The media has to be re-uploaded and a new container created.
Can I publish to Instagram without a Facebook Page?
Yes, using Instagram Login with the instagram_business_* scopes. The account still has to be Business or Creator. The tradeoff is that licensed audio on Reels and sponsor tagging in paid partnerships need the Facebook Login path.
How many posts can I publish per day through the Instagram API?
Meta documents 25 per rolling 24 hours, but the real ceiling is per account and varies widely. Healthy accounts often reach 100; accounts Meta treats as less than ideal start failing around 50 and have little left past 70. Query content_publishing_limit rather than assuming, and pace well below whatever you find.
Why did my carousel fail when the individual images were fine?
Most often one child container failed while the others succeeded, which makes the parent unusable, or caption and location parameters were attached to a child instead of the parent. Check each child's status individually rather than only the parent's.