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.
Intisari
- Tanda tangannya:
SHA512(order_id + status_code + gross_amount + ServerKey). gross_amountharus dipakai persis seperti yang dikirim โ"49000.00", bukan49000.- Wajib endpoint
.ts, bukan Action: Action sudah mem-parsing body sebelum kamu bisa membacanya mentah. - Sukses berarti tiga syarat sekaligus:
status_code200,transaction_statussettlement/capture, danfraud_statusaccept. - 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 webhook | Action | Endpoint .ts |
|---|---|---|
| Baca raw body | Tidak bisa โ sudah di-parse | Bisa, request.text() |
Tanpa cek Origin | Otomatis dicek | Tidak dicek |
| Kendali status HTTP | Terbatas | Penuh |
| Bentuk request dari luar | Diasumsikan milikmu | Bebas |
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_status | Artinya | Tindakan |
|---|---|---|
capture | Kartu: dana ditahan | Aktifkan kalau fraud_status = accept |
settlement | Dana masuk | Aktifkan |
pending | Menunggu pembeli membayar | Catat saja; jangan aktifkan |
deny | Ditolak | Tandai gagal |
cancel | Dibatalkan | Tandai batal |
expire | Kedaluwarsa tanpa dibayar | Tandai kedaluwarsa |
refund / partial_refund | Dikembalikan | Cabut akses |
chargeback | Sengketa kartu | Cabut akses, tinjau manual |
Lima syarat yang disebut Midtrans sendiri
| Syarat | Konsekuensi kalau dilanggar |
|---|---|
| Balas dalam โค 5 detik (batas 15 detik) | Dianggap gagal, dikirim ulang, beban ganda di kedua sisi |
Idempoten, pakai order_id sebagai kunci | Langganan diperpanjang dua kali dari satu pembayaran |
| Verifikasi tanda tangan | Siapa pun bisa mengaktifkan langganan gratis |
| Port standar 80/443 | Notifikasi 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.