Instagram private API client for Android. Covers device signals, request signatures, session handling, and a large chunk of the mobile API surface.
yarn add @igpapi/android
import { AndroidAppLaunchSimulation, LoginHomepageScreen } from '@igpapi/android';
import { Effect } from 'effect';
const pushToken: string | undefined = process.env.ANDROID_PUSH_TOKEN;
const program = Effect.gen(function* () {
// One call builds the app on a virtual device and launches it. A fresh
// install holds no session, so the launch returns the login screen alone.
const screen = yield* AndroidAppLaunchSimulation({ seed: 'any-stable-string', pushToken });
if (!LoginHomepageScreen.is(screen)) return yield* Effect.die(new Error('not the login home'));
// The login continues the same process through the recorded post-login
// launch and returns the screen the app shows next: the feed.
const firstScreen = yield* screen.logIn({ username: 'your_username', password: 'your_password' });
// Only the handle's data leaves the scope: a handle must not be used after
// the scope that launched its process closes.
return firstScreen.id === 'FeedTimeline' ? firstScreen.items.map(entry => entry.type) : [];
});
const feedEntries = await Effect.runPromise(Effect.scoped(program));
AndroidAppLaunchSimulation(options) is the process entry: it builds the app
on a virtual device and launches it in one call. The options are
AndroidApp's, with the device-and-session part as
{ seed?, snapshot?: undefined } for a fresh install or { snapshot } for a
stored session: seed names the deterministic device (a random one when
omitted), and a snapshot restores a session exported earlier with
ExportSnapshot; a seed beside a defined snapshot is refused by the type,
because the snapshot names the device; snapshot: undefined is a fresh install,
not a snapshot to decode. A caller's own optional saved-session value,
{ snapshot?: MobileSnapshot }, is accepted too and takes the union result. It
returns the first screen the recordings show in front — the feed from a stored
session, the login homepage from a fresh install — as the named union
AndroidAppLaunchFirstScreen. The process lives in the Effect scope it was
launched in (Effect.scoped), and a handle must not be used after that scope
closes; the library cannot enforce that lifetime today (the lifetime rule is
below). A second launch of the same process is not expressible.
The returned handle's actions run on that process without providing the layer
again.
AndroidApp(options) is the Instagram Android app on a virtual device, as an
Effect Layer. One build of the layer is one app process: provide it once
around the whole program, or keep it in a ManagedRuntime you own and dispose
(ManagedRuntime.make(AndroidApp(options))) to run several programs in the
same process. The login and the shared effects run over that layer; the launch
is the separate process entry above.
AndroidLoginis the layer path's one-call login: it runs the platform cold-start warmup and then the Bloks login request. It is not a step of a simulation process —AndroidAppLaunchSimulation()builds and owns its own layer, and its login is the login screen's action. On a layer where the package's own logged-out launch opened the login homepage, it reads the issued state instead of warming up again.NativeLogincalls the legacy/accounts/login/endpoint — kept for reference only, don't use in production.
Do not manually run the pre-login flow immediately before
AndroidLogin. It owns the warmup, or reads the launch's issued state. The screen path is the app's:AndroidAppLaunchSimulation()returns the login homepage for a logged-out process, andscreen.logIn({ username, password })logs in from that handle. A successfullogIncontinues the process itself — it runs the recorded post-login launch and returns the screen the launch leaves on top, the feed (or, after an account-recovery login, the location-permission splash) — and a two-factor account returns the screen the app shows next: the approval screen, or the method picker when the server's default is another method (see "Two-factor login").
A session is kept by checkpointing a screen: a handle's checkpoint() returns
JSON for the process at that screen — the whole session (device, session,
application, cookies), the process state, the trail and the screen stack — and a
later process continues it with AndroidAppLaunchSimulation({ checkpoint }):
import { AndroidAppLaunchSimulation, FeedTimelineScreen } from '@igpapi/android';
import { Effect } from 'effect';
const checkpoint = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
// A fresh install's launch result is the login home alone; its login
// continues the process through the recorded launch.
const home = yield* AndroidAppLaunchSimulation({ seed: 'my-account' });
const next = yield* home.logIn({ username, password });
// The 2FA and recovery answers come before the process holds a session.
if (!FeedTimelineScreen.is(next)) return yield* Effect.die('the login did not reach the feed');
// The checkpoint names the screen it was taken on.
return yield* next.checkpoint();
}),
),
);
await fs.writeFile('session.json', JSON.stringify(checkpoint));
// Later — a new process continues the stored one. The launch runs the app's
// warm start, so it shows the screen the app shows first, not the
// checkpoint's screen. The checkpoint came back from a file, so its screen is
// no longer in the type: the result is the union — the login home when that
// process never logged in, the feed when it did — and one `is` narrows it.
const saved = JSON.parse(await fs.readFile('session.json', 'utf8'));
const feedEntries = await Effect.runPromise(
Effect.scoped(
AndroidAppLaunchSimulation({ checkpoint: saved }).pipe(
Effect.flatMap(screen =>
FeedTimelineScreen.is(screen) ? Effect.succeed(screen.items.map(entry => entry.type)) : Effect.succeed([]),
),
),
),
);
To continue on the checkpoint's own screen instead, build the process with
AndroidApp({ checkpoint }) and use the restore that fits:
restoreSilently(screenId, checkpoint),
restoreAsAppReturningFromBackground(screenId, checkpoint) or
restoreAndReenterEveryScreenOfTheNavigationChain(screenId, checkpoint) (the
root README's "Checkpoints and restores" describes what each sends).
A snapshot exported with ExportSnapshot remains the stored-session form the
snapshot option accepts; it is a copy, and a snapshot (or checkpoint) that
cannot be restored fails the build or the simulation with the typed
SnapshotDecodeError — handle it, usually by logging in again. A snapshot
key present but undefined is not a snapshot: it is "no saved session", and
the layer starts a fresh install instead of failing.
♻️ Launch every process, a restored one included. Hitting feature endpoints from a restored session that hasn't been used for hours (or days) with no preceding launch traffic is a strong anti-abuse signal — the real app always sends its launch traffic before the user sees the home feed.
AndroidAppLaunchSimulation({ snapshot })sends the recorded warm-start burst of a stored session; a process that logged in itself (AndroidAppLaunchSimulation()→screen.logIn(...)) continues into the recorded post-login burst and returns the feed's handle.
AndroidApp — the layer the login and the shared effects run over.
AndroidAppLaunchSimulation — the process entry: builds the client
and launches the app in one call, returning its first screen. The package's
effects are what you compose into a program: the login (AndroidLogin), the
launch and the screens it returns, the shared mobile effects
(UsersInfo, FriendshipCreate, the feeds such as followersFeed, …),
ExportSnapshot and ProtocolDiagnostics.
The package also re-exports everything from @igpapi/mobile for advanced use cases.
createAndroidIgpapi(options) is the Promise facade over the same layer AndroidApp
builds, with the same options. It stays public and supported for code built
on it; Effect over AndroidApp is the main path, and new examples are written on
it. The facade returns a runtime object:
ig.loginBloks — authentication (recommended); ig.loginNative — legacy, reference onlyig.users, ig.friendships, ig.account — profile & social graphig.activity, ig.news, ig.live, ig.clips, ig.highlights — feedsig.collections, ig.creatives, ig.restrict, ig.graphqlWww — other endpointsig.export() — the session snapshot, the facade's counterpart of ExportSnapshotig.run(effect) — runs any of the package's effects on the facade's process; the same effects run over AndroidApp directlyThe app layer installs the app process's screen stack, screen catalog and
launch flag itself, and it provides exactly the package's userland surface
(AndroidRuntimeServices): the services the package's publicly runnable
effects require — the shared effects' services, JsonResponseHttp
and MobileNetworkWire for the platform and shared flows, AnalyticsSend for the
Pigeon upload, WebViewHttp for the challenge web bricks, the process
services ScreenStack, ScreenCatalog, MobileNavigation and
ScreenLaunchState, the cookie jar ExportSnapshot reads and the transport
state ProtocolDiagnostics reads. A screen's actions and edges are effects like
any other. The native Transport is not part of that set: it takes ready
bytes. MobileHttpRequest is still in it because some effects use it today
(PostingLive, the segmented video upload), so a caller can send an arbitrary
request through it — an open gap whose cost is a request with caller-chosen
headers and host; closing it needs those effects moved to narrow capabilities
like AnalyticsSend.
AndroidAppLaunchSimulation(options) is the process entry; the launch has no
caller input — the FCM/FBNS push token is a device fact
(createAndroidIgpapi({ pushToken }), AndroidApp({ pushToken })). The
platform package binds its launch declarations —
declareLaunch(FeedTimelineScreen, { source: 'cold_start' }, { screenInput, burst })
for a stored session and
declareLaunch(LoginHomepageScreen, { source: 'cold_start' }, { screenInput, burst })
for one without a session, internal to the package — by inference, so the
screens' data, input and services reach the entry's result. The entry returns
the first screen directly (never a client object and never a checkpoint) as the
named union AndroidAppLaunchFirstScreen: the feed or the login homepage, the
only screens a launch of a fresh install or a stored session can show first.
The location-permission splash is not a member: it appears only after a login
in the same process, so it is the process continuation's result
(screen.logIn(...)), never the simulation's. Without a
snapshot the result type is the login screen alone
(AndroidAppLaunchLoginScreen), because a fresh install holds no user session;
a widened AndroidAppLaunchSimulationInput gets the union, so logIn is not
typed on a value that may hold the feed's handle. Narrow the union with
FeedTimelineScreen.is(screen) / LoginHomepageScreen.is(screen) (or
screen.id); adding a screen to the union is a breaking change of the type.
The entry builds the app layer in the caller's Effect scope
(Effect.scoped(AndroidAppLaunchSimulation(...))): that scope is the process's
lifetime, and a second launch of the same process is not expressible. A
handle must not be used after the scope that built its process closes, and the
library cannot prevent it today: the transport has no close, so the scope
closes nothing, and TypeScript cannot tie a handle's use to a scope. Enforcing
it is the named task process-lifetime-enforced-by-transport-close — a close
on the transport's client and a layer that finalizes it with the scope. The
returned handle carries the services of the process it was entered in, so its
actions (screen.logIn(...), screen.nextPage(), screen.back()) run without
providing the layer again. Keep handles inside the scope that launched the
process, and let only their data out.
A logged-out process opens the login homepage through the core launch edge;
the pre-login bootstrap (the client-data redirect, USDID registration, the
login-home screen fetch, the phone prefill) belongs to that launch, and the
typed login is the screen's action: screen.logIn({ username, password })
sends oauth.token.fetch with the typed username and then
send_login_request. The FCM/FBNS push token is a device fact
(AndroidApp({ pushToken })), so the continuation into the post-login burst
reads it from the device and registers it (push/register is skipped without
it). Its answer is a sum: the feed — the action continues the process through
the logged-in launch (the post-login burst, the session navigation, the stack
started over) and returns the screen the launch leaves on top, the feed or the
launch's splash — or a two-factor account's approval screen
(LoginTwoFactorApproveFromAnotherDeviceScreen), or the last-resort recovery
code screen; each is an ordinary edge, so the login homepage stays under it
and back() returns there (see "Two-factor login"). The one-launch rule holds
in the layer itself: a second launch inside one layer is
AppAlreadyStartedError before any request.
The platform's screen catalog is one set (androidScreens) holding the login
homepage, the account-recovery search, authentication-confirmation and email
code-entry screens, the three two-factor login screens, the feed, the search,
SERP and profile screens (another user's profile, UserProfileScreen, and the
viewer's own, MyProfileScreen, opened from the feed's tab bar), the Direct
screens (inbox, thread, thread details, shared media, customize chat, theme
picker, nickname change, group name and photo editor), and the media screens:
the post opened from the feed
(PostOpenedFromLinkScreen), the reels viewer (ReelsViewerScreen), the
comments sheet (MediaCommentsScreen) and the repost note editor
(RepostNoteEditorScreen). The process installs the set of the build it runs
on (androidScreensFor): the note editor is the 448/450 build's screen, and
the build that sends a repost's text in the note itself does not install it.
The launch's first screen is the
feed, and its burst is everything the app sends when it launches, in order and
windows: a launch over a process that holds a session sends the burst, opens
the feed at the burst's one firstScreen mark (the feed's own enter sends the
one cold feed/timeline) and returns the feed's handle. A launch after an
account-recovery login in the same process can leave the location-permission
splash in front instead; that is the login continuation's result, not the
simulation's. The camera permission and
the hardware decode block are device state; the battery and charging are
readings taken at request time from the device's simulation; the version's
launch constants are the app profile's. A launch that fails rolls back the
navigation and the root, and one that fails before the first screen enters can
be retried; once the first screen entered, the process counts as launched. Only
the first screen's own request is fatal: a rejected background bootstrap
request (a 429, a 400 or a 5xx) leaves the launch running, as the app does.
An action on one element of a list is that item's method: a screen never takes
the list element it acts on as an argument (an action that involves a second
object keeps its home on the element, as in message.forwardTo(thread)). A
message reacts with its own react, a participant opens its own nickname
editor, and a theme row applies itself:
const thread = yield * inbox.threads[0].open();
yield * thread.messages[0].react('like');
const editor = yield * thread.participants[0].openNicknameEditor();
A two-factor account's typed login returns a screen instead of an error. The three screens are the app's recorded 2FA flow (Android 448):
LoginTwoFactorApproveFromAnotherDeviceScreen (LoginTwoFactorEntrypoint) — the login
waits for the person to approve it from a device where the account is already
signed in ("Check your notifications on another device"). checkApproval()
polls once and returns { status: 'still-waiting' } until the login is
approved — the caller owns the pace; the app repeats every ~7.5 s — then
continues the process and returns the screen the launch leaves on top. A login
declined on the other device is LoginTwoFactorApprovalDeclinedError.
tryAnotherWay() opens the method picker.LoginTwoFactorChooseMethodScreen (LoginTwoFactorMethodPicker) — "Choose a way to
confirm it’s you", the methods the account offers. The login opens it directly
when the server's default is not the approval (the choice the app makes); the
login answer has already fetched the picker's rows, so opening it sends no
second picker request. chooseAuthenticationApp() opens the
authenticator-app code screen; the selection request and the code screen's
fetch are that screen's own burst. WhatsApp, SMS and backup codes are shown as
data (offeredMethods) but have no action: no recording covers those flows.LoginTwoFactorAuthenticationAppCodeScreen (LoginTwoFactorEnterTotpCode) — the
six-digit code from the authentication app. The screen owns the
has_been_allowed poll its own answer scheduled: checkApproval() polls once
(with this screen's QPL and should_trust_device: 0) and returns
{ status: 'still-waiting' }, or the feed once the login is approved.
submitCode(code) sends verify_code; an accepted code continues the process
and returns the screen the launch leaves on top. A rejected code is
LoginTwoFactorAuthenticationAppCodeRejectedError (an outcome the recordings do
not contain).All server-issued values of the 2FA flow (the challenge, the entrypoint and
picker screen instances, the QPL pairs) stay behind opaque tickets in a
process registry. A handle exposes the person's actions and the methods the
account offers; the ticket field on its data is opaque client state, not a
value a caller reads, and a trail step carries no ticket.
The traffic after a 2FA approval or an accepted code is not recorded: both
actions send the same post-login burst a password login sends, and a recording
of that launch will settle it. The "Trust this device and skip this step from
now on" toggle is not modelled — the recorded poll and verify_code calls send
should_trust_device: 0.
const program = Effect.gen(function* () {
const screen = yield* AndroidAppLaunchSimulation();
if (!LoginHomepageScreen.is(screen)) return;
const next = yield* screen.logIn({ username, password });
if (next.id === 'LoginTwoFactorEntrypoint') {
let approved = yield* next.checkApproval();
while (approved.status === 'still-waiting') {
yield* Effect.sleep('7500 millis'); // the caller owns the pace
approved = yield* next.checkApproval();
}
// approved is the screen the launch leaves on top
} else if (next.id === 'LoginTwoFactorMethodPicker') {
// The server's default is another method: pick one from `next.offeredMethods`.
const code = yield* next.chooseAuthenticationApp();
// `code.submitCode(typedCode)` continues the process into the launch.
}
});
IGPAPI_LICENSE_TOKEN environment variable@igpapi/ios — iOS variant@igpapi/mobile — platform-agnostic API@igpapi/core — core types and HTTP infrastructure