DEV Community

Neil Yan
Neil Yan

Posted on

Integrating Crypto Payments into Your Web Application: A Step-by-Step Guide

Integrating Crypto Payments into Your Web Application: A Step-by-Step Guide

Adding cryptocurrency payment support to a web application has historically meant either integrating with a third-party gateway (and paying their fees) or building blockchain transaction handling from scratch (a massive engineering effort). There's a middle path that's increasingly viable: self-hosted payment gateways with clean REST APIs.

In this tutorial, I'll walk through integrating crypto payments into a Vue.js e-commerce application backed by a self-hosted payment gateway. The same patterns apply to React, Angular, or any other framework — the API layer is framework-agnostic.

Prerequisites

  • A running self-hosted payment gateway instance (I'll show the Docker setup briefly)
  • Node.js 18+ for the demo backend
  • Basic familiarity with Vue.js or any frontend framework
  • curl or Postman for testing API calls

Step 1: Deploy the Gateway (Quick Setup)

I'm going to use Docker Compose because it's the fastest way to get a production-grade gateway running. Create a docker-compose.yml:

version: '3.8'

services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: payment_gateway
      MYSQL_USER: gateway
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - mysql_data:/var/lib/mysql
    restart: always

  redis:
    image: redis:7-alpine
    command: redis-server --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    restart: always

  gateway:
    image: ghcr.io/xpaylabs/gateway:latest
    ports:
      - "8080:8080"
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/payment_gateway
      SPRING_DATASOURCE_USERNAME: gateway
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
      SPRING_REDIS_HOST: redis
      SPRING_REDIS_PASSWORD: ${REDIS_PASSWORD}
      XPAY_MASTER_SEED: ${XPAY_MASTER_SEED}
      XPAY_WEBHOOK_SECRET: ${WEBHOOK_SECRET}
      XPAY_API_KEY: ${API_KEY}
      XPAY_CHAINS: tron,eth,bsc,polygon
    depends_on:
      - mysql
      - redis
    restart: always

volumes:
  mysql_data:
  redis_data:
Enter fullscreen mode Exit fullscreen mode

Create a .env file:

DB_ROOT_PASSWORD=changeme_root_password
DB_PASSWORD=changeme_db_password
REDIS_PASSWORD=changeme_redis_password
XPAY_MASTER_SEED="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
XPAY_WEBHOOK_SECRET=whsec_your_webhook_secret
API_KEY=xpay_api_key_for_internal_use
Enter fullscreen mode Exit fullscreen mode

Security warning: The seed phrase above is the BIP-39 test vector. NEVER use it on mainnet. Generate a real seed offline for production use.

Then deploy:

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

After about 30 seconds, the gateway should be available at http://localhost:8080.

Step 2: Understand the API Model

The gateway follows a Stripe-inspired API design. The core resource is an invoice:

interface Invoice {
  id: string;              // Unique invoice ID
  amount: string;          // Amount in fiat (e.g., "100.00")
  currency: string;        // Fiat currency code (e.g., "USD")
  chain: string;           // Blockchain (e.g., "tron", "eth", "bsc")
  settlementAsset: string; // Stablecoin (e.g., "USDT", "USDC")
  depositAddress: string;  // Address where funds should be sent
  status: InvoiceStatus;   // "pending" | "confirmed" | "failed" | "expired"
  checkoutUrl: string;     // Hosted checkout page URL
  metadata: Record<string, string>; // Your custom data
  createdAt: string;
  expiresAt: string;
}
Enter fullscreen mode Exit fullscreen mode

The lifecycle is straightforward:

  1. Your backend creates an invoice via the API
  2. The gateway generates a unique deposit address and returns a checkout URL
  3. Your customer pays by sending crypto to that address
  4. The gateway detects the transaction and updates the invoice status
  5. A webhook is fired to your backend on status changes

Step 3: Backend Integration (Node.js/Express)

Let's build a simple Express backend that creates invoices and handles webhooks.

First, install dependencies:

npm install express @xpaylabs/node-sdk dotenv
Enter fullscreen mode Exit fullscreen mode

Create the server:

import express from 'express';
import crypto from 'crypto';
import { XPayClient } from '@xpaylabs/node-sdk';

const app = express();
app.use(express.json());

const xpay = new XPayClient({
  apiKey: process.env.XPAY_API_KEY,
  gatewayUrl: process.env.GATEWAY_URL || 'http://localhost:8080'
});

// Create an invoice for an order
app.post('/api/create-payment', async (req, res) => {
  try {
    const { orderId, amount, currency, chain } = req.body;

    const invoice = await xpay.createInvoice({
      amount: amount.toString(),
      currency: currency || 'USD',
      chain: chain || 'tron',
      settlementAsset: 'USDT',
      description: `Order #${orderId}`,
      metadata: { orderId },
      successUrl: `${process.env.APP_URL}/order/success/${orderId}`,
      cancelUrl: `${process.env.APP_URL}/order/${orderId}`,
      notifyUrl: `${process.env.APP_URL}/api/webhooks/xpay`
    });

    res.json({
      invoiceId: invoice.id,
      checkoutUrl: invoice.checkoutUrl,
      amount: invoice.amount,
      depositAddress: invoice.depositAddress
    });
  } catch (error) {
    console.error('Failed to create invoice:', error);
    res.status(500).json({ error: 'Payment creation failed' });
  }
});

// Webhook handler for payment status updates
app.post('/api/webhooks/xpay', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const expectedSig = crypto
    .createHmac('sha256', process.env.XPAY_WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');

  if (signature !== expectedSig) {
    console.warn('Invalid webhook signature detected');
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body.toString());

  switch (event.type) {
    case 'invoice.confirmed': {
      const { orderId } = event.data.metadata;
      console.log(`Order ${orderId} payment confirmed`);
      // Release the order, send download links, update database
      break;
    }
    case 'invoice.failed': {
      console.log(`Invoice ${event.data.id} payment failed`);
      break;
    }
    case 'invoice.expired': {
      console.log(`Invoice ${event.data.id} expired unpaid`);
      break;
    }
  }

  res.status(200).send('OK');
});

app.listen(3001, () => {
  console.log('Backend running on port 3001');
});
Enter fullscreen mode Exit fullscreen mode

Key points about this implementation:

  • The webhook endpoint uses express.raw() because we need the raw body for HMAC verification. If you use express.json(), the body is consumed before we can verify the signature.
  • Webhook verification is mandatory in production. Without it, anyone who discovers your endpoint URL could forge payment confirmations.
  • The gateway implements exponential backoff for webhook delivery, so even if your handler is down temporarily, you won't miss events.

Step 4: Frontend Integration (Vue 3)

Now let's build the customer-facing payment experience. I'll show a Vue 3 component, but the logic is identical in React or any other framework.

<template>
  <div class="payment-page">
    <div v-if="loading" class="loading">
      Creating payment invoice...
    </div>

    <div v-else-if="invoice" class="payment-details">
      <div class="payment-header">
        <h2>Complete Your Payment</h2>
        <div class="amount-display">
          <span class="fiat-amount">{{ invoice.amount }} {{ invoice.currency }}</span>
          <span class="crypto-amount">{{ displayAmount }} USDT on {{ displayChain }}</span>
        </div>
      </div>

      <div class="qr-section">
        <img :src="qrCodeUrl" alt="Payment QR Code" />
        <p class="instruction">Scan with your wallet app to pay</p>
      </div>

      <div class="address-section">
        <label>Send to this address:</label>
        <div class="address-row">
          <code>{{ invoice.depositAddress }}</code>
          <button @click="copyAddress" class="btn-copy">
            {{ copied ? 'Copied!' : 'Copy' }}
          </button>
        </div>
      </div>

      <div class="status-section">
        <div v-if="status === 'pending'" class="status-pending">
          <div class="spinner"></div>
          <span>Waiting for payment...</span>
        </div>
        <div v-else-if="status === 'confirmed'" class="status-confirmed">
          ✓ Payment Confirmed!
        </div>
        <div v-else-if="status === 'expired'" class="status-expired">
          ✗ Invoice Expired
          <button @click="createNewInvoice" class="btn-retry">Try Again</button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const props = defineProps({
  orderId: String,
  amount: Number,
  chain: { type: String, default: 'tron' }
});

const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:3001';

const loading = ref(true);
const invoice = ref(null);
const status = ref('pending');
const copied = ref(false);
const qrCodeUrl = ref('');
const displayAmount = ref('');
const displayChain = ref('');

let pollingInterval = null;

const chainDisplayNames = {
  tron: 'TRON (TRC20)',
  eth: 'Ethereum (ERC20)',
  bsc: 'BNB Chain (BEP20)',
  polygon: 'Polygon'
};

async function createInvoice() {
  loading.value = true;
  try {
    const res = await fetch(`${API_BASE}/api/create-payment`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        orderId: props.orderId,
        amount: props.amount,
        chain: props.chain
      })
    });
    const data = await res.json();

    invoice.value = data;
    displayAmount.value = data.amount;
    displayChain.value = chainDisplayNames[props.chain] || props.chain;
    qrCodeUrl.value = `${API_BASE}/qr?address=${data.depositAddress}&amount=${data.amount}`;
    loading.value = false;

    startPolling(data.invoiceId);
  } catch (err) {
    console.error('Failed to create invoice:', err);
    loading.value = false;
  }
}

