Back to Tutorials
Multi-Account Instagram Publishing With the Graph API
Tutorial Intermediate 45 min 10 min read

Multi-Account Instagram Publishing With the Graph API

Publish to several Instagram accounts from your own code: one System User token, no App Review, no third-party scheduler. Build it in five steps — past the auth-path trap, through the manual gates, to a publish call that won't quietly double-post.

NC

Nino Chavez

Product Architect

Prerequisites

  • A Meta Business Manager with the Instagram accounts you own added to it
  • Each Instagram account set to Business type (not Creator) and linked to a Facebook Page
  • Node.js, and a public bucket (R2/S3/CDN) to host media the API can fetch
  • Read the companion post: Where the API Stops

What you'll build

  • Pick the right Meta auth model for multi-account publishing (System User token vs Instagram Login)
  • Get Standard Access for owned accounts and skip the App Review queue
  • Publish a reel through the container → poll → publish flow
  • Make publishing idempotent so a silent API failure never double-posts

Companion Post: Where the API Stops

Publish to Many Accounts From Your Own Code

Most write-ups for posting to Instagram from code assume one account and a token that expires every 60 days. The moment you need to publish to several accounts on a schedule, that path falls apart — and the docs don’t tell you why, because the answer lives on a different page than the one you started on.

This is the build for the multi-account case: one Meta app, one non-expiring token, no App Review, no SaaS scheduler in the middle. The reasoning behind building it instead of renting it is the companion post, Where the API Stops. This is the runbook.

Five exercises. The first three are one-time setup you do by hand. The last two are the code you’ll reuse for every post — including the idempotency pattern that’s the most transferable thing here.


1

Pick the Right Auth Model and Claim Your Accounts

15 min

Why this matters

Meta exposes two completely different ways to authenticate, and they share enough vocabulary that you can build the wrong one without noticing — then tear it down when you add a second account.

  • Instagram Login — host graph.instagram.com, per-account tokens you refresh every ~60 days. Built for a single account logging into your app.
  • Facebook Login / Business — host graph.facebook.com, a System User token issued from a Business Manager that owns the accounts. One token reaches every account in the portfolio, and it doesn’t expire.

If you’re publishing to multiple owned accounts on a schedule, you want the second one. Pick it first and skip the rework.

There’s a bonus hiding in the Business path: the publish permission instagram_content_publish normally needs Advanced Access (Meta’s App Review queue — weeks). For accounts your Business Manager owns or is role-connected to, Standard Access is automatic. No review. That single fact is what makes this viable on day one.

The structure

These first steps claim assets and prove ownership, so Meta gates them behind your own login — you genuinely cannot script them. In business.facebook.com → your Business → Business settings → Accounts → Instagram accounts:

  1. Add each Instagram account. Log into each when prompted — that’s the ownership check. If an account only shows under a Facebook Page, use the Page’s Connect Instagram.
  2. Confirm each is a Business account (Instagram app → Settings → Account type). Creator will not publish via the API.
  3. Link each Instagram account to its Facebook Page — you need this to resolve the numeric account ID later.

Your turn

Claim every account you intend to publish to, and switch any Creator accounts to Business now. Doing this up front avoids a confusing mid-build rejection where the API simply refuses to publish with no obvious reason.

Checkpoint

Verify the foundation. In Business settings → Accounts → Instagram accounts, every account you’ll publish to is listed, each shows Business as its type, and each is linked to a Page. If any account is missing or still Creator, the later steps will fail in ways the error messages won’t explain.


2

Create the App and a System User Token

10 min

Why this matters

The System User token is the whole point of the Business path: one non-expiring credential that reaches every account you own. You generate it once.

The structure

  1. Create the app. developers.facebook.com → Create App → Instagram product, attached to your business portfolio. (Abandon any half-built Instagram-Login app — wrong model.)
  2. Create a System User. Business settings → Users → System users → Add. Give it the Admin role.
  3. Assign assets to the System User: the app, all the Instagram accounts, and their Pages, each at Full control.
  4. Generate the token for that System User with the scopes below, choosing no expiration. Copy it once — you won’t see it again.
System User token scopes
instagram_basic
instagram_content_publish
pages_show_list
pages_read_engagement
business_management

Your turn

Generate the token, then store it in a secret manager — never in the repo. A Worker secret, 1Password, Doppler, Vault; any of them. Everything downstream reads it from an environment variable:

export IG_ACCESS_TOKEN="<your-system-user-token>"
Checkpoint

Prove the token works. This call lists every Page you manage and its linked Instagram business account. If it returns your accounts, the token and asset assignments are correct:

curl -s "https://graph.facebook.com/v25.0/me/accounts\
?fields=name,id,instagram_business_account{id,username}\
&access_token=$IG_ACCESS_TOKEN" | jq

An empty list means the System User wasn’t assigned the accounts (or the Pages aren’t linked) — go back to step 3.


3

Resolve Account IDs Into a Registry

5 min

Why this matters

The publish endpoints address each account by a numeric ig_user_id, not its handle. You’ll pass that ID on every call, so map your own slugs to the IDs once and keep them somewhere your code reads.

Your turn

Take each instagram_business_account.id from the Checkpoint call above and drop it into a small registry keyed by a slug you choose:

accounts.json
{
  "brand":  { "handle": "your.brand",  "ig_user_id": "<ig-user-id>" },
  "studio": { "handle": "your.studio", "ig_user_id": "<ig-user-id>" }
}
Checkpoint

Sanity-check an ID. curl -s "https://graph.facebook.com/v25.0/<ig-user-id>?fields=username&access_token=$IG_ACCESS_TOKEN" should return the handle you expect for that slug.


