Inspiration
Audio drama never got a native mobile form. It got podcast apps, a feed, a play button, a progress bar. You decide to listen, which means the fiction only ever exists when you go and fetch it.
But there is one channel that reaches into someone's day uninvited, and every phone has it: the notification. We build entire products to make notifications less intrusive. What if the intrusion was the story?
That was the whole idea. Don't put a play button in an app. Ring the phone. Let a stranger be on the line, at a plausible hour, saying your name, and let the player answer out loud.
What it does
Withheld is an episodic audio thriller delivered as incoming phone calls.
- You choose the hours you're reachable. Calls only arrive inside that window, at a believable minute, never 3am, never twice in an hour.
- The phone actually rings. A full-screen incoming call, its own ringtone, its own notification channel. Answer or decline, declining is a real choice the story hears.
- You speak your replies. On-device speech recognition turns what you said into text, the server picks the character's response, and it comes back in her voice. If you can't talk, every reply can be tapped. The entire season is playable in silence.
- The story remembers. What you told a caller in episode two is still true in episode six.
- Miss a call and it says so. An unanswered ring goes to voicemail, the story acknowledges the silence, and the season carries on rather than stalling.
Season 1, Wrong Number, is seven calls over about a week. A stranger rings you by someone else's name and will not accept that she has the wrong person.
How we built it
The app — Expo SDK 57 / React Native 0.86, new architecture, expo-router. The UI is diegetic: no tab bar, no cards, no spinners. A phone that is lying to you.
The delivery — every call is a OneSignal push booked with send_after at the moment the previous call completes, on a dedicated Android channel so the phone plays a ring rather than a message ping, and time_sensitive on iOS so it lands through Focus — without ever bypassing Do Not Disturb.
The server — Fastify on Azure Container Apps with Cosmos DB, owning scheduling, entitlements and story state. The app is a client, not the source of truth. A sweeper runs every minute to mark missed calls, send voicemail nudges, and re-queue any player whose season has stalled.
The story engine — a typed schema and engine shared by server, renderer and app. Content is manifest-first: the season arrives from the server at session start, so new episodes, re-castings and pacing changes ship without an app release.
The voices — ElevenLabs eleven_v3, rendered ahead of time through a content-hash cache so a script tweak re-renders one line, not a season. Free-form spoken replies are handled live: the transcript goes to Claude, which picks the in-character response from the authored graph, and the line is rendered and streamed back mid-call.
Monetization — RevenueCat owns one entitlement, secure_line, across a monthly subscription with a 7-day trial, an annual, and a one-time Season Pass. A RevenueCat webhook mirrors entitlement into our own store, so the server gates content without trusting the client and premium survives a reinstall.
Challenges we ran into
Every ring push was failing, silently. OneSignal rejected each one with "Ios interruption level is invalid" — we had sent time-sensitive where the API wants time_sensitive. One character. For an app that is nothing but notifications, the entire product was inert, and the only symptom was calls that never came.
The app died the moment anyone spoke. Android killed the process with EXCESSIVE_BINDER_OBJECTS — two unrelated leaks stacked: a new audio player per line, each holding a MediaSession, and a speech recognizer being re-armed synchronously from inside its own end event, thousands of times a second.
Then the microphone cancelled itself. abort() emits end asynchronously, so a fresh capture read the previous session's end as its own silence and stopped. The kind of bug that exists only because two correct APIs disagree about whose event is whose.
Recovery lived where it could never run. A missed or abandoned call was only re-queued when the player next opened the app — but this app is opened when it rings. The mechanism that unsticks a stuck player depended on the thing that was stuck. It now runs server-side every minute. The same mistake in miniature: an appointment was recorded even when its push failed to book, producing a scheduled call that could never ring and that nothing would ever retry.
Time is harder than it looks. A call window of 21:00–00:00, read as same-day, put the end 22 hours in the past — so every call rolled to tomorrow and the player got one a day, at the wrong hour.
And the reviewer's session told us more than any bug report. After Apple rejected the build for a missing Terms of Use link, we added one — and our crash reporter caught what happened next, three taps in eighty seconds: Unable to open app settings, Unable to open URL: .../terms/, Unable to open app settings. React Native's Linking.openURL rejects whenever iOS declines a URL, and on iOS 26 it was declining even plain https. We had shipped Apple the "functional link" they asked for, as a button that did nothing.
Accomplishments we're proud of
The app never lies about being able to reach you. If notifications are off, or the push token is missing, the home screen says so — because for this product a silent delivery failure is indistinguishable from a broken app.
And the server heals itself. Missed, abandoned, unscheduled, or a push that failed to book: the sweeper finds it and re-queues it, without the player ever needing to open an app they have no reason to open.
What we learned
Push delivery is a product surface, not plumbing. When the whole experience arrives by notification, "the send failed" and "your app is broken" are the same event to the person holding the phone.
We also learned how much of shipping happens outside the code. Store metadata, EULA links, a review screenshot for every product, price schedules, demo-account declarations — each capable of costing a review cycle. So we scripted all of it against the App Store Connect and Play Developer APIs, and the scripts refuse to publish a URL that doesn't resolve.
What's next for Withheld
- Season 2, and a second caller whose calls overlap the first's.
- Making a declined notification permission cost the surprise rather than the product — a waiting call you can start yourself.
- Localised seasons. The engine is content-driven, so a season is data, not a build.
Built With
- admob
- android
- anthropic-claude
- azure-blob-storage
- azure-container-apps
- azure-cosmos-db
- azure-static-web-apps
- bicep
- codemagic
- docker
- elevenlabs
- expo-router
- expo.io
- fastify
- ios
- node.js
- onesignal
- pnpm
- react-native
- revenuecat
- sentry
- typescript
- zod
Log in or sign up for Devpost to join the conversation.