This wasn’t originally planned as a full payment system — it started as a small internal task to help my coworker who was preparing the GB Toolkit.
The goals were simple:
But as I started building the pieces — payment, QRIS, download automation — the mini project naturally grew into a more complete, reliable system.
What began as a favor to support a coworker eventually turned into a solid micro-product powering GB Toolkit distribution.
Here’s what the system does:


qr_string (for QRIS)

Simple, robust, and works well on mobile.
Here’s the simplified server endpoint:
// /api/create-charge
import { NextRequest } from 'next/server';
import { createOrder, updateOrderFields } from '@/lib/db';
import { sendPaymentEmail } from '@/lib/email/payment';
export async function POST(req) {
const { amount, itemName, buyer_email } = await req.json();
const order_id = `AIG-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
await createOrder({
order_id,
amount,
item_name: itemName,
buyer_email,
status: 'pending',
});
const payload = {
payment_type: 'qris',
transaction_details: { order_id, gross_amount: amount },
customer_details: { email: buyer_email },
};
const serverKey = process.env.MIDTRANS_SERVER_KEY;
const auth = Buffer.from(`${serverKey}:`).toString('base64');
const r = await fetch('https://api.sandbox.midtrans.com/v2/charge', {
method: 'POST',
headers: {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await r.json();
await updateOrderFields(order_id, { midtrans_response: data });
if (buyer_email) {
const midtransUrl = data.actions?.[0]?.url || data.payment_url;
await sendPaymentEmail({
to: buyer_email,
orderId: order_id,
midtransUrl,
itemName,
amount,
});
}
return new Response(JSON.stringify({ ok: true, midtrans: data, order_id }));
}This returns the QR string, which is rendered inside the modal.
Midtrans calls your server when payment is completed.
// /api/midtrans-notification
export async function POST(req) {
const body = await req.json();
const order_id = body.order_id;
const status = body.transaction_status;
if (status === 'settlement' || status === 'capture') {
await updateOrderFields(order_id, {
status: 'paid',
paid_at: new Date(),
midtrans_notification: body,
});
}
return new Response('OK');
}The success page polls /api/order-status every few seconds:
useEffect(() => {
if (status === 'paid' && !downloadLink) {
createToken(); // auto-generate download link once
}
}, [status, downloadLink]);Then the token endpoint:
// /api/create-download-token
const ttl = Number(process.env.DOWNLOAD_TOKEN_TTL_MINUTES || 60);
const token = crypto.randomBytes(32).toString('hex');
await insertDownloadToken({
token,
order_id,
email,
ttl_minutes: ttl,
});
return { downloadLink: `${base}/api/download?token=${token}` };Sent right after user clicks Pay.

Formatted HTML email:
Sent only after payment = paid.

Includes:
I used:
This project ended up as a complete, smooth, reliable donation system:
If you want to integrate Midtrans + QRIS + Next.js for digital products, this approach is flexible, scalable, and production-ready.
Categories: