Fondasi

TutorialNext.js 16 · Midtrans Snap · Supabase

Integrasi Midtrans Snap di Next.js

Dari checkout sampai webhook. Terima QRIS, e-wallet, dan virtual account, lalu aktifkan akses pembeli hanya dari notifikasi Midtrans yang sudah diverifikasi.

Diperbarui 29 Oktober 2026 · 14 menit baca

Daftar isi
  1. Yang akan kamu bangun
  2. Alur pembayaran
  3. 1Siapkan akun sandbox dan environment variable
  4. 2Buat transaksi di server
  5. 3Tampilkan Snap di browser
  6. 4Verifikasi notifikasi dan baca statusnya
  7. 5Terima webhook dengan aman
  8. 6Uji di sandbox
  9. Sebelum pindah ke production
  10. Penutup

Yang akan kamu bangun

Di akhir tutorial ini, aplikasi Next.js-mu bisa menerima pembayaran QRIS, GoPay, ShopeePay, dan virtual account lewat Midtrans Snap, lalu mengaktifkan akses pembeli secara otomatis.

Tiga kesalahan yang sering terjadi, dan akan kita hindari:

  • Nominal dikirim dari browser, sehingga pembeli bisa mengubahnya.
  • Akses diberikan dari callback onSuccess di browser, padahal callback itu bisa dipicu siapa saja.
  • Notifikasi yang datang dua kali memberi akses dua kali, atau notifikasi lama menimpa status yang lebih baru.

Prasyarat: Next.js dengan App Router (kode diuji di Next.js 16 dan React 19), proyek Supabase untuk tabel orders, dan akun sandbox Midtrans.

Alur pembayaran

Browser hanya membuka popup pembayaran. Keputusan lunas atau belum selalu diambil server, dari notifikasi Midtrans yang sudah diverifikasi.

alur pembayaran9 langkah
  1. 1Browser ke Server (Next.js)POST /api/checkout
  2. 2Server (Next.js) ke Databasesimpan order pending
  3. 3Server (Next.js) ke Midtransbuat transaksi Snap
  4. 4Midtrans ke Server (Next.js)token
  5. 5Server (Next.js) ke Browsertoken
  6. 6Browser ke Midtranspembeli bayar di Snap
  7. 7Midtrans ke Server (Next.js)notifikasi + signature
  8. 8Server (Next.js) ke Midtranscek Status API
  9. 9Server (Next.js) ke Databasepending → paid, sekali

Langkah 7 sampai 9 adalah satu-satunya jalan menuju status lunas. Callback popup di browser hanya mengubah tampilan.

Langkah 1: Siapkan akun sandbox dan environment variable

  1. Daftar di dashboard Midtrans, lalu pindah ke mode Sandbox.
  2. Salin Server Key dan Client Key dari Settings → Access Keys. Tergantung akunnya, key sandbox diawali SB-Mid- atau langsung Mid- seperti key production, jadi pastikan dashboard masih di mode Sandbox saat menyalinnya.
  3. Buat tabel orders di Supabase lewat SQL Editor:
sql
create table public.orders (
  id           text primary key,          -- order_id yang dikirim ke Midtrans
  email        text not null,
  plan         text not null,
  amount       integer not null check (amount > 0),
  status       text not null default 'pending'
               check (status in ('pending', 'paid', 'failed', 'refunded')),
  payment_type text,
  paid_at      timestamptz,
  created_at   timestamptz not null default now()
);

alter table public.orders enable row level security;
-- Sengaja tanpa policy: hanya server (dengan secret key) yang membaca dan menulis tabel ini.
  1. Isi .env.local:
bash
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxxxxx
NEXT_PUBLIC_MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxx
NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION=false
NEXT_PUBLIC_SUPABASE_URL=https://xxxxxxxx.supabase.co
SUPABASE_SECRET_KEY=sb_secret_xxxxxxxx

Server Key Midtrans dan secret key Supabase tidak boleh diawali NEXT_PUBLIC_, karena variabel dengan awalan itu ikut terkirim ke browser. Client Key aman di browser.

  1. Buat klien Supabase khusus server di lib/supabase-admin.ts. Paket server-only membuat build gagal kalau file ini tidak sengaja diimpor dari komponen browser.
ts
import "server-only";
import { createClient, type SupabaseClient } from "@supabase/supabase-js";

let client: SupabaseClient | null = null;

// Pakai secret key (sb_secret_...) atau service_role key. Hanya di server.
export function supabaseAdmin(): SupabaseClient {
  if (!client) {
    const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
    const key = process.env.SUPABASE_SECRET_KEY;
    if (!url || !key) throw new Error("NEXT_PUBLIC_SUPABASE_URL atau SUPABASE_SECRET_KEY belum diisi");
    client = createClient(url, key, { auth: { persistSession: false } });
  }
  return client;
}

