Apple takes 15%. RevenueCat takes another 1% of tracked revenue on top.
That's one dollar in six, and the last slice goes to a service that forwards JSON between my server and Apple's.
So I built it myself for Nutix over a weekend, both platforms. Claude Code one-shot most of the implementation — and that's the point. The code isn't the hard part anymore. The edge cases are, and they're what took the rest of the weekend.
This post is those edge cases.
For react-native-iap I found this post most useful for me React Native IAP
Should you?
Use RevenueCat if you're pre-revenue, or you don't want payments to be something you're on call for. At low revenue the 1% is rounding error and you get a real dashboard for free.
Build it yourself if the fee is a real line item, or you want payments to be something you understand rather than something you configure.
The usual argument for RevenueCat is cross-platform — one entitlement model across StoreKit and Google Play. I did both myself. It's genuinely more work, but it's a weekend of work, not a quarter.
Everything below is the Apple side, for simplicity.
1. The client decides nothing
StoreKit 2 hands you a JWS. The app forwards it and waits. It never decodes it, never checks expiresDate, never unlocks anything locally.
RNIap.purchaseUpdatedListener(async (purchase) => {
// StoreKit replays old transactions on every launch. Not user intent.
if (!pendingPurchaseRef.current && !pendingRedemptionRef.current) {
await RNIap.finishTransaction({ purchase, isConsumable: false });
return;
}
await subscriptionService.verifyReceipt({
transactionId: purchase.transactionId!,
receiptData: purchase.purchaseToken,
});
// Finish AFTER the server has it. Non-fatal if it throws.
try {
await RNIap.finishTransaction({ purchase, isConsumable: false });
} catch (e) {
logIAPError('finishTransactionFailed', e);
}
});
Three rules:
- Finish after your server records the purchase. Do it before, and a crash between the two leaves a paying user with nothing.
- Guard against replays. Without that check, my success screen fired on app launch for people who bought weeks ago.
-
Don't await
requestPurchase. It doesn't resolve when the user dismisses the sheet. Resolve from the listener pair instead. If you're awaiting it today, you have a hung spinner in production.
2. Ask Apple, don't trust the receipt
Decode the token for originalTransactionId and environment. Then call the App Store Server API for the real state.
Gotchas: your auth JWT can't live longer than 60 minutes, and sandbox is on a different host — which you only know after decoding.
Where mine is wrong: Apple's payloads carry a certificate chain you're meant to verify up to their root CA. I don't. I decode and lean on TLS. For the API path that's defensible. For the webhook path it isn't — anything that can POST to my endpoint gets parsed. Use app-store-server-library-node and skip my mistake.
3. Key on originalTransactionId
It's Apple's stable ID for the whole subscription lifetime — it survives renewals, plan changes, resubscribes. transactionId changes every renewal and is useless as a key.
Your table is a cache. Apple is the truth.
4. Most of the work happens while the app is closed
Renewals at 3am. Billing retries. Cancellations from Settings. Refunds. None of it produces a client call — you need App Store Server Notifications V2.
Two subtypes cost money if you get them wrong:
case 'DID_CHANGE_RENEWAL_STATUS':
// Cancelling a PAID period doesn't end access. Apple sends EXPIRED later.
if (subtype === 'AUTO_RENEW_DISABLED' && subscription.isTrialPeriod) {
subscription.status = 'cancelled';
}
break;
case 'DID_FAIL_TO_RENEW':
// GRACE_PERIOD means keep them premium.
subscription.status = subtype === 'GRACE_PERIOD' ? 'grace_period' : 'billing_retry';
break;
Someone who cancels on day 3 of a paid month keeps premium until day 30. Revoke early and you've stolen 27 days.
Handlers must be idempotent, and return 200 fast — Apple retries on anything else.
5. Signing promotional offers
The one section Apple documents completely and uselessly, and the one place the generated code was confidently wrong. Get it wrong and StoreKit fails with a generic error and no hint which field was bad.
const SEP = '\u2063'; // INVISIBLE SEPARATOR. Write the escape, never the character.
const payload = [
bundleId,
keyId, // In-App Purchase key, NOT the Server API key
productId,
offerIdentifier,
applicationUsername, // '' if unused — the empty slot still counts
crypto.randomUUID(),
String(Date.now()), // MILLISECONDS
].join(SEP);
const signature = crypto
.sign('SHA256', Buffer.from(payload), {
key: crypto.createPrivateKey({ key: pem, format: 'pem' }),
dsaEncoding: 'ieee-p1363', // Node defaults to DER. Apple wants raw r||s.
})
.toString('base64');
All four comments are things that failed silently on me. The separator is the worst — it's zero-width, so it won't survive a copy-paste out of rendered docs. That's why half the posts about this are wrong, and probably why the models trained on them are too.
Introductory offers need none of this. Apple handles eligibility; you just read offerType: 1.
What broke
Sandbox testing is miserable. A month renews in five minutes, accounts get stuck in states you can't clear, and notifications sometimes just don't arrive — so you can't tell a broken handler from a missing event. Budget as much time for testing as for the code.
That's the real shape of this project now. Generating the implementation is an afternoon. Knowing which of the generated lines will quietly cost you money is the weekend.
The 1% was the excuse. Wanting to know what happens between a tap and a row in my database was the reason.
Top comments (0)