← All Posts
Integrations

Vue.js & Nuxt.js Crypto Payments: Paychainly Integration Guide

May 21, 2026· 8 min read

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/qrcode

Step 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 timingSafeEqual before writing to your database or sending confirmation emails.
  • Deduplicate webhook deliveries. Paychainly guarantees at-least-once delivery. Store each processed txHash in 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() calls clearInterval. Calling it from onUnmounted prevents 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/credit endpoint 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.

← Back to Blog
Vue.jsNuxt.jsUSDT paymentscrypto checkoutBEP-20composableswebhook verification