Instagram API with Unified Login: Integration Guide
The short answer
Integrating Instagram API with Unified Login means configuring consent in Meta, exchanging an authorization code for a system-user token, and discovering the Instagram accounts that token can access. You then subscribe the selected account to webhooks and use its ID for feature calls on graph.facebook.com.
This guide follows the sequence we implemented when moving Adeli to Unified Login in October 2026. Examples use generic identifiers and server-side fetch. Treat them as building blocks; you still need your own OAuth server around them. Read the definition of Unified Login for terminology or the migration checklist before changing existing connections.
What do I need before starting the integration?
Unified Login requires a portfolio-owned Meta app using Facebook Login for Business, plus an eligible Instagram account and HTTPS endpoints. Meta's get started guide lists the prerequisites.
- App setup. Add the Manage messaging & content on Instagram use case and make sure you have an admin role. Apps still on classic Facebook Login must switch to Facebook Login for Business.
- Test account. Use a professional Instagram account and accept its Instagram Tester invitation. Meta does not support test users for this flow.
- Endpoints. Prepare an HTTPS callback for OAuth and an HTTPS webhook receiver that verifies notifications.
- Outside customers. Get Advanced Access to the configured permissions and put the app in Live mode. Standard Access supports testing with app-role users.
If owning the app and review is the blocker, Adeli's Instagram integration removes those responsibilities from your team. The remaining steps are for developers maintaining a direct Meta integration.
Common mistakes: what are developers running into?
Developers report missing setup controls in new apps and account discovery failures after consent succeeds. Each excerpt points to a different check to run before you treat a token as a working connection.
the option "API setup with Instagram login" does not appear in any newly created app. Only "API setup with Facebook login" is offered.
— Dei on the Meta Developer Community, October 7, 2026
The returned token passes app ID, SYSTEM_USER type, scope and client_business_id checks, but GET /me/assigned_instagram_accounts?fields=id,username returns data:[].
— Isha on the Meta Developer Community, October 28, 2026
So the asset assignment is recorded in Business Manager, but it is not exposed to the system user token.
— Lorenzo Pascucci on the Meta Developer Community, November 2, 2026
Meta's migration guide explains the missing Instagram Login setup for new apps. The two empty-account reports have no confirmed resolution; Steps 5–6 separate documented checks from conditions your app must reject safely.
Step 1: Create the Unified Login configuration
The Facebook Login for Business configuration controls the requested assets, permissions, and token lifetime. Open Use cases → Manage messaging & content on Instagram → Customize → API setup with Unified login and create a configuration, following Meta's get started guide.
- Choose the token settings. Confirm System-user access token and inspect its expiration. For Adeli, we selected a non-expiring token rather than scheduling a 60-day Instagram User token refresh.
- Choose the assets. We requested Instagram accounts only and checked Asset Required, so the flow could not finish without sharing an Instagram asset. We chose that setting ourselves; it doesn't mean you have to add an ad account.
- Review permissions. The default includes ads permissions. Remove permissions outside your product's use case and add the grants your features need.
- Save the redirect. Enter your HTTPS callback, select the configuration with Use this configuration, and copy its login URL and
config_id.
| Feature | Permission to configure |
|---|---|
| Profile and media reads | instagram_basic |
| Receiving and replying to DMs | instagram_manage_messages |
| Comment moderation | instagram_manage_comments |
| Publishing | instagram_content_publish |
| Insights | instagram_manage_insights |
| BISU token management API | business_management |
The permissions reference distinguishes these grants. We included business_management to keep a future token-management path open; no Adeli Instagram feature required it, and we did not implement rotation as part of this migration.
Configure the webhook callback and verify token on the Unified Login tab. Select fields your receiver handles: Adeli used messages, comments, and messaging_postbacks. Callback verification must return the challenge after checking the verify token; event handling should validate the signature against the raw request body, as Meta's webhook setup guide describes.
Step 2: Build the login URL with the configuration ID
The Unified Login authorization URL identifies the configuration through config_id; Adeli sends no scope parameter. Start from Meta's generated URL and bind an unpredictable, single-use state to the initiating session and intended customer.
const login = new URL('https://www.facebook.com/dialog/oauth');
login.search = new URLSearchParams({
client_id: metaAppId,
config_id: configurationId,
redirect_uri: redirectUri,
response_type: 'code',
prompt: 'login',
state: singleUseState,
}).toString();
// Redirect the browser to login.toString().Meta documents prompt=login to show both Instagram and Facebook choices even when the browser is already signed into Facebook. Adeli's shared authorization helper used auth_type=rerequest to request previously declined permissions again; that parameter does not replace the configuration.
At the callback, validate and consume state before exchanging the code. Handle cancellation explicitly and keep the target customer bound to the server-side session. A successful login must not let a caller substitute a different customer ID in the callback.
Step 3: Exchange the code on your server
Exchange the authorization code once using the Meta app ID and secret, not the separate Instagram app credentials. Meta's authentication guide specifies the Facebook Graph exchange.
The snippets share graphBase, token, and igId. Pick the Graph version on purpose from the ones your app supports, instead of copying one from an unrelated tutorial.
const graphBase = `https://graph.facebook.com/${graphVersion}`;
const exchangeUrl = new URL(`${graphBase}/oauth/access_token`);
exchangeUrl.search = new URLSearchParams({
client_id: metaAppId,
client_secret: metaAppSecret,
redirect_uri: redirectUri,
code,
}).toString();
const response = await fetch(exchangeUrl, {
signal: AbortSignal.timeout(15_000),
});
const payload = await response.json();
if (!response.ok || payload.error ||
typeof payload.access_token !== 'string' || !payload.access_token) {
throw new Error('Code exchange failed');
}
const token: string = payload.access_token;
const expiresAt = typeof payload.expires_in === 'number' &&
Number.isFinite(payload.expires_in) && payload.expires_in > 0
? new Date(Date.now() + payload.expires_in * 1000)
: null;Adeli handled an absent positive expires_in as null because our configuration was non-expiring. Meta's Unified Login page does not promise an exchange response with that field, so confirm your configured lifetime rather than interpreting every missing field as perpetual access. There is no second ig_exchange_token or fb_exchange_token hop in our Unified Login flow.
Store the credential encrypted on the server. Redact the exchange URL from request logging because the secret is in its query string, and keep tokens out of analytics and browser responses.
Step 4: Read the business identity and permissions
Store the client business ID alongside the system-user token, and keep the permissions you observed separate from the ones you configured. Under Unified Login, /me identifies the system user rather than the Instagram account.
Use a helper that rejects both HTTP and Graph errors without serializing tokens into error messages:
async function graphGet(path: string, fields?: string, after?: string) {
const url = new URL(`${graphBase}/${path}`);
if (fields) url.searchParams.set('fields', fields);
if (after) url.searchParams.set('after', after);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(15_000),
});
const body = await response.json();
if (!response.ok || body.error) {
throw Object.assign(new Error('Graph request failed'), {
code: body.error?.code,
subcode: body.error?.error_subcode,
});
}
return body;
}
const business = await graphGet('me', 'client_business_id');
const clientBusinessId = business.client_business_id ?? null;Meta documents client_business_id for its separate BISU token-management API. Do not mistake business.id, the system user's ID, for the Instagram account ID.
We also tried GET /me/permissions and kept entries whose status was granted. If the request failed or returned no grants, we recorded the configuration's expected permission list and logged which source supplied it.
// Mirror the saved configuration; do not label this list as observed consent.
const configuredPermissions = [
'instagram_basic', 'instagram_manage_messages',
'instagram_manage_comments', 'instagram_content_publish',
'instagram_manage_insights',
];
let grants: string[] = [];
try {
const result = await graphGet('me/permissions');
grants = (result.data ?? [])
.filter((entry: { status?: string }) => entry.status === 'granted')
.map((entry: { permission: string }) => entry.permission);
} catch {
// Record a sanitized diagnostic, never the request token.
}
const permissionEvidence = grants.length
? { source: 'observed', permissions: grants }
: { source: 'configuration', permissions: configuredPermissions };We added that fallback defensively. Meta's Unified Login docs do not specify this grants response, and our migration plan leaves the live probe unresolved. A configured permission is not proof of customer consent. Keep the source distinction, handle permission errors at feature use, and ask for reconnection when a required grant is missing.
Step 5: Discover every shared Instagram account
Read assigned Instagram accounts with pagination before deciding which account to connect. Meta's get started guide documents /me/assigned_instagram_accounts?fields=id,username, including cursor fields rather than a guaranteed next URL.
const accounts = new Map<string, { id: string; username?: string }>();
const cursors = new Set<string>();
let after: string | undefined;
let complete = false;
for (let page = 0; page < 100; page++) {
const result = await graphGet(
'me/assigned_instagram_accounts', 'id,username', after,
);
if (!Array.isArray(result.data)) throw new Error('Invalid account response');
for (const account of result.data) {
if (account.id != null) accounts.set(String(account.id), {
id: String(account.id), username: account.username,
});
}
const next = result.paging?.cursors?.after;
if (!result.data.length || !next || next === after) {
complete = true;
break;
}
if (cursors.has(next)) throw new Error('Repeated account cursor');
cursors.add(next);
after = next;
}
if (!complete) throw new Error('Account discovery exceeded safety limit');The 100-page cap is a safety limit for this example; Meta does not set one. Don't silently select from incomplete results. A cursor may remain on a final nonempty page, so an additional empty page can finish discovery.
If the list is empty, check whether the account is professional and belongs to the selected portfolio. Meta's troubleshooting page documents those checks. If they pass, collect the app ID, configuration ID, attempt time and timezone, login method, failing step, and visible error for support; the forum reports above don't point to a further fix.
Step 6: Select an account and read its profile
Select the customer's intended Instagram account explicitly; never assume the first result is correct. Adeli supported one Instagram connection per profile and implemented this rule:
- No accounts: stop and explain that no professional account was shared.
- One account: connect that account.
- Several accounts: retain the existing account if its ID matches a result; otherwise ask the customer to share only the intended account.
A product supporting several accounts can build a picker instead. Bind the selected ID to the authorized customer and verify it belongs to the discovered set.
const profile = await graphGet(
igId, 'id,username,name,profile_picture_url,followers_count,media_count',
);Save the chosen identity with its token, portfolio ID, configuration ID, and expiry semantics. Adeli's Facebook-host profile read omitted Instagram Login's user_id and account_type fields; copying the old profile request wholesale would request fields our Facebook-host implementation did not use.
Step 7: Subscribe the selected account to webhooks
After dashboard setup, subscribe the individual account with its system-user token. Instagram Login subscriptions do not transfer to Unified Login, according to Meta's Instagram Login migration guide.
curl -X POST "https://graph.facebook.com/$GRAPH_VERSION/$IG_ID/subscribed_apps" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
--data-urlencode "subscribed_fields=messages,comments,messaging_postbacks"
curl "https://graph.facebook.com/$GRAPH_VERSION/$IG_ID/subscribed_apps" \
-H "Authorization: Bearer $ACCESS_TOKEN"Set those shell variables locally; do not paste real credentials into shared examples. A successful subscription returns {"success":true}. Verify the fields with GET and trigger a real event on the account.
Adeli made subscription best-effort after saving the connection and logged failures prominently. For your product, track subscription health separately so a saved token does not hide broken message delivery. Our code comments also noted that replies failed as outside the messaging window when the account subscription was missing. Confirm events arrive before diagnosing that error as a timestamp problem.
Step 8: Make publishing, comments, and messaging calls
Feature requests use the selected Instagram account and the new token on graph.facebook.com. Test an operation requiring each permission rather than stopping after the profile read.
For a public JPEG, Meta's content publishing guide defines the container sequence:
curl -X POST "https://graph.facebook.com/$GRAPH_VERSION/$IG_ID/media" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
--data-urlencode "image_url=$PUBLIC_JPEG_URL" \
--data-urlencode "caption=Testing our Instagram connection"
# Set CONTAINER_ID to the returned id; inspect readiness before publishing.
curl "https://graph.facebook.com/$GRAPH_VERSION/$CONTAINER_ID?fields=status_code" \
-H "Authorization: Bearer $ACCESS_TOKEN"
# Run only after status_code is FINISHED.
curl -X POST "https://graph.facebook.com/$GRAPH_VERSION/$IG_ID/media_publish" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
--data-urlencode "creation_id=$CONTAINER_ID"Read comments on the resulting media, using the comments reference:
curl "https://graph.facebook.com/$GRAPH_VERSION/$MEDIA_ID/comments?fields=id,text,timestamp" \
-H "Authorization: Bearer $ACCESS_TOKEN"For messaging, the migration guide maps sends to /{ig-user-id}/messages. The messaging guide defines the recipient and message body; apply the migration guide's host change. Reply to a recent eligible inbound conversation using its Instagram-scoped sender ID, not a username or Page ID:
curl -X POST "https://graph.facebook.com/$GRAPH_VERSION/$IG_ID/messages" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"recipient":{"id":"<SENDER_IG_SCOPED_ID>"},"message":{"text":"Thanks for your message."}}'Keep Allow access to messages enabled on the Instagram account. Unified Login does not remove messaging eligibility or permission checks. Use an inbound test message, confirm its webhook, then send the reply.
What should happen when a token becomes invalid?
A Unified Login token-invalid error should move the connection into a reconnect-required state. Meta's authentication guidance asks clients to reconnect after removing the app or moving an account to another portfolio.
Adeli stopped the request on that error and did not retry through the legacy refresh flow. There is no OAuth refresh grant in this implementation. Meta separately documents a BISU token-management API; that is not Instagram Login's ig_refresh_token endpoint.
Keep missing-permission errors separate from invalid tokens, and handle transient network failures separately from both. A missing grant needs corrected consent; repeating the same request cannot add it.
How do I know the integration is ready for customers?
Verify a complete connection and its failure paths before widening access. The minimum walkthrough should include:
- Consent: both sign-in choices, intended portfolio, correct account, cancellation and invalid state.
- Features: profile read, test publication, comment read, received DM and reply.
- Events: account subscription, authentic notification, safe duplicate delivery.
- Recovery: missing permission, empty or multiple accounts, app removal and reconnect.
If the Meta connection is infrastructure you would rather hand off, Adeli's posting API covers Instagram publishing and scheduling with your API key. For inbox features, the messaging API provides received conversations and replies while Adeli maintains the underlying provider authorization.