One Night, One Store Submission: Push Notifications, Deep Links and Native Video Share for a WebView App
Klooboo, our game platform, went live on the App Store on October 7. The app is deliberately thin: an Expo shell around a WebView that loads the web game. Every feature we ship on the web reaches the app the moment it deploys — no store review, no waiting for people to update.
That design has one cost. There are things a web page inside a WebView simply cannot do, and the day after launch we hit three of them at once:
- Share a video. Kids can turn their world into a trailer. In the app, the “Share video” button could only copy a link, and “Save” did nothing — a WebView can’t hand a file to the share sheet or write to the photo library.
- Push notifications. Building a world or a hero takes minutes. Nothing told a kid it was ready once they’d left the app.
- Deep links. A shared
klooboo.com/g/abc123link opened Safari, not the app the friend had just installed.
Each of these needs native code, and native code means a store submission. I didn’t want three. So the plan for 1.0.1 was one release carrying all three — and I wrote the plan in the evening, handed the build to an AI coding agent overnight, and spent the morning reviewing and testing on real phones.
This is what we built, and more usefully, what broke.
The rule: the shell carries plumbing, nothing else
Before any code, one constraint shaped everything: the native side only gets plumbing. It can ask for permission, hold a device token, download a file, open a share sheet. It does not decide what to push, when, or what a link means. All of that lives on the server and the web page.
The reason is review time. If the copy of a notification, its timing or its rules lived in the native shell, every tweak would be a store submission. Kept server-side, a push is a row in a database and a deploy.
The shell and the page talk through a small message bridge. The page asks (PUSH_STATUS_REQUEST, PUSH_PERMISSION_REQUEST, VIDEO_SHARE_REQUEST); the shell answers (PUSH_STATUS, OPEN_PATH, VIDEO_RESULT). That’s the whole native surface for 1.0.1.
Video share: the shell fetches, but only from one place
The share fix was the simplest of the three. When the page asks to share a video, the shell downloads the MP4 to a temp file and opens the system share sheet with the real file — so it lands in WhatsApp or Messages as a video, not a link. Save goes to Photos.
One deliberate limit: the shell only downloads from our own CDN’s /videos/*.mp4 path. A bridge that downloads any URL a page names is a bridge someone will eventually abuse.
And one policy surprise: on Android we turned Save off. Writing to the shared photo library means asking for media permissions, and Google Play now restricts apps that request photo and video access unless it’s core to the app. Sharing works everywhere; saving is iOS-only for now.
Push without Firebase on iOS
Our other app — a kids’ puzzle game — sends push through React Native Firebase. It works, but it cost us: on iOS it forced static frameworks, building React Native from source, 30-to-60-minute archives, and a header patch to compile under Xcode 26. I didn’t want any of that again.
So klooboo uses expo-notifications, which hands you the platform’s raw device token: an APNs token on iOS, an FCM token on Android. No Firebase SDK on iOS at all.
That moves the sending to us. The server talks to APNs directly: HTTP/2 from Bun, authenticated with a short-lived ES256 JWT signed with the team’s .p8 key, cached for 50 minutes. Android goes through FCM with the Firebase Admin SDK. Two small transports we own, instead of a third party in the path of every message.
APNs keys are team-scoped, so the key the puzzle app already had works for klooboo too. On Android we registered klooboo inside the puzzle’s existing Firebase project, so the server key the puzzle backend uses can push to it as well.
The page registers the token, not the shell
The token comes from the shell, but the page sends it to the server — because the page holds the signed-in session. That gets one subtle case right for free: a shared family phone. When a different kid signs in, the page re-registers the same device token under the new account, and the server moves it. A push meant for the previous kid never lands on the new one’s screen.
What we push — and the one person we never push
Four kinds of push, each with its own rules:
- Creation ready. “Your world is ready! 🌍”, “Your hero is ready!”, “Your video is ready! 🎬” — and when a build fails, a push saying the credits are back. These are transactional: they always go out.
- Play milestones. The first time someone else plays your world, then at 10, 25, 50 and up. Being played is the best feeling the app can give a kid who made something.
- An evening digest. “7 players played your worlds today” — counted as different players, so one friend replaying twice isn’t a digest.
- Ladder nudges. One push per step, ever, nudging a player up a ladder: a guest who’s played a few runs gets “sign in free, you get credits to make anything”; a signed-in player who never made anything gets “your credits are waiting”; a world nobody has played gets “send it to a friend”; a world that took off gets “make another”.
The top of our ladder is “director” — people who build games with us. Directors are adults and invite-only, so the ladder never pushes anyone toward it. Instead, the strongest makers show up in a log line for me to read and invite myself. A notification system that tries to move kids into an adult role is a design bug, not a growth lever.
Everything except creation pushes shares a budget: one push per person per 20 hours, and only between 09:00 and 20:00 their local time. The digest and nudge passes run hourly, but each one only considers people whose local time is between 17:00 and 20:00 — so one hourly job reaches every timezone once a day, at a sensible hour.
One table, two jobs
Every push the server attempts writes one row to a PushLog collection, and that row does double duty.
It is the idempotency lock. Each push has a key like world:<jobId> or milestone:<worldId>:10, and the key has a unique index. A world finishing twice, a sweeper racing a callback, two server pods running the same hourly pass — they all try to insert the same key, and exactly one wins. No distributed lock, no “did we already send this” query that races.
It is the open-rate numerator. When a kid taps a notification, the app reports it and the row gets an openedAt. So “do pushes bring anyone back?” stops being a guess: open rate per kind is opened ÷ sent straight from the table. The admin page shows exactly that — reach, sent, open rate by type, and the last hundred messages word for word.
The budget gets the same care. Claiming it is a single atomic update on a per-person row, so two budgeted pushes racing for the same kid can’t both slip through. And if a push is skipped for timing reasons — quiet hours, budget — its key is released, so it can go out later instead of being silently burned.
Asking for permission once, at the right moment
iOS gives you exactly one chance to show the notification prompt. Ask at launch, before the kid knows why, and the answer is usually no — forever.
So the app never asks at launch. The only place it asks is inside the progress bubble of something the kid is building: ”🔔 Ping me when it’s ready.” By then something of theirs is cooking, and the reason to say yes is sitting right there.
Deep links: two files and two fingerprints
Deep links are mostly two JSON files on your own domain: apple-app-site-association for iOS and assetlinks.json for Android. We serve both from the web container on play.klooboo.com and klooboo.com. One catch on the second: our apex domain redirects to play., and the association files must not redirect — so the redirect skips /.well-known/.
On Android, assetlinks.json lists the SHA-256 fingerprint of the certificate the app is signed with. With Play App Signing, store installs are signed by Google’s key, while a build you install from your laptop is signed with your upload key. We list both, so links work in the store version and in the build on my desk.
The eight traps that only showed up on real devices
The unit tests were green all night (1,351 on the server, 2,781 on the client). Every one of these still bit us.
1. iOS push data arrived empty. expo-notifications on iOS reads a remote notification’s custom data from a key called body in the payload, not from the top level where we’d put it. The tap opened the app but didn’t know which world to open. The server now sends the data under body as well.
2. A tap on a cold start could be lost. When a notification launches the app from scratch, the shell learns about the tap before the web page has loaded. On a slow phone the message to open the world was sent into a page that wasn’t listening yet. The shell now holds the pending path and replays it when the page finishes loading — and marks the tap as handled, so relaunching the app doesn’t reopen the same world again.
3. Apple cached our “not found”. iOS doesn’t fetch apple-app-site-association from your server; it fetches Apple’s CDN copy. The app went onto my phone a few minutes before the web deploy carrying the file finished, Apple’s CDN cached a 404, and links kept opening Safari. For development builds there’s an escape hatch: add ?mode=developer to the associated-domains entry and turn on Associated Domains Development in the iPhone’s developer settings, and iOS reads the file straight from your server. Later that day Apple’s CDN refreshed on its own.
4. The push entitlement said “development”. The entitlements file in the repo says aps-environment: development, because builds installed directly from Xcode need it. The question was whether the App Store export would rewrite it to production. It does — we checked the signed archive with codesign -d --entitlements rather than trusting it. Worth checking: ship development to the store and every push silently goes to the wrong server.
5. One missing secret broke all of them. Our Kubernetes secrets sync from AWS Secrets Manager through an ExternalSecret. If the manifest references a property that doesn’t exist in the secret yet, the entire sync fails — not just the new key, every key. So the infra change adding the APNs key stayed a draft until the key was actually in Secrets Manager.
6. The Android emulator never received a push. On a Pixel emulator with Google APIs, everything looked right: Firebase initialized, the token reached our server, FCM accepted the send. Nothing ever arrived — not a trace in the device logs. On a real Samsung Galaxy A16 the first push arrived instantly. Test push on hardware.
7. Android cut our logo in half. Since Android 12, the splash icon is masked to a circle about two-thirds the size of its box. Our logo is two overlapping circles side by side — wide — and it reached 78% of the way from the center, so both sides got clipped. The fix was an Android-only size that keeps the logo inside 62%. (The Expo splash plugin’s Android block also doesn’t inherit the shared settings — leave them out and you get a tiny logo on an opaque square.)
8. The very first push was bad news. The first real notification to reach my phone wasn’t “your hero is ready”. It was “Your hero didn’t make it — your credits are back.” The push worked exactly as designed. The hero build hadn’t: our 3D quality check rejected it because the model’s arms were fused to its body. Looking closer, about one in five hero builds that week had failed the same way. The notification system’s first act was surfacing our biggest quality problem.
Where it landed
By the end of the next day:
- Push works end to end on iPhone and Android: permission, token, server send, delivery, tap, and the tap counted.
- Links open the app on both platforms, verified on device for both domains.
- Videos share as real files from the app’s share sheet.
- An admin page shows reach, sends and open rate per push type, previews who the next nudge pass would reach and with which words, and can run a pass on demand — with the same rules as the hourly job.
- Version 1.0.1 is built for both stores, carrying all three features in one submission.
Takeaways
- Keep the native shell dumb. Permission, tokens, files, share sheets. Every rule about what and when belongs on the server, where changing it is a deploy, not a review.
- You may not need Firebase on iOS. Raw APNs from your own server is an HTTP/2 request and a JWT. It removed a whole category of build pain.
- Make your idempotency key and your metrics the same row. A unique key that prevents double sends is also the natural place to record the open.
- Ask for push permission when the user has a reason. “Ping me when it’s ready” beats a cold prompt at launch.
- Test on real hardware, and verify what you ship. The emulator lied about push, Apple’s CDN lied about our file for an afternoon, and the only way to know the store build had the right entitlement was to open it and look.