Integrating Xendit QRIS in a Next.js App Using Webhooks

A complete guide to implementing QRIS payments using Xendit's latest /payment_requests endpoint and webhooks in a modern Next.js App Router project.

Integrating Xendit QRIS in a Next.js App Using Webhooks

In modern digital transactions, QRIS has become the most seamless way to accept payments from any e-wallet or mobile banking app in Indonesia. When building a food delivery web app with Next.js App Router, I needed a clean, reliable, and scalable way to handle QRIS payments — and I chose Xendit’s /payment_requests endpoint with webhook handling as the core strategy.
Here’s how I implemented it step-by-step.

🔧 Step 1: Create Payment Request via QRIS

I built a custom API route in my Next.js project to forward data from the frontend to Xendit’s latest /payment_requests endpoint.

// /api/xendit/create-payment-request/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function POST(req: NextRequest) {
  const body = await req.json()

  const payload = {
    reference_id: `cbk_${Date.now()}`,
    currency: 'IDR',
    amount: body.amount,
    payment_method: {
      type: 'QRIS',
    },
    metadata: {
      order_id: body.orderId,
    },
  }

  const res = await fetch('https://api.xendit.co/payment_requests', {
    method: 'POST',
    headers: {
      Authorization: `Basic ${Buffer.from(process.env.XENDIT_API_KEY + ':').toString('base64')}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(payload),
  })

  const data = await res.json()
  return NextResponse.json(data)
}

The response from Xendit includes:

  • id: payment request ID
  • status: PENDING
  • actions.qr_checkout_string: string for generating the QR
  • expires_at: when the QRIS expires

🖼️ Step 2: Render the QR Code in a Modal

Once the frontend receives the qr_checkout_string, I generate the QR code using react-qr-code:

import QRCode from 'react-qr-code'

export default function InvoicePopup({ qrString }: { qrString: string }) {
  return (
    <div className="flex flex-col items-center p-6">
      <QRCode value={qrString} size={200} />
      <p className="text-sm text-center mt-2 text-gray-500">
        Scan with any QRIS-compatible app to complete your payment.
      </p>
    </div>
  )
}

No redirection, no friction — just native UX.

📬 Step 3: Handle Webhooks from Xendit (Server-Side Truth)

Instead of polling, I implemented a secure webhook receiver at:

POST /api/xendit/webhook

This endpoint listens for Xendit events like payment_request.succeeded and payment_request.expired.

// /api/xendit/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/firebase'

export async function POST(req: NextRequest) {
  const callbackToken = req.headers.get('x-callback-token')
  if (callbackToken !== process.env.XENDIT_CALLBACK_TOKEN) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const event = await req.json()
  const { status, metadata, id } = event

  // Update Firestore order by metadata.order_id
  await db.collection('orders').doc(metadata.order_id).update({
    status: status === 'SUCCEEDED' ? 'paid' : status.toLowerCase(),
    payment_id: id,
    updated_at: Date.now(),
  })

  return NextResponse.json({ success: true })
}

🔐 Always validate the x-callback-token to protect against spoofed webhook requests.

✅ Step 4: Update UI from Firestore

Because I use Firestore for order data, I simply listen to the order status client-side. When the webhook updates the order to paid, the frontend reflects the change in real-time.

useEffect(() => {
  const unsub = onSnapshot(doc(db, 'orders', orderId), (doc) => {
    if (doc.data()?.status === 'paid') {
      router.push('/checkout/success')
    }
  })
  return () => unsub()
}, [orderId])

🧠 Why Webhooks Over Polling?

PollingWebhook
Frontend-based, periodic checksBackend-based, real-time push
Can miss timing or overload APIAlways accurate, event-driven
Good for temporary UI feedbackBest for production-grade logic

✨ Final Thoughts

Integrating QRIS with Xendit in a Next.js App is incredibly powerful when done right. By relying on webhooks for truth and Firestore for reactive UI, I built a smooth, scalable checkout experience that works in real-world conditions — from mobile to desktop.
The result: a fast, native-feeling payment process that just works — without bloated SDKs or external redirections.