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.
What connecting does
Section titled “What connecting does”/api/connect/instagram/* runs the Instagram Login flow:
- authorize on
www.instagram.com/oauth/authorizewith the scopesinstagram_business_basic,instagram_business_content_publishandinstagram_business_manage_comments - code → short-lived token (
POST api.instagram.com/oauth/access_token) - short-lived → long-lived token, valid 60 days (
GET graph.instagram.com/access_token?grant_type=ig_exchange_token) - 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.
How publishing works
Section titled “How publishing works”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>, captionGET /{container-id}?fields=status_code,statusPOST /{ig-user-id}/media_publish creation_id=<container> → the media idGET /{media-id}?fields=permalink → stored on the cardPOST /{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:
- The first pass stages the container and checks it once. A photo is usually
FINISHEDalready and publishes straight away. - If the container is still
IN_PROGRESS, the publisher defers. The card goes back toreviewed, due 20 seconds later, with the container onpayload.mediaContainer. The run parks (park { until, reenter: true }): it holds no queue slot and survives a restart. - When the run wakes, the card is due again. The normal atomic
reviewed → postingclaim 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_code | What happens |
|---|---|
FINISHED | Publish, read the permalink, mark the card posted. Its first comment follows. |
IN_PROGRESS | Defer again, checking every 30 s. After 30 minutes the card fails with “still processing”. |
ERROR | The card fails, showing Meta’s reason (e.g. an unsupported codec). |
EXPIRED | The container was not published within 24 h, so a fresh one is staged. |
PUBLISHED | The 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 carousel: 2 to 10 images
Section titled “A carousel: 2 to 10 images”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_postandupdate_tasktakeimages, 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 status | What happens |
|---|---|
all FINISHED | Create the carousel container. If it is FINISHED, publish it. Otherwise defer on it and resume it like a Reel. |
any IN_PROGRESS | Defer with payload.mediaContainer = { id: "", children: [...] }. The next pass checks the same items again. The 30-minute cap applies from the first item. |
any ERROR | The card fails and names the slide: Instagram could not process image 2 of 3: … |
any EXPIRED | Stage 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.
Images are sent as JPEG
Section titled “Images are sent as JPEG”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/.jpegis 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.
Media must be publicly reachable
Section titled “Media must be publicly reachable”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.
Limits enforced before anything is sent
Section titled “Limits enforced before anything is sent”- 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_limitand refuses with that reason when the quota is used up. If that read itself fails, publishing continues and Meta enforces the limit.
Board MCP
Section titled “Board MCP”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. The Meta app (Meta dashboard)
Section titled “1. The Meta app (Meta dashboard)”- Use your existing Meta app, or create a Business app, at developers.facebook.com.
- Add the Instagram product, then choose API setup with Instagram login.
- Under that product’s Business login settings, add the OAuth redirect URI:
https://platform-api.viite.ai/api/connect/instagram/callback
- Copy the Instagram app ID and Instagram app secret shown there. These are not the Meta app ID and secret.
- Add the permissions
instagram_business_basic,instagram_business_content_publishandinstagram_business_manage_comments.
2. Env
Section titled “2. Env”| Key | Class | Notes |
|---|---|---|
INSTAGRAM_CLIENT_ID | public | The Instagram app ID. |
INSTAGRAM_CLIENT_SECRET | 🔒 secret | The Instagram app secret. Never goes in allowed.config.types.ts. |
INSTAGRAM_OAUTH_REDIRECT_URI | public | Must 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.
The review gate
Section titled “The review gate”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:
- Business Verification of the business portfolio that owns the app.
- In App settings → Basic: privacy policy URL, terms URL, data deletion instructions, app icon and category.
- 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.
- Switch the app to Live mode.
The code ships before the grant. The grant itself needs no code change.
Troubleshooting
Section titled “Troubleshooting”| What you see | What it means |
|---|---|
| The connect page refuses the account | The 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 video | The 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 minutes | The container never finished. Try a smaller or shorter export, then publish again. |
| used its 100 API posts for the last 24 hours | The rate limit. Publish again later. |
| reports this post as already published | See PUBLISHED above. Check the account before re-arming. |
| expired and no refresh token is stored / a refresh error | The 60-day token lapsed. Reconnect the channel. |