async function checkStatus(invoiceId) {
  try {
    const res = await fetch(`${API_BASE}/api/invoice/${invoiceId}`);
    const data = await res.json();
    status.value = data.status;

    if (data.status === 'confirmed' || data.status === 'expired') {
      stopPolling();
    }
  } catch (err) {
    console.error('Status check failed:', err);
  }
}

function startPolling(invoiceId) {
  pollingInterval = setInterval(() => checkStatus(invoiceId), 2000);
}

function stopPolling() {
  if (pollingInterval) {
    clearInterval(pollingInterval);
    pollingInterval = null;
  }
}

async function copyAddress() {
  await navigator.clipboard.writeText(invoice.value.depositAddress);
  copied.value = true;
  setTimeout(() => { copied.value = false; }, 2000);
}

onMounted(() => createInvoice());
onUnmounted(() => stopPolling());
</script>
Enter fullscreen mode Exit fullscreen mode

The polling interval of 2 seconds is aggressive but appropriate — TRON transactions are detectable in 1-6 seconds. For Ethereum-based chains, you might increase this to 5-10 seconds. Some gateways also support WebSocket-based status updates, which is more efficient for higher volumes.

Step 5: The Checkout Flow

Putting it all together, here's what the complete payment flow looks like:

Customer clicks "Pay with Crypto"
        │
        ▼