Langkah 2: Buat transaksi di server

Browser hanya mengirim ID paket. Nominal selalu diambil dari daftar harga di server.

lib/plans.ts:

ts
// Harga hanya ditentukan di server. Browser cukup mengirim ID paket.
export const PLANS = {
  pro: { name: "Paket Pro", amount: 299000 },
} as const;

export type PlanId = keyof typeof PLANS;

export function isPlanId(value: unknown): value is PlanId {
  return typeof value === "string" && Object.hasOwn(PLANS, value);
}

Object.hasOwn dipakai, bukan value in PLANS, supaya nilai seperti "toString" tidak lolos sebagai paket.

Bagian pertama lib/midtrans.ts membuat transaksi Snap. Autentikasinya Basic Auth dengan Server Key sebagai username dan password kosong.

ts
import "server-only";
import crypto from "node:crypto";

const isProduction = process.env.NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION === "true";
const SNAP_URL = isProduction
  ? "https://app.midtrans.com/snap/v1/transactions"
  : "https://app.sandbox.midtrans.com/snap/v1/transactions";
const API_BASE = isProduction ? "https://api.midtrans.com" : "https://api.sandbox.midtrans.com";

function serverKey(): string {
  const key = process.env.MIDTRANS_SERVER_KEY;
  if (!key) throw new Error("MIDTRANS_SERVER_KEY belum diisi");
  return key;
}

// Server Key sebagai username, password kosong.
function authHeader(): string {
  return "Basic " + Buffer.from(serverKey() + ":").toString("base64");
}

export async function createSnapTransaction(input: {
  orderId: string;
  amount: number;
  itemId: string;
  itemName: string;
  email: string;
}): Promise<{ token: string; redirect_url: string }> {
  const res = await fetch(SNAP_URL, {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: authHeader(),
    },
    body: JSON.stringify({
      transaction_details: { order_id: input.orderId, gross_amount: input.amount },
      item_details: [{ id: input.itemId, price: input.amount, quantity: 1, name: input.itemName }],
      customer_details: { email: input.email },
      enabled_payments: ["other_qris", "gopay", "shopeepay", "bca_va", "bni_va", "bri_va", "permata_va", "echannel"],
      expiry: { unit: "minutes", duration: 60 },
    }),
    cache: "no-store",
  });
  if (res.status !== 201) {
    throw new Error(`Snap gagal (${res.status}): ${await res.text()}`);
  }
  return res.json();
}

Beberapa aturan dari Midtrans yang perlu diingat:

  • order_id harus unik, maksimal 50 karakter, berisi huruf, angka, -, _, ~, atau ..
  • gross_amount berupa bilangan bulat dan harus sama dengan jumlah price × quantity di item_details.
  • enabled_payments membatasi metode yang tampil. Kode lain yang tersedia antara lain ovo, dana, cimb_va, alfamart, dan indomaret. Kalau dihapus, semua metode yang aktif di akunmu akan tampil.

lib/orders.ts menyimpan dan membaca order. Fungsi transition dipakai di Langkah 5.

ts
import "server-only";
import { supabaseAdmin } from "./supabase-admin";

export type OrderStatus = "pending" | "paid" | "failed" | "refunded";
export type Order = { id: string; email: string; plan: string; amount: number; status: OrderStatus };

export async function createOrder(order: Omit<Order, "status">): Promise<void> {
  const { error } = await supabaseAdmin().from("orders").insert({ ...order, status: "pending" });
  if (error) throw error;
}

export async function getOrder(id: string): Promise<Order | null> {
  const { data, error } = await supabaseAdmin()
    .from("orders")
    .select("id, email, plan, amount, status")
    .eq("id", id)
    .maybeSingle();
  if (error) throw error;
  return data;
}

// Ubah status hanya dari status yang diizinkan. Satu UPDATE atomik:
// kalau dua notifikasi datang bersamaan, hanya satu yang mendapat true.
export async function transition(
  id: string,
  to: OrderStatus,
  from: OrderStatus[],
  extra: Record<string, unknown> = {},
): Promise<boolean> {
  const { data, error } = await supabaseAdmin()
    .from("orders")
    .update({ status: to, ...extra })
    .eq("id", id)
    .in("status", from)
    .select("id");
  if (error) throw error;
  return (data?.length ?? 0) > 0;
}

Terakhir, route app/api/checkout/route.ts:

