Jump to content

Implementing In-Game Currency Purchase in HTML5 Games


yudengdeng
 Share

Recommended Posts

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

  1. Use the provider’s test/sandbox mode - never test with real payments

  2. Simulate webhook delay - add 5-10 second delay in test to verify polling works

  3. Test failure cases - what if webhook never arrives? Add manual “check order” button

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

 

Link to comment
Share on other sites

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.

Guest
Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

Loading...
 Share

  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...