Your backend creates an invoice
        │
        ▼
Customer sees checkout page with:
  - Amount in USDT/USDC
  - Deposit address
  - QR code
        │
        ▼
Customer sends crypto from their wallet
        │
        ▼
Gateway detects transaction (1-60s depending on chain)
        │
        ▼
Gateway fires webhook → your backend
        │
        ▼
Your backend releases the order
        │
        ▼
Checkout page shows "Payment Confirmed"
        │
        ▼
Redirect to success page
Enter fullscreen mode Exit fullscreen mode

The entire flow is synchronous from the customer's perspective — they scan the QR code, confirm the transaction in their wallet, and within seconds see the confirmation on screen.

Step 6: Multi-Currency Pricing with Oracle Support

A common requirement is showing prices in fiat but settling in stablecoins. The gateway handles the conversion internally, but for transparency you might want to show the exchange rate:

// Estimate the USDT equivalent
async function getEstimatedAmount(fiatAmount, fiatCurrency, chain) {
  const response = await fetch(
    `${GATEWAY_URL}/api/v1/estimate?amount=${fiatAmount}&from=${fiatCurrency}&chain=${chain}&asset=USDT`
  );
  const data = await response.json();
  return data.estimatedAmount; // e.g., "100.50" USDT
}
Enter fullscreen mode Exit fullscreen mode

