Deep-link gateway · Auth handoff · Product context
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.
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.
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)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.
// 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 skippedAfter 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.
// 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)}`
};
}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.
// 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 gatewayThe 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.