Instagram Graph API: A Production Guide for Multi-Account Integrations
Two-step container publishing, Reels and Stories constraints, impressions-derived rate limits, and the daily publishing cap that Meta's own documentation contradicts. Plus the part no other guide covers: what breaks when you run a six-figure fleet of connected accounts instead of one.
The Instagram Graph API is the HTTP interface for publishing to and reading from Instagram professional accounts - Business and Creator. It runs on Meta's Graph API infrastructure (current version v26.0), it cannot touch personal accounts, and publishing is always two steps: create a media container, then publish it. Rate limits are derived from the account's impressions, not from a plan you buy. There is a separate daily publishing cap that you should query rather than assume.
This guide covers the parts that break in production, and one thing no other guide covers: what happens to all of the above when you have a six-figure fleet of client accounts connected instead of one. The numbers here come from running about 227,000 of them.
What the API does and does not do
The first column of this table is why teams start. The second column is where the plan changes.
| You want to | Supported? | Notes |
|---|---|---|
| Publish images, videos, Reels, carousels to a professional account | Yes | Two-step container flow, always |
| Publish Stories | Yes | media_type=STORIES; docs note Stories publishing is restricted to business accounts, not all professional accounts |
| Publish to a personal Instagram account | No | The API explicitly cannot access non-Business, non-Creator accounts. No workaround exists |
| Schedule a post natively | No | There is no scheduled_publish_time equivalent. You hold the schedule; the API publishes now |
| Publish with a filter | No | Filters are not supported |
| Upload PNG or WebP | No | JPEG only for images. Extended JPEG (MPO, JPS) also rejected |
| Add shopping tags | No on the publishing flow | Shopping tags unsupported; product_tags is a separate, narrower mechanism |
| Read comments, reply, hide, delete | Yes | instagram_manage_comments / instagram_business_manage_comments |
| Hashtag search, mentions, business discovery | Yes - Facebook Login only | Not available on the Instagram Login configuration |
| Product tagging and Partnership Ads | Yes - Facebook Login only | Instagram Login "cannot access ads or tagging" |
| Serve accounts you do not own | Requires Advanced Access | Standard Access reaches only assets on your app |
Three of these deserve a sentence more, because they are where sprints go to die.
There is no personal-account publishing, and there never will be. If your product plan says "let users post to their Instagram," the user has to convert to a professional account first. Build that conversion prompt into onboarding; do not discover it during a customer demo.
There is no native scheduling. Facebook Pages have scheduled_publish_time. Instagram does not. Every "schedule an Instagram post" feature in every tool is a job queue holding the payload until the minute arrives and then calling the API. That is your problem to build, and it interacts badly with the publishing cap - covered below.
Standard Access looks like it works. Every call succeeds against your own test account. Point the same code at a customer's account and you get empty arrays or permission errors. That is not a bug. Advanced Access requires App Review and Business Verification. Same trap as the Facebook Graph API, same fix.
Two logins, two hosts, two token lifecycles
Meta ships two configurations of the Instagram API, and they are not variants of one thing. They use different hosts, different token types, different permission names, and different refresh mechanics. Choosing wrong costs you a rewrite.
| Instagram API with Instagram Login | Instagram API with Facebook Login | |
|---|---|---|
| Auth flow | Business Login for Instagram | Facebook Login for Business |
| Host | graph.instagram.com | graph.facebook.com (+ rupload.facebook.com) |
| Token type | Instagram User access token | Facebook User or Page access token |
| Facebook Page required | No | Required |
| User logs in with | Instagram credentials | Facebook credentials |
| Publishing permission | instagram_business_content_publish | instagram_content_publish |
| Basic permission | instagram_business_basic | instagram_basic + pages_read_engagement + pages_show_list |
| Hashtag search | ✗ | ✓ |
| Product tagging / Partnership Ads | ✗ | ✓ |
| Insights | Media insights | Media + account insights |
| Long-lived token refresh | Dedicated /refresh_access_token endpoint | Page token derived from a long-lived user token |
| Resumable video upload | ✗ | ✓ (upload_type=resumable) |
Pick Instagram Login if you only publish and moderate comments, and you want the shortest onboarding - no Facebook Page, no Business Manager role checks, one fewer thing for the customer to get wrong.
Pick Facebook Login if you need hashtag search, product tagging, account-level insights, or resumable uploads for large video files. Also pick it if the customer already connects their Facebook Page to you, because then one consent screen covers both.
In practice, agencies serving many brands end up with both, which is the multi-tenant complication discussed later.
The Instagram Login flow, end to end
# 1. Send the user to the authorization window https://www.instagram.com/oauth/authorize ?client_id=990602627938098 &redirect_uri=https://app.example.com/callback &response_type=code &scope=instagram_business_basic,instagram_business_content_publish &state=csrf_nonce_here
The redirect returns ?code=...#_. Strip the trailing #_ - it is not part of the code. The code is valid for one hour and single-use.
# 2. Exchange the code for a short-lived token (server-side only) curl -X POST https://api.instagram.com/oauth/access_token \ -F 'client_id=990602627938098' \ -F 'client_secret=a1b2C3D4' \ -F 'grant_type=authorization_code' \ -F 'redirect_uri=https://app.example.com/callback' \ -F 'code=AQBx-hBsH3...'
{ "data": [ { "access_token": "EAACEdEose0...", "user_id": "1020...", "permissions": "instagram_business_basic,instagram_business_content_publish" } ] }
Two things to notice. The response is wrapped in a data array - unlike the Facebook token endpoints, which return a bare object. And permissions tells you exactly what the user actually granted, which may be less than what you asked for. Read it and store it. A user who unticked content publishing on the consent screen will produce a permission error on their first scheduled post, hours later, with no obvious cause.
# 3. Exchange for a long-lived token - 60 days curl -G "https://graph.instagram.com/access_token" \ -d "grant_type=ig_exchange_token" \ -d "client_secret=${APP_SECRET}" \ -d "access_token=${SHORT_LIVED_TOKEN}"
{ "access_token": "EAACEdEose0...", "token_type": "bearer", "expires_in": 5183944 }
# 4. Refresh before day 60 curl -G "https://graph.instagram.com/refresh_access_token" \ -d "grant_type=ig_refresh_token" \ -d "access_token=${LONG_LIVED_TOKEN}"
The refresh has three preconditions that are easy to violate:
- The existing token must be at least 24 hours old. A refresh loop that fires immediately after issuance fails.
- The token must not have expired. There is no grace period - a token unrefreshed for 60 days is dead, and the user has to re-authorize.
- The user must still have granted
instagram_business_basic.
The short-lived token, incidentally, is also valid for only one hour. Exchange it in the callback handler, not in a nightly job.
For the Facebook Login path, token mechanics are the Page-token mechanics we cover in Facebook Page access tokens - no /refresh_access_token, a different failure taxonomy, and role changes in Business Manager that silently invalidate access.
Publishing: the two-step container flow

