Automate Instagram post scheduling using the Graph API's two-step container model: create a media container with POST /{ig-user-id}/media, poll its status until FINISHED, then publish with POST /{ig-user-id}/media_publish. Containers expire after 24 hours — store image URLs, not containers. The key limit is 100 API-published posts per 24-hour sliding window per account.
API Quick Reference
OAuth 2.0
200 calls/hour per user; 100 posts/24h
JSON
Available
Understanding the Instagram Graph API
The Instagram Graph API is a REST API that lets you manage Instagram Business and Creator accounts programmatically. It operates on the Meta Graph API infrastructure with a base URL of https://graph.instagram.com/v25.0 and uses long-lived access tokens (valid 60 days) for user-context operations. The API strictly requires a Business or Creator account linked to a Facebook Page — personal accounts have no API access.
Post scheduling on Instagram works through a deliberate two-step container model. You first create a media container (an unpublished post object) with your image URL and caption. The API processes the media asynchronously, and you must poll the container status until it returns FINISHED before triggering the actual publish call. This design separates media upload time from publish time, letting you pre-validate content without making it live.
The critical gotcha for scheduling: containers expire after exactly 24 hours. This means you cannot create containers days ahead and store them — instead, store your scheduled post data (image URL, caption, target time) in your own database and create the container only at publish time. Official docs are at https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media.
https://graph.instagram.com/v25.0Setting Up Instagram Graph API Authentication
The Instagram Graph API uses OAuth 2.0 with long-lived User Access Tokens. The short-lived token from the initial OAuth flow lasts only 1 hour — you must exchange it for a 60-day long-lived token immediately. There is no auto-refresh; you must call the refresh endpoint before 60 days expire, but only after the token is at least 24 hours old.
- 1Go to developers.facebook.com and create a new App (type: Business)
- 2Add the Instagram product to your app from the App Dashboard
- 3Under Instagram > API setup with Instagram Business Login, note your App ID and App Secret
- 4In App Review > Permissions and Features, request instagram_business_basic and instagram_business_content_publish permissions
- 5Implement the OAuth flow: redirect users to https://www.facebook.com/v25.0/dialog/oauth?client_id={app-id}&redirect_uri={redirect-uri}&scope=instagram_business_basic,instagram_business_content_publish
- 6After the user authorizes, exchange the authorization code for a short-lived token at https://graph.facebook.com/v25.0/oauth/access_token
- 7Exchange the short-lived token for a long-lived token: GET https://graph.instagram.com/access_token?grant_type=fb_exchange_token&client_id={app-id}&client_secret={app-secret}&fb_exchange_token={short-lived-token}
- 8Store the long-lived token securely and schedule a refresh job to call GET https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token={long-lived-token} every 50 days
1import requests2import os34APP_ID = os.environ['INSTAGRAM_APP_ID']5APP_SECRET = os.environ['INSTAGRAM_APP_SECRET']67def exchange_for_long_lived_token(short_lived_token):8 """Exchange a short-lived token for a 60-day long-lived token."""9 resp = requests.get(10 'https://graph.instagram.com/access_token',11 params={12 'grant_type': 'fb_exchange_token',13 'client_id': APP_ID,14 'client_secret': APP_SECRET,15 'fb_exchange_token': short_lived_token,16 }17 )18 resp.raise_for_status()19 data = resp.json()20 return data['access_token'] # valid for 60 days2122def refresh_long_lived_token(long_lived_token):23 """Refresh a long-lived token. Call this every 50 days."""24 resp = requests.get(25 'https://graph.instagram.com/refresh_access_token',26 params={27 'grant_type': 'ig_refresh_token',28 'access_token': long_lived_token,29 }30 )31 resp.raise_for_status()32 return resp.json()['access_token']3334def get_ig_user_id(access_token):35 """Get the Instagram user ID for the authenticated account."""36 resp = requests.get(37 'https://graph.instagram.com/v25.0/me',38 params={'fields': 'id,username', 'access_token': access_token}39 )40 resp.raise_for_status()41 return resp.json()['id']Security notes
- •Never hardcode access tokens or app secrets — store them in environment variables or a secrets manager like AWS Secrets Manager or HashiCorp Vault
- •Never expose your App Secret in frontend code or client-side applications
- •Long-lived tokens expire after 60 days; build an automated refresh job that runs every 50 days
- •Store tokens encrypted at rest in your database; treat them like passwords
- •Only request the minimum scopes your app needs — each additional scope requires separate App Review approval
- •Rotate tokens after any suspected exposure by generating a new OAuth authorization from the user
Key endpoints
/{ig-user-id}/mediaCreates a media container (an unpublished post). This is step 1 of the two-step publish flow. The container will process asynchronously and must reach FINISHED status before you can publish it.
| Parameter | Type | Required | Description |
|---|---|---|---|
image_url | string | required | Publicly accessible HTTPS URL of the image (JPEG only; PNG/WebP/GIF rejected). Must remain accessible until Instagram downloads it. |
caption | string | optional | Post caption. Can include hashtags and @mentions. Max 2,200 characters. |
access_token | string | required | Long-lived User Access Token with instagram_business_content_publish scope. |
Request
1{"image_url": "https://example.com/photo.jpg", "caption": "Beautiful sunset #nature", "access_token": "EAAB..."}Response
1{"id": "17889615691580904"}/{container-id}?fields=status_codePolls the processing status of a media container. You must wait for FINISHED status before calling media_publish. IN_PROGRESS means still processing; ERROR means the media was rejected.
| Parameter | Type | Required | Description |
|---|---|---|---|
fields | string | required | Must include 'status_code'. Possible values: IN_PROGRESS, FINISHED, EXPIRED, ERROR, PUBLISHED. |
access_token | string | required | Same token used to create the container. |
Response
1{"status_code": "FINISHED", "id": "17889615691580904"}/{ig-user-id}/media_publishPublishes a container that has FINISHED processing. This is step 2 of the two-step publish flow. The post goes live immediately after this call.
| Parameter | Type | Required | Description |
|---|---|---|---|
creation_id | string | required | The container ID returned from the /media endpoint. |
access_token | string | required | Long-lived User Access Token with instagram_business_content_publish scope. |
Request
1{"creation_id": "17889615691580904", "access_token": "EAAB..."}Response
1{"id": "17895695668004550"}/{ig-user-id}/content_publishing_limitReturns how many of your 100 daily API-published posts you have used in the current 24-hour window. Check this before scheduling to avoid hitting the cap.
| Parameter | Type | Required | Description |
|---|---|---|---|
fields | string | optional | Set to 'config,quota_usage' to get both the limit and current usage. |
access_token | string | required | Long-lived User Access Token. |
Response
1{"data": [{"quota_usage": 5, "config": {"quota_total": 100, "quota_duration": 86400}}]}Step-by-step automation
Check Your Publishing Quota Before Creating a Container
Why: You get 100 API-published posts per 24-hour window — hitting this limit means your publish call will fail after you've already uploaded the media.
Before creating a container, call the content_publishing_limit endpoint to see how many posts you've used in the current window. If quota_usage is at or near 100, queue the post for the next window. This prevents wasted API calls and failed publishes.
1curl -s "https://graph.instagram.com/v25.0/${IG_USER_ID}/content_publishing_limit?fields=config,quota_usage&access_token=${ACCESS_TOKEN}"Pro tip: Set up an alert when quota_usage hits 80 — that gives you time to delay low-priority posts instead of having urgent ones fail at the cap.
Expected result: JSON response with quota_usage (how many posts you've published via API in the last 24h) and quota_total (100). If usage is below 100, proceed to create the container.
Create the Media Container
Why: The container is the unpublished post object — creating it triggers Instagram's media validation and processing pipeline before anything goes live.
Send a POST request to /{ig-user-id}/media with your image_url and caption. The image must be a publicly accessible HTTPS URL (JPEG format only — PNG and WebP are rejected). Instagram will download and validate the image asynchronously. Store the returned container ID — you'll need it for the next two steps.
1curl -X POST \2 "https://graph.instagram.com/v25.0/${IG_USER_ID}/media" \3 -d "image_url=https://example.com/photo.jpg" \4 -d "caption=Beautiful sunset %23nature" \5 -d "access_token=${ACCESS_TOKEN}"Pro tip: Images must use aspect ratios between 4:5 and 1.91:1 for feed posts. Square (1:1) and portrait (4:5) generally perform better. Check the aspect ratio before upload to avoid error code 24.
Expected result: JSON response: {"id": "17889615691580904"} — the container ID. Save this; it expires in 24 hours if not published.
Poll Container Status Until FINISHED
Why: Instagram processes media asynchronously — publishing before FINISHED status will fail, but you need to proceed within 24 hours before the container expires.
Poll GET /{container-id}?fields=status_code in a loop with exponential backoff. Images usually finish in 2-10 seconds; videos take longer. Stop polling when status_code is FINISHED. If you get ERROR, the media was rejected (check aspect ratio, file format, or size). Never poll more frequently than every 5 seconds to preserve your 200 calls/hour quota.
1curl -s "https://graph.instagram.com/v25.0/${CONTAINER_ID}?fields=status_code&access_token=${ACCESS_TOKEN}"Pro tip: For scheduled posts, don't poll interactively — create the container 2-3 minutes before the target publish time, then poll. This avoids holding long connections open.
Expected result: After 2-60 seconds for images, status_code returns FINISHED and the function returns true. The container is now ready to publish.
Publish the Container
Why: The media_publish call is what actually makes the post live on Instagram — until this call, nothing is visible.
Once the container status is FINISHED, POST to /{ig-user-id}/media_publish with creation_id set to the container ID. The post goes live immediately. The API returns the published media ID, which you should store for later analytics queries. This call counts toward your 100 posts/24h cap.
1curl -X POST \2 "https://graph.instagram.com/v25.0/${IG_USER_ID}/media_publish" \3 -d "creation_id=${CONTAINER_ID}" \4 -d "access_token=${ACCESS_TOKEN}"Pro tip: Store the media_id in your database so you can query analytics (likes, impressions, reach) later via GET /{media-id}/insights.
Expected result: JSON response: {"id": "17895695668004550"} — the published media ID. The post is now live on Instagram.
Complete working code
This complete script implements a full Instagram post scheduler. It reads a scheduled post from your database (or queue), checks the publishing quota, creates the media container at the scheduled time, polls for completion, then publishes. Designed to run as a cron job every minute.
1#!/usr/bin/env python32"""Instagram Post Scheduler - runs as a cron job every minute."""3import os4import time5import logging6import requests7from datetime import datetime, timezone89logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')10log = logging.getLogger(__name__)1112IG_USER_ID = os.environ['IG_USER_ID']13ACCESS_TOKEN = os.environ['IG_ACCESS_TOKEN']14BASE = 'https://graph.instagram.com/v25.0'1516def api_get(path, params):17 params['access_token'] = ACCESS_TOKEN18 r = requests.get(f'{BASE}{path}', params=params, timeout=30)19 r.raise_for_status()20 return r.json()2122def api_post(path, data):23 data['access_token'] = ACCESS_TOKEN24 r = requests.post(f'{BASE}{path}', data=data, timeout=30)25 r.raise_for_status()26 return r.json()2728def check_quota():29 data = api_get(f'/{IG_USER_ID}/content_publishing_limit', {'fields': 'config,quota_usage'})30 item = data['data'][0]31 used = item['quota_usage']32 total = item['config']['quota_total']33 log.info(f'Quota: {used}/{total}')34 return used < total3536def create_container(image_url, caption):37 result = api_post(f'/{IG_USER_ID}/media', {'image_url': image_url, 'caption': caption})38 return result['id']3940def wait_for_ready(container_id, max_wait=120):41 deadline = time.time() + max_wait42 delay = 543 while time.time() < deadline:44 result = api_get(f'/{container_id}', {'fields': 'status_code'})45 status = result['status_code']46 log.info(f'Container {container_id}: {status}')47 if status == 'FINISHED':48 return True49 if status in ('ERROR', 'EXPIRED'):50 raise ValueError(f'Container failed with status: {status}')51 time.sleep(delay)52 delay = min(delay * 2, 30)53 raise TimeoutError('Container processing timed out')5455def publish(container_id):56 result = api_post(f'/{IG_USER_ID}/media_publish', {'creation_id': container_id})57 return result['id']5859def schedule_post(image_url, caption):60 """Full scheduling flow: check quota, create, wait, publish."""61 if not check_quota():62 log.warning('Daily quota exhausted. Skipping.')63 return None64 log.info(f'Creating container for: {image_url}')65 container_id = create_container(image_url, caption)66 log.info(f'Waiting for container {container_id} to finish processing...')67 wait_for_ready(container_id)68 media_id = publish(container_id)69 log.info(f'Post published successfully. Media ID: {media_id}')70 return media_id7172if __name__ == '__main__':73 # In production, fetch this from your scheduler DB74 media_id = schedule_post(75 image_url='https://example.com/photo.jpg',76 caption='Your caption here #hashtag'77 )78 print(f'Done: {media_id}')Error handling
{"error":{"code":24,"message":"(#24) Invalid aspect ratio","type":"OAuthException"}}The image aspect ratio is outside Instagram's allowed range of 4:5 to 1.91:1, or the image is not JPEG format (PNG/WebP/GIF are rejected).
Validate image dimensions before upload. Convert to JPEG using Pillow (Python) or sharp (Node.js). Acceptable aspect ratios: 4:5 portrait (0.8), 1:1 square, 1.91:1 landscape. Crop or pad images outside this range.
Do not retry — fix the image first.
{"error":{"code":9004,"message":"(#9004) Container has expired","type":"OAuthException"}}The container was created more than 24 hours ago and has expired without being published. Instagram expires unpublished containers after exactly 24 hours.
Create a new container. Design your scheduler to create containers only at publish time (within minutes of the target time), not days ahead. Store the image URL and caption in your own database instead.
Create a fresh container and go through the flow again.
{"error":{"code":17,"message":"User request limit reached","type":"OAuthException"}}You've exceeded the 200 API calls per hour limit for this user+app pair, or the 100 posts/24h publishing cap.
Check the X-Business-Use-Case-Usage response header to see your current usage percentage. Implement backoff. For the 100 posts/day cap, check content_publishing_limit before each publish and queue excess posts for the next window.
Exponential backoff starting at 60 seconds. The rolling window resets gradually over the 24-hour period.
{"error":{"code":190,"message":"Invalid OAuth access token - Cannot parse access token","type":"OAuthException"}}The access token is expired (long-lived tokens last 60 days), revoked, or malformed. The user may have revoked app permissions.
Refresh the long-lived token by calling GET https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token={token}. If the token is fully expired, the user must re-authorize through your OAuth flow.
Do not retry — refresh or re-authorize first.
{"error":{"code":10,"message":"Application does not have permission for this action","type":"OAuthException"}}The instagram_business_content_publish permission has not been approved via Meta App Review, or the app is in Development mode and the user is not a test user.
Submit the instagram_business_content_publish permission for App Review at developers.facebook.com. While in Development mode, add test users via App Roles > Test Users. Production use requires approved review (typically 2-4 weeks).
Do not retry — resolve permission issues first.
Rate Limits for Instagram Graph API
| Scope | Limit | Window |
|---|---|---|
| Per user (app+user pair) | 200 API calls | per hour (rolling) |
| Content publishing | 100 API-published posts | per 24-hour sliding window per account |
| App BUC (Business Use Case) | 4,800 × number_of_impressions calls | per 24 hours (minimum 10 impressions baseline) |
1import time2import requests34def api_request_with_retry(method, url, max_retries=5, **kwargs):5 for attempt in range(max_retries):6 resp = getattr(requests, method)(url, **kwargs, timeout=30)7 if resp.status_code == 429 or resp.status_code == 400:8 data = resp.json()9 code = data.get('error', {}).get('code')10 if code in (17, 4, 32): # Rate limit codes11 wait = min(60 * (2 ** attempt), 3600)12 print(f'Rate limited. Waiting {wait}s (attempt {attempt+1})')13 time.sleep(wait)14 continue15 return resp16 raise Exception('Max retries exceeded')- Check X-Business-Use-Case-Usage response header — it returns JSON with call_count, total_cputime, total_time as percentages; alert when any exceeds 80%
- Cache GET responses (media metadata, insights) for at least 1 hour to avoid redundant reads eating your 200 calls/hour quota
- Batch your analytics queries: fetch the top 10 most recent posts and their insights in two calls rather than one call per post
- Create containers only at publish time (not days ahead) since they expire in 24 hours — this lets you concentrate your API calls around actual publish windows
- Monitor the content_publishing_limit endpoint daily to proactively manage the 100 posts/day cap across multiple accounts
Security checklist
- Store access tokens in environment variables or a secrets manager — never in source code or version control
- Never expose your App Secret or long-lived tokens in client-side JavaScript or mobile app bundles
- Implement token refresh before the 60-day expiry; set up an automated job to refresh every 50 days
- Use HTTPS for all image URLs passed to the API — Instagram rejects HTTP image URLs
- Validate image content before uploading to avoid accidentally posting sensitive images
- Restrict your Meta App's valid OAuth redirect URIs to only your production and staging domains
- Implement an audit log of all API-published posts with timestamps, container IDs, and media IDs
- Rotate access tokens immediately if a server or secrets store is compromised
Automation use cases
Content Calendar Publisher
intermediateStore a week of posts in a database with target publish timestamps; a cron job runs every minute to check for due posts and triggers the container+publish flow.
Multi-Account Post Scheduler
advancedManage posting schedules for multiple Instagram Business accounts from a single service, each with its own access token and quota tracking.
Webhook-Triggered Cross-Post
intermediateListen for a webhook from your CMS (e.g., WordPress publish, Contentful entry publish) and automatically post the featured image and excerpt to Instagram.
E-Commerce Product Poster
advancedAutomatically post new Shopify or WooCommerce products to Instagram when they go live, with auto-generated captions from product descriptions.
No-code alternatives
Don't want to write code? These platforms can automate the same workflows visually.
Zapier
Free tier (limited); Starter from $19.99/monthZapier's Instagram for Business integration lets you create media posts via a point-and-click interface, triggered by events in other apps like Google Sheets, RSS feeds, or webhooks.
- + No code required
- + 500+ trigger sources
- + Built-in retry logic
- - $19.99+/month for multi-step Zaps
- - Less control over timing precision
- - Harder to handle bulk schedules
Make (formerly Integromat)
Free tier (1,000 ops/month); Core from $9/monthMake offers an Instagram module that wraps the Graph API with visual workflow building, supporting the full container+publish flow through drag-and-drop.
- + More affordable than Zapier
- + Visual data transformation
- + Webhook triggers with exact timing
- - Learning curve for complex flows
- - Limited free tier operations
- - Instagram module requires Meta App Review regardless
n8n
Free (self-hosted); Cloud from $20/monthSelf-hosted n8n provides an Instagram node with full Graph API access, letting you build scheduling workflows that run on your own infrastructure with no per-operation costs.
- + Free self-hosted
- + Full API access with custom HTTP nodes
- + Can run on a $5/month VPS
- - Requires server to self-host
- - Instagram node less feature-rich than direct API
- - Community support only on free tier
Best practices
- Never create media containers more than a few minutes before the target publish time — containers expire after 24 hours, and storing containers wastes quota
- Always validate image aspect ratio (4:5 to 1.91:1) and format (JPEG only) before calling the API to avoid wasted container creation calls
- Implement idempotency in your scheduler: store container_id and media_id per post so failed runs don't create duplicate containers
- Monitor the X-Business-Use-Case-Usage header on every response and pause non-urgent operations when any metric exceeds 80%
- Use the content_publishing_limit endpoint at the start of each scheduling run, not just when you expect to hit the cap
- Log both the container creation time and publish time for every post — this helps debug expiry issues and quota calculations
- For bulk posting (campaigns, product launches), spread posts across multiple hours rather than posting at once — the 100/day cap resets as a sliding window
Ask AI to help
Copy one of these prompts to get a personalized, working implementation.
I'm building an Instagram post scheduler using the Instagram Graph API v25.0. My scheduler creates a media container via POST /{ig-user-id}/media, polls the status_code until FINISHED, then calls /{ig-user-id}/media_publish. I'm getting error code 17 (rate limit) intermittently. My access token is long-lived (60 days). Can you help me implement proper exponential backoff that reads the X-Business-Use-Case-Usage header and pauses when call_count exceeds 80%? Also help me structure a queue-based scheduler using APScheduler in Python.
Build a React dashboard for managing an Instagram post schedule. It needs: a calendar view showing scheduled posts with their image thumbnails and captions, a form to add new posts with image URL upload, caption editor with character count, and scheduled datetime picker. Show each post's status (scheduled, container_created, published, failed) with color coding. Include a quota widget showing the 100 posts/24h limit with current usage. Connect to a Supabase backend that stores posts, and trigger the actual Instagram API calls via Supabase Edge Functions.
Frequently asked questions
Is the Instagram Graph API free?
Yes — the Instagram Graph API is free for Instagram Business and Creator accounts. There are no tiered pricing plans or per-request costs from Meta. The API requires a Facebook App with approved permissions (which requires a free Meta developer account), and the App Review process takes 2-4 weeks. Some third-party resellers (Phyllo, EnsembleData) charge $200-$1,400/month for higher throughput or simplified access, but direct API use is free.
Why does my scheduling fail when I create containers in advance?
Media containers expire after exactly 24 hours from creation. If you create a container on Monday for a post scheduled Thursday, it will expire before you try to publish it, resulting in error code 9004. The correct pattern is to store the image URL and caption in your own database, then create the container only at the scheduled publish time (a few minutes before).
What happens when I hit the rate limit?
You'll get HTTP 400 with error code 17 (User request limit reached) or code 4 (Application request limit). The 200 calls/hour limit is a rolling window, not a fixed hourly reset. Check the X-Business-Use-Case-Usage response header on every API call — it returns call_count, total_cputime, and total_time as percentages. When any exceeds 80%, start backing off. Implement exponential backoff starting at 60 seconds.
Can I schedule Instagram posts without Meta App Review?
In Development mode, you can test with up to 25 test users added to your App in the App Dashboard under Roles > Test Users. Those test accounts can authorize your app and receive API-published posts. For any real users or production use, you must submit the instagram_business_content_publish permission for App Review. The review typically takes 2-4 weeks and requires a screencast demonstrating the permission's use.
What image formats does the API accept?
Only JPEG. PNG, WebP, GIF, and HEIC are all rejected — you'll get error code 24 (aspect ratio/format error). Convert images to JPEG before passing the URL to the API. Also ensure the aspect ratio is between 4:5 (portrait, 0.8:1) and 1.91:1 (landscape). Images outside this range are rejected with the same error code.
How do I handle multiple Instagram accounts from one application?
Each Instagram Business account needs its own User Access Token, stored separately in your database. Your app can manage multiple accounts — each token is tied to an Instagram user's authorization of your Facebook App. Build a token management system that tracks each account's token, expiry date (60 days), and refreshes them independently. Each account has its own 200 calls/hour and 100 posts/day quotas.
Can RapidDev help build a custom Instagram scheduling system?
Yes — RapidDev has built Instagram automation tools for content teams, e-commerce brands, and agencies. We handle the full stack: Meta App setup and App Review submission, token management, the scheduling database and UI, and production monitoring. Book a free consultation at rapidevelopers.com.
Is there a native scheduling feature in the Instagram API?
No — the Instagram Graph API has no native scheduling endpoint. There is no 'scheduled_publish_time' parameter like Facebook's Pages API has. You must build your own scheduler using a cron job, task queue (Celery, BullMQ, etc.), or a database-backed job runner. The two-step container model gives you control over timing: create the container close to publish time, poll until ready, then publish at your target moment.
Need this automated?
Our team has built 600+ apps with API automations. We can build this for you.
Book a free consultation