ts
import crypto from "node:crypto";
import { NextResponse } from "next/server";
import { PLANS, isPlanId } from "@/lib/plans";
import { createOrder } from "@/lib/orders";
import { createSnapTransaction } from "@/lib/midtrans";

export async function POST(req: Request) {
  const body = (await req.json().catch(() => null)) as { plan?: unknown; email?: unknown } | null;
  const planId = body?.plan;
  // Di aplikasi nyata, ambil email dari sesi login, bukan dari body.
  const email = typeof body?.email === "string" ? body.email.trim() : "";

  if (!isPlanId(planId) || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
    return NextResponse.json({ error: "Paket atau email tidak valid" }, { status: 400 });
  }

  const plan = PLANS[planId]; // nominal dari server, bukan dari browser
  const orderId = `ORD-${Date.now()}-${crypto.randomBytes(3).toString("hex")}`;

  await createOrder({ id: orderId, email, plan: planId, amount: plan.amount });

  try {
    const snap = await createSnapTransaction({
      orderId,
      amount: plan.amount,
      itemId: planId,
      itemName: plan.name,
      email,
    });
    return NextResponse.json({ token: snap.token, orderId });
  } catch (err) {
    console.error(err);
    return NextResponse.json({ error: "Pembayaran belum bisa dimulai" }, { status: 502 });
  }
}

Order disimpan dengan status pending sebelum memanggil Snap, sehingga setiap notifikasi Midtrans nanti selalu punya pasangan di database.

Langkah 3: Tampilkan Snap di browser

Komponen ini memuat snap.js, meminta token ke server, lalu membuka popup pembayaran. Simpan sebagai app/beli/pay-button.tsx:

tsx
"use client";

import Script from "next/script";
import { useState } from "react";

type SnapCallbacks = {
  onSuccess?: (result: unknown) => void;
  onPending?: (result: unknown) => void;
  onError?: (result: unknown) => void;
  onClose?: () => void;
};

declare global {
  interface Window {
    snap?: { pay: (token: string, callbacks?: SnapCallbacks) => void };
  }
}

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 PayButton({ email }: { email: string }) {
  const [state, setState] = useState<"idle" | "loading" | "waiting" | "error">("idle");

  async function pay() {
    setState("loading");
    const res = await fetch("/api/checkout", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ plan: "pro", email }),
    });
    if (!res.ok || !window.snap) {
      setState("error");
      return;
    }
    const { token } = (await res.json()) as { token: string };

    // Callback ini hanya untuk tampilan. Akses diberikan oleh webhook, bukan di sini.
    window.snap.pay(token, {
      onSuccess: () => setState("waiting"),
      onPending: () => setState("waiting"),
      onError: () => setState("error"),
      onClose: () => setState("idle"),
    });
  }

  return (
    <>
      <Script
        src={SNAP_JS}
        data-client-key={process.env.NEXT_PUBLIC_MIDTRANS_CLIENT_KEY}
        strategy="afterInteractive"
      />
      <button onClick={pay} disabled={state === "loading"}>
        {state === "loading" ? "Menyiapkan pembayaran..." : "Bayar Rp299.000"}
      </button>
      {state === "waiting" && <p>Pembayaran sedang dikonfirmasi. Akses aktif otomatis dalam beberapa detik.</p>}
      {state === "error" && <p>Pembayaran belum bisa dimulai. Coba lagi sebentar lagi.</p>}
    </>
  );
}

Pakai di halaman mana saja, misalnya app/beli/page.tsx:

tsx
import { PayButton } from "./pay-button";

export default function BeliPage() {
  // Contoh saja: di aplikasi nyata, email diambil dari sesi login.
  return (
    <main>
      <h1>Paket Pro</h1>
      <PayButton email="pembeli@contoh.com" />
    </main>
  );
}

onSuccess hanya berarti popup melaporkan sukses. Siapa pun bisa memanggil fungsi itu dari console browser, jadi jangan pernah memberi akses dari sini. Halaman cukup menampilkan "sedang dikonfirmasi", lalu membaca status order dari server.

Langkah 4: Verifikasi notifikasi dan baca statusnya

Setiap notifikasi Midtrans membawa signature_key, yaitu SHA512 dari order_id + status_code + gross_amount + ServerKey. Hanya pihak yang tahu Server Key-mu yang bisa membuatnya.

Perhatikan dua hal. Pertama, gross_amount di notifikasi berupa string seperti "299000.00"; pakai persis seperti yang diterima, jangan diubah jadi angka. Kedua, bandingkan dengan timingSafeEqual, bukan ===, supaya waktu perbandingan tidak membocorkan isi signature.

