Inspiration
I live in Zürich and I play football. Finding a pitch was never the problem — finding nine other people who actually turn up was. Every pickup game in this city lives in a WhatsApp group that dies after three weeks, and the ones that survive are closed: the same fifteen guys, no way in. Move to a new city and you simply stop playing.
PLATZ is that group chat without the group chat. Open games near you, sides balanced by skill so the game is worth playing, and a level that follows you instead of a clique.
What it does
Find or host a pickup game on a real pitch. PLATZ balances the two teams by skill rather than by who picks first, splits the pitch rental between the players who actually show up, and rates you afterwards so your PLATZ Level means something. Free to join, anywhere there is a pitch.
How I built it
- App: Expo / React Native (SDK 56), TypeScript end to end.
- API: Fastify + Prisma + PostgreSQL, self-hosted on a VPS behind Caddy. No managed platform, no per-seat SaaS bill.
- Money — two rails, deliberately separated. Splitting a real pitch rental between players runs on Stripe Connect: it is a physical-world service and explicitly outside IAP scope under App Store guideline 3.1.5(a). Digital extras (PLATZ Pro) run on StoreKit, managed by RevenueCat.
- RevenueCat: entitlements and offerings drive the paywall, and the webhook is the only thing that grants paid Pro — it writes one
plusUntildate server-side. Pro earned by inviting a friend and Pro that was paid for are indistinguishable to every feature downstream. - Push: OneSignal, deep-linking straight into the game.
Challenges I ran into
Shipping a test key would have bricked the app. RevenueCat's Test Store key is debug-only. In a release build the SDK puts up "Wrong API Key" and then terminates the process to protect test purchases. I found it on a simulator release build: PLATZ quit to the home screen on launch, before a single game was ever visible. The SDK is now never handed a key the build is not allowed to use.
A lifetime purchase granted nothing. NON_RENEWING_PURCHASE arrives with no expiration_at_ms, and my webhook read "no expiry" as "nothing to do" — taking someone's money and giving back nothing, which is the worst failure a billing endpoint can have.
Restore was hidden exactly when it mattered. The Restore button lived inside the "is there anything to sell" branch, so an empty offering — the state a reinstalling subscriber is most likely to land in — rendered a paywall with no way back at all. That is guideline 3.1.1, and a real person losing what they paid for.
Refunds that cannot be gamed. Stripe's fees stack: 3.25% on a foreign card, +2% for currency conversion, and a cross-currency refund converts at the current rate, so the merchant absorbs the FX movement. I priced against that worst case at every share size, so no refund loop can drain the platform.
What I learned
Paywall correctness is almost entirely about the failure states, not the happy path. Every bug above was invisible on the screen that converts and fatal on the screen someone reaches when something has already gone wrong.
What's next for PLATZ
Pro is honest but thin today — two themes and a badge, and it deliberately buys you nothing on the pitch. Hosts are the segment with real reason to pay: recurring games, a locked-in pitch, no-show protection. That is the next tier.
Built With
- caddy
- docker
- expo.io
- fastify
- ios
- node.js
- onesignal
- pnpm
- postgresql
- prisma
- react-native
- revenuecat
- self-hosted
- storekit
- stripe
- typescript
- zod
Log in or sign up for Devpost to join the conversation.