Tools

Integrasi Payment Gateway Midtrans di Next.js: Dari Snap Token sampai Webhook

admin
admin

28 Sep 2026 • 7 min baca

Integrasi Payment Gateway Midtrans di Next.js: Dari Snap Token sampai Webhook

Saya pernah membangun toko digital dengan Next.js yang pembayarannya ditangani Midtrans, dari sandbox sampai transaksi production nyata. Selama proses itu saya mencatat pola integrasi yang benar, dan kebetulan polanya bertahan dari sandbox sampai sekarang. Artikel ini fokus ke satu hal: bagaimana mengintegrasikan payment gateway Midtrans ke aplikasi Next.js, dari mendapatkan token pembayaran sampai menangani webhook dengan aman. Kode di sini pola yang sama dengan yang saya pakai di production, hanya digeneralisasi.

Kenapa Snap dan bukan Core API

Midtrans punya dua jalur integrasi. Core API berarti Anda membangun UI pembayaran sendiri, memegang kartu kredit sendiri, dan mengurus setiap metode pembayaran satu per satu. Snap adalah halaman pembayaran siap pakai: satu popup berisi semua metode, dari kartu kredit, VA bank, e-wallet, QRIS, sampai gerai retail, dan sudah PCI-compliant karena kartu tidak pernah menyentuh server Anda.

Untuk kebanyakan kasus di Indonesia, Snap adalah titik mulai yang benar. Alurnya pendek: backend minta token ke Midtrans, frontend membuka popup dengan token itu, pembayaran terjadi di domain Midtrans, lalu status dikirim balik lewat dua jalur, callback di browser dan webhook di server.

Persiapan keys dan environment

Daftar di dashboard Midtrans, buat project, dan Anda dapat sepasang key per mode. Sandbox pakai prefix SB-Mid, production pakai prefix Mid. Ada dua key: client key yang boleh terekspos ke browser, dan server key yang wajib rahasia. Konvensi penamaan variabel di Next.js seperti ini.

# .env.local (sandbox)
NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION=false
NEXT_PUBLIC_MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxx
MIDTRANS_IS_PRODUCTION=false
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxx

Client key pakai prefix NEXT_PUBLIC karena memang dibaca browser, sementara server key tidak. Ada satu jebakan yang pernah menghabiskan waktu saya satu jam: variabel NEXT_PUBLIC di-inject saat build time, bukan runtime. Ganti key di file env lalu restart server saja tidak cukup, Anda harus build ulang. Kalau popup Snap error 401 padahal key sudah benar di env, cek dulu apakah build Anda masih membake key lama.

Langkah 1: minta snap token dari server

Token dibuat dengan memanggil endpoint Snap API memakai server key sebagai Basic Auth. Di Next.js App Router, logika ini hidup di Route Handler, jangan pernah di component client.

// src/lib/midtrans.ts (server only)
const SNAP_API = process.env.MIDTRANS_IS_PRODUCTION === "true"
  ? "https://app.midtrans.com/snap/v1/transactions"
  : "https://app.sandbox.midtrans.com/snap/v1/transactions";

const SERVER_KEY = process.env.MIDTRANS_SERVER_KEY!;

export async function createSnapToken(params: {
  items: Array<{ id: string; name: string; price: number; quantity: number }>;
  customer: { name: string; email: string; phone?: string };
}) {
  const gross = params.items.reduce((s, i) => s + i.price * i.quantity, 0);

  const body = {
    transaction_details: {
      order_id: `ORDER-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
      gross_amount: gross,
    },
    item_details: params.items.map(i => ({
      id: i.id,
      name: i.name.substring(0, 50),
      price: i.price,
      quantity: i.quantity,
    })),
    customer_details: {
      first_name: params.customer.name,
      email: params.customer.email,
      phone: params.customer.phone || "",
    },
    enabled_payments: ["credit_card", "bca_va", "bni_va", "bri_va",
      "gopay", "shopeepay", "qris", "cstore"],
  };

  const res = await fetch(SNAP_API, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Basic ${Buffer.from(SERVER_KEY + ":").toString("base64")}`,
    },
    body: JSON.stringify(body),
  });

  if (!res.ok) {
    console.error("Midtrans snap error:", await res.text());
    return null;
  }
  const data = await res.json();
  return { token: data.token, redirectUrl: data.redirect_url };
}

Tiga aturan payload yang sering bikin request ditolak. gross_amount harus sama persis dengan jumlah price dikali quantity di item_details, Midtrans memvalidasi ini ketat. Nama item dipotong maksimal 50 karakter. Dan order_id harus unik, mengulang order_id yang sudah pernah dipakai akan error duplikat, jadi kombinasi timestamp sudah cukup aman.

Langkah 2: Route handler checkout

Endpoint checkout menjahit semuanya: terima data form dari client, buat order di sistem Anda, minta token, balikkan token ke client.

// src/app/api/checkout/route.ts
import { NextResponse } from "next/server";
import { createSnapToken } from "@/lib/midtrans";

export async function POST(req: Request) {
  const { productId, price, customer } = await req.json();

  // 1. simpan order di database Anda, status: pending
  const order = await db.order.create({ /* ... */ });

  // 2. minta snap token
  const snap = await createSnapToken({
    items: [{ id: String(productId), name: "Produk Anda",
              price, quantity: 1 }],
    customer,
  });

  if (!snap) {
    return NextResponse.json({ error: "Gagal membuat token" },
      { status: 502 });
  }

  return NextResponse.json({
    order_id: order.id,
    snap_token: snap.token,
  });
}

Langkah 3: buka popup Snap di client

Bagian browser cuma dua langkah: muat snap.js dengan client key, lalu panggil pay dengan token. snap.js dimuat dinamis hanya saat user menekan tombol bayar, jadi tidak menambah beban halaman produk.

