Integrasi Pembayaran Midtrans Snap ke Aplikasi Next.js

Foto oleh Torsten Dettlaff via Wikimedia Commons (CC BY-SA 4.0)
Ya. Client Key memang dirancang untuk publik dan hanya dipakai snap.js untuk merender popup pembayaran. Server Key adalah yang rahasia — ia mengotorisasi pembuatan token dan menandatangani webhook, jadi harus tetap di sisi server dan tidak boleh diawali NEXT_PUBLIC_.
Callback onSuccess berjalan di browser pengguna dan bisa terlewat kalau mereka menutup tab atau kehilangan koneksi. Untuk Virtual Account dan QRIS, pembayaran bisa selesai berjam-jam kemudian. Konfirmasi yang andal hanya HTTP notification di sisi server, yang tanda tangannya Anda verifikasi sebelum menandai pesanan lunas.
Gabungkan order_id, status_code, gross_amount, dan Server Key jadi satu string, lalu hitung hash SHA512-nya dalam bentuk hex. Bandingkan dengan signature_key pada body notifikasi. Kalau tidak cocok, tolak permintaan dengan 403 karena berarti palsu atau rusak.
Pending berarti instruksi pembayaran (nomor Virtual Account atau kode QRIS) sudah dibuat tetapi pelanggan belum membayar. Settlement berarti bank atau penyedia sudah mengonfirmasi dananya. Penuhi pesanan hanya saat settlement, atau saat capture dengan fraud_status accept untuk pembayaran kartu.
Sandbox membuat token di app.sandbox.midtrans.com/snap/v1/transactions dan memuat app.sandbox.midtrans.com/snap/snap.js. Production memakai app.midtrans.com untuk keduanya. Kedua environment punya kunci dan dashboard terpisah, dan token sandbox tidak akan terbuka di popup production.