Every Instagram publish is two API calls minimum. There is no single-shot endpoint.
POST /{ig-user-id}/media → container id POST /{ig-user-id}/media_publish → media id
Step 1 - create the container
curl -X POST "https://graph.instagram.com/v26.0/${IG_USER_ID}/media" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -d '{ "image_url": "https://cdn.example.com/launch.jpg", "caption": "Launch day.", "alt_text": "A laptop showing a deployment dashboard" }'
{ "id": "17889455560051444" }
That is a container ID, not a media ID. It is not a post. Nothing is visible on Instagram yet.
The critical constraint on image_url and video_url: Meta cURLs the URL from its own servers at the moment of the call. The media must be on a publicly reachable host. Signed URLs with short TTLs, staging environments behind basic auth, and buckets with IP allowlists all fail here - and they fail with a generic fetch error, not a helpful one.
Step 2 - publish
curl -X POST "https://graph.instagram.com/v26.0/${IG_USER_ID}/media_publish" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -d '{ "creation_id": "17889455560051444" }'
{ "id": "17920853729530539" }
Now it is live.
The 24-hour expiry, and why it changes your queue design
A container that is not published within 24 hours expires. This single fact invalidates the obvious scheduler design.
The naive implementation creates the container when the user hits "schedule", stores the container ID, and publishes at the appointed time. That works for a post scheduled two hours out. It fails silently for one scheduled next Tuesday.
The correct design: store the payload, create the container at publish time. Container creation is part of the publish job, not part of the scheduling request. Cost: your job now makes two or three calls instead of one, and validation errors surface at publish time instead of at schedule time. Mitigate by validating media dimensions and caption length synchronously when the user schedules, and creating the container only in the worker.
Polling the container status
Video containers are not immediately publishable - Meta transcodes first. Publishing too early returns error 9007 / 2207027, "The media is not ready for publishing."
curl -G "https://graph.instagram.com/v26.0/${CONTAINER_ID}" \ -d "fields=status_code" \ -d "access_token=${ACCESS_TOKEN}"
{ "status_code": "IN_PROGRESS", "id": "17889455560051444" }
status_code | Meaning | What to do |
|---|---|---|
IN_PROGRESS | Still processing | Keep polling |
FINISHED | Ready | Call media_publish |
PUBLISHED | Already live | Stop. Do not publish again |
ERROR | Processing failed | Terminal. Recreate the container from source |
EXPIRED | Not published within 24 h | Terminal. Recreate from source |
Meta's guidance is to poll once per minute for no more than five minutes. In our experience that is right for images and short videos and optimistic for long Reels; treat five minutes as a soft floor for your timeout, not a hard ceiling.
The idempotency problem nobody writes about
media_publish can succeed on Meta's side and still return a 500 to you - typically code: 2, is_transient: true, "An unexpected error has occurred." A socket timeout does the same thing.
Retrying blindly publishes the post twice. Not retrying abandons a post that may have failed for real. Both are wrong.
The resolution is that the container knows. Before retrying a failed media_publish, read the container's status_code. If it is PUBLISHED, the post is live - you simply never received the media ID. Treat the container ID as your correlation key and move on.
on media_publish failure: status = GET /{container-id}?fields=status_code # never let this throw if status == "PUBLISHED": return success # already live, do not retry if status in ("ERROR", "EXPIRED"): fail terminally else: retry with backoff
If the status lookup itself fails, treat it as "unknown" and retry - the duplicate risk is real but smaller than the risk of dropping a customer's post. This is the container flow's one genuinely subtle piece of engineering, and it is invisible until you are publishing at volume.
This is not a rare edge case. In our traffic, about 10% of media_publish errors are false failures - the call returned an error, but the container was already PUBLISHED and the post was live on Instagram.
One in ten. If you retry blindly on media_publish errors, roughly a tenth of those retries are attempts to publish something that already exists, and the ones that succeed produce a visible duplicate on your customer's feed. The status check above costs one extra API call on a path that is already failing, and it is the difference between an error rate and a credibility problem.
(https://media.bundle.social/container_publication_a3df62460a.jpg)
Reels, Stories, and carousels
Same two-step flow, different parameters and different constraints.
| Image post | Reel | Story | Carousel | |
|---|---|---|---|---|
media_type | omit (default) | REELS | STORIES | CAROUSEL |
| Media parameter | image_url | video_url | image_url or video_url | children |
| Container calls | 1 | 1 | 1 | n + 1 |
| Caption | ✓ | ✓ | ✗ | ✓ (on the parent) |
alt_text | ✓ | ✗ | ✗ | ✓ (per child) |
cover_url / thumb_offset | - | ✓ | - | - |
share_to_feed | - | ✓ | - | - |
collaborators (max 3) | ✓ | ✓ | ✗ | ✓ |
user_tags | ✓ (x/y) | ✓ | ✓ | ✓ (per child) |
location_id | ✓ | ✓ | ✓ | ✓ |
Reported media_type after publish | IMAGE | VIDEO | IMAGE/VIDEO | CAROUSEL_ALBUM |
| Duration | - | 3 s – 15 min | 3–60 s (video) | - |
| Max file size | 8 MB image | 300 MB | 100 MB video | per child |
Three traps in that table.
A published Reel reports media_type: VIDEO. If you store media_type to distinguish Reels from feed videos, your data is wrong. Request media_product_type instead. The same applies to Stories, which report IMAGE or VIDEO.
Carousels are n + 1 containers. Each child needs its own container created with is_carousel_item=true, then a parent container with media_type=CAROUSEL and children as a comma-separated list of up to 10 IDs. A 10-image carousel is 12 API calls. Every child inherits the 24-hour expiry independently - if child 1 was created 23 hours ago and child 10 five minutes ago, the parent will fail.
# child curl -X POST "https://graph.instagram.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}" # parent curl -X POST "https://graph.instagram.com/v26.0/${IG_USER_ID}/media" \ -d "media_type=CAROUSEL" \ -d "caption=Five things we shipped" \ -d "children=17889455560051444,17889455560051445,17889455560051446" \ -d "access_token=${TOKEN}"
Note also that carousel images are all cropped to match the first image, defaulting to 1:1. Customers notice this immediately, and it is not a bug you can fix in code - surface it in your UI before upload.
Stories are the constrained one. No caption. Metrics available for 24 hours only. And per Meta's own limitation note, Stories publishing is available to business accounts rather than all professional accounts - worth an eligibility check in onboarding rather than a failed post later.
Caption limits across all types: 2,200 characters, 30 hashtags, 20 @-mentions. Exceeding the caption length returns 36004 / 2207010; exceeding the tag count returns 100 / 2207040.
Rate limits and the publishing cap
Two independent ceilings. Hitting either one stops publishing, and they behave nothing alike.
Call volume: derived from impressions
Calls within 24 hours = 4800 × Number of Impressions
"Number of Impressions" is how many times content from that account entered someone's screen in the last 24 hours. The window is rolling, and the counter is scoped to the app–user pair, not to your app globally.
The consequence is the one that catches every integration: a brand-new client account has almost no call budget. Precisely when onboarding wants to backfill a content calendar, the ceiling is at its lowest. There is no documented floor for Instagram (Threads, by contrast, treats impressions as a minimum of 10).
Business Discovery and Hashtag Search are the exceptions - they fall under Platform rate limits (200 × users per hour) rather than Business Use Case limits. Messaging endpoints have their own per-second caps entirely.
Usage comes back in X-Business-Use-Case-Usage, keyed by business object ID, with type: "instagram":
x-business-use-case-usage: { "17841400008460056": [ { "type": "instagram", "call_count": 34, "total_cputime": 12, "total_time": 15, "estimated_time_to_regain_access": 0 } ] }
All three usage numbers are percentages, 0–100, not counts. estimated_time_to_regain_access is in minutes and is the single most useful field for a scheduler - it tells you exactly how long to park the queue for that account instead of guessing. The cross-platform version of this table is in social media API rate limits.
The publishing cap: query it, do not hardcode it
There is a separate cap on how many containers an account can publish per 24 hours, enforced on media_publish. Carousels count as one post.
Meta's own documentation gives three different numbers for it. As of 31 July 2026:
| Source | Figure |
|---|---|
| Content Publishing guide, "Rate Limit" section | 100 posts / 24 h |
| Content Publishing guide, carousel "Limitations" section | 50 posts / 24 h |
content_publishing_limit endpoint reference, quota_total | 50 |
Third-party guides confidently quote 25. Others quote 50. They are all quoting a snapshot of a number that has changed more than once and that Meta cannot keep consistent across two sections of the same page.
Meta ships an endpoint specifically so you do not have to guess:
curl -G "https://graph.instagram.com/v26.0/${IG_USER_ID}/content_publishing_limit" \ -d "fields=config,quota_usage" \ -d "access_token=${TOKEN}"
{ "data": [ { "quota_usage": 2, "config": { "quota_total": 50, "quota_duration": 86400 } } ] }
quota_usage is containers published since the since timestamp (defaults to the last 24 hours; since cannot be older than that). quota_total and quota_duration are the live values for that account. Read them. Do not compile them into your code.
Exceeding the cap returns error 9 / 2207042, "You reached maximum number of posts."
Queueing under the cap
The cap is per account and the window is rolling, which rules out a simple daily counter.
- Check before you publish, not after. One
content_publishing_limitcall per account per publish cycle is cheap insurance against a post that fails after you have already created and transcoded a container. - Reserve capacity, do not just count. In a system with concurrent workers, two jobs can each read
quota_usage: 49and both publish. Take a token from a per-account semaphore before creating the container, and release it on terminal failure. - Treat the window as rolling. With a 24-hour rolling window, a burst of 50 posts at 09:00 does not free capacity at midnight - it frees it at 09:00 the next day, gradually.
- Degrade to "delayed", not "failed". A post blocked by the cap should be re-queued with the delay implied by the oldest publish in the window, and the customer should be told it is queued, not that it failed.
- Fail fast on the terminal codes. Cap and throttle errors are retryable; a bad caption is not. Retrying a
36004burns cap headroom to produce the same error.
Everything below is already handled across 227,000 connected accounts. You can build it yourself - or connect it.
bundle.social
The whole Graph surface, already integrated and audited.
Containers, polling and carousels behind a single call.
What changes at scale: notes from 227,000 connected accounts
Everything above describes one account. Multi-tenant is not that, repeated. Four properties of the API change character entirely once the fleet is large, and none of them are documented as such.
1. Rate limits are per account, so your global counter is meaningless

The Instagram BUC limit is scoped to the app–user pair. We run about 227,000 connected Instagram accounts, which means 227,000 independent budgets, each derived from a different impressions figure, each moving hourly.
There is no aggregate view. There is no endpoint that returns "usage across all connected accounts." Your rate limiter's key must be (account_id), its state must be persisted, and it must be reconciled from response headers rather than computed.
And here is the wall: X-Business-Use-Case-Usage returns at most 32 objects in a single response. At our fleet size, enumerating budget state would take over 7,000 calls - to produce a snapshot that is stale before it finishes. So you do not enumerate. Budget state is built up incrementally, one response at a time, per account, and is always slightly stale for accounts you have not touched recently. Design for stale budgets rather than pretending you have a live global view.
The corollary: your most idle accounts have the least reliable budget estimates, and they are also the ones most likely to be brand-new with near-zero impressions. First publish for a new client is the highest-risk publish you will make.
2. Token lifecycle becomes a scheduled system, not an error handler
At one account, token refresh is something you do when a call fails. At six figures, it is a continuously running system with its own failure modes.
The mechanics force this. Instagram Login tokens live 60 days, must be at least 24 hours old to refresh, and cannot be recovered once expired - the customer has to re-authorize through a consent screen. That makes an unrefreshed token a support ticket, not a retry.
What the job actually needs:
- A refresh horizon, not an expiry check. Refresh at day 45–50, not day 59. A refresh that fails on day 59 leaves no room for a second attempt.
- Staggering. Accounts onboarded together during a migration all expire in the same week. Jitter the refresh schedule so the load - and the failure blast radius - spreads.
- Two state machines, not one. If you support both login types you have Instagram User tokens (explicit refresh endpoint) and Facebook Page tokens (derived, revoked by Business Manager role changes). They fail differently and recover differently. One
tokenstable with aflavourcolumn and one refresh worker per flavour. - A re-authorization path that is a product feature. Expired and revoked tokens are permanent until the user acts. That needs an email, an in-app banner, and a one-click reconnect - not a log line.
Here is what that looks like at our scale. Across roughly 227,000 connected Instagram accounts, a 60-day token life and the day-45–50 refresh horizon above work out to about 4,500 token refreshes every day just to keep the fleet alive. That is the steady-state floor, not a peak - it is the cost of doing nothing new.
The number that actually drives product decisions is the other one: about 10% of accounts per year need the user to re-authorize, because the token expired unrefreshed, the password changed, the account was checkpointed, or a Business Manager role was removed. At our fleet size that is roughly 22,000 re-consents a year - around 60 a day, every day.
Sixty a day is not an error queue. It is a recurring product surface, and it is why the last bullet above is not optional. If re-authorization is a log line and a support ticket, ten percent of your customers hit a dead integration each year and find out from their own audience. If it is an email, an in-app banner and a one-click reconnect, it is background noise. The engineering is identical; only the framing differs.
3. Fault isolation, or one bad account takes down the queue

A single global FIFO worker pool is the default design and the wrong one.
The failure looks like this. One customer's account is checkpointed (25 / 2207050, "The Instagram account is restricted"). Every job for that account fails, retries, backs off, and retries again. Those retries occupy workers. Meanwhile every healthy account waits behind them, and your on-call sees "publishing is slow" rather than "one account is broken."
Three mitigations, in order of importance:
Partition the queue by account. Round-robin across accounts rather than draining a global FIFO. One customer backfilling 500 posts must not starve everyone else - and one customer failing 500 posts must not either.
Circuit-break per account. After n consecutive terminal failures on an account, stop scheduling work for it and mark it degraded in your UI. A restricted account, a revoked token, and a deleted Instagram profile all produce infinite retry loops otherwise.
Classify errors before retrying, always. This matters more at scale than anywhere else, because retry storms are the mechanism by which one broken account consumes an entire worker pool.
4. Onboarding is the load spike, not steady state
Steady-state publishing, even across a very large fleet, is a trickle - spread thin across accounts and across the day. The spike is onboarding.
When an agency connects 40 client accounts on a Monday morning, you get, per account: an OAuth exchange, a long-lived token exchange, an account-info fetch, a media backfill, and often a batch of scheduled posts. All at once, against accounts whose impressions-derived budgets are the smallest they will ever be.
Rate-limit your own onboarding pipeline. Backfill lazily and in the background. Do not fetch 90 days of media insights synchronously during connect. The single most common self-inflicted outage in a multi-tenant social integration is an onboarding flow that behaves like a load test.
We have written up the data model, team isolation, and webhook fan-out for this in multi-tenant social media API architecture.
Error codes and retry classification
Instagram returns Graph API-shaped errors, but with an Instagram-specific subcode range that carries the real information. The code is generic; the error_subcode tells you what actually happened.
{ "error": { "message": "The media could not be fetched from this uri", "type": "OAuthException", "code": 9004, "error_subcode": 2207052, "fbtrace_id": "EJplcsCHuLu" } }
Log fbtrace_id on every failure. It is the only identifier Meta support can act on.
| Code / subcode | Message | Class | Action |
|---|---|---|---|
-2 / 2207003 | Takes too long to download the media | Transient | Retry; check media host latency |
-1 / 2207032 | Create media fail, please re-create | Transient | Recreate the container |
-2 / 2207020 | The media you are trying to access has expired | Terminal | Container expired; recreate from source |
4 / 2207051 | Restricted activity - flagged as spam | Terminal | Stop. Do not retry. Review posting patterns |
9 / 2207042 | You reached maximum number of posts | Cap | Re-queue; check content_publishing_limit |
24 / 2207008 | Media builder does not exist or expired | Transient | Retry in 30 s – 2 min |
25 / 2207050 | The Instagram account is restricted | Terminal | Circuit-break the account; notify the customer |
100 / 2207028 | Won't work as a carousel | Terminal | Carousel needs 2–10 items |
100 / 2207040 | Cannot use more than {n} tags | Terminal | Reduce @-mentions to 20 |
352 / 2207026 | Video format is not supported | Terminal | Re-encode to MP4 or MOV |
9004 / 2207052 | Media could not be fetched from this uri | Terminal | URL not publicly reachable |
9007 / 2207027 | Media is not ready for publishing | Transient | Poll status_code until FINISHED |
36000 / 2207004 | Image too large to download | Terminal | Exceeds 8 MiB |
36001 / 2207005 | Image format not supported | Terminal | JPEG only |
36003 / 2207009 | Aspect ratio not supported | Terminal | Must fall between 4:5 and 1.91:1 |
36004 / 2207010 | Caption too long | Terminal | Trim to 2,200 characters |
80002 | Instagram BUC rate limit reached | Throttle | Back off; use estimated_time_to_regain_access |
190 | Access token expired / revoked | Terminal | Refresh, or trigger re-authorization |
2 (transient) | Unexpected error | Transient | Check container status_code before retrying |
Two rules that follow from this table.
Terminal means terminal. Roughly two thirds of the rows above will never succeed on retry. Retrying a 36003 five times produces five identical failures, five wasted calls against an impressions-derived budget, and a delayed error message to the customer. Map every subcode to a class at the point where you parse the error, not in the retry loop.
4 / 2207051 is special. It is a behavioural block, not a rate limit. Retrying deepens it. The only correct response is to stop publishing for that account and look at what the content had in common.
Permission errors trace back to what the user granted at consent time - the per-permission requirements and review process are covered in Facebook API permissions and app review.
The shorter path
Everything above is one platform. The container flow, the polling loop, the idempotency check, the 500 token refresh schedules, the per-account rate limiter, the subcode classification table - that is the Instagram integration, before you add TikTok, LinkedIn, YouTube, or X, each with its own version of all of it.
With bundle.social's Instagram API integration, a Reel is one call:
curl -X POST "https://api.bundle.social/api/v1/post" \ -H "x-api-key: ${BUNDLE_API_KEY}" \ -H "content-type: application/json" \ -d '{ "teamId": "team_123", "postDate": "2026-08-14T09:00:00Z", "socialAccountTypes": ["INSTAGRAM"], "data": { "INSTAGRAM": { "type": "REEL", "text": "Launch day.", "uploadIds": ["upload_abc"], "shareToFeed": true, "collaborators": ["acme_design"] } } }'
| Native Instagram Graph API | bundle.social | |
|---|---|---|
| Container flow + polling | You build it | Managed |
| Publish idempotency | You discover it in production | Handled |
| Token refresh across accounts | Your scheduled job | Managed |
| Per-account rate limiting | Your limiter, your state | Managed |
| Publishing cap enforcement | You query and reserve | Managed |
| App Review + Business Verification | Yours to pass | Ours, already passed |
| Adding Facebook, TikTok, LinkedIn | Three more integrations | Three more strings in socialAccountTypes |
Teams isolate customers, an org-level API key covers all of them, and the queueing and fairness described above is the default rather than something you build.
Instagram API without managing a fleet of tokens. Explore the Instagram API→
FAQ
Can the Instagram Graph API post to a personal account? No. It only works with Instagram professional accounts - Business or Creator. The API explicitly cannot access consumer accounts, and there is no workaround. Users must convert their account first.
How many posts per day can the API publish?
It depends on the account, and Meta's own documentation contradicts itself (100 in one section, 50 in another, 50 from the API). Query GET /{ig-user-id}/content_publishing_limit and read config.quota_total for the live value. Carousels count as one post.
Why did my media container disappear?
Containers expire 24 hours after creation. Query GET /{container-id}?fields=status_code - EXPIRED means exactly that. Create containers at publish time, not at schedule time.
What is the difference between Instagram Login and Facebook Login for the API?
Instagram Login uses graph.instagram.com, Instagram User tokens, and needs no Facebook Page. Facebook Login uses graph.facebook.com, Page tokens, requires a linked Page, and is the only option for hashtag search, product tagging, and resumable video upload.
Is the Instagram Graph API free? Yes, there is no per-call charge. The costs are rate limits derived from account impressions, App Review, Business Verification, and the engineering time to maintain the integration.
How do I avoid publishing a post twice after a timeout?
Before retrying media_publish, read the container's status_code. PUBLISHED means the post is live and you simply never received the media ID. Meta can publish successfully and still return a 500.