Tambahkan ke lib/midtrans.ts:

ts
export type MidtransStatus = {
  order_id: string;
  status_code: string;
  transaction_status: string;
  fraud_status?: string;
  gross_amount: string;
  payment_type?: string;
};

export async function getTransactionStatus(orderId: string): Promise<MidtransStatus> {
  const res = await fetch(`${API_BASE}/v2/${encodeURIComponent(orderId)}/status`, {
    headers: { Accept: "application/json", Authorization: authHeader() },
    cache: "no-store",
  });
  if (!res.ok) throw new Error(`Status API gagal (${res.status})`);
  return res.json();
}

// SHA512(order_id + status_code + gross_amount + ServerKey), dibandingkan dengan signature_key.
export function isValidSignature(n: Record<string, unknown>): boolean {
  const { order_id, status_code, gross_amount, signature_key } = n;
  if (
    typeof order_id !== "string" ||
    typeof status_code !== "string" ||
    typeof gross_amount !== "string" ||
    typeof signature_key !== "string"
  ) {
    return false;
  }
  const expected = crypto
    .createHash("sha512")
    .update(order_id + status_code + gross_amount + serverKey())
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature_key);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

export type Outcome = "paid" | "pending" | "failed" | "refunded";

export function interpretStatus(s: MidtransStatus): Outcome {
  if (s.fraud_status === "deny") return "failed";
  switch (s.transaction_status) {
    case "settlement":
    case "capture":
      // Lunas hanya kalau status_code 200 dan fraud_status accept (atau tidak ada).
      return s.status_code === "200" && (!s.fraud_status || s.fraud_status === "accept") ? "paid" : "pending";
    case "deny":
    case "cancel":
    case "expire":
    case "failure":
      return "failed";
    case "refund":
    case "partial_refund":
    case "chargeback":
    case "partial_chargeback":
      return "refunded";
    default:
      return "pending";
  }
}

Arti setiap transaction_status dan hasilnya di aplikasi:

transaction_status Arti Hasil di aplikasi
settlement Dana diterima paid
capture Kartu berhasil ditagih, fraud_status accept paid
pending Menunggu pembeli membayar pending
deny, cancel, expire, failure Ditolak, dibatalkan, lewat batas waktu, atau gangguan di bank failed
refund, partial_refund, chargeback Dana dikembalikan ke pembeli refunded

Status yang tidak dikenal dianggap pending, sehingga tidak ada yang lunas karena kebetulan.

Langkah 5: Terima webhook dengan aman

Midtrans bisa mengirim notifikasi yang sama lebih dari sekali, dan urutannya bisa terbalik (misalnya settlement datang sebelum pending). Route webhook di bawah menangani keduanya dengan empat pengaman:

  1. Signature diverifikasi, notifikasi palsu dibalas 403.
  2. Status API dijadikan sumber kebenaran, bukan isi notifikasi. Notifikasi lama tidak bisa menimpa status terbaru.
  3. Nominal yang dibayar dicocokkan dengan nominal order.
  4. Status hanya maju: pending → paid lewat satu UPDATE atomik, jadi hanya satu notifikasi yang "menang".

app/api/webhooks/midtrans/route.ts:

ts
import { NextResponse } from "next/server";
import { getTransactionStatus, interpretStatus, isValidSignature } from "@/lib/midtrans";
import { getOrder, transition } from "@/lib/orders";
import { grantAccess, revokeAccess, sendReceipt } from "@/lib/access";

export async function POST(req: Request) {
  const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;

  // 1. Pastikan notifikasi benar-benar dari Midtrans.
  if (!body || !isValidSignature(body)) {
    return NextResponse.json({ error: "Signature tidak valid" }, { status: 403 });
  }

  const orderId = body.order_id as string;
  const order = await getOrder(orderId);
  if (!order) {
    // Bukan order kita. Balas 200 supaya Midtrans tidak mengulang.
    return NextResponse.json({ ok: true });
  }

  // 2. Status terkini dari Status API, bukan dari isi notifikasi
  //    (notifikasi bisa datang dobel atau tidak berurutan).
  const status = await getTransactionStatus(orderId);
  const outcome = interpretStatus(status);

  if (outcome === "paid") {
    // 3. Nominal yang dibayar harus sama dengan nominal order.
    if (Number(status.gross_amount) !== order.amount) {
      console.error(`Nominal tidak cocok untuk ${orderId}: ${status.gross_amount}`);
      return NextResponse.json({ ok: true });
    }
    // 4. Status hanya maju: pending -> paid, sekali saja.
    const firstTime = await transition(orderId, "paid", ["pending"], {
      paid_at: new Date().toISOString(),
      payment_type: status.payment_type ?? null,
    });
    await grantAccess(order); // idempoten, aman diulang
    if (firstTime) await sendReceipt(order); // hanya sekali
  } else if (outcome === "failed") {
    const changed = await transition(orderId, "failed", ["pending"]);
    if (!changed && order.status === "paid") {
      console.warn(`${orderId} sudah lunas tapi kini ${status.transaction_status}. Cek di dashboard Midtrans.`);
    }
  } else if (outcome === "refunded") {
    if (await transition(orderId, "refunded", ["paid"])) await revokeAccess(order);
  }

  return NextResponse.json({ ok: true });
}

