robots on payroll.

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).

PieceWhat it doesWhere you get it
post-to-instagram.mjsPosts the next pending queue item: create container, poll, publish, mark done, alert on failureDownload in section 4
token-refresh.mjsWeekly cron that resets the 60-day token clock and screams before anything expiresDownload in section 3
caption-voice-prompt.mdOne prompt session per month produces 30 captions in your voice, hooks and CTAs rotatedDownload in section 5
queue-example.jsonThe queue format: pending, done, failed, scheduled itemsDownload 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. 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. 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. 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. 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. 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. 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 the code from 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:

ScopeYou need it?
instagram_business_basicYes. Profile and media read access, base scope
instagram_business_content_publishYes. This is the one that lets you publish
instagram_business_manage_commentsNo, unless you later want to auto-reply to comments
instagram_business_manage_messagesNo. 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:

CapabilityInstagram Login pathFacebook Login path
Publish feed posts, Reels, Stories, carouselsYesYes
Comments, messaging, insightsYes (insights since Jan 21, 2025)Yes
Ads / boosting postsNoYes
Product and shopping taggingNoYes
Hashtag searchNoYes
Resumable video upload (rupload.facebook.com)No (URL fetch only)Yes
Requires a Facebook PageNoYes

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

01-short-lived.sh
curl -X POST https://api.instagram.com/oauth/access_token \
  -F client_id=IG_APP_ID_HERE \
  -F client_secret=IG_APP_SECRET_HERE \
  -F grant_type=authorization_code \
  -F redirect_uri=YOUR_REDIRECT_URI \
  -F code=CODE_FROM_OAUTH_REDIRECT
# Returns: { "access_token": "...", "user_id": ... }  (valid ~1 hour)

Step 2: short-lived to long-lived (60 days)

02-long-lived.sh
curl "https://graph.instagram.com/access_token?grant_type=ig_exchange_token&client_secret=IG_APP_SECRET_HERE&access_token=SHORT_LIVED_TOKEN"
# Returns: { "access_token": "...", "token_type": "bearer", "expires_in": 5183944 }
# expires_in is just under 60 days. This is the token your scripts use.

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:

03-refresh.sh
curl "https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=CURRENT_LONG_LIVED_TOKEN"
# Returns: { "access_token": "...", "token_type": "bearer", "expires_in": 5184000 }
# Store the NEW token. The refresh is stateful: next refresh uses this one.

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.

crontab (token refresh)
# Every Monday 08:00. Weekly is 8x more often than needed, which is the point.
0 8 * * 1 cd /home/you/ig-poster && /usr/bin/env node token-refresh.mjs >> token.log 2>&1

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. 1

    Create the container

    POST https://graph.instagram.com/v25.0/<IG_USER_ID>/media with image_url and caption. For a plain image you omit media_type. Returns a container ID. Optional since Mar 2025: alt_text (use it, it's free accessibility and the script supports it).

  2. 2

    Poll status_code, once per minute, max 5 times

    GET /<CONTAINER_ID>?fields=status_code until it returns FINISHED. 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. 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 hits FINISHED throws 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):

container-flow-core.mjs
const API = "https://graph.instagram.com/v25.0";

// 1. create container
const container = await post(`${API}/${IG_USER_ID}/media`, {
  image_url: "https://yournewsletter.com/ig/today.jpg", // public JPEG, 4:5 to 1.91:1
  caption: "caption text, max 2200 chars, max 30 hashtags",
  alt_text: "what the image shows",
  access_token: TOKEN,
});

// 2. poll once per minute, max 5 (Meta's guidance), after a 10s head start
await sleep(10_000);
for (let i = 0; i < 5; i++) {
  const { status_code } = await get(`${API}/${container.id}`, {
    fields: "status_code", access_token: TOKEN,
  });
  if (status_code === "FINISHED") break;
  if (status_code === "ERROR" || status_code === "EXPIRED")
    throw new Error(`container ${status_code}: check URL, JPEG, aspect ratio`);
  await sleep(60_000);
}