The gateway uses aggregated price feeds to determine the conversion rate, applying a small buffer to cover price volatility during the transaction window.

Step 7: Handling Expired Invoices

Invoices have a configurable expiry time (default is 30 minutes). If the customer doesn't pay within that window, the invoice expires. Your webhook handler receives an invoice.expired event.

The deposit address can be reused for a new invoice, but each invoice should generate a fresh address for privacy and tracking purposes. HD wallet derivation makes this trivial — the next unused address index is always deterministic.

Production Considerations

Rate Limiting

When the gateway polls for transactions, it's making RPC calls to blockchain nodes. Rate limit your invoice creation endpoints to prevent abuse. A malicious actor could otherwise cause rapid address consumption:

import rateLimit from 'express-rate-limit';

const invoiceLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 10,              // 10 invoices per minute per IP
  message: { error: 'Too many payment requests. Please try again.' }
});

app.post('/api/create-payment', invoiceLimiter, async (req, res) => {
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Webhook Idempotency

Network issues can cause duplicate webhook deliveries. Make your webhook handler idempotent:

// Track processed webhook IDs to avoid double-processing
const processedEvents = new Set();

app.post('/api/webhooks/xpay', express.raw({ type: 'application/json' }), (req, res) => {
  const event = JSON.parse(req.body.toString());

  if (processedEvents.has(event.id)) {
    return res.status(200).send('Already processed');
  }
  processedEvents.add(event.id);

  // Process event...
});
Enter fullscreen mode Exit fullscreen mode

For production, use a database or Redis to track processed event IDs rather than an in-memory Set.

Webhook Retry Backoff

The gateway retries failed webhook deliveries on this schedule:

Attempt Delay
1st retry 10 seconds
2nd retry 30 seconds
3rd retry 60 seconds
4th retry 5 minutes
5th retry 30 minutes
6th retry 2 hours
7th retry 6 hours
8th retry 24 hours

After 8 failed attempts, the event is marked as permanently failed and logged for manual review. This means you have a full day to fix a downed webhook handler before events are truly lost.

Testing Your Integration

Most gateways provide a testnet mode. Set the gateway to testnet, and use testnet tokens:

# For TRON testnet (Shasta or Nile)
XPAY_TRON_NETWORK=shasta

# For Ethereum testnet (Sepolia)
XPAY_ETH_NETWORK=sepolia
Enter fullscreen mode Exit fullscreen mode

Testnet faucets provide free tokens. Walk through the full flow:

  1. Create an invoice via the API
  2. Open the checkout URL in a browser
  3. Send testnet tokens to the deposit address
  4. Verify the webhook fires
  5. Confirm the order is released in your system

Conclusion

Integrating crypto payments into a web application doesn't require deep blockchain expertise. A self-hosted gateway with a clean REST API abstracts away the chain-specific complexity — HD wallet derivation, transaction scanning, confirmation handling, and webhook delivery — behind familiar HTTP endpoints.

The integration pattern is the same one you'd use for Stripe or PayPal: create an invoice server-side, handle a webhook to confirm payment. The difference is that your gateway runs on your infrastructure, your keys never leave your control, and there are no per-transaction fees beyond blockchain gas costs.

For the frontend, a polling-based checkout page with QR code display provides a smooth user experience. Add real-time status updates, address copying, and clear error states, and your customers won't notice they're interacting with a self-hosted infrastructure rather than Stripe.

If you're building a Vue, React, or vanilla JS application and want to add crypto payments without the overhead of direct blockchain integration, this pattern gives you the best of both worlds: developer productivity and full financial sovereignty.

Top comments (0)