Perhatikan pembagian di blok paid. grantAccess dipanggil setiap kali, jadi fungsinya harus idempoten: hasilnya sama walau dipanggil berulang. sendReceipt hanya dipanggil saat firstTime, karena email tidak boleh terkirim dua kali. Kalau grantAccess gagal, route melempar error, Next.js membalas 500, dan Midtrans mengirim ulang notifikasinya; pada kiriman ulang itu akses akan diberikan lagi.

Isi lib/access.ts sesuai aplikasimu. Kerangkanya:

ts
import "server-only";
import type { Order } from "./orders";

// Harus aman dipanggil berulang (idempoten). Contoh: set profiles.plan = 'pro'
// dan plan_until = paid_at + 30 hari, dihitung dari paid_at di tabel orders, bukan dari now().
export async function grantAccess(order: Order): Promise<void> {
  console.log(`Akses ${order.plan} aktif untuk ${order.email}`);
}

export async function revokeAccess(order: Order): Promise<void> {
  console.log(`Akses ${order.plan} dicabut untuk ${order.email}`);
}

// Efek yang tidak boleh terjadi dua kali, seperti email kuitansi.
export async function sendReceipt(order: Order): Promise<void> {
  console.log(`Kuitansi dikirim ke ${order.email}`);
}

Respons webhook harus cepat: Midtrans menyarankan di bawah 5 detik. Kalau ada pekerjaan berat, seperti membuat PDF, pindahkan ke antrean dan balas webhook lebih dulu.

Langkah 6: Uji di sandbox

Midtrans hanya mau mengirim notifikasi ke URL HTTPS publik dengan sertifikat valid, jadi localhost perlu dibuka lewat tunnel.

  1. Jalankan aplikasi: npm run dev.
  2. Buka tunnel HTTPS ke port 3000, misalnya ngrok http 3000 atau cloudflared tunnel --url http://localhost:3000.
  3. Di dashboard sandbox Midtrans, buka Settings → Configuration, isi Payment Notification URL dengan https://<alamat-tunnel>/api/webhooks/midtrans, lalu simpan.
  4. Buka /beli, klik Bayar, pilih QRIS. Salin URL gambar QR yang muncul, lalu tempel di QRIS Simulator dan bayar. Untuk virtual account BCA, masukkan nomor VA di simulator BCA.
  5. Dalam beberapa detik, status order di tabel orders berubah menjadi paid dan log server menampilkan "Akses pro aktif".

Lalu uji dua pengaman yang paling penting:

  • Notifikasi palsu. Kirim notifikasi dengan signature asal-asalan. Hasilnya harus 403 dan tabel tidak berubah.
bash
curl -i -X POST https://<alamat-tunnel>/api/webhooks/midtrans \
  -H "Content-Type: application/json" \
  -d '{"order_id":"ORD-1","status_code":"200","gross_amount":"299000.00","signature_key":"palsu","transaction_status":"settlement"}'
  • Notifikasi dobel. Tambahkan sementara console.log(JSON.stringify(body)) di awal webhook, bayar satu order, salin body notifikasi dari log, lalu kirim ulang dengan curl yang sama. Hasilnya 200, status tetap paid, dan "Kuitansi dikirim" tidak muncul lagi di log.

Pembayaran sandbox hanya boleh lewat simulator. Jangan membayar transaksi sandbox dengan uang sungguhan, karena dananya bisa tidak kembali.

Sebelum pindah ke production

Penutup

Intinya ada tiga: nominal ditentukan server, akses diberikan dari webhook yang sudah diverifikasi, dan status order hanya boleh maju. Dengan tiga aturan itu, notifikasi palsu, dobel, atau terlambat tidak bisa memberi akses yang salah.

Kalau kamu tidak mau merakit ini di setiap proyek, saya sedang menyiapkan boilerplate Next.js + Supabase yang sudah berisi semua langkah di atas. Daftar waitlist-nya di fondasi.pages.dev.

Sumber