Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Instagram — Facebook Business (instagram)

This provider publishes to an Instagram professional account through a Facebook Page using Facebook Login for Business. It is the “Instagram API with Facebook Login” setup: the Instagram account must be a Business or Creator account linked to a Facebook Page, and the token Rustiz stores is a Page access token paired with the user token.

If you want to connect an Instagram account without a Facebook Page, use instagram-standalone. The two providers share the same publishing core in instagram.rs; only the auth host and token flow differ.

  • instagram → FACEBOOK_APP_ID / FACEBOOK_APP_SECRET, Facebook Login for Business, a Facebook Page is required, token is {page_token}___{user_token}, is_between_steps = true, no automatic token refresh.

  • instagram-standalone → INSTAGRAM_APP_ID / INSTAGRAM_APP_SECRET, Business Login for Instagram, no Page, plain Instagram user token, is_between_steps = false, refresh_cron = true.

  • Rust source: crates/rustiz-providers/src/instagram.rs

  • Upstream port: instagram.provider.ts

  • Editor: Normal

  • Connection mode: oauth (Facebook Login for Business)

  • Shared implementation: InstagramCore is also used by instagram-standalone with graph.instagram.com

Overview

PropertyValue
Identifierinstagram
Display nameInstagram\n(Facebook Business)
Connection modeoauth (Facebook Login for Business)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; extra entries are posted as POST /{ig-media-id}/comments
Pending / async publishingYes. supports_pending = true; containers are created, polled to FINISHED, then media_publish
AnalyticsYes. supports_analytics = true; account insights
Post analyticsYes. supports_post_analytics = true; media insights
MentionsNo. supports_mentions = false (the platform supports mentions, but the provider does not implement them)
is_between_stepstrue — the OAuth callback creates an unfinished channel; an Instagram account must be selected with channelSetupTool
convert_to_jpegfalse — images are not converted, despite Instagram accepting only JPEG
max_concurrent_job400
Character limit (max_length)2,200 characters
Graph API versionv25.0
API hostshttps://graph.facebook.com (FACEBOOK_BASE_URL), https://www.facebook.com (FACEBOOK_WEB_BASE_URL)
Tool tip“Instagram must be business and connected to a Facebook page”

Server Environment Variables

required_env() returns exactly:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["FACEBOOK_APP_ID", "FACEBOOK_APP_SECRET"]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
FACEBOOK_APP_IDYes—The Meta app ID, not the Instagram app ID. Shared with facebook.
FACEBOOK_APP_SECRETYes—The Meta app secret. Shared with facebook.
FACEBOOK_BASE_URLNohttps://graph.facebook.comGraph API origin for containers, publishing, insights and page discovery.
FACEBOOK_WEB_BASE_URLNohttps://www.facebook.comOrigin of the /v25.0/dialog/oauth login dialog.
INSTAGRAM_WEB_BASE_URLNohttps://www.instagram.comDefined by the shared core for the standalone provider; not used by this provider’s login flow.
INSTAGRAM_API_BASE_URLNohttps://api.instagram.comDefined by the shared core; not used by the Facebook Business flow.
INSTAGRAM_GRAPH_BASE_URLNohttps://graph.instagram.comUsed only by instagram-standalone.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the redirect URI.
JWT_SECRETYes in practiceemptyEncrypts pending OAuth state.
RESTRICT_UPLOAD_DOMAINSNounsetGlobal upload guard; Instagram fetches media from the URL, so it must be publicly reachable.

rustiz providers --status only checks FACEBOOK_APP_ID and FACEBOOK_APP_SECRET. It cannot tell whether the app has the Instagram product, the Instagram account is professional, or the account is linked to a Page.

Step-by-Step Developer App Setup

Facebook Page and Instagram (Facebook Business) can share one Meta app. If you already created an app for facebook, reuse it.

