Integrating Xendit QRIS in a Next.js App Using Webhooks

2 min read

by Sandi Maulana Juhana, Full-stack Developer

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?

Polling

  • Frontend-based, periodic checks
  • Bisa miss timing atau overload API
  • Cocok untuk feedback UI sementara

Webhook

  • Backend-based, real-time push
  • Selalu akurat, event-driven
  • Terbaik untuk logic production

✨ 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.


More insights

Sometimes Allah Helps Not Because We Deserve It, but Because We Can No Longer Live Without Him

3 min read

Allah doesn’t always help us because we are strong or obedient. Sometimes, His help comes precisely when we are at our weakest and feel we cannot live without Him.

Read more

Jangan Punya Anak Kalau Gaji UMR !!!

2 min read

Bukan soal gaji UMR, tapi soal iman: logika cacat di balik ketakutan punya anak.

Read more

Let’s build something meaningful.

Based in

  • South Jakarta
    12140, Jakarta, Indonesia