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.
| Aspect | instagram | instagram-standalone |
|---|---|---|
| App credentials | FACEBOOK_APP_ID / FACEBOOK_APP_SECRET | INSTAGRAM_APP_ID / INSTAGRAM_APP_SECRET |
| Login product | Facebook Login for Business + Instagram API setup with Facebook login | Business Login for Instagram + API setup with Instagram login |
| Facebook Page | Required | Not required |
| API host | graph.facebook.com | graph.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_steps | true (select an IG account) | false |
refresh_cron | false (no automatic refresh) | true (ig_refresh_token) |
| Audio in Reels | Yes (Facebook Graph only) | Silently ignored |
| Local carousel cap | 10 | None (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
| Property | Value |
|---|---|
| Identifier | instagram-standalone |
| Display name | Instagram\n(Standalone) |
| Connection mode | oauth (Business Login for Instagram) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; extra entries become comments on the root media |
| Pending / async publishing | Yes. supports_pending = true; containers → poll → media_publish |
| Analytics | Yes. supports_analytics = true |
| Post analytics | Yes. supports_post_analytics = true |
| Mentions | No. supports_mentions = false |
is_between_steps | false — the account is stored directly, no page/account selection step |
convert_to_jpeg | false |
max_concurrent_job | 200 (“standalone has stricter limits”) |
Character limit (max_length) | 2,200 |
| Graph API version | v25.0 |
| API hosts | https://graph.instagram.com (INSTAGRAM_GRAPH_BASE_URL), https://api.instagram.com (INSTAGRAM_API_BASE_URL), https://www.instagram.com (INSTAGRAM_WEB_BASE_URL) |
| Token refresh | Yes. 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
INSTAGRAM_APP_ID | Yes | — | The Instagram App ID from App Dashboard → Instagram → API setup with Instagram login → Business login settings. Not the Meta/Facebook app ID. |
INSTAGRAM_APP_SECRET | Yes | — | The Instagram App Secret from the same panel (32 characters). |
INSTAGRAM_GRAPH_BASE_URL | No | https://graph.instagram.com | Host for /v25.0/me, /access_token, /refresh_access_token and all publishing calls. |
INSTAGRAM_API_BASE_URL | No | https://api.instagram.com | Host for the code → short-lived-token exchange (POST /oauth/access_token). |
INSTAGRAM_WEB_BASE_URL | No | https://www.instagram.com | Host of the authorization window (/oauth/authorize). |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Base of the redirect URI, wrapped in redirectmeto.com when it is not HTTPS. |
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 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
- Go to https://developers.facebook.com/apps/creation/.
- Choose Other → Business (a Business portfolio is recommended).
- Add the product Instagram.
- In the Instagram product, choose API setup with Instagram login.
- 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_URL | Registered redirect URI |
|---|---|
https://social.example.com | https://social.example.com/integrations/social/instagram-standalone |
http://localhost:5000 | https://redirectmeto.com/http://localhost:5000/integrations/social/instagram-standalone |
http://localhost:4200 | https://redirectmeto.com/http://localhost:4200/integrations/social/instagram-standalone |
http://127.0.0.1:5000 | https://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. AnyFRONTEND_URLcontaining the lettershttpsskips the wrapper. This is almost always what you want forhttps://…, but it also matches odd hosts/paths containinghttps. - There is no trailing slash between the frontend and the path.
- If
FRONTEND_URLalready has a path prefix (https://example.com/app), the callback becomeshttps://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.
| Scope | Why Rustiz needs it |
|---|---|
instagram_business_basic | Read the profile (/me) and media |
instagram_business_content_publish | Create containers and publish |
instagram_business_manage_comments | Post comments/replies |
instagram_business_manage_insights | Account and media insights |
4. Token flow performed by Rustiz
- Authorization code →
POST https://api.instagram.com/oauth/access_tokenasmultipart/form-datawithclient_id,client_secret,grant_type=authorization_code,redirect_uri,code. - Short-lived → long-lived →
GET https://graph.instagram.com/access_token?grant_type=ig_exchange_token&client_id=…&client_secret=…&access_token=… - Profile →
GET https://graph.instagram.com/v25.0/me?fields=user_id,username,name,profile_picture_url&access_token=… - Stores
id = me.user_id,name,picture,username, andaccess_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 readsshort["access_token"]andshort["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 (readdata[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:
- App Dashboard → App roles → Roles → Testers → Add People.
- Choose Instagram Tester and enter the Instagram username.
- On the Instagram account, open Settings → Website permissions → Apps and websites → Invites and accept the invitation (https://www.instagram.com/accounts/manage_access/).
- 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
customFieldsroutes toconnect_custom()and is used as an OAuth code. Never send it.externalUrlis 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_SECRETare 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-standalonevalue when developing locally. Registering onlyhttp://localhost:…fails because Instagram requires HTTPS. - The
redirectmeto.comwrapper is decided at request time. If you changeFRONTEND_URLfromhttp://…tohttps://…, 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 underdata[0]. This is the most likely cause of a connection that fails before/meis ever called. - Refresh actually works here.
refresh_cron = true; the hourly sweep refreshes any token expiring within 2 hours viaGET 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 theinstagram_business_basicgrant to still be valid. A token not refreshed within 60 days cannot be refreshed; reconnect. - 58-day expiry.
expires_inis hard-coded to58 * 24 * 3600even though Meta says 60 days. The 2-day margin is deliberate; do not “fix” it without accounting for the sweep window. mereturnsuser_id, notid. The provider storesme.user_idas the internal id (unlike the Facebook Business provider, which stores the IG id from a Page lookup).usernameis stored as the profile.idis not requested, so a response withoutuser_idstores an empty id. The code also expects a flat object (me["user_id"]); some Meta samples wrap the user in adataarray. If the channel name/id come back empty, inspect this response shape.app_id/app_secretare read on every auth/refresh. ChangingINSTAGRAM_APP_IDorINSTAGRAM_APP_SECRETaffects 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/audioSearchhelper.functions()is not overridden, sotriggerToolcannot 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 againstgraph.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.idset 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 × impressionsper 24 hours per app/user pair.max_concurrent_job = 200is worker concurrency, not a quota. - Error mapping is shared. See the
Instagram error table:
2207004/2207005/2207009image errors,2207028carousel,2207042daily limit,2207051blocked,2207001spam,1772107comment rejection,(#200)missing Page permission, checkpoint → disconnect. The “maximum of 25 posts per day” text for2207042is 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/viewsavailability varies by media type and API version. - App Review is per-permission. Adding
instagram_business_manage_insightsto the requested scopes does not grant it; each permission needs approval for non-tester users, and the app must be Live.
References
- Meta app creation: https://developers.facebook.com/apps/creation/
- Instagram API with Instagram Login: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login
- Business Login for Instagram (authorize + token endpoints): https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login
- Content publishing: https://developers.facebook.com/docs/instagram-platform/content-publishing
- IG User Media reference: 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
- App Review: https://developers.facebook.com/docs/app-review
- Postiz Instagram setup (standalone section): https://docs.postiz.com/providers/instagram
- Postiz Instagram settings: https://docs.postiz.com/public-api/providers/instagram