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 IDstatus
:PENDING
actions.qr_checkout_string
: string for generating the QRexpires_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 | Webhook |
---|---|
Frontend-based, periodic checks | Backend-based, real-time push |
Can miss timing or overload API | Always accurate, event-driven |
Good for temporary UI feedback | Best 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.