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 — Standalone (instagram-standalone)

This provider uses the Instagram API with Instagram Login (Business Login for Instagram). It connects directly to an Instagram professional account with Instagram credentials, without a Facebook Page. Publishing goes to graph.instagram.com, and the token is an Instagram User access token.

The publishing core is shared with instagram; only auth, refresh and media validity differ.

Aspectinstagraminstagram-standalone
App credentialsFACEBOOK_APP_ID / FACEBOOK_APP_SECRETINSTAGRAM_APP_ID / INSTAGRAM_APP_SECRET
Login productFacebook Login for Business + Instagram API setup with Facebook loginBusiness Login for Instagram + API setup with Instagram login
Facebook PageRequiredNot required
API hostgraph.facebook.comgraph.instagram.com
Redirect URI{FRONTEND_URL}/integrations/social/instagram{redirectmeto or FRONTEND_URL}/integrations/social/instagram-standalone
Stored token{page_token}___{user_token}Instagram user access token
is_between_stepstrue (select an IG account)false
refresh_cronfalse (no automatic refresh)true (ig_refresh_token)
Audio in ReelsYes (Facebook Graph only)Silently ignored
Local carousel cap10None (platform enforces)
  • Rust source: crates/rustiz-providers/src/instagram_standalone.rs
  • Shared core: crates/rustiz-providers/src/instagram.rs (InstagramCore)
  • Upstream port: instagram.standalone.provider.ts
  • Editor: Normal
  • Connection mode: oauth (Business Login for Instagram)

Overview

PropertyValue
Identifierinstagram-standalone
Display nameInstagram\n(Standalone)
Connection modeoauth (Business Login for Instagram)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; extra entries become comments on the root media
Pending / async publishingYes. supports_pending = true; containers → poll → media_publish
AnalyticsYes. supports_analytics = true
Post analyticsYes. supports_post_analytics = true
MentionsNo. supports_mentions = false
is_between_stepsfalse — the account is stored directly, no page/account selection step
convert_to_jpegfalse
max_concurrent_job200 (“standalone has stricter limits”)
Character limit (max_length)2,200
Graph API versionv25.0
API hostshttps://graph.instagram.com (INSTAGRAM_GRAPH_BASE_URL), https://api.instagram.com (INSTAGRAM_API_BASE_URL), https://www.instagram.com (INSTAGRAM_WEB_BASE_URL)
Token refreshYes. refresh_cron = true; ig_refresh_token

Server Environment Variables

required_env() returns exactly:

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

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
INSTAGRAM_APP_IDYes—The Instagram App ID from App Dashboard → Instagram → API setup with Instagram login → Business login settings. Not the Meta/Facebook app ID.
INSTAGRAM_APP_SECRETYes—The Instagram App Secret from the same panel (32 characters).
INSTAGRAM_GRAPH_BASE_URLNohttps://graph.instagram.comHost for /v25.0/me, /access_token, /refresh_access_token and all publishing calls.
INSTAGRAM_API_BASE_URLNohttps://api.instagram.comHost for the code → short-lived-token exchange (POST /oauth/access_token).
INSTAGRAM_WEB_BASE_URLNohttps://www.instagram.comHost of the authorization window (/oauth/authorize).
FRONTEND_URLYes in practicehttp://localhost:5000Base of the redirect URI, wrapped in redirectmeto.com when it is not HTTPS.
JWT_SECRETYes in practiceemptyEncrypts pending OAuth state.
RESTRICT_UPLOAD_DOMAINSNounsetGlobal upload guard. Instagram fetches media from the URL, so it must be public.

rustiz providers --status checks only INSTAGRAM_APP_ID and INSTAGRAM_APP_SECRET. It cannot tell whether the app has API setup with Instagram login configured or whether the tester invitation was accepted.

Step-by-Step Developer App Setup

1. Create the Meta app

  1. Go to https://developers.facebook.com/apps/creation/.
  2. Choose Other → Business (a Business portfolio is recommended).
  3. Add the product Instagram.
  4. In the Instagram product, choose API setup with Instagram login.
  5. Open 3. Set up Instagram business login → Business login settings. Copy the Instagram App ID and Instagram App Secret.

This is a different app ID/secret pair from the Meta app ID used by facebook and instagram (Facebook Business). The App Dashboard displays both; make sure you copy the pair from the Instagram API setup with Instagram login panel.