// 3. publish
const media = await post(`${API}/${IG_USER_ID}/media_publish`, {
  creation_id: container.id, access_token: TOKEN,
});
console.log("live:", media.id);
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.

TypeContainer paramsGotchas
Reelmedia_type=REELS + video_urlMP4/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
Storymedia_type=STORIES + image_url or video_urlSame hosting rules as feed
CarouselUp to 10 child containers with is_carousel_item=true, then a parent with media_type=CAROUSEL and children=[ids], publish the parentEvery 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_url returns HTTP 200 with Content-Type: image/jpeg from 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 to graph.instagram.com (no host mixing)
  • [ ]You waited for FINISHED (or at least 10s for images) before media_publish
  • [ ]Test with node post-to-instagram.mjs --dry-run before 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.

PROMPT: The brand voice caption prompt
You are writing a month of Instagram captions for my account. You will match MY voice, not a generic social media voice. My voice is defined by the examples below; the tone doc is a tiebreaker, the examples win any conflict.

## MY ACCOUNT
- Handle and niche: [HANDLE + ONE-LINE NICHE, e.g. "@buildlog, I document building SaaS products with AI coding agents"]
- Audience: [WHO FOLLOWS YOU AND WHY, e.g. "solo devs and indie hackers, 25-40, want to ship faster"]
- What I'm promoting this month: [PRODUCT/OFFER/NEWSLETTER + LINK LOCATION, e.g. "my newsletter, link in bio"]
- Posting cadence: [N] posts over [N] days

## MY PAST CAPTIONS (the voice source, verbatim, do not clean these up)
[PASTE 10-20 REAL CAPTIONS, EACH SEPARATED BY "---". Include your best performers AND typical ones. More is better.]

## MY TONE DOC
[PASTE YOUR TONE/STYLE DOC, OR 5 BULLETS: identity, audience, words you use, words you ban, the one feeling every post should leave]

## THIS MONTH'S CONTENT PILLARS
[LIST 3-5 PILLARS WITH ROUGH WEIGHTS, e.g.:
- 40% build-in-public updates (what I shipped, what broke, what it cost)
- 25% tactical how-tos my audience can use today
- 20% opinions/hot takes on my niche
- 15% personal/behind-the-scenes]

## RAW MATERIAL (turn these into posts)
[PASTE YOUR NOTES: wins, numbers, screenshots you plan to post, lessons, launches, anything. Bullet points are fine. The model must NOT invent facts, numbers, or events not listed here.]

