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

  • 3 months later...

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