โ† Semua pembelajaran / Astro Nol โ†’ Portal Berita
Fase 6 ยท Midtrans & Langganan

Webhook: verifikasi tanda tangan atas raw body

Webhook adalah satu-satunya sumber kebenaran tentang pembayaran. Kalau verifikasinya salah, siapa pun bisa mengaktifkan langganan dengan satu perintah curl.

Sumber asli docs.midtrans.com Resmi Rangkuman ~10 menit baca

Intisari

  • Tanda tangannya: SHA512(order_id + status_code + gross_amount + ServerKey).
  • gross_amount harus dipakai persis seperti yang dikirim โ€” "49000.00", bukan 49000.
  • Wajib endpoint .ts, bukan Action: Action sudah mem-parsing body sebelum kamu bisa membacanya mentah.
  • Sukses berarti tiga syarat sekaligus: status_code 200, transaction_status settlement/capture, dan fraud_status accept.
  • Midtrans menunggu maksimal 15 detik dan menyarankan balas dalam 5 โ€” jangan kerjakan hal berat di dalam handler.

Rumus tanda tangannya

signature_key = SHA512(order_id + status_code + gross_amount + ServerKey)

Keempatnya digabung sebagai string tanpa pemisah, lalu di-hash SHA-512. Hasilnya harus sama persis dengan field signature_key di badan notifikasi.

Kesalahan nomor satu: mengonversi gross_amount. Midtrans mengirimnya sebagai string "49000.00". Kalau kamu mem-parsing JSON lalu mengubahnya jadi angka, kamu akan menghitung tanda tangan dari "49000" dan hasilnya tidak akan pernah cocok. Ambil nilai mentahnya apa adanya dari JSON, jangan sentuh formatnya.

Endpoint lengkap

// src/pages/api/midtrans-webhook.ts
import type { APIRoute } from "astro";
import { createHash, timingSafeEqual } from "node:crypto";
import { z } from "zod";
import { env } from "@/lib/env";
import { prosesNotifikasi } from "@/lib/pembayaran";

export const prerender = false;

// Zod secara bawaan mengabaikan field yang tidak dikenal โ€” dan itu
// tepat yang diminta Midtrans: notifikasi bisa bertambah field baru
// kapan saja, dan parser yang ketat akan mulai gagal tanpa peringatan.
const SkemaNotifikasi = z.object({
  order_id: z.string().min(1).max(100),
  status_code: z.string(),
  gross_amount: z.string(),
  signature_key: z.string().length(128),
  transaction_status: z.string(),
  transaction_id: z.string(),
  payment_type: z.string(),
  fraud_status: z.string().optional(),
  settlement_time: z.string().optional(),
});

export const POST: APIRoute = async ({ request }) => {
  // 1. Baca RAW body. Ini yang tidak bisa dilakukan Action.
  const mentah = await request.text();

  let json: unknown;
  try {
    json = JSON.parse(mentah);
  } catch {
    return new Response("bad json", { status: 400 });
  }

  const hasil = SkemaNotifikasi.safeParse(json);
  if (!hasil.success) {
    console.error(JSON.stringify({ level: "error", pesan: "notifikasi tak dikenal", isu: hasil.error.issues }));
    return new Response("bad payload", { status: 400 });
  }

  const n = hasil.data;

  // 2. Verifikasi tanda tangan โ€” SEBELUM apa pun yang lain.
  if (!tandaTanganSah(n)) {
    console.warn(JSON.stringify({
      level: "warn",
      pesan: "tanda tangan webhook tidak sah",
      order_id: n.order_id,
    }));
    return new Response("invalid signature", { status: 403 });
  }

  // 3. Proses โ€” idempoten, dan cepat.
  try {
    await prosesNotifikasi(n);
  } catch (e) {
    console.error(JSON.stringify({ level: "error", order_id: n.order_id, e: String(e) }));
    // 500 supaya Midtrans mengirim ulang.
    return new Response("error", { status: 500 });
  }

  return new Response("ok", { status: 200 });
};

function tandaTanganSah(n: {
  order_id: string; status_code: string; gross_amount: string; signature_key: string;
}): boolean {
  const dihitung = createHash("sha512")
    .update(n.order_id + n.status_code + n.gross_amount + env.MIDTRANS_SERVER_KEY)
    .digest("hex");

  const a = Buffer.from(dihitung, "hex");
  const b = Buffer.from(n.signature_key.toLowerCase(), "hex");

  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}

timingSafeEqual, bukan ===. Perbandingan string biasa berhenti di karakter pertama yang berbeda, sehingga waktu eksekusinya membocorkan berapa banyak karakter awal yang sudah benar. Dengan cukup banyak percobaan, tanda tangan bisa disusun karakter demi karakter. Serangan ini sulit lewat jaringan publik, tapi biayanya menutupnya adalah satu baris โ€” tidak ada alasan tidak.

