Skip to Content

Projects

Native App Inbound Gateway Web Billing App Return

Deep-link gateway · Auth handoff · Product context

iOS Alternate Payment Flow

Built both the web and native sides of ABCmouse's iOS/Android alternate payment system — a SvelteKit inbound-gateway that validates deep-linked payloads from native apps (Zod schema, platform/referrer resolution, auth, product-hash translation) and a FlutterAppService that mints short-lived tokens and routes users back via platform-specific deep links or authenticated Flutter web URLs.

  • SvelteKit
  • Zod
  • TypeScript
  • Flutter
  • Vitest
  • WebdriverIO

The Problem

App Store billing rules limited how Age of Learning could offer certain subscription paths inside the ABCmouse iOS (and Android) apps. The business needed a trusted web alternate-payment path — without dropping auth, attribution identifiers, or product context when users crossed the native → web boundary, and without stranding them after purchase when they needed to return to the app.

  • Cross-boundary Auth
  • Attribution Continuity
  • Product Context

System Overview

I owned both sides of the handoff: a client-side SvelteKit inbound-gateway that receives deep links from the native apps, validates and processes them, then routes into web subscription/reactivation flows; and a FlutterAppService that mints short-lived backend auth tokens and sends users back to the app via platform-specific deep links or authenticated Flutter web URLs.

Native app (iOS / Android)
  │  deep link with encoded payload
  ▼
/inbound-gateway
  │  1. Zod-validate payload
  │  2. Resolve platform + referrer
  │  3. Hydrate / gate auth
  │  4. Translate product hashes (store → web)
  │  5. Strip sensitive query params from history
  ▼
Web billing / reactivation destination
  │  (pass-through state re-serialized on ?payload=)
  ▼
FlutterAppService
  │  mint short-lived token
  │  pick native deep link vs Flutter web auth URL
  ▼
Back in app (device tracking + post-login destination)

Inbound Gateway

The gateway is a client-only SvelteKit route (ssr = false). The load function owns pure data processing — parse query params, validate, resolve platform, assemble redirect data — while the page component owns side effects (client goto()). That split avoids hydration timing issues and keeps failure modes graceful: invalid or missing data surfaces a fallback UI instead of a hard crash.

Entry URLs carry an encoded JSON payload, a product_uuid, an optional referrer (which app version sent the link), and optional pass-through flags. After a successful process, history.replaceState strips tokens and payload from the browser history so they do not linger in the address bar.

  • Schema-first Validation
  • Platform Resolution
  • Auth Pipeline
  • Product Hash Swap
// Sanitized Zod schema (illustrative)
const InboundDataSchema = z.object({
  path: z.string().min(1).refine((p) => p.startsWith('/')),
  token: z.string().optional(),
  platform: z.string().optional(), // "ios" | "android" | ...
  countryCode: z.string().optional(),
  adid: z.string().optional(),
  idfa: z.string().optional(),
  udid: z.string().optional(),
  source: z.string().optional(),
  data: z.record(z.any()).optional() // pass-through blob
});

// Example deep-link payload
{
  "token": "mock-session-token",
  "path": "/subscription",
  "platform": "ios",
  "countryCode": "US",
  "adid": "mock-adid-uuid",
  "data": {
    "products": [
      { "type": "monthly", "hash": "hash_monthly_store" },
      { "type": "annual",  "hash": "hash_annual_store" }
    ]
  }
}
// Platform + referrer → resolved context
// payload.platform × referrer query param

ios      + app_v1  → ios_legacy
ios      + app_v2  → ios_current
android  + app_v1  → android_legacy
android  + app_v2  → android_current
android  + (none)  → android_generic
(other)            → web_default

// Product hash translation before redirect
const PRODUCT_HASH_SWAP_MAP = {
  hash_monthly_store: 'hash_monthly_web',
  hash_annual_store:  'hash_annual_web'
};

// Auth edge cases
// ✓ valid token     → redirect to destination with session
// ✗ missing token   → fallback UI (or destination without auth when allowed)
// ✗ expired campaign token → offer-resend / recovery path
// legacy in-app webview referrer → auth requirement skipped

FlutterAppService

After web billing (or when the native shell needs a trusted return path), FlutterAppService mints a short-lived backend auth token and chooses how to send the user home: a platform-specific native deep link when the app can open it, or an authenticated Flutter web URL otherwise. Device identifiers travel with the handoff for tracking, and the post-login destination respects which registration path the user came from — so a reactivation user does not land in a first-time onboarding flow.

  • Short-lived Tokens
  • Native Deep Links
  • Flutter Web Auth
  • Path-aware Destinations
// Public-shaped API surface (illustrative)
type ReturnTarget =
  | { kind: 'native'; url: string }   // platform deep link
  | { kind: 'flutter_web'; url: string };

async function buildAppReturn(options: {
  platform: 'ios' | 'android';
  deviceId: string;
  registrationPath: 'new' | 'reactivation' | 'upgrade';
}): Promise<ReturnTarget> {
  const token = await mintShortLivedToken({
    deviceId: options.deviceId,
    ttlSeconds: 90
  });

  const destination = resolvePostLoginPath(options.registrationPath);

  if (canOpenNativeDeepLink(options.platform)) {
    return {
      kind: 'native',
      url: `app://auth?token=${token}&to=${encodeURIComponent(destination)}`
    };
  }

  return {
    kind: 'flutter_web',
    url: `/flutter-auth?token=${token}&to=${encodeURIComponent(destination)}`
  };
}

Testing

I owned the gateway’s unit and E2E coverage. Vitest suites pin the Zod schema, platform branches, auth edge cases, and product-hash swap behavior with mocked stores and fixtures. WebdriverIO E2E scenarios exercise the full redirect chain under device emulation — including happy paths, malformed payloads, and auth failures that must stay on the gateway and expose the fallback Home / Log In UI.

  • Vitest + Faker Fixtures
  • WebdriverIO E2E
  • Page Object Model
// Unit: schema + hash swap (illustrative)
expect(validateInboundData({ path: 'subscription' })).toBeNull(); // must start with /
expect(swapProductHashes(data).products[0].hash).toBe('hash_monthly_web');

// E2E scenarios covered
// ✓ iOS payload, valid credentials  → subscription destination
// ✓ iOS payload, hash swap          → web hashes in final URL
// ✓ custom pass-through data        → preserved through redirect
// ✓ invalid / missing payload       → stays on gateway, fallback UI
// ✓ invalid token                   → stays on gateway

Outcome

The system shipped in Fall 2025 as ABCmouse’s production alternate-payment path for iOS and Android. Native users can leave the app store billing surface, land on a validated web flow with their session and product context intact, complete purchase or reactivation, and return to the app through a controlled auth handoff — with a test pyramid that covers the boundary cases that usually break deep-link gateways.