THE VAULT / RUN IT LIKE A BUSINESS / BLUEPRINT + CODE
Instagram Auto-Poster Blueprint
I got tired of paying scheduler tax for what is, mechanically, two HTTP calls and a cron job. This blueprint builds your own Instagram auto-poster on the official API (v25.0, verified July 2026): account setup, tokens that don't die on day 60, a working publish script, and a prompt that writes a month of on-brand captions in one sitting. You walk away with 4 files and a $0/mo posting system.
LAST VERIFIED 2026-07-08 · FOUNDING MEMBER RESOURCE
NOTE
Use this page as a prompt reference. Every file below is built to be handed straight to an AI. Download it, drop it into Claude, ChatGPT, Cursor, or Lovable, and say "use this as my playbook." The prompts on this page are copy-paste ready.
A $99/mo scheduler is two HTTP calls in a trenchcoat
Here's the whole system: a queue.json file holding a month of posts, a 200-line Node script that publishes the next one via the official Instagram Platform API, and a cron job that runs it every morning. Plus a second tiny cron that refreshes your access token so the thing doesn't silently die on day 60.
That's the entire feature set of the paid tier of most social schedulers, minus the dashboard you'd stop opening after week two. Total running cost: $0/mo if you already have anywhere to host a JPEG publicly (a Vercel/Netlify static folder, an S3 bucket, even your existing website).
| Piece | What it does | Where you get it |
|---|---|---|
| post-to-instagram.mjs | Posts the next pending queue item: create container, poll, publish, mark done, alert on failure | Download in section 4 |
| token-refresh.mjs | Weekly cron that resets the 60-day token clock and screams before anything expires | Download in section 3 |
| caption-voice-prompt.md | One prompt session per month produces 30 captions in your voice, hooks and CTAs rotated | Download in section 5 |
| queue-example.json | The queue format: pending, done, failed, scheduled items | Download in section 6 |
NOTE
Scope of this blueprint: single-image feed posts to your own account, which is 90% of what people pay schedulers for. Reels, Stories, and carousels use the same container flow with different params; I cover the differences in section 4 so you can extend the script yourself.
WATCH OUT
Everything API-specific here was verified against Meta's docs in July 2026 against version v25.0. The old Instagram Basic Display API is fully dead (deprecated Dec 4, 2024, all requests error). If a tutorial mentions Basic Display, or uses the old truncated scope names like business_content_publish (deprecated Jan 27, 2025), it's outdated; close the tab.
Account setup: 20 minutes, zero App Review
There are two setup paths in 2026. For posting to your own account with no ads or shopping tags, the right one is the Instagram API with Instagram Login (host: graph.instagram.com). It needs no Facebook Page and no Facebook account. The other path (Facebook Login for Business, host graph.facebook.com) is only worth the extra ceremony if you need ads, product tagging, or hashtag search.
- 1
Convert your Instagram account to professional
In the Instagram app: Settings, Account type, switch to Business or Creator. Personal accounts cannot publish via API, full stop. No Facebook Page linkage needed for this path, and you don't need Meta Business Manager either.
- 2
Create a dedicated Meta app, type Business
Go to developers.facebook.com, Create App, choose app type Business. Make it a fresh app. Reusing an old Facebook-login app you had lying around is a common source of weird failures.
- 3
Add the Instagram product
In the App Dashboard, add the Instagram product, then pick API setup with Instagram login, then step 3, Set up Instagram business login, and configure your redirect URI (a localhost URL is fine for a personal tool).
- 4
Grab the Instagram App ID and secret (not the Meta ones)
The Instagram product gives you a separate Instagram App ID and secret, distinct from the Meta app ID at the top of the dashboard. Use the Instagram ones for OAuth. Using the Meta app ID here is failure gallery entry number 2.
- 5
Add your IG account as an Instagram tester
App Dashboard, App roles, add your Instagram account as an Instagram tester. Then, and half the failed setups I've seen die right here, accept the invite inside the Instagram app (Settings, Website permissions / Apps and websites, Tester invites). Skip the accept step and every publish call fails in dev mode.
- 6
Run the OAuth flow once
Send yourself to
https://www.instagram.com/oauth/authorize?client_id=IG_APP_ID&redirect_uri=YOUR_URI&response_type=code&scope=instagram_business_basic,instagram_business_content_publish. Log in, approve, and copy thecodefrom the redirect. You only ever do this once (tokens handle the rest, section 3).
PAYOFF
No App Review needed. In Development mode with Standard Access, publishing works for any account with an app role (admin, developer, or tester). You're one business posting to your own account, so Standard Access is all you'll ever need. App Review, screencasts, privacy policies, Business Verification, the 2 to 6 week wait: all of that is only for apps serving other people's accounts.
The scope names matter. The pre-2025 names (business_content_publish and friends) were deprecated Jan 27, 2025. The current set, exactly as spelled:
| Scope | You need it? |
|---|---|
instagram_business_basic | Yes. Profile and media read access, base scope |
instagram_business_content_publish | Yes. This is the one that lets you publish |
instagram_business_manage_comments | No, unless you later want to auto-reply to comments |
instagram_business_manage_messages | No. Skip it, over-requesting scopes buys you nothing here |
When you'd need the Facebook Login path instead
The Instagram API with Facebook Login for Business requires your IG account to be linked to a Facebook Page and runs against graph.facebook.com. You only need it for:
| Capability | Instagram Login path | Facebook Login path |
|---|---|---|
| Publish feed posts, Reels, Stories, carousels | Yes | Yes |
| Comments, messaging, insights | Yes (insights since Jan 21, 2025) | Yes |
| Ads / boosting posts | No | Yes |
| Product and shopping tagging | No | Yes |
| Hashtag search | No | Yes |
| Resumable video upload (rupload.facebook.com) | No (URL fetch only) | Yes |
| Requires a Facebook Page | No | Yes |
It also uses different scope names (instagram_basic, instagram_content_publish, pages_show_list, business_management) and standard Facebook long-lived tokens. Everything else in this blueprint stays on the Instagram Login path. Don't mix the two hosts; a token from one path against the other's host throws "Invalid OAuth access token" and the error message won't tell you why.
Tokens: the 60-day clock that kills every DIY poster
Three token stages, two API calls, one cron. The short-lived token you get from OAuth lasts about 1 hour. You exchange it for a long-lived token that lasts 60 days. Long-lived tokens do not auto-renew: forget the refresh and your poster stops working on day 60 with zero warning, which is exactly how most homemade posters die.
Step 1: code to short-lived token
Step 2: short-lived to long-lived (60 days)
Step 3: the refresh cron (the part everyone skips)
Refresh is allowed any time the token is older than 24 hours and not yet expired, and every refresh returns a fresh 60-day token. So a weekly cron keeps you permanently 53+ days away from expiry. The exact call:
The download below wraps this in a stateful script: it keeps the current token in a token.json file (chmod 600), swaps in the new one on every run, skips politely if the token is under 24h old, and fires a webhook alert if a refresh fails while you still have runway to fix it. The posting script reads its token from the same token.json, so a refresh instantly reaches the poster with no redeploy.
WATCH OUT
A dead token cannot be refreshed. If you let it expire, the refresh endpoint won't revive it; you redo the full OAuth flow from section 2 and re-seed. This is why the script alerts on refresh failure instead of failing quietly: the alert arrives while the old token still has days left.
Publishing: containers, polling, and the JPEG rule
Instagram publishing is a two-step container flow. You never upload a file. You hand Meta a public URL, Meta's servers fetch it into a "container", you poll until the container is ready, then you publish it. Three calls total:
- 1
Create the container
POST https://graph.instagram.com/v25.0/<IG_USER_ID>/mediawithimage_urlandcaption. For a plain image you omitmedia_type. Returns a container ID. Optional since Mar 2025:alt_text(use it, it's free accessibility and the script supports it). - 2
Poll status_code, once per minute, max 5 times
GET /<CONTAINER_ID>?fields=status_codeuntil it returnsFINISHED. That polling cadence (1/min, max 5 min) is Meta's own guidance, not mine. Images usually finish in seconds; even so, publishing instantly after creation is a classic failure, so the script waits 10s before the first poll. Other statuses:IN_PROGRESS,ERROR,EXPIRED(containers die after 24 hours unpublished),PUBLISHED. - 3
Publish
POST /<IG_USER_ID>/media_publish?creation_id=<CONTAINER_ID>. Returns the media ID of the live post. Publishing a video container before it hitsFINISHEDthrows error 9007, "media not ready".
WATCH OUT
Images are JPEG only. PNG and WebP get rejected, and so do exotic JPEG variants (MPO/JPS). Feed aspect ratio must sit between 4:5 and 1.91:1, width 320 to 1440px. Outside that window you get error 36003 or 2207009. Export everything as a 1080x1350 (4:5) or 1080x1080 JPEG and you never think about this again.
WATCH OUT
The URL must be publicly fetchable by a robot. Meta fetches image_url server-side at container creation. CDN auth, signed URLs that expire, redirects, or bot-blocking (Cloudflare "verify you are human" on image paths) make the container silently go to ERROR with no useful message. The script pre-flights this by fetching your URL first and checking for HTTP 200 plus Content-Type: image/jpeg.
Here's the core of the flow in Node (the full script with queue handling, quota checks, and alerting is the download below):
Extending the script: Reels, Stories, carousels
Same three-call flow, different container params. All feed video is Reels now; there's no separate "video post" type.
| Type | Container params | Gotchas |
|---|---|---|
| Reel | media_type=REELS + video_url | MP4/MOV, H.264/HEVC, progressive scan, closed GOP, 23 to 60 fps, moov atom at front, no edit lists. 9:16 recommended; only 5 to 90s at 9:16 is eligible for the Reels tab. cover_url or thumb_offset for the cover. Up to ~15 min via the standard flow on current versions |
| Story | media_type=STORIES + image_url or video_url | Same hosting rules as feed |
| Carousel | Up to 10 child containers with is_carousel_item=true, then a parent with media_type=CAROUSEL and children=[ids], publish the parent | Every image gets cropped to the FIRST image's aspect ratio (default 1:1). Counts as 1 post against the daily quota, but carousels have their own 50/24h cap |
Video containers genuinely need the polling loop; a 60-second Reel can take a couple of minutes to process. That's what the 1/min-max-5 cadence is designed for. Newer optional params if you want them: is_ai_generated=true (AI disclosure label, June 2026) and is_paid_partnership with branded_content_sponsor_ids, max 2 sponsors (Apr 2026).
- [ ]Image is a real JPEG (not a renamed PNG), 4:5 to 1.91:1, width 320 to 1440px
- [ ]
image_urlreturns HTTP 200 withContent-Type: image/jpegfrom a plain curl with no cookies - [ ]Caption is under 2,200 characters with 30 or fewer hashtags
- [ ]Token is under 60 days old and came from
graph.instagram.com, and the request goes tograph.instagram.com(no host mixing) - [ ]You waited for
FINISHED(or at least 10s for images) beforemedia_publish - [ ]Test with
node post-to-instagram.mjs --dry-runbefore trusting the cron
The brand voice prompt: a month of captions in 90 minutes
The script solves distribution. This solves the other half: never facing a blank caption box again. Once a month I run one prompt session that ingests my real past captions plus my tone doc and returns 30 captions as JSON, hooks rotated across 5 types, CTAs rotated on a 4-post cycle, every fact traceable to notes I supplied. The output pastes straight into queue.json.
Two things make it work, and neither is the prompt: 10 to 20 real past captions (models learn voice from examples, not from adjectives like "witty but professional") and a raw material dump of your actual month (wins, numbers, launches, lessons). The prompt is hard-banned from inventing facts, so garbage in genuinely means nothing out.
My post-generation routine: skim all 30 in one pass, kill the bottom 5 (they're always there), ask for replacements in the same chat, fact-check anything flagged, map captions to images, done. Next month, feed this month's best performers back into the PAST CAPTIONS block. The prompt compounds because the voice source compounds.
The content queue: one JSON file is the whole product
The queue is a single queue.json sitting next to the script. Each item has a status (pending, done, failed), the public JPEG URL, the caption, and optionally a not_before date to hold a post for a launch day. Every cron run: take the first pending item, post it, write done plus the live media ID back into the file. Failures get failed plus the error string, and the next run moves on to the next item, so one bad image never stalls the whole month.
The script also handles the two alerts that matter: failure (webhook fires with the item ID and the exact error) and low queue (3 or fewer pending posts left, meaning it's caption-batching weekend). Point ALERT_WEBHOOK at any Slack or Discord incoming webhook; the payload works for both.
- 1
Monthly: batch the content (90 min)
Run the brand voice prompt with your month's raw material. Review, replace the weak 5, fact-check flags.
- 2
Monthly: prep the images (60 min)
Export each image as a 1080x1350 JPEG named by date, upload the folder to your public host. Screenshot-of-a-tweet style, Figma template, or a Canva batch all work; the API doesn't care as long as it's a public JPEG in ratio.
- 3
Monthly: fill the queue (10 min)
Paste the prompt's JSON output into
queue.json, add the matchingimage_urlper item, runnode post-to-instagram.mjs --dry-runto validate the first item. - 4
Daily: do nothing
Cron posts at 09:30. You get pinged only when something fails or the queue drops to 3. That's the entire operating cost of the system.
NOTE
Where to run the crons: any always-on box. A $4/mo VPS, a Raspberry Pi, GitHub Actions on a schedule (commit the queue back to the repo), or your homelab. One catch with Actions: the refresh script has to persist the rotated token between runs, and token.json must never be committed. Either update a repo secret from the workflow via the GitHub API, or skip the ceremony and use the VPS; a box with a disk is the simple version.
Rate limits and the failure gallery
The official docs (as of July 2026) allow 100 API-published posts per rolling 24 hours per account, raised from the old 25 and then 50. In practice, lower-trust accounts still hit the old ceilings, and error messages still quote 25 or 50. So don't assume: read your live quota.
| Limit | Number (as of July 2026) | Notes |
|---|---|---|
| API-published posts | 100 / rolling 24h | Carousels count as 1; hitting it throws error code 9, subcode 2207042 |
| Carousels specifically | 50 / 24h | Separate cap on top of the 100 |
| Caption | 2,200 chars, 30 hashtags | Script validates before posting |
| Container lifetime | 24 hours unpublished | Then status goes EXPIRED |
| General API calls | 4800 x impressions/1000 per 24h, min ~200/hr | A daily poster uses maybe 10 calls/day; you will never see this limit |
For a 1/day poster, none of these limits are real constraints. They matter the day you decide to build this for clients, at which point reread the App Review paragraph in section 2 first.
The failure gallery: every way this breaks, with fixes
| Symptom | Cause | Fix |
|---|---|---|
| "Invalid OAuth access token" | Host mixing: an Instagram-Login token sent to graph.facebook.com, or vice versa | Instagram Login path talks ONLY to graph.instagram.com (plus api.instagram.com for the initial code exchange) |
| OAuth flow rejects your client_id | You used the Meta app ID instead of the Instagram-product app ID | Copy the ID from the Instagram product page in the dashboard, not the app header |
| Publish fails with "media not ready" (error 9007) | You called media_publish right after container creation | Poll status_code until FINISHED; even images deserve a ~10s wait |
| Error 36003 / 2207009 | Non-JPEG image, or aspect ratio outside 4:5 to 1.91:1 | Export 1080x1350 or 1080x1080 JPEG, always |
| Container goes to ERROR with no message | Media URL behind CDN auth, signed-URL expiry, redirects, or bot blocking | Curl the URL from a clean machine: must be 200, image/jpeg, no auth. The script pre-flights this |
| Everything worked for 2 months, now every call 401s | Token hit day 60 with no refresh | That's why token-refresh.mjs runs weekly. Recovery: full OAuth redo |
| Publishing fails in dev mode for your own account | IG account was never added as an Instagram tester, or the invite was never accepted in the IG app | App roles: add tester, then accept in Instagram settings. Both steps |
| Error code 9 | Daily publish quota exhausted | Check content_publishing_limit; wait for the window to roll |
| App Review rejection (if you ever go that route) | Over-requested scopes, screencast missing the full flow, or no data-deletion URL | Request only the 2 scopes you need; budget 2 to 6 weeks |
| You want shopping tags or filters via API | They don't exist on this path | Shopping tags need the Facebook Login path; filters aren't in the API at all |
The math: $0/mo vs the scheduler subscription
| Option | Price (as of July 2026, verify current) | 3-year cost |
|---|---|---|
| This blueprint | $0/mo (runs on any box you already have; a dedicated $4/mo VPS if you have nothing) | $0 to $144 |
| Buffer (paid tiers) | Roughly $5 to $6 per channel/mo on the entry paid plan | ~$180 to $216 for one channel |
| Later | Entry plans have started around $17 to $25/mo | ~$600 to $900 |
| Agency-tier schedulers (Sprout, Hootsuite class) | $99 to $249+/mo | $3,600 to $9,000+ |
To be fair to the tools: you're not rebuilding their analytics dashboards, team approval flows, or multi-network posting. You're rebuilding the one feature you actually used, scheduled Instagram publishing, which Meta gives away through the official API. If you manage 10 client accounts with a team, pay for the tool. If you're one operator posting your own content, the tool is a $200 to $1,000/yr subscription for a cron job.
Setup time, honestly: about 2 hours end to end (20 min account chain, 20 min tokens, 30 min deploying and dry-running the scripts, the rest reading). After that the recurring cost is one 90-minute caption batch per month, which you'd spend writing captions anyway, just spread across 30 anxious evenings instead.
PAYOFF
Ship checklist: professional account converted, Business app with Instagram product, tester invite accepted, long-lived token in token.json, both crons installed, dry-run green, queue filled with 30 posts. From here your Instagram runs itself and pings you maybe once a month. Here's the move: batch your first month of captions this weekend.
Get the next drop
Every new Vault system ships to the list first. Twice a week, free.