1. Create the Meta app

  1. Go to https://developers.facebook.com/apps/creation/.
  2. Choose Other → Business and create the app under a Business portfolio. Public apps need Business Verification.
  3. Add the product Facebook Login for Business.
  4. Add the product Instagram and choose Instagram API setup with Facebook login.
  5. In Facebook Login for Business → Settings, add the exact redirect URI from step 2.

2. Redirect URI (exact)

Instagram::redirect_uri() is:

#![allow(unused)]
fn main() {
format!("{}/integrations/social/instagram", self.ctx.config.frontend_url())
}

Examples:

https://social.example.com/integrations/social/instagram   # production
http://localhost:5000/integrations/social/instagram        # Docker / rustiz serve
http://localhost:4200/integrations/social/instagram        # upstream Postiz dev UI

There is no redirectmeto.com wrapper for the Facebook Business flow, because Facebook accepts http://localhost redirect URIs. Register the value without a trailing slash.

Reconnect quirk: on ?refresh=<channel id>, the token exchange sends …/integrations/social/instagram?refresh=<id> while the login dialog used the plain URI. Meta requires an exact redirect-URI match; if the exchange fails with a redirect mismatch, start a fresh connection instead of using refresh.

3. Permissions requested and checked

Instagram::new() sets:

#![allow(unused)]
fn main() {
info.scopes = vec![
    "instagram_basic",
    "pages_show_list",
    "pages_read_engagement",
    "business_management",
    "instagram_content_publish",
    "instagram_manage_comments",
    "instagram_manage_insights",
];
}

The login URL joins them with commas. After the code → long-lived token exchange, authenticate_facebook() calls GET /v25.0/me/permissions and check_scopes() over the granted permissions. All seven must be granted, otherwise the connection fails with NotEnoughScopes.

ScopeWhy Rustiz needs it
instagram_basicRead the Instagram professional account profile/media
pages_show_listDiscover Facebook Pages and their linked Instagram accounts
pages_read_engagementRead Page metadata and linked IG account
business_managementDiscover Pages through Business Manager (owned_pages, client_pages)
instagram_content_publishCreate containers and media_publish
instagram_manage_commentsPost comments/replies
instagram_manage_insightsAccount and media insights

The account must be able to perform the Page tasks CREATE_CONTENT or MANAGE (for publishing) and MODERATE (for comments) on the linked Page. Instagram account roles are managed by the Page’s Business Manager assignment.

4. Account prerequisites

  • The Instagram account must be Business or Creator (professional). A personal account cannot be connected; the API returns “The user is not an Instagram business account”, which Rustiz maps to a refresh error.
  • The professional account must be linked to a Facebook Page.
  • The Page must be published and must not be blocked by Page Publishing Authorization (PPA). An account whose Page requires PPA cannot publish until PPA is completed.
  • The user must have full control of the Page or the relevant Page task.

5. App roles and Development mode

  • All new apps start in Development mode. Only Admins, Developers and Testers can grant permissions and see content.
  • For Instagram (Facebook Business), the tester’s Facebook account must be able to manage the Page; the Instagram account is reached through that Page.
  • In Development mode, only role users can publish. The Postiz docs warn that posts made with media in Development mode may be visible only to the app’s role users.
  • Instagram Tester roles are primarily for the standalone flow; for the Facebook Business flow the Page role is what matters. Adding the account as an Instagram Tester is still useful when diagnosing “Insufficient developer role” errors.

6. Live mode and App Review

  • Switch to Live only after App Review. Live mode exposes only permissions approved through App Review.
  • Advanced Access is required to serve Instagram accounts you do not own or manage. It needs App Review + Business Verification.
  • Standard Access is enough for an app used only by its own role users.
  • App Review needs a screencast showing the complete Facebook login, the Instagram account selection, a published feed post/carousel/Reel, and a comment. Vague use-case descriptions are a common rejection.
  • Permissions unused for 90 days may expire.

7. Sandbox / testing

Instagram has no separate sandbox. Use:

  • a test Meta app in Development mode;
  • a test Facebook Page and a test Instagram professional account linked to it;
  • role users only.

