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:
InstagramCoreis also used byinstagram-standalonewithgraph.instagram.com
Overview
| Property | Value |
|---|---|
| Identifier | instagram |
| Display name | Instagram\n(Facebook Business) |
| Connection mode | oauth (Facebook Login for Business) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; extra entries are posted as POST /{ig-media-id}/comments |
| Pending / async publishing | Yes. supports_pending = true; containers are created, polled to FINISHED, then media_publish |
| Analytics | Yes. supports_analytics = true; account insights |
| Post analytics | Yes. supports_post_analytics = true; media insights |
| Mentions | No. supports_mentions = false (the platform supports mentions, but the provider does not implement them) |
is_between_steps | true — the OAuth callback creates an unfinished channel; an Instagram account must be selected with channelSetupTool |
convert_to_jpeg | false — images are not converted, despite Instagram accepting only JPEG |
max_concurrent_job | 400 |
Character limit (max_length) | 2,200 characters |
| Graph API version | v25.0 |
| API hosts | https://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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
FACEBOOK_APP_ID | Yes | — | The Meta app ID, not the Instagram app ID. Shared with facebook. |
FACEBOOK_APP_SECRET | Yes | — | The Meta app secret. Shared with facebook. |
FACEBOOK_BASE_URL | No | https://graph.facebook.com | Graph API origin for containers, publishing, insights and page discovery. |
FACEBOOK_WEB_BASE_URL | No | https://www.facebook.com | Origin of the /v25.0/dialog/oauth login dialog. |
INSTAGRAM_WEB_BASE_URL | No | https://www.instagram.com | Defined by the shared core for the standalone provider; not used by this provider’s login flow. |
INSTAGRAM_API_BASE_URL | No | https://api.instagram.com | Defined by the shared core; not used by the Facebook Business flow. |
INSTAGRAM_GRAPH_BASE_URL | No | https://graph.instagram.com | Used only by instagram-standalone. |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Base of the redirect URI. |
JWT_SECRET | Yes in practice | empty | Encrypts pending OAuth state. |
RESTRICT_UPLOAD_DOMAINS | No | unset | Global 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
- Go to https://developers.facebook.com/apps/creation/.
- Choose Other → Business and create the app under a Business portfolio. Public apps need Business Verification.
- Add the product Facebook Login for Business.
- Add the product Instagram and choose Instagram API setup with Facebook login.
- 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.
| Scope | Why Rustiz needs it |
|---|---|
instagram_basic | Read the Instagram professional account profile/media |
pages_show_list | Discover Facebook Pages and their linked Instagram accounts |
pages_read_engagement | Read Page metadata and linked IG account |
business_management | Discover Pages through Business Manager (owned_pages, client_pages) |
instagram_content_publish | Create containers and media_publish |
instagram_manage_comments | Post comments/replies |
instagram_manage_insights | Account 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
-
List the Instagram accounts reachable through the connected Pages:
{ "integrationId": "<unfinished channel id>", "methodName": "pages", "dataSchema": [] }instagramexposes two functions:Function Description Input 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"}pagesreturns:[ { "pageId": "1234567890", "id": "17841400000000000", "name": "My Instagram", "picture": { "data": { "url": "https://…" } } } ]pageIdis the Facebook Page id;idis the Instagram professional account id used for publishing. -
Choose an account and call:
{ "integrationId": "<unfinished channel id>", "data": { "id": "17841400000000000", "pageId": "1234567890" } }channelSetupToolpassesdatastraight toInstagram::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 clearsinBetweenSteps.
- reads
-
Verify with
integrationList: the channel name is the Instagram name,profileis the username andinBetweenStepsisfalse.
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
customFieldsroutes toconnect_custom()and is treated as an OAuth code. Never send it toinstagram.externalUrlis 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
}
| Field | Required | Behaviour |
|---|---|---|
post_type | Yes | post = feed image/video/carousel; story = story media. A story with multiple media publishes each as a separate story |
is_trial_reel | No | Only for a single video. Adds trial_params={"graduation_strategy": …}. The account must support Trial Reels |
graduation_strategy | No | Defaults to MANUAL when is_trial_reel is true |
collaborators | No | Array of {label}. A leading @ is stripped. Sent as a JSON array; not allowed on stories or carousel children; allowed on the carousel parent |
audio | No | Audio 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()
settingsmust be an object.post_typemust be exactlypostorstory; it is not defaulted.is_trial_reel, when non-null, must be boolean or string (the string is parsed byas_bool).graduation_strategy, when non-null, must beMANUALorSS_PERFORMANCE.collaborators, when non-null, must be an array; each item must be an object with a non-empty stringlabel.audio, when non-null:- must be an object;
idmust be a string (an empty string is accepted and means “no audio”);title,artist,imagemust be strings when present;audio_volumeandvideo_volumemust be numbers in[0, 100].
graduation_strategyis validated even whenis_trial_reelis false.- No maximum caption length is checked here;
max_lengthhandles the engine side.
check_validity()
The shared instagram_check_validity(posts, settings, carousel_checks = true)
enforces:
- At least one media item in the first post.
- At most 10 media items in the first post: “Instagram carousel only supports up to 10 media attachments”.
- If
is_trial_reel:- only one media;
- that media must be a video (
pathcontains.mp4).
- Alt text on any item must be ≤ 1,000 UTF-16 code units.
- 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_jpegis 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):
| Media | Rule |
|---|---|
| Feed image | JPEG only, max 8 MB, aspect ratio 4:5 to 1.91:1, width 320–1,440 px (Meta scales outside that range) |
| Carousel | 2–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 video | Published 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 image | JPEG, ≤ 8 MB, 9:16 recommended |
| Story video | MP4/MOV, 3–60 s, ≤ 100 MB, 9:16 recommended |
| Alt text | Images only (single or carousel item), max 1,000 characters, ignored on videos and stories |
| Collaborators | Up to 3 on a feed post/Reel; account must be public and allow invites |
| Audio | Single-video Reel only; not on stories, images or carousels |
How Rustiz builds each container:
- Single image:
POST /{ig-id}/media?image_url=…&caption=…(nomedia_type, so Instagram defaults toIMAGE). - Single video, feed:
video_url=…&media_type=REELS, pluscover_urlwhen a thumbnail is provided, otherwisethumb_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=VIDEOfor videos,is_carousel_item=true, no caption. - Carousel parent: created later in
finalize_post()withcaption,media_type=CAROUSEL,children=<ids>, optionalcollaborators. - Multiple stories: each media is its own
STORIEScontainer; 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:
- The first entry creates the containers and publishes the feed post/Reel/story.
- Each later entry calls
POST /{post-id}/comments?message=…. comment()ignoreslast_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.- 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_tokensplits 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 emptypage.access_token; it builds"___<user token>". Publishing then fails as the Page. The Facebook provider does check for this case. - Empty
pageslist can hide a permission problem.InstagramCore::pages()fetches each Page’s Instagram profile separately and useslet 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
channelSetupToolruns,internal_idis the user id, not an Instagram account id, and publishing targets the wrong object. - JPEG-only means JPEG-only.
convert_to_jpegisfalse, and the provider never re-encodes images. Upload PNG/WebP and Instagram returns2207005(“Unsupported image format”) or2207004(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 Mediareference 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_errorsmaps2207042to “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 withexpires_in: 0;refresh_cronisfalse. The long-lived user token is stored with a hard-coded 59-day expiry. When it expires, a190orREVOKED_ACCESS_TOKENtriggers the engine’s refresh path, which fails and marks the channelrefreshNeeded; the user must reconnect. - Reconnect id mismatch. After setup,
internal_idis the Instagram id. If the OAuth callback is called with?refresh=<ig id>,save()compares it toauthenticate()’s/meid, which is the Facebook user id, and rejects with “Please refresh the channel that needs to be refreshed”. Reconnect by creating a new channel. Thereconnect()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’sstatus_codeonce and returns Pending onIN_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 blockingpost()compatibility path sleeps 30 s. ERROR/EXPIREDcontainers.container_status()maps the status text throughinstagram_handle_errors(). ADisconnectclassification becomes a disconnect; everything else becomes a bad-body error with the platform message, falling back to “Instagram could not process the media”.media_publishblocked but live. Instagram sometimes returns2207051(“action is blocked”) after actually publishing the container.finalize_post()probes the container; if it reportsPUBLISHED, it reads the newest/{ig-id}/media?fields=id&limit=1and 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 alreadyPUBLISHED. The finalpost_idis 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.2207018is mapped to a collaborator-specific message. - Audio is silently dropped outside its supported shape.
audio_configurationis only added whendomain == 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) and2207078(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,reachwithperiod=day, thenmetric_type=total_value&metric=likes,views,comments,shares,saves,replies. Post analytics requestsviews,reach,saved,likes,comments,sharesand readsvalues[0].value. Every error is swallowed and returns[]. Percentage change is hard-coded to5.0for account metrics and0.0for post metrics. - Insight metric names drift.
savedvssaves,sharesavailability, andviewson 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 × impressionsper rolling 24 hours per app/user pair. Comments have their own limits.max_concurrent_job = 400is far above what the publishing cap allows in a day; it controls worker concurrency, not the daily quota. - Error mapping highlights.
2207050restricted user;2207003media download timeout;2207020expired media;2207026unsupported video format;2207028carousel validation;2207010caption too long;2207004image too large;2207005unsupported image;2207009/36003aspect ratio;2207042daily limit;2207001spam;1772107comment rejected but post published;2207082audio on a silent video;2207085video processing;2207077download 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.0is 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
- Meta app creation: https://developers.facebook.com/apps/creation/
- Instagram Platform overview (three setups): https://developers.facebook.com/docs/instagram-platform/overview
- Instagram API with Facebook Login: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-facebook-login
- Content publishing (containers, carousel, Reels, stories): https://developers.facebook.com/docs/instagram-platform/content-publishing
- IG User Media reference (image/video specs): https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media
- Permissions reference: https://developers.facebook.com/docs/permissions/reference
- App modes: https://developers.facebook.com/docs/development/build-and-test/app-modes
- Access levels: https://developers.facebook.com/docs/graph-api/overview/access-levels
- App Review: https://developers.facebook.com/docs/app-review
- Postiz Instagram setup: https://docs.postiz.com/providers/instagram
- Postiz Instagram settings: https://docs.postiz.com/public-api/providers/instagram