@apex-inc/capacitor-plugin
v2.3.0
Published
Apex Capacitor plugin — iOS/Android attribution, events, deep linking, SKAN, and offline-tolerant tracking for Capacitor apps.
Maintainers
Readme
@apex-inc/capacitor-plugin
Apex Capacitor plugin — iOS and Android attribution, events, deep linking, SKAN, and offline-tolerant tracking for Capacitor apps.
Ships alongside apex.js (the web snippet) so a Capacitor app gets unified identity and events across WebView + native APIs with one dependency pattern.
Scope. This plugin is for Capacitor apps. Native iOS (Swift), native Android (Kotlin), React Native, and Flutter apps need their own SDKs — see https://apex.inc/docs/mobile/which-sdk.
Try it in 5 minutes. The sample app is a three-screen Capacitor + React app that exercises every API in this plugin.
git clone,npm install,npm run dev, watch events land in your Apex dashboard.
Install
npm install @apex-inc/capacitor-plugin
npx cap syncInitialize
Call initialize() once at app startup, before any other plugin method.
import { Apex } from "@apex-inc/capacitor-plugin";
await Apex.initialize({
workspaceKey: "prj_your_key",
// Optional:
// apiKey: "apex_sk_…", // see "API keys" below — unlocks verified identity stitching
// apiUrl: "https://app.apex.inc",
// autoTrackScreenViews: true, // default — screen_view auto-fires from SPA navigation
// sessionTimeoutMinutes: 30,
// offlineQueueMaxSize: 1000,
// testMode: false,
// debug: false,
});app_open auto-fires on initialize(). screen_view auto-fires from
the webview's History API (pushState / replaceState / back-forward /
hashchange — covers Ionic, React Router, Vue Router, and plain SPA
routing, on device and in browser preview). Screen names default to the
URL path (/product/42); for richer names, set
autoTrackScreenViews: false and wire trackScreenView("Product Detail")
into your router instead.
API keys
identify() and server-trusted flows work best with a workspace SDK key
(apex_sk_…). Mint one in the Apex dashboard under Settings →
Workspace → AI agents & API keys, and pass it as apiKey in
initialize(). Without a key, identity stitches from untrusted contexts
may be quarantined (held un-verified) rather than applied — events still
flow, but traits and journey triggers wait until a verified stitch.
Treat the value like a publishable key: assume it can be extracted from
the app bundle; Apex rate-limits per key + per visitor to keep leaked
keys low-yield.
Track events
await Apex.track({
type: "app_open",
data: { from: "push_notification" },
});
// In-app purchase — typed payload
await Apex.track({
type: "in_app_purchase",
purchase: {
productId: "com.example.app.pro_monthly",
amount: 9.99,
currency: "USD",
transactionId: "abc123",
},
});Every event gets a client-generated UUIDv4 id automatically. Server-side idempotency means replayed events never double-count.
Push notifications (iOS)
const { permission, token } = await Apex.registerForPushNotifications();
if (permission === "granted" && token) {
console.log(`APNs token: ${token}`);
// The plugin already POSTed it to /api/mobile/push-token for you.
}
// Subsequent token rotations:
await Apex.addListener("pushTokenReceived", ({ token }) => {
console.log("New token", token);
});
// Foreground push payloads:
await Apex.addListener("pushReceived", (event) => {
console.log("Push:", event.title, event.body, event.data);
});You also need three small additions to your iOS app's AppDelegate so the
plugin can observe the OS-side callbacks. Paste this into
ios/App/App/AppDelegate.swift:
import UIKit
extension AppDelegate {
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
NotificationCenter.default.post(
name: Notification.Name("ApexCapacitor.didRegisterForRemoteNotifications"),
object: nil,
userInfo: ["deviceToken": token]
)
}
func application(_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) {
NotificationCenter.default.post(
name: Notification.Name("ApexCapacitor.didFailToRegisterForRemoteNotifications"),
object: nil,
userInfo: ["error": error]
)
}
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
NotificationCenter.default.post(
name: Notification.Name("ApexCapacitor.didReceiveRemoteNotification"),
object: nil,
userInfo: userInfo as? [AnyHashable: Any] ?? [:]
)
completionHandler(.noData)
}
}Then enable the Push Notifications capability in Xcode (Signing & Capabilities → + → Push Notifications). Sandbox builds (Xcode-built installs to a real device) hit Apple's sandbox APNs endpoint — configure that environment in your Apex workspace settings.
iOS App Tracking Transparency
// Present the ATT prompt once (no-op on Android).
const { status } = await Apex.requestTrackingAuthorization();
// Check status later without prompting.
const { status: current } = await Apex.getTrackingStatus();Advertising identifiers
// Returns IDFA (iOS, ATT authorized), GAID (Android), or fallback.
const { id, fallback } = await Apex.getAdvertisingId();
if (!id && fallback === "idfv") {
console.log("Using IDFV fallback — user denied ATT");
}SKAdNetwork conversion values (iOS 4.0+)
await Apex.updateConversionValue({
fineValue: 42,
coarseValue: "high",
});Deep links
// Cold start: read the URL that opened the app (if any).
const { url } = await Apex.getInitialDeepLink();
// Warm-start / subsequent links while running:
await Apex.addListener("deepLink", ({ url }) => {
handleRoute(url);
});Sessions
const { sessionId } = await Apex.startSession();
await Apex.addListener("sessionEnd", ({ sessionId, durationSeconds }) => {
console.log(`Session ${sessionId} ended after ${durationSeconds}s`);
});Experiments
Resolve a visitor's variant for a mobile experiment, then branch your UI:
const { variant, payload, eligible } = await Apex.getVariant({
experimentId: "exp_123",
});
if (!eligible) return renderControl();
if (variant === "variant_b") return render(payload);
return renderControl();On-device variant screenshots (debug builds)
captureVariantScreenshot() rasterizes the current screen and attaches it to
the experiment as a device-source asset. The shot lands on the dashboard
experiment card (cover) and the detail Variant previews gallery, alongside
the web/agent captures. Use it for authed in-app mobile screens that servers
can't reach — public web URLs are auto-captured server-side on create, and
the agent CLI / attach_experiment_asset covers localhost.
captureVariantScreenshot(options: {
experimentId: string;
variantKey: string;
label?: string;
commitSha?: string;
}): Promise<{ captured: boolean; url?: string; reason?: string }>;It's debug-gated: you must Apex.initialize({ ..., debug: true }). In
production it no-ops ({ captured: false, reason: "debug_disabled" }) and never
screenshots real end users — it's a QA/review tool. (Web returns
{ captured: false, reason: "web_unsupported" } — capture is native-only.)
Capture on the screen that renders the variant, keyed to the resolved variant:
const variant = useApexVariant(experimentId); // or: const { variant } = await Apex.getVariant({ experimentId })
useEffect(() => {
if (variant) {
Apex.captureVariantScreenshot({ experimentId, variantKey: variant });
}
}, [variant]);Offline durability
Events are persisted to IndexedDB (web), Core Data (iOS), or Room (Android). If the device is offline, they queue up to the configured offlineQueueMaxSize (default 1000). When the network returns, the plugin drains the queue in batches with exponential-backoff retry.
const { count, oldestEventAt } = await Apex.getQueueSize();
// Manually trigger a flush (rarely needed — happens automatically):
const { flushed, remaining } = await Apex.flushQueue();Test mode
// At init:
await Apex.initialize({ workspaceKey: "prj_test", testMode: true });
// Or toggle at runtime:
await Apex.setTestMode({ enabled: true });Test-mode events are stored separately on the server and never pollute production analytics.
Web fallback
The plugin runs in a browser or PWA too — identifiers return null, ATT is a no-op, events still flow to the server via the normal offline queue. This lets you run shared code paths between web + mobile without Capacitor.isNativePlatform() checks everywhere.
License
Apache-2.0 — see LICENSE. See also the workspace plan document for the full MMP roadmap.
