yudengdeng Posted April 28 Share Posted April 28 Recently integrated a payment system for an HTML5 game, thought I’d document the process in case it helps others. The Problem Client wanted players to buy in-game currency directly without leaving the game. The game is a mobile web app (PWA), so we needed: Seamless checkout flow (no redirects if possible) Server-side verification (no client-side manipulation) Multiple payment methods (players from different regions) Webhook for delivery confirmation Architecture Overview code复制 ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Game UI │ ──── │ Your API │ ──── │ Payment │ │ (client) │ │ (server) │ │ Gateway │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ │ │ │ ▼ ▼ ▼ Player selects Create order, Process payment, package generate signature send webhook Key principle: Never trust the client. All price/amount validation happens server-side. Step 1: Define Your Packages javascript复制 // server/config/packages.js const PACKAGES = { 'gems_small': { amount: 100, price: 0.99, currency: 'USD' }, 'gems_medium': { amount: 500, price: 4.99, currency: 'USD' }, 'gems_large': { amount: 1200, price: 9.99, currency: 'USD' }, }; // Validate on every request - never trust client function getPackage(packageId) { const pkg = PACKAGES[packageId]; if (!pkg) throw new Error('Invalid package'); return { ...pkg }; // return copy, not reference } Step 2: Create Order Endpoint javascript复制 // server/routes/payment.js const crypto = require('crypto'); router.post('/create-order', async (req, res) => { const { packageId, userId } = req.body; // 1. Validate package const pkg = getPackage(packageId); // 2. Generate unique order ID const orderId = crypto.randomUUID(); // 3. Create order with payment provider const order = await paymentProvider.createOrder({ orderId, amount: pkg.price, currency: pkg.currency, customData: { userId, packageId }, // attach for webhook notifyUrl: 'https://yourgame.com/api/webhook/payment', }); // 4. Store pending order in DB await db.orders.insert({ orderId, userId, packageId, status: 'pending', createdAt: Date.now(), }); // 5. Return checkout URL to client res.json({ checkoutUrl: order.checkoutUrl, orderId }); }); Step 3: Handle Webhook This is the critical part. Payment provider will POST to your server when payment completes. javascript复制 router.post('/webhook/payment', async (req, res) => { // 1. Verify signature (prevent spoofing) const signature = req.headers['x-signature']; const expected = computeSignature(req.body, SECRET_KEY); if (signature !== expected) { return res.status(403).json({ error: 'Invalid signature' }); } // 2. Parse webhook data const { orderId, status, customData } = req.body; const { userId, packageId } = customData; // 3. Idempotency check - prevent duplicate delivery const order = await db.orders.findOne({ orderId }); if (order.status === 'completed') { return res.json({ ok: true }); // already processed } // 4. Update order status if (status === 'completed') { await db.orders.update({ orderId }, { status: 'completed' }); // 5. Deliver goods const pkg = getPackage(packageId); await db.users.update( { userId }, { $inc: { gems: pkg.amount } } ); // 6. Notify game client (optional - via WebSocket) ws.notify(userId, { type: 'purchase_complete', amount: pkg.amount }); } res.json({ ok: true }); }); Step 4: Client-Side Integration javascript复制 // client/shop.js class ShopManager { async purchasePackage(packageId) { // Show loading this.showLoading(); try { // 1. Call your API const res = await fetch('/api/create-order', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ packageId, userId: getCurrentUser().id }), }); const { checkoutUrl } = await res.json(); // 2. Open checkout // Option A: New window (desktop) window.open(checkoutUrl, '_blank', 'width=600,height=800'); // Option B: Redirect (mobile) // window.location.href = checkoutUrl; // 3. Poll for completion (optional) this.pollOrderStatus(orderId); } catch (err) { this.showError('Purchase failed. Please try again.'); } } pollOrderStatus(orderId) { const interval = setInterval(async () => { const res = await fetch(`/api/order-status/${orderId}`); const { status } = await res.json(); if (status === 'completed') { clearInterval(interval); this.showSuccess(); this.refreshBalance(); } }, 2000); } } Security Checklist ✅ Server-side price validation - never accept price from client ✅ Signature verification - verify webhook is from provider, not attacker ✅ Idempotency - same webhook twice = one delivery ✅ Order expiration - pending orders should expire after X minutes ✅ Rate limiting - prevent spam create-order calls ✅ HTTPS only - no HTTP for payment endpoints Payment Providers I’ve Used Provider Pros Cons Stripe Best docs, great test mode US/EU focused Xsolla Gaming-focused, many methods Complex setup Kardz Good for game top-up, simple API Niche use case PayPal Universal recognition High fees, poor API For this project I used the client’s existing platform (Kardz - they run a game top-up service). API was straightforward, webhook reliable. If you’re building from scratch, Stripe’s test mode is excellent for development. Testing Tips Use the provider’s test/sandbox mode - never test with real payments Simulate webhook delay - add 5-10 second delay in test to verify polling works Test failure cases - what if webhook never arrives? Add manual “check order” button Load test - use k6 or Artillery to simulate concurrent purchases Common Mistakes ❌ Trusting client-side price javascript复制 // NEVER do this const amount = req.body.amount; // client could manipulate this! ❌ No idempotency check javascript复制 // Webhook fires twice = player gets double currency await deliverGoods(); // without checking order status first ❌ Blocking UI during checkout javascript复制 // Player can't close payment window await showPaymentModal(); // use non-blocking approach sarahcollins 1 Quote Link to comment Share on other sites More sharing options...
kevingh Posted July 29 Share Posted July 29 (edited) This is a really solid write-up, thanks for taking the time. I went through almost the exact same pain last year when we added currency purchases to a PWA - the webhook verification and idempotency checks saved us twice in the first week alone when the provider re-sent the same confirmation. One thing I'd add from our experience: we implemented a small admin panel with a manual "force deliver" button for those rare cases where webhooks get lost in the void (happened maybe 3 times out of 2000 orders), and it gave us peace of mind more than anything else. Also worth mentioning that we initially skipped the order expiration logic and had to clean up a bunch of stale pending records later - 15 minutes worked well for us. On a side note, when we were researching how different games structure their in-game economies, I found that the site here has articles covering how to get coins in games like Palworld, which gave me a few ideas about currency flow and what affects pricing. Anyway, great post, definitely bookmarking this for future reference. Edited July 29 by kevingh Quote Link to comment Share on other sites More sharing options...
Recommended Posts
Join the conversation
You can post now and register later. If you have an account, sign in now to post with your account.
Note: Your post will require moderator approval before it will be visible.