Developer

Angular setup

Configure the App SDK in an Angular or Ionic app with an app initializer, a root service, optional NGXS state, an RxJS helper and Capacitor Preferences storage.

Configure the SDK once in an Angular app initializer. Then inject one root service wherever the app reads flags or tracks events.

Root service

ts
// apex.service.ts
import { Injectable, NgZone, inject } from '@angular/core';
import { Router } from '@angular/router';
import { Apex } from '@drip-apex/app-sdk';
 
@Injectable({ providedIn: 'root' })
export class ApexService {
  private readonly zone = inject(NgZone);
  private readonly router = inject(Router);
 
  init(): Promise<void> {
    return this.zone.runOutsideAngular(() =>
      Apex.configure({
        publishableKey: 'apx_pk_live_…',
        shopId: 'YOUR_SHOP_ID',
        applicationId: 'com.example.shop',
        appVersion: '1.0.0',
        // The current route, without query or fragment.
        screen: () => this.router.url.split(/[?#]/)[0],
      }),
    );
  }
 
  welcome(): boolean {
    return Apex.flag('welcome', false);
  }
 
  subscribe(update: () => void) {
    return Apex.onChange((change) => {
      if (change.type === 'changed') this.zone.run(update);
    });
  }
}

Start configure() outside the Angular zone. SDK timers and network responses then do not trigger change detection. Re-enter the zone with zone.run() only to update the UI.

Run it at startup

Add the initializer to ApplicationConfig.providers (Angular 19 and later):

ts
// app.config.ts
import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core';
import { ApexService } from './apex.service';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideAppInitializer(() => inject(ApexService).init()),
  ],
};

Angular versions without provideAppInitializer use APP_INITIALIZER. Capture the service synchronously in the factory:

ts
import { APP_INITIALIZER, inject } from '@angular/core';
import { ApexService } from './apex.service';
 
export const apexInitializer = {
  provide: APP_INITIALIZER,
  multi: true,
  useFactory: () => {
    const service = inject(ApexService);
    return () => service.init();
  },
};

The initializer waits for configure(). The first render then sees cached or fresh flag values. The wait is at most initTimeoutMs (default 3,000 ms).

Use it in a component

ts
import { Component, OnDestroy, inject } from '@angular/core';
import { ApexService } from './apex.service';
 
@Component({
  selector: 'app-home',
  template: `<h1>{{ enabled ? 'Welcome back' : 'Welcome' }}</h1>`,
})
export class HomeComponent implements OnDestroy {
  private readonly experiments = inject(ApexService);
  enabled = this.experiments.welcome();
  private readonly subscription = this.experiments.subscribe(() => {
    this.enabled = this.experiments.welcome();
  });
 
  ngOnDestroy(): void {
    this.subscription.cancel();
  }
}

onChange callbacks run asynchronously. Update Angular state through change detection or signals, as you do for other async data.

Optional: NGXS state

Apps that use NGXS can mirror flag values into the store. Selectors and components then read flags like other state. The mirror reads with manual exposure, so filling the store never counts an exposure. Call logExposure() where the app shows the variant.

ts
// apex-flags.state.ts
import { Injectable } from '@angular/core';
import { Action, State, StateContext, createSelector } from '@ngxs/store';
 
export interface ApexFlagsStateModel {
  /** Typed flag values by key (the default when unassigned or invalid). */
  values: Record<string, unknown>;
  /** Set when the SDK stopped networking, for example after an invalid key. */
  stopped: string | null;
  updatedAt: number | null;
}
 
export class ApexFlagsChanged {
  static readonly type = '[Apex] Flags changed';
  constructor(public readonly values: Record<string, unknown>) {}
}
 
export class ApexStopped {
  static readonly type = '[Apex] Stopped';
  constructor(public readonly code: string) {}
}
 
@State<ApexFlagsStateModel>({
  name: 'apexFlags',
  defaults: { values: {}, stopped: null, updatedAt: null },
})
@Injectable()
export class ApexFlagsState {
  /** store.select(ApexFlagsState.flag<Banner>('banner')) */
  static flag<T>(key: string) {
    return createSelector([ApexFlagsState], (state: ApexFlagsStateModel) => state.values[key] as T);
  }
 
  @Action(ApexFlagsChanged)
  changed(ctx: StateContext<ApexFlagsStateModel>, action: ApexFlagsChanged): void {
    ctx.patchState({ values: action.values, updatedAt: Date.now() });
  }
 
  @Action(ApexStopped)
  stopped(ctx: StateContext<ApexFlagsStateModel>, action: ApexStopped): void {
    ctx.patchState({ stopped: action.code });
  }
}

This service version replaces the root service above. It registers the flags the app reads, publishes them to the store at startup and after every change, and adds an RxJS helper.

ts
// apex.service.ts (NGXS version)
import { Injectable, InjectionToken, NgZone, inject } from '@angular/core';
import { Store } from '@ngxs/store';
import { Observable } from 'rxjs';
import { Apex, type ApexChange, type ApexOptions } from '@drip-apex/app-sdk';
import { ApexFlagsChanged, ApexStopped } from './apex-flags.state';
 
export interface ApexFlagDefinition<T = unknown> {
  key: string;
  defaultValue: T;
  validate?: (value: unknown) => value is T;
}
 
/** Pass userId when the app knows the signed-in user at startup. */
export type ApexInitOptions = ApexOptions & { userId?: string | null };
export type ApexOptionsFactory = () => ApexInitOptions | Promise<ApexInitOptions>;
export const APEX_OPTIONS = new InjectionToken<ApexOptionsFactory>('APEX_OPTIONS');
export const APEX_FLAGS = new InjectionToken<ApexFlagDefinition[]>('APEX_FLAGS');
 
