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 asal Indonesia, dan Snap adalah checkout siap-pakainya: satu popup yang mencakup Virtual Account transfer bank (BCA, BNI, BRI, Permata, dan lainnya) serta Mandiri Bill Payment, 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.
Tiga kunci dan dua environment
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 them
NEXT_PUBLIC_MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxxxxxxxxxx
NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION=falseSandbox dan production adalah environment terpisah dengan Server Key dan Client Key masing-masing: pilih environment lewat dropdown di kiri atas dashboard, lalu ambil pasangan kuncinya di Settings, Access Keys. Contoh sandbox di dokumentasi Midtrans memakai kunci berawalan SB-Mid-, tetapi patokannya tetap dashboard, bukan awalan kunci. Host snap.js dan API juga berbeda (app.sandbox.midtrans.com dan app.midtrans.com), jadi environment yang tidak cocok adalah hal pertama yang harus dicek saat popup gagal tanpa pesan.
Langkah 1 — Buat Snap token di sisi server
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 bertipe integer dalam Rupiah utuh, dan Midtrans juga meminta harga item ditulis tanpa desimal. Jika Anda mengirim item_details, total harga dikali jumlah untuk semua item harus sama persis dengan gross_amount. Di notifikasi nilainya kembali sebagai string seperti 10000.00, dan string itulah yang di-hash apa adanya.
Langkah 2 — Buka popup Snap di klien
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_PRODUCTION === "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.
Langkah 3 — Tangani dan verifikasi webhook
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", "failure"].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 respons 2xx dengan cepat dan kerjakan proses berat secara asinkron. Midtrans menunggu paling lama 15 detik, meminta respons dalam sekitar 5 detik, dan mengulang pengiriman yang gagal hingga lima kali selama beberapa jam (lebih sedikit untuk 500, 503, 400, dan 404). Midtrans juga mengingatkan bahwa notifikasi yang sama sesekali bisa datang dua kali, jadi kunci logika update pesanan pada order_id agar settlement yang diulang tidak pernah mengirim pesanan dua kali atau menambah saldo dompet dua kali.
Membaca status transaksi dengan benar
Field yang penting adalah transaction_status, 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. Ada satu pengecualian terdokumentasi: untuk Permata VA, Mandiri Bill Payment, dan Indomaret, settlement sesekali bisa berbalik menjadi deny, biasanya dalam hitungan menit, dan Midtrans meminta transaksi itu dianggap belum dibayar. Kalau notifikasi terlambat atau tidak berurutan, tanyakan ke GET Status API.
| 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 | Ditolak penyedia atau sistem deteksi fraud Midtrans | Tandai pesanan gagal |
| expire | Tidak dibayar dalam batas waktu | Lepas stok, tandai gagal |
| cancel / refund | Dibatalkan atau uang dikembalikan | Batalkan pemenuhan pesanan |
Menguji di sandbox sebelum go-live
- Pakai simulator pembayaran berbasis web milik Midtrans (simulator.sandbox.midtrans.com) untuk membayar Virtual Account atau kode QRIS uji tanpa uang sungguhan, pakai kartu uji yang terdokumentasi untuk skenario diterima, challenge, dan ditolak, lalu atur expiry kustom yang singkat untuk melihat notifikasi expire datang.
- Ekspos webhook lokal Anda dengan tunnel (misalnya ngrok) dan daftarkan URL itu di Settings, Configuration, Payment Notification URL.
- Uji pengecekan tanda tangan dengan mengirim notifikasi palsu ber-signature_key salah dan pastikan route Anda mengembalikan 403.
- Saat pindah ke production, ganti kedua kunci, ubah flag environment, dan daftarkan ulang URL notifikasi production — semuanya tidak terbawa dari sandbox.