// src/components/CheckoutButton.tsx
"use client";

export function CheckoutButton({ snapToken }: { snapToken: string }) {
  const openPay = () => {
    const isProd =
      process.env.NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION === "true";
    const script = document.createElement("script");
    script.src = isProd
      ? "https://app.midtrans.com/snap/snap.js"
      : "https://app.sandbox.midtrans.com/snap/snap.js";
    script.setAttribute("data-client-key",
      process.env.NEXT_PUBLIC_MIDTRANS_CLIENT_KEY || "");
    script.onload = () => {
      window.snap.pay(snapToken, {
        onSuccess: (result) => {
          fetch("/api/checkout/update", {
            method: "PUT",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
              orderId: result.order_id, status: "settlement" }),
          });
          window.location.href = `/success?order=${result.order_id}`;
        },
        onPending: (result) => {
          fetch("/api/checkout/update", {
            method: "PUT",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
              orderId: result.order_id, status: "pending" }),
          });
        },
        onError: () => setError("Pembayaran gagal, coba lagi."),
        onClose: () => {
          // popup ditutup tanpa menyelesaikan pembayaran
          fetch("/api/checkout/update", {
            method: "PUT",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ status: "pending" }),
          });
        },
      });
    };
    document.body.appendChild(script);
  };

  return <button onClick={openPay}>Bayar Sekarang</button>;
}

Perhatikan onClose. Saat user menutup popup tanpa membayar, tidak ada error yang dilempar, popup cuma tertutup. Kalau tidak ditangani, order Anda menggantung tanpa status jelas. Saya selalu menandainya pending di callback ini supaya halaman riwayat order tetap bisa menampilkan tombol bayar lagi.

Langkah 4: webhook sebagai sumber kebenaran

Ini bagian yang paling sering disalahpahami pemula. Callback onSuccess di browser hanya untuk UX, bukan bukti pembayaran. User bisa menutup tab di tengah proses, ekstensi bisa memblokir request, jaringan bisa putus setelah bayar sukses tapi sebelum callback jalan. Sumber kebenaran status pembayaran adalah webhook: Midtrans POST JSON ke endpoint server Anda setiap kali status transaksi berubah.

// src/app/api/midtrans/webhook/route.ts
import { createHash } from "crypto";

export async function POST(req: Request) {
  const notif = await req.json();
  const SERVER_KEY = process.env.MIDTRANS_SERVER_KEY!;

  // 1. verifikasi signature wajib
  const signature = createHash("sha512")
    .update(notif.order_id + notif.status_code +
            notif.gross_amount + SERVER_KEY)
    .digest("hex");

  if (signature !== notif.signature_key) {
    return new Response("Invalid signature", { status: 401 });
  }

  // 2. petakan status Midtrans ke status internal
  const map: Record<string, string> = {
    capture: "paid", settlement: "paid",
    pending: "pending", deny: "failed",
    cancel: "cancelled", expire: "expired",
  };
  const status = map[notif.transaction_status] || "pending";

  // 3. update order, idempotent
  await db.order.update({
    where: { midtransOrderId: notif.order_id },
    data: { status },
  });

  return new Response("OK");
}

Signature dihitung sha512 dari gabungan order_id, status_code, gross_amount, dan server key. Tanpa verifikasi ini, siapa pun bisa POST palsu ke endpoint Anda dan menandai order sendiri lunas. Webhook juga wajib idempotent karena Midtrans mengirim notifikasi berulang sampai Anda balas 200, jadi update status yang sama dua kali tidak boleh menimbulkan efek ganda seperti mengirim produk dua kali.

Alur lengkap dalam satu gambar

Alur integrasi Midtrans Snap di Next.js dari checkout form, route handler, snap API, popup pembayaran, sampai callback browser dan webhook server
Alur lengkap integrasi Midtrans Snap

Ringkasannya: client submit form ke Route Handler Anda, Route Handler membuat order pending dan meminta token dari Midtrans memakai server key, client membuka popup Snap memakai token, user membayar di halaman Midtrans, lalu dua jalur balik bekerja bersamaan. Callback browser menyalakan halaman sukses, webhook server mengubah status order menjadi lunas. Dua jalur itu tidak saling menunggu.

Testing di sandbox yang benar

Mode sandbox punya kartu uji publik, misalnya 5108 0000 0000 0009 dengan CVV 123 dan expiry bebas di masa depan, plus VA dan e-wallet simulasi di dashboard. Dua hal yang wajib Anda uji selain happy path: bayar lalu tutup tab sebelum redirect, pastikan webhook tetap datang dan order berubah lunas, dan uji popup ditutup di tengah, pastikan order jadi pending dan bisa dibayar ulang. Kalau toko Anda headless WordPress seperti yang pernah saya tulis di artikel Panduan Lengkap WordPress Headless dengan Next.js, webhook-nya bisa ditaruh sebagai mu-plugin di sisi WP dan Route Handler tetap untuk token.

Rangkuman urutan integrasi

Siapkan pasangan key per mode dan ingat NEXT_PUBLIC dibake saat build. Buat fungsi minta token di server dengan Basic Auth dari server key. Jahit lewat Route Handler yang juga membuat order pending. Muat snap.js dinamis di client dan tangani semua callback termasuk onClose. Terakhir, webhook dengan verifikasi signature sebagai satu-satunya sumber kebenaran status. Urutannya seperti itu karena tiap langkah bergantung pada keluaran langkah sebelumnya, dan sekali lima langkah itu jalan di sandbox, pindah ke production biasanya cuma urusan ganti key dan aktifkan mode production di dashboard.

Artikel Terkait

(3)

Komentar

(0)

Komentar Anda akan dimoderasi.

Memuat komentar...