Foto oleh Torsten Dettlaff via Wikimedia Commons (CC BY-SA 4.0)
Ringkasan Utama
Midtrans Snap butuh tiga bagian utama: route server yang menukar Server Key jadi Snap token, popup snap.js di sisi klien yang terbuka dengan token itu, dan webhook yang memverifikasi setiap pembayaran dengan menghitung ulang SHA512 dari order_id, status_code, gross_amount, dan Server Key Anda. Jangan pernah percaya redirect browser saja.
Midtrans adalah payment gateway yang paling sering dipakai aplikasi di Indonesia, dan Snap adalah checkout siap-pakainya: satu popup yang mencakup Virtual Account (BCA, BNI, Mandiri, Permata), QRIS, e-wallet besar (GoPay, OVO, DANA, ShopeePay), dan kartu. Di sebuah proyek untuk klien Indonesia, saya menyambungkan Snap ke build Next.js 15 App Router, dan alurnya lebih rapi daripada kesan dokumentasi lamanya, asalkan Anda pegang satu aturan: uang baru dianggap masuk kalau webhook yang bilang.
Panduan ini membahas seluruh perjalanan bolak-balik: membuat transaction token dari route server, membuka popup Snap di klien, menerima HTTP notification, memverifikasi tanda tangannya, dan membaca status settlement dengan benar. Contohnya memakai App Router dan fetch biasa sehingga bisa Anda pasang di codebase Next.js mana pun tanpa SDK resmi kalau lebih suka begitu.
Midtrans memberi Anda Server Key dan Client Key, masing-masing tersedia dalam pasangan sandbox dan pasangan production. Server Key wajib hanya ada di server; itu rahasia yang sekaligus mengotorisasi pembuatan token dan menandatangani notifikasi webhook. Client Key aman diekspos di browser karena hanya dipakai snap.js untuk merender popup. Salah membagi dua kunci ini adalah kesalahan keamanan paling umum yang saya lihat di tutorial Midtrans berbahasa Indonesia.
# .env.local
# Server-only — never prefix with NEXT_PUBLIC_
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxxxxxxxxxxxxx
MIDTRANS_IS_PRODUCTION=false
# Safe in the browser — snap.js needs it
NEXT_PUBLIC_MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxxxxxxxxxxKunci sandbox berawalan SB-Mid- dan kunci production hanya Mid-. Kedua environment punya dashboard, transaksi, dan URL snap.js yang benar-benar terpisah. Token yang dibuat di sandbox tidak akan pernah terbuka di popup production, jadi environment yang tidak cocok adalah hal pertama yang harus dicek saat popup gagal tanpa pesan.
Tugas backend adalah mem-POST detail pesanan ke endpoint transactions Snap memakai HTTP Basic Auth, dengan username berupa Server Key dan password kosong. Midtrans mengembalikan token yang dipakai klien untuk membuka popup. Buat sendiri order_id-nya dan pastikan unik serta idempoten per pesanan, karena nanti Anda mencocokkannya di webhook.
// app/api/payment/route.ts
import { NextRequest, NextResponse } from "next/server";
const IS_PROD = process.env.MIDTRANS_IS_PRODUCTION === "true";
const SNAP_URL = IS_PROD
? "https://app.midtrans.com/snap/v1/transactions"
: "https://app.sandbox.midtrans.com/snap/v1/transactions";
export async function POST(req: NextRequest) {
const { orderId, grossAmount, customer } = await req.json();
// Basic auth: base64(serverKey + ":") — empty password
const auth = Buffer.from(`${process.env.MIDTRANS_SERVER_KEY}:`).toString("base64");
const res = await fetch(SNAP_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Basic ${auth}`,
},
body: JSON.stringify({
transaction_details: {
order_id: orderId, // must be unique per order
gross_amount: grossAmount, // integer IDR, no decimals
},
customer_details: {
first_name: customer.name,
email: customer.email,
phone: customer.phone,
},
}),
});
if (!res.ok) {
return NextResponse.json({ error: "Failed to create transaction" }, { status: 502 });
}
const data = await res.json(); // { token, redirect_url }
return NextResponse.json({ token: data.token });
}gross_amount dalam Rupiah utuh dan harus berupa bilangan bulat — Midtrans menolak desimal karena Rupiah tidak punya sen. Pastikan juga nilai yang dikirim sama dengan total item_details jika Anda menyertakannya, kalau tidak permintaan gagal validasi.
Di sisi klien Anda memuat snap.js sekali dengan Client Key, lalu memanggil window.snap.pay(token) dengan token yang dikembalikan route Anda. Di Next.js cara rapi menyisipkan skripnya adalah lewat next/script dengan URL sandbox atau production yang tepat. Popup-nya menangani semua metode pembayaran yang Anda aktifkan di akun Midtrans, jadi Anda tidak perlu membangun UI kartu atau Virtual Account sendiri.
"use client";
import Script from "next/script";
import { useState } from "react";
const SNAP_JS = process.env.NEXT_PUBLIC_MIDTRANS_IS_PROD === "true"
? "https://app.midtrans.com/snap/snap.js"
: "https://app.sandbox.midtrans.com/snap/snap.js";
export function CheckoutButton({ orderId, amount, customer }: any) {
const [loading, setLoading] = useState(false);
async function pay() {
setLoading(true);
const res = await fetch("/api/payment", {
method: "POST",
body: JSON.stringify({ orderId, grossAmount: amount, customer }),
});
const { token } = await res.json();
setLoading(false);
// @ts-expect-error snap is injected by snap.js
window.snap.pay(token, {
onSuccess: (r) => console.log("paid", r),
onPending: (r) => console.log("pending — VA/QRIS awaiting payment", r),
onError: (r) => console.log("error", r),
onClose: () => console.log("popup closed without finishing"),
});
}
return (
<>
<Script src={SNAP_JS} data-client-key={process.env.NEXT_PUBLIC_MIDTRANS_CLIENT_KEY} strategy="afterInteractive" />
<button onClick={pay} disabled={loading}>Bayar Sekarang</button>
</>
);
}Callback onSuccess berjalan di browser dan bagus untuk UX, tetapi itu bukan bukti pembayaran. Pengguna bisa menutup tab, kehilangan sinyal, atau Virtual Account baru dibayar berjam-jam kemudian. Pakai callback ini hanya untuk memperbarui tampilan. Sumber kebenaran selalu webhook di sisi server pada Langkah 3.
Midtrans mengirim HTTP notification ke URL yang Anda daftarkan di dashboard setiap kali transaksi berubah status. Ini satu-satunya sinyal andal bahwa uang benar-benar bergerak. Setiap notifikasi membawa signature_key, dan Anda wajib menghitungnya ulang: SHA512 dari gabungan order_id, status_code, gross_amount, dan Server Key. Kalau hash Anda tidak cocok, buang permintaannya — itu palsu atau rusak. Baru setelah tanda tangan valid, baca statusnya.
// app/api/payment/notification/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createHash } from "crypto";
export async function POST(req: NextRequest) {
const body = await req.json();
const { order_id, status_code, gross_amount, signature_key,
transaction_status, fraud_status } = body;
// Recompute: SHA512(order_id + status_code + gross_amount + ServerKey)
const expected = createHash("sha512")
.update(order_id + status_code + gross_amount + process.env.MIDTRANS_SERVER_KEY)
.digest("hex");
if (expected !== signature_key) {
return NextResponse.json({ error: "Invalid signature" }, { status: 403 });
}
// Signature valid — now decide the real outcome
const paid =
(transaction_status === "settlement") ||
(transaction_status === "capture" && fraud_status === "accept");
if (paid) {
// idempotent: mark order paid only if not already
// await markOrderPaid(order_id);
} else if (["deny", "cancel", "expire"].includes(transaction_status)) {
// await markOrderFailed(order_id);
} // "pending" — VA/QRIS created, wait for the next notification
return NextResponse.json({ ok: true }); // return 200 fast
}Kembalikan HTTP 200 dengan cepat dan kerjakan proses berat secara asinkron. Midtrans mengulang notifikasi yang tidak mendapat 200, jadi handler yang lambat memicu pengiriman ganda. Buat logika update pesanan idempoten — kunci pada order_id agar notifikasi settlement yang diulang tidak pernah mengirim pesanan dua kali atau menambah saldo dompet dua kali.
Field yang penting adalah transaction_status, kadang dipasangkan dengan fraud_status untuk pembayaran kartu. Bug umum adalah menganggap pending sebagai sukses — untuk Virtual Account atau QRIS, pending hanya berarti instruksi pembayaran sudah dibuat dan pelanggan belum membayar. Hanya settlement (atau capture dengan fraud status accept untuk kartu) yang berarti dana sudah terkonfirmasi.
| transaction_status | Artinya | Tindakan |
|---|---|---|
| pending | VA/QRIS dibuat, pelanggan belum bayar | Tunggu notifikasi berikutnya |
| settlement | Dana dikonfirmasi bank/penyedia | Penuhi pesanan |
| capture | Kartu terotorisasi; cek fraud_status | Penuhi hanya jika fraud_status accept |
| deny | Pembayaran ditolak penyedia | Tandai pesanan gagal |
| expire | Tidak dibayar dalam batas waktu | Lepas stok, tandai gagal |
| cancel / refund | Dibatalkan atau uang dikembalikan | Batalkan pemenuhan pesanan |