@Injectable({ providedIn: 'root' })
export class ApexService {
  private readonly zone = inject(NgZone);
  private readonly store = inject(Store);
  private readonly optionsFactory = inject(APEX_OPTIONS);
  private readonly flags = inject(APEX_FLAGS, { optional: true }) ?? [];
 
  async init(): Promise<void> {
    const { userId, ...options } = await this.optionsFactory();
    // Subscribe before configure: onChange does not replay, so a fatal startup stop (for example an invalid key)
    // would otherwise never reach the store.
    Apex.onChange((change: ApexChange) =>
      this.zone.run(() => {
        if (change.type === 'stopped') this.store.dispatch(new ApexStopped(change.code));
        else this.publish();
      }),
    );
    await Apex.configure(options);
    if (userId !== undefined) await Apex.identify(userId);
    this.publish();
  }
 
  /** Emits now and after every change. Reading it records the exposure. */
  flag$<T>(key: string, defaultValue: T, validate?: (value: unknown) => value is T): Observable<T> {
    return new Observable<T>((subscriber) => {
      const read = () => subscriber.next(Apex.flagObject<T>(key, defaultValue, { validate }));
      read();
      const sub = Apex.onChange((change) => {
        if (change.type === 'changed' && change.keys.includes(key)) this.zone.run(read);
      });
      return () => sub.cancel();
    });
  }
 
  /** Records the exposure for a flag whose value came from the store. */
  logExposure(key: string): void {
    Apex.logExposure(key);
  }
 
  identify(userId: string | null): Promise<void> {
    return Apex.identify(userId);
  }
 
  track(event: string, properties?: Record<string, unknown>): Promise<void> {
    return Apex.track(event, properties);
  }
 
  trackPurchase(orderId: string, revenue: number, currency: string): Promise<void> {
    return Apex.trackPurchase(orderId, revenue, currency);
  }
 
  private publish(): void {
    const values: Record<string, unknown> = {};
    for (const f of this.flags) {
      values[f.key] = Apex.flagObject(f.key, f.defaultValue, { validate: f.validate, exposure: 'manual' });
    }
    this.store.dispatch(new ApexFlagsChanged(values));
  }
}

A provider function registers the options, the flags, the state and the initializer. It needs provideStore() at the root.

ts
// provide-apex.ts
import { EnvironmentProviders, inject, makeEnvironmentProviders, provideAppInitializer } from '@angular/core';
import { provideStates } from '@ngxs/store';
import { ApexFlagsState } from './apex-flags.state';
import { APEX_FLAGS, APEX_OPTIONS, ApexFlagDefinition, ApexOptionsFactory, ApexService } from './apex.service';
 
export function provideApex(options: ApexOptionsFactory, flags: ApexFlagDefinition[] = []): EnvironmentProviders {
  return makeEnvironmentProviders([
    { provide: APEX_OPTIONS, useValue: options },
    { provide: APEX_FLAGS, useValue: flags },
    provideStates([ApexFlagsState]),
    provideAppInitializer(() => inject(ApexService).init()),
  ]);
}
ts
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideStore } from '@ngxs/store';
import { provideApex } from './apex/provide-apex';
import { capacitorPreferencesStorage } from './apex/apex-preferences.storage';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideStore([]),
    provideApex(
      () => ({
        publishableKey: 'apx_pk_live_…',
        shopId: 'YOUR_SHOP_ID',
        applicationId: 'com.example.shop',
        storage: capacitorPreferencesStorage(),
        userId: null, // Or the signed-in user ID, if the app knows it at startup.
      }),
      [{ key: 'welcome', defaultValue: false }],
    ),
  ],
};

Read a flag from the store and record the exposure where the variant is visible:

ts
readonly welcome$ = this.store.select(ApexFlagsState.flag<boolean>('welcome'));
 
ngOnInit(): void {
  this.apex.logExposure('welcome');
}

Or use the RxJS helper. It reads with automatic exposure, so subscribe only where the app shows the variant:

ts
readonly welcome$ = this.apex.flag$('welcome', false);

Capacitor Preferences storage

The SDK stores its state in localStorage by default. To use Capacitor Preferences (UserDefaults on iOS, SharedPreferences on Android), pass a storage adapter. Capacitor stays an app dependency, not an SDK dependency.

ts
// apex-preferences.storage.ts
import { Preferences } from '@capacitor/preferences';
import type { ApexStorage } from '@drip-apex/app-sdk';
 
/** Prefixes keys so Apex data stays separate from the app's own preferences. */
export function capacitorPreferencesStorage(prefix = 'apex.'): ApexStorage {
  return {
    async get(key: string): Promise<string | null> {
      return (await Preferences.get({ key: prefix + key })).value;
    },
    async set(key: string, value: string): Promise<void> {
      await Preferences.set({ key: prefix + key, value });
    },
  };
}

A storage adapter must replace a value in one step, or reject without changing it. Preferences.set meets this rule.

Cordova content security policy

Cordova apps must allow the events host in their content security policy before you test. Without connect-src https://events.drip-apex.com, every SDK request fails with a network error. Keep the other origins your app needs:

html
<meta http-equiv="Content-Security-Policy"
      content="default-src 'self'; connect-src 'self' https://events.drip-apex.com">

Cordova iOS apps need cordova-ios 8 or later to build with current Xcode. See Supported versions.