Publishing a post to Instagram programmatically is genuinely harder than it looks, and most of the difficulty is not in the code. It is in a handful of places where Meta’s documentation is either wrong, incomplete, or describes a different integration than the one you are building.
We shipped this and published real posts through it. These are the things that cost us time, written down so they do not cost you any.
1. Meta’s own setup guide has a typo in the permission name
To publish, you request the permission that lets you publish. Meta’s "API setup with Facebook login" guide page names it:
- instagram_content_publishing ← this does not exist
The real permission, confirmed against the authoritative Permissions and Features catalogue, is:
- instagram_content_publish ← no "-ing"
This is a genuinely nasty one because the failure is not obvious. You are following an official Meta guide, on a Meta domain, and the string it gives you is wrong. Worse, the failure is not a clean rejection at the point of the mistake. The authorization dialog does not reliably stop you and say this permission is not real; you end up with a granted token that is missing the one capability you needed, and you find out at publish time with a permissions error that names the correct scope you thought you had asked for.
The lesson generalises past Meta. Verify every scope name against the machine-readable permissions catalogue, never against the prose walkthrough. Walkthroughs are written once and drift; the catalogue is generated from what the platform actually enforces. If a guide and a catalogue disagree, the catalogue is right and the guide has a bug report waiting to be filed.
The cheap defence is to make your scope list a single named constant in one file, with a comment recording where each string came from and when you last checked it. Scope strings scattered across an authorize-URL builder are strings nobody will ever re-verify.
2. There are two different Instagram APIs and the scopes are not interchangeable
This is the deeper reason people end up with the wrong permission string. Meta ships two separate integrations for posting to Instagram:
- Instagram API with Facebook Login — the older, Page-linked path. Requires an Instagram Business or Creator account connected to a Facebook Page. Uses instagram_basic, instagram_content_publish, pages_show_list and pages_read_engagement.
- Instagram API with Instagram Login — the newer standalone path. No Facebook Page required. Uses the renamed instagram_business_* scopes.
The renamed instagram_business_* scopes apply only to the second one. If you are on the Page-linked path and you paste those in because a blog post told you to, nothing works and the error will not explain why. Decide which integration you are building first, then take the scope list from that integration’s own page and from nowhere else.
The two paths differ in more than scope names, which is why mixing documentation between them causes so much confusion. The Page-linked path routes everything through the Page: the token you hold is a Page token, the account you address is discovered from the Page, and a user with no Page cannot connect at all. The standalone path removes the Page from the picture entirely. Any tutorial that mentions /me/accounts is describing the first one; any tutorial that never mentions a Page is describing the second.
Worth noting for anyone choosing today: the Instagram Login path asks the user for fewer permissions and does not drag a Facebook Page into it. If you are starting fresh, it is the easier road, and the shorter consent screen is a measurable difference in how many people finish connecting.
3. Persist the Page token, not the user token
The OAuth dance hands you a user access token. It is tempting to store that and move on. It will not work for publishing.
The sequence that actually works:
- Exchange the authorization code for a short-lived user token — these live on the order of an hour or two.
- Immediately exchange that for a long-lived user token using the fb_exchange_token grant. This is the ~60-day one.
- Call /me/accounts with a fields list that includes access_token and instagram_business_account. Each Page in the response carries its own Page access token.
- Find the Page that has an instagram_business_account — a person may manage several Pages and only one may have Instagram linked.
- Store the PAGE token as the credential, and the Instagram business account id as the account identifier — not the Page id.
That last distinction matters more than it sounds. The token you authenticate with and the id you address belong to two different objects. Storing the Page id as your account identifier produces an integration that authenticates fine and then cannot find anything to post to, with an error that reads like a permissions problem because that is what a wrong-object-id error usually looks like on the Graph API.
The reason to persist the Page token rather than the user token is lifetime. A Page token derived from a long-lived user token does not carry its own short expiry — it stays valid as long as the underlying user grant does. Storing the user token and re-deriving the Page token on every publish adds a network hop and a failure mode for no benefit.
One sharp edge in the token exchange itself: the long-lived exchange does not always return expires_in. We saw it absent in production. If you compute an expiry from a missing field you get an invalid date, and then your database driver refuses to serialise it and the whole connect flow fails with a time-value error that says nothing about Meta. Default to the documented 60-day lifetime when the field is missing rather than trusting it to be there.
4. Publishing is two calls, and Instagram will reject text
There is no single create-post endpoint. You create a media container, then publish it:
- POST /{ig-user-id}/media with the image or video URL and the caption — returns a container id. Video goes as media_type REELS with a video_url; images go as image_url.
- POST /{ig-user-id}/media_publish with that container id as creation_id — returns the published media id.
And a constraint that surprises people building a cross-poster: Instagram does not accept text-only posts through the API at all. A caption with no media is not a valid post. If you are fanning one draft out to several networks, this is the case you have to handle explicitly — the same text that publishes fine to LinkedIn or Bluesky simply cannot go to Instagram without an image or video attached. Reject it in your own validation layer with a message that explains why, rather than passing it through to get a platform error your user cannot act on.
5. The container is asynchronous, and pretending otherwise breaks video
The two-call sequence above is the version in the docs, and it is incomplete. Between the two calls the container has to finish processing. For an image that is usually instant. For video it is not — Meta is transcoding, and publishing a container that is not ready fails.
So the real sequence has a poll in the middle. Read the container object with a fields list of status_code and look at the result:
- IN_PROGRESS — still processing. Wait and ask again.
- FINISHED — ready to publish. Proceed to media_publish.
- ERROR — processing failed. Do not retry the publish; the container is dead and you need a new one.
- EXPIRED — the container was created too long ago and was never published.
Poll on a fixed short interval with a hard attempt cap, and treat exhausting the cap as a timeout rather than a failure — the distinction matters when you decide whether to retry the whole job. Running the same poll for images too costs one extra request and removes a branch from your code, which is a good trade.
Related and easy to get wrong: the media URL you hand Meta is fetched by Meta’s servers, not by you. It must be publicly reachable for as long as processing takes. Presigned URLs with short expiries, URLs behind auth, and anything on localhost will all fail here, and the error arrives as a processing failure with no explanation of the cause.
6. Page tokens do not self-refresh, so design the reconnect flow now
There is no refresh token in this flow. When the underlying long-lived user grant lapses, the Page token goes with it, and the only remedy is the human clicking through the consent screen again. No background job can fix it for them.
That is not a bug to code around, so build for it: store the expiry as a real deadline, flip the account into an explicit needs-reconnecting state before it expires rather than after a publish has already failed, and make reconnecting re-authorise onto the existing account record instead of creating a duplicate row. The failure mode you are avoiding is the quiet one — an account that looks connected in your UI and silently drops every scheduled post on the floor.
7. The deletion callbacks will silently do nothing
This one is a compliance bug rather than a publishing bug, and it is invisible until someone checks. Meta sends deauthorize and data-deletion callbacks carrying a signed_request whose user_id is the app-scoped user id — the id of the person, obtained from /me with the user token.
But if you followed the advice above, the identifier you stored on the account is an Instagram business account id, or a Page id for Facebook. Never a user id. So the callback arrives, you look up accounts by the id it gave you, you find zero rows, and you return a success confirmation code having deleted nothing at all. Everything looks correct in your logs.
The fix is to capture the app-scoped user id at connect time, with the user token, before you swap to the Page token — reading id from /me needs no extra permission beyond what every app has — and store it alongside the account so the callbacks have something to match on. Make that read non-fatal: a failure to get it should degrade the callbacks, not break someone’s login.
The thing nobody tells you about app review
You do not need App Review to post to your own accounts. While the Meta app stays in Development Mode and the target account is added as a Tester or Admin, the whole flow works. App Review is for letting strangers connect their accounts.
One more, related: do not request permissions your code never calls. We had a Business Manager scope in our scope string and no code path that used it — it had been added speculatively while debugging. Requesting a scope you never exercise is a standard App Review rejection, so we removed it. Everything the Instagram flow needs comes from /me/accounts, which pages_show_list already covers.
A working checklist
- Pick your integration: Facebook Login (Page-linked) or Instagram Login (standalone). Do not mix their documentation.
- Take scope names from the Permissions and Features catalogue, never from a setup guide.
- instagram_content_publish. No "-ing".
- Store the Page access token; store the Instagram business account id.
- Default the token lifetime when expires_in is missing from the response.
- Container, poll for FINISHED, then publish. Three calls, not two.
- Serve media from a URL Meta can fetch for the whole processing window.
- Reject text-only posts before you send them, with a message that says why.
- Capture the app-scoped user id so deauthorize and deletion callbacks can match.
- Request nothing you do not call.
Why we wrote this down
Every platform integration carries a set of quirks like these, and they are individually small and collectively expensive. LinkedIn silently retires its API versions and returns 426 with no warning. A sandboxed TikTok app reports success on posts nobody can see. Instagram has the typo above and a two-object token model that reads like a mistake until you understand it. Each one is a day of someone’s life, and none of them are in the tutorial you started from.
The reason we keep paying that cost is what happens after publishing: seenpaid tracks the link in each post, reads the seller’s own payment data, and reports which posts actually produced revenue — which is the question all of this API work was in service of to begin with.