Kenapa harus endpoint, bukan Action

Kebutuhan webhookActionEndpoint .ts
Baca raw bodyTidak bisa โ€” sudah di-parseBisa, request.text()
Tanpa cek OriginOtomatis dicekTidak dicek
Kendali status HTTPTerbatasPenuh
Bentuk request dari luarDiasumsikan milikmuBebas

Midtrans tidak mengirim header Origin milikmu dan tidak akan pernah. Keamanan webhook datang dari tanda tangan, bukan dari CSRF.

Tiga syarat untuk menyatakan sukses

export function pembayaranBerhasil(n: {
  status_code: string;
  transaction_status: string;
  fraud_status?: string;
}): boolean {
  if (n.status_code !== "200") return false;

  const st = n.transaction_status;
  if (st !== "settlement" && st !== "capture") return false;

  // fraud_status hanya ada untuk kartu. Kalau tidak ada, abaikan.
  if (n.fraud_status !== undefined && n.fraud_status !== "accept") return false;

  return true;
}

capture tanpa memeriksa fraud_status adalah lubang yang mahal. Untuk kartu kredit, capture berarti dananya ditahan โ€” tapi kalau fraud_status bernilai challenge, transaksinya sedang ditinjau dan bisa dibatalkan. Mengaktifkan langganan pada titik itu berarti memberi akses untuk pembayaran yang mungkin tidak pernah selesai.

Peta status transaksi

transaction_statusArtinyaTindakan
captureKartu: dana ditahanAktifkan kalau fraud_status = accept
settlementDana masukAktifkan
pendingMenunggu pembeli membayarCatat saja; jangan aktifkan
denyDitolakTandai gagal
cancelDibatalkanTandai batal
expireKedaluwarsa tanpa dibayarTandai kedaluwarsa
refund / partial_refundDikembalikanCabut akses
chargebackSengketa kartuCabut akses, tinjau manual

Lima syarat yang disebut Midtrans sendiri

SyaratKonsekuensi kalau dilanggar
Balas dalam โ‰ค 5 detik (batas 15 detik)Dianggap gagal, dikirim ulang, beban ganda di kedua sisi
Idempoten, pakai order_id sebagai kunciLangganan diperpanjang dua kali dari satu pembayaran
Verifikasi tanda tanganSiapa pun bisa mengaktifkan langganan gratis
Port standar 80/443Notifikasi tidak pernah sampai
Parsing JSON longgar (izinkan field baru)Berhenti bekerja saat Midtrans menambah field

Syarat pertama menentukan bentuk kodemu: jangan kirim email, jangan panggil API lain, jangan hitung apa pun yang berat di dalam handler. Simpan status, balas 200, lalu kerjakan sisanya di latar atau lewat antrean.

// Di dalam prosesNotifikasi, setelah status tersimpan:
void kirimEmailAktivasi(memberId).catch((e) =>
  console.error(JSON.stringify({ level: "error", pesan: "email gagal", e: String(e) })),
);
// Sengaja tidak di-await. Kegagalan email tidak boleh membuat webhook gagal.

Menguji secara lokal

# Terowongan supaya Midtrans sandbox bisa mencapai laptopmu
cloudflared tunnel --url http://localhost:4321

# Lalu daftarkan URL-nya di dashboard Midtrans sandbox.
# Uji tanpa Midtrans: hitung tanda tangan sendiri
ORDER="LGN-1-123-abc"; KODE="200"; JUMLAH="49000.00"
SIG=$(printf '%s%s%s%s' "$ORDER" "$KODE" "$JUMLAH" "$SERVER_KEY" | openssl dgst -sha512 -hex | awk '{print $2}')

curl -sS -X POST http://localhost:4321/api/midtrans-webhook \
  -H 'Content-Type: application/json' \
  -d "{\"order_id\":\"$ORDER\",\"status_code\":\"$KODE\",\"gross_amount\":\"$JUMLAH\",
       \"signature_key\":\"$SIG\",\"transaction_status\":\"settlement\",
       \"transaction_id\":\"uji-1\",\"payment_type\":\"bank_transfer\"}"

Latihan: bangun endpoint webhook lengkap. Uji empat hal: tanda tangan benar โ†’ 200 dan status berubah; satu karakter tanda tangan diubah โ†’ 403 dan status tidak berubah; gross_amount dikirim sebagai 49000 alih-alih "49000.00" โ†’ 403; dan transaction_status: "capture" dengan fraud_status: "challenge" โ†’ tidak mengaktifkan apa pun.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.