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:
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
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
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;
}
The lifecycle is straightforward:
- Your backend creates an invoice via the API
- The gateway generates a unique deposit address and returns a checkout URL
- Your customer pays by sending crypto to that address
- The gateway detects the transaction and updates the invoice status
- 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
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');
});
Key points about this implementation:
- The webhook endpoint uses
express.raw()because we need the raw body for HMAC verification. If you useexpress.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>
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
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
}
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) => {
// ...
});
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...
});
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
Testnet faucets provide free tokens. Walk through the full flow:
- Create an invoice via the API
- Open the checkout URL in a browser
- Send testnet tokens to the deposit address
- Verify the webhook fires
- 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)