Here's something I noticed when browsing the App Store a few months back: apps in the "interview preparation" category are quietly generating solid subscription revenue. Names like "Mock Interview AI" and "Interview Coach Pro" appear in the lifestyle and education charts, with price points between $8–$25/month, and they're not tiny operations.
The market makes sense when you think about it. Job searching is a high-stakes activity where people genuinely want to improve. The motivation to practice is there — what's historically been missing is an accessible, on-demand way to get feedback on your actual answers. AI changes that equation.
What follows is the pipeline I ended up with, along with the two places it broke on me: the way I pulled JSON out of the model's reply, and the place where I counted the free tier. Voice recording, transcription via Whisper, evaluation via Claude, session storage, and a freemium model — with working code, and with the measurements that made me rewrite two of those pieces.
Why This App Category Makes Sense for Indie Developers
Before diving into implementation, it's worth understanding what makes this a good market to enter.
Recurring need with seasonal spikes. Job searching happens throughout the year, with peaks during hiring seasons. Unlike a one-time utility app, interview prep is something users return to over weeks or months as they actively search.
High willingness to pay. If someone lands a job that pays $10,000 more per year, they'll happily look back at a $20/month subscription as one of the best ROIs of their life. The value proposition is clear and the payback period is obvious.
Underserved in non-English markets. High-quality interview prep apps designed specifically for Japanese-style interviews, German corporate culture, or Korean hiring processes are genuinely rare. Localizing this concept is itself a competitive advantage.
Technical moat is real but buildable. The combination of speech recognition + AI evaluation + progress tracking is sophisticated enough to be defensible, but entirely achievable with Rork Max and the right APIs.
Architecture: The Three-Step Pipeline
The core of this app is a three-step pipeline:
Step 1: Voice Recording The user reads a question (displayed as text, or optionally played via TTS), records their answer via microphone, and the audio file is captured locally.
Step 2: Transcription The audio is sent to OpenAI's Whisper API, which converts it to text. Whisper handles accents, filler words ("um," "uh"), and conversational speech well — which is actually useful information for interview feedback.
Step 3: AI Evaluation
The transcript is sent to Claude's Messages API via a Cloudflare Workers backend. Claude returns a structured evaluation: numerical scores on four dimensions, specific strengths, areas for improvement, a rewritten model answer, and a one-line tip. The model ID below is claude-sonnet-4-6; move up a tier for finer evaluation or down to a Haiku model for cost. Whichever you pick, keep the response handling described two sections down.
Results and session history are stored in Supabase.
[Rork Max App]
↓ audio file (m4a/mp4)
[Cloudflare Workers]
↓ forward
[Whisper API (OpenAI)]
↓ transcript
[Claude Messages API (Anthropic)]
↓ structured JSON evaluation
[Supabase] ← session history stored
↓
[Rork Max App] ← feedback UI rendered
Running the API calls through a Cloudflare Workers backend is non-negotiable for production. Embedding API keys in a mobile app violates both security best practices and App Store policies. Cloudflare Workers handles this with about 50 lines of code and has a generous free tier.
Step 1: Voice Recording with expo-audio
One library note before the code. Ask any model for Expo recording code and you'll get expo-av and Audio.Recording, because that's what most of the training data contains. The Audio API in expo-av was deprecated in SDK 53 in favour of expo-audio, and the SDK 54 docs say expo-av itself is slated for removal in SDK 55.
For a while I let the generated version stand, since it ran. The bill comes later: you bump the SDK one morning and only the recording screen fails to build. What generation gives you is the majority spelling, not the current one. Audio, video and filesystem APIs churn faster than most, so I now spend one pass checking generated code against the primary docs before it goes anywhere near a build.
With that settled, the prompt:
"Create a recording screen where tapping a microphone button starts audio recording and tapping again stops it. While recording, the button turns red and a timer shows elapsed time. After stopping, show a waveform visualization and an 'Analyze Answer' button. Use
useAudioRecorderfrom expo-audio, not expo-av."
Then replace the recording logic with this hook:
// hooks/useInterviewRecorder.ts
import { useEffect, useState } from 'react';
import {
useAudioRecorder,
useAudioRecorderState,
AudioModule,
RecordingPresets,
setAudioModeAsync,
} from 'expo-audio';
const MAX_DURATION_SEC = 180; // cap answers at 3 minutes
export function useInterviewRecorder() {
// Recordings land in the cache directory by default, which the OS may clear
// when storage runs low. Keep them in documents until evaluation finishes.
const recorder = useAudioRecorder({
...RecordingPresets.HIGH_QUALITY,
directory: 'document',
});
const state = useAudioRecorderState(recorder, 250);
const [permissionGranted, setPermissionGranted] = useState(false);
useEffect(() => {
(async () => {
const status = await AudioModule.requestRecordingPermissionsAsync();
setPermissionGranted(status.granted);
// iOS defaults allowsRecording to false — without this, nothing records
await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true });
})();
}, []);
const start = async () => {
if (!permissionGranted) {
throw new Error('Microphone permission is required');
}
await recorder.prepareToRecordAsync();
// record() is synchronous — awaiting it waits for nothing
recorder.record({ forDuration: MAX_DURATION_SEC });
};
const stop = async (): Promise<string> => {
await recorder.stop();
// Leaving the recording session open interferes with other apps' playback
await setAudioModeAsync({ allowsRecording: false });
const uri = recorder.uri; // the state object exposes `url`, the recorder `uri`
if (!uri) {
throw new Error('Recording file not found');
}
return uri;
};
return {
isRecording: state.isRecording,
durationSec: Math.floor(state.durationMillis / 1000),
permissionGranted,
start,
stop,
};
}Let the library enforce the cap through record({ forDuration }) rather than watching a timer yourself — a timer you own will miss the stop when the app goes to the background. Answers past three minutes aren't good interview practice anyway, and transcription cost scales with duration.
Three small things that cost me time: record() is synchronous, so the promises worth awaiting are prepareToRecordAsync() and stop(). The file path lives on recorder.uri, while the state object from useAudioRecorderState calls the same thing url. And iOS keeps allowsRecording off unless setAudioModeAsync says otherwise — I spent an evening sending silent files to Whisper before I found that one.
Step 2: Cloudflare Workers Backend — Whisper + Claude Evaluation
This is the most critical component. The Workers function receives an audio file, runs it through Whisper, then passes the transcript to Claude for evaluation.
// Cloudflare Workers: src/index.ts
interface Env {
OPENAI_API_KEY: string;
ANTHROPIC_API_KEY: string;
}
interface EvaluationResult {
transcription: string;
scores: {
relevance: number; // How directly the answer addresses the question (0-10)
specificity: number; // Use of concrete examples, numbers, outcomes (0-10)
structure: number; // Logical flow — STAR method or equivalent (0-10)
language: number; // Vocabulary, clarity, filler word frequency (0-10)
};
overall: number; // Weighted composite score (0-100)
strengths: string[];
improvements: string[];
rewrittenAnswer: string; // Claude's improved version of the answer
tips: string; // One-line coaching tip
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
try {
const formData = await request.formData();
const audioFile = formData.get('audio') as File;
const questionText = formData.get('question') as string;
const industry = (formData.get('industry') as string) ?? 'general';
const level = (formData.get('level') as string) ?? 'mid-level';
if (!audioFile || !questionText) {
return new Response(
JSON.stringify({ error: 'Audio file and question text are required' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Step A: Transcription via Whisper
const whisperFormData = new FormData();
whisperFormData.append('file', audioFile, 'recording.mp4');
whisperFormData.append('model', 'whisper-1');
whisperFormData.append('response_format', 'json');
const whisperResponse = await fetch('https://api.openai.com/v1/audio/transcriptions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${env.OPENAI_API_KEY}` },
body: whisperFormData,
});
if (!whisperResponse.ok) {
throw new Error(`Whisper API error: ${await whisperResponse.text()}`);
}
const { text: transcription } = await whisperResponse.json() as { text: string };
if (!transcription || transcription.trim().length < 15) {
return new Response(
JSON.stringify({
error: 'The recording was too short or could not be recognized. Please try again.'
}),
{ status: 422, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Step B: Evaluation via Claude Messages API
const claudeResponse = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': env.ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-sonnet-4-6',
max_tokens: 1500,
messages: [{
role: 'user',
content: buildEvaluationPrompt(questionText, transcription, industry, level)
}],
}),
});
if (!claudeResponse.ok) {
throw new Error(`Claude API error: ${claudeResponse.status}`);
}
const claudeData = await claudeResponse.json() as {
content: Array<{ text: string }>;
stop_reason: string;
};
// A truncated reply can only parse into broken JSON. The API tells you
// directly, so branch on stop_reason before touching the text.
if (claudeData.stop_reason === 'max_tokens') {
return new Response(
JSON.stringify({ error: 'The evaluation was cut short. Please try again.', retryable: true }),
{ status: 503, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
const rawText = claudeData.content[0]?.text ?? '';
const evaluation = extractEvaluation(rawText); // implemented in the next section
evaluation.transcription = transcription;
return new Response(JSON.stringify(evaluation), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Processing error:', error);
return new Response(
JSON.stringify({ error: 'An error occurred during evaluation. Please try again.' }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
}
};
function buildEvaluationPrompt(
question: string,
answer: string,
industry: string,
level: string
): string {
return `You are an expert interview coach specializing in ${industry} industry hiring at the ${level} level.
Evaluate the following interview answer and return ONLY valid JSON — no preamble, no explanation.
QUESTION: ${question}
CANDIDATE ANSWER (from voice transcript): ${answer}
Return this exact JSON structure:
{
"scores": {
"relevance": <0-10, how directly this addresses the question>,
"specificity": <0-10, use of concrete examples/numbers/outcomes>,
"structure": <0-10, logical flow, STAR method adherence>,
"language": <0-10, vocabulary, clarity, minimal filler words>
},
"overall": <0-100 weighted composite>,
"strengths": ["specific strength 1", "specific strength 2"],
"improvements": ["specific improvement 1", "specific improvement 2", "specific improvement 3"],
"rewrittenAnswer": "An improved version of this answer in ~100 words that demonstrates best practices",
"tips": "One actionable coaching tip for this specific candidate"
}
Be direct and honest. Vague feedback ("good job!") is not useful. Note that this is a voice transcript, so filler words ("um", "uh", "like") appear in the text — factor this into the language score.`;
}The evaluation prompt design is the core of this product's value. Including industry and seniority level parameters makes the feedback significantly more relevant — a junior developer should not be evaluated by the same standards as a VP of Engineering.
Pulling the Evaluation JSON Out of Six Response Shapes
This is where the 500 I mentioned at the top came from. The original code took everything between the first { and the last } with rawText.match(/\{[\s\S]*\}/) and handed it to JSON.parse. Most replies survive that. The ones that don't leave the user staring at an empty screen.
So I laid the failure modes out side by side. One evaluation payload, six response shapes I've actually observed, run through both extractors (Node.js v22, sandbox):
| Response shape | Greedy regex | Brace-counting extractor |
|---|---|---|
| A: bare JSON | OK | OK |
| B: "Here is the evaluation:" preamble | OK | OK |
| C: wrapped in a json code fence | OK | OK |
| D: prose containing braces after the JSON | throws a 500 | OK |
| E: truncated at max_tokens | throws a 500 | reported as truncated |
| F: braces inside the preamble | throws a 500 | OK |
Three of six. What the failures share is the greedy reach to the last closing brace: D and F swallow braces from the surrounding prose, and E hands JSON.parse a string that was never closed.
The replacement walks the text, tracks whether it's inside a string literal, counts brace depth, and takes the first balanced object:
// src/extract-evaluation.ts
export class IncompleteEvaluationError extends Error {}
function isEvaluation(value: unknown): value is EvaluationResult {
const v = value as EvaluationResult;
return !!v && typeof v === 'object'
&& !!v.scores && typeof v.overall === 'number'
&& Array.isArray(v.improvements) && typeof v.tips === 'string';
}
export function extractEvaluation(text: string): EvaluationResult {
// Strip the outer fence only; inner braces get counted, not matched
const fence = new RegExp('\\u0060{3}(?:json)?\\s*([\\s\\S]*?)\\u0060{3}', 'g');
const unfenced = text.replace(fence, '$1');
for (let i = unfenced.indexOf('{'); i !== -1; i = unfenced.indexOf('{', i + 1)) {
let depth = 0;
let inString = false;
let escaped = false;
for (let j = i; j < unfenced.length; j++) {
const c = unfenced[j];
if (escaped) { escaped = false; continue; }
if (c === '\\') { escaped = true; continue; }
if (c === '"') { inString = !inString; continue; }
if (inString) continue;
if (c === '{') {
depth++;
} else if (c === '}' && --depth === 0) {
try {
const value = JSON.parse(unfenced.slice(i, j + 1));
if (isEvaluation(value)) return value; // shape has to match too
} catch {
// unusable chunk — resume the search at the next {
}
break;
}
}
}
throw new IncompleteEvaluationError('No complete evaluation object in the response');
}My first version had no isEvaluation check, and on the truncated case it happily returned the inner scores object as "the first balanced chunk" — an evaluation with no score in it, rendered straight to the user. A 500 would have been kinder. A wrong-shaped value that passes is harder to find than a crash. Validating the shape in the same step that extracts it is what closed that class of report.
On the Worker side, catch IncompleteEvaluationError, return 503 with retryable: true, and let the app retry the evaluation once. The transcript already exists, so the retry costs one Claude call, not another upload.
Step 3: Session Storage and Progress Tracking
Single-session evaluation is useful. Multi-session progress tracking is what makes users stay subscribed.
// lib/session-manager.ts
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.EXPO_PUBLIC_SUPABASE_URL!,
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!
);
export interface PracticeSession {
id: string;
user_id: string;
question_id: string;
question_text: string;
transcription: string;
scores: {
relevance: number;
specificity: number;
structure: number;
language: number;
};
overall: number;
strengths: string[];
improvements: string[];
rewritten_answer: string;
tips: string;
industry: string;
level: string;
created_at: string;
}
export async function saveSession(
userId: string,
questionText: string,
questionId: string,
evaluation: Omit<PracticeSession, 'id' | 'user_id' | 'created_at'>
): Promise<PracticeSession> {
const { data, error } = await supabase
.from('practice_sessions')
.insert({
user_id: userId,
question_id: questionId,
question_text: questionText,
transcription: evaluation.transcription,
scores: evaluation.scores,
overall: evaluation.overall,
strengths: evaluation.strengths,
improvements: evaluation.improvements,
rewritten_answer: evaluation.rewritten_answer,
tips: evaluation.tips,
industry: evaluation.industry,
level: evaluation.level,
})
.select()
.single();
if (error) {
console.error('Session save error:', error);
throw new Error('Failed to save practice session');
}
return data as PracticeSession;
}
export async function getUserProgress(
userId: string,
questionId?: string,
limit = 20
): Promise<PracticeSession[]> {
let query = supabase
.from('practice_sessions')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(limit);
if (questionId) {
query = query.eq('question_id', questionId);
}
const { data, error } = await query;
if (error) throw new Error(`Failed to fetch progress: ${error.message}`);
return (data ?? []) as PracticeSession[];
}
// Calculate improvement trend across sessions for a question
export function calculateImprovementTrend(sessions: PracticeSession[]): {
trend: 'improving' | 'stable' | 'declining';
percentChange: number;
} {
if (sessions.length < 2) return { trend: 'stable', percentChange: 0 };
const recent = sessions.slice(0, Math.min(3, sessions.length));
const earlier = sessions.slice(-Math.min(3, sessions.length));
const recentAvg = recent.reduce((sum, s) => sum + s.overall, 0) / recent.length;
const earlierAvg = earlier.reduce((sum, s) => sum + s.overall, 0) / earlier.length;
const percentChange = ((recentAvg - earlierAvg) / earlierAvg) * 100;
return {
trend: percentChange > 5 ? 'improving' : percentChange < -5 ? 'declining' : 'stable',
percentChange: Math.round(percentChange),
};
}When you prompt Rork Max to generate a "progress dashboard with a line chart showing score history," it will typically output Victory Native chart code with a clean layout. Wire in calculateImprovementTrend to show badges like "+15% since last week" — that positive reinforcement drives session frequency.
Freemium and Subscription Design
The freemium structure for this category is fairly well established. Here's what I'd recommend:
Free plan:
- 3 practice sessions per day
- Basic evaluation (scores + 2 improvement points)
- 7-day history
Premium ($9.99/month):
- Unlimited sessions
- Full evaluation (rewritten model answer + coaching tip)
- Industry-specific question banks (Tech, Finance, Consulting, Healthcare, etc.)
- Complete history and detailed progress charts
- Monthly weakness analysis report
The critical design decision: show free users the score and strengths, but blur the rewritten answer and coaching tip behind a paywall. The psychological "I can almost see it" pattern converts well for a self-improvement product. Users who can see their score is a 6/10 but can't see how to get to a 9/10 are motivated to upgrade.
// components/EvaluationResultScreen.tsx (excerpt)
import { useRevenueCat } from '../hooks/useRevenueCat';
export function EvaluationResultScreen({ result }: { result: EvaluationResult }) {
const { isPremium } = useRevenueCat();
return (
<ScrollView>
{/* Always visible */}
<ScoreCard scores={result.scores} overall={result.overall} />
<StrengthsList strengths={result.strengths} />
{/* Premium-gated */}
{isPremium ? (
<>
<ImprovementsList improvements={result.improvements} />
<RewrittenAnswer text={result.rewrittenAnswer} />
<CoachingTip tip={result.tips} />
</>
) : (
<PremiumPaywallTeaser
preview={result.improvements[0]} // Show the first improvement point
onUpgrade={() => Purchases.presentOfferingIfEligibleForIntroOffer()}
/>
)}
</ScrollView>
);
}Counting the Free Tier on the Device
There's a flaw in the code above that I shipped and lived with for a while: todayCount is device state.
Counted on the device, a limit is a request rather than a limit. Reinstall the app and the counter resets. More to the point, the Worker endpoint is open to anyone — CORS set to *, no authentication — so a single curl invocation walks straight past both the free tier and the paywall. The invoices still arrive from OpenAI and Anthropic, addressed to you.
If a count decides who pays, the count belongs on the server. Keep the client-side counter as well: "2 sessions left" has to render instantly, and only the device can do that. Display on the client, decide on the server — both stay, with different jobs.
// Cloudflare Workers: src/authorize.ts
import { createClient } from '@supabase/supabase-js';
const FREE_DAILY_LIMIT = 3;
// Start of "today" in the user's business timezone, not UTC
function startOfTodayJst(): string {
const jst = new Date(Date.now() + 9 * 60 * 60 * 1000);
const midnightUtc = Date.UTC(jst.getUTCFullYear(), jst.getUTCMonth(), jst.getUTCDate());
return new Date(midnightUtc - 9 * 60 * 60 * 1000).toISOString();
}
type AuthResult =
| { ok: true; userId: string }
| { ok: false; status: number; error: string };
export async function authorize(request: Request, env: Env): Promise<AuthResult> {
const jwt = request.headers.get('Authorization')?.replace(/^Bearer\s+/i, '');
if (!jwt) {
return { ok: false, status: 401, error: 'Sign-in required' };
}
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_KEY);
const { data, error } = await supabase.auth.getUser(jwt);
if (error || !data.user) {
return { ok: false, status: 401, error: 'Invalid session' };
}
const userId = data.user.id;
// Entitlement comes from the RevenueCat webhook writing into `profiles`.
// Never let the client assert "I'm premium".
const { data: profile } = await supabase
.from('profiles')
.select('is_premium')
.eq('id', userId)
.single();
if (profile?.is_premium) {
return { ok: true, userId };
}
const { count } = await supabase
.from('practice_sessions')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.gte('created_at', startOfTodayJst());
if ((count ?? 0) >= FREE_DAILY_LIMIT) {
return { ok: false, status: 402, error: "You've used today's free practice sessions" };
}
return { ok: true, userId };
}Call it at the top of fetch, before formData() — rejecting after you've read the audio means paying for the transfer you just refused.
const auth = await authorize(request, env);
if (!auth.ok) {
return new Response(JSON.stringify({ error: auth.error }), {
status: auth.status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}One note on the day boundary. Cutting created_at on UTC days means users in Tokyo get their free sessions back at 9am — and evening is exactly when people practise. Cut the day in the timezone your users live in.
Three more guards worth the ten minutes: pin Access-Control-Allow-Origin to your app's origin instead of *, cap the upload size (5 MB covers three minutes of AAC), and check Content-Length so oversized bodies get a 413 before you read them.
Common Pitfalls
Pitfall 1: Leaving the iOS recording session open
After stop(), call setAudioModeAsync({ allowsRecording: false }). Skip it and your app interferes with other apps' playback on iOS. The hook above does this; generated code usually doesn't. (The expo-av spelling was allowsRecordingIOS — another name to fix while migrating.)
Pitfall 2: The preset changes the format on Android only
In expo-audio, RecordingPresets.HIGH_QUALITY produces .m4a (AAC) on both platforms, while LOW_QUALITY drops Android to .3gp AMR-NB. Choosing the smaller preset to save bandwidth gives you transcription failures on some Android devices and nowhere else — a miserable bug to narrow down. Interview answers are short; stay on HIGH_QUALITY.
Pitfall 3: Trusting one regex to extract the JSON
As measured above, the greedy regex fails on three of six realistic response shapes. Count braces, validate the shape, and catch truncation with stop_reason before you parse anything.
Pitfall 4: Discriminatory interview questions App Store review teams flag apps that include questions related to age, marital status, nationality, or religion in hiring contexts. Even if such questions exist in some real-world interviews, avoid including them in your question bank. It's both the right thing to do and avoids review complications.
Pitfall 5: Where the recordings live, and who deletes them
expo-audio writes to the cache directory by default, which the OS may clear under storage pressure — hence directory: 'document' for anything you need until evaluation returns. That choice makes cleanup your job: call FileSystem.deleteAsync(uri) once the evaluation lands. If playback is a premium feature, upload to Supabase Storage first and keep the URL on the session row.
Growth Strategy After Launch
Once the core experience is working, the highest-leverage next move is usually improving question quality and expanding the question bank. Users who practice the same 20 questions repeatedly will churn. A rotating library of fresh, industry-specific questions is what keeps subscriptions active.
The second lever is seasonality. In markets with defined hiring seasons (Japan's spring graduation hiring, fall internship season in the US), targeted campaigns and App Store featuring requests aligned with these periods can generate outsized installs.
The longer-term opportunity is B2B. Corporate clients — for onboarding training, internal promotion prep, or bootcamp curricula — will pay significantly more per seat than individual subscribers, and they churn far less. Getting your first B2B client often comes from your existing B2C users who happen to work in L&D departments.
Start with the consumer subscription product, validate the evaluation quality, build the question bank, and revisit B2B once you have 500+ active subscribers as social proof.
The first step today: deploy the Worker and push one thirty-second answer of your own through transcription and evaluation. Two numbers decide whether this is worth building — how accurate the evaluation feels, and how long you sit there waiting for it. Everything else, the UI polish and the question bank, layers on afterwards.
One thing I'd do differently if I started again: put authorize() in front of the pipeline on day one, before there's a single user. Adding a gate to an endpoint that's already in the wild is a much less pleasant afternoon. Thanks for reading this far.