Containers are invisible until media_publish, so a failed processing run leaves no public post — a useful property for testing. A container that was never published expires after 24 hours.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect instagram
rustiz channels connect instagram --open
rustiz channels connect instagram --timeout 600

The login URL goes to stderr. With a local FRONTEND_URL the CLI serves the callback and waits; with a public HTTPS FRONTEND_URL it prints the URL and exits.

The callback creates the channel with inBetweenSteps = true. There is no CLI setup command; finish it with the MCP channelSetupTool. Until then the scheduler refuses the channel with “channel … is not set up yet, finish it with channelSetupTool”.

MCP connectChannelTool

{ "provider": "instagram" }

Response:

{
  "connected": false,
  "pending": true,
  "connectionId": "…",
  "url": "https://www.facebook.com/v25.0/dialog/oauth?client_id=…&redirect_uri=…&state=…&scope=instagram_basic,pages_show_list,pages_read_engagement,business_management,instagram_content_publish,instagram_manage_comments,instagram_manage_insights",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Poll with { "connectionId": "…" }.

Finishing setup: triggerTool then channelSetupTool

  1. List the Instagram accounts reachable through the connected Pages:

    {
      "integrationId": "<unfinished channel id>",
      "methodName": "pages",
      "dataSchema": []
    }
    

    instagram exposes two functions:

    FunctionDescriptionInput
    pages“Instagram business accounts reachable through the connected Facebook pages”{}
    audioSearch“Search audio (music or original sounds) to attach to a Reel via the "audio" setting, an empty query returns trending audio”{q?: string, type?: "music" | "original_sound"}

    pages returns:

    [
      {
        "pageId": "1234567890",
        "id": "17841400000000000",
        "name": "My Instagram",
        "picture": { "data": { "url": "https://…" } }
      }
    ]
    

    pageId is the Facebook Page id; id is the Instagram professional account id used for publishing.

  2. Choose an account and call:

    {
      "integrationId": "<unfinished channel id>",
      "data": {
        "id": "17841400000000000",
        "pageId": "1234567890"
      }
    }
    

    channelSetupTool passes data straight to Instagram::fetch_page_information(), which:

    • reads GET /{pageId}?fields=access_token,name,picture.type(large);
    • reads GET /{id}?fields=username,name,profile_picture_url;
    • stores the token as "{page_access_token}___{user_access_token}", the internal id as the Instagram id, the profile as the IG username, and clears inBetweenSteps.
  3. Verify with integrationList: the channel name is the Instagram name, profile is the username and inBetweenSteps is false.

Both keys are required. {"id": …} alone leaves pageId empty and the Graph call becomes /{empty}?fields=access_token…; {"pageId": …} alone leaves the Instagram id empty.

What not to send

  • customFields routes to connect_custom() and is treated as an OAuth code. Never send it to instagram.
  • externalUrl is unused; do not pass it.

Post Settings & Media Validation

settings_schema()

{
  "type": "object",
  "properties": {
    "post_type": {
      "type": "string",
      "enum": ["post", "story"],
      "description": "post = a regular feed post or carousel, story = publish the media as stories (stories can only hold one picture)"
    },
    "is_trial_reel": {
      "type": "boolean",
      "description": "Publish the single video as a Trial Reel (trial Reels are shown to non-followers first; the account must support them)"
    },
    "graduation_strategy": {
      "type": "string",
      "enum": ["MANUAL", "SS_PERFORMANCE"],
      "description": "How a Trial Reel graduates to a regular Reel: MANUAL requires manual graduation, SS_PERFORMANCE graduates automatically when it performs well"
    },
    "collaborators": {
      "type": "array",
      "description": "Accounts invited as collaborators on a feed post or Reel; labels may include a leading @, it is stripped before publishing",
      "items": {
        "type": "object",
        "properties": {
          "label": { "type": "string", "description": "The collaborator username" }
        },
        "required": ["label"]
      }
    },
    "audio": {
      "type": "object",
      "description": "Audio (music or original sound) attached to a single Reel; not supported on stories, images or carousels",
      "properties": {
        "id": { "type": "string", "description": "The audio id returned by the audio search tool" },
        "title": { "type": "string", "description": "Display title of the audio" },
        "artist": { "type": "string", "description": "Display artist of the audio" },
        "image": { "type": "string", "description": "Cover artwork URL of the audio" },
        "audio_volume": { "type": "number", "minimum": 0, "maximum": 100 },
        "video_volume": { "type": "number", "minimum": 0, "maximum": 100 }
      },
      "required": ["id"]
    }
  },
  "required": ["post_type"],
  "additionalProperties": false
}
FieldRequiredBehaviour
post_typeYespost = feed image/video/carousel; story = story media. A story with multiple media publishes each as a separate story
is_trial_reelNoOnly for a single video. Adds trial_params={"graduation_strategy": …}. The account must support Trial Reels
graduation_strategyNoDefaults to MANUAL when is_trial_reel is true
collaboratorsNoArray of {label}. A leading @ is stripped. Sent as a JSON array; not allowed on stories or carousel children; allowed on the carousel parent
audioNoAudio id/title/artist/image/volumes. Only applied on graph.facebook.com, non-story, single-video Reels. Ignored silently on standalone and on carousels/stories/images

additionalProperties: false is advisory: the exported schema tells UI/MCP clients not to send unknown keys, but the Rust validate_settings() ignores extra keys rather than rejecting them.

validate_settings()

  • settings must be an object.
  • post_type must be exactly post or story; it is not defaulted.
  • is_trial_reel, when non-null, must be boolean or string (the string is parsed by as_bool).
  • graduation_strategy, when non-null, must be MANUAL or SS_PERFORMANCE.
  • collaborators, when non-null, must be an array; each item must be an object with a non-empty string label.
  • audio, when non-null:
    • must be an object;
    • id must be a string (an empty string is accepted and means “no audio”);
    • title, artist, image must be strings when present;
    • audio_volume and video_volume must be numbers in [0, 100].
  • graduation_strategy is validated even when is_trial_reel is false.
  • No maximum caption length is checked here; max_length handles the engine side.

check_validity()

The shared instagram_check_validity(posts, settings, carousel_checks = true) enforces:

  1. At least one media item in the first post.
  2. At most 10 media items in the first post: “Instagram carousel only supports up to 10 media attachments”.
  3. If is_trial_reel:
    • only one media;
    • that media must be a video (path contains .mp4).
  4. Alt text on any item must be ≤ 1,000 UTF-16 code units.
  5. If an audio id is set:
    • not allowed on stories;
    • not allowed with more than one media;
    • the single media must be a video.

It does not check:

  • JPEG-only images (the API requires JPEG; Rustiz sends PNG/WebP as-is and convert_to_jpeg is false);
  • image dimensions, 8 MB size, or 4:5–1.91:1 aspect ratio;
  • video duration/codec/size;
  • caption length beyond max_length;
  • collaborator validity (the API returns 2207018).

Media requirements

Platform rules enforced by Instagram (and mapped by handle_errors):

MediaRule
Feed imageJPEG only, max 8 MB, aspect ratio 4:5 to 1.91:1, width 320–1,440 px (Meta scales outside that range)
Carousel2–10 items in the Rust provider; current Meta docs describe up to 20 in one section but error 2207028 and most reference pages still document 10. Treat 10 as the safe limit. Children are cropped to the first item’s ratio
Single videoPublished as a Reel: MP4/MOV, H.264/HEVC + AAC, 3 s–15 min, ≤ 300 MB, 23–60 fps, width ≤ 1,920, bitrate ≤ 25 Mbps
Story imageJPEG, ≤ 8 MB, 9:16 recommended
Story videoMP4/MOV, 3–60 s, ≤ 100 MB, 9:16 recommended
Alt textImages only (single or carousel item), max 1,000 characters, ignored on videos and stories
CollaboratorsUp to 3 on a feed post/Reel; account must be public and allow invites
AudioSingle-video Reel only; not on stories, images or carousels

How Rustiz builds each container:

  • Single image: POST /{ig-id}/media?image_url=…&caption=… (no media_type, so Instagram defaults to IMAGE).
  • Single video, feed: video_url=…&media_type=REELS, plus cover_url when a thumbnail is provided, otherwise thumb_offset (0 when absent).
  • Single video, story: video_url=…&media_type=STORIES.
  • Single image, story: image_url=…&media_type=STORIES.
  • Carousel child: image_url/video_url, media_type=VIDEO for videos, is_carousel_item=true, no caption.
  • Carousel parent: created later in finalize_post() with caption, media_type=CAROUSEL, children=<ids>, optional collaborators.
  • Multiple stories: each media is its own STORIES container; they are published one at a time.

URL encoding bug: media paths are interpolated into the query string without percent-encoding (image_url={path}, video_url={path}). A signed URL containing ?, & or # will be truncated or misparsed. Host media on a plain public URL without query parameters.

Threads

supports_comments = true, but Instagram threads are comments on the first media, not separate feed posts:

  1. The first entry creates the containers and publishes the feed post/Reel/story.
  2. Each later entry calls POST /{post-id}/comments?message=….
  3. comment() ignores last_comment_id — every comment is attached to the root media, not to the previous comment. A multi-entry thread therefore looks like several top-level comments, not a reply chain.
  4. A comment can carry no media; Instagram comments are text-only in this provider.

Plugs

instagram.rs has no @Plug or @PostPlug methods. run_plug() and run_post_plug() return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • Two ids, two tokens, one string. After setup the stored token is "{page_access_token}___{user_access_token}" (split_token splits on ___). Publishing uses the page token; container status checks and permalink reads prefer the user token when present. If the user token is missing, the page token is used for both.
  • The page token is optional at setup. fetch_page_information() does not reject an empty page.access_token; it builds "___<user token>". Publishing then fails as the Page. The Facebook provider does check for this case.
  • Empty pages list can hide a permission problem. InstagramCore::pages() fetches each Page’s Instagram profile separately and uses let Ok(information) = … else { continue }. A Page whose IG profile read fails (missing permission, deleted account, restricted Page) is silently omitted from the picker instead of producing an error. If the expected account is missing, call the Graph endpoint manually with the same token.
  • You must finish setup. The OAuth callback stores a user token and the Facebook user id. Until channelSetupTool runs, internal_id is the user id, not an Instagram account id, and publishing targets the wrong object.
  • JPEG-only means JPEG-only. convert_to_jpeg is false, and the provider never re-encodes images. Upload PNG/WebP and Instagram returns 2207005 (“Unsupported image format”) or 2207004 (too large). Convert to JPEG before uploading to Rustiz.
  • Carousel limit mismatch. Rustiz enforces 10; Meta’s 2026 content publishing page says “up to 20” in the carousel section while the error-code documentation and the IG User Media reference still describe 10. The provider’s hard 10-item check is the conservative choice; if you raise it, test with the live API.
  • Daily publish cap mismatch. handle_errors maps 2207042 to “maximum of 25 posts per day”, but Meta’s current Content Publishing guide states 100 API-published posts per 24-hour moving period (carousel counts as one). The error text is stale; the platform cap is higher.
  • No token refresh. refresh_token() returns an empty access token with expires_in: 0; refresh_cron is false. The long-lived user token is stored with a hard-coded 59-day expiry. When it expires, a 190 or REVOKED_ACCESS_TOKEN triggers the engine’s refresh path, which fails and marks the channel refreshNeeded; the user must reconnect.
  • Reconnect id mismatch. After setup, internal_id is the Instagram id. If the OAuth callback is called with ?refresh=<ig id>, save() compares it to authenticate()’s /me id, which is the Facebook user id, and rejects with “Please refresh the channel that needs to be refreshed”. Reconnect by creating a new channel. The reconnect() implementation (which re-derives the page token) is only used for root/sub-account setups and Instagram channels have no root.
  • Container status polling is one read per check. check_post_status() reads each container’s status_code once and returns Pending on IN_PROGRESS. finalize_post() creates the carousel parent and hands back to the workflow, so the parent is also polled. There is no long sleep inside the durable path; the blocking post() compatibility path sleeps 30 s.
  • ERROR/EXPIRED containers. container_status() maps the status text through instagram_handle_errors(). A Disconnect classification becomes a disconnect; everything else becomes a bad-body error with the platform message, falling back to “Instagram could not process the media”.
  • media_publish blocked but live. Instagram sometimes returns 2207051 (“action is blocked”) after actually publishing the container. finalize_post() probes the container; if it reports PUBLISHED, it reads the newest /{ig-id}/media?fields=id&limit=1 and treats that as success instead of failing a live post. If the probe fails, the original error is returned.
  • Carousel parent crash safety. If a run dies after creating the carousel parent, the id is stored in pending.carousel_id; the next check polls it instead of creating a second parent. Orphan containers are invisible and expire after 24 hours.
  • Stories are not carousels. A multi-media story is stored as post_type: "stories" and each container is published separately, skipping containers already PUBLISHED. The final post_id is the last published media id, or the last container id if everything was already published.
  • Per-media processing means slow carousels. All child containers are created in parallel (try_join_all), but the status check reads them sequentially. A 10-video carousel can take several minutes.
  • Collaborators are handled carefully. A leading @ is stripped; the JSON array is only sent for non-stories; child items never get the parameter (Instagram rejects it on children); the parent carousel container gets it. 2207018 is mapped to a collaborator-specific message.
  • Audio is silently dropped outside its supported shape. audio_configuration is only added when domain == graph.facebook.com, non-story, exactly one media, and the media is a video. On the standalone provider (graph.instagram.com) the same settings object is accepted by validation but never sent.
  • Trial Reels can fail for account-specific reasons. 2207081 (account does not support Trial Reels) and 2207078 (publish limit) are mapped to actionable messages. The account must be eligible; there is no pre-flight check.
  • Analytics. Account analytics makes two calls: follower_count,reach with period=day, then metric_type=total_value&metric=likes,views,comments,shares,saves,replies. Post analytics requests views,reach,saved,likes,comments,shares and reads values[0].value. Every error is swallowed and returns []. Percentage change is hard-coded to 5.0 for account metrics and 0.0 for post metrics.
  • Insight metric names drift. saved vs saves, shares availability, and views on older media vary by media product type and Graph version. An unsupported metric can make the whole insights response an error, which the provider turns into an empty analytics list.
  • Rate limits. Instagram publishing is capped at 100 posts per 24 hours per account (carousel = 1). General Graph calls use 4800 × impressions per rolling 24 hours per app/user pair. Comments have their own limits. max_concurrent_job = 400 is far above what the publishing cap allows in a day; it controls worker concurrency, not the daily quota.
  • Error mapping highlights. 2207050 restricted user; 2207003 media download timeout; 2207020 expired media; 2207026 unsupported video format; 2207028 carousel validation; 2207010 caption too long; 2207004 image too large; 2207005 unsupported image; 2207009/36003 aspect ratio; 2207042 daily limit; 2207001 spam; 1772107 comment rejected but post published; 2207082 audio on a silent video; 2207085 video processing; 2207077 download failed. (#200) is a Page permission error. “You cannot access the app till you log in to” and “Session key is malformed” produce a disconnect (checkpoint), not a refresh.
  • Graph version. v25.0 is compiled in. Meta’s 2026 App Dashboard defaults to newer versions; older versions remain callable for a transition period. Plan an upgrade before the version is retired.

References