Skip to content

Connect an Instagram account

The platform publishes approved drafts, photos, carousels (2 to 10 images) and Reels, to an Instagram professional account (Business or Creator). It uses Instagram API with Instagram Login: the person signs in with the Instagram account itself, so no Facebook Page is needed.

/api/connect/instagram/* runs the Instagram Login flow:

  1. authorize on www.instagram.com/oauth/authorize with the scopes instagram_business_basic, instagram_business_content_publish and instagram_business_manage_comments
  2. code → short-lived token (POST api.instagram.com/oauth/access_token)
  3. short-lived → long-lived token, valid 60 days (GET graph.instagram.com/access_token?grant_type=ig_exchange_token)
  4. the account (GET graph.instagram.com/me?fields=user_id,username) names the channel: post://<username>@instagram.com, stored encrypted and owned by the person who connected it, in their org

The token refreshes itself. Instagram Login has no separate refresh token: the long-lived token is refreshed with grant_type=ig_refresh_token. The platform stores it as its own refresh handle and records an expiry 7 days before the real one, so the next publish refreshes it in time. A token nobody publishes with for 60 days still expires, and then the channel must be reconnected.

Once connected, the destination shows in list_destinations (board MCP) as post://<username>@instagram.com in automated mode. The manual post://instagram.com destination stays available for posting by hand.

Moving a post card to reviewed arms it. The publish pass, which is Publish now or the “Publish scheduled posts” workflow, then:

POST /{ig-user-id}/media stage a container:
photo → image_url + caption
Reel → media_type=REELS, video_url, cover_url (the image),
caption, share_to_feed=true
carousel → one item per image (is_carousel_item=true,
image_url), then media_type=CAROUSEL,
children=<item ids, comma-separated>, caption
GET /{container-id}?fields=status_code,status
POST /{ig-user-id}/media_publish creation_id=<container> → the media id
GET /{media-id}?fields=permalink → stored on the card
POST /{media-id}/comments the card's first comment, if any (the post_comment step)

A Reel is polled without blocking the queue

Section titled “A Reel is polled without blocking the queue”

Meta processes a Reel for anything from seconds to minutes, and a container can be published only once its status_code is FINISHED. Production runs one workflow at a time, so the publisher never waits in place. Instead:

  1. The first pass stages the container and checks it once. A photo is usually FINISHED already and publishes straight away.
  2. If the container is still IN_PROGRESS, the publisher defers. The card goes back to reviewed, due 20 seconds later, with the container on payload.mediaContainer. The run parks (park { until, reenter: true }): it holds no queue slot and survives a restart.
  3. When the run wakes, the card is due again. The normal atomic reviewed → posting claim makes that pass the card’s only writer, so an overlapping Publish-now click or scheduled pass is safe. The pass resumes the same container:
status_codeWhat happens
FINISHEDPublish, read the permalink, mark the card posted. Its first comment follows.
IN_PROGRESSDefer again, checking every 30 s. After 30 minutes the card fails with “still processing”.
ERRORThe card fails, showing Meta’s reason (e.g. an unsupported codec).
EXPIREDThe container was not published within 24 h, so a fresh one is staged.
PUBLISHEDThe card fails without publishing. An earlier attempt published and was interrupted before saving, and publishing again would duplicate the post. Check the account.

If the caption, cover or video changed while the container was processing, a fresh container is staged, so stale content is never published. A failed publish drops the stored container, so re-arming the card starts over.

A post with two or more images publishes as a carousel. The images are an ordered list on the card (payload.images); payload.image is always the first of them, so the card’s thumbnail is slide 1 and a one-image post looks exactly as it did before carousels.

  • Board: the Media tab of an Instagram post edits the list. Add (upload, Illustrate or a pasted URL), reorder with the arrows, and remove. Each change saves at once.
  • MCP: create_post and update_task take images, the full ordered list (see Board MCP).
  • Order and crop: the slides publish in the list’s order. Instagram crops every slide to the first image’s aspect ratio (square by default), so keep one ratio throughout.
  • Limits: at most 10 images, refused at save time (board, API and MCP) and again by the publisher. Meta’s current documentation allows 20; 10 is the limit every API version and the Instagram app share. A carousel takes no video: with a video the post is a Reel, whose one image is its cover, so the board refuses a list beside a video. Every other destination takes a single image and refuses a second one.

The publisher stages one item container per image, in order. It creates the CAROUSEL container only when every item is FINISHED, then publishes it like any post. The first comment follows as usual. The waits are the same non-blocking deferral as a Reel’s:

Item statusWhat happens
all FINISHEDCreate the carousel container. If it is FINISHED, publish it. Otherwise defer on it and resume it like a Reel.
any IN_PROGRESSDefer with payload.mediaContainer = { id: "", children: [...] }. The next pass checks the same items again. The 30-minute cap applies from the first item.
any ERRORThe card fails and names the slide: Instagram could not process image 2 of 3: …
any EXPIREDStage everything again.

If Meta refuses to stage an item at all (for example, the URL cannot be fetched), the card fails with Meta’s own words and the slide number. Reordering, adding or removing a slide while the post is processing stages a fresh carousel, so stale content is never published.

Meta’s content-publishing API accepts JPEG only for images. Extended JPEG formats (MPO, JPS) are not supported either. The platform’s own images are usually PNG: uploads, Illustrate and generated slides are stored as /api/assets/<org>/pub/<uuid>.png. So when the publisher stages a container, it passes every image through a converter. This covers a carousel slide, the single photo and a Reel’s cover:

  • A URL whose path ends in .jpg / .jpeg is sent unchanged, with no download.
  • Otherwise the bytes are read. A platform asset is read straight from storage; any other URL goes through the guarded fetcher (https only, no private network, 8 MB cap). If the bytes are already JPEG, the original URL is sent.
  • Anything else is converted with sharp, flattened on white because JPEG has no transparency, and stored as a new public JPEG asset of the same org. Meta gets that URL. The card keeps its original image.

The conversion runs once per staged container, never on a resume. The JPEG copy does not count against the org’s storage quota.

Meta fetches image_url, video_url and cover_url from its own servers. Use assets on the platform’s public lane (/api/assets/<org>/pub/<key>, which is what the post editor uploads to). That route needs no auth, sends the real content type (video/mp4) and length (including on HEAD), and answers range requests. Uploads are capped at 50 MB per video.

A media fetch that fails comes back on the card in Meta’s own words (Media download has failed), which usually means a private or unreachable URL.

Reel specs: MP4 (H.264 video, AAC audio), 9:16 recommended, 3 seconds to 15 minutes.

  • images: 1 to 10 (2+ is a carousel), and no list beside a video.
  • caption: 2200 characters and 30 hashtags. A draft over 2200 characters is refused at save time, on the board and in MCP.
  • first comment: 2200 characters
  • rate limit: an account can publish 100 posts through the API in any 24 hours. Before staging a container, the publisher reads GET /{ig-user-id}/content_publishing_limit and refuses with that reason when the quota is used up. If that read itself fails, publishing continues and Meta enforces the limit.

create_post and update_task take video (a public https MP4) and image on an Instagram destination. With a video, the post is a Reel and the image is its cover. upload_asset accepts only images, so upload a video from the board’s post editor (Media tab) or pass an already-hosted URL.

For a carousel, pass images: 2 to 10 public image URLs, in slide order. Upload each one with upload_asset first if it is not online yet. On update_task, images replaces the whole list: send it reordered to reorder it, or leave an entry out to remove it. image alone replaces only the first slide. get_task returns post.images for a carousel, and list_destinations reports maxImages per destination (Instagram: 10, the rest: 1).

  1. Use your existing Meta app, or create a Business app, at developers.facebook.com.
  2. Add the Instagram product, then choose API setup with Instagram login.
  3. Under that product’s Business login settings, add the OAuth redirect URI:
    https://platform-api.viite.ai/api/connect/instagram/callback
  4. Copy the Instagram app ID and Instagram app secret shown there. These are not the Meta app ID and secret.
  5. Add the permissions instagram_business_basic, instagram_business_content_publish and instagram_business_manage_comments.
KeyClassNotes
INSTAGRAM_CLIENT_IDpublicThe Instagram app ID.
INSTAGRAM_CLIENT_SECRET🔒 secretThe Instagram app secret. Never goes in allowed.config.types.ts.
INSTAGRAM_OAUTH_REDIRECT_URIpublicMust match a URL registered on the Instagram product byte for byte.

Instagram stays dormant until all three keys are set (plus SOCIAL_TOKEN_ENCRYPTION_KEY). A partial set logs a warning at boot and registers nothing.

Until Meta grants Advanced Access, the permissions work only for accounts with a role on the app. To test, add the account under App roles → Roles → Instagram Testers, then accept the invite in the Instagram app (Settings → Apps and websites → Tester invites).

To let any org connect:

  1. Business Verification of the business portfolio that owns the app.
  2. In App settings → Basic: privacy policy URL, terms URL, data deletion instructions, app icon and category.
  3. App Review: request Advanced Access for the three permissions. Each one needs a screencast of the real flow (connect, compose, publish a Reel, the first comment) and a written use case.
  4. Switch the app to Live mode.

The code ships before the grant. The grant itself needs no code change.

What you seeWhat it means
The connect page refuses the accountThe account is personal, or (before Advanced Access) it is not an accepted Instagram Tester on the app.
Instagram posts must carry an image or a videoThe draft was armed with no media. Add one in the Media tab.
Media download has failed (Meta’s words)video_url, image_url or cover_url is not publicly reachable.
Instagram could not process the video: …Meta’s container reported ERROR. The reason follows; it is usually the codec, aspect ratio or duration.
still processing this Reel after 30 minutesThe container never finished. Try a smaller or shorter export, then publish again.
used its 100 API posts for the last 24 hoursThe rate limit. Publish again later.
reports this post as already publishedSee PUBLISHED above. Check the account before re-arming.
expired and no refresh token is stored / a refresh errorThe 60-day token lapsed. Reconnect the channel.