Buying Dana Outside the App Store
Mobile has in-app purchases, but the web and desktop have no store. So I opened another door — payment happens in the browser, and the dana is credited when Polar's webhook reaches the server.
Buying Dana Outside the App Store
Dana is the small currency you spend inside Fecit. On mobile you top it up through App Store and Play in-app purchases. The receipt goes to the server, the server checks it directly with the store, and dana is credited.
But Fecit also runs on the web and desktop. There’s no store there. The desktop dana screen could only show your balance and history; when lumen ran out it would say “top up” with nowhere to send you. A dead end.
So I opened another door for payment — Polar.
The flow: we make the checkout, then wait for a webhook
On desktop, you pick a dana bundle and the client asks our server for a checkout. The server creates a payment session on Polar, tucks whose payment this is (the achiever id) into the session’s metadata, and returns a checkout URL. We open that URL in the browser.
When payment finishes, the thing that tells us isn’t the user’s screen — it’s Polar’s webhook. Polar sends an order.paid event to the server, and the server credits the dana.
The key is that this crediting rides the same path as in-app purchases. Same ledger, same idempotency — the very (platform, transaction_id) unique index that keeps one order from being credited twice, reused with the platform set to polar. Two ways to pay, but only one place dana lands.
Don’t trust what the client says
Checkout or webhook, the amount to credit is never the number the client sent. The product id → dana amount mapping lives only on the server. We look the incoming product id up in that table to decide the amount, and ignore anything that isn’t in it. Same rule we kept for in-app purchases — where money moves, the client’s claim is input, not authority.
The webhook’s own authenticity is settled by signature. Polar signs requests per the Standard Webhooks spec, and we verify that signature with the secret. If it doesn’t match, 401 — and if someone spoofs our endpoint, not a grain of dana moves.
The async gap
There’s a small gap here. When the user finishes paying and returns to the app, the webhook may not have arrived yet. Crediting is an async process — Polar notifying our server — so the dana on the screen you come back to might still be unchanged.
So on return, we make the balance catch up. When dana changes, the server emits a realtime event; in case that’s missed, we briefly poll right after a payment to fill the gap. The moment the webhook lands, the number on screen quietly ticks up.
Two worlds: sandbox and production
Polar’s sandbox is a completely separate world from production. Different account, different products, different tokens. Even the same “Dana 100” has a different product id across the two.
So the product mapping is split by environment, and which products you can buy is served by the server, matched to the current environment. The client doesn’t hard-code ids — so testing in sandbox never accidentally invokes a real product.
One door isn’t enough
To buy the same dana, mobile goes through a store, the web goes through Polar. Two entrances, one destination — same balance, same history.
Even where there’s no store, there has to be a door for payment. The desktop dana screen, once a dead end, now leads to another one.