- internship
- 2025 to now
- Voice rooms
- Stripe billing
- AI pipeline
- AWS
IABTM is a US self-improvement platform: personalised growth paths, curated film, music and writing, a social feed, live voice stages, paid tiers and two Shopify stores.
I joined in September 2025 and picked up a 55,000-line codebase. Since then I’ve rebuilt its voice rooms and its billing, built the AI pipeline, the email platform and the tools the team runs the product from, and I look after the servers it runs on.



- Type
- internship
- Role
- Software Engineer, internship, remote
- When
- Sep 2025 to now
- Stack
- Next.js, React, Express, MongoDB, Redis, Socket.IO, mediasoup, Stripe, Shopify, Gemini, PostHog, AWS
- Links
- iambetterthanme.com
- publishers, curated daily
- 6
- jobs that run themselves
- 7
- admin tools for the team
- 25
What I’ve shipped
Live Stripe billing
Three tiers and four plans, rebuilt after I found four holes in the old one. Prices are set on the server, and a payment event that arrives twice only counts once.
How it worksVoice rooms on an SFU
Live audio rooms where five members hold the stage and the rest raise a hand, rebuilt from a peer-to-peer mesh onto mediasoup.
How it worksA daily AI pipeline
Every morning, new articles from six major publishers land in the right members’ growth paths. Gemini picks the fit from the platform’s own list, never its own words.
How it worksSeven jobs that run themselves
Email campaigns, daily nudges in each member’s own time zone, store syncs and health digests, each run once on schedule however many servers are up.
How it worksPremium, enforced once
Free members can browse the whole catalogue and get a taste of every premium section. One check on the server decides what plays.
How it worksFailures that make noise
If a new member’s growth path fails to build, the account is marked and the team hears about it, instead of signup quietly saying yes.
How it worksTwo Shopify stores
Two stores behind one headless backend. Orders arrive verified, a repeat can’t duplicate one, and new products publish themselves overnight.
Email end to end
Sign-in codes, billing, lifecycle and campaign email moved onto Resend. Every send is logged, bounces are kept off the list, and failures reach the team within the hour.
The tools the team works in
25 admin screens: a support desk, a campaign CRM, analytics and A/B panels, and controls for subscriptions and the shops.
SEO as a system
Every page gets its own title, share image and structured data from one builder, across about 30 layouts.
Monitoring and alerts
Uptime checks, server errors and model telemetry that never records what members wrote, plus a daily health digest.
The server it runs on
EC2 with nginx and PM2, media on S3 behind CloudFront, and a release tag to roll back to for every launch.
Uploads straight to S3
Posts and banners of up to 2 GB go from the browser to S3 in parallel parts that retry on their own, so a dropped connection costs one part, not the whole upload.
A feed that stays fast
One aggregation, cursor pagination checked against the query plan, and a short Redis cache, so page fifty costs the database what page one does.
Ready for more servers
Sockets and caches shared through Redis, jobs that claim their turn, and duplicates stopped in the database, so the web side can run on several servers.
Accessible by default
I built most of the app's accessibility: keyboard paths, focus handling, screen-reader labels and reduced motion.
How it fits together
Billing that counts every payment once
The Stripe integration I inherited let the browser choose the price, let anyone cancel anyone’s subscription, never actually switched a paying member on, and handled every retried event all over again.
I rebuilt it around three rules: prices live on the server, one service decides who has access, and every Stripe event is written down before it is acted on, so a retry can’t do anything twice. It went live in August 2026 with existing members carried over, covered by unit, end-to-end and live-Stripe tests.
billingWebhookController.js · handleStripeWebhook
try { await StripeWebhookEvent.create({ eventId: event.id, type: event.type, });} catch (error) { if (error?.code === 11000) { return res.status(200).send('Already processed'); } return res.status(500).send('Could not record event');}try { await dispatch(event); await StripeWebhookEvent.updateOne( { eventId: event.id }, { $set: { completed: true } }, ); return res.status(200).send('OK');} catch (error) { // Deliberately a 200: a 5xx makes Stripe retry a // request that will fail the same way. The row // above records the failure so it can be replayed. await StripeWebhookEvent.updateOne( { eventId: event.id }, { $set: { completed: false, error: error.message } }, ); return res.status(200).send('Recorded with error');}Seven jobs, one run each
Campaign emails, daily nudges, store syncs and the morning AI pipeline all run on a schedule. When I arrived, that schedule had never been installed on the server at all, and the app is built to run as several processes, where a plain timer fires once in each and emails every member several times.
So every job now claims its slot in the database before it runs. The first claim wins and the rest skip, and if the database can’t answer, the job skips rather than risk sending twice.
helpers/runWithLease.js
export const acquireRunLease = async (name, bucket) => { try { await CronLock.create({ key: `${name}:${bucket}` }); return true; } catch (err) { // another worker won this window if (err?.code === 11000) return false; throw err; }};export const runWithLease = async (name, bucket, fn) => { let owns = false; try { owns = await acquireRunLease(name, bucket); } catch (err) { // Fail closed: skip this window rather than // risk a double send. The job fires again on // its next schedule. return; } if (!owns) return; // another worker has it await fn();};The model only chooses from a list
Every morning, new articles from six publishers, among them The Atlantic, Wired and MIT Technology Review, go to the members they suit. Gemini reads each one and picks who it is for from the platform’s own list of growth attributes, up to three of each kind. Anything it makes up is thrown away.
If the model answers with nonsense, answers with nothing or isn’t there at all, a plain keyword tagger does the job instead. Articles it has already seen are skipped before a single model call is paid for.
scripts/automated-editorial-pipeline.js
function normalizeAndValidateClassification( parsed, allowedCurrent, allowedImagine,) { const cur = Array.isArray(parsed?.currentSelf) ? parsed.currentSelf : []; const img = Array.isArray(parsed?.imagineSelf) ? parsed.imagineSelf : []; const currentSelf = uniq( cur.map(normalizeAttributeTitle) .filter(a => allowedCurrent.has(a)), ).slice(0, 3); const imagineSelf = uniq( img.map(normalizeAttributeTitle) .filter(a => allowedImagine.has(a)), ).slice(0, 3); return { currentSelf, imagineSelf, via: 'gemini' };}// In classifyArticle, on any model error:if (looksLikeGeminiQuotaError(err)) { // Rate limited: off for the rest of this run. geminiDisabledForRun = true;}return keywordFallbackTagger({ title, description, currentSelfAttrs, imagineSelfAttrs,});Voice rooms on an SFU
Members meet in live audio rooms and on stages, where five people speak and everyone else listens and raises a hand. The rooms were a peer-to-peer mesh, where every speaker sends their audio to every listener, which falls apart past a handful of people.
I rebuilt them on an SFU (mediasoup): each speaker sends once and the server forwards, the ring lights up on whoever is talking, and a crashed media worker restarts on its own instead of taking the app down. Then I audited the whole realtime layer, chat and notifications included, and shipped the critical fixes.
A signup that said yes for over a week
Every new member is meant to leave signup with a growth path of their own. For over a week none did, and signup kept saying yes: a change had broken the path builder, and the way it was called swallowed the error before anyone could see it.
The fix was one line. The real work was making sure it can’t happen quietly again: signup now checks the path exists, flags the account if it doesn’t, and tells the team.
try {
await createPersonlisedPath(user, fakeRes)
} catch (e) { /* never fires */ }
Next. Refunds and dispute handling, and a load test that puts a number on how many listeners one voice box carries.