## HARD RULES
1. Every caption starts with a HOOK as line 1: a claim, a number, a tension, or a question. Never a soft warm-up like "So excited to share". Line 1 must survive being the only line anyone reads (Instagram truncates after ~125 characters).
2. Rotate hook types across the month, no two consecutive posts with the same type: [NUMBER hook / CONTRARIAN hook / STORY-OPENER hook / QUESTION hook / MISTAKE-CONFESSION hook].
3. Rotate CTAs on a 4-post cycle and label each: (a) comment prompt with a specific question, (b) save/share prompt tied to the post's utility, (c) link-in-bio push for [OFFER], (d) no CTA at all, just end strong. Never two link pushes in a row.
4. Caption length: vary between 300 and 1,500 characters. Hard cap 2,200 characters including hashtags (Instagram's limit).
5. Hashtags: exactly [N, suggest 3-8] per post, niche-specific, on the last line. Never more than 30 (API rejects it). No banned or spammy tags.
6. Every fact, number, and event must come from RAW MATERIAL above. If a pillar has no raw material left, write an evergreen post for that pillar and flag it "EVERGREEN" so I can fact-check it.
7. Match my sentence length, punctuation habits, emoji usage (or absence), and line-break style from the past captions. If my captions never use emojis, use zero.
8. No engagement-bait cliches: no "double tap if", no "tag someone who", no "you won't believe".

## OUTPUT FORMAT
Return ONLY a JSON array, no prose before or after. One object per post:

[
  {
    "day": 1,
    "pillar": "build-in-public",
    "hook_type": "NUMBER",
    "cta_type": "comment",
    "caption": "full caption text with line breaks as \n, hashtags on last line",
    "image_direction": "one sentence describing the JPEG to pair with this (I shoot/design these separately)",
    "alt_text": "one-sentence accessibility description of the intended image",
    "flag": null
  }
]

"flag" is null, or "EVERGREEN" (rule 6), or "NEEDS_FACT_CHECK" if you had to interpret ambiguous raw material.

Before the JSON is generated, silently verify: hook rotation valid, CTA cycle valid, all facts traceable to raw material, zero captions over 2,200 chars. Fix violations, then output.

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.

queue.json (shape)
{
  "account": "@yourhandle",
  "posts": [
    {
      "id": "2026-07-09-build-update",
      "status": "pending",
      "image_url": "https://yournewsletter.com/ig/2026-07-09.jpg",
      "caption": "hook line first...\n\nbody...\n\n#tags #on #last #line",
      "alt_text": "what the image shows",
      "pillar": "build-in-public",
      "cta_type": "comment",
      "not_before": "2026-07-09T00:00:00Z"
    }
  ]
}

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.

crontab (the full system)
# Post daily at 09:30 (pick your audience's active hour)
30 9 * * * cd /home/you/ig-poster && /usr/bin/env node post-to-instagram.mjs >> poster.log 2>&1

# Refresh the token every Monday 08:00
0 8 * * 1 cd /home/you/ig-poster && /usr/bin/env node token-refresh.mjs >> token.log 2>&1
  1. 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. 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. 3

    Monthly: fill the queue (10 min)

    Paste the prompt's JSON output into queue.json, add the matching image_url per item, run node post-to-instagram.mjs --dry-run to validate the first item.

  4. 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.

check-quota.sh
curl "https://graph.instagram.com/v25.0/IG_USER_ID_HERE/content_publishing_limit?fields=quota_usage,config&access_token=TOKEN_HERE"
# quota_usage = posts published in the current 24h window
# config.quota_total = your actual ceiling (the script checks this before every post)
LimitNumber (as of July 2026)Notes
API-published posts100 / rolling 24hCarousels count as 1; hitting it throws error code 9, subcode 2207042
Carousels specifically50 / 24hSeparate cap on top of the 100
Caption2,200 chars, 30 hashtagsScript validates before posting
Container lifetime24 hours unpublishedThen status goes EXPIRED
General API calls4800 x impressions/1000 per 24h, min ~200/hrA 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
SymptomCauseFix
"Invalid OAuth access token"Host mixing: an Instagram-Login token sent to graph.facebook.com, or vice versaInstagram Login path talks ONLY to graph.instagram.com (plus api.instagram.com for the initial code exchange)
OAuth flow rejects your client_idYou used the Meta app ID instead of the Instagram-product app IDCopy 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 creationPoll status_code until FINISHED; even images deserve a ~10s wait
Error 36003 / 2207009Non-JPEG image, or aspect ratio outside 4:5 to 1.91:1Export 1080x1350 or 1080x1080 JPEG, always
Container goes to ERROR with no messageMedia URL behind CDN auth, signed-URL expiry, redirects, or bot blockingCurl 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 401sToken hit day 60 with no refreshThat's why token-refresh.mjs runs weekly. Recovery: full OAuth redo
Publishing fails in dev mode for your own accountIG account was never added as an Instagram tester, or the invite was never accepted in the IG appApp roles: add tester, then accept in Instagram settings. Both steps
Error code 9Daily publish quota exhaustedCheck 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 URLRequest only the 2 scopes you need; budget 2 to 6 weeks
You want shopping tags or filters via APIThey don't exist on this pathShopping tags need the Facebook Login path; filters aren't in the API at all

The math: $0/mo vs the scheduler subscription

OptionPrice (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
LaterEntry 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.