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 { AndroidIgpapi, FeedTimelineScreen } from '@igpapi/android';
import { Effect } from 'effect';
const pushToken: string | undefined = process.env.ANDROID_PUSH_TOKEN;
// One call is one app process, birth to death: it builds the app on a virtual
// device, launches it, runs the program with the login screen, and closes the
// process when the program ends. A fresh install holds no session, so the
// program gets the login screen alone.
const feedEntries = await AndroidIgpapi.install({
seed: 'any-stable-string',
pushToken,
program: home =>
Effect.gen(function* () {
// 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* home.logIn({ username: 'your_username', password: 'your_password' });
// Only the handle's data leaves the program: a handle must not be used
// after the program that owns its process ends.
return FeedTimelineScreen.is(firstScreen) ? firstScreen.items.map(entry => entry.data.type) : [];
}),
});
AndroidIgpapi.install({ …options, program }) is the process entry for a fresh
install: nothing is built, and nothing is sent, before the call. The options are
AndroidApp's without the stored state: seed names the deterministic device (a
random one when omitted), and the rest is the proxy, the certificate policy, the
timeout, the protocol mode, the network and the push token. The program is a
function from the first screen to an Effect — home => Effect.gen(function* () { … }), or an Effect.fn(function* (home) { … }) — and the call resolves with its
result, as a plain Promise: a typed failure, of the start or of the program,
rejects with the very same tagged error object, and the process is closed when
the program ends, whatever way it ends. Saving and loading state is plain
JavaScript around the await. The returned handles' actions run on that process
without providing the layer; a handle must not be used after the program ends.
Every library error a program's screens and actions can raise is handled inside
the program, with typed catchTag. A challenge that interrupts the start of
launch or of a resume that sends requests is opened by the process: the
program receives the challenge's step (or its end) as its first screen. Only what happens before the program gets
its screen rejects the call: the stored value's decode error, the
checkpoint-versus-session refusals, CheckpointScreenMismatchError and the
launch's or the restore's own failure — the union AndroidIgpapiStartError.
Every entry takes signal (an AbortSignal) in its options: aborting it
interrupts the program, closes the process and rejects the call with the abort
reason.
The other starts take a stored value — a snapshot or a checkpoint, as an object (never JSON text; parse what your storage returns):
AndroidIgpapi.launch({ checkpoint | snapshot, screen?, …options, program }) — a cold start of the app
for the stored user. The program gets the screen the app shows first, as the
named union AndroidAppLaunchFirstScreen (the feed or the login homepage),
or the feed / the login home alone when a typed checkpoint says so. Name the
screen you expect — AndroidIgpapi.launch({ checkpoint: saved, screen: FeedTimelineScreen, program: feed => … }) —
and the program gets that handle or the challenge the start ran into (the name
narrows only what is not a challenge; tell them apart with .is); a launch that
shows another screen fails with the typed CheckpointScreenMismatchError.AndroidIgpapi.resumeSilently, resumeFromBackground and
resumeByReentering — continue the checkpointed process on the checkpoint's
own screen; see "Persisting sessions".AndroidApp(options) is the Instagram Android app on a virtual device, as an
Effect Layer, for code that composes the package's effects itself. 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.
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 anAndroidIgpapiprocess — the entry 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:AndroidIgpapi.installgives the program the login homepage for a logged-out process, andhome.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 AndroidIgpapi.launch({ checkpoint, … }). The
program returns the checkpoint; storing it is plain JavaScript around the call:
import { AndroidIgpapi, FeedTimelineScreen } from '@igpapi/android';
import { Effect } from 'effect';
import { readFile, writeFile } from 'node:fs/promises';
const username = 'myuser';
const password = '***';
// A fresh install's first screen is the login home alone; its login continues
// the process through the recorded launch, and the checkpoint carries the
// screen it was taken on.
const checkpoint = await AndroidIgpapi.install({
seed: 'my-account',
program: home =>
Effect.gen(function* () {
const next = yield* home.logIn({ username, password });
return yield* next.checkpoint();
}),
});
await writeFile('session.json', JSON.stringify(checkpoint));
// Later — a new process continues the stored one. The launch runs the app's
// warm start, so the program gets the screen the app shows first, not the
// checkpoint's screen. Name the screen you expect (a signed-in user's is the
// feed) and the program gets exactly that handle.
const saved = JSON.parse(await readFile('session.json', 'utf8'));
const feedEntries = await AndroidIgpapi.launch({
checkpoint: saved,
screen: FeedTimelineScreen,
program: feed =>
Effect.gen(function* () {
// The name narrows only what is not a challenge: a challenge the start ran
// into is opened by the process and reaches the program as the first screen.
if (!FeedTimelineScreen.is(feed)) return [];
const item = yield* feed.swipeToNext();
return [item?.key];
}),
});
void feedEntries;
To continue on the checkpoint's own screen instead, use the resume that fits —
AndroidIgpapi.resumeSilently({ checkpoint: saved, screen: FeedTimelineScreen, program: feed => … }),
resumeFromBackground or resumeByReentering — naming the screen you expect:
the program gets exactly that handle, and a checkpoint of another screen
rejects with the typed CheckpointScreenMismatchError before any process is
built. Without the screen field the program gets the union of all screen
handles and one XxxScreen.is(screen) narrows it. The checkpoint is the whole
process, so a resume builds it itself (the root README's "Checkpoints and
restores" describes what each sends). A checkpoint or snapshot is accepted as
an object, never JSON text: parse what your storage returns (JSON.parse); a string is
refused with the typed decode error.
A snapshot exported with ExportSnapshot is the same stored form
launch accepts; it is a copy, and a snapshot (or checkpoint) that cannot be
restored rejects the call with the typed SnapshotDecodeError — handle it,
usually by logging in again.
♻️ 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.
AndroidIgpapi.launch({ snapshot, … })sends the recorded warm-start burst of a stored session; a process that logged in itself (AndroidIgpapi.install→home.logIn(...)) continues into the recorded post-login burst and returns the feed's handle.
An account that already exists in your own system comes onto its own phone
without a checkpoint of ours: build a snapshot from its device and session and
launch it. The snapshot is typed from the library's own state
(AndroidSnapshotInput). The device and the application are given whole or not
at all: a complete device is kept exactly as given, without one the whole device
is made from seed (random without it), and without an application the
library's build is presented; the session is any subset of the session state.
The user agent is never an input — the library builds it from the device and the
application.
import { AndroidIgpapi, FeedTimelineScreen } from '@igpapi/android';
import { Effect } from 'effect';
// With a session the launch is the signed-in app's cold start: the feed.
const count = await AndroidIgpapi.launch({
snapshot: { device: phone, session: { authorization, xMid: mid } },
program: screen => Effect.succeed(FeedTimelineScreen.is(screen) ? screen.items.length : 0),
});
// Without a session the program gets the login home, on a device made from the seed.
await AndroidIgpapi.launch({
seed: 'my-account',
snapshot: {},
program: home => Effect.succeed(home.id),
});
The root README's "Bring an account from another system" has the rest, and "Ways to start the app" compares every entry.
AndroidIgpapi — the process entries: install, launch and the
three resume…, each one app process for the length of one program. AndroidApp
— the layer the login and the shared effects run over. 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.
The 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.
AndroidIgpapi is the process entry; the launch has no
caller input — the FCM/FBNS push token is a device fact
(AndroidIgpapi.install({ 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 screen. The program gets the
first screen directly (never a client object and never a checkpoint): the login
screen alone for a fresh install, and for a stored session the named union
AndroidAppLaunchFirstScreen — the feed or the login homepage, the only screens
a launch of a stored session can show first (a typed checkpoint narrows it).
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
(home.logIn(...)), never the launch's. Narrow the union with
FeedTimelineScreen.is(screen) / LoginHomepageScreen.is(screen), or name the
expected screen as the screen field of the entry's object; adding a screen to the union
is a breaking change of the type.
The entry builds the process's ManagedRuntime for the length of the program and
disposes it when the program ends — on success, on failure and when
interrupted — so no Scope appears in a signature and a second launch of the
same process is not expressible. A handle belongs to that process and must not
be used after the program ends; its actions (screen.logIn(...),
screen.nextPage(), screen.back()) run without providing the layer. Let only
data out of the program.
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: home.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
launch'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:
const thread = yield * inbox.threads[0].open();
yield * thread.messages[0].react('like');
const editor = yield * thread.participants[0].openNicknameEditor();
A person picked in "New message" opens the conversation the participant lookup
answers with (the recorded 1:1 run: an existing one), and the thread is as good
as one opened from the inbox. A lookup that answers without a thread is
DirectThreadNotFoundByParticipantsError (not observed in any recording).
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.
import {
AndroidIgpapi,
LoginTwoFactorApproveFromAnotherDeviceScreen,
LoginTwoFactorChooseMethodScreen,
} from '@igpapi/android';
import { Effect } from 'effect';
await AndroidIgpapi.install({
seed: username,
program: home =>
Effect.gen(function* () {
const next = yield* home.logIn({ username, password });
if (LoginTwoFactorApproveFromAnotherDeviceScreen.is(next)) {
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 (LoginTwoFactorChooseMethodScreen.is(next)) {
// The server's default is another method: pick one from `next.data.offeredMethods`.
const code = yield* next.chooseAuthenticationApp();
// `code.submitCode(typedCode)` continues the process into the launch.
}
}),
});
When Instagram interrupts a request with a challenge, the request fails with
IgCheckpointError (a challenge that interrupts the start of launch or of a
resume that sends requests is opened by the process itself, and the program gets
its step as the first screen). Catch it once and open the challenge with
OpenChallenge(error): the error carries the app process it interrupted, so no
layer is provided, and the challenge comes as screens of that process, over the
screen in front.
const program = (feed: StoredSessionFirstScreen) =>
Effect.gen(function* () {
// … a screen action of `feed` the server interrupts with a challenge …
}).pipe(
Effect.catchTag('IgCheckpointError', error =>
Effect.gen(function* () {
const outcome = yield* OpenChallenge(error);
if (ChallengeClosed.is(outcome)) {
// The challenge is over and `outcome.screen` is in front again.
// The interrupted action is not retried: take it again yourself.
}
}),
),
);
Every step answers with a ChallengeOutcome: the next challenge screen, or the
challenge's end — ChallengeClosed with the screen it interrupted, or
ChallengeClosedBeforeLaunch with the session as a snapshot when the launch
itself was interrupted (launch again from it); both are narrowed with their own
.is(outcome) guard, as the screens are with XxxScreen.is(handle). A handler
written apart from the catchTag takes OpenChallengeInput, the type
OpenChallenge accepts. A challenge step the library does
not model is ChallengeStepNotModelledError; an error raised outside an app
process of this library dies with ChallengeWithoutProcessError (a defect). A challenge screen
has no back, writes no navigation of its own, and is no step of a trail; a
checkpoint taken on it restores it silently over the screen it covered.
IGPAPI_LICENSE_TOKEN environment variable@igpapi/ios — iOS variant@igpapi/mobile — platform-agnostic API@igpapi/core — core types and HTTP infrastructure