-
-
Home: "The letter now. The ad later." A tab chip reads "Tab: 1 ad owed · pay later" above the letter picker.
-
Which letter do you need? Leave from work, school absence, power of attorney, sworn statement, job application, resignation.
-
The form, labelled in English and Indonesian: name, position, addressee, reason, leave date. City and date chips on top.
-
Live preview of the formatted Indonesian leave letter built from that form, with a Make PDF button below.
-
The tab sheet: "1 ad owed", ADS 0. Watch 1 ad to clear the tab, get Nanti Pro (no ds, ever), or Later.
-
Design diagram: letter first, one ad owed, AdMob SSV, RevenueCat ADS +1 settles the tab. Or Nanti Pro ends it.
Nanti (Indonesian for "later") makes a correctly formatted Indonesian formal letter and hands you the PDF first. Only then does one rewarded ad go on a visible tab, capped at one, designed to be settled through RevenueCat Ads with AdMob server-side verification, or cleared for good with Pro.
- Google Play: https://play.google.com/store/apps/details?id=dev.edycu.nanti
- Landing page + privacy policy: https://nanti.edycu.dev/
- Source code (MIT): https://github.com/edycutjong/nanti
- Judge page: https://nanti.edycu.dev/judge.html
- Demo video (under 2 min): https://youtu.be/i9_0VmnGx8A
The Google Play listing is in review and may not load yet. The demo video was filmed on an Android 15 emulator, where no rewarded ad could load, so it shows the no-ad path; the Pro purchase in it uses a RevenueCat Test Store build (no money charged).
Entered for the Catvertising Award, the HAMM Award and the RevenueCat Peace Prize.
Inspiration
In Indonesia a lot of ordinary life runs on the surat: a one-page formal letter with a very particular shape. Sender block, city and date, Perihal, Kepada Yth., the body, Hormat saya. You need one to miss a day of work, to keep your child home from school, to let someone collect a document for you, to resign, to apply for a job. You usually need it on your phone, right now, and you usually send it over chat.
The letter apps that exist put a 30-second ad between you and the PDF. The ad is the toll you pay before you get what you came for, at the exact moment you are in a hurry. I wanted the opposite order. You get the value first, and the ad comes afterwards. It is small, visible, capped, and you settle it when it suits you.
What it does
- Pick one of eight letters: leave from work, leave from school, power of attorney (surat kuasa), statement (surat pernyataan), job application, resignation, annual-leave request, general request.
- Fill five fields. City and date are pre-filled in the Indonesian format. A live paper preview redraws the A4 letter on every keystroke.
- Tap Buat PDF. The PDF is printed on the device and the share sheet opens with a properly named file. No ad has appeared.
- Back on Home, an amber pill slides in: "Tab: 1 ad owed · pay later."
- When you start your next letter while the tab is open, a settle sheet offers three exits:
- Watch one rewarded ad. When the ad completes, AdMob's server-side callback is designed to reach RevenueCat, which grants one unit of a virtual currency called
ADS; the app reads the new balance, and the pill turns green and disappears. Not yet observed on a physical device (see G1 below). - Nanti Pro. A RevenueCat Paywall opens. Pro clears the tab for good.
- No ad available? You continue anyway. The letter is delivered and the tab stays at 1, never 2.
- Watch one rewarded ad. When the ad completes, AdMob's server-side callback is designed to reach RevenueCat, which grants one unit of a virtual currency called
The rule the app is held to: you never pay before a letter; every letter is delivered before its own ad; the one ad you owe is settled before the next free letter. The alternatives are never (Pro) or waived (no fill).
The letters are always in Indonesian because that is what the recipient expects. The app itself runs in Indonesian or English (auto-detected, with an override in Settings).
How we built it
Expo 54 / React Native 0.81, TypeScript, no backend of our own. RevenueCat is the only server the product depends on.
The tab is not counted by the app. It is a derived value:
debt = clamp(letters_exported − ADS_balance, 0, 1)
letters_exported is a local SQLite count. ADS is a RevenueCat virtual currency that the app only reads (getVirtualCurrencies()). Settling an ad follows RevenueCat's React Native reward-verification flow:
-
Purchases.generateRewardVerificationToken(impressionId)binds the impression to this customer before the ad is requested. - AdMob
RewardedAd.createForAdRequestis called withserverSideVerificationOptionscarrying the token, so AdMob's SSV callback goes to RevenueCat. - On
EARNED_REWARD,Purchases.pollRewardVerification(clientTransactionId, …)waits for RevenueCat to confirm the server-side grant. - The balance is re-read with
getVirtualCurrencies(). Only this read clears the tab. If verification fails, nothing happens and the tab stays.
The whole ad lifecycle is reported to RevenueCat Ads through Purchases.adTracker (trackAdLoaded, trackAdDisplayed, trackAdOpened, trackAdRevenue with impression-level revenue, trackAdFailedToLoad), keyed by the same impressionId the reward token was minted for. This is built to put every settled ad next to every Pro purchase in the same RevenueCat customer.
Pro is the RevenueCat entitlement pro. It is sold through RevenueCatUI.presentPaywall with the offering from getCurrentOfferingForPlacement('tab_locked'). getCustomerInfo() + addCustomerInfoUpdateListener gate the tab, and restorePurchases() sits in Settings. After every export the app calls setAttributes({ letters_made, writer_tier }) + syncAttributesAndOfferingsIfNeeded(), so a RevenueCat Targeting rule can show heavy writers (3+ letters) a lifetime-first offering.
Letters are eight pure TypeScript functions that return HTML (packages/surat). They are golden-tested byte-for-byte and rendered to PDF with expo-print. The same package ships as a CLI (npx tsx bin/surat.ts demo 1) so anyone can inspect a letter without a phone.
Proof, not promises, all reproducible from a fresh clone without credentials:
- 81 tests pass, including 8/8 golden templates.
npm run ablationgreps the source for any client-side settle increment and must print 0.- The settle state machine is walked over 299,592 event paths.
settledis reached on 504 of them, every one throughverified. - The ledger is checked exhaustively over 70,602 transitions: no client event can lower the debt.
- 100% line coverage is enforced on the pure modules (templates, ledger, settle machine, i18n).
- CI runs in 6 stages: quality → security (CodeQL, gitleaks over full history, TruffleHog) → Metro bundle → Android native build → bench + offline verification → gate.
On-device verification (G1): not yet run on a physical phone. On the emulator no rewarded ad could load, so the app took the no-fill path: the letter is delivered and the tab stays at one (shown in the demo video).
Challenges we ran into
- RevenueCat Ads on React Native is beta and has no AdMob adapter, so the whole settle path is the manual one. We generate the impression id ourselves, attach the RevenueCat token to AdMob's SSV options, poll for the verified result and refresh the balance. The code follows RevenueCat's published React Native sample and is checked against the SDK's typings.
- The native build took four attempts. Expo 53 failed because
react-native-google-mobile-ads16.5 needs RN 0.80+ codegen. On Expo 54,play-services-ads25.x ships Kotlin-2.3 metadata against a 2.1 toolchain. Pinning an older ads SDK broke the library's own source. The fix is one compiler flag, packaged as a unit-tested Expo config plugin so it can't regress. - The round-trip can't be exercised early. AdMob's SSV can't be enabled on Google's sample ad units, so no end-to-end run is possible until a real AdMob unit exists. The app never fakes it: on the sample unit every settle correctly ends in verification failed and the tab stays.
- Restraint. The no-ad-available path had to let the letter through without turning into a loophole. The cap of one solves it: at worst you owe one ad, never two.
- We logged four developer-experience findings from the typings and docs (for example, a token docstring that disagrees with the guide's sample, and mediator names typed as plain
string). They are indocs/DX-REPORT.mdfor the RevenueCat team.
Accomplishments that we're proud of
- An ad users don't hate, in the judges' own words. It comes after the value, it is visibly capped at one, and it pays for a letter you already sent. It never gates the one you are writing.
- A tab the client cannot game. No code path marks an ad as paid locally, and the tests prove it (ablation 0; 299,592 paths; 70,602 transitions).
- Built so ad revenue and subscription revenue land in one RevenueCat customer, keyed by the same impression id.
- Eight letters, golden-tested byte-for-byte. Zero backend of our own.
Pricing and results
Nanti Pro is $2.99 a month with a 7-day free trial, or $9.99 lifetime; letters stay free, with one rewarded ad owed per letter at most. Pre-launch: the app is in Google Play review, so there is no revenue yet.
Who it helps
The people who need a surat fastest — a parent keeping a sick child home, a worker who has to miss a shift, someone sending a relative to collect a document — are often on a cheap phone and short of time and data. Nanti gives them a correct letter first, free and with no account, and never makes them sit through an ad to get it.
What we learned
The order of value and payment is a product decision, not an ad-network setting. Moving the ad from before the PDF to after it changed everything downstream: the cap, the tab, the need for a server-verified ledger, and the reason Pro exists.
We also learned to treat a reward as something the server grants and the client only reads. That one rule made the whole design testable.
What's next
- More letter types by request, and a saved sender profile.
- Running the rewarded-ad round-trip on physical phones and publishing the measured settle latency (p50/p95).
- iOS. The code is plain Expo, but this entry is Google Play only.
Built With
- android
- expo-print
- expo.io
- github
- github-actions
- google-admob
- google-play
- react-native
- react-native-webview
- revenuecat
- revenuecat-paywalls
- sqlite
- typescript
- vitest
Log in or sign up for Devpost to join the conversation.