2. Redirect URI (exact, including redirectmeto.com)

InstagramStandalone::redirect_uri() is:

#![allow(unused)]
fn main() {
let frontend = self.ctx.config.frontend_url();
let base = if frontend.contains("https") {
    frontend
} else {
    format!("https://redirectmeto.com/{frontend}")
};
format!("{base}/integrations/social/instagram-standalone")
}

Config::frontend_url() trims the trailing slash, so the resulting values are:

FRONTEND_URLRegistered redirect URI
https://social.example.comhttps://social.example.com/integrations/social/instagram-standalone
http://localhost:5000https://redirectmeto.com/http://localhost:5000/integrations/social/instagram-standalone
http://localhost:4200https://redirectmeto.com/http://localhost:4200/integrations/social/instagram-standalone
http://127.0.0.1:5000https://redirectmeto.com/http://127.0.0.1:5000/integrations/social/instagram-standalone

The Instagram login window requires an HTTPS redirect URI, which is why the provider tunnels a plain-HTTP local frontend through redirectmeto.com. Register the redirectmeto URL, not the localhost URL, in the App Dashboard. After the human authorizes, redirectmeto.com 302s the browser back to http://localhost:…/integrations/social/instagram-standalone?code=…&state=….

Quirks:

  • The check is frontend.contains("https") — a substring test, not a URL scheme parse. Any FRONTEND_URL containing the letters https skips the wrapper. This is almost always what you want for https://…, but it also matches odd hosts/paths containing https.
  • There is no trailing slash between the frontend and the path.
  • If FRONTEND_URL already has a path prefix (https://example.com/app), the callback becomes https://example.com/app/integrations/social/instagram-standalone.

3. Scopes requested and checked

InstagramStandalone::new() sets:

#![allow(unused)]
fn main() {
info.scopes = vec![
    "instagram_business_basic",
    "instagram_business_content_publish",
    "instagram_business_manage_comments",
    "instagram_business_manage_insights",
];
}

The authorize URL joins them with commas:

https://www.instagram.com/oauth/authorize
  ?enable_fb_login=0
  &client_id={INSTAGRAM_APP_ID}
  &redirect_uri={redirectmeto or https URI}
  &response_type=code
  &scope=instagram_business_basic,instagram_business_content_publish,instagram_business_manage_comments,instagram_business_manage_insights
  &state={state}

There is no force_reauth parameter, and enable_fb_login=0 hides the “Log in with Facebook” option so the account is always an Instagram login.

After the token exchange, check_scopes_value() validates the permissions field from the short-lived token response:

  • if it is an array, every required scope must be present;
  • if it is a string, check_scopes() splits on comma or space;
  • anything else → NotEnoughScopes.

instagram_business_manage_messages is not requested; Rustiz does not send DMs.

ScopeWhy Rustiz needs it
instagram_business_basicRead the profile (/me) and media
instagram_business_content_publishCreate containers and publish
instagram_business_manage_commentsPost comments/replies
instagram_business_manage_insightsAccount and media insights

4. Token flow performed by Rustiz

  1. Authorization code → POST https://api.instagram.com/oauth/access_token as multipart/form-data with client_id, client_secret, grant_type=authorization_code, redirect_uri, code.
  2. Short-lived → long-lived → GET https://graph.instagram.com/access_token?grant_type=ig_exchange_token&client_id=…&client_secret=…&access_token=…
  3. Profile → GET https://graph.instagram.com/v25.0/me?fields=user_id,username,name,profile_picture_url&access_token=…
  4. Stores id = me.user_id, name, picture, username, and access_token = refresh_token = long-lived token, expires_in = 58 days.

Response-shape risk: Meta’s Business Login sample returns the short-lived token under {"data":[{"access_token":…,"user_id":…,"permissions":…}]}, but this provider reads short["access_token"] and short["permissions"] at the top level. If your app receives the wrapped shape, authenticate_code() sees an empty token and fails with “Instagram token exchange failed”. Check the raw response with curl before debugging anything else; a small compatibility shim (read data[0] when present) is the likely fix.

5. App roles: Instagram Tester

While the app is in Development mode, only users with a role can authorize it. For Instagram Login that means an Instagram Tester:

  1. App Dashboard → App roles → Roles → Testers → Add People.
  2. Choose Instagram Tester and enter the Instagram username.
  3. On the Instagram account, open Settings → Website permissions → Apps and websites → Invites and accept the invitation (https://www.instagram.com/accounts/manage_access/).
  4. Retry the connection.

The common error “Insufficient developer role” means this invitation was not sent or not accepted for the exact account.

6. Development mode vs Live / App Review

  • Development mode (default): tester accounts only. Standard Access is enough for accounts you own/manage.
  • Live mode + Advanced Access: required to serve Instagram professional accounts you do not own or manage. It needs App Review + Business Verification.
  • App Review needs a screencast of the complete Instagram login, permission grant, and a published post/Reel/carousel plus a comment.
  • Permissions unused for 90 days may expire and must be re-granted.

7. Sandbox / testing

There is no separate sandbox host. Use the real Instagram API with a test professional account added as an Instagram Tester. Containers are invisible until media_publish, and unpublished containers expire after 24 hours, so a failed test leaves no public content.

Connecting a Channel (CLI & MCP)

CLI

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

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

Because is_between_steps is false, the account is usable as soon as the callback completes. There is no triggerTool / channelSetupTool step and the CLI returns the finished channel:

{
  "url": "https://www.instagram.com/oauth/authorize?...",
  "connected": true,
  "channel": {
    "id": "…",
    "name": "My Studio",
    "identifier": "instagram-standalone",
    "profile": "mystudio",
    "type": "social",
    "refreshNeeded": false
  }
}

MCP connectChannelTool

{ "provider": "instagram-standalone" }

Response:

{
  "connected": false,
  "pending": true,
  "connectionId": "…",
  "url": "https://www.instagram.com/oauth/authorize?enable_fb_login=0&client_id=…&redirect_uri=…&response_type=code&scope=…&state=…",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Poll:

{ "connectionId": "…" }

integrationSchema reports oauth, headless: false, requiresHuman: true, requiresExternalUrl: false. There is no follow-up triggerTool or channelSetupTool.

What not to send

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

Post Settings & Media Validation

instagram-standalone reuses the exact schema, validator and media settings of instagram:

#![allow(unused)]
fn main() {
fn settings_schema(&self) -> Value {
    crate::instagram::instagram_settings_schema()
}
fn validate_settings(&self, settings: &Value) -> Result<(), String> {
    crate::instagram::instagram_validate_settings(settings)
}
}
{
  "type": "object",
  "properties": {
    "post_type": { "type": "string", "enum": ["post", "story"] },
    "is_trial_reel": { "type": "boolean" },
    "graduation_strategy": { "type": "string", "enum": ["MANUAL", "SS_PERFORMANCE"] },
    "collaborators": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": { "label": { "type": "string" } },
        "required": ["label"]
      }
    },
    "audio": {
      "type": "object",
      "properties": {
        "id": { "type": "string" },
        "title": { "type": "string" },
        "artist": { "type": "string" },
        "image": { "type": "string" },
        "audio_volume": { "type": "number", "minimum": 0, "maximum": 100 },
        "video_volume": { "type": "number", "minimum": 0, "maximum": 100 }
      },
      "required": ["id"]
    }
  },
  "required": ["post_type"],
  "additionalProperties": false
}

The descriptions are identical to the Instagram settings table: post_type is required and has no default; is_trial_reel is a boolean; graduation_strategy is MANUAL or SS_PERFORMANCE; each collaborator needs a non-empty label; audio.id must be a string and the volumes must be 0–100.

check_validity()

The standalone provider calls the shared validator with carousel_checks = false:

#![allow(unused)]
fn main() {
instagram_check_validity(posts, settings, false)
}

That means:

  • at least one media in the first post;
  • if is_trial_reel: exactly one video;
  • alt text ≤ 1,000 UTF-16 units;
  • no 10-item carousel cap (the platform still enforces its own limit; the current docs say 10 in most places, 20 in one section);
  • no audio rules (the standalone API cannot attach Instagram audio anyway).

The audio setting remains in the schema for compatibility, validates normally, and is silently ignored at publish time because create_container() only adds audio_configuration when the host is graph.facebook.com.

Media requirements

Same platform constraints as the Facebook Business provider:

  • Images: JPEG only, ≤ 8 MB, aspect ratio 4:5–1.91:1, width 320–1,440 px.
  • Feed video: published as a Reel, MP4/MOV, 3 s–15 min, ≤ 300 MB, H.264/HEVC + AAC, 23–60 fps.
  • Story image: JPEG, ≤ 8 MB, 9:16 recommended.
  • Story video: 3–60 s, ≤ 100 MB.
  • Carousel: 2–10 (Rustiz does not cap it here; the API does).
  • Alt text: images only, ≤ 1,000 characters.
  • Public media URLs: Instagram cURLs the URL; it must be publicly reachable.

convert_to_jpeg is false, so PNG/WebP are not converted and will be rejected by Instagram. Upload JPEG.

Threads

Same as instagram: the first entry is the feed post/Reel/story; later entries are comments on the root media. comment() ignores last_comment_id, so all comments are top-level comments on the root post.

Plugs

No @Plug or @PostPlug methods. run_plug() / run_post_plug() return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • Different app credentials. INSTAGRAM_APP_ID/INSTAGRAM_APP_SECRET are the Instagram Login pair. Using the Meta/Facebook app ID here produces an “Invalid client_id” or a login window that never authorizes the app.
  • Redirect URI must be HTTPS and exact. Register the fully expanded https://redirectmeto.com/http://localhost:…/integrations/social/instagram-standalone value when developing locally. Registering only http://localhost:… fails because Instagram requires HTTPS.
  • The redirectmeto.com wrapper is decided at request time. If you change FRONTEND_URL from http://… to https://…, the redirect URI changes and must be re-registered in the App Dashboard.
  • Token response shape. As noted above, the code reads top-level access_token/permissions; Meta’s current sample nests them under data[0]. This is the most likely cause of a connection that fails before /me is ever called.
  • Refresh actually works here. refresh_cron = true; the hourly sweep refreshes any token expiring within 2 hours via GET https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=…. Meta requires the token to be at least 24 hours old and the instagram_business_basic grant to still be valid. A token not refreshed within 60 days cannot be refreshed; reconnect.
  • 58-day expiry. expires_in is hard-coded to 58 * 24 * 3600 even though Meta says 60 days. The 2-day margin is deliberate; do not “fix” it without accounting for the sweep window.
  • me returns user_id, not id. The provider stores me.user_id as the internal id (unlike the Facebook Business provider, which stores the IG id from a Page lookup). username is stored as the profile. id is not requested, so a response without user_id stores an empty id. The code also expects a flat object (me["user_id"]); some Meta samples wrap the user in a data array. If the channel name/id come back empty, inspect this response shape.
  • app_id/app_secret are read on every auth/refresh. Changing INSTAGRAM_APP_ID or INSTAGRAM_APP_SECRET affects existing channels on the next refresh, not just new connections.
  • The client secret is sent in a query string for the long-lived exchange (GET /access_token?...&client_secret=…). That is Meta’s documented shape, but it means the secret can appear in proxy/access logs. Ensure the server’s egress logging redacts query strings.
  • No pages / audioSearch helper. functions() is not overridden, so triggerTool cannot list accounts or search audio. The channel is complete after OAuth.
  • Same publishing core, different host. Containers, carousel parents, stories, comments, insights and the 2207051 “blocked but published” probe all run against graph.instagram.com. Any Graph API difference between the two hosts (metric availability, audio, collaborators) surfaces here first.
  • No carousel cap locally. A 25-item carousel passes Rustiz validation and fails at Instagram. Keep the client at 10 or fewer unless you have verified the account’s current limit.
  • Audio silently dropped. A post with audio.id set validates and publishes without audio; no warning is emitted. Use the Facebook Business provider if you need Instagram music/original sound.
  • Rate limits. 100 API-published posts per 24-hour moving period (carousel = 1). General Graph calls are capped by 4800 × impressions per 24 hours per app/user pair. max_concurrent_job = 200 is worker concurrency, not a quota.
  • Error mapping is shared. See the Instagram error table: 2207004/2207005/2207009 image errors, 2207028 carousel, 2207042 daily limit, 2207051 blocked, 2207001 spam, 1772107 comment rejection, (#200) missing Page permission, checkpoint → disconnect. The “maximum of 25 posts per day” text for 2207042 is stale.
  • Analytics. Same two-call account insights and one-call media insights as the Facebook Business provider. All errors are swallowed and return []; percentage change is hard-coded. saved/shares/views availability varies by media type and API version.
  • App Review is per-permission. Adding instagram_business_manage_insights to the requested scopes does not grant it; each permission needs approval for non-tester users, and the app must be Live.

References