backend
8 Jul 2026
3 MIN READ

What Actually Breaks When You Implement In-App Purchases (iOS + Android, Server-Side)

Server-side IAP verification for a client's app surfaced problems the docs don't mention — Apple and Google using three different price scaling factors, a clock-drift bug that looked like a credentials failure, and a webhook reconciliation gap that's still open rather than solved.


Subscription billing on the web is hard enough. Add in-app purchases for iOS and Android and you're now dealing with two vendors who agree on almost nothing — different APIs, different price formats, different failure modes. Here's what building server-side IAP verification for a client's app actually involved, past what the docs tell you.

Verify on the server, not the client

The obvious shortcut is to trust the receipt the client hands you and mark the purchase done. Don't. Client-side verification is trivially bypassed with jailbreak tooling that spoofs a "purchase successful" payload, and there's no way to distinguish a real receipt from a forged one without calling Apple or Google directly.

The second reason is less obvious: without server-side checks, nothing stops the same purchase token from being redeemed by multiple accounts. Every verification call has to check the transaction ID (iOS) or purchase token (Android) against what's already in your database before accepting it — otherwise one real purchase can unlock the product for as many accounts as someone's willing to share a token with.

And because a purchase usually has side effects — enrolling a user in a course, unlocking content — writing the purchase record and triggering that side effect need to happen in the same transaction. If the process dies between "recorded the purchase" and "granted the entitlement," you get a paying user with nothing to show for it.

Apple and Google don't even agree on what a price is

This one cost real time in a "why do the numbers not match" audit. Apple returns transaction prices in milli-units — divide by 1,000 to get the actual amount. Google returns two different things depending on which endpoint you're hitting: catalog/base-plan prices come back in nanos (divide by a billion), but the subscription validation API returns priceAmountMicros (divide by a million). Three different scaling factors across two vendors, and picking the wrong one silently gives you a number that's off by orders of magnitude rather than throwing an error. Worth writing one conversion function per source and never trusting a raw price field without knowing exactly which endpoint it came from.

The bug that looked like a credentials problem

Apple's server-to-server API started rejecting every request with a generic 401, despite the key ID, issuer ID, and private key all being correct. No amount of double-checking the credentials fixed it, because the credentials weren't the problem.

Apple validates the iat (issued-at) claim in the JWT strictly. If your server's clock is even slightly ahead of Apple's, the token looks like it was issued in the future, and Apple rejects it outright — with an error message that gives you no hint this is what's happening. The fix is to deliberately backdate the token's issued-at time by 60 seconds when signing, which absorbs normal clock drift without weakening anything meaningfully.

Related, and just as opaque: private keys loaded from environment variables often get mangled — literal \n instead of real newlines, stray Windows carriage returns, or an older key format (EC PRIVATE KEY) that modern signing libraries won't accept without conversion to PKCS8 first. None of these produce an error that points at "PEM formatting." They just produce a signing failure that looks like it should be a credentials issue and isn't.

Webhooks are the source of truth — with a caveat I'm not going to pretend isn't there

Same principle as web billing: the webhook (App Store Server Notifications for iOS, Real-Time Developer Notifications for Android) is what updates subscription status, not the client. Cancellations and expirations come through as events, and the backend reacts to those rather than assuming anything based on the original purchase call.

The honest gap: right now there's no background job polling store state to catch a webhook that never arrived, and no check on event ordering. If a cancellation webhook gets delayed and arrives after a renewal already went through, it'll overwrite an active subscription back to cancelled — an out-of-order event problem I know exists and haven't closed yet. I'd rather say that plainly than write a post implying every edge case here is handled, because it isn't, and pretending otherwise is the kind of thing that falls apart the moment someone tests it.

Edge cases that don't get discussed enough

  • Refunds: both platforms tell you when a transaction's been revoked (a revocation date from Apple, a payment/purchase state from Google), and the right move is to reject re-validation of a revoked transaction outright rather than just flagging it.
  • Platform switching: a user who buys on iOS and later switches to Android shouldn't end up with two active subscription records. Enforcing a single subscription per user and letting a new purchase overwrite the provider and token on that same record keeps it from duplicating.
  • Sandbox timing: Apple's sandbox compresses subscription durations dramatically — a monthly plan can expire in minutes. If your code assumes calendar-month durations instead of reading the actual expiry timestamp the sandbox returns, testing renewal logic becomes very confusing very fast.

The actual lesson

Almost none of these problems show up in the official documentation, and almost all of them look like something else when you first hit them — a credentials bug, a data bug, a random flake. The pattern worth taking away isn't specific to IAP: when store integrations fail in ways that seem inconsistent, the cause is usually a mismatch in assumptions (time, units, formats) between your system and theirs, not a bug in your business logic.

Have questions about the implementation? Drop a Message

Mentioned Technologies

#backend#nestjs#mobile#payments

Frequently Asked Questions

Client-side receipts can be spoofed with jailbreak/bypass tooling, and there's no way to block the same purchase token being reused across multiple accounts without a server-side check against your own database first.

Two common causes covered in the post: Apple rejects JWTs if your server's clock is even slightly ahead of theirs (fixed by backdating the iat claim by ~60 seconds), and private keys pulled from environment variables often get mangled (wrong newline characters, legacy key format) in a way that produces a signing failure that looks like a credentials issue.

Not fully — webhooks can be delayed or arrive out of order, and without a polling job or event-ordering check, a late cancellation event can incorrectly overwrite an active subscription. That's flagged in the post as an open gap rather than something already solved.

Need this built right?

I'm a freelance NestJS & Node.js backend developer. Let's ship your MVP or SaaS.

Hire Me

Ready for more?

Explore other insights in the gallery.

Browse All Posts