Why Vue.js Developers Are Adding USDT Payments in 2025
Vue.js and Nuxt.js power thousands of storefronts, SaaS dashboards, and marketplace platforms worldwide. In 2025, integrating USDT payments on BNB Smart Chain has shifted from a niche experiment to a genuine competitive advantage — zero chargebacks, sub-second block confirmations, and access to a global customer base that holds stablecoins. Paychainly provides a REST API that fits naturally into the Vue ecosystem, whether you are shipping a client-side SPA or a full-stack Nuxt 3 application with file-system server routes.
This guide walks through a complete, production-ready integration: creating payment sessions server-side, displaying deposit addresses as QR codes, polling for confirmation, and verifying incoming webhooks — all in TypeScript with Vue 3 Composition API patterns.
Prerequisites
- A Paychainly account with an API key (generate one from your merchant dashboard)
- Node.js 18+ with npm, yarn, or pnpm
- A Nuxt 3 project, or Vue 3 with a separate Node.js backend for server-side API proxying
- Basic familiarity with Vue 3 Composition API and TypeScript
Step 1: Install the QR Code Package
You will display each deposit address as a scannable QR code so customers with mobile wallets can pay without copying a long hex string. The qrcode package works in both Node.js and the browser and returns a base-64 PNG data URL you can drop straight into an <img> tag.
npm install qrcode
npm install --save-dev @types/qrcodeStep 2: Create a Payment Session via a Nuxt Server Route
Your Paychainly API key must never be exposed to the browser. In Nuxt 3, any file placed under server/api/ becomes a server-only HTTP endpoint automatically — the ideal place to proxy calls to external payment APIs without shipping credentials into the client bundle.
// server/api/payments/create.post.ts
import { defineEventHandler, readBody, createError } from 'h3'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { amount, orderId } = body
if (!amount || isNaN(Number(amount))) {
throw createError({ statusCode: 400, message: 'Invalid amount' })
}
const res = await fetch('https://api.paychainly.com/api/v1/sessions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.PAYCHAINLY_API_KEY!,
},
body: JSON.stringify({ amount: String(amount), orderId }),
})
if (!res.ok) {
const err = await res.json()
throw createError({ statusCode: 502, message: err.message ?? 'Gateway error' })
}
const { data } = await res.json()
// Return only what the client needs — never forward the raw gateway response
return {
sessionId: data.sessionId,
depositAddress: data.depositAddress,
amount: data.amount,
expiresAt: data.expiresAt,
}
})Add PAYCHAINLY_API_KEY=your_key_here to your .env file. Nuxt reads it at runtime and it is never bundled into client-side JavaScript.
Step 3: The usePayment Composable
Centralising all payment state in a single composable keeps your components thin and makes the checkout flow reusable across a checkout page, an invoice modal, and a subscription prompt — without duplicating API call or polling logic.
// composables/usePayment.ts
import { ref, computed } from 'vue'
type PaymentStatus = 'idle' | 'pending' | 'confirmed' | 'expired' | 'error'
interface PaymentSession {
sessionId: string
depositAddress: string
amount: string
expiresAt: string
}
export function usePayment() {
const session = ref<PaymentSession | null>(null)
const status = ref<PaymentStatus>('idle')
const error = ref<string | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
const isActive = computed(() => status.value === 'pending')
async function createSession(amount: string, orderId: string) {
status.value = 'pending'
error.value = null
try {
const data = await $fetch('/api/payments/create', {
method: 'POST',
body: { amount, orderId },
})
session.value = data as PaymentSession
startPolling()
} catch (e: any) {
status.value = 'error'
error.value = e?.data?.message ?? 'Failed to create payment session'
}
}
function startPolling() {
if (pollTimer) clearInterval(pollTimer)
pollTimer = setInterval(async () => {
if (!session.value) return
try {
const { data } = await $fetch<{ data: { status: string } }>(
`/api/payments/status?sessionId=${session.value.sessionId}`
)
if (data.status === 'confirmed') { status.value = 'confirmed'; stopPolling() }
if (data.status === 'expired') { status.value = 'expired'; stopPolling() }
} catch { /* transient network error — keep polling */ }
}, 5000)
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
function reset() {
stopPolling()
session.value = null
status.value = 'idle'
error.value = null
}
return { session, status, error, isActive, createSession, reset }
}Step 4: Checkout Component with Live QR Code and Countdown
The component below renders the deposit address as a QR code and shows a live expiry countdown. A watcher on status emits payment-confirmed so the parent page can redirect to an order success route without letting polling logic bleed into the template layer. The onUnmounted hook calls reset() — skipping this leaks setInterval callbacks across Nuxt route navigations.
<!-- components/CryptoCheckout.vue -->
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import QRCode from 'qrcode'
import { usePayment } from '~/composables/usePayment'
const props = defineProps<{ amount: string; orderId: string }>()
const emit = defineEmits<{ (e: 'payment-confirmed'): void; (e: 'payment-expired'): void }>()
const { session, status, error, createSession, reset } = usePayment()
const qrDataUrl = ref('')
const timeLeft = ref('20:00')
let countdown: ReturnType<typeof setInterval> | null = null
onMounted(() => createSession(props.amount, props.orderId))
onUnmounted(() => { reset(); if (countdown) clearInterval(countdown) })
watch(session, async (s) => {
if (!s) return
qrDataUrl.value = await QRCode.toDataURL(s.depositAddress, { width: 200, margin: 2 })
startCountdown(new Date(s.expiresAt))
})
watch(status, (s) => {
if (s === 'confirmed') emit('payment-confirmed')
if (s === 'expired') emit('payment-expired')
})
function startCountdown(expiresAt: Date) {
if (countdown) clearInterval(countdown)
countdown = setInterval(() => {
const diff = expiresAt.getTime() - Date.now()
if (diff <= 0) { timeLeft.value = '00:00'; return }
const m = Math.floor(diff / 60000)
const s = Math.floor((diff % 60000) / 1000)
timeLeft.value = `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}, 1000)
}
</script>
<template>
<div class="crypto-checkout">
<template v-if="status === 'pending' && session">
<p>Send exactly <strong>{{ session.amount }} USDT</strong> (BEP-20 on BNB Chain)</p>
<img v-if="qrDataUrl" :src="qrDataUrl" alt="Deposit address QR code" width="200" height="200" />
<p><code>{{ session.depositAddress }}</code></p>
<p>Expires in <strong>{{ timeLeft }}</strong></p>
</template>
<p v-else-if="status === 'confirmed'">Payment confirmed — thank you!</p>
<div v-else-if="status === 'expired'">
Session expired.
<button @click="createSession(props.amount, props.orderId)">Try again</button>
</div>
<p v-else-if="error">{{ error }}</p>
</div>
</template>Step 5: Session Status Server Route
Route all status polls through your own Nuxt server route. This ensures the API key never appears in browser network requests and lets you add caching or rate limiting later without touching the composable.
// server/api/payments/status.get.ts
import { defineEventHandler, getQuery, createError } from 'h3'
export default defineEventHandler(async (event) => {
const { sessionId } = getQuery(event)
if (!sessionId || typeof sessionId !== 'string') {
throw createError({ statusCode: 400, message: 'sessionId required' })
}
const res = await fetch(
`https://api.paychainly.com/api/v1/sessions/${sessionId}`,
{ headers: { 'x-api-key': process.env.PAYCHAINLY_API_KEY! } }
)
if (!res.ok) throw createError({ statusCode: 502, message: 'Gateway error' })
return res.json()
})Step 6: Webhook Signature Verification
Polling keeps your checkout UI in sync, but webhooks are how you reliably trigger order fulfillment on the server without the customer keeping the browser tab open. Paychainly signs every webhook request with HMAC-SHA256. Always verify using Node.js's timingSafeEqual — a plain string comparison leaks timing information that an attacker can exploit to forge valid-looking signatures.
// server/api/webhooks/paychainly.post.ts
import { defineEventHandler, readRawBody, getHeader, createError } from 'h3'
import { createHmac, timingSafeEqual } from 'crypto'
function verifySignature(raw: string, sig: string, secret: string): boolean {
const expected = createHmac('sha256', secret).update(raw).digest('hex')
try {
return timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))
} catch {
return false
}
}
export default defineEventHandler(async (event) => {
const rawBody = await readRawBody(event)
const sig = getHeader(event, 'x-paychainly-signature') ?? ''
if (!rawBody || !verifySignature(rawBody, sig, process.env.PAYCHAINLY_WEBHOOK_SECRET!)) {
throw createError({ statusCode: 401, message: 'Invalid signature' })
}
const payload = JSON.parse(rawBody)
if (payload.event === 'deposit_detected') {
const { txHash, toAddress, amount } = payload
// Fulfill order: mark paid in database, send confirmation email, unlock content
console.log(`Received ${amount} USDT to ${toAddress} — tx ${txHash}`)
}
return { received: true }
})Your webhook secret is a separate credential from your API key. Generate a strong random string (32+ characters), store it as PAYCHAINLY_WEBHOOK_SECRET in .env, and register the webhook endpoint URL along with the secret inside your Paychainly dashboard settings.
Production Checklist
- Keep the API key server-only. It must never appear in Nuxt public runtime config or any client bundle. All Paychainly HTTP calls must go through
server/api/routes. - Verify every webhook signature with
timingSafeEqualbefore writing to your database or sending confirmation emails. - Deduplicate webhook deliveries. Paychainly guarantees at-least-once delivery. Store each processed
txHashin your database and silently skip any duplicate events. - Show the expiry countdown. Payment sessions close after 20 minutes. A visible timer prevents customers from abandoning checkout when a session silently lapses.
- Clear intervals on unmount. The composable's
reset()callsclearInterval. Calling it fromonUnmountedprevents memory leaks across client-side Nuxt navigations. - Test in sandbox mode first. Switch to a sandbox API key, target BSC testnet, and use the
/api/sandbox/creditendpoint to simulate incoming USDT transfers without spending real funds.
Wrapping Up
Vue 3's Composition API and Nuxt 3's server routes make a Paychainly integration clean, type-safe, and straightforward to maintain. The composable pattern centralises all payment state; server routes keep credentials off the client; and the webhook handler gives you a cryptographically verified trigger for order fulfillment that works even when the customer closes the browser tab. With USDT on BNB Smart Chain you get near-instant block confirmations, sub-cent transaction fees, and irreversible settlement — no chargebacks, no currency conversion risk, and no payment processor lock-in. Generate a sandbox API key from your Paychainly dashboard and have your first Vue checkout accepting crypto payments in under an hour.