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...
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.