4

Publish a Reel

10 min

Why this matters

Publishing a reel is three calls: build a container, wait for the video to transcode, publish the container. The video_url has to be a publicly reachable URL — Meta’s servers fetch it, so local file paths don’t work. Upload to your bucket first and pass that URL.

The structure

publish.mjs — the three-call flow
const GRAPH = 'https://graph.facebook.com/v25.0'

async function api(token, path, params, method = 'POST') {
  const body = new URLSearchParams({ ...params, access_token: token })
  const url = `${GRAPH}/${path}`
  const res = method === 'GET'
    ? await fetch(`${url}?${body}`)
    : await fetch(url, { method, body })
  const json = await res.json()
  if (!res.ok || json.error) throw new Error(json.error?.message || JSON.stringify(json))
  return json
}

// 1. Build the container. Returns a creation_id.
async function buildReel(token, igUserId, { videoUrl, caption }) {
  const { id } = await api(token, `${igUserId}/media`, {
    media_type: 'REELS', video_url: videoUrl, caption, share_to_feed: 'true',
  })
  return id
}

// 2. Poll until the video finishes transcoding (images skip this step).
async function pollStatus(token, containerId, maxMs = 75000) {
  const deadline = Date.now() + maxMs
  while (Date.now() < deadline) {
    const { status_code } = await api(token, containerId, { fields: 'status_code' }, 'GET')
    if (status_code === 'FINISHED') return 'FINISHED'
    if (status_code === 'ERROR' || status_code === 'EXPIRED') throw new Error(`container ${status_code}`)
    await new Promise((r) => setTimeout(r, 5000))
  }
  return 'IN_PROGRESS' // still transcoding — resume later with the SAME container id
}

To tag another account or send a co-author (Collab) invite, add them to the container call. Tagging is documented; collaborators is community-confirmed but absent from Meta’s main publishing doc — so treat a rejection as a real, surfaced error, not something to swallow:

const extras = {
  user_tags: JSON.stringify([{ username: 'your.studio' }]),
  collaborators: JSON.stringify(['your.studio']),
}

Your turn

Wire buildReel → pollStatus → media_publish against one account and one hosted video. Publish a single test reel before you automate anything.

Checkpoint

Confirm it’s live. The reel appears on the account’s grid, and media_publish returned a numeric media id. If the container poll returns ERROR, the most common cause is a video_url Meta can’t reach (private bucket) or a clip outside the 9:16 / ~90s reel constraints.


5

Make It Safe — The Double-Post Gotcha

5 min

Why this matters

This is the one that caused real damage in production, and the most transferable lesson in the tutorial.

The publish call — POST /{ig}/media_publish — sometimes returns “An unexpected error occurred” even though the post went live. If you treat that error as “failed” and re-run, a naive retry builds a new container and publishes the same video again. I shipped exactly that and watched one reel post three times.

The structure

Two rules fix it.

Persist the container id before you publish, and retry the same one. Re-publishing an identical creation_id is idempotent — Meta won’t create a duplicate. So a retry either returns the real media id (it had actually published) or completes the publish that genuinely hadn’t happened. It never doubles.

publishWithRetry
// Re-publishing the SAME creation_id never duplicates.
async function publishWithRetry(token, igUserId, creationId, tries = 4) {
  let lastErr
  for (let i = 0; i < tries; i++) {
    try {
      const { id } = await api(token, `${igUserId}/media_publish`, { creation_id: creationId })
      return id
    } catch (e) {
      lastErr = e
      // "already published" → fetch and return the existing id instead of retrying
      if (/already.*publish|has already been/i.test(String(e?.message || e))) {
        try { const r = await api(token, creationId, { fields: 'id' }, 'GET'); return r.id }
        catch { return null }
      }
      await new Promise((r) => setTimeout(r, 8000))
    }
  }
  throw lastErr
}

Treat errors as terminal, not as “try again next run.” An item that errored stays errored until you look at it; only genuinely pending items are eligible to post. The failure that bit me was a loop that re-queued anything not marked done — so a post that succeeded-but-reported-failure got picked up and posted again on the next tick.

And mind the order around the slow step: build the container, save the container id and mark the item building, then poll and publish. On restart, an item still marked building resumes from its existing container instead of building a new one.

Your turn

Replace your direct media_publish call with publishWithRetry, persist the container id the moment you have it, and make your “what’s eligible to post” filter exclude error and building — only pending posts.

Checkpoint

The reusable rule, far beyond Instagram: when an external API can report failure on a call that actually succeeded, trust the resource state, not the HTTP response. Make the write idempotent and re-check what’s real before you act on the error. If you internalize one thing from this build, make it this.


What Comes Next

Running this from a laptop cron means nothing posts when the lid is closed. To make it hands-off, move the publish step to a small always-on runner — a Cloudflare Worker on an hourly cron works well: the token becomes a Worker secret, the queue lives in KV (or D1), the media sits in R2, and the code above moves over unchanged.

One thing to guard if you do: don’t let a manual trigger and the scheduled tick run at the same moment against the same queue. Two invocations that both read “pending” before either writes back will each build a container for the same item — the same double-post, by a different cause. A short lock, or simply never triggering manually near the top of the hour, closes it.

A note on versions: the values here — Graph API v25.0, the scope names, the constraints — are what shipped and worked. Meta rolls API versions on a schedule, so check the current version and the content publishing reference before you build. The shape — container, poll, publish, idempotent retry — has been stable. The version string and exact permission names are the parts most likely to have moved by the time you read this.

Share: