reel-acquire-sdk
v1.0.2
Published
Reward users for scrolling videos!
Maintainers
Readme
reel-acquire-sdk
Reward users for scrolling Instagram. The SDK opens a native full-screen WebView, loads an Instagram link, injects a tracking script, and reports activity back to the host app so it can award rewards.
Native-first. Almost all logic lives in the native iOS and Android cores. The React Native / Expo layers are thin wrappers over those cores.
Consumption modes
This package is designed to be used separately or together across four targets:
| Target | Entry point | Mechanism |
|--------|-------------|-----------|
| Native iOS (Swift) | ios/ReelAcquireCore | Swift Package ReelAcquireCore |
| Native Android (Kotlin) | android/core | Gradle lib com.reelacquire:reel-acquire-core |
| React Native | src/index.ts → TurboModule ReelAcquire | CocoaPods bridge + :reactnative Gradle module |
| Expo | app.plugin.js config plugin | Wires native config during expo prebuild |
The core layers have zero React Native dependency. The RN bridges depend on the cores; they do not recompile them.
Module boundaries
iOS — the core is a Swift Package (ReelAcquireCore). Native apps add it
via SPM. For React Native it also ships a co-located podspec
(ios/ReelAcquireCore/ReelAcquireCore.podspec) so the thin bridge pod
(reel-acquire-sdk) can dependency 'ReelAcquireCore' — a real module boundary,
same sources. The Expo plugin adds the core path-pod to the app Podfile.
Android — two Gradle modules:
android/core→com.reelacquire:reel-acquire-core(no RN dependency, publishable).android/reactnative→ the autolinked bridge (react-native.config.jspoints here). Depends on:corewhen built standalone, or the published core artifact when autolinked into an app (./gradlew :core:publishToMavenLocalfor local dev).
Architecture
┌─────────────────────────────────────────────┐
JS / TS │ src/index.ts (public API + typed events) │
│ src/NativeReelAcquire.ts (TurboModule spec) │
└───────────────┬───────────────┬──────────────┘
│ │
┌───────────────────┘ └───────────────────┐
iOS │ ios/rn/ (pod: reel-acquire-sdk) android/reactnative │ Android
bridge │ thin wrappers, depend on the cores │ bridge
└───────────────────┬───────────────┬───────────────────┘
│ depends on │ depends on
┌──────────────────────┘ └──────────────────────┐
iOS │ ios/ReelAcquireCore (Swift Package) android/core (Gradle) │ Android
core │ ReelAcquire · APIClient · ReelAcquire · │ core
│ WebViewController · ScriptInjector · WebViewActivity · │
│ Models ScriptInjector · │
│ Models │
└────────────────────────┬──────────────────────────────────┘
│ GET {host}/api/v1/_t
▼
Reel Acquire backend (returns the tracking JS;
fetched at initialize, cached in memory, injected)Layout
sdk/
├── package.json RN library manifest (+ codegen config)
├── react-native.config.js Points android autolinking at the bridge module
├── reel-acquire-sdk.podspec iOS RN bridge pod (depends on ReelAcquireCore)
├── app.plugin.js Expo config plugin entry
├── src/ TypeScript public API + TurboModule spec
├── ios/
│ ├── ReelAcquireCore/ Swift Package (the core) + ReelAcquireCore.podspec
│ └── rn/ React Native bridge sources
├── android/
│ ├── core/ Standalone Gradle library (the core)
│ └── reactnative/ React Native bridge module (depends on :core)
└── plugin/ Expo config plugin (TypeScript source)Usage
The flow is: initialize → get the next video → present it. When the video finishes playing, the SDK marks it watched on the server automatically. Only one video is active at a time.
Native Swift:
import ReelAcquireCore
// Defaults to host https://api.reelacquire.com:
ReelAcquire.shared.initialize(ReelAcquireSettings(apiKey: "pk_…", userId: "user-123", logLevel: .debug))
// After session_initialized, fetch and present the next video:
ReelAcquire.shared.nextVideo { result in
if case .success(let video) = result {
DispatchQueue.main.async { ReelAcquire.shared.present(video: video) }
}
}React Native:
import { initialize, getNextVideo, present, LogLevel } from 'reel-acquire-sdk';
// host defaults to https://api.reelacquire.com
initialize({ apiKey: 'pk_…', userId: 'user-123', logLevel: LogLevel.DEBUG });
const video = await getNextVideo(); // { video: { id, title?, url? }, watchKey }
present(video); // opens video.url; auto-marks watched when it ends
// Optional: observe logs yourself (they also go to console.* by default).
const sub = addLogListener((e) => myLogger(e.level, e.message));
// sub.remove();Logging (end-to-end)
LogLevel is DEBUG | INFO | WARN (a message is emitted when its level ≥ the
configured level). The native core filters once, logs via os_log, and fans
each entry out to observers. The RN bridge is an observer (RCTEventEmitter)
that re-emits ReelAcquireLog events; on the JS side they're routed to
console.debug / console.info / console.warn automatically. Native-only
hosts can register a ReelAcquireLogObserver instead.
Session
The API key is sent as the X-Api-Key header on every request (not a query
param or body field).
On initialize, the native core calls POST {host}/api/v1/sessions with
{ appuser_id } and stores the returned session in memory for its lifetime
(ReelAcquire.shared.session / .sessionKey).
JSON endpoints use a standard envelope: { success: true, data } on success,
{ success: false, error_messages: [...] } on error (surfaced as
APIError.server(messages:)). APIClient.postJSON / putJSON unwrap it,
trusting the success flag over the HTTP status. The session payload is parsed
from data.session.{key,created_at} and data.appuser.{id,external_id}.
The tracking script reports Instagram login state:
logged_in→ the SDK links the appuser viaPUT /api/v1/appusers/:appuser_id, forwarding the raw event payload ({ session_key, page_url?, payload }); the server reads the Instagram id from it. Sent once per session.not_logged_in→ a modal warns that videos won't count as watched until the user logs into Instagram.url_change→ if the user is logged in and has navigated away from the active video's URL, a "Come back!" modal prompts them to return (its "Go back" action reloads the active video). URL comparison is by host + path.
When logged_in includes an ig_username, the SDK also spins up a hidden
profile WebView (HiddenWebView) pointing at https://instagram.com/<username>,
injects the secondary script from GET /api/v1/_s, and routes its messages to a
ProfileScriptMessageHandler (debug-logged for now). It's created once per
session, off-screen, and torn down on initialize.
Per-WebView message handling is abstracted behind ScriptMessageHandler: the
primary (video) WebView uses PrimaryScriptMessageHandler (which routes to
ReelAcquire's on* intent methods) and the hidden WebView uses
ProfileScriptMessageHandler.
Events
The native core has an event bus (ReelAcquire.shared.events) that fans
lifecycle events out to observers; the RN bridge re-emits them on a
ReelAcquireEvent channel. The first event is session_initialized (fired
after the session POST resolves) — use it to sequence work that needs a session.
Emitted events (each carries a data payload):
| Event | When | data |
|-------|------|--------|
| session_initialized | Session POST resolved | appuser_id?, external_id? |
| session_closed | User closed the experience | — |
| video_watched | Server confirmed a watch | video_id, watch_key |
| video_liked | Server confirmed a like/unlike | video_id, watch_key, liked |
import { addEventListener, getNextVideo, present, ReelAcquireEventName } from 'reel-acquire-sdk';
const sub = addEventListener(async (event) => {
if (event.name === ReelAcquireEventName.SessionInitialized) {
const video = await getNextVideo(); // session is guaranteed ready here
present(video); // auto-marks watched when it ends
}
});
// sub.remove();Native hosts can conform to ReelAcquireEventObserver and call
ReelAcquire.shared.events.addObserver(_:).
Videos
Once a session exists (both require one — they fail with
ReelAcquireError.noSession otherwise):
GET /api/v1/videos/watch_next→nextVideoreturns a singleWatchVideo({ video: { id, title?, url? }, watchKey }).GET /api/v1/videos/queue→videoQueuereturns an ordered[WatchVideo](each entry the same per-item shape as watch_next; may be empty). Exposed to RN asgetVideoQueue(): Promise<WatchVideo[]>.present(video:)opensvideo.urlfull-screen and makes it the single active video. When the injected script reportsvideo_watched, the SDK automaticallyPOST /api/v1/videos/:videoId/watchedwith the active video'swatchKey. On the first presentation, a one-time onboarding modal (IntroOverlayView) frosts the screen and explains the loop in three steps; "Watch" dismisses it. It's persisted per user inUserDefaults(com.reelacquire.introShown.<userId>), so it only ever shows once.- Per-video actions all
POST /api/v1/videos/:videoId/<action>withvideoIdin the path:watched,liked,commented,shared, andbroken(markWatched/markLiked/markCommented/markShared/markBroken; the overlay's "Video isn't loading" reportsbroken).
So the normal flow is just get next → present; marking watched happens on its own when playback ends. There is one active video at a time — presenting another replaces it.
Reward overlay. Watch progress is shown by a floating "Pulse pill"
(RewardOverlayView) above the WebView — a frosted capsule with a hue-shifting
progress ring and a watched / total count (the total comes from a one-time
videos/queue fetch). When the server confirms a watch the pill springs, flashes
a "+1 ✦" spark, and advances the ring. The pill has three interactive modes:
- Watching (a video is showing) → a "(?)" button opens a context menu whose "Video isn't loading" action skips the current video and offers the next.
- Next available → the pill shows "Next ▸" and the whole pill is tappable to load that video into the same WebView (loop repeats).
- Completed (no video left) → the pill blooms (spark burst + glow) into an "All caught up ✦" state.
(The "come back" / "log in to Instagram" notices remain UIAlertController
modals.)
Tracking script
The injected script is no longer bundled with the SDK. At initialize, the
native core fetches it from GET {host}/api/v1/_t (via APIClient, with
.reloadIgnoringLocalCacheData so each run gets the latest script), caches it
in memory for the process lifetime, and injects it into the WebView. When the
script is cached it runs as a WKUserScript at document start (before the
page's own scripts, so it can intercept navigation/login early); on a cold first
run it's injected via evaluateJavaScript once the network fetch returns. The
script itself lives in the backend project and must defer DOM-dependent work
until the document is ready.
Development
npm install
npm run typecheck # type-check the JS layer
npm run build:plugin